forked from platypush/platypush
Added execute tab to webpanel
This commit is contained in:
parent
f378fa8832
commit
be5c5d365c
17 changed files with 866 additions and 10 deletions
|
@ -67,6 +67,7 @@ def index():
|
||||||
websocket_port=get_websocket_port(),
|
websocket_port=get_websocket_port(),
|
||||||
template_folder=template_folder, static_folder=static_folder,
|
template_folder=template_folder, static_folder=static_folder,
|
||||||
plugins=Config.get_plugins(), backends=Config.get_backends(),
|
plugins=Config.get_plugins(), backends=Config.get_backends(),
|
||||||
|
procedures=Config.get_procedures(),
|
||||||
has_ssl=http_conf.get('ssl_cert') is not None)
|
has_ssl=http_conf.get('ssl_cert') is not None)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -33,4 +33,5 @@ select:-moz-focusring {
|
||||||
@import 'common/elements/slider';
|
@import 'common/elements/slider';
|
||||||
@import 'common/elements/text';
|
@import 'common/elements/text';
|
||||||
@import 'common/elements/dropdown';
|
@import 'common/elements/dropdown';
|
||||||
|
@import 'common/elements/autocomplete';
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
.autocomplete {
|
||||||
|
/*the container must be positioned relative:*/
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-items {
|
||||||
|
position: absolute;
|
||||||
|
border: $default-border-2;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: none;
|
||||||
|
z-index: 99;
|
||||||
|
/*position the autocomplete items to be the same width as the container:*/
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-items div {
|
||||||
|
padding: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: $default-border-2;
|
||||||
|
background-color: $autocomplete-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-items div:hover {
|
||||||
|
background-color: $hover-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-active {
|
||||||
|
background-color: $selected-bg !important;
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,6 @@ button[disabled],
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #d8ffe0 !important;
|
background-color: #d8ffe0 !important;
|
||||||
border: 1px solid #98efb0 !important;
|
border: 1px solid #c2f0cf !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,3 +107,6 @@ $modal-header-bg: #f0f0f0 !default;
|
||||||
$modal-header-border: 1px solid #ccc !default;
|
$modal-header-border: 1px solid #ccc !default;
|
||||||
$modal-body-bg: white !default;
|
$modal-body-bg: white !default;
|
||||||
|
|
||||||
|
//// Autocomplete element
|
||||||
|
$autocomplete-bg: white !default;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,173 @@
|
||||||
|
@import 'common/vars';
|
||||||
|
@import 'common/layout';
|
||||||
|
@import 'webpanel/plugins/execute/vars';
|
||||||
|
|
||||||
|
.execute-container {
|
||||||
|
height: 99%;
|
||||||
|
color: $default-fg-2;
|
||||||
|
font-weight: 400;
|
||||||
|
//line-height: 3.8rem;
|
||||||
|
//letter-spacing: .1rem;
|
||||||
|
border-bottom: $default-border-2;
|
||||||
|
border-radius: 0 0 1em 1em;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
background: $title-bg;
|
||||||
|
padding: .2em;
|
||||||
|
border: $title-border;
|
||||||
|
box-shadow: $title-shadow;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-type-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: 1em 0 0 1em;
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin: 0 1em 0 .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.request {
|
||||||
|
margin: 0 .5em;
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 60em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-name {
|
||||||
|
box-shadow: $action-name-shadow;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=submit] {
|
||||||
|
margin-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
margin-top: .5em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding-top: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params {
|
||||||
|
margin-right: 1.5em;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
.param {
|
||||||
|
margin-bottom: .25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-param-value {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-param {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
background: $extra-params-btn-bg;
|
||||||
|
border: $title-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-param {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
|
.action-extra-param-del {
|
||||||
|
border: 0;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0 .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-container {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
.response,
|
||||||
|
.error,
|
||||||
|
.doc {
|
||||||
|
padding: .5em .5em 0 .5em;
|
||||||
|
border-radius: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response {
|
||||||
|
background: $response-bg;
|
||||||
|
border: $response-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: $error-bg;
|
||||||
|
border: $error-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc {
|
||||||
|
background: $doc-bg;
|
||||||
|
border: $doc-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 60em;
|
||||||
|
height: 10em;
|
||||||
|
border-radius: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-request {
|
||||||
|
.first-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.procedures-container {
|
||||||
|
.procedure {
|
||||||
|
border-bottom: $default-border-2;
|
||||||
|
padding: 1.5em .5em;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: $selected-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: $procedure-submit-btn-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
$title-bg: #eee;
|
||||||
|
$title-border: 1px solid #ddd;
|
||||||
|
$title-shadow: 0 3px 3px 0 rgba(187,187,187,0.75);
|
||||||
|
$action-name-shadow: 1px 1px 1px 1px #ddd;
|
||||||
|
$extra-params-btn-bg: #eee;
|
||||||
|
$response-bg: #edfff2;
|
||||||
|
$response-border: 1px dashed #98ff98;
|
||||||
|
$error-bg: #ffbcbc;
|
||||||
|
$error-border: 1px dashed #ff5353;
|
||||||
|
$doc-bg: #e8feff;
|
||||||
|
$doc-border: 1px dashed #84f9ff;
|
||||||
|
$procedure-submit-btn-bg: #ebffeb;
|
||||||
|
|
||||||
|
|
110
platypush/backend/http/static/js/autocomplete.js
Normal file
110
platypush/backend/http/static/js/autocomplete.js
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
function autocomplete(inp, arr, listener) {
|
||||||
|
/*the autocomplete function takes two arguments,
|
||||||
|
the text field element and an array of possible autocompleted values:*/
|
||||||
|
var currentFocus;
|
||||||
|
/*execute a function when someone writes in the text field:*/
|
||||||
|
inp.addEventListener("input", function(e) {
|
||||||
|
var a, b, i, val = this.value;
|
||||||
|
/*close any already open lists of autocompleted values*/
|
||||||
|
closeAllLists();
|
||||||
|
if (!val) { return false;}
|
||||||
|
currentFocus = -1;
|
||||||
|
/*create a DIV element that will contain the items (values):*/
|
||||||
|
a = document.createElement("DIV");
|
||||||
|
a.setAttribute("id", this.id + "autocomplete-list");
|
||||||
|
a.setAttribute("class", "autocomplete-items");
|
||||||
|
/*append the DIV element as a child of the autocomplete container:*/
|
||||||
|
this.parentNode.appendChild(a);
|
||||||
|
/*for each item in the array...*/
|
||||||
|
for (i = 0; i < arr.length; i++) {
|
||||||
|
/*check if the item starts with the same letters as the text field value:*/
|
||||||
|
if (arr[i].substr(0, val.length).toUpperCase() == val.toUpperCase()) {
|
||||||
|
/*create a DIV element for each matching element:*/
|
||||||
|
b = document.createElement("DIV");
|
||||||
|
/*make the matching letters bold:*/
|
||||||
|
b.innerHTML = "<strong>" + arr[i].substr(0, val.length) + "</strong>";
|
||||||
|
b.innerHTML += arr[i].substr(val.length);
|
||||||
|
/*insert a input field that will hold the current array item's value:*/
|
||||||
|
b.innerHTML += "<input type='hidden' value='" + arr[i] + "'>";
|
||||||
|
/*execute a function when someone clicks on the item value (DIV element):*/
|
||||||
|
b.addEventListener("click", function(e) {
|
||||||
|
/*insert the value for the autocomplete text field:*/
|
||||||
|
inp.value = this.getElementsByTagName("input")[0].value;
|
||||||
|
/*trigger event listener if any:*/
|
||||||
|
if (listener) {
|
||||||
|
listener(e, inp.value);
|
||||||
|
}
|
||||||
|
/*close the list of autocompleted values,
|
||||||
|
(or any other open lists of autocompleted values:*/
|
||||||
|
closeAllLists();
|
||||||
|
});
|
||||||
|
a.appendChild(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inp.addEventListener("keydown", function(e) {
|
||||||
|
if (e.keyCode == 9) {
|
||||||
|
/*Reset the list if tab has been pressed*/
|
||||||
|
closeAllLists();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*execute a function presses a key on the keyboard:*/
|
||||||
|
inp.addEventListener("keydown", function(e) {
|
||||||
|
var x = document.getElementById(this.id + "autocomplete-list");
|
||||||
|
if (x) x = x.getElementsByTagName("div");
|
||||||
|
if (e.keyCode == 40) {
|
||||||
|
/*If the arrow DOWN key is pressed,
|
||||||
|
increase the currentFocus variable:*/
|
||||||
|
currentFocus++;
|
||||||
|
/*and and make the current item more visible:*/
|
||||||
|
addActive(x);
|
||||||
|
} else if (e.keyCode == 38) { //up
|
||||||
|
/*If the arrow UP key is pressed,
|
||||||
|
decrease the currentFocus variable:*/
|
||||||
|
currentFocus--;
|
||||||
|
/*and and make the current item more visible:*/
|
||||||
|
addActive(x);
|
||||||
|
} else if (e.keyCode == 13) {
|
||||||
|
/*If the ENTER key is pressed, prevent the form from being submitted,*/
|
||||||
|
if (currentFocus > -1 && x && x.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
/*and simulate a click on the "active" item:*/
|
||||||
|
x[currentFocus].click();
|
||||||
|
/*and restore the focus on the input element:*/
|
||||||
|
this.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function addActive(x) {
|
||||||
|
/*a function to classify an item as "active":*/
|
||||||
|
if (!x) return false;
|
||||||
|
/*start by removing the "active" class on all items:*/
|
||||||
|
removeActive(x);
|
||||||
|
if (currentFocus >= x.length) currentFocus = 0;
|
||||||
|
if (currentFocus < 0) currentFocus = (x.length - 1);
|
||||||
|
/*add class "autocomplete-active":*/
|
||||||
|
x[currentFocus].classList.add("autocomplete-active");
|
||||||
|
}
|
||||||
|
function removeActive(x) {
|
||||||
|
/*a function to remove the "active" class from all autocomplete items:*/
|
||||||
|
for (var i = 0; i < x.length; i++) {
|
||||||
|
x[i].classList.remove("autocomplete-active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeAllLists(elmnt) {
|
||||||
|
/*close all autocomplete lists in the document,
|
||||||
|
except the one passed as an argument:*/
|
||||||
|
var x = document.getElementsByClassName("autocomplete-items");
|
||||||
|
for (var i = 0; i < x.length; i++) {
|
||||||
|
if (elmnt != x[i] && elmnt != inp) {
|
||||||
|
x[i].parentNode.removeChild(x[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*execute a function when someone clicks in the document:*/
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
closeAllLists(e.target);
|
||||||
|
});
|
||||||
|
}
|
215
platypush/backend/http/static/js/plugins/execute/index.js
Normal file
215
platypush/backend/http/static/js/plugins/execute/index.js
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
Vue.component('execute', {
|
||||||
|
template: '#tmpl-execute',
|
||||||
|
props: ['config'],
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
running: false,
|
||||||
|
structuredInput: true,
|
||||||
|
actionChanged: false,
|
||||||
|
selectedDoc: undefined,
|
||||||
|
selectedProcedure: {
|
||||||
|
name: undefined,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
response: undefined,
|
||||||
|
error: undefined,
|
||||||
|
htmlDoc: false,
|
||||||
|
rawRequest: undefined,
|
||||||
|
actions: {},
|
||||||
|
plugins: {},
|
||||||
|
procedures: {},
|
||||||
|
action: {
|
||||||
|
name: undefined,
|
||||||
|
args: {},
|
||||||
|
extraArgs: [],
|
||||||
|
supportsExtraArgs: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refresh: async function() {
|
||||||
|
this.loading = true;
|
||||||
|
this.procedures = JSON.parse(this.config);
|
||||||
|
this.plugins = await request('inspect.get_all_plugins', {html_doc: true});
|
||||||
|
|
||||||
|
for (const plugin of Object.values(this.plugins)) {
|
||||||
|
if (plugin.html_doc)
|
||||||
|
this.htmlDoc = true;
|
||||||
|
|
||||||
|
for (const action of Object.values(plugin.actions)) {
|
||||||
|
action.name = plugin.name + '.' + action.name;
|
||||||
|
action.supportsExtraArgs = !!action.has_kwargs;
|
||||||
|
delete action.has_kwargs;
|
||||||
|
this.actions[action.name] = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
autocomplete(this.$refs.actionName, Object.keys(this.actions).sort(), (evt, value) => {
|
||||||
|
this.action.name = value;
|
||||||
|
self.updateAction();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAction: function() {
|
||||||
|
if (!this.actionChanged || !(this.action.name in this.actions))
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.action = {
|
||||||
|
...this.actions[this.action.name],
|
||||||
|
args: Object.entries(this.actions[this.action.name].args).reduce((args, entry) => {
|
||||||
|
args[entry[0]] = {
|
||||||
|
...entry[1],
|
||||||
|
value: entry[1].default,
|
||||||
|
};
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}, {}),
|
||||||
|
extraArgs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.selectedDoc = this.action.doc;
|
||||||
|
this.actionChanged = false;
|
||||||
|
this.response = undefined;
|
||||||
|
this.error = undefined;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProcedure: function(name) {
|
||||||
|
if (event.target.getAttribute('type') === 'submit') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedProcedure.name === name) {
|
||||||
|
this.selectedProcedure = {
|
||||||
|
name: undefined,
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(name in this.procedures)) {
|
||||||
|
console.warn('Procedure not found: ' + name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedProcedure = {
|
||||||
|
name: name,
|
||||||
|
args: this.procedures[name].args.reduce((args, arg) => {
|
||||||
|
args[arg] = undefined;
|
||||||
|
return args;
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addParameter: function() {
|
||||||
|
this.action.extraArgs.push({
|
||||||
|
name: undefined,
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeParameter: function(i) {
|
||||||
|
this.action.extraArgs.pop(i);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAttrDoc: function(name) {
|
||||||
|
this.response = undefined;
|
||||||
|
this.error = undefined;
|
||||||
|
this.selectedDoc = this.action.args[name].doc;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetDoc: function() {
|
||||||
|
this.response = undefined;
|
||||||
|
this.error = undefined;
|
||||||
|
this.selectedDoc = this.action.doc;
|
||||||
|
},
|
||||||
|
|
||||||
|
onInputTypeChange: function(structuredInput) {
|
||||||
|
this.structuredInput = structuredInput;
|
||||||
|
this.response = undefined;
|
||||||
|
this.error = undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
onResponse: function(response) {
|
||||||
|
this.response = '<pre>' + JSON.stringify(response, null, 2) + '</pre>';
|
||||||
|
this.error = undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: function(error) {
|
||||||
|
this.response = undefined;
|
||||||
|
this.error = '<pre>' + error + '</pre>';
|
||||||
|
},
|
||||||
|
|
||||||
|
onDone: function() {
|
||||||
|
this.running = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
executeAction: function() {
|
||||||
|
if (!this.action.name && !this.rawRequest || this.running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
if (this.structuredInput) {
|
||||||
|
const args = {
|
||||||
|
...Object.entries(this.action.args).reduce((args, param) => {
|
||||||
|
if (param[1].value != null) {
|
||||||
|
let value = param[1].value;
|
||||||
|
try {value = JSON.parse(value);}
|
||||||
|
catch (e) {}
|
||||||
|
args[param[0]] = value;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}, {}),
|
||||||
|
|
||||||
|
...this.action.extraArgs.reduce((args, param) => {
|
||||||
|
let value = args[param.value];
|
||||||
|
try {value = JSON.parse(value);}
|
||||||
|
catch (e) {}
|
||||||
|
|
||||||
|
args[param.name] = value;
|
||||||
|
return args;
|
||||||
|
}, {})
|
||||||
|
};
|
||||||
|
|
||||||
|
request(this.action.name, args).then(this.onResponse).catch(this.onError).finally(this.onDone);
|
||||||
|
} else {
|
||||||
|
execute(JSON.parse(this.rawRequest)).then(this.onResponse).catch(this.onError).finally(this.onDone);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
executeProcedure: function(event) {
|
||||||
|
if (!this.selectedProcedure.name || this.running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
this.running = true;
|
||||||
|
const args = {
|
||||||
|
...Object.entries(this.selectedProcedure.args).reduce((args, param) => {
|
||||||
|
if (param[1] != null) {
|
||||||
|
let value = param[1];
|
||||||
|
try {value = JSON.parse(value);}
|
||||||
|
catch (e) {}
|
||||||
|
args[param[0]] = value;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
request('procedure.' + this.selectedProcedure.name, args)
|
||||||
|
.then(this.onResponse).catch(this.onError).finally(this.onDone);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created: function() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/normalize.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/normalize.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome/css/all.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome/css/all.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dist/webpanel.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/dist/webpanel.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/dist/webpanel/plugins/execute.css') }}">
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
if (!window.config) {
|
if (!window.config) {
|
||||||
|
@ -43,12 +44,14 @@
|
||||||
<script type="text/javascript" src="{{ url_for('static', filename='js/api.js') }}"></script>
|
<script type="text/javascript" src="{{ url_for('static', filename='js/api.js') }}"></script>
|
||||||
<script type="text/javascript" src="{{ url_for('static', filename='js/events.js') }}"></script>
|
<script type="text/javascript" src="{{ url_for('static', filename='js/events.js') }}"></script>
|
||||||
<script type="text/javascript" src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
<script type="text/javascript" src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
||||||
|
<script type="text/javascript" src="{{ url_for('static', filename='js/autocomplete.js') }}"></script>
|
||||||
|
|
||||||
{% for style in styles.values() %}
|
{% for style in styles.values() %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename=style['_style_file']) }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename=style['_style_file']) }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% include 'elements.html' %}
|
{% include 'elements.html' %}
|
||||||
|
{% include 'plugins/execute/index.html' %}
|
||||||
|
|
||||||
{% for plugin, conf in templates.items() %}
|
{% for plugin, conf in templates.items() %}
|
||||||
{% with configuration=templates[plugin] %}
|
{% with configuration=templates[plugin] %}
|
||||||
|
@ -56,6 +59,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
<script type="text/javascript" src="{{ url_for('static', filename='js/plugins/execute/index.js') }}"></script>
|
||||||
{% for script in scripts.values() %}
|
{% for script in scripts.values() %}
|
||||||
<script type="text/javascript" src="{{ url_for('static', filename=script['_script_file']) }}"></script>
|
<script type="text/javascript" src="{{ url_for('static', filename=script['_script_file']) }}"></script>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -69,6 +73,9 @@
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="plugins-container">
|
<div class="plugins-container">
|
||||||
|
<plugin tag="execute" key="execute" :class="{hidden: selectedPlugin != 'execute'}"
|
||||||
|
config="{{ utils.to_json(procedures) }}"></plugin>
|
||||||
|
|
||||||
<plugin v-for="(conf, plugin) in {{ utils.to_json(templates) }}"
|
<plugin v-for="(conf, plugin) in {{ utils.to_json(templates) }}"
|
||||||
:tag="plugin.replace(/\./g, '-')"
|
:tag="plugin.replace(/\./g, '-')"
|
||||||
:key="plugin"
|
:key="plugin"
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
with pluginIcons = {
|
with pluginIcons = {
|
||||||
'camera': 'fas fa-camera',
|
'camera': 'fas fa-camera',
|
||||||
'camera.ir.mlx90640': 'fas fa-sun',
|
'camera.ir.mlx90640': 'fas fa-sun',
|
||||||
|
'execute': 'fas fa-play',
|
||||||
'light.hue': 'fa fa-lightbulb',
|
'light.hue': 'fa fa-lightbulb',
|
||||||
'media.mplayer': 'fa fa-film',
|
'media.mplayer': 'fa fa-film',
|
||||||
'media.mpv': 'fa fa-film',
|
'media.mpv': 'fa fa-film',
|
||||||
|
@ -19,6 +20,12 @@
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li :class="{selected: 'execute' == selectedPlugin}" title="execute">
|
||||||
|
<a href="#execute" @click="selectedPlugin = 'execute'">
|
||||||
|
<i class="{{ pluginIcons['execute'] }}"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
{% for plugin in plugins|sort %}
|
{% for plugin in plugins|sort %}
|
||||||
<li :class="{selected: '{{ plugin }}' == selectedPlugin}" title="{{ plugin }}">
|
<li :class="{selected: '{{ plugin }}' == selectedPlugin}" title="{{ plugin }}">
|
||||||
<a href="#{{ plugin }}" @click="selectedPlugin = '{{ plugin }}'">
|
<a href="#{{ plugin }}" @click="selectedPlugin = '{{ plugin }}'">
|
||||||
|
|
112
platypush/backend/http/templates/plugins/execute/index.html
Normal file
112
platypush/backend/http/templates/plugins/execute/index.html
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<script type="text/x-template" id="tmpl-execute">
|
||||||
|
<div class="row plugin execute-container">
|
||||||
|
<div class="command-container">
|
||||||
|
<div class="title">Execute Action</div>
|
||||||
|
<form ref="actionForm" autocomplete="off" @submit.prevent="executeAction">
|
||||||
|
<div class="request-type-container">
|
||||||
|
<input type="radio" id="action-structured-input"
|
||||||
|
:checked="structuredInput" @change="onInputTypeChange(true)">
|
||||||
|
<label for="action-structured-input">Structured request</label>
|
||||||
|
<input type="radio" id="action-raw-input"
|
||||||
|
:checked="!structuredInput" @change="onInputTypeChange(false)">
|
||||||
|
<label for="action-raw-input">Raw request</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request structured-request" :class="structuredInput ? '' : 'hidden'">
|
||||||
|
<div class="autocomplete">
|
||||||
|
<input ref="actionName" type="text" class="action-name"
|
||||||
|
placeholder="Action Name" :disabled="loading" v-model="action.name"
|
||||||
|
@change="actionChanged=true" @blur="updateAction">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="running" title="Run">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="options" v-if="action.name in actions">
|
||||||
|
<div class="params col-6" ref="params"
|
||||||
|
v-if="Object.keys(action.args).length || action.supportsExtraArgs">
|
||||||
|
<div class="param" :key="name" v-for="(param, name) in action.args">
|
||||||
|
<input type="text" class="action-param-value"
|
||||||
|
:placeholder="name" v-model="action.args[name].value"
|
||||||
|
@focus="selectAttrDoc(name)"
|
||||||
|
@blur="resetDoc">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="extra-params" ref="extraParams" v-if="Object.keys(action.extraArgs).length">
|
||||||
|
<div class="param extra-param" :key="i" v-for="(param, i) in action.extraArgs">
|
||||||
|
<input type="text" class="action-extra-param-name col-5"
|
||||||
|
placeholder="Name" v-model="action.extraArgs[i].name">
|
||||||
|
<input type="text" class="action-extra-param-value col-6"
|
||||||
|
placeholder="Value" v-model="action.extraArgs[i].value">
|
||||||
|
<button type="button" class="action-extra-param-del col-1" title="Remove parameter"
|
||||||
|
@click="removeParameter(i)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-param" v-if="action.supportsExtraArgs">
|
||||||
|
<button type="button" title="Add a parameter" @click="addParameter">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-container"
|
||||||
|
:class="Object.keys(action.args).length || action.supportsExtraArgs ? 'col-6' : 'col-12'">
|
||||||
|
<div class="response" v-html="response" v-if="response != null"></div>
|
||||||
|
<div class="error" v-html="error" v-else-if="error != null"></div>
|
||||||
|
<div class="doc-container" v-else-if="selectedDoc">
|
||||||
|
<div class="doc" v-html="selectedDoc" v-if="htmlDoc"></div>
|
||||||
|
<div class="doc" v-text="selectedDoc" v-else></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request raw-request" :class="structuredInput ? 'hidden' : ''">
|
||||||
|
<div class="first-row">
|
||||||
|
<textarea v-model="rawRequest" placeholder="Raw JSON request"></textarea>
|
||||||
|
<button type="submit" :disabled="running" class="btn-primary" title="Run">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-container" v-if="response != null || error != null">
|
||||||
|
<div class="response" v-html="response" v-if="response != null"></div>
|
||||||
|
<div class="error" v-html="error" v-else="error != null"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="procedures-container">
|
||||||
|
<div class="title">Execute Procedure</div>
|
||||||
|
<div class="procedure" :class="selectedProcedure.name === name ? 'selected' : ''"
|
||||||
|
v-for="(procedure, name) in procedures" :key="name" @click="updateProcedure(name)">
|
||||||
|
<form ref="procedureForm" autocomplete="off" @submit.prevent="executeProcedure">
|
||||||
|
<div class="head">
|
||||||
|
<div class="name col-no-margin-11" v-text="name"></div>
|
||||||
|
<div class="btn-container col-no-margin-1">
|
||||||
|
<button type="submit" class="btn-default" :disabled="running" title="Run"
|
||||||
|
v-if="selectedProcedure.name === name">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="params" v-if="selectedProcedure.name === name">
|
||||||
|
<div class="param"
|
||||||
|
v-for="(argvalue, argname) in selectedProcedure.args"
|
||||||
|
:key="argname">
|
||||||
|
<input type="text" class="action-param-value" @click="$event.stopPropagation()"
|
||||||
|
:placeholder="argname" v-model="selectedProcedure.args[argname]">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import yaml
|
import yaml
|
||||||
|
@ -157,7 +158,6 @@ class Config(object):
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _init_components(self):
|
def _init_components(self):
|
||||||
for key in self._config.keys():
|
for key in self._config.keys():
|
||||||
if key.startswith('backend.'):
|
if key.startswith('backend.'):
|
||||||
|
@ -173,9 +173,21 @@ class Config(object):
|
||||||
tokens = key.split('.')
|
tokens = key.split('.')
|
||||||
_async = True if len(tokens) > 2 and tokens[1] == 'async' else False
|
_async = True if len(tokens) > 2 and tokens[1] == 'async' else False
|
||||||
procedure_name = '.'.join(tokens[2:] if len(tokens) > 2 else tokens[1:])
|
procedure_name = '.'.join(tokens[2:] if len(tokens) > 2 else tokens[1:])
|
||||||
|
args = []
|
||||||
|
m = re.match(r'^([^(]+)\(([^)]+)\)\s*', procedure_name)
|
||||||
|
|
||||||
|
if m:
|
||||||
|
procedure_name = m.group(1).strip()
|
||||||
|
args = [
|
||||||
|
arg.strip()
|
||||||
|
for arg in m.group(2).strip().split(',')
|
||||||
|
if arg.strip()
|
||||||
|
]
|
||||||
|
|
||||||
self.procedures[procedure_name] = {
|
self.procedures[procedure_name] = {
|
||||||
'_async': _async,
|
'_async': _async,
|
||||||
'actions': self._config[key]
|
'actions': self._config[key],
|
||||||
|
'args': args,
|
||||||
}
|
}
|
||||||
elif not self._is_special_token(key):
|
elif not self._is_special_token(key):
|
||||||
self.plugins[key] = self._config[key]
|
self.plugins[key] = self._config[key]
|
||||||
|
|
|
@ -209,13 +209,16 @@ class Request(Message):
|
||||||
args = self.expand_value_from_context(args, **context)
|
args = self.expand_value_from_context(args, **context)
|
||||||
response = plugin.run(method=method_name, **args)
|
response = plugin.run(method=method_name, **args)
|
||||||
|
|
||||||
if response and response.is_error():
|
if not response:
|
||||||
logger.warning(('Response processed with errors from ' +
|
logger.warning('Received null response from action {}'.format(action))
|
||||||
'action {}: {}').format(
|
else:
|
||||||
action, str(response)))
|
if response.is_error():
|
||||||
elif not response.disable_logging:
|
logger.warning(('Response processed with errors from ' +
|
||||||
logger.info('Processed response from action {}: {}'.
|
'action {}: {}').format(
|
||||||
format(action, str(response)))
|
action, str(response)))
|
||||||
|
elif not response.disable_logging:
|
||||||
|
logger.info('Processed response from action {}: {}'.
|
||||||
|
format(action, str(response)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Retry mechanism
|
# Retry mechanism
|
||||||
plugin.logger.exception(e)
|
plugin.logger.exception(e)
|
||||||
|
|
160
platypush/plugins/inspect.py
Normal file
160
platypush/plugins/inspect.py
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import pkgutil
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import platypush.plugins
|
||||||
|
|
||||||
|
from platypush.plugins import Plugin, action
|
||||||
|
from platypush.utils import get_decorators
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
class Model:
|
||||||
|
def __str__(self):
|
||||||
|
return json.dumps(dict(self), indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return json.dumps(dict(self))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_html(doc):
|
||||||
|
try:
|
||||||
|
import docutils.core
|
||||||
|
except ImportError:
|
||||||
|
# docutils not found
|
||||||
|
return doc
|
||||||
|
|
||||||
|
return docutils.core.publish_parts(doc, writer_name='html')['html_body']
|
||||||
|
|
||||||
|
|
||||||
|
class PluginModel(Model):
|
||||||
|
def __init__(self, plugin, prefix='', html_doc: bool = False):
|
||||||
|
self.name = plugin.__module__[len(prefix):]
|
||||||
|
self.html_doc = html_doc
|
||||||
|
self.doc = self.to_html(plugin.__doc__) if html_doc and plugin.__doc__ else plugin.__doc__
|
||||||
|
self.actions = {action_name: ActionModel(getattr(plugin, action_name), html_doc=html_doc)
|
||||||
|
for action_name in get_decorators(plugin, climb_class_hierarchy=True).get('action', [])}
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for attr in ['name', 'actions', 'doc', 'html_doc']:
|
||||||
|
if attr == 'actions':
|
||||||
|
# noinspection PyShadowingNames
|
||||||
|
yield attr, {name: dict(action) for name, action in self.actions.items()},
|
||||||
|
else:
|
||||||
|
yield attr, getattr(self, attr)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionModel(Model):
|
||||||
|
# noinspection PyShadowingNames
|
||||||
|
def __init__(self, action, html_doc: bool = False):
|
||||||
|
self.name = action.__name__
|
||||||
|
self.doc, argsdoc = self._parse_docstring(action.__doc__, html_doc=html_doc)
|
||||||
|
self.args = {}
|
||||||
|
self.has_kwargs = False
|
||||||
|
|
||||||
|
for arg in list(inspect.signature(action).parameters.values())[1:]:
|
||||||
|
if arg.kind == arg.VAR_KEYWORD:
|
||||||
|
self.has_kwargs = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.args[arg.name] = {
|
||||||
|
'default': arg.default if not issubclass(arg.default.__class__, type) else None,
|
||||||
|
'doc': argsdoc.get(arg.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_docstring(cls, docstring: str, html_doc: bool = False):
|
||||||
|
new_docstring = ''
|
||||||
|
params = {}
|
||||||
|
cur_param = None
|
||||||
|
cur_param_docstring = ''
|
||||||
|
|
||||||
|
if not docstring:
|
||||||
|
return None, {}
|
||||||
|
|
||||||
|
for line in docstring.split('\n'):
|
||||||
|
m = re.match(r'^\s*:param ([^:]+):\s*(.*)', line)
|
||||||
|
if m:
|
||||||
|
if cur_param:
|
||||||
|
params[cur_param] = cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
|
||||||
|
|
||||||
|
cur_param = m.group(1)
|
||||||
|
cur_param_docstring = m.group(2)
|
||||||
|
elif re.match(r'^\s*:[^:]+:\s*.*', line):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if cur_param:
|
||||||
|
if not line.strip():
|
||||||
|
params[cur_param] = cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
|
||||||
|
cur_param = None
|
||||||
|
cur_param_docstring = ''
|
||||||
|
else:
|
||||||
|
cur_param_docstring += '\n' + line.strip()
|
||||||
|
else:
|
||||||
|
new_docstring += line.rstrip() + '\n'
|
||||||
|
|
||||||
|
if cur_param:
|
||||||
|
params[cur_param] = cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
|
||||||
|
|
||||||
|
return new_docstring.strip() if not html_doc else cls.to_html(new_docstring), params
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for attr in ['name', 'args', 'doc', 'has_kwargs']:
|
||||||
|
yield attr, getattr(self, attr)
|
||||||
|
|
||||||
|
|
||||||
|
class InspectPlugin(Plugin):
|
||||||
|
"""
|
||||||
|
This plugin can be used to inspect platypush plugins and backends
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
|
||||||
|
* **docutils** (``pip install docutils``) - optional, for HTML doc generation
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._plugins = {}
|
||||||
|
self._plugins_lock = threading.RLock()
|
||||||
|
self._html_doc = False
|
||||||
|
|
||||||
|
def _init_plugins(self):
|
||||||
|
package = platypush.plugins
|
||||||
|
prefix = package.__name__ + '.'
|
||||||
|
|
||||||
|
for _, modname, _ in pkgutil.walk_packages(path=package.__path__,
|
||||||
|
prefix=prefix,
|
||||||
|
onerror=lambda x: None):
|
||||||
|
# noinspection PyBroadException
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(modname)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for _, obj in inspect.getmembers(module):
|
||||||
|
if inspect.isclass(obj) and issubclass(obj, Plugin):
|
||||||
|
model = PluginModel(plugin=obj, prefix=prefix, html_doc=self._html_doc)
|
||||||
|
if model.name:
|
||||||
|
self._plugins[model.name] = model
|
||||||
|
|
||||||
|
@action
|
||||||
|
def get_all_plugins(self, html_doc: bool = None):
|
||||||
|
"""
|
||||||
|
:param html_doc: If True then the docstring will be parsed into HTML (default: False)
|
||||||
|
"""
|
||||||
|
with self._plugins_lock:
|
||||||
|
if not self._plugins or (html_doc is not None and html_doc != self._html_doc):
|
||||||
|
self._html_doc = html_doc
|
||||||
|
self._init_plugins()
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
name: dict(plugin)
|
||||||
|
for name, plugin in self._plugins.items()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -181,3 +181,6 @@ croniter
|
||||||
|
|
||||||
# Support for clipboard manipulation
|
# Support for clipboard manipulation
|
||||||
# pyperclip
|
# pyperclip
|
||||||
|
|
||||||
|
# Support for RST->HTML docstring conversion
|
||||||
|
# docutils
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -246,6 +246,8 @@ setup(
|
||||||
'mlx90640': ['Pillow'],
|
'mlx90640': ['Pillow'],
|
||||||
# Support for machine learning and CV plugin
|
# Support for machine learning and CV plugin
|
||||||
'cv': ['cv2', 'numpy'],
|
'cv': ['cv2', 'numpy'],
|
||||||
|
# Support for the generation of HTML documentation from docstring
|
||||||
|
'htmldoc': ['docutils'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue