diff options
Diffstat (limited to 'modules/luci-base/htdocs')
11 files changed, 1351 insertions, 350 deletions
diff --git a/modules/luci-base/htdocs/luci-static/resources/cbi.js b/modules/luci-base/htdocs/luci-static/resources/cbi.js index d4d61eb381..4c3128bfd1 100644 --- a/modules/luci-base/htdocs/luci-static/resources/cbi.js +++ b/modules/luci-base/htdocs/luci-static/resources/cbi.js @@ -298,8 +298,6 @@ function cbi_init() { node.getAttribute('data-type')); } - document.querySelectorAll('[data-browser]').forEach(cbi_browser_init); - document.querySelectorAll('.cbi-tooltip:not(:empty)').forEach(function(s) { s.parentNode.classList.add('cbi-tooltip-container'); }); @@ -316,39 +314,20 @@ function cbi_init() { i.addEventListener('mouseout', handler); }); + var tasks = []; + document.querySelectorAll('[data-ui-widget]').forEach(function(node) { var args = JSON.parse(node.getAttribute('data-ui-widget') || '[]'), widget = new (Function.prototype.bind.apply(L.ui[args[0]], args)), markup = widget.render(); - markup.addEventListener('widget-change', cbi_d_update); - node.parentNode.replaceChild(markup, node); + tasks.push(Promise.resolve(markup).then(function(markup) { + markup.addEventListener('widget-change', cbi_d_update); + node.parentNode.replaceChild(markup, node); + })); }); - cbi_d_update(); -} - -function cbi_filebrowser(id, defpath) { - var field = L.dom.elem(id) ? id : document.getElementById(id); - var browser = window.open( - cbi_strings.path.browser + (field.value || defpath || '') + '?field=' + field.id, - "luci_filebrowser", "width=300,height=400,left=100,top=200,scrollbars=yes" - ); - - browser.focus(); -} - -function cbi_browser_init(field) -{ - field.parentNode.insertBefore( - E('img', { - 'src': L.resource('cbi/folder.gif'), - 'class': 'cbi-image-button', - 'click': function(ev) { - cbi_filebrowser(field, field.getAttribute('data-browser')); - ev.preventDefault(); - } - }), field.nextSibling); + Promise.all(tasks).then(cbi_d_update); } function cbi_validate_form(form, errmsg) @@ -566,7 +545,7 @@ String.prototype.format = function() switch(pType) { case 'b': - subst = (~~param || 0).toString(2); + subst = Math.floor(+param || 0).toString(2); break; case 'c': @@ -574,11 +553,12 @@ String.prototype.format = function() break; case 'd': - subst = (~~param || 0); + subst = Math.floor(+param || 0).toFixed(0); break; case 'u': - subst = ~~Math.abs(+param || 0); + var n = +param || 0; + subst = Math.floor((n < 0) ? 0x100000000 + n : n).toFixed(0); break; case 'f': @@ -588,7 +568,7 @@ String.prototype.format = function() break; case 'o': - subst = (~~param || 0).toString(8); + subst = Math.floor(+param || 0).toString(8); break; case 's': @@ -596,11 +576,11 @@ String.prototype.format = function() break; case 'x': - subst = ('' + (~~param || 0).toString(16)).toLowerCase(); + subst = Math.floor(+param || 0).toString(16).toLowerCase(); break; case 'X': - subst = ('' + (~~param || 0).toString(16)).toUpperCase(); + subst = Math.floor(+param || 0).toString(16).toUpperCase(); break; case 'h': diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index cc72d6e487..f0629d0ca7 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -52,7 +52,7 @@ var CBINode = Class.extend({ }, stripTags: function(s) { - if (!s.match(/[<>]/)) + if (typeof(s) == 'string' && !s.match(/[<>]/)) return s; var x = E('div', {}, s); @@ -132,18 +132,19 @@ var CBIMap = CBINode.extend({ return Promise.all(tasks); }, - save: function(cb) { + save: function(cb, silent) { this.checkDepends(); return this.parse() .then(cb) .then(uci.save.bind(uci)) .then(this.load.bind(this)) - .then(this.renderContents.bind(this)) .catch(function(e) { - alert('Cannot save due to invalid values') + if (!silent) + alert('Cannot save due to invalid values'); + return Promise.reject(); - }); + }).finally(this.renderContents.bind(this)); }, reset: function() { @@ -516,7 +517,7 @@ var CBIAbstractValue = CBINode.extend({ else { var conf = this.uciconfig || this.section.uciconfig || this.map.config, res = this.map.lookupOption(dep, section_id, conf), - val = res ? res[0].formvalue(res[1]) : null; + val = (res && res[0].isActive(res[1])) ? res[0].formvalue(res[1]) : null; istat = (istat && isEqual(val, this.deps[i][dep])); } @@ -658,14 +659,14 @@ var CBITypedSection = CBIAbstractSection.extend({ var config_name = this.uciconfig || this.map.config; uci.add(config_name, this.sectiontype, name); - this.map.save(); + return this.map.save(null, true); }, handleRemove: function(section_id, ev) { var config_name = this.uciconfig || this.map.config; uci.remove(config_name, section_id); - this.map.save(); + return this.map.save(null, true); }, renderSectionAdd: function(extra_class) { @@ -680,13 +681,11 @@ var CBITypedSection = CBIAbstractSection.extend({ createEl.classList.add(extra_class); if (this.anonymous) { - createEl.appendChild(E('input', { - 'type': 'submit', + createEl.appendChild(E('button', { 'class': 'cbi-button cbi-button-add', - 'value': btn_title || _('Add'), 'title': btn_title || _('Add'), - 'click': L.bind(this.handleAdd, this) - })); + 'click': L.ui.createHandlerFn(this, 'handleAdd') + }, btn_title || _('Add'))); } else { var nameEl = E('input', { @@ -701,12 +700,12 @@ var CBITypedSection = CBIAbstractSection.extend({ 'type': 'submit', 'value': btn_title || _('Add'), 'title': btn_title || _('Add'), - 'click': L.bind(function(ev) { + 'click': L.ui.createHandlerFn(this, function(ev) { if (nameEl.classList.contains('cbi-input-invalid')) return; - this.handleAdd(ev, nameEl.value); - }, this) + return this.handleAdd(ev, nameEl.value); + }) }) ]); @@ -743,14 +742,12 @@ var CBITypedSection = CBIAbstractSection.extend({ if (this.addremove) { sectionEl.appendChild( E('div', { 'class': 'cbi-section-remove right' }, - E('input', { - 'type': 'submit', + E('button', { 'class': 'cbi-button', 'name': 'cbi.rts.%s.%s'.format(config_name, cfgsections[i]), - 'value': _('Delete'), 'data-section-id': cfgsections[i], - 'click': L.bind(this.handleRemove, this, cfgsections[i]) - }))); + 'click': L.ui.createHandlerFn(this, 'handleRemove', cfgsections[i]) + }, _('Delete')))); } if (!this.anonymous) @@ -988,7 +985,7 @@ var CBITableSection = CBITypedSection.extend({ 'value': more_label, 'title': more_label, 'class': 'cbi-button cbi-button-edit', - 'click': L.bind(this.renderMoreOptionsModal, this, section_id) + 'click': L.ui.createHandlerFn(this, 'renderMoreOptionsModal', section_id) }) ); } @@ -997,16 +994,14 @@ var CBITableSection = CBITypedSection.extend({ var btn_title = this.titleFn('removebtntitle', section_id); L.dom.append(tdEl.lastElementChild, - E('input', { - 'type': 'submit', - 'value': btn_title || _('Delete'), + E('button', { 'title': btn_title || _('Delete'), 'class': 'cbi-button cbi-button-remove', - 'click': L.bind(function(sid, ev) { + 'click': L.ui.createHandlerFn(this, function(sid, ev) { uci.remove(config_name, sid); - this.map.save(); - }, this, section_id) - }) + return this.map.save(null, true); + }, section_id) + }, [ btn_title || _('Delete') ]) ); } @@ -1120,6 +1115,8 @@ var CBITableSection = CBITypedSection.extend({ m = new CBIMap(this.map.config, null, null), s = m.section(CBINamedSection, section_id, this.sectiontype); + m.parent = parent; + s.tabs = this.tabs; s.tab_names = this.tab_names; @@ -1156,24 +1153,18 @@ var CBITableSection = CBITypedSection.extend({ } } - //ev.target.classList.add('spinning'); - Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) { - //ev.target.classList.remove('spinning'); + return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) { L.ui.showModal(title, [ nodes, E('div', { 'class': 'right' }, [ - E('input', { - 'type': 'button', + E('button', { 'class': 'btn', - 'click': L.bind(this.handleModalCancel, this, m), - 'value': _('Dismiss') - }), ' ', - E('input', { - 'type': 'button', + 'click': L.ui.createHandlerFn(this, 'handleModalCancel', m) + }, _('Dismiss')), ' ', + E('button', { 'class': 'cbi-button cbi-button-positive important', - 'click': L.bind(this.handleModalSave, this, m), - 'value': _('Save') - }) + 'click': L.ui.createHandlerFn(this, 'handleModalSave', m) + }, _('Save')) ]) ], 'cbi-modal'); }, this)).catch(L.error); @@ -1189,8 +1180,8 @@ var CBIGridSection = CBITableSection.extend({ var config_name = this.uciconfig || this.map.config, section_id = uci.add(config_name, this.sectiontype); - this.addedSection = section_id; - this.renderMoreOptionsModal(section_id); + this.addedSection = section_id; + return this.renderMoreOptionsModal(section_id); }, handleModalSave: function(/* ... */) { @@ -1283,7 +1274,7 @@ var CBINamedSection = CBIAbstractSection.extend({ config_name = this.uciconfig || this.map.config; uci.add(config_name, this.sectiontype, section_id); - this.map.save(); + return this.map.save(null, true); }, handleRemove: function(ev) { @@ -1291,7 +1282,7 @@ var CBINamedSection = CBIAbstractSection.extend({ config_name = this.uciconfig || this.map.config; uci.remove(config_name, section_id); - this.map.save(); + return this.map.save(null, true); }, renderContents: function(data) { @@ -1315,12 +1306,10 @@ var CBINamedSection = CBIAbstractSection.extend({ if (this.addremove) { sectionEl.appendChild( E('div', { 'class': 'cbi-section-remove right' }, - E('input', { - 'type': 'submit', + E('button', { 'class': 'cbi-button', - 'value': _('Delete'), - 'click': L.bind(this.handleRemove, this) - }))); + 'click': L.ui.createHandlerFn(this, 'handleRemove') + }, _('Delete')))); } sectionEl.appendChild(E('div', { @@ -1332,12 +1321,10 @@ var CBINamedSection = CBIAbstractSection.extend({ } else if (this.addremove) { sectionEl.appendChild( - E('input', { - 'type': 'submit', + E('button', { 'class': 'cbi-button cbi-button-add', - 'value': _('Add'), - 'click': L.bind(this.handleAdd, this) - })); + 'click': L.ui.createHandlerFn(this, 'handleAdd') + }, _('Add'))); } L.dom.bindClassInstance(sectionEl, this); @@ -1642,6 +1629,9 @@ var CBIDummyValue = CBIValue.extend({ hiddenEl.render() ]); }, + + remove: function() {}, + write: function() {} }); var CBIButtonValue = CBIValue.extend({ @@ -1655,15 +1645,13 @@ var CBIButtonValue = CBIValue.extend({ if (value !== false) L.dom.content(outputEl, [ - E('input', { + E('button', { 'class': 'cbi-button cbi-button-%s'.format(this.inputstyle || 'button'), - 'type': 'button', - 'value': btn_title, - 'click': L.bind(this.onclick || function(ev) { + 'click': L.ui.createHandlerFn(this, this.onclick || function(ev) { ev.target.previousElementSibling.value = ev.target.value; - this.map.save(); - }, this) - }) + return this.map.save(); + }) + }, [ btn_title ]) ]); else L.dom.content(outputEl, ' - '); @@ -1687,6 +1675,32 @@ var CBIHiddenValue = CBIValue.extend({ } }); +var CBIFileUpload = CBIValue.extend({ + __name__: 'CBI.FileSelect', + + __init__: function(/* ... */) { + this.super('__init__', arguments); + + this.show_hidden = false; + this.enable_upload = true; + this.enable_remove = true; + this.root_directory = '/etc/luci-uploads'; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id), + name: this.cbid(section_id), + show_hidden: this.show_hidden, + enable_upload: this.enable_upload, + enable_remove: this.enable_remove, + root_directory: this.root_directory + }); + + return browserEl.render(); + } +}); + var CBISectionValue = CBIValue.extend({ __name__: 'CBI.ContainerValue', __init__: function(map, section, option, cbiClass /*, ... */) { @@ -1740,5 +1754,6 @@ return L.Class.extend({ DummyValue: CBIDummyValue, Button: CBIButtonValue, HiddenValue: CBIHiddenValue, + FileUpload: CBIFileUpload, SectionValue: CBISectionValue }); diff --git a/modules/luci-base/htdocs/luci-static/resources/luci.js b/modules/luci-base/htdocs/luci-static/resources/luci.js index d72764b114..bcc6870bd2 100644 --- a/modules/luci-base/htdocs/luci-static/resources/luci.js +++ b/modules/luci-base/htdocs/luci-static/resources/luci.js @@ -360,8 +360,13 @@ break; case 'object': - content = JSON.stringify(opt.content); - contenttype = 'application/json'; + if (!(opt.content instanceof FormData)) { + content = JSON.stringify(opt.content); + contenttype = 'application/json'; + } + else { + content = opt.content; + } break; default: @@ -378,6 +383,9 @@ contenttype = opt.headers[header]; } + if ('progress' in opt && 'upload' in opt.xhr) + opt.xhr.upload.addEventListener('progress', opt.progress); + if (contenttype != null) opt.xhr.setRequestHeader('Content-Type', contenttype); @@ -491,7 +499,7 @@ return true; }, - remove: function(entry) { + remove: function(fn) { if (typeof(fn) != 'function') throw new TypeError('Invalid argument to LuCI.Poll.remove()'); @@ -567,8 +575,13 @@ __init__: function(env) { document.querySelectorAll('script[src*="/luci.js"]').forEach(function(s) { - if (env.base_url == null || env.base_url == '') - env.base_url = s.getAttribute('src').replace(/\/luci\.js(?:\?v=[^?]+)?$/, ''); + if (env.base_url == null || env.base_url == '') { + var m = (s.getAttribute('src') || '').match(/^(.*)\/luci\.js(?:\?v=([^?]+))?$/); + if (m) { + env.base_url = m[1]; + env.resource_version = m[2]; + } + } }); if (env.base_url == null) @@ -611,16 +624,35 @@ if (type instanceof Error) { e = type; - stack = (e.stack || '').split(/\n/); if (msg) e.message = msg + ': ' + e.message; } else { + try { throw new Error('stacktrace') } + catch (e2) { stack = (e2.stack || '').split(/\n/) } + e = new (window[type || 'Error'] || Error)(msg || 'Unspecified error'); e.name = type || 'Error'; } + stack = (stack || []).map(function(frame) { + frame = frame.replace(/(.*?)@(.+):(\d+):(\d+)/g, 'at $1 ($2:$3:$4)').trim(); + return frame ? ' ' + frame : ''; + }); + + if (!/^ at /.test(stack[0])) + stack.shift(); + + if (/\braise /.test(stack[0])) + stack.shift(); + + if (/\berror /.test(stack[0])) + stack.shift(); + + if (stack.length) + e.message += '\n' + stack.join('\n'); + if (window.console && console.debug) console.debug(e); @@ -632,28 +664,16 @@ L.raise.apply(L, Array.prototype.slice.call(arguments)); } catch (e) { - var stack = (e.stack || '').split(/\n/).map(function(frame) { - frame = frame.replace(/(.*?)@(.+):(\d+):(\d+)/g, 'at $1 ($2:$3:$4)').trim(); - return frame ? ' ' + frame : ''; - }); - - if (!/^ at /.test(stack[0])) - stack.shift(); - - if (/\braise /.test(stack[0])) - stack.shift(); - - if (/\berror /.test(stack[0])) - stack.shift(); - - stack = stack.length ? '\n' + stack.join('\n') : ''; + if (!e.reported) { + if (L.ui) + L.ui.addNotification(e.name || _('Runtime error'), + E('pre', {}, e.message), 'danger'); + else + L.dom.content(document.querySelector('#maincontent'), + E('pre', { 'class': 'alert-message error' }, e.message)); - if (L.ui) - L.ui.showModal(e.name || _('Runtime error'), - E('pre', { 'class': 'alert-message error' }, e.message + stack)); - else - L.dom.content(document.querySelector('#maincontent'), - E('pre', { 'class': 'alert-message error' }, e + stack)); + e.reported = true; + } throw e; } @@ -678,7 +698,7 @@ return classes[name]; } - url = '%s/%s.js'.format(L.env.base_url, name.replace(/\./g, '/')); + url = '%s/%s.js%s'.format(L.env.base_url, name.replace(/\./g, '/'), (L.env.resource_version ? '?v=' + L.env.resource_version : '')); from = [ name ].concat(from); var compileClass = function(res) { @@ -835,6 +855,25 @@ return (ft != null && ft != false); }, + notifySessionExpiry: function() { + Poll.stop(); + + L.ui.showModal(_('Session expired'), [ + E('div', { class: 'alert-message warning' }, + _('A new login is required since the authentication session expired.')), + E('div', { class: 'right' }, + E('div', { + class: 'btn primary', + click: function() { + var loc = window.location; + window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search; + } + }, _('To login…'))) + ]); + + L.raise('SessionError', 'Login session is expired'); + }, + setupDOM: function(res) { var domEv = res[0], uiClass = res[1], @@ -844,26 +883,31 @@ rpcClass.setBaseURL(rpcBaseURL); - Request.addInterceptor(function(res) { - if (res.status != 403 || res.headers.get('X-LuCI-Login-Required') != 'yes') + rpcClass.addInterceptor(function(msg, req) { + if (!L.isObject(msg) || !L.isObject(msg.error) || msg.error.code != -32002) return; - Poll.stop(); - - L.ui.showModal(_('Session expired'), [ - E('div', { class: 'alert-message warning' }, - _('A new login is required since the authentication session expired.')), - E('div', { class: 'right' }, - E('div', { - class: 'btn primary', - click: function() { - var loc = window.location; - window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search; - } - }, _('To login…'))) - ]); + if (!L.isObject(req) || (req.object == 'session' && req.method == 'access')) + return; + + return rpcClass.declare({ + 'object': 'session', + 'method': 'access', + 'params': [ 'scope', 'object', 'function' ], + 'expect': { access: true } + })('uci', 'luci', 'read').catch(L.notifySessionExpiry); + }); + + Request.addInterceptor(function(res) { + var isDenied = false; + + if (res.status == 403 && res.headers.get('X-LuCI-Login-Required') == 'yes') + isDenied = true; + + if (!isDenied) + return; - throw 'Session expired'; + L.notifySessionExpiry(); }); return this.probeSystemFeatures().finally(this.initDOM); @@ -1230,9 +1274,9 @@ return inst[method].apply(inst, inst.varargs(arguments, 2)); }, - isEmpty: function(node) { + isEmpty: function(node, ignoreFn) { for (var child = node.firstElementChild; child != null; child = child.nextElementSibling) - if (!child.classList.contains('hidden')) + if (!child.classList.contains('hidden') && (!ignoreFn || !ignoreFn(child))) return false; return true; @@ -1298,24 +1342,18 @@ if (mc.querySelector('.cbi-map')) { footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ - E('input', { + E('button', { 'class': 'cbi-button cbi-button-apply', - 'type': 'button', - 'value': _('Save & Apply'), - 'click': L.bind(this.handleSaveApply, this) - }), ' ', - E('input', { + 'click': L.ui.createHandlerFn(this, 'handleSaveApply') + }, _('Save & Apply')), ' ', + E('button', { 'class': 'cbi-button cbi-button-save', - 'type': 'submit', - 'value': _('Save'), - 'click': L.bind(this.handleSave, this) - }), ' ', - E('input', { + 'click': L.ui.createHandlerFn(this, 'handleSave') + }, _('Save')), ' ', + E('button', { 'class': 'cbi-button cbi-button-reset', - 'type': 'button', - 'value': _('Reset'), - 'click': L.bind(this.handleReset, this) - }) + 'click': L.ui.createHandlerFn(this, 'handleReset') + }, _('Reset')) ])); } diff --git a/modules/luci-base/htdocs/luci-static/resources/network.js b/modules/luci-base/htdocs/luci-static/resources/network.js index fb23fba857..525b7c9f19 100644 --- a/modules/luci-base/htdocs/luci-static/resources/network.js +++ b/modules/luci-base/htdocs/luci-static/resources/network.js @@ -44,17 +44,18 @@ var iface_patterns_wireless = [ var iface_patterns_virtual = [ ]; -var callNetworkWirelessStatus = rpc.declare({ - object: 'network.wireless', - method: 'status' -}); - var callLuciNetdevs = rpc.declare({ object: 'luci', method: 'getNetworkDevices', expect: { '': {} } }); +var callLuciWifidevs = rpc.declare({ + object: 'luci', + method: 'getWirelessDevices', + expect: { '': {} } +}); + var callLuciIfaddrs = rpc.declare({ object: 'luci', method: 'getIfaddrs', @@ -66,10 +67,18 @@ var callLuciBoardjson = rpc.declare({ method: 'getBoardJSON' }); -var callIwinfoInfo = rpc.declare({ +var callIwinfoAssoclist = rpc.declare({ object: 'iwinfo', - method: 'info', - params: [ 'device' ] + method: 'assoclist', + params: [ 'device', 'mac' ], + expect: { results: [] } +}); + +var callIwinfoScan = rpc.declare({ + object: 'iwinfo', + method: 'scan', + params: [ 'device' ], + expect: { results: [] } }); var callNetworkInterfaceStatus = rpc.declare({ @@ -90,21 +99,17 @@ var callGetProtoHandlers = rpc.declare({ expect: { '': {} } }); +var callGetHostHints = rpc.declare({ + object: 'luci', + method: 'getHostHints', + expect: { '': {} } +}); + var _init = null, _state = null, _protocols = {}, _protospecs = {}; -function getWifiState(cache) { - return callNetworkWirelessStatus().then(function(state) { - if (!L.isObject(state)) - throw !1; - return state; - }).catch(function() { - return {}; - }); -} - function getInterfaceState(cache) { return callNetworkInterfaceStatus().then(function(state) { if (!Array.isArray(state)) @@ -145,6 +150,16 @@ function getNetdevState(cache) { }); } +function getWifidevState(cache) { + return callLuciWifidevs().then(function(state) { + if (!L.isObject(state)) + throw !1; + return state; + }).catch(function() { + return {}; + }); +} + function getBoardState(cache) { return callLuciBoardjson().then(function(state) { if (!L.isObject(state)) @@ -160,6 +175,14 @@ function getProtocolHandlers(cache) { if (!L.isObject(protos)) throw !1; + /* Register "none" protocol */ + if (!protos.hasOwnProperty('none')) + Object.assign(protos, { none: { no_device: false } }); + + /* Hack: emulate relayd protocol */ + if (!protos.hasOwnProperty('relay')) + Object.assign(protos, { relay: { no_device: true } }); + Object.assign(_protospecs, protos); return Promise.all(Object.keys(protos).map(function(p) { @@ -175,6 +198,16 @@ function getProtocolHandlers(cache) { }); } +function getHostHints(cache) { + return callGetHostHints().then(function(hosts) { + if (!L.isObject(hosts)) + throw !1; + return hosts; + }).catch(function() { + return {}; + }); +} + function getWifiStateBySid(sid) { var s = uci.get('wireless', sid); @@ -188,8 +221,12 @@ function getWifiStateBySid(sid) { var s2 = uci.get('wireless', netstate.section); - if (s2 != null && s['.type'] == s2['.type'] && s['.name'] == s2['.name']) + if (s2 != null && s['.type'] == s2['.type'] && s['.name'] == s2['.name']) { + if (s2['.anonymous'] == false && netstate.section.charAt(0) == '@') + return null; + return [ radioname, _state.radios[radioname], netstate ]; + } } } } @@ -221,37 +258,6 @@ function isWifiIfname(ifname) { return false; } -function getWifiIwinfoByIfname(ifname, forcePhyOnly) { - var tasks = [ callIwinfoInfo(ifname) ]; - - if (!forcePhyOnly) - tasks.push(getNetdevState()); - - return Promise.all(tasks).then(function(info) { - var iwinfo = info[0], - devstate = info[1], - phyonly = forcePhyOnly || !devstate[ifname] || (devstate[ifname].type != 1); - - if (L.isObject(iwinfo)) { - if (phyonly) { - delete iwinfo.bitrate; - delete iwinfo.quality; - delete iwinfo.quality_max; - delete iwinfo.mode; - delete iwinfo.ssid; - delete iwinfo.bssid; - delete iwinfo.encryption; - } - - iwinfo.ifname = ifname; - } - - return iwinfo; - }).catch(function() { - return null; - }); -} - function getWifiSidByNetid(netid) { var m = /^(\w+)\.network(\d+)$/.exec(netid); if (m) { @@ -427,14 +433,15 @@ function initNetworkState(refresh) { if (_state == null || refresh) { _init = _init || Promise.all([ getInterfaceState(), getDeviceState(), getBoardState(), - getWifiState(), getIfaddrState(), getNetdevState(), getProtocolHandlers(), + getIfaddrState(), getNetdevState(), getWifidevState(), + getHostHints(), getProtocolHandlers(), uci.load('network'), uci.load('wireless'), uci.load('luci') ]).then(function(data) { - var board = data[2], ifaddrs = data[4], devices = data[5]; + var board = data[2], ifaddrs = data[3], devices = data[4]; var s = { isTunnel: {}, isBridge: {}, isSwitch: {}, isWifi: {}, - ifaces: data[0], devices: data[1], radios: data[3], - netdevs: {}, bridges: {}, switches: {} + ifaces: data[0], devices: data[1], radios: data[5], + hosts: data[6], netdevs: {}, bridges: {}, switches: {} }; for (var i = 0, a; (a = ifaddrs[i]) != null; i++) { @@ -609,12 +616,91 @@ function deviceSort(a, b) { return a.getName() > b.getName(); } +function formatWifiEncryption(enc) { + if (!L.isObject(enc)) + return null; + + if (!enc.enabled) + return 'None'; + + var ciphers = Array.isArray(enc.ciphers) + ? enc.ciphers.map(function(c) { return c.toUpperCase() }) : [ 'NONE' ]; + + if (Array.isArray(enc.wep)) { + var has_open = false, + has_shared = false; + + for (var i = 0; i < enc.wep.length; i++) + if (enc.wep[i] == 'open') + has_open = true; + else if (enc.wep[i] == 'shared') + has_shared = true; + + if (has_open && has_shared) + return 'WEP Open/Shared (%s)'.format(ciphers.join(', ')); + else if (has_open) + return 'WEP Open System (%s)'.format(ciphers.join(', ')); + else if (has_shared) + return 'WEP Shared Auth (%s)'.format(ciphers.join(', ')); + + return 'WEP'; + } + + if (Array.isArray(enc.wpa)) { + var versions = [], + suites = Array.isArray(enc.authentication) + ? enc.authentication.map(function(a) { return a.toUpperCase() }) : [ 'NONE' ]; + + for (var i = 0; i < enc.wpa.length; i++) + switch (enc.wpa[i]) { + case 1: + versions.push('WPA'); + break; + + default: + versions.push('WPA%d'.format(enc.wpa[i])); + break; + } + + if (versions.length > 1) + return 'mixed %s %s (%s)'.format(versions.join('/'), suites.join(', '), ciphers.join(', ')); + + return '%s %s (%s)'.format(versions[0], suites.join(', '), ciphers.join(', ')); + } + + return 'Unknown'; +} + +function enumerateNetworks() { + var uciInterfaces = uci.sections('network', 'interface'), + networks = {}; + + for (var i = 0; i < uciInterfaces.length; i++) + networks[uciInterfaces[i]['.name']] = this.instantiateNetwork(uciInterfaces[i]['.name']); + + for (var i = 0; i < _state.ifaces.length; i++) + if (networks[_state.ifaces[i].interface] == null) + networks[_state.ifaces[i].interface] = + this.instantiateNetwork(_state.ifaces[i].interface, _state.ifaces[i].proto); + + var rv = []; + + for (var network in networks) + if (networks.hasOwnProperty(network)) + rv.push(networks[network]); + + rv.sort(networkSort); + + return rv; +} + -var Network, Protocol, Device, WifiDevice, WifiNetwork; +var Hosts, Network, Protocol, Device, WifiDevice, WifiNetwork; Network = L.Class.extend({ prefixToMask: prefixToMask, maskToPrefix: maskToPrefix, + formatWifiEncryption: formatWifiEncryption, flushCache: function() { initNetworkState(true); @@ -729,28 +815,7 @@ Network = L.Class.extend({ }, getNetworks: function() { - return initNetworkState().then(L.bind(function() { - var uciInterfaces = uci.sections('network', 'interface'), - networks = {}; - - for (var i = 0; i < uciInterfaces.length; i++) - networks[uciInterfaces[i]['.name']] = this.instantiateNetwork(uciInterfaces[i]['.name']); - - for (var i = 0; i < _state.ifaces.length; i++) - if (networks[_state.ifaces[i].interface] == null) - networks[_state.ifaces[i].interface] = - this.instantiateNetwork(_state.ifaces[i].interface, _state.ifaces[i].proto); - - var rv = []; - - for (var network in networks) - if (networks.hasOwnProperty(network)) - rv.push(networks[network]); - - rv.sort(networkSort); - - return rv; - }, this)); + return initNetworkState().then(L.bind(enumerateNetworks, this)); }, deleteNetwork: function(name) { @@ -967,38 +1032,26 @@ Network = L.Class.extend({ }, getWifiDevice: function(devname) { - return Promise.all([ getWifiIwinfoByIfname(devname, true), initNetworkState() ]).then(L.bind(function(res) { + return initNetworkState().then(L.bind(function() { var existingDevice = uci.get('wireless', devname); if (existingDevice == null || existingDevice['.type'] != 'wifi-device') return null; - return this.instantiateWifiDevice(devname, res[0]); + return this.instantiateWifiDevice(devname, _state.radios[devname] || {}); }, this)); }, getWifiDevices: function() { - var deviceNames = []; - return initNetworkState().then(L.bind(function() { var uciWifiDevices = uci.sections('wireless', 'wifi-device'), - tasks = []; + rv = []; for (var i = 0; i < uciWifiDevices.length; i++) { - tasks.push(callIwinfoInfo(uciWifiDevices['.name'], true)); - deviceNames.push(uciWifiDevices['.name']); + var devname = uciWifiDevices[i]['.name']; + rv.push(this.instantiateWifiDevice(devname, _state.radios[devname] || {})); } - return Promise.all(tasks); - }, this)).then(L.bind(function(iwinfos) { - var rv = []; - - for (var i = 0; i < deviceNames.length; i++) - if (L.isObject(iwinfos[i])) - rv.push(this.instantiateWifiDevice(deviceNames[i], iwinfos[i])); - - rv.sort(function(a, b) { return a.getName() < b.getName() }); - return rv; }, this)); }, @@ -1048,11 +1101,7 @@ Network = L.Class.extend({ } } - return (netstate ? getWifiIwinfoByIfname(netstate.ifname) : Promise.reject()) - .catch(function() { return radioname ? getWifiIwinfoByIfname(radioname) : Promise.reject() }) - .catch(function() { return Promise.resolve({ ifname: netid || sid || netname }) }); - }, this)).then(L.bind(function(iwinfo) { - return this.instantiateWifiNetwork(sid || netname, radioname, radiostate, netid, netstate, iwinfo); + return this.instantiateWifiNetwork(sid || netname, radioname, radiostate, netid, netstate); }, this)); }, @@ -1075,7 +1124,7 @@ Network = L.Class.extend({ var radioname = existingDevice['.name'], netid = getWifiNetidBySid(sid) || []; - return this.instantiateWifiNetwork(sid, radioname, _state.radios[radioname], netid[0], null, { ifname: netid }); + return this.instantiateWifiNetwork(sid, radioname, _state.radios[radioname], netid[0], null); }, this)); }, @@ -1187,16 +1236,19 @@ Network = L.Class.extend({ return new protoClass(name); }, - instantiateDevice: function(name, network) { + instantiateDevice: function(name, network, extend) { + if (extend != null) + return new (Device.extend(extend))(name, network); + return new Device(name, network); }, - instantiateWifiDevice: function(radioname, iwinfo) { - return new WifiDevice(radioname, iwinfo); + instantiateWifiDevice: function(radioname, radiostate) { + return new WifiDevice(radioname, radiostate); }, - instantiateWifiNetwork: function(sid, radioname, radiostate, netid, netstate, iwinfo) { - return new WifiNetwork(sid, radioname, radiostate, netid, netstate, iwinfo); + instantiateWifiNetwork: function(sid, radioname, radiostate, netid, netstate) { + return new WifiNetwork(sid, radioname, radiostate, netid, netstate); }, getIfnameOf: function(obj) { @@ -1207,6 +1259,70 @@ Network = L.Class.extend({ return initNetworkState().then(function() { return _state.hasDSLModem ? _state.hasDSLModem.type : null; }); + }, + + getHostHints: function() { + return initNetworkState().then(function() { + return new Hosts(_state.hosts); + }); + } +}); + +Hosts = L.Class.extend({ + __init__: function(hosts) { + this.hosts = hosts; + }, + + getHostnameByMACAddr: function(mac) { + return this.hosts[mac] ? this.hosts[mac].name : null; + }, + + getIPAddrByMACAddr: function(mac) { + return this.hosts[mac] ? this.hosts[mac].ipv4 : null; + }, + + getIP6AddrByMACAddr: function(mac) { + return this.hosts[mac] ? this.hosts[mac].ipv6 : null; + }, + + getHostnameByIPAddr: function(ipaddr) { + for (var mac in this.hosts) + if (this.hosts[mac].ipv4 == ipaddr && this.hosts[mac].name != null) + return this.hosts[mac].name; + return null; + }, + + getMACAddrByIPAddr: function(ipaddr) { + for (var mac in this.hosts) + if (this.hosts[mac].ipv4 == ipaddr) + return mac; + return null; + }, + + getHostnameByIP6Addr: function(ip6addr) { + for (var mac in this.hosts) + if (this.hosts[mac].ipv6 == ip6addr && this.hosts[mac].name != null) + return this.hosts[mac].name; + return null; + }, + + getMACAddrByIP6Addr: function(ip6addr) { + for (var mac in this.hosts) + if (this.hosts[mac].ipv6 == ip6addr) + return mac; + return null; + }, + + getMACHints: function(preferIp6) { + var rv = []; + for (var mac in this.hosts) { + var hint = this.hosts[mac].name || + this.hosts[mac][preferIp6 ? 'ipv6' : 'ipv4'] || + this.hosts[mac][preferIp6 ? 'ipv4' : 'ipv6']; + + rv.push([mac, hint]); + } + return rv.sort(function(a, b) { return a[0] > b[0] }); } }); @@ -1802,7 +1918,7 @@ Device = L.Class.extend({ if (this.networks == null) { this.networks = []; - var networks = L.network.getNetworks(); + var networks = enumerateNetworks.apply(L.network); for (var i = 0; i < networks.length; i++) if (networks[i].containsDevice(this.ifname) || networks[i].getIfname() == this.ifname) @@ -1820,18 +1936,32 @@ Device = L.Class.extend({ }); WifiDevice = L.Class.extend({ - __init__: function(name, iwinfo) { + __init__: function(name, radiostate) { var uciWifiDevice = uci.get('wireless', name); if (uciWifiDevice != null && uciWifiDevice['.type'] == 'wifi-device' && uciWifiDevice['.name'] != null) { this.sid = uciWifiDevice['.name']; - this.iwinfo = iwinfo; } - this.sid = this.sid || name; - this.iwinfo = this.iwinfo || { ifname: this.sid }; + this.sid = this.sid || name; + this._ubusdata = { + radio: name, + dev: radiostate + }; + }, + + ubus: function(/* ... */) { + var v = this._ubusdata; + + for (var i = 0; i < arguments.length; i++) + if (L.isObject(v)) + v = v[arguments[i]]; + else + return null; + + return v; }, get: function(opt) { @@ -1842,34 +1972,45 @@ WifiDevice = L.Class.extend({ return uci.set('wireless', this.sid, opt, value); }, + isDisabled: function() { + return this.ubus('dev', 'disabled') || this.get('disabled') == '1'; + }, + getName: function() { return this.sid; }, getHWModes: function() { - if (L.isObject(this.iwinfo.hwmodelist)) - for (var k in this.iwinfo.hwmodelist) - return this.iwinfo.hwmodelist; + var hwmodes = this.ubus('dev', 'iwinfo', 'hwmodes'); + return Array.isArray(hwmodes) ? hwmodes : [ 'b', 'g' ]; + }, - return { b: true, g: true }; + getHTModes: function() { + var htmodes = this.ubus('dev', 'iwinfo', 'htmodes'); + return (Array.isArray(htmodes) && htmodes.length) ? htmodes : null; }, getI18n: function() { - var type = this.iwinfo.hardware_name || 'Generic'; + var hw = this.ubus('dev', 'iwinfo', 'hardware'), + type = L.isObject(hw) ? hw.name : null; - if (this.iwinfo.type == 'wl') + if (this.ubus('dev', 'iwinfo', 'type') == 'wl') type = 'Broadcom'; var hwmodes = this.getHWModes(), modestr = ''; - if (hwmodes.a) modestr += 'a'; - if (hwmodes.b) modestr += 'b'; - if (hwmodes.g) modestr += 'g'; - if (hwmodes.n) modestr += 'n'; - if (hwmodes.ad) modestr += 'ac'; + hwmodes.sort(function(a, b) { + return (a.length != b.length ? a.length > b.length : a > b); + }); + + modestr = hwmodes.join(''); - return '%s 802.11%s Wireless Controller (%s)'.format(type, modestr, this.getName()); + return '%s 802.11%s Wireless Controller (%s)'.format(type || 'Generic', modestr, this.getName()); + }, + + getScanList: function() { + return callIwinfoScan(this.sid); }, isUp: function() { @@ -1933,10 +2074,8 @@ WifiDevice = L.Class.extend({ }); WifiNetwork = L.Class.extend({ - __init__: function(sid, radioname, radiostate, netid, netstate, iwinfo) { + __init__: function(sid, radioname, radiostate, netid, netstate) { this.sid = sid; - this.wdev = iwinfo.ifname; - this.iwinfo = iwinfo; this.netid = netid; this._ubusdata = { radio: radioname, @@ -1965,6 +2104,10 @@ WifiNetwork = L.Class.extend({ return uci.set('wireless', this.sid, opt, value); }, + isDisabled: function() { + return this.ubus('dev', 'disabled') || this.get('disabled') == '1'; + }, + getMode: function() { return this.ubus('net', 'config', 'mode') || this.get('mode') || 'ap'; }, @@ -1990,7 +2133,7 @@ WifiNetwork = L.Class.extend({ }, getIfname: function() { - var ifname = this.ubus('net', 'ifname') || this.iwinfo.ifname; + var ifname = this.ubus('net', 'ifname') || this.ubus('net', 'iwinfo', 'ifname'); if (ifname == null || ifname.match(/^(wifi|radio)\d/)) ifname = this.netid; @@ -1998,8 +2141,12 @@ WifiNetwork = L.Class.extend({ return ifname; }, + getWifiDeviceName: function() { + return this.ubus('radio') || this.get('device'); + }, + getWifiDevice: function() { - var radioname = this.ubus('radio') || this.get('device'); + var radioname = this.getWifiDeviceName(); if (radioname == null) return Promise.reject(); @@ -2017,7 +2164,7 @@ WifiNetwork = L.Class.extend({ }, getActiveMode: function() { - var mode = this.iwinfo.mode || this.ubus('net', 'config', 'mode') || this.get('mode') || 'ap'; + var mode = this.ubus('net', 'iwinfo', 'mode') || this.ubus('net', 'config', 'mode') || this.get('mode') || 'ap'; switch (mode) { case 'ap': return 'Master'; @@ -2043,25 +2190,23 @@ WifiNetwork = L.Class.extend({ }, getActiveSSID: function() { - return this.iwinfo.ssid || this.ubus('net', 'config', 'ssid') || this.get('ssid'); + return this.ubus('net', 'iwinfo', 'ssid') || this.ubus('net', 'config', 'ssid') || this.get('ssid'); }, getActiveBSSID: function() { - return this.iwinfo.bssid || this.ubus('net', 'config', 'bssid') || this.get('bssid'); + return this.ubus('net', 'iwinfo', 'bssid') || this.ubus('net', 'config', 'bssid') || this.get('bssid'); }, getActiveEncryption: function() { - var encryption = this.iwinfo.encryption; - - return (L.isObject(encryption) ? encryption.description || '-' : '-'); + return formatWifiEncryption(this.ubus('net', 'iwinfo', 'encryption')) || '-'; }, getAssocList: function() { - // XXX tbd + return callIwinfoAssoclist(this.getIfname()); }, getFrequency: function() { - var freq = this.iwinfo.frequency; + var freq = this.ubus('net', 'iwinfo', 'frequency'); if (freq != null && freq > 0) return '%.03f'.format(freq / 1000); @@ -2070,7 +2215,7 @@ WifiNetwork = L.Class.extend({ }, getBitRate: function() { - var rate = this.iwinfo.bitrate; + var rate = this.ubus('net', 'iwinfo', 'bitrate'); if (rate != null && rate > 0) return (rate / 1000); @@ -2079,28 +2224,27 @@ WifiNetwork = L.Class.extend({ }, getChannel: function() { - return this.iwinfo.channel || this.ubus('dev', 'config', 'channel') || this.get('channel'); + return this.ubus('net', 'iwinfo', 'channel') || this.ubus('dev', 'config', 'channel') || this.get('channel'); }, getSignal: function() { - return this.iwinfo.signal || 0; + return this.ubus('net', 'iwinfo', 'signal') || 0; }, getNoise: function() { - return this.iwinfo.noise || 0; + return this.ubus('net', 'iwinfo', 'noise') || 0; }, getCountryCode: function() { - return this.iwinfo.country || this.ubus('dev', 'config', 'country') || '00'; + return this.ubus('net', 'iwinfo', 'country') || this.ubus('dev', 'config', 'country') || '00'; }, getTXPower: function() { - var pwr = this.iwinfo.txpower || 0; - return (pwr + this.getTXPowerOffset()); + return this.ubus('net', 'iwinfo', 'txpower'); }, getTXPowerOffset: function() { - return this.iwinfo.txpower_offset || 0; + return this.ubus('net', 'iwinfo', 'txpower_offset') || 0; }, getSignalLevel: function(signal, noise) { @@ -2119,8 +2263,8 @@ WifiNetwork = L.Class.extend({ }, getSignalPercent: function() { - var qc = this.iwinfo.quality || 0, - qm = this.iwinfo.quality_max || 0; + var qc = this.ubus('net', 'iwinfo', 'quality') || 0, + qm = this.ubus('net', 'iwinfo', 'quality_max') || 0; if (qc > 0 && qm > 0) return Math.floor((100 / qm) * qc); diff --git a/modules/luci-base/htdocs/luci-static/resources/protocol/dhcp.js b/modules/luci-base/htdocs/luci-static/resources/protocol/dhcp.js new file mode 100644 index 0000000000..f0a3ec579c --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/protocol/dhcp.js @@ -0,0 +1,62 @@ +'use strict'; +'require rpc'; +'require form'; +'require network'; + +var callFileRead = rpc.declare({ + object: 'file', + method: 'read', + params: [ 'path' ], + expect: { data: '' }, + filter: function(value) { return value.trim() } +}); + +return network.registerProtocol('dhcp', { + getI18n: function() { + return _('DHCP client'); + }, + + renderFormOptions: function(s) { + var dev = this.getL2Device() || this.getDevice(), o; + + o = s.taboption('general', form.Value, 'hostname', _('Hostname to send when requesting DHCP')); + o.datatype = 'hostname'; + o.load = function(section_id) { + return callFileRead('/proc/sys/kernel/hostname').then(L.bind(function(hostname) { + this.placeholder = hostname; + return form.Value.prototype.load.apply(this, [section_id]); + }, this)); + }; + + o = s.taboption('advanced', form.Flag, 'broadcast', _('Use broadcast flag'), _('Required for certain ISPs, e.g. Charter with DOCSIS 3')); + o.default = o.disabled; + + o = s.taboption('advanced', form.Flag, 'defaultroute', _('Use default gateway'), _('If unchecked, no default route is configured')); + o.default = o.enabled; + + o = s.taboption('advanced', form.Flag, 'peerdns', _('Use DNS servers advertised by peer'), _('If unchecked, the advertised DNS server addresses are ignored')); + o.default = o.enabled; + + o = s.taboption('advanced', form.DynamicList, 'dns', _('Use custom DNS servers')); + o.depends('peerdns', '0'); + o.datatype = 'ipaddr'; + o.cast = 'string'; + + o = s.taboption('advanced', form.Value, 'metric', _('Use gateway metric')); + o.placeholder = '0'; + o.datatype = 'uinteger'; + + o = s.taboption('advanced', form.Value, 'clientid', _('Client ID to send when requesting DHCP')); + o.datatype = 'hexstring'; + + s.taboption('advanced', form.Value, 'vendorid', _('Vendor Class to send when requesting DHCP')); + + o = s.taboption('advanced', form.Value, 'macaddr', _('Override MAC address')); + o.datatype = 'macaddr'; + o.placeholder = dev ? (dev.getMAC() || '') : ''; + + o = s.taboption('advanced', form.Value, 'mtu', _('Override MTU')); + o.placeholder = dev ? (dev.getMTU() || '1500') : '1500'; + o.datatype = 'max(9200)'; + } +}); diff --git a/modules/luci-base/htdocs/luci-static/resources/protocol/none.js b/modules/luci-base/htdocs/luci-static/resources/protocol/none.js new file mode 100644 index 0000000000..37674c0ea4 --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/protocol/none.js @@ -0,0 +1,8 @@ +'use strict'; +'require network'; + +return network.registerProtocol('none', { + getI18n: function() { + return _('Unmanaged'); + } +}); diff --git a/modules/luci-base/htdocs/luci-static/resources/protocol/static.js b/modules/luci-base/htdocs/luci-static/resources/protocol/static.js new file mode 100644 index 0000000000..8470e0a20e --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/protocol/static.js @@ -0,0 +1,231 @@ +'use strict'; +'require form'; +'require network'; +'require validation'; + +function isCIDR(value) { + return Array.isArray(value) || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/(\d{1,2}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.test(value); +} + +function calculateBroadcast(s, use_cfgvalue) { + var readfn = use_cfgvalue ? 'cfgvalue' : 'formvalue', + addropt = s.children.filter(function(o) { return o.option == 'ipaddr'})[0], + addrvals = addropt ? L.toArray(addropt[readfn](s.section)) : [], + maskopt = s.children.filter(function(o) { return o.option == 'netmask'})[0], + maskval = maskopt ? maskopt[readfn](s.section) : null, + firstsubnet = maskval ? addrvals[0] + '/' + maskval : addrvals.filter(function(a) { return a.indexOf('/') > 0 })[0]; + + if (firstsubnet == null) + return null; + + var addr_mask = firstsubnet.split('/'), + addr = validation.parseIPv4(addr_mask[0]), + mask = addr_mask[1]; + + if (!isNaN(mask)) + mask = validation.parseIPv4(network.prefixToMask(+mask)); + else + mask = validation.parseIPv4(mask); + + var bc = [ + addr[0] | (~mask[0] >>> 0 & 255), + addr[1] | (~mask[1] >>> 0 & 255), + addr[2] | (~mask[2] >>> 0 & 255), + addr[3] | (~mask[3] >>> 0 & 255) + ]; + + return bc.join('.'); +} + +function validateBroadcast(section_id, value) { + var opt = this.map.lookupOption('broadcast', section_id), + node = opt ? this.map.findElement('id', opt[0].cbid(section_id)) : null, + addr = node ? calculateBroadcast(this.section, false) : null; + + if (node != null) { + if (addr != null) + node.querySelector('input').setAttribute('placeholder', addr); + else + node.querySelector('input').removeAttribute('placeholder'); + } + + return true; +} + +return network.registerProtocol('static', { + CBIIPValue: form.Value.extend({ + handleSwitch: function(section_id, option_index, ev) { + var maskopt = this.map.lookupOption('netmask', section_id); + + if (maskopt == null || !this.isValid(section_id)) + return; + + var maskval = maskopt[0].formvalue(section_id), + addrval = this.formvalue(section_id), + prefix = maskval ? network.maskToPrefix(maskval) : 32; + + if (prefix == null) + return; + + this.datatype = 'or(cidr4,ipmask4)'; + + var parent = L.dom.parent(ev.target, '.cbi-value-field'); + L.dom.content(parent, form.DynamicList.prototype.renderWidget.apply(this, [ + section_id, + option_index, + addrval ? '%s/%d'.format(addrval, prefix) : '' + ])); + + var masknode = this.map.findElement('id', maskopt[0].cbid(section_id)); + if (masknode) { + parent = L.dom.parent(masknode, '.cbi-value'); + parent.parentNode.removeChild(parent); + } + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var maskopt = this.map.lookupOption('netmask', section_id), + widget = isCIDR(cfgvalue) ? 'DynamicList' : 'Value'; + + if (widget == 'DynamicList') { + this.datatype = 'or(cidr4,ipmask4)'; + this.placeholder = _('Add IPv4 address…'); + } + else { + this.datatype = 'ip4addr("nomask")'; + } + + var node = form[widget].prototype.renderWidget.apply(this, [ section_id, option_index, cfgvalue ]); + + if (widget == 'Value') + L.dom.append(node, E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'title': _('Switch to CIDR list notation'), + 'aria-label': _('Switch to CIDR list notation'), + 'click': L.bind(this.handleSwitch, this, section_id, option_index) + }, '…')); + + return node; + }, + + validate: validateBroadcast + }), + + CBINetmaskValue: form.Value.extend({ + render: function(option_index, section_id, in_table) { + var addropt = this.section.children.filter(function(o) { return o.option == 'ipaddr' })[0], + addrval = addropt ? addropt.cfgvalue(section_id) : null; + + if (addrval != null && isCIDR(addrval)) + return E([]); + + this.value('255.255.255.0'); + this.value('255.255.0.0'); + this.value('255.0.0.0'); + + return form.Value.prototype.render.apply(this, [ option_index, section_id, in_table ]); + }, + + validate: validateBroadcast + }), + + CBIGatewayValue: form.Value.extend({ + datatype: 'ip4addr("nomask")', + + render: function(option_index, section_id, in_table) { + return network.getWANNetworks().then(L.bind(function(wans) { + if (wans.length == 1) { + var gwaddr = wans[0].getGatewayAddr(); + this.placeholder = gwaddr ? '%s (%s)'.format(gwaddr, wans[0].getName()) : ''; + } + + return form.Value.prototype.render.apply(this, [ option_index, section_id, in_table ]); + }, this)); + }, + + validate: function(section_id, value) { + var addropt = this.section.children.filter(function(o) { return o.option == 'ipaddr' })[0], + addrval = addropt ? L.toArray(addropt.cfgvalue(section_id)) : null; + + if (addrval != null) { + for (var i = 0; i < addrval.length; i++) { + var addr = addrval[i].split('/')[0]; + if (value == addr) + return _('The gateway address must not be a local IP address'); + } + } + + return true; + } + }), + + CBIBroadcastValue: form.Value.extend({ + datatype: 'ip4addr("nomask")', + + render: function(option_index, section_id, in_table) { + this.placeholder = calculateBroadcast(this.section, true); + return form.Value.prototype.render.apply(this, [ option_index, section_id, in_table ]); + } + }), + + getI18n: function() { + return _('Static address'); + }, + + renderFormOptions: function(s) { + var dev = this.getL2Device() || this.getDevice(), o; + + s.taboption('general', this.CBIIPValue, 'ipaddr', _('IPv4 address')); + s.taboption('general', this.CBINetmaskValue, 'netmask', _('IPv4 netmask')); + s.taboption('general', this.CBIGatewayValue, 'gateway', _('IPv4 gateway')); + s.taboption('general', this.CBIBroadcastValue, 'broadcast', _('IPv4 broadcast')); + s.taboption('general', form.DynamicList, 'dns', _('Use custom DNS servers')); + + o = s.taboption('general', form.Value, 'ip6assign', _('IPv6 assignment length'), _('Assign a part of given length of every public IPv6-prefix to this interface')); + o.value('', _('disabled')); + o.value('64'); + o.datatype = 'max(64)'; + + o = s.taboption('general', form.Value, 'ip6hint', _('IPv6 assignment hint'), _('Assign prefix parts using this hexadecimal subprefix ID for this interface.')); + o.placeholder = '0'; + o.validate = function(section_id, value) { + var n = parseInt(value, 16); + + if (!/^(0x)?[0-9a-fA-F]+$/.test(value) || isNaN(n) || n >= 0xffffffff) + return _('Expecting an hexadecimal assignment hint'); + + return true; + }; + for (var i = 33; i <= 64; i++) + o.depends('ip6assign', String(i)); + + o = s.taboption('general', form.DynamicList, 'ip6addr', _('IPv6 address')); + o.datatype = 'ip6addr'; + o.placeholder = _('Add IPv6 address…'); + o.depends('ip6assign', ''); + + o = s.taboption('general', form.Value, 'ip6gw', _('IPv6 gateway')); + o.datatype = 'ip6addr("nomask")'; + o.depends('ip6assign', ''); + + o = s.taboption('general', form.Value, 'ip6prefix', _('IPv6 routed prefix'), _('Public prefix routed to this device for distribution to clients.')); + o.datatype = 'ip6addr'; + o.depends('ip6assign', ''); + + o = s.taboption('general', form.Value, 'ip6ifaceid', _('IPv6 suffix'), _("Optional. Allowed values: 'eui64', 'random', fixed value like '::1' or '::1:2'. When IPv6 prefix (like 'a:b:c:d::') is received from a delegating server, use the suffix (like '::1') to form the IPv6 address ('a:b:c:d::1') for the interface.")); + o.datatype = 'ip6hostid'; + o.placeholder = '::1'; + + o = s.taboption('advanced', form.Value, 'macaddr', _('Override MAC address')); + o.datatype = 'macaddr'; + o.placeholder = dev ? (dev.getMAC() || '') : ''; + + o = s.taboption('advanced', form.Value, 'mtu', _('Override MTU')); + o.datatype = 'max(9200)'; + o.placeholder = dev ? (dev.getMTU() || '1500') : '1500'; + + o = s.taboption('advanced', form.Value, 'metric', _('Use gateway metric')); + o.placeholder = this.getMetric() || '0'; + o.datatype = 'uinteger'; + } +}); diff --git a/modules/luci-base/htdocs/luci-static/resources/rpc.js b/modules/luci-base/htdocs/luci-static/resources/rpc.js index e12c2f77ee..87850b8564 100644 --- a/modules/luci-base/htdocs/luci-static/resources/rpc.js +++ b/modules/luci-base/htdocs/luci-static/resources/rpc.js @@ -2,7 +2,8 @@ var rpcRequestID = 1, rpcSessionID = L.env.sessionid || '00000000000000000000000000000000', - rpcBaseURL = L.url('admin/ubus'); + rpcBaseURL = L.url('admin/ubus'), + rpcInterceptorFns = []; return L.Class.extend({ call: function(req, cb) { @@ -13,55 +14,73 @@ return L.Class.extend({ return Promise.resolve([]); for (var i = 0; i < req.length; i++) - q += '%s%s.%s'.format( - q ? ';' : '/', - req[i].params[1], - req[i].params[2] - ); + if (req[i].params) + q += '%s%s.%s'.format( + q ? ';' : '/', + req[i].params[1], + req[i].params[2] + ); } - else { + else if (req.params) { q += '/%s.%s'.format(req.params[1], req.params[2]); } return L.Request.post(rpcBaseURL + q, req, { timeout: (L.env.rpctimeout || 5) * 1000, credentials: true - }).then(cb); + }).then(cb, cb); }, - handleListReply: function(req, msg) { - var list = msg.result; + parseCallReply: function(req, res) { + var msg = null; - /* verify message frame */ - if (typeof(msg) != 'object' || msg.jsonrpc != '2.0' || !msg.id || !Array.isArray(list)) - list = [ ]; + if (res instanceof Error) + return req.reject(res); - req.resolve(list); - }, - - handleCallReply: function(req, res) { - var type = Object.prototype.toString, - msg = null; + try { + if (!res.ok) + L.raise('RPCError', 'RPC call to %s/%s failed with HTTP error %d: %s', + req.object, req.method, res.status, res.statusText || '?'); - if (!res.ok) - L.error('RPCError', 'RPC call failed with HTTP error %d: %s', - res.status, res.statusText || '?'); + msg = res.json(); + } + catch (e) { + return req.reject(e); + } - msg = res.json(); + /* + * The interceptor args are intentionally swapped. + * Response is passed as first arg to align with Request class interceptors + */ + Promise.all(rpcInterceptorFns.map(function(fn) { return fn(msg, req) })) + .then(this.handleCallReply.bind(this, req, msg)) + .catch(req.reject); + }, - /* fetch response attribute and verify returned type */ - var ret = undefined; + handleCallReply: function(req, msg) { + var type = Object.prototype.toString, + ret = null; + + try { + /* verify message frame */ + if (!L.isObject(msg) || msg.jsonrpc != '2.0') + L.raise('RPCError', 'RPC call to %s/%s returned invalid message frame', + req.object, req.method); + + /* check error condition */ + if (L.isObject(msg.error) && msg.error.code && msg.error.message) + L.raise('RPCError', 'RPC call to %s/%s failed with error %d: %s', + req.object, req.method, msg.error.code, msg.error.message || '?'); + } + catch (e) { + return req.reject(e); + } - /* verify message frame */ - if (typeof(msg) == 'object' && msg.jsonrpc == '2.0') { - if (typeof(msg.error) == 'object' && msg.error.code && msg.error.message) - req.reject(new Error('RPC call failed with error %d: %s' - .format(msg.error.code, msg.error.message || '?'))); - else if (Array.isArray(msg.result) && msg.result[0] == 0) - ret = (msg.result.length > 1) ? msg.result[1] : msg.result[0]; + if (!req.object && !req.method) { + ret = msg.result; } - else { - req.reject(new Error('Invalid message frame received')); + else if (Array.isArray(msg.result)) { + ret = (msg.result.length > 1) ? msg.result[1] : msg.result[0]; } if (req.expect) { @@ -94,7 +113,16 @@ return L.Class.extend({ params: arguments.length ? this.varargs(arguments) : undefined }; - return this.call(msg, this.handleListReply); + return new Promise(L.bind(function(resolveFn, rejectFn) { + /* store request info */ + var req = { + resolve: resolveFn, + reject: rejectFn + }; + + /* call rpc */ + this.call(msg, this.parseCallReply.bind(this, req)); + }, this)); }, declare: function(options) { @@ -120,7 +148,9 @@ return L.Class.extend({ resolve: resolveFn, reject: rejectFn, params: params, - priv: priv + priv: priv, + object: options.object, + method: options.method }; /* build message object */ @@ -137,7 +167,7 @@ return L.Class.extend({ }; /* call rpc */ - rpc.call(msg, rpc.handleCallReply.bind(rpc, req)); + rpc.call(msg, rpc.parseCallReply.bind(rpc, req)); }); }, this, this, options); }, @@ -156,5 +186,36 @@ return L.Class.extend({ setBaseURL: function(url) { rpcBaseURL = url; + }, + + getStatusText: function(statusCode) { + switch (statusCode) { + case 0: return _('Command OK'); + case 1: return _('Invalid command'); + case 2: return _('Invalid argument'); + case 3: return _('Method not found'); + case 4: return _('Resource not found'); + case 5: return _('No data received'); + case 6: return _('Permission denied'); + case 7: return _('Request timeout'); + case 8: return _('Not supported'); + case 9: return _('Unspecified error'); + case 10: return _('Connection lost'); + default: return _('Unknown error code'); + } + }, + + addInterceptor: function(interceptorFn) { + if (typeof(interceptorFn) == 'function') + rpcInterceptorFns.push(interceptorFn); + return interceptorFn; + }, + + removeInterceptor: function(interceptorFn) { + var oldlen = rpcInterceptorFns.length, i = oldlen; + while (i--) + if (rpcInterceptorFns[i] === interceptorFn) + rpcInterceptorFns.splice(i, 1); + return (rpcInterceptorFns.length < oldlen); } }); 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 861d8c8cea..1667fa6707 100644 --- a/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js +++ b/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js @@ -478,7 +478,7 @@ var CBIDeviceSelect = form.ListValue.extend({ var networks = device.getNetworks(); if (networks.length > 0) - L.dom.append(item.lastChild, [ ' (', networks.join(', '), ')' ]); + L.dom.append(item.lastChild, [ ' (', networks.map(function(n) { return n.getName() }).join(', '), ')' ]); if (checked[name]) values.push(name); diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index 77cef53964..fed5dafa33 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -1,4 +1,5 @@ 'use strict'; +'require rpc'; 'require uci'; 'require validation'; @@ -1453,6 +1454,417 @@ var UIHiddenfield = UIElement.extend({ } }); +var UIFileUpload = UIElement.extend({ + __init__: function(value, options) { + this.value = value; + this.options = Object.assign({ + show_hidden: false, + enable_upload: true, + enable_remove: true, + root_directory: '/etc/luci-uploads' + }, 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; + + this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel'); + this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel'); + + L.dom.bindClassInstance(browserEl, this); + + return browserEl; + }, + + render: function() { + return Promise.resolve(this.value != null ? this.callFileStat(this.value) : null).then(L.bind(function(stat) { + var label; + + if (L.isObject(stat) && stat.type != 'directory') + this.stat = stat; + + if (this.stat != null) + label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ]; + else if (this.value != null) + label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ]; + else + label = _('Select file…'); + + return this.bind(E('div', { 'id': this.options.id }, [ + E('button', { + 'class': 'btn', + 'click': L.ui.createHandlerFn(this, 'handleFileBrowser') + }, label), + E('div', { + 'class': 'cbi-filebrowser' + }), + E('input', { + 'type': 'hidden', + 'name': this.options.name, + 'value': this.value + }) + ])); + }, this)); + }, + + truncatePath: function(path) { + if (path.length > 50) + path = path.substring(0, 25) + '…' + path.substring(path.length - 25); + + return path; + }, + + iconForType: function(type) { + switch (type) { + case 'symlink': + return E('img', { + 'src': L.resource('cbi/link.gif'), + 'title': _('Symbolic link'), + 'class': 'middle' + }); + + case 'directory': + return E('img', { + 'src': L.resource('cbi/folder.gif'), + 'title': _('Directory'), + 'class': 'middle' + }); + + default: + return E('img', { + 'src': L.resource('cbi/file.gif'), + 'title': _('File'), + 'class': 'middle' + }); + } + }, + + canonicalizePath: function(path) { + return path.replace(/\/{2,}/, '/') + .replace(/\/\.(\/|$)/g, '/') + .replace(/[^\/]+\/\.\.(\/|$)/g, '/') + .replace(/\/$/, ''); + }, + + splitPath: function(path) { + var croot = this.canonicalizePath(this.options.root_directory || '/'), + cpath = this.canonicalizePath(path || '/'); + + if (cpath.length <= croot.length) + return [ croot ]; + + if (cpath.charAt(croot.length) != '/') + return [ croot ]; + + var parts = cpath.substring(croot.length + 1).split(/\//); + + parts.unshift(croot); + + return parts; + }, + + handleUpload: function(path, list, ev) { + var form = ev.target.parentNode, + fileinput = form.querySelector('input[type="file"]'), + nameinput = form.querySelector('input[type="text"]'), + filename = (nameinput.value != null ? nameinput.value : '').trim(); + + ev.preventDefault(); + + if (filename == '' || filename.match(/\//) || fileinput.files[0] == null) + return; + + var existing = list.filter(function(e) { return e.name == filename })[0]; + + if (existing != null && existing.type == 'directory') + return alert(_('A directory with the same name already exists.')); + else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename))) + return; + + var data = new FormData(); + + data.append('sessionid', L.env.sessionid); + data.append('filename', path + '/' + filename); + data.append('filedata', fileinput.files[0]); + + return L.Request.post('/cgi-bin/cgi-upload', data, { + progress: L.bind(function(btn, ev) { + btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100); + }, this, ev.target) + }).then(L.bind(function(path, ev, res) { + var reply = res.json(); + + if (L.isObject(reply) && reply.failure) + alert(_('Upload request failed: %s').format(reply.message)); + + return this.handleSelect(path, null, ev); + }, this, path, ev)); + }, + + handleDelete: function(path, fileStat, ev) { + var parent = path.replace(/\/[^\/]+$/, '') || '/', + name = path.replace(/^.+\//, ''), + msg; + + ev.preventDefault(); + + if (fileStat.type == 'directory') + msg = _('Do you really want to recursively delete the directory "%s" ?').format(name); + else + msg = _('Do you really want to delete "%s" ?').format(name); + + if (confirm(msg)) { + var button = this.node.firstElementChild, + hidden = this.node.lastElementChild; + + if (path == hidden.value) { + L.dom.content(button, _('Select file…')); + 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)); + } + }, + + renderUpload: function(path, list) { + if (!this.options.enable_upload) + return E([]); + + return E([ + E('a', { + 'href': '#', + 'class': 'btn cbi-button-positive', + 'click': function(ev) { + var uploadForm = ev.target.nextElementSibling, + fileInput = uploadForm.querySelector('input[type="file"]'); + + ev.target.style.display = 'none'; + uploadForm.style.display = ''; + fileInput.click(); + } + }, _('Upload file…')), + E('div', { 'class': 'upload', 'style': 'display:none' }, [ + E('input', { + 'type': 'file', + 'style': 'display:none', + 'change': function(ev) { + var nameinput = ev.target.parentNode.querySelector('input[type="text"]'), + uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save'); + + nameinput.value = ev.target.value.replace(/^.+[\/\\]/, ''); + uploadbtn.disabled = false; + } + }), + E('button', { + 'class': 'btn', + 'click': function(ev) { + ev.preventDefault(); + ev.target.previousElementSibling.click(); + } + }, _('Browse…')), + E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })), + E('button', { + 'class': 'btn cbi-button-save', + 'click': L.ui.createHandlerFn(this, 'handleUpload', path, list), + 'disabled': true + }, _('Upload file')) + ]) + ]); + }, + + renderListing: function(container, path, list) { + var breadcrumb = E('p'), + rows = E('ul'); + + list.sort(function(a, b) { + var isDirA = (a.type == 'directory'), + isDirB = (b.type == 'directory'); + + if (isDirA != isDirB) + return isDirA < isDirB; + + return a.name > b.name; + }); + + for (var i = 0; i < list.length; i++) { + if (!this.options.show_hidden && list[i].name.charAt(0) == '.') + continue; + + var entrypath = this.canonicalizePath(path + '/' + list[i].name), + selected = (entrypath == this.node.lastElementChild.value), + mtime = new Date(list[i].mtime * 1000); + + rows.appendChild(E('li', [ + E('div', { 'class': 'name' }, [ + this.iconForType(list[i].type), + ' ', + E('a', { + 'href': '#', + 'style': selected ? 'font-weight:bold' : null, + 'click': L.ui.createHandlerFn(this, 'handleSelect', + entrypath, list[i].type != 'directory' ? list[i] : null) + }, '%h'.format(list[i].name)) + ]), + E('div', { 'class': 'mtime hide-xs' }, [ + ' %04d-%02d-%02d %02d:%02d:%02d '.format( + mtime.getFullYear(), + mtime.getMonth() + 1, + mtime.getDate(), + mtime.getHours(), + mtime.getMinutes(), + mtime.getSeconds()) + ]), + E('div', [ + selected ? E('button', { + 'class': 'btn', + 'click': L.ui.createHandlerFn(this, 'handleReset') + }, _('Deselect')) : '', + this.options.enable_remove ? E('button', { + 'class': 'btn cbi-button-negative', + 'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i]) + }, _('Delete')) : '' + ]) + ])); + } + + if (!rows.firstElementChild) + rows.appendChild(E('em', _('No entries in this directory'))); + + var dirs = this.splitPath(path), + cur = ''; + + for (var i = 0; i < dirs.length; i++) { + cur = cur ? cur + '/' + dirs[i] : dirs[i]; + L.dom.append(breadcrumb, [ + i ? ' » ' : '', + E('a', { + 'href': '#', + 'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null) + }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')), + ]); + } + + L.dom.content(container, [ + breadcrumb, + rows, + E('div', { 'class': 'right' }, [ + this.renderUpload(path, list), + E('a', { + 'href': '#', + 'class': 'btn', + 'click': L.ui.createHandlerFn(this, 'handleCancel') + }, _('Cancel')) + ]), + ]); + }, + + handleCancel: function(ev) { + var button = this.node.firstElementChild, + browser = button.nextElementSibling; + + browser.classList.remove('open'); + button.style.display = ''; + + this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {})); + }, + + handleReset: function(ev) { + var button = this.node.firstElementChild, + hidden = this.node.lastElementChild; + + hidden.value = ''; + L.dom.content(button, _('Select file…')); + + this.handleCancel(ev); + }, + + handleSelect: function(path, fileStat, ev) { + var browser = L.dom.parent(ev.target, '.cbi-filebrowser'), + ul = browser.querySelector('ul'); + + 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)); + } + else { + var button = this.node.firstElementChild, + hidden = this.node.lastElementChild; + + path = this.canonicalizePath(path); + + L.dom.content(button, [ + this.iconForType(fileStat.type), + ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size) + ]); + + browser.classList.remove('open'); + button.style.display = ''; + hidden.value = path; + + this.stat = Object.assign({ path: path }, fileStat); + this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat })); + } + }, + + handleFileBrowser: function(ev) { + var button = ev.target, + browser = button.nextElementSibling, + path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : this.options.root_directory; + + if (this.options.root_directory.indexOf(path) != 0) + path = this.options.root_directory; + + ev.preventDefault(); + + return this.callFileList(path).then(L.bind(function(button, browser, path, list) { + document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) { + L.dom.findClassInstance(browserEl).handleCancel(ev); + }); + + button.style.display = 'none'; + browser.classList.add('open'); + + return this.renderListing(browser, path, list); + }, this, button, browser, path)); + }, + + getValue: function() { + return this.node.lastElementChild.value; + }, + + setValue: function(value) { + this.node.lastElementChild.value = value; + } +}); + return L.Class.extend({ __init__: function() { @@ -1555,6 +1967,43 @@ return L.Class.extend({ tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true })); }, + addNotification: function(title, children /*, ... */) { + var mc = document.querySelector('#maincontent') || document.body; + var msg = E('div', { + 'class': 'alert-message fade-in', + 'style': 'display:flex', + 'transitionend': function(ev) { + var node = ev.currentTarget; + if (node.parentNode && node.classList.contains('fade-out')) + node.parentNode.removeChild(node); + } + }, [ + E('div', { 'style': 'flex:10' }), + E('div', { 'style': 'flex:1; display:flex' }, [ + E('button', { + 'class': 'btn', + 'style': 'margin-left:auto; margin-top:auto', + 'click': function(ev) { + L.dom.parent(ev.target, '.alert-message').classList.add('fade-out'); + }, + + }, _('Dismiss')) + ]) + ]); + + if (title != null) + L.dom.append(msg.firstElementChild, E('h4', {}, title)); + + L.dom.append(msg.firstElementChild, children); + + for (var i = 2; i < arguments.length; i++) + msg.classList.add(arguments[i]); + + mc.insertBefore(msg, mc.firstElementChild); + + return msg; + }, + /* Widget helper */ itemlist: function(node, items, separators) { var children = []; @@ -1628,7 +2077,7 @@ return L.Class.extend({ active = pane.getAttribute('data-tab-active') === 'true'; menu.appendChild(E('li', { - 'style': L.dom.isEmpty(pane) ? 'display:none' : null, + 'style': this.isEmptyPane(pane) ? 'display:none' : null, 'class': active ? 'cbi-tab' : 'cbi-tab-disabled', 'data-tab': name }, E('a', { @@ -1645,9 +2094,9 @@ return L.Class.extend({ if (selected === null) { selected = this.getActiveTabId(panes[0]); - if (selected < 0 || selected >= panes.length || L.dom.isEmpty(panes[selected])) { + if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) { for (var i = 0; i < panes.length; i++) { - if (!L.dom.isEmpty(panes[i])) { + if (!this.isEmptyPane(panes[i])) { selected = i; break; } @@ -1660,6 +2109,12 @@ return L.Class.extend({ this.setActiveTabId(panes[selected], selected); } + + this.updateTabs(group); + }, + + isEmptyPane: function(pane) { + return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') }); }, getPathForPane: function(pane) { @@ -1712,7 +2167,7 @@ return L.Class.extend({ }, updateTabs: function(ev, root) { - (root || document).querySelectorAll('[data-tab-title]').forEach(function(pane) { + (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) { var menu = pane.parentNode.previousElementSibling, tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null, n_errors = pane.querySelectorAll('.cbi-input-invalid').length; @@ -1720,7 +2175,7 @@ return L.Class.extend({ if (!menu || !tab) return; - if (L.dom.isEmpty(pane)) { + if (this.isEmptyPane(pane)) { tab.style.display = 'none'; tab.classList.remove('flash'); } @@ -1738,7 +2193,7 @@ return L.Class.extend({ tab.removeAttribute('data-errors'); tab.removeAttribute('data-tooltip'); } - }); + }, this)); }, switchTab: function(ev) { @@ -2152,7 +2607,7 @@ return L.Class.extend({ if (t.blur) t.blur(); - Promise.resolve(fn.apply(ctx, arguments)).then(function() { + Promise.resolve(fn.apply(ctx, arguments)).finally(function() { t.classList.remove('spinning'); t.disabled = false; }); @@ -2167,5 +2622,6 @@ return L.Class.extend({ Dropdown: UIDropdown, DynamicList: UIDynamicList, Combobox: UICombobox, - Hiddenfield: UIHiddenfield + Hiddenfield: UIHiddenfield, + FileUpload: UIFileUpload }); diff --git a/modules/luci-base/htdocs/luci-static/resources/validation.js b/modules/luci-base/htdocs/luci-static/resources/validation.js index ca544cb15d..79ae1d6707 100644 --- a/modules/luci-base/htdocs/luci-static/resources/validation.js +++ b/modules/luci-base/htdocs/luci-static/resources/validation.js @@ -419,6 +419,12 @@ var ValidatorFactory = L.Class.extend({ return this.assert(this.factory.parseDecimal(this.value) <= +max, _('value smaller or equal to %f').format(max)); }, + length: function(len) { + var val = '' + this.value; + return this.assert(val.length == +len, + _('value with %d characters').format(len)); + }, + rangelength: function(min, max) { var val = '' + this.value; return this.assert((val.length >= +min) && (val.length <= +max), |