luci-app-commands: rewrite to client side rendering

Rewrite the luci-app-command configuration to client side cbi forms and
port the server side templates and controller logic to ucode.

Also utilize a query string parameter to pass custom arguments.

Fixes: #5559
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
This commit is contained in:
Jo-Philipp Wich 2022-10-25 00:55:14 +02:00
parent 036424df5b
commit dd1c538b2e
9 changed files with 573 additions and 532 deletions

View file

@ -0,0 +1,34 @@
'use strict';
'require view';
'require form';
return view.extend({
render: function(data) {
var m, s, o;
m = new form.Map('luci', _('Custom Commands'),
_('This page allows you to configure custom shell commands which can be easily invoked from the web interface.'));
s = m.section(form.GridSection, 'command');
s.nodescriptions = true;
s.anonymous = true;
s.addremove = true;
o = s.option(form.Value, 'name', _('Description'),
_('A short textual description of the configured command'));
o = s.option(form.Value, 'command', _('Command'), _('Command line to execute'));
o.textvalue = function(section_id) {
return E('code', [ this.cfgvalue(section_id) ]);
};
o = s.option(form.Flag, 'param', _('Custom arguments'),
_('Allow the user to provide additional command line arguments'));
o = s.option(form.Flag, 'public', _('Public access'),
_('Allow executing the command and downloading its output without prior authentication'));
return m.render();
}
});

View file

@ -1,268 +0,0 @@
-- Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
-- Licensed to the public under the Apache License 2.0.
module("luci.controller.commands", package.seeall)
function index()
entry({"admin", "system", "commands"}, firstchild(), _("Custom Commands"), 80).acl_depends = { "luci-app-commands" }
entry({"admin", "system", "commands", "dashboard"}, template("commands"), _("Dashboard"), 1)
entry({"admin", "system", "commands", "config"}, cbi("commands"), _("Configure"), 2)
entry({"admin", "system", "commands", "run"}, call("action_run"), nil, 3).leaf = true
entry({"admin", "system", "commands", "download"}, call("action_download"), nil, 3).leaf = true
entry({"command"}, call("action_public"), nil, 1).leaf = true
end
--- Decode a given string into arguments following shell quoting rules
--- [[abc \def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]]
local function parse_args(str)
local args = { }
local function isspace(c)
if c == 9 or c == 10 or c == 11 or c == 12 or c == 13 or c == 32 then
return c
end
end
local function isquote(c)
if c == 34 or c == 39 or c == 96 then
return c
end
end
local function isescape(c)
if c == 92 then
return c
end
end
local function ismeta(c)
if c == 36 or c == 92 or c == 96 then
return c
end
end
--- Convert given table of byte values into a Lua string and append it to
--- the "args" table. Segment byte value sequence into chunks of 256 values
--- to not trip over the parameter limit for string.char()
local function putstr(bytes)
local chunks = { }
local csz = 256
local upk = unpack
local chr = string.char
local min = math.min
local len = #bytes
local off
for off = 1, len, csz do
chunks[#chunks+1] = chr(upk(bytes, off, min(off + csz - 1, len)))
end
args[#args+1] = table.concat(chunks)
end
--- Scan substring defined by the indexes [s, e] of the string "str",
--- perform unquoting and de-escaping on the fly and store the result in
--- a table of byte values which is passed to putstr()
local function unquote(s, e)
local off, esc, quote
local res = { }
for off = s, e do
local byte = str:byte(off)
local q = isquote(byte)
local e = isescape(byte)
local m = ismeta(byte)
if e then
esc = true
elseif esc then
if m then res[#res+1] = 92 end
res[#res+1] = byte
esc = false
elseif q and quote and q == quote then
quote = nil
elseif q and not quote then
quote = q
else
if m then res[#res+1] = 92 end
res[#res+1] = byte
end
end
putstr(res)
end
--- Find substring boundaries in "str". Ignore escaped or quoted
--- whitespace, pass found start- and end-index for each substring
--- to unquote()
local off, esc, start, quote
for off = 1, #str + 1 do
local byte = str:byte(off)
local q = isquote(byte)
local s = isspace(byte) or (off > #str)
local e = isescape(byte)
if esc then
esc = false
elseif e then
esc = true
elseif q and quote and q == quote then
quote = nil
elseif q and not quote then
start = start or off
quote = q
elseif s and not quote then
if start then
unquote(start, off - 1)
start = nil
end
else
start = start or off
end
end
--- If the "quote" is still set we encountered an unfinished string
if quote then
unquote(start, #str)
end
return args
end
local function parse_cmdline(cmdid, args)
local uci = require "luci.model.uci".cursor()
if uci:get("luci", cmdid) == "command" then
local cmd = uci:get_all("luci", cmdid)
local argv = parse_args(cmd.command)
local i, v
if cmd.param == "1" and args then
for i, v in ipairs(parse_args(luci.http.urldecode(args))) do
argv[#argv+1] = v
end
end
for i, v in ipairs(argv) do
if v:match("[^%w%.%-i/|]") then
argv[i] = '"%s"' % v:gsub('"', '\\"')
end
end
return argv
end
end
function execute_command(callback, ...)
local fs = require "nixio.fs"
local argv = parse_cmdline(...)
if argv then
local outfile = os.tmpname()
local errfile = os.tmpname()
local rv = os.execute(table.concat(argv, " ") .. " >%s 2>%s" %{ outfile, errfile })
local stdout = fs.readfile(outfile, 1024 * 512) or ""
local stderr = fs.readfile(errfile, 1024 * 512) or ""
fs.unlink(outfile)
fs.unlink(errfile)
local binary = not not (stdout:match("[%z\1-\8\14-\31]"))
callback({
ok = true,
command = table.concat(argv, " "),
stdout = not binary and stdout,
stderr = stderr,
exitcode = rv,
binary = binary
})
else
callback({
ok = false,
code = 404,
reason = "No such command"
})
end
end
function return_json(result)
if result.ok then
luci.http.prepare_content("application/json")
luci.http.write_json(result)
else
luci.http.status(result.code, result.reason)
end
end
function action_run(...)
execute_command(return_json, ...)
end
function return_html(result)
if result.ok then
require("luci.template")
luci.template.render("commands_public", {
exitcode = result.exitcode,
stdout = result.stdout,
stderr = result.stderr
})
else
luci.http.status(result.code, result.reason)
end
end
function action_download(...)
local fs = require "nixio.fs"
local argv = parse_cmdline(...)
if argv then
local fd = io.popen(table.concat(argv, " ") .. " 2>/dev/null")
if fd then
local chunk = fd:read(4096) or ""
local name
if chunk:match("[%z\1-\8\14-\31]") then
luci.http.header("Content-Disposition", "attachment; filename=%s"
% fs.basename(argv[1]):gsub("%W+", ".") .. ".bin")
luci.http.prepare_content("application/octet-stream")
else
luci.http.header("Content-Disposition", "attachment; filename=%s"
% fs.basename(argv[1]):gsub("%W+", ".") .. ".txt")
luci.http.prepare_content("text/plain")
end
while chunk do
luci.http.write(chunk)
chunk = fd:read(4096)
end
fd:close()
else
luci.http.status(500, "Failed to execute command")
end
else
luci.http.status(404, "No such command")
end
end
function action_public(cmdid, args)
local disp = false
if string.sub(cmdid, -1) == "s" then
disp = true
cmdid = string.sub(cmdid, 1, -2)
end
local uci = require "luci.model.uci".cursor()
if cmdid and
uci:get("luci", cmdid) == "command" and
uci:get("luci", cmdid, "public") == "1"
then
if disp then
execute_command(return_html, cmdid, args)
else
action_download(cmdid, args)
end
else
luci.http.status(403, "Access to command denied")
end
end

View file

@ -1,27 +0,0 @@
-- Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
-- Licensed to the public under the Apache License 2.0.
local m, s
m = Map("luci", translate("Custom Commands"),
translate("This page allows you to configure custom shell commands which can be easily invoked from the web interface."))
s = m:section(TypedSection, "command", "")
s.template = "cbi/tblsection"
s.anonymous = true
s.addremove = true
s:option(Value, "name", translate("Description"),
translate("A short textual description of the configured command"))
s:option(Value, "command", translate("Command"),
translate("Command line to execute"))
s:option(Flag, "param", translate("Custom arguments"),
translate("Allow the user to provide additional command line arguments"))
s:option(Flag, "public", translate("Public access"),
translate("Allow executing the command and downloading its output without prior authentication"))
return m

View file

@ -1,187 +0,0 @@
<%#
Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
Licensed to the public under the Apache License 2.0.
-%>
<% css = [[
.commandbox {
height: 12em;
width: 30%;
float: left;
height: 12em;
margin: 5px;
position: relative;
}
.commandbox h3 {
font-size: 1.5em !important;
line-height: 2em !important;
margin: 0 !important;
}
.commandbox input[type="text"] {
width: 50% !important;
}
.commandbox div {
position: absolute;
left: 0;
bottom: 1.5em;
}
]] -%>
<%+header%>
<script type="text/javascript">//<![CDATA[
var stxhr = new XHR();
function command_run(ev, id)
{
var args;
var field = document.getElementById(id);
if (field)
args = encodeURIComponent(field.value);
var legend = document.getElementById('command-rc-legend');
var output = document.getElementById('command-rc-output');
if (legend && output)
{
output.innerHTML =
'<img src="<%=resource%>/icons/loading.gif" alt="<%:Loading%>" style="vertical-align:middle" /> ' +
'<%:Waiting for command to complete...%>'
;
legend.parentNode.style.display = 'block';
legend.style.display = 'inline';
stxhr.get('<%=url('admin/system/commands/run')%>/' + id + (args ? '/' + args : ''), null,
function(x, st)
{
if (st)
{
if (st.binary)
st.stdout = '[<%:Binary data not displayed, download instead.%>]';
legend.style.display = 'none';
output.innerHTML = String.format(
'<pre><strong># %h\n</strong>%h<span style="color:red">%h</span></pre>' +
'<div class="alert-message warning">%s (<%:Code:%> %d)</div>',
st.command, st.stdout, st.stderr,
(st.exitcode == 0) ? '<%:Command successful%>' : '<%:Command failed%>',
st.exitcode);
}
else
{
legend.style.display = 'none';
output.innerHTML = '<span class="error"><%:Failed to execute command!%></span>';
}
location.hash = '#output';
}
);
}
ev.preventDefault();
}
function command_download(ev, id)
{
var args;
var field = document.getElementById(id);
if (field)
args = encodeURIComponent(field.value);
location.href = '<%=url('admin/system/commands/download')%>/' + id + (args ? '/' + args : '');
ev.preventDefault();
}
function command_link(ev, id)
{
var legend = document.getElementById('command-rc-legend');
var output = document.getElementById('command-rc-output');
var args;
var field = document.getElementById(id);
if (field)
args = encodeURIComponent(field.value);
if (legend && output)
{
var prefix = location.protocol + '//' + location.host + '<%=url('command')%>/';
var suffix = (args ? '/' + args : '');
var link = prefix + id + suffix;
var link_nodownload = prefix + id + "s" + suffix;
legend.style.display = 'none';
output.parentNode.style.display = 'block';
output.innerHTML = String.format(
'<div class="alert-message"><p><%:Download execution result%> <a href="%s">%s</a></p><p><%:Or display result%> <a href="%s">%s</a></p></div>',
link, link, link_nodownload, link_nodownload
);
location.hash = '#output';
}
ev.preventDefault();
}
//]]></script>
<%
local uci = require "luci.model.uci".cursor()
local commands = { }
uci:foreach("luci", "command", function(s) commands[#commands+1] = s end)
%>
<form method="get" action="<%=pcdata(FULL_REQUEST_URI)%>">
<div class="cbi-map">
<h2 name="content"><%:Custom Commands%></h2>
<% if #commands == 0 then %>
<div class="cbi-section">
<div class="table cbi-section-table">
<div class="tr cbi-section-table-row">
<p>
<em><%:This section contains no values yet%></em>
</p>
</div>
</div>
</div>
<% else %>
<fieldset class="cbi-section">
<% local _, command; for _, command in ipairs(commands) do %>
<div class="commandbox">
<h3><%=pcdata(command.name)%></h3>
<p><%:Command:%> <code><%=pcdata(command.command)%></code></p>
<% if command.param == "1" then %>
<p><%:Arguments:%> <input type="text" id="<%=command['.name']%>" /></p>
<% end %>
<div>
<button class="cbi-button cbi-button-apply" onclick="command_run(event, '<%=command['.name']%>')"><%:Run%></button>
<button class="cbi-button cbi-button-download" onclick="command_download(event, '<%=command['.name']%>')"><%:Download%></button>
<% if command.public == "1" then %>
<button class="cbi-button cbi-button-link" onclick="command_link(event, '<%=command['.name']%>')"><%:Link%></button>
<% end %>
</div>
</div>
<% end %>
<br style="clear:both" /><br />
<a name="output"></a>
</fieldset>
<% end %>
</div>
<fieldset class="cbi-section" style="display:none">
<legend id="command-rc-legend"><%:Collecting data...%></legend>
<span id="command-rc-output"></span>
</fieldset>
</form>
<%+footer%>

View file

@ -1,50 +0,0 @@
<%#
Copyright 2016 t123yh <t123yh@outlook.com>
Licensed to the public under the Apache License 2.0.
-%>
<% css = [[
.alert-success {
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
}
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
]] -%>
<%+header%>
<% if exitcode == 0 then %>
<div class="alert alert-success" role="alert"> <%:Command executed successfully.%> </div>
<% else %>
<div class="alert alert-warning" role="alert"> <%:Command exited with status code %> <%= exitcode %> </div>
<% end %>
<% if stdout ~= "" then %>
<h3><%:Standard Output%></h3>
<pre><%= stdout %></pre>
<% end %>
<% if stderr ~= "" then %>
<h3><%:Standard Error%></h3>
<pre><%= stderr %></pre>
<% end %>
<script>
<%# Display top bar on mobile devices -%>
document.getElementsByClassName('brand')[0].style.setProperty("display", "block", "important");
</script>
<%+footer%>

View file

@ -0,0 +1,56 @@
{
"admin/system/commands": {
"title": "Custom Commands",
"order": 80,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-commands" ]
}
},
"admin/system/commands/dashboard": {
"title": "Dashboard",
"order": 1,
"action": {
"type": "template",
"path": "commands"
}
},
"admin/system/commands/config": {
"title": "Configure",
"order": 2,
"action": {
"type": "view",
"path": "commands"
}
},
"admin/system/commands/run/*": {
"order": 3,
"action": {
"type": "function",
"module": "luci.controller.commands",
"function": "action_run"
}
},
"admin/system/commands/download/*": {
"order": 4,
"action": {
"type": "function",
"module": "luci.controller.commands",
"function": "action_download"
}
},
"command/*": {
"action": {
"type": "function",
"module": "luci.controller.commands",
"function": "action_public"
}
}
}

View file

@ -0,0 +1,256 @@
// Copyright 2012-2022 Jo-Philipp Wich <jow@openwrt.org>
// Licensed to the public under the Apache License 2.0.
'use strict';
import { basename, mkstemp, popen } from 'fs';
import { urldecode } from 'luci.http';
// Decode a given string into arguments following shell quoting rules
// [[abc\ def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]]
function parse_args(str) {
let args = [];
function isspace(c) {
if (c == 9 || c == 10 || c == 11 || c == 12 || c == 13 || c == 32)
return c;
}
function isquote(c) {
if (c == 34 || c == 39 || c == 96)
return c;
}
function isescape(c) {
if (c == 92)
return c;
}
function ismeta(c) {
if (c == 36 || c == 92 || c == 96)
return c;
}
// Scan substring defined by the indexes [s, e] of the string "str",
// perform unquoting and de-escaping on the fly and store the result
function unquote(start, end) {
let esc, quote, res = [];
for (let off = start; off < end; off++) {
const byte = ord(str, off);
const q = isquote(byte);
const e = isescape(byte);
const m = ismeta(byte);
if (esc) {
if (!m)
push(res, 92);
push(res, byte);
esc = false;
}
else if (e && quote != 39) {
esc = true;
}
else if (q && quote && q == quote) {
quote = null;
}
else if (q && !quote) {
quote = q;
}
else {
push(res, byte);
}
}
push(args, chr(...res));
}
// Find substring boundaries in "str". Ignore escaped or quoted
// whitespace, pass found start- and end-index for each substring
// to unquote()
let esc, start, quote;
for (let off = 0; off <= length(str); off++) {
const byte = ord(str, off);
const q = isquote(byte);
const s = isspace(byte) ?? (byte === null);
const e = isescape(byte);
if (esc) {
esc = false;
}
else if (e && quote != 39) {
esc = true;
start ??= off;
}
else if (q && quote && q == quote) {
quote = null;
}
else if (q && !quote) {
start ??= off;
quote = q;
}
else if (s && !quote) {
if (start !== null) {
unquote(start, off);
start = null;
}
}
else {
start ??= off;
}
}
// If the "quote" is still set we encountered an unfinished string
if (quote)
unquote(start, length(str));
return args;
}
function test_binary(str) {
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
if (byte <= 8 || (byte >= 14 && byte <= 31))
return true;
return false;
}
function parse_cmdline(cmdid, args) {
if (uci.get('luci', cmdid) == 'command') {
let cmd = uci.get_all('luci', cmdid);
let argv = parse_args(cmd?.command);
if (cmd?.param == '1') {
if (length(args))
push(argv, ...(parse_args(urldecode(args)) ?? []));
else if (length(args = http.formvalue('args')))
push(argv, ...(parse_args(args) ?? []));
}
return map(argv, v => match(v, /[^\w.\/|-]/) ? `'${replace(v, "'", "'\\''")}'` : v);
}
}
function execute_command(callback, ...args) {
let argv = parse_cmdline(...args);
if (argv) {
let outfd = mkstemp();
let errfd = mkstemp();
const exitcode = system(`${join(' ', argv)} >&${outfd.fileno()} 2>&${errfd.fileno()}`);
outfd.seek(0);
errfd.seek(0);
const stdout = outfd.read(1024 * 512) ?? '';
const stderr = errfd.read(1024 * 512) ?? '';
outfd.close();
errfd.close();
const binary = test_binary(stdout);
callback({
ok: true,
command: join(' ', argv),
stdout: binary ? null : stdout,
stderr,
exitcode,
binary
});
}
else {
callback({
ok: false,
code: 404,
reason: "No such command"
});
}
}
function return_json(result) {
if (result.ok) {
http.prepare_content('application/json');
http.write_json(result);
}
else {
http.status(result.code, result.reason);
}
}
function return_html(result) {
if (result.ok) {
include('commands_public', result);
}
else {
http.status(result.code, result.reason);
}
}
return {
action_run: function(...args) {
execute_command(return_json, ...args);
},
action_download: function(...args) {
const argv = parse_cmdline(...args);
if (argv) {
const fd = popen(`${join(' ', argv)} 2>/dev/null`);
if (fd) {
let filename = replace(basename(argv[0]), /\W+/g, '.');
let chunk = fd.read(4096) ?? '';
let name;
if (test_binary(chunk)) {
http.header("Content-Disposition", `attachment; filename=${filename}.bin`);
http.prepare_content("application/octet-stream");
}
else {
http.header("Content-Disposition", `attachment; filename=${filename}.txt`);
http.prepare_content("text/plain");
}
while (length(chunk)) {
http.write(chunk);
chunk = fd.read(4096);
}
fd.close();
}
else {
http.status(500, "Failed to execute command");
}
}
else {
http.status(404, "No such command");
}
},
action_public: function(cmdid, ...args) {
let disp = false;
if (substr(cmdid, -1) == "s") {
disp = true;
cmdid = substr(cmdid, 0, -1);
}
if (cmdid &&
uci.get('luci', cmdid) == 'command' &&
uci.get('luci', cmdid, 'public') == '1')
{
if (disp)
execute_command(return_html, cmdid, ...args);
else
this.action_download(cmdid, args);
}
else {
http.status(403, "Access to command denied");
}
}
};

View file

@ -0,0 +1,179 @@
{#
Copyright 2012-2022 Jo-Philipp Wich <jo@mein.io>
Licensed to the public under the Apache License 2.0.
-#}
{%
include('header', { css: `
.commands {
display: flex;
flex-wrap: wrap;
}
.commandbox {
flex: 0 0 30%;
margin: .5em;
display: flex;
flex-direction: column;
}
.commandbox > p,
.commandbox > p > * {
display: block;
}
.commandbox div {
margin-top: auto;
}
` });
-%}
<script type="text/javascript">//<![CDATA[
var stxhr = new XHR();
function command_run(ev, id)
{
var args;
var field = document.getElementById(id);
if (field)
args = encodeURIComponent(field.value);
var legend = document.getElementById('command-rc-legend');
var output = document.getElementById('command-rc-output');
if (legend && output)
{
output.innerHTML =
'<img src="{{ resource }}/icons/loading.gif" alt="{{ _('Loading') }}" style="vertical-align:middle" /> ' +
'{{ _('Waiting for command to complete...') }}'
;
legend.parentNode.style.display = 'block';
legend.style.display = 'inline';
stxhr.get('{{ dispatcher.build_url('admin/system/commands/run') }}/' + id + (args ? '?args=' + args : ''), null,
function(x, st)
{
if (st)
{
if (st.binary)
st.stdout = '[{{ _('Binary data not displayed, download instead.') }}]';
legend.style.display = 'none';
output.innerHTML = String.format(
'<pre><strong># %h\n</strong>%h<span style="color:red">%h</span></pre>' +
'<div class="alert-message warning">%s ({{ _('Code:') }} %d)</div>',
st.command, st.stdout, st.stderr,
(st.exitcode == 0) ? '{{ _('Command successful') }}' : '{{ _('Command failed') }}',
st.exitcode);
}
else
{
legend.style.display = 'none';
output.innerHTML = '<span class="error">{{ _('Failed to execute command!') }}</span>';
}
location.hash = '#output';
}
);
}
ev.preventDefault();
}
function command_download(ev, id)
{
var args;
var field = document.getElementById(id);
if (field)
args = encodeURIComponent(field.value);
location.href = '{{ dispatcher.build_url('admin/system/commands/download') }}/' + id + (args ? '/' + args : '');
ev.preventDefault();
}
function command_link(ev, id)
{
var legend = document.getElementById('command-rc-legend');
var output = document.getElementById('command-rc-output');
var args;
var field = document.getElementById(id);
if (field)
args = encodeURIComponent(field.value);
if (legend && output)
{
var prefix = location.protocol + '//' + location.host + '{{ dispatcher.build_url('command') }}/';
var suffix = (args ? '?args=' + args : '');
var link = prefix + id + suffix;
var link_nodownload = prefix + id + "s" + suffix;
legend.style.display = 'none';
output.parentNode.style.display = 'block';
output.innerHTML = String.format(
'<div class="alert-message"><p>{{ _('Download execution result') }} <a href="%s">%s</a></p><p>{{ _('Or display result') }} <a href="%s">%s</a></p></div>',
link, link, link_nodownload, link_nodownload
);
location.hash = '#output';
}
ev.preventDefault();
}
//]]></script>
{%
const commands = [];
uci.foreach('luci', 'command', s => push(commands, s));
-%}
<form method="get" action="{{ entityencode(FULL_REQUEST_URI) }}">
<div class="cbi-map">
<h2 name="content">{{ _('Custom Commands') }}</h2>
{% if (length(commands) == 0): %}
<div class="cbi-section">
<div class="table cbi-section-table">
<div class="tr cbi-section-table-row">
<p>
<em>{{ _('This section contains no values yet') }}</em>
</p>
</div>
</div>
</div>
{% else %}
<div class="commands">
{% for (let command in commands): %}
<div class="commandbox">
<h3>{{ entityencode(command.name) }}</h3>
<p>{{ _('Command:') }} <code>{{ entityencode(command.command) }}</code></p>
{% if (command.param == "1"): %}
<p>{{ _('Arguments:') }} <input type="text" id="{{ command['.name'] }}" /></p>
{% endif %}
<div>
<button class="cbi-button cbi-button-apply" onclick="command_run(event, '{{ command['.name'] }}')">{{ _('Run') }}</button>
<button class="cbi-button cbi-button-download" onclick="command_download(event, '{{ command['.name'] }}')">{{ _('Download') }}</button>
{% if (command.public == "1"): %}
<button class="cbi-button cbi-button-link" onclick="command_link(event, '{{ command['.name'] }}')">{{ _('Link') }}</button>
{% endif %}
</div>
</div>
{% endfor %}
<a name="output"></a>
</div>
{% endif %}
</div>
<fieldset class="cbi-section" style="display:none">
<legend id="command-rc-legend">{{ _('Collecting data...') }}</legend>
<span id="command-rc-output"></span>
</fieldset>
</form>
{% include('footer') %}

View file

@ -0,0 +1,48 @@
{#
Copyright 2016 t123yh <t123yh@outlook.com>
Copyright 2022 Jo-Philipp Wich <jo@mein.io>
Licensed to the public under the Apache License 2.0.
-#}
{%
include('header', { blank_page: true, css: `
.alert-success {
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
}
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
` });
-%}
<div class="alert alert-success" role="alert">
{% if (exitcode == 0): %}
{{ _('Command executed successfully.') }}
{% else %}
{{ sprintf(_('Command exited with status code %d'), exitcode) }}
{% endif %}
</div>
{% if (length(stdout)): %}
<h3>{{ _('Standard Output') }}</h3>
<pre>{{ entityencode(stdout) }}</pre>
{% endif %}
{% if (length(stderr)): %}
<h3>{{ _('Standard Error') }}</h3>
<pre>{{ entityencode(stderr) }}</pre>
{% endif %}
{% include('footer', { blank_page: true }) %}