diff options
Diffstat (limited to 'modules/luci-base/htdocs/luci-static/resources')
5 files changed, 418 insertions, 33 deletions
diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index 46f1ff4825..f4cce17fe1 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -834,7 +834,7 @@ var CBIAbstractValue = CBINode.extend({ return Promise.resolve(this.write(section_id, fval)); } else { - if (this.rmempty || this.optional) { + if (!active || this.rmempty || this.optional) { return Promise.resolve(this.remove(section_id)); } else if (!isEqual(cval, fval)) { @@ -1031,6 +1031,9 @@ var CBITableSection = CBITypedSection.extend({ for (var i = 0; i < nodes.length; i++) { var sectionname = this.titleFn('sectiontitle', cfgsections[i]); + if (sectionname == null) + sectionname = cfgsections[i]; + var trEl = E('div', { 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]), 'class': 'tr cbi-section-table-row', diff --git a/modules/luci-base/htdocs/luci-static/resources/fs.js b/modules/luci-base/htdocs/luci-static/resources/fs.js new file mode 100644 index 0000000000..6eb0390909 --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/fs.js @@ -0,0 +1,295 @@ +'use strict'; +'require rpc'; + +/** + * @typedef {Object} FileStatEntry + * @memberof LuCI.fs + + * @property {string} name - Name of the directory entry + * @property {string} type - Type of the entry, one of `block`, `char`, `directory`, `fifo`, `symlink`, `file`, `socket` or `unknown` + * @property {number} size - Size in bytes + * @property {number} mode - Access permissions + * @property {number} atime - Last access time in seconds since epoch + * @property {number} mtime - Last modification time in seconds since epoch + * @property {number} ctime - Last change time in seconds since epoch + * @property {number} inode - Inode number + * @property {number} uid - Numeric owner id + * @property {number} gid - Numeric group id + */ + +/** + * @typedef {Object} FileExecResult + * @memberof LuCI.fs + * + * @property {number} code - The exit code of the invoked command + * @property {string} [stdout] - The stdout produced by the command, if any + * @property {string} [stderr] - The stderr produced by the command, if any + */ + +var callFileList, callFileStat, callFileRead, callFileWrite, callFileRemove, + callFileExec, callFileMD5; + +callFileList = rpc.declare({ + object: 'file', + method: 'list', + params: [ 'path' ] +}); + +callFileStat = rpc.declare({ + object: 'file', + method: 'stat', + params: [ 'path' ] +}); + +callFileRead = rpc.declare({ + object: 'file', + method: 'read', + params: [ 'path' ] +}); + +callFileWrite = rpc.declare({ + object: 'file', + method: 'write', + params: [ 'path', 'data' ] +}); + +callFileRemove = rpc.declare({ + object: 'file', + method: 'remove', + params: [ 'path' ] +}); + +callFileExec = rpc.declare({ + object: 'file', + method: 'exec', + params: [ 'command', 'params', 'env' ] +}); + +callFileMD5 = rpc.declare({ + object: 'file', + method: 'md5', + params: [ 'path' ] +}); + +var rpcErrors = [ + null, + 'InvalidCommandError', + 'InvalidArgumentError', + 'MethodNotFoundError', + 'NotFoundError', + 'NoDataError', + 'PermissionError', + 'TimeoutError', + 'UnsupportedError' +]; + +function handleRpcReply(expect, rc) { + if (typeof(rc) == 'number' && rc != 0) { + var e = new Error(rpc.getStatusText(rc)); e.name = rpcErrors[rc] || 'Error'; + throw e; + } + + if (expect) { + var type = Object.prototype.toString; + + for (var key in expect) { + if (rc != null && key != '') + rc = rc[key]; + + if (rc == null || type.call(rc) != type.call(expect[key])) { + var e = new Error(_('Unexpected reply data format')); e.name = 'TypeError'; + throw e; + } + + break; + } + } + + return rc; +} + +/** + * @class fs + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * Provides high level utilities to wrap file system related RPC calls. + * To import the class in views, use `'require fs'`, to import it in + * external JavaScript, use `L.require("fs").then(...)`. + */ +var FileSystem = L.Class.extend(/** @lends LuCI.fs.prototype */ { + /** + * Obtains a listing of the specified directory. + * + * @param {string} path + * The directory path to list. + * + * @returns {Promise<LuCI.fs.FileStatEntry[]>} + * Returns a promise resolving to an array of stat detail objects or + * rejecting with an error stating the failure reason. + */ + list: function(path) { + return callFileList(path).then(handleRpcReply.bind(this, { entries: [] })); + }, + + /** + * Return file stat information on the specified path. + * + * @param {string} path + * The filesystem path to stat. + * + * @returns {Promise<LuCI.fs.FileStatEntry>} + * Returns a promise resolving to a stat detail object or + * rejecting with an error stating the failure reason. + */ + stat: function(path) { + return callFileStat(path).then(handleRpcReply.bind(this, { '': {} })); + }, + + /** + * Read the contents of the given file and return them. + * Note: this function is unsuitable for obtaining binary data. + * + * @param {string} path + * The file path to read. + * + * @returns {Promise<string>} + * Returns a promise resolving to a string containing the file contents or + * rejecting with an error stating the failure reason. + */ + read: function(path) { + return callFileRead(path).then(handleRpcReply.bind(this, { data: '' })); + }, + + /** + * Write the given data to the specified file path. + * If the specified file path does not exist, it will be created, given + * sufficient permissions. + * + * Note: `data` will be converted to a string using `String(data)` or to + * `''` when it is `null`. + * + * @param {string} path + * The file path to write to. + * + * @param {*} [data] + * The file data to write. If it is null, it will be set to an empty + * string. + * + * @returns {Promise<number>} + * Returns a promise resolving to `0` or rejecting with an error stating + * the failure reason. + */ + write: function(path, data) { + data = (data != null) ? String(data) : ''; + return callFileWrite(path, data).then(handleRpcReply.bind(this, { '': 0 })); + }, + + /** + * Unlink the given file. + * + * @param {string} + * The file path to remove. + * + * @returns {Promise<number>} + * Returns a promise resolving to `0` or rejecting with an error stating + * the failure reason. + */ + remove: function(path) { + return callFileRemove(path).then(handleRpcReply.bind(this, { '': 0 })); + }, + + /** + * Execute the specified command, optionally passing params and + * environment variables. + * + * Note: The `command` must be either the path to an executable, + * or a basename without arguments in which case it will be searched + * in $PATH. If specified, the values given in `params` will be passed + * as arguments to the command. + * + * The key/value pairs in the optional `env` table are translated to + * `setenv()` calls prior to running the command. + * + * @param {string} command + * The command to invoke. + * + * @param {string[]} [params] + * The arguments to pass to the command. + * + * @param {Object.<string, string>} [env] + * Environment variables to set. + * + * @returns {Promise<LuCI.fs.FileExecResult>} + * Returns a promise resolving to an object describing the execution + * results or rejecting with an error stating the failure reason. + */ + exec: function(command, params, env) { + if (!Array.isArray(params)) + params = null; + + if (!L.isObject(env)) + env = null; + + return callFileExec(command, params, env).then(handleRpcReply.bind(this, { '': {} })); + }, + + /** + * Read the contents of the given file, trim leading and trailing white + * space and return the trimmed result. In case of errors, return an empty + * string instead. + * + * Note: this function is useful to read single-value files in `/sys` + * or `/proc`. + * + * This function is guaranteed to not reject its promises, on failure, + * an empty string will be returned. + * + * @param {string} path + * The file path to read. + * + * @returns {Promise<string>} + * Returns a promise resolving to the file contents or the empty string + * on failure. + */ + trimmed: function(path) { + return L.resolveDefault(this.read(path), '').then(function(s) { + return s.trim(); + }); + }, + + /** + * Read the contents of the given file, split it into lines, trim + * leading and trailing white space of each line and return the + * resulting array. + * + * This function is guaranteed to not reject its promises, on failure, + * an empty array will be returned. + * + * @param {string} path + * The file path to read. + * + * @returns {Promise<string[]>} + * Returns a promise resolving to an array containing the stripped lines + * of the given file or `[]` on failure. + */ + lines: function(path) { + return L.resolveDefault(this.read(path), '').then(function(s) { + var lines = []; + + s = s.trim(); + + if (s != '') { + var l = s.split(/\n/); + + for (var i = 0; i < l.length; i++) + lines.push(l[i].trim()); + } + + return lines; + }); + } +}); + +return FileSystem; diff --git a/modules/luci-base/htdocs/luci-static/resources/luci.js b/modules/luci-base/htdocs/luci-static/resources/luci.js index cad7208532..3f4707d4e5 100644 --- a/modules/luci-base/htdocs/luci-static/resources/luci.js +++ b/modules/luci-base/htdocs/luci-static/resources/luci.js @@ -1852,6 +1852,27 @@ return s.split(/\s+/); }, + /** + * Returns a promise resolving with either the given value or or with + * the given default in case the input value is a rejecting promise. + * + * @instance + * @memberof LuCI + * + * @param {*} value + * The value to resolve the promise with. + * + * @param {*} defvalue + * The default value to resolve the promise with in case the given + * input value is a rejecting promise. + * + * @returns {Promise<*>} + * Returns a new promise resolving either to the given input value or + * to the given default value on error. + */ + resolveDefault: function(value, defvalue) { + return Promise.resolve(value).catch(function() { return defvalue }); + }, /** * The request callback function is invoked whenever an HTTP diff --git a/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js b/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js index 1667fa6707..9cc3e26ed2 100644 --- a/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js +++ b/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js @@ -3,6 +3,19 @@ 'require form'; 'require network'; 'require firewall'; +'require fs'; + +function getUsers() { + return fs.lines('/etc/passwd').then(function(lines) { + return lines.map(function(line) { return line.split(/:/)[0] }); + }); +} + +function getGroups() { + return fs.lines('/etc/group').then(function(lines) { + return lines.map(function(line) { return line.split(/:/)[0] }); + }); +} var CBIZoneSelect = form.ListValue.extend({ __name__: 'CBI.ZoneSelect', @@ -559,10 +572,48 @@ var CBIDeviceSelect = form.ListValue.extend({ }, }); +var CBIUserSelect = form.ListValue.extend({ + __name__: 'CBI.UserSelect', + + load: function(section_id) { + return getUsers().then(L.bind(function(users) { + for (var i = 0; i < users.length; i++) { + this.value(users[i]); + } + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, +}); + +var CBIGroupSelect = form.ListValue.extend({ + __name__: 'CBI.GroupSelect', + + load: function(section_id) { + return getGroups().then(L.bind(function(groups) { + for (var i = 0; i < groups.length; i++) { + this.value(groups[i]); + } + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, +}); + return L.Class.extend({ ZoneSelect: CBIZoneSelect, ZoneForwards: CBIZoneForwards, NetworkSelect: CBINetworkSelect, DeviceSelect: CBIDeviceSelect, + UserSelect: CBIUserSelect, + GroupSelect: CBIGroupSelect, }); diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index 99e1548a43..caae812812 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -2,6 +2,7 @@ 'require rpc'; 'require uci'; 'require validation'; +'require fs'; var modalDiv = null, tooltipDiv = null, @@ -1470,26 +1471,6 @@ var UIFileUpload = UIElement.extend({ }, options); }, - callFileStat: rpc.declare({ - 'object': 'file', - 'method': 'stat', - 'params': [ 'path' ], - 'expect': { '': {} } - }), - - callFileList: rpc.declare({ - 'object': 'file', - 'method': 'list', - 'params': [ 'path' ], - 'expect': { 'entries': [] } - }), - - callFileRemove: rpc.declare({ - 'object': 'file', - 'method': 'remove', - 'params': [ 'path' ] - }), - bind: function(browserEl) { this.node = browserEl; @@ -1502,7 +1483,7 @@ var UIFileUpload = UIElement.extend({ }, render: function() { - return Promise.resolve(this.value != null ? this.callFileStat(this.value) : null).then(L.bind(function(stat) { + return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) { var label; if (L.isObject(stat) && stat.type != 'directory') @@ -1647,15 +1628,11 @@ var UIFileUpload = UIElement.extend({ hidden.value = ''; } - return this.callFileRemove(path).then(L.bind(function(parent, ev, rc) { - if (rc == 0) - return this.handleSelect(parent, null, ev); - else if (rc == 6) - alert(_('Delete permission denied')); - else - alert(_('Delete request failed: %d %s').format(rc, rpc.getStatusText(rc))); - - }, this, parent, ev)); + return fs.remove(path).then(L.bind(function(parent, ev) { + return this.handleSelect(parent, null, ev); + }, this, parent, ev)).catch(function(err) { + alert(_('Delete request failed: %s').format(err.message)); + }); } }, @@ -1817,7 +1794,7 @@ var UIFileUpload = UIElement.extend({ if (fileStat == null) { L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…'))); - this.callFileList(path).then(L.bind(this.renderListing, this, browser, path)); + L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path)); } else { var button = this.node.firstElementChild, @@ -1849,7 +1826,7 @@ var UIFileUpload = UIElement.extend({ ev.preventDefault(); - return this.callFileList(path).then(L.bind(function(button, browser, path, list) { + return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) { document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) { L.dom.findClassInstance(browserEl).handleCancel(ev); }); @@ -2237,6 +2214,44 @@ return L.Class.extend({ } }), + /* Reconnect handling */ + pingDevice: function(proto, ipaddr) { + var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random()); + + return new Promise(function(resolveFn, rejectFn) { + var img = new Image(); + + img.onload = resolveFn; + img.onerror = rejectFn; + + window.setTimeout(rejectFn, 1000); + + img.src = target; + }); + }, + + awaitReconnect: function(/* ... */) { + var ipaddrs = arguments.length ? arguments : [ window.location.host ]; + + window.setTimeout(L.bind(function() { + L.Poll.add(L.bind(function() { + var tasks = [], reachable = false; + + for (var i = 0; i < 2; i++) + for (var j = 0; j < ipaddrs.length; j++) + tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j]) + .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {})); + + return Promise.all(tasks).then(function() { + if (reachable) { + L.Poll.stop(); + window.location = reachable; + } + }); + }, this)); + }, this), 5000); + }, + /* UCI Changes */ changes: L.Class.singleton({ init: function() { |