From f139a9c7846543c1565afe2b4fd8d6ce4c681e0f Mon Sep 17 00:00:00 2001 From: "Ycarus (Yannick Chabanois)" Date: Fri, 12 Jul 2019 18:27:38 +0200 Subject: [PATCH] Update to latest LuCi changes --- luci-base/Makefile | 12 +- luci-base/htdocs/luci-static/resources/cbi.js | 1399 +---------- .../htdocs/luci-static/resources/firewall.js | 560 +++++ .../htdocs/luci-static/resources/form.js | 1669 +++++++++++++ .../htdocs/luci-static/resources/luci.js | 1629 +++++++++---- .../htdocs/luci-static/resources/network.js | 2085 +++++++++++++++++ .../luci-static/resources/promis.min.js | 5 + luci-base/htdocs/luci-static/resources/rpc.js | 160 ++ .../luci-static/resources/tools/prng.js | 93 + .../luci-static/resources/tools/widgets.js | 327 +++ luci-base/htdocs/luci-static/resources/uci.js | 540 +++++ luci-base/htdocs/luci-static/resources/ui.js | 2054 ++++++++++++++++ .../luci-static/resources/validation.js | 568 +++++ luci-base/htdocs/luci-static/resources/xhr.js | 251 +- luci-base/luasrc/cbi.lua | 12 + luci-base/luasrc/cbi/datatypes.lua | 2 +- luci-base/luasrc/controller/admin/index.lua | 121 + luci-base/luasrc/controller/admin/uci.lua | 49 +- luci-base/luasrc/dispatcher.lua | 9 + luci-base/luasrc/sys.lua | 52 +- luci-base/luasrc/sys/zoneinfo/tzdata.lua | 12 +- luci-base/luasrc/tools/status.lua | 19 +- luci-base/luasrc/util.lua | 5 +- luci-base/luasrc/view/admin_uci/changelog.htm | 66 - luci-base/luasrc/view/admin_uci/changes.htm | 45 - luci-base/luasrc/view/admin_uci/revert.htm | 33 - luci-base/luasrc/view/cbi/apply_widget.htm | 172 -- .../luasrc/view/cbi/cell_valueheader.htm | 2 +- luci-base/luasrc/view/cbi/dropdown.htm | 69 +- luci-base/luasrc/view/cbi/dynlist.htm | 21 +- .../luasrc/view/cbi/full_valueheader.htm | 2 +- luci-base/luasrc/view/cbi/fvalue.htm | 18 +- luci-base/luasrc/view/cbi/lvalue.htm | 53 +- luci-base/luasrc/view/cbi/mvalue.htm | 63 +- luci-base/luasrc/view/cbi/value.htm | 57 +- .../luasrc/view/cbi/wireless_modefreq.htm | 5 + luci-base/luasrc/view/footer.htm | 13 +- luci-base/luasrc/view/header.htm | 23 +- luci-base/luasrc/view/lease_status.htm | 31 +- luci-base/luasrc/view/view.htm | 8 + luci-base/po/ca/base.po | 829 +++---- luci-base/po/cs/base.po | 829 +++---- luci-base/po/de/base.po | 912 +++---- luci-base/po/el/base.po | 826 +++---- luci-base/po/en/base.po | 826 +++---- luci-base/po/es/base.po | 1150 ++++----- luci-base/po/fr/base.po | 829 +++---- luci-base/po/he/base.po | 826 +++---- luci-base/po/hu/base.po | 829 +++---- luci-base/po/it/base.po | 829 +++---- luci-base/po/ja/base.po | 858 +++---- luci-base/po/ko/base.po | 826 +++---- luci-base/po/ms/base.po | 826 +++---- luci-base/po/no/base.po | 829 +++---- luci-base/po/pl/base.po | 844 +++---- luci-base/po/pt-br/base.po | 841 +++---- luci-base/po/pt/base.po | 829 +++---- luci-base/po/ro/base.po | 826 +++---- luci-base/po/ru/base.po | 879 +++---- luci-base/po/sk/base.po | 826 +++---- luci-base/po/sv/base.po | 841 +++---- luci-base/po/templates/base.pot | 826 +++---- luci-base/po/tr/base.po | 829 +++---- luci-base/po/uk/base.po | 862 +++---- luci-base/po/vi/base.po | 826 +++---- luci-base/po/zh-cn/base.po | 853 +++---- luci-base/po/zh-tw/base.po | 1085 ++++----- luci-base/root/usr/libexec/rpcd/luci | 350 +++ .../root/usr/share/rpcd/acl.d/luci-base.json | 21 + .../resources/view/network/dhcp.js | 410 ++++ .../resources/view/network/hosts.js | 41 + .../resources/view/network/routes.js | 102 + .../luasrc/controller/admin/network.lua | 9 +- .../luci-static/openmptcprouter/cascade.css | 27 +- .../view/themes/openmptcprouter/header.htm | 25 - 75 files changed, 22413 insertions(+), 14077 deletions(-) create mode 100644 luci-base/htdocs/luci-static/resources/firewall.js create mode 100644 luci-base/htdocs/luci-static/resources/form.js create mode 100644 luci-base/htdocs/luci-static/resources/network.js create mode 100644 luci-base/htdocs/luci-static/resources/promis.min.js create mode 100644 luci-base/htdocs/luci-static/resources/rpc.js create mode 100644 luci-base/htdocs/luci-static/resources/tools/prng.js create mode 100644 luci-base/htdocs/luci-static/resources/tools/widgets.js create mode 100644 luci-base/htdocs/luci-static/resources/uci.js create mode 100644 luci-base/htdocs/luci-static/resources/ui.js create mode 100644 luci-base/htdocs/luci-static/resources/validation.js delete mode 100644 luci-base/luasrc/view/admin_uci/changelog.htm delete mode 100644 luci-base/luasrc/view/admin_uci/changes.htm delete mode 100644 luci-base/luasrc/view/admin_uci/revert.htm delete mode 100644 luci-base/luasrc/view/cbi/apply_widget.htm create mode 100644 luci-base/luasrc/view/view.htm create mode 100755 luci-base/root/usr/libexec/rpcd/luci create mode 100644 luci-mod-network/htdocs/luci-static/resources/view/network/dhcp.js create mode 100644 luci-mod-network/htdocs/luci-static/resources/view/network/hosts.js create mode 100644 luci-mod-network/htdocs/luci-static/resources/view/network/routes.js diff --git a/luci-base/Makefile b/luci-base/Makefile index 32a8e2f5d..dc4681b6f 100644 --- a/luci-base/Makefile +++ b/luci-base/Makefile @@ -14,17 +14,13 @@ LUCI_BASENAME:=base LUCI_TITLE:=LuCI core libraries LUCI_DEPENDS:=+lua +luci-lib-nixio +luci-lib-ip +rpcd +libubus-lua +luci-lib-jsonc +liblucihttp-lua -LUCI_LUASRCDIET_VERSION:=1.0.0 -PKG_SOURCE_URL:=https://github.com/jirutka/luasrcdiet.git -PKG_SOURCE_VERSION:=f138fc9359821d9201cd6b57cfa2fcbed5b9af97 -PKG_SOURCE_SUBDIR:=luasrcdiet-$(LUCI_LUASRCDIET_VERSION) -PKG_SOURCE_PROTO:=git -PKG_SOURCE:=$(PKG_SOURCE_SUBDIR).tar.gz -PKG_MIRROR_HASH:=a5c9d098549fbef618e6022b701e66c8c6fb16c910e63219adad3a4e71341f72 +PKG_SOURCE:=v1.0.0.tar.gz +PKG_SOURCE_URL:=https://github.com/jirutka/luasrcdiet/archive/ +PKG_HASH:=48162e63e77d009f5848f18a5cabffbdfc867d0e5e73c6d407f6af5d6880151b PKG_LICENSE:=MIT -HOST_BUILD_DIR:=$(BUILD_DIR_HOST)/$(PKG_SOURCE_SUBDIR) +HOST_BUILD_DIR:=$(BUILD_DIR_HOST)/luasrcdiet-1.0.0 include $(INCLUDE_DIR)/host-build.mk diff --git a/luci-base/htdocs/luci-static/resources/cbi.js b/luci-base/htdocs/luci-static/resources/cbi.js index 67ddc6af3..d4d61eb38 100644 --- a/luci-base/htdocs/luci-static/resources/cbi.js +++ b/luci-base/htdocs/luci-static/resources/cbi.js @@ -98,540 +98,6 @@ function _(s) { return (window.TR && TR[sfh(s)]) || s; } -function Int(x) { - return (/^-?\d+$/.test(x) ? +x : NaN); -} - -function Dec(x) { - return (/^-?\d+(?:\.\d+)?$/.test(x) ? +x : NaN); -} - -function IPv4(x) { - if (!x.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)) - return null; - - if (RegExp.$1 > 255 || RegExp.$2 > 255 || RegExp.$3 > 255 || RegExp.$4 > 255) - return null; - - return [ +RegExp.$1, +RegExp.$2, +RegExp.$3, +RegExp.$4 ]; -} - -function IPv6(x) { - if (x.match(/^([a-fA-F0-9:]+):(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)) { - var v6 = RegExp.$1, v4 = IPv4(RegExp.$2); - - if (!v4) - return null; - - x = v6 + ':' + (v4[0] * 256 + v4[1]).toString(16) - + ':' + (v4[2] * 256 + v4[3]).toString(16); - } - - if (!x.match(/^[a-fA-F0-9:]+$/)) - return null; - - var prefix_suffix = x.split(/::/); - - if (prefix_suffix.length > 2) - return null; - - var prefix = (prefix_suffix[0] || '0').split(/:/); - var suffix = prefix_suffix.length > 1 ? (prefix_suffix[1] || '0').split(/:/) : []; - - if (suffix.length ? (prefix.length + suffix.length > 7) - : ((prefix_suffix.length < 2 && prefix.length < 8) || prefix.length > 8)) - return null; - - var i, word; - var words = []; - - for (i = 0, word = parseInt(prefix[0], 16); i < prefix.length; word = parseInt(prefix[++i], 16)) - if (prefix[i].length <= 4 && !isNaN(word) && word <= 0xFFFF) - words.push(word); - else - return null; - - for (i = 0; i < (8 - prefix.length - suffix.length); i++) - words.push(0); - - for (i = 0, word = parseInt(suffix[0], 16); i < suffix.length; word = parseInt(suffix[++i], 16)) - if (suffix[i].length <= 4 && !isNaN(word) && word <= 0xFFFF) - words.push(word); - else - return null; - - return words; -} - -var CBIValidatorPrototype = { - apply: function(name, value, args) { - var func; - - if (typeof(name) === 'function') - func = name; - else if (typeof(this.types[name]) === 'function') - func = this.types[name]; - else - return false; - - if (value !== undefined && value !== null) - this.value = value; - - return func.apply(this, args); - }, - - assert: function(condition, message) { - if (!condition) { - this.field.classList.add('cbi-input-invalid'); - this.error = message; - return false; - } - - this.field.classList.remove('cbi-input-invalid'); - this.error = null; - return true; - }, - - compile: function(code) { - var pos = 0; - var esc = false; - var depth = 0; - var stack = [ ]; - - code += ','; - - for (var i = 0; i < code.length; i++) { - if (esc) { - esc = false; - continue; - } - - switch (code.charCodeAt(i)) - { - case 92: - esc = true; - break; - - case 40: - case 44: - if (depth <= 0) { - if (pos < i) { - var label = code.substring(pos, i); - label = label.replace(/\\(.)/g, '$1'); - label = label.replace(/^[ \t]+/g, ''); - label = label.replace(/[ \t]+$/g, ''); - - if (label && !isNaN(label)) { - stack.push(parseFloat(label)); - } - else if (label.match(/^(['"]).*\1$/)) { - stack.push(label.replace(/^(['"])(.*)\1$/, '$2')); - } - else if (typeof this.types[label] == 'function') { - stack.push(this.types[label]); - stack.push(null); - } - else { - throw "Syntax error, unhandled token '"+label+"'"; - } - } - - pos = i+1; - } - - depth += (code.charCodeAt(i) == 40); - break; - - case 41: - if (--depth <= 0) { - if (typeof stack[stack.length-2] != 'function') - throw "Syntax error, argument list follows non-function"; - - stack[stack.length-1] = this.compile(code.substring(pos, i)); - pos = i+1; - } - - break; - } - } - - return stack; - }, - - validate: function() { - /* element is detached */ - if (!findParent(this.field, 'form')) - return true; - - this.field.classList.remove('cbi-input-invalid'); - this.value = matchesElem(this.field, 'select') ? this.field.options[this.field.selectedIndex].value : this.field.value; - this.error = null; - - var valid; - - if (this.value.length === 0) - valid = this.assert(this.optional, _('non-empty value')); - else - valid = this.vstack[0].apply(this, this.vstack[1]); - - if (!valid) { - this.field.setAttribute('data-tooltip', _('Expecting %s').format(this.error)); - this.field.setAttribute('data-tooltip-style', 'error'); - this.field.dispatchEvent(new CustomEvent('validation-failure', { bubbles: true })); - } - else { - this.field.removeAttribute('data-tooltip'); - this.field.removeAttribute('data-tooltip-style'); - this.field.dispatchEvent(new CustomEvent('validation-success', { bubbles: true })); - } - - return valid; - }, - - types: { - integer: function() { - return this.assert(Int(this.value) !== NaN, _('valid integer value')); - }, - - uinteger: function() { - return this.assert(Int(this.value) >= 0, _('positive integer value')); - }, - - float: function() { - return this.assert(Dec(this.value) !== NaN, _('valid decimal value')); - }, - - ufloat: function() { - return this.assert(Dec(this.value) >= 0, _('positive decimal value')); - }, - - ipaddr: function(nomask) { - return this.assert(this.apply('ip4addr', null, [nomask]) || this.apply('ip6addr', null, [nomask]), - nomask ? _('valid IP address') : _('valid IP address or prefix')); - }, - - ip4addr: function(nomask) { - var re = nomask ? /^(\d+\.\d+\.\d+\.\d+)$/ : /^(\d+\.\d+\.\d+\.\d+)(?:\/(\d+\.\d+\.\d+\.\d+)|\/(\d{1,2}))?$/, - m = this.value.match(re); - - return this.assert(m && IPv4(m[1]) && (m[2] ? IPv4(m[2]) : (m[3] ? this.apply('ip4prefix', m[3]) : true)), - nomask ? _('valid IPv4 address') : _('valid IPv4 address or network')); - }, - - ip6addr: function(nomask) { - var re = nomask ? /^([0-9a-fA-F:.]+)$/ : /^([0-9a-fA-F:.]+)(?:\/(\d{1,3}))?$/, - m = this.value.match(re); - - return this.assert(m && IPv6(m[1]) && (m[2] ? this.apply('ip6prefix', m[2]) : true), - nomask ? _('valid IPv6 address') : _('valid IPv6 address or prefix')); - }, - - ip4prefix: function() { - return this.assert(!isNaN(this.value) && this.value >= 0 && this.value <= 32, - _('valid IPv4 prefix value (0-32)')); - }, - - ip6prefix: function() { - return this.assert(!isNaN(this.value) && this.value >= 0 && this.value <= 128, - _('valid IPv6 prefix value (0-128)')); - }, - - cidr: function() { - return this.assert(this.apply('cidr4') || this.apply('cidr6'), _('valid IPv4 or IPv6 CIDR')); - }, - - cidr4: function() { - var m = this.value.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/); - return this.assert(m && IPv4(m[1]) && this.apply('ip4prefix', m[2]), _('valid IPv4 CIDR')); - }, - - cidr6: function() { - var m = this.value.match(/^([0-9a-fA-F:.]+)\/(\d{1,3})$/); - return this.assert(m && IPv6(m[1]) && this.apply('ip6prefix', m[2]), _('valid IPv6 CIDR')); - }, - - ipnet4: function() { - var m = this.value.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); - return this.assert(m && IPv4(m[1]) && IPv4(m[2]), _('IPv4 network in address/netmask notation')); - }, - - ipnet6: function() { - var m = this.value.match(/^([0-9a-fA-F:.]+)\/([0-9a-fA-F:.]+)$/); - return this.assert(m && IPv6(m[1]) && IPv6(m[2]), _('IPv6 network in address/netmask notation')); - }, - - ip6hostid: function() { - if (this.value == "eui64" || this.value == "random") - return true; - - var v6 = IPv6(this.value); - return this.assert(!(!v6 || v6[0] || v6[1] || v6[2] || v6[3]), _('valid IPv6 host id')); - }, - - ipmask: function() { - return this.assert(this.apply('ipmask4') || this.apply('ipmask6'), - _('valid network in address/netmask notation')); - }, - - ipmask4: function() { - return this.assert(this.apply('cidr4') || this.apply('ipnet4') || this.apply('ip4addr'), - _('valid IPv4 network')); - }, - - ipmask6: function() { - return this.assert(this.apply('cidr6') || this.apply('ipnet6') || this.apply('ip6addr'), - _('valid IPv6 network')); - }, - - port: function() { - var p = Int(this.value); - return this.assert(p >= 0 && p <= 65535, _('valid port value')); - }, - - portrange: function() { - if (this.value.match(/^(\d+)-(\d+)$/)) { - var p1 = +RegExp.$1; - var p2 = +RegExp.$2; - return this.assert(p1 <= p2 && p2 <= 65535, - _('valid port or port range (port1-port2)')); - } - - return this.assert(this.apply('port'), _('valid port or port range (port1-port2)')); - }, - - macaddr: function() { - return this.assert(this.value.match(/^([a-fA-F0-9]{2}:){5}[a-fA-F0-9]{2}$/) != null, - _('valid MAC address')); - }, - - host: function(ipv4only) { - return this.assert(this.apply('hostname') || this.apply(ipv4only == 1 ? 'ip4addr' : 'ipaddr'), - _('valid hostname or IP address')); - }, - - hostname: function(strict) { - if (this.value.length <= 253) - return this.assert( - (this.value.match(/^[a-zA-Z0-9_]+$/) != null || - (this.value.match(/^[a-zA-Z0-9_][a-zA-Z0-9_\-.]*[a-zA-Z0-9]$/) && - this.value.match(/[^0-9.]/))) && - (!strict || !this.value.match(/^_/)), - _('valid hostname')); - - return this.assert(false, _('valid hostname')); - }, - - network: function() { - return this.assert(this.apply('uciname') || this.apply('host'), - _('valid UCI identifier, hostname or IP address')); - }, - - hostport: function(ipv4only) { - var hp = this.value.split(/:/); - return this.assert(hp.length == 2 && this.apply('host', hp[0], [ipv4only]) && this.apply('port', hp[1]), - _('valid host:port')); - }, - - ip4addrport: function() { - var hp = this.value.split(/:/); - return this.assert(hp.length == 2 && this.apply('ip4addr', hp[0], [true]) && this.apply('port', hp[1]), - _('valid IPv4 address:port')); - }, - - ipaddrport: function(bracket) { - var m4 = this.value.match(/^([^\[\]:]+):(\d+)$/), - m6 = this.value.match((bracket == 1) ? /^\[(.+)\]:(\d+)$/ : /^([^\[\]]+):(\d+)$/); - - if (m4) - return this.assert(this.apply('ip4addr', m4[1], [true]) && this.apply('port', m4[2]), - _('valid address:port')); - - return this.assert(m6 && this.apply('ip6addr', m6[1], [true]) && this.apply('port', m6[2]), - _('valid address:port')); - }, - - wpakey: function() { - var v = this.value; - - if (v.length == 64) - return this.assert(v.match(/^[a-fA-F0-9]{64}$/), _('valid hexadecimal WPA key')); - - return this.assert((v.length >= 8) && (v.length <= 63), _('key between 8 and 63 characters')); - }, - - wepkey: function() { - var v = this.value; - - if (v.substr(0, 2) === 's:') - v = v.substr(2); - - if ((v.length == 10) || (v.length == 26)) - return this.assert(v.match(/^[a-fA-F0-9]{10,26}$/), _('valid hexadecimal WEP key')); - - return this.assert((v.length === 5) || (v.length === 13), _('key with either 5 or 13 characters')); - }, - - uciname: function() { - return this.assert(this.value.match(/^[a-zA-Z0-9_]+$/), _('valid UCI identifier')); - }, - - range: function(min, max) { - var val = Dec(this.value); - return this.assert(val >= +min && val <= +max, _('value between %f and %f').format(min, max)); - }, - - min: function(min) { - return this.assert(Dec(this.value) >= +min, _('value greater or equal to %f').format(min)); - }, - - max: function(max) { - return this.assert(Dec(this.value) <= +max, _('value smaller or equal to %f').format(max)); - }, - - rangelength: function(min, max) { - var val = '' + this.value; - return this.assert((val.length >= +min) && (val.length <= +max), - _('value between %d and %d characters').format(min, max)); - }, - - minlength: function(min) { - return this.assert((''+this.value).length >= +min, - _('value with at least %d characters').format(min)); - }, - - maxlength: function(max) { - return this.assert((''+this.value).length <= +max, - _('value with at most %d characters').format(max)); - }, - - or: function() { - var errors = []; - - for (var i = 0; i < arguments.length; i += 2) { - if (typeof arguments[i] != 'function') { - if (arguments[i] == this.value) - return this.assert(true); - errors.push('"%s"'.format(arguments[i])); - i--; - } - else if (arguments[i].apply(this, arguments[i+1])) { - return this.assert(true); - } - else { - errors.push(this.error); - } - } - - return this.assert(false, _('one of:\n - %s'.format(errors.join('\n - ')))); - }, - - and: function() { - for (var i = 0; i < arguments.length; i += 2) { - if (typeof arguments[i] != 'function') { - if (arguments[i] != this.value) - return this.assert(false, '"%s"'.format(arguments[i])); - i--; - } - else if (!arguments[i].apply(this, arguments[i+1])) { - return this.assert(false, this.error); - } - } - - return this.assert(true); - }, - - neg: function() { - return this.apply('or', this.value.replace(/^[ \t]*![ \t]*/, ''), arguments); - }, - - list: function(subvalidator, subargs) { - this.field.setAttribute('data-is-list', 'true'); - - var tokens = this.value.match(/[^ \t]+/g); - for (var i = 0; i < tokens.length; i++) - if (!this.apply(subvalidator, tokens[i], subargs)) - return this.assert(false, this.error); - - return this.assert(true); - }, - - phonedigit: function() { - return this.assert(this.value.match(/^[0-9\*#!\.]+$/), - _('valid phone digit (0-9, "*", "#", "!" or ".")')); - }, - - timehhmmss: function() { - return this.assert(this.value.match(/^[0-6][0-9]:[0-6][0-9]:[0-6][0-9]$/), - _('valid time (HH:MM:SS)')); - }, - - dateyyyymmdd: function() { - if (this.value.match(/^(\d\d\d\d)-(\d\d)-(\d\d)/)) { - var year = +RegExp.$1, - month = +RegExp.$2, - day = +RegExp.$3, - days_in_month = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; - - function is_leap_year(year) { - return ((!(year % 4) && (year % 100)) || !(year % 400)); - } - - function get_days_in_month(month, year) { - return (month === 2 && is_leap_year(year)) ? 29 : days_in_month[month - 1]; - } - - /* Firewall rules in the past don't make sense */ - return this.assert(year >= 2015 && month && month <= 12 && day && day <= get_days_in_month(month, year), - _('valid date (YYYY-MM-DD)')); - - } - - return this.assert(false, _('valid date (YYYY-MM-DD)')); - }, - - unique: function(subvalidator, subargs) { - var ctx = this, - option = findParent(ctx.field, '[data-type][data-name]'), - section = findParent(option, '.cbi-section'), - query = '[data-type="%s"][data-name="%s"]'.format(option.getAttribute('data-type'), option.getAttribute('data-name')), - unique = true; - - section.querySelectorAll(query).forEach(function(sibling) { - if (sibling === option) - return; - - var input = sibling.querySelector('[data-type]'), - values = input ? (input.getAttribute('data-is-list') ? input.value.match(/[^ \t]+/g) : [ input.value ]) : null; - - if (values !== null && values.indexOf(ctx.value) !== -1) - unique = false; - }); - - if (!unique) - return this.assert(false, _('unique value')); - - if (typeof(subvalidator) === 'function') - return this.apply(subvalidator, undefined, subargs); - - return this.assert(true); - }, - - hexstring: function() { - return this.assert(this.value.match(/^([a-f0-9][a-f0-9]|[A-F0-9][A-F0-9])+$/), - _('hexadecimal encoded value')); - } - } -}; - -function CBIValidator(field, type, optional) -{ - this.field = field; - this.optional = optional; - this.vstack = this.compile(type); -} - -CBIValidator.prototype = CBIValidatorPrototype; - function cbi_d_add(field, dep, index) { var obj = (typeof(field) === 'string') ? document.getElementById(field) : field; @@ -738,6 +204,11 @@ function cbi_d_update() { function cbi_init() { var nodes; + document.querySelectorAll('.cbi-dropdown').forEach(function(node) { + cbi_dropdown_init(node); + node.addEventListener('cbi-dropdown-change', cbi_d_update); + }); + nodes = document.querySelectorAll('[data-strings]'); for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { @@ -772,8 +243,8 @@ function cbi_init() { nodes = document.querySelectorAll('[data-choices]'); for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { - var choices = JSON.parse(node.getAttribute('data-choices')); - var options = {}; + var choices = JSON.parse(node.getAttribute('data-choices')), + options = {}; for (var j = 0; j < choices[0].length; j++) options[choices[0][j]] = choices[1][j]; @@ -781,15 +252,24 @@ function cbi_init() { var def = (node.getAttribute('data-optional') === 'true') ? node.placeholder || '' : null; - cbi_combobox_init(node, options, def, - node.getAttribute('data-manual')); + var cb = new L.ui.Combobox(node.value, options, { + name: node.getAttribute('name'), + sort: choices[0], + select_placeholder: def || _('-- Please choose --'), + custom_placeholder: node.getAttribute('data-manual') || _('-- custom --') + }); + + var n = cb.render(); + n.addEventListener('cbi-dropdown-change', cbi_d_update); + node.parentNode.replaceChild(n, node); } nodes = document.querySelectorAll('[data-dynlist]'); for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { - var choices = JSON.parse(node.getAttribute('data-dynlist')); - var options = null; + var choices = JSON.parse(node.getAttribute('data-dynlist')), + values = JSON.parse(node.getAttribute('data-values') || '[]'), + options = null; if (choices[0] && choices[0].length) { options = {}; @@ -798,7 +278,17 @@ function cbi_init() { options[choices[0][j]] = choices[1][j]; } - cbi_dynlist_init(node, choices[2], choices[3], options); + var dl = new L.ui.DynamicList(values, options, { + name: node.getAttribute('data-prefix'), + sort: choices[0], + datatype: choices[2], + optional: choices[3], + placeholder: node.getAttribute('data-placeholder') + }); + + var n = dl.render(); + n.addEventListener('cbi-dynlist-change', cbi_d_update); + node.parentNode.replaceChild(n, node); } nodes = document.querySelectorAll('[data-type]'); @@ -808,7 +298,6 @@ function cbi_init() { node.getAttribute('data-type')); } - document.querySelectorAll('.cbi-dropdown').forEach(cbi_dropdown_init); document.querySelectorAll('[data-browser]').forEach(cbi_browser_init); document.querySelectorAll('.cbi-tooltip:not(:empty)').forEach(function(s) { @@ -827,49 +316,18 @@ function cbi_init() { i.addEventListener('mouseout', handler); }); + 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); + }); + cbi_d_update(); } -function cbi_combobox_init(id, values, def, man) { - var obj = (typeof(id) === 'string') ? document.getElementById(id) : id; - var sb = E('div', { - 'name': obj.name, - 'class': 'cbi-dropdown', - 'display-items': 5, - 'optional': obj.getAttribute('data-optional'), - 'placeholder': _('-- Please choose --'), - 'data-type': obj.getAttribute('data-type'), - 'data-optional': obj.getAttribute('data-optional') - }, [ E('ul') ]); - - if (!(obj.value in values) && obj.value.length) { - sb.lastElementChild.appendChild(E('li', { - 'data-value': obj.value, - 'selected': '' - }, obj.value.length ? obj.value : (def || _('-- Please choose --')))); - } - - for (var i in values) { - sb.lastElementChild.appendChild(E('li', { - 'data-value': i, - 'selected': (i == obj.value) ? '' : null - }, values[i])); - } - - sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, [ - E('input', { - 'type': 'text', - 'class': 'create-item-input', - 'data-type': obj.getAttribute('data-type'), - 'data-optional': true, - 'placeholder': (man || _('-- custom --')) - }) - ])); - - sb.value = obj.value; - obj.parentNode.replaceChild(sb, obj); -} - function cbi_filebrowser(id, defpath) { var field = L.dom.elem(id) ? id : document.getElementById(id); var browser = window.open( @@ -893,156 +351,6 @@ function cbi_browser_init(field) }), field.nextSibling); } -CBIDynamicList = { - addItem: function(dl, value, text, flash) { - var exists = false, - new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [ - E('span', {}, text || value), - E('input', { - 'type': 'hidden', - 'name': dl.getAttribute('data-prefix'), - 'value': value })]); - - dl.querySelectorAll('.item, .add-item').forEach(function(item) { - if (exists) - return; - - var hidden = item.querySelector('input[type="hidden"]'); - - if (hidden && hidden.value === value) - exists = true; - else if (!hidden || hidden.value >= value) - exists = !!item.parentNode.insertBefore(new_item, item); - }); - - cbi_d_update(); - }, - - removeItem: function(dl, item) { - var sb = dl.querySelector('.cbi-dropdown'); - if (sb) { - var value = item.querySelector('input[type="hidden"]').value; - - sb.querySelectorAll('ul > li').forEach(function(li) { - if (li.getAttribute('data-value') === value) - li.removeAttribute('unselectable'); - }); - } - - item.parentNode.removeChild(item); - cbi_d_update(); - }, - - handleClick: function(ev) { - var dl = ev.currentTarget, - item = findParent(ev.target, '.item'); - - if (item) { - this.removeItem(dl, item); - } - else if (matchesElem(ev.target, '.cbi-button-add')) { - var input = ev.target.previousElementSibling; - if (input.value.length && !input.classList.contains('cbi-input-invalid')) { - this.addItem(dl, input.value, null, true); - input.value = ''; - } - } - }, - - handleDropdownChange: function(ev) { - var dl = ev.currentTarget, - sbIn = ev.detail.instance, - sbEl = ev.detail.element, - sbVal = ev.detail.value; - - if (sbVal === null) - return; - - sbIn.setValues(sbEl, null); - sbVal.element.setAttribute('unselectable', ''); - - this.addItem(dl, sbVal.value, sbVal.text, true); - }, - - handleKeydown: function(ev) { - var dl = ev.currentTarget, - item = findParent(ev.target, '.item'); - - if (item) { - switch (ev.keyCode) { - case 8: /* backspace */ - if (item.previousElementSibling) - item.previousElementSibling.focus(); - - this.removeItem(dl, item); - break; - - case 46: /* delete */ - if (item.nextElementSibling) { - if (item.nextElementSibling.classList.contains('item')) - item.nextElementSibling.focus(); - else - item.nextElementSibling.firstElementChild.focus(); - } - - this.removeItem(dl, item); - break; - } - } - else if (matchesElem(ev.target, '.cbi-input-text')) { - switch (ev.keyCode) { - case 13: /* enter */ - if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) { - this.addItem(dl, ev.target.value, null, true); - ev.target.value = ''; - ev.target.blur(); - ev.target.focus(); - } - - ev.preventDefault(); - break; - } - } - } -}; - -function cbi_dynlist_init(dl, datatype, optional, choices) -{ - if (!(this instanceof cbi_dynlist_init)) - return new cbi_dynlist_init(dl, datatype, optional, choices); - - dl.classList.add('cbi-dynlist'); - dl.appendChild(E('div', { 'class': 'add-item' }, E('input', { - 'type': 'text', - 'name': 'cbi.dynlist.' + dl.getAttribute('data-prefix'), - 'class': 'cbi-input-text', - 'placeholder': dl.getAttribute('data-placeholder'), - 'data-type': datatype, - 'data-optional': true - }))); - - if (choices) - cbi_combobox_init(dl.lastElementChild.lastElementChild, choices, '', _('-- custom --')); - else - dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+')); - - dl.addEventListener('click', this.handleClick.bind(this)); - dl.addEventListener('keydown', this.handleKeydown.bind(this)); - dl.addEventListener('cbi-dropdown-change', this.handleDropdownChange.bind(this)); - - try { - var values = JSON.parse(dl.getAttribute('data-values') || '[]'); - - if (typeof(values) === 'object' && Array.isArray(values)) - for (var i = 0; i < values.length; i++) - this.addItem(dl, values[i], choices ? choices[values[i]] : null); - } - catch (e) {} -} - -cbi_dynlist_init.prototype = CBIDynamicList; - - function cbi_validate_form(form, errmsg) { /* if triggered by a section removal or addition, don't validate */ @@ -1078,7 +386,7 @@ function cbi_validate_field(cbid, optional, type) var validatorFn; try { - var cbiValidator = new CBIValidator(field, type, optional); + var cbiValidator = L.validation.create(field, type, optional); validatorFn = cbiValidator.validate.bind(cbiValidator); } catch(e) { @@ -1258,7 +566,7 @@ String.prototype.format = function() switch(pType) { case 'b': - subst = (+param || 0).toString(2); + subst = (~~param || 0).toString(2); break; case 'c': @@ -1266,7 +574,7 @@ String.prototype.format = function() break; case 'd': - subst = ~~(+param || 0); + subst = (~~param || 0); break; case 'u': @@ -1280,7 +588,7 @@ String.prototype.format = function() break; case 'o': - subst = (+param || 0).toString(8); + subst = (~~param || 0).toString(8); break; case 's': @@ -1288,11 +596,11 @@ String.prototype.format = function() break; case 'x': - subst = ('' + (+param || 0).toString(16)).toLowerCase(); + subst = ('' + (~~param || 0).toString(16)).toLowerCase(); break; case 'X': - subst = ('' + (+param || 0).toString(16)).toUpperCase(); + subst = ('' + (~~param || 0).toString(16)).toUpperCase(); break; case 'h': @@ -1425,622 +733,11 @@ if (typeof(window.CustomEvent) !== 'function') { window.CustomEvent = CustomEvent; } -CBIDropdown = { - openDropdown: function(sb) { - var st = window.getComputedStyle(sb, null), - ul = sb.querySelector('ul'), - li = ul.querySelectorAll('li'), - fl = findParent(sb, '.cbi-value-field'), - sel = ul.querySelector('[selected]'), - rect = sb.getBoundingClientRect(), - items = Math.min(this.dropdown_items, li.length); - - document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { - s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); - }); - - sb.setAttribute('open', ''); - - var pv = ul.cloneNode(true); - pv.classList.add('preview'); - - if (fl) - fl.classList.add('cbi-dropdown-open'); - - if ('ontouchstart' in window) { - var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0), - vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0), - scrollFrom = window.pageYOffset, - scrollTo = scrollFrom + rect.top - vpHeight * 0.5, - start = null; - - ul.style.top = sb.offsetHeight + 'px'; - ul.style.left = -rect.left + 'px'; - ul.style.right = (rect.right - vpWidth) + 'px'; - ul.style.maxHeight = (vpHeight * 0.5) + 'px'; - ul.style.WebkitOverflowScrolling = 'touch'; - - var scrollStep = function(timestamp) { - if (!start) { - start = timestamp; - ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0; - } - - var duration = Math.max(timestamp - start, 1); - if (duration < 100) { - document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100); - window.requestAnimationFrame(scrollStep); - } - else { - document.body.scrollTop = scrollTo; - } - }; - - window.requestAnimationFrame(scrollStep); - } - else { - ul.style.maxHeight = '1px'; - ul.style.top = ul.style.bottom = ''; - - window.requestAnimationFrame(function() { - var height = items * li[Math.max(0, li.length - 2)].offsetHeight; - - ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0; - ul.style[((rect.top + rect.height + height) > window.innerHeight) ? 'bottom' : 'top'] = rect.height + 'px'; - ul.style.maxHeight = height + 'px'; - }); - } - - ul.querySelectorAll('[selected] input[type="checkbox"]').forEach(function(c) { - c.checked = true; - }); - - ul.classList.add('dropdown'); - - sb.insertBefore(pv, ul.nextElementSibling); - - li.forEach(function(l) { - l.setAttribute('tabindex', 0); - }); - - sb.lastElementChild.setAttribute('tabindex', 0); - - this.setFocus(sb, sel || li[0], true); - }, - - closeDropdown: function(sb, no_focus) { - if (!sb.hasAttribute('open')) - return; - - var pv = sb.querySelector('ul.preview'), - ul = sb.querySelector('ul.dropdown'), - li = ul.querySelectorAll('li'), - fl = findParent(sb, '.cbi-value-field'); - - li.forEach(function(l) { l.removeAttribute('tabindex'); }); - sb.lastElementChild.removeAttribute('tabindex'); - - sb.removeChild(pv); - sb.removeAttribute('open'); - sb.style.width = sb.style.height = ''; - - ul.classList.remove('dropdown'); - ul.style.top = ul.style.bottom = ul.style.maxHeight = ''; - - if (fl) - fl.classList.remove('cbi-dropdown-open'); - - if (!no_focus) - this.setFocus(sb, sb); - - this.saveValues(sb, ul); - }, - - toggleItem: function(sb, li, force_state) { - if (li.hasAttribute('unselectable')) - return; - - if (this.multi) { - var cbox = li.querySelector('input[type="checkbox"]'), - items = li.parentNode.querySelectorAll('li'), - label = sb.querySelector('ul.preview'), - sel = li.parentNode.querySelectorAll('[selected]').length, - more = sb.querySelector('.more'), - ndisplay = this.display_items, - n = 0; - - if (li.hasAttribute('selected')) { - if (force_state !== true) { - if (sel > 1 || this.optional) { - li.removeAttribute('selected'); - cbox.checked = cbox.disabled = false; - sel--; - } - else { - cbox.disabled = true; - } - } - } - else { - if (force_state !== false) { - li.setAttribute('selected', ''); - cbox.checked = true; - cbox.disabled = false; - sel++; - } - } - - while (label.firstElementChild) - label.removeChild(label.firstElementChild); - - for (var i = 0; i < items.length; i++) { - items[i].removeAttribute('display'); - if (items[i].hasAttribute('selected')) { - if (ndisplay-- > 0) { - items[i].setAttribute('display', n++); - label.appendChild(items[i].cloneNode(true)); - } - var c = items[i].querySelector('input[type="checkbox"]'); - if (c) - c.disabled = (sel == 1 && !this.optional); - } - } - - if (ndisplay < 0) - sb.setAttribute('more', ''); - else - sb.removeAttribute('more'); - - if (ndisplay === this.display_items) - sb.setAttribute('empty', ''); - else - sb.removeAttribute('empty'); - - more.innerHTML = (ndisplay === this.display_items) ? this.placeholder : '···'; - } - else { - var sel = li.parentNode.querySelector('[selected]'); - if (sel) { - sel.removeAttribute('display'); - sel.removeAttribute('selected'); - } - - li.setAttribute('display', 0); - li.setAttribute('selected', ''); - - this.closeDropdown(sb, true); - } - - this.saveValues(sb, li.parentNode); - }, - - transformItem: function(sb, li) { - var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })), - label = E('label'); - - while (li.firstChild) - label.appendChild(li.firstChild); - - li.appendChild(cbox); - li.appendChild(label); - }, - - saveValues: function(sb, ul) { - var sel = ul.querySelectorAll('li[selected]'), - div = sb.lastElementChild, - strval = '', - values = []; - - while (div.lastElementChild) - div.removeChild(div.lastElementChild); - - sel.forEach(function (s) { - if (s.hasAttribute('placeholder')) - return; - - var v = { - text: s.innerText, - value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText, - element: s - }; - - div.appendChild(E('input', { - type: 'hidden', - name: s.hasAttribute('name') ? s.getAttribute('name') : (sb.getAttribute('name') || ''), - value: v.value - })); - - values.push(v); - - strval += strval.length ? ' ' + v.value : v.value; - }); - - var detail = { - instance: this, - element: sb - }; - - if (this.multi) - detail.values = values; - else - detail.value = values.length ? values[0] : null; - - sb.value = strval; - - sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', { - bubbles: true, - detail: detail - })); - - cbi_d_update(); - }, - - setValues: function(sb, values) { - var ul = sb.querySelector('ul'); - - if (this.multi) { - ul.querySelectorAll('li[data-value]').forEach(function(li) { - if (values === null || !(li.getAttribute('data-value') in values)) - this.toggleItem(sb, li, false); - else - this.toggleItem(sb, li, true); - }); - } - else { - var ph = ul.querySelector('li[placeholder]'); - if (ph) - this.toggleItem(sb, ph); - - ul.querySelectorAll('li[data-value]').forEach(function(li) { - if (values !== null && (li.getAttribute('data-value') in values)) - this.toggleItem(sb, li); - }); - } - }, - - setFocus: function(sb, elem, scroll) { - if (sb && sb.hasAttribute && sb.hasAttribute('locked-in')) - return; - - if (sb.target && findParent(sb.target, 'ul.dropdown')) - return; - - document.querySelectorAll('.focus').forEach(function(e) { - if (!matchesElem(e, 'input')) { - e.classList.remove('focus'); - e.blur(); - } - }); - - if (elem) { - elem.focus(); - elem.classList.add('focus'); - - if (scroll) - elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop; - } - }, - - createItems: function(sb, value) { - var sbox = this, - val = (value || '').trim(), - ul = sb.querySelector('ul'); - - if (!sbox.multi) - val = val.length ? [ val ] : []; - else - val = val.length ? val.split(/\s+/) : []; - - val.forEach(function(item) { - var new_item = null; - - ul.childNodes.forEach(function(li) { - if (li.getAttribute && li.getAttribute('data-value') === item) - new_item = li; - }); - - if (!new_item) { - var markup, - tpl = sb.querySelector(sbox.template); - - if (tpl) - markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^$/, '').trim(); - else - markup = '
  • {{value}}
  • '; - - new_item = E(markup.replace(/{{value}}/g, item)); - - if (sbox.multi) { - sbox.transformItem(sb, new_item); - } - else { - var old = ul.querySelector('li[created]'); - if (old) - ul.removeChild(old); - - new_item.setAttribute('created', ''); - } - - new_item = ul.insertBefore(new_item, ul.lastElementChild); - } - - sbox.toggleItem(sb, new_item, true); - sbox.setFocus(sb, new_item, true); - }); - }, - - closeAllDropdowns: function() { - document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { - s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); - }); - }, - - handleClick: function(ev) { - var sb = ev.currentTarget; - - if (!sb.hasAttribute('open')) { - if (!matchesElem(ev.target, 'input')) - this.openDropdown(sb); - } - else { - var li = findParent(ev.target, 'li'); - if (li && li.parentNode.classList.contains('dropdown')) - this.toggleItem(sb, li); - else if (li && li.parentNode.classList.contains('preview')) - this.closeDropdown(sb); - } - - ev.preventDefault(); - ev.stopPropagation(); - }, - - handleKeydown: function(ev) { - var sb = ev.currentTarget; - - if (matchesElem(ev.target, 'input')) - return; - - if (!sb.hasAttribute('open')) { - switch (ev.keyCode) { - case 37: - case 38: - case 39: - case 40: - this.openDropdown(sb); - ev.preventDefault(); - } - } - else { - var active = findParent(document.activeElement, 'li'); - - switch (ev.keyCode) { - case 27: - this.closeDropdown(sb); - break; - - case 13: - if (active) { - if (!active.hasAttribute('selected')) - this.toggleItem(sb, active); - this.closeDropdown(sb); - ev.preventDefault(); - } - break; - - case 32: - if (active) { - this.toggleItem(sb, active); - ev.preventDefault(); - } - break; - - case 38: - if (active && active.previousElementSibling) { - this.setFocus(sb, active.previousElementSibling); - ev.preventDefault(); - } - break; - - case 40: - if (active && active.nextElementSibling) { - this.setFocus(sb, active.nextElementSibling); - ev.preventDefault(); - } - break; - } - } - }, - - handleDropdownClose: function(ev) { - var sb = ev.currentTarget; - - this.closeDropdown(sb, true); - }, - - handleDropdownSelect: function(ev) { - var sb = ev.currentTarget, - li = findParent(ev.target, 'li'); - - if (!li) - return; - - this.toggleItem(sb, li); - this.closeDropdown(sb, true); - }, - - handleMouseover: function(ev) { - var sb = ev.currentTarget; - - if (!sb.hasAttribute('open')) - return; - - var li = findParent(ev.target, 'li'); - - if (li && li.parentNode.classList.contains('dropdown')) - this.setFocus(sb, li); - }, - - handleFocus: function(ev) { - var sb = ev.currentTarget; - - document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { - if (s !== sb || sb.hasAttribute('open')) - s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); - }); - }, - - handleCanaryFocus: function(ev) { - this.closeDropdown(ev.currentTarget.parentNode); - }, - - handleCreateKeydown: function(ev) { - var input = ev.currentTarget, - sb = findParent(input, '.cbi-dropdown'); - - switch (ev.keyCode) { - case 13: - ev.preventDefault(); - - if (input.classList.contains('cbi-input-invalid')) - return; - - this.createItems(sb, input.value); - input.value = ''; - input.blur(); - break; - } - }, - - handleCreateFocus: function(ev) { - var input = ev.currentTarget, - cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'), - sb = findParent(input, '.cbi-dropdown'); - - if (cbox) - cbox.checked = true; - - sb.setAttribute('locked-in', ''); - }, - - handleCreateBlur: function(ev) { - var input = ev.currentTarget, - cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'), - sb = findParent(input, '.cbi-dropdown'); - - if (cbox) - cbox.checked = false; - - sb.removeAttribute('locked-in'); - }, - - handleCreateClick: function(ev) { - ev.currentTarget.querySelector(this.create).focus(); - } -}; - function cbi_dropdown_init(sb) { - if (!(this instanceof cbi_dropdown_init)) - return new cbi_dropdown_init(sb); - - this.multi = sb.hasAttribute('multiple'); - this.optional = sb.hasAttribute('optional'); - this.placeholder = sb.getAttribute('placeholder') || '---'; - this.display_items = parseInt(sb.getAttribute('display-items') || 3); - this.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || 5); - this.create = sb.getAttribute('item-create') || '.create-item-input'; - this.template = sb.getAttribute('item-template') || 'script[type="item-template"]'; - - var ul = sb.querySelector('ul'), - more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')), - open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')), - canary = sb.appendChild(E('div')), - create = sb.querySelector(this.create), - ndisplay = this.display_items, - n = 0; - - if (this.multi) { - var items = ul.querySelectorAll('li'); - - for (var i = 0; i < items.length; i++) { - this.transformItem(sb, items[i]); - - if (items[i].hasAttribute('selected') && ndisplay-- > 0) - items[i].setAttribute('display', n++); - } - } - else { - if (this.optional && !ul.querySelector('li[data-value=""]')) { - var placeholder = E('li', { placeholder: '' }, this.placeholder); - ul.firstChild ? ul.insertBefore(placeholder, ul.firstChild) : ul.appendChild(placeholder); - } - - var items = ul.querySelectorAll('li'), - sel = sb.querySelectorAll('[selected]'); - - sel.forEach(function(s) { - s.removeAttribute('selected'); - }); - - var s = sel[0] || items[0]; - if (s) { - s.setAttribute('selected', ''); - s.setAttribute('display', n++); - } - - ndisplay--; - } - - this.saveValues(sb, ul); - - ul.setAttribute('tabindex', -1); - sb.setAttribute('tabindex', 0); - - if (ndisplay < 0) - sb.setAttribute('more', '') - else - sb.removeAttribute('more'); - - if (ndisplay === this.display_items) - sb.setAttribute('empty', '') - else - sb.removeAttribute('empty'); - - more.innerHTML = (ndisplay === this.display_items) ? this.placeholder : '···'; - - - sb.addEventListener('click', this.handleClick.bind(this)); - sb.addEventListener('keydown', this.handleKeydown.bind(this)); - sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this)); - sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this)); - - if ('ontouchstart' in window) { - sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); }); - window.addEventListener('touchstart', this.closeAllDropdowns); - } - else { - sb.addEventListener('mouseover', this.handleMouseover.bind(this)); - sb.addEventListener('focus', this.handleFocus.bind(this)); - - canary.addEventListener('focus', this.handleCanaryFocus.bind(this)); - - window.addEventListener('mouseover', this.setFocus); - window.addEventListener('click', this.closeAllDropdowns); - } - - if (create) { - create.addEventListener('keydown', this.handleCreateKeydown.bind(this)); - create.addEventListener('focus', this.handleCreateFocus.bind(this)); - create.addEventListener('blur', this.handleCreateBlur.bind(this)); - - var li = findParent(create, 'li'); - - li.setAttribute('unselectable', ''); - li.addEventListener('click', this.handleCreateClick.bind(this)); - } + var dl = new L.ui.Dropdown(sb, null, { name: sb.getAttribute('name') }); + return dl.bind(sb); } -cbi_dropdown_init.prototype = CBIDropdown; - function cbi_update_table(table, data, placeholder) { var target = isElem(table) ? table : document.querySelector(table); diff --git a/luci-base/htdocs/luci-static/resources/firewall.js b/luci-base/htdocs/luci-static/resources/firewall.js new file mode 100644 index 000000000..d034d6e01 --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/firewall.js @@ -0,0 +1,560 @@ +'use strict'; +'require uci'; +'require rpc'; +'require tools.prng as random'; + + +function initFirewallState() { + return uci.load('firewall'); +} + +function parseEnum(s, values) { + if (s == null) + return null; + + s = String(s).toUpperCase(); + + if (s == '') + return null; + + for (var i = 0; i < values.length; i++) + if (values[i].toUpperCase().indexOf(s) == 0) + return values[i]; + + return null; +} + +function parsePolicy(s, defaultValue) { + return parseEnum(s, ['DROP', 'REJECT', 'ACCEPT']) || (arguments.length < 2 ? null : defaultValue); +} + + +var Firewall, AbstractFirewallItem, Defaults, Zone, Forwarding, Redirect, Rule; + +function lookupZone(name) { + var z = uci.get('firewall', name); + + if (z != null && z['.type'] == 'zone') + return new Zone(z['.name']); + + var sections = uci.sections('firewall', 'zone'); + + for (var i = 0; i < sections.length; i++) { + if (sections[i].name != name) + continue; + + return new Zone(sections[i]['.name']); + } + + return null; +} + +function getColorForName(forName) { + if (forName == null) + return '#eeeeee'; + else if (forName == 'lan') + return '#90f090'; + else if (forName == 'wan') + return '#f09090'; + + random.seed(parseInt(sfh(forName), 16)); + + var r = random.get(128), + g = random.get(128), + min = 0, + max = 128; + + if ((r + g) < 128) + min = 128 - r - g; + else + max = 255 - r - g; + + var b = min + Math.floor(random.get() * (max - min)); + + return '#%02x%02x%02x'.format(0xff - r, 0xff - g, 0xff - b); +} + + +Firewall = L.Class.extend({ + getDefaults: function() { + return initFirewallState().then(function() { + return new Defaults(); + }); + }, + + newZone: function() { + return initFirewallState().then(L.bind(function() { + var name = 'newzone', + count = 1; + + while (this.getZone(name) != null) + name = 'newzone%d'.format(++count); + + return this.addZone(name); + }, this)); + }, + + addZone: function(name) { + return initFirewallState().then(L.bind(function() { + if (name == null || !/^[a-zA-Z0-9_]+$/.test(name)) + return null; + + if (this.getZone(name) != null) + return null; + + var d = new Defaults(), + z = uci.add('firewall', 'zone'); + + uci.set('firewall', z, 'name', name); + uci.set('firewall', z, 'network', ' '); + uci.set('firewall', z, 'input', d.getInput() || 'DROP'); + uci.set('firewall', z, 'output', d.getOutput() || 'DROP'); + uci.set('firewall', z, 'forward', d.getForward() || 'DROP'); + + return new Zone(z); + }, this)); + }, + + getZone: function(name) { + return initFirewallState().then(function() { + return lookupZone(name); + }); + }, + + getZones: function() { + return initFirewallState().then(function() { + var sections = uci.sections('firewall', 'zone'), + zones = []; + + for (var i = 0; i < sections.length; i++) + zones.push(new Zone(sections[i]['.name'])); + + zones.sort(function(a, b) { return a.getName() > b.getName() }); + + return zones; + }); + }, + + getZoneByNetwork: function(network) { + return initFirewallState().then(function() { + var sections = uci.sections('firewall', 'zone'); + + for (var i = 0; i < sections.length; i++) + if (L.toArray(sections[i].network || sections[i].name).indexOf(network) != -1) + return new Zone(sections[i]['.name']); + + return null; + }); + }, + + deleteZone: function(name) { + return initFirewallState().then(function() { + var section = uci.get('firewall', name), + found = false; + + if (section != null && section['.type'] == 'zone') { + found = true; + name = zone.name; + uci.remove('firewall', zone['.name']); + } + else if (name != null) { + var sections = uci.sections('firewall', 'zone'); + + for (var i = 0; i < sections.length; i++) { + if (sections[i].name != name) + continue; + + found = true; + uci.remove('firewall', sections[i]['.name']); + } + } + + if (found == true) { + sections = uci.sections('firewall'); + + for (var i = 0; i < sections.length; i++) { + if (sections[i]['.type'] != 'rule' && + sections[i]['.type'] != 'redirect' && + sections[i]['.type'] != 'forwarding') + continue; + + if (sections[i].src == name || sections[i].dest == name) + uci.remove('firewall', sections[i]['.name']); + } + } + + return found; + }); + }, + + renameZone: function(oldName, newName) { + return initFirewallState().then(L.bind(function() { + if (oldName == null || newName == null || !/^[a-zA-Z0-9_]+$/.test(newName)) + return false; + + if (lookupZone(newName) != null) + return false; + + var sections = uci.sections('firewall', 'zone'), + found = false; + + for (var i = 0; i < sections.length; i++) { + if (sections[i].name != oldName) + continue; + + if (L.toArray(sections[i].network).length == 0) + uci.set('firewall', sections[i]['.name'], 'network', oldName); + + uci.set('firewall', sections[i]['.name'], 'name', newName); + found = true; + } + + if (found == true) { + sections = uci.sections('firewall'); + + for (var i = 0; i < sections.length; i++) { + if (sections[i]['.type'] != 'rule' && + sections[i]['.type'] != 'redirect' && + sections[i]['.type'] != 'forwarding') + continue; + + if (sections[i].src == oldName) + uci.set('firewall', sections[i]['.name'], 'src', newName); + + if (sections[i].dest == oldName) + uci.set('firewall', sections[i]['.name'], 'dest', newName); + } + } + + return found; + }, this)); + }, + + deleteNetwork: function(network) { + return this.getZones().then(L.bind(function(zones) { + var rv = false; + + for (var i = 0; i < zones.length; i++) + if (zones[i].deleteNetwork(network)) + rv = true; + + return rv; + }, this)); + }, + + getColorForName: getColorForName +}); + + +AbstractFirewallItem = L.Class.extend({ + get: function(option) { + return uci.get('firewall', this.sid, option); + }, + + set: function(option, value) { + return uci.set('firewall', this.sid, option, value); + } +}); + + +Defaults = AbstractFirewallItem.extend({ + __init__: function() { + var sections = uci.sections('firewall', 'defaults'); + + for (var i = 0; i < sections.length; i++) { + this.sid = sections[i]['.name']; + break; + } + + if (this.sid == null) + this.sid = uci.add('firewall', 'defaults'); + }, + + isSynFlood: function() { + return (this.get('syn_flood') == '1'); + }, + + isDropInvalid: function() { + return (this.get('drop_invalid') == '1'); + }, + + getInput: function() { + return parsePolicy(this.get('input'), 'DROP'); + }, + + getOutput: function() { + return parsePolicy(this.get('output'), 'DROP'); + }, + + getForward: function() { + return parsePolicy(this.get('forward'), 'DROP'); + } +}); + + +Zone = AbstractFirewallItem.extend({ + __init__: function(name) { + var section = uci.get('firewall', name); + + if (section != null && section['.type'] == 'zone') { + this.sid = name; + this.data = section; + } + else if (name != null) { + var sections = uci.get('firewall', 'zone'); + + for (var i = 0; i < sections.length; i++) { + if (sections[i].name != name) + continue; + + this.sid = sections[i]['.name']; + this.data = sections[i]; + break; + } + } + }, + + isMasquerade: function() { + return (this.get('masq') == '1'); + }, + + getName: function() { + return this.get('name'); + }, + + getNetwork: function() { + return this.get('network'); + }, + + getInput: function() { + return parsePolicy(this.get('input'), (new Defaults()).getInput()); + }, + + getOutput: function() { + return parsePolicy(this.get('output'), (new Defaults()).getOutput()); + }, + + getForward: function() { + return parsePolicy(this.get('forward'), (new Defaults()).getForward()); + }, + + addNetwork: function(network) { + var section = uci.get('network', network); + + if (section == null || section['.type'] != 'interface') + return false; + + var newNetworks = this.getNetworks(); + + if (newNetworks.filter(function(net) { return net == network }).length) + return false; + + newNetworks.push(network); + this.set('network', newNetworks.join(' ')); + + return true; + }, + + deleteNetwork: function(network) { + var oldNetworks = this.getNetworks(), + newNetworks = oldNetworks.filter(function(net) { return net != network }); + + if (newNetworks.length > 0) + this.set('network', newNetworks.join(' ')); + else + this.set('network', ' '); + + return (newNetworks.length < oldNetworks.length); + }, + + getNetworks: function() { + return L.toArray(this.get('network') || this.get('name')); + }, + + clearNetworks: function() { + this.set('network', ' '); + }, + + getForwardingsBy: function(what) { + var sections = uci.sections('firewall', 'forwarding'), + forwards = []; + + for (var i = 0; i < sections.length; i++) { + if (sections[i].src == null || sections[i].dest == null) + continue; + + if (sections[i][what] != this.getName()) + continue; + + forwards.push(new Forwarding(sections[i]['.name'])); + } + + return forwards; + }, + + addForwardingTo: function(dest) { + var forwards = this.getForwardingsBy('src'), + zone = lookupZone(dest); + + if (zone == null || zone.getName() == this.getName()) + return null; + + for (var i = 0; i < forwards.length; i++) + if (forwards[i].getDestination() == zone.getName()) + return null; + + var sid = uci.add('firewall', 'forwarding'); + + uci.set('firewall', sid, 'src', this.getName()); + uci.set('firewall', sid, 'dest', zone.getName()); + + return new Forwarding(sid); + }, + + addForwardingFrom: function(src) { + var forwards = this.getForwardingsBy('dest'), + zone = lookupZone(src); + + if (zone == null || zone.getName() == this.getName()) + return null; + + for (var i = 0; i < forwards.length; i++) + if (forwards[i].getSource() == zone.getName()) + return null; + + var sid = uci.add('firewall', 'forwarding'); + + uci.set('firewall', sid, 'src', zone.getName()); + uci.set('firewall', sid, 'dest', this.getName()); + + return new Forwarding(sid); + }, + + deleteForwardingsBy: function(what) { + var sections = uci.sections('firewall', 'forwarding'), + found = false; + + for (var i = 0; i < sections.length; i++) { + if (sections[i].src == null || sections[i].dest == null) + continue; + + if (sections[i][what] != this.getName()) + continue; + + uci.remove('firewall', sections[i]['.name']); + found = true; + } + + return found; + }, + + deleteForwarding: function(forwarding) { + if (!(forwarding instanceof Forwarding)) + return false; + + var section = uci.get('firewall', forwarding.sid); + + if (!section || section['.type'] != 'forwarding') + return false; + + uci.remove('firewall', section['.name']); + + return true; + }, + + addRedirect: function(options) { + var sid = uci.add('firewall', 'redirect'); + + if (options != null && typeof(options) == 'object') + for (var key in options) + if (options.hasOwnProperty(key)) + uci.set('firewall', sid, key, options[key]); + + uci.set('firewall', sid, 'src', this.getName()); + + return new Redirect(sid); + }, + + addRule: function(options) { + var sid = uci.add('firewall', 'rule'); + + if (options != null && typeof(options) == 'object') + for (var key in options) + if (options.hasOwnProperty(key)) + uci.set('firewall', sid, key, options[key]); + + uci.set('firewall', sid, 'src', this.getName()); + + return new Redirect(sid); + }, + + getColor: function(forName) { + var name = (arguments.length > 0 ? forName : this.getName()); + + return getColorForName(name); + } +}); + + +Forwarding = AbstractFirewallItem.extend({ + __init__: function(sid) { + this.sid = sid; + }, + + getSource: function() { + return this.get('src'); + }, + + getDestination: function() { + return this.get('dest'); + }, + + getSourceZone: function() { + return lookupZone(this.getSource()); + }, + + getDestinationZone: function() { + return lookupZone(this.getDestination()); + } +}); + + +Rule = AbstractFirewallItem.extend({ + getSource: function() { + return this.get('src'); + }, + + getDestination: function() { + return this.get('dest'); + }, + + getSourceZone: function() { + return lookupZone(this.getSource()); + }, + + getDestinationZone: function() { + return lookupZone(this.getDestination()); + } +}); + + +Redirect = AbstractFirewallItem.extend({ + getSource: function() { + return this.get('src'); + }, + + getDestination: function() { + return this.get('dest'); + }, + + getSourceZone: function() { + return lookupZone(this.getSource()); + }, + + getDestinationZone: function() { + return lookupZone(this.getDestination()); + } +}); + + +return Firewall; diff --git a/luci-base/htdocs/luci-static/resources/form.js b/luci-base/htdocs/luci-static/resources/form.js new file mode 100644 index 000000000..508e2c485 --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/form.js @@ -0,0 +1,1669 @@ +'use strict'; +'require ui'; +'require uci'; + +var scope = this; + +var CBINode = Class.extend({ + __init__: function(title, description) { + this.title = title || ''; + this.description = description || ''; + this.children = []; + }, + + append: function(obj) { + this.children.push(obj); + }, + + parse: function() { + var args = arguments; + this.children.forEach(function(child) { + child.parse.apply(child, args); + }); + }, + + render: function() { + L.error('InternalError', 'Not implemented'); + }, + + loadChildren: function(/* ... */) { + var tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < this.children.length; i++) + if (!this.children[i].disable) + tasks.push(this.children[i].load.apply(this.children[i], arguments)); + + return Promise.all(tasks); + }, + + renderChildren: function(tab_name /*, ... */) { + var tasks = [], + index = 0; + + if (Array.isArray(this.children)) + for (var i = 0; i < this.children.length; i++) + if (tab_name === null || this.children[i].tab === tab_name) + if (!this.children[i].disable) + tasks.push(this.children[i].render.apply( + this.children[i], this.varargs(arguments, 1, index++))); + + return Promise.all(tasks); + }, + + stripTags: function(s) { + if (!s.match(/[<>]/)) + return s; + + var x = E('div', {}, s); + return x.textContent || x.innerText || ''; + } +}); + +var CBIMap = CBINode.extend({ + __init__: function(config /*, ... */) { + this.super('__init__', this.varargs(arguments, 1)); + + this.config = config; + this.parsechain = [ config ]; + }, + + findElements: function(/* ... */) { + var q = null; + + if (arguments.length == 1) + q = arguments[0]; + else if (arguments.length == 2) + q = '[%s="%s"]'.format(arguments[0], arguments[1]); + else + L.error('InternalError', 'Expecting one or two arguments to findElements()'); + + return this.root.querySelectorAll(q); + }, + + findElement: function(/* ... */) { + var res = this.findElements.apply(this, arguments); + return res.length ? res[0] : null; + }, + + chain: function(config) { + if (this.parsechain.indexOf(config) == -1) + this.parsechain.push(config); + }, + + section: function(cbiClass /*, ... */) { + if (!CBIAbstractSection.isSubclass(cbiClass)) + L.error('TypeError', 'Class must be a descendent of CBIAbstractSection'); + + var obj = cbiClass.instantiate(this.varargs(arguments, 1, this)); + this.append(obj); + return obj; + }, + + load: function() { + return uci.load(this.parsechain || [ this.config ]) + .then(this.loadChildren.bind(this)); + }, + + parse: function() { + var tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < this.children.length; i++) + tasks.push(this.children[i].parse()); + + return Promise.all(tasks); + }, + + save: function() { + this.checkDepends(); + + return this.parse() + .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') + return Promise.reject(); + }); + }, + + reset: function() { + return this.renderContents(); + }, + + render: function() { + return this.load().then(this.renderContents.bind(this)); + }, + + renderContents: function() { + var mapEl = this.root || (this.root = E('div', { + 'id': 'cbi-%s'.format(this.config), + 'class': 'cbi-map', + 'cbi-dependency-check': L.bind(this.checkDepends, this) + })); + + L.dom.bindClassInstance(mapEl, this); + + return this.renderChildren(null).then(L.bind(function(nodes) { + var initialRender = !mapEl.firstChild; + + L.dom.content(mapEl, null); + + if (this.title != null && this.title != '') + mapEl.appendChild(E('h2', { 'name': 'content' }, this.title)); + + if (this.description != null && this.description != '') + mapEl.appendChild(E('div', { 'class': 'cbi-map-descr' }, this.description)); + + L.dom.append(mapEl, nodes); + + if (!initialRender) { + mapEl.classList.remove('flash'); + + window.setTimeout(function() { + mapEl.classList.add('flash'); + }, 1); + } + + this.checkDepends(); + + return mapEl; + }, this)); + }, + + lookupOption: function(name, section_id) { + var id, elem, sid, inst; + + if (name.indexOf('.') > -1) + id = 'cbid.%s'.format(name); + else + id = 'cbid.%s.%s.%s'.format(this.config, section_id, name); + + elem = this.findElement('data-field', id); + sid = elem ? id.split(/\./)[2] : null; + inst = elem ? L.dom.findClassInstance(elem) : null; + + return (inst instanceof CBIAbstractValue) ? [ inst, sid ] : null; + }, + + checkDepends: function(ev, n) { + var changed = false; + + for (var i = 0, s = this.children[0]; (s = this.children[i]) != null; i++) + if (s.checkDepends(ev, n)) + changed = true; + + if (changed && (n || 0) < 10) + this.checkDepends(ev, (n || 10) + 1); + } +}); + +var CBIAbstractSection = CBINode.extend({ + __init__: function(map, sectionType /*, ... */) { + this.super('__init__', this.varargs(arguments, 2)); + + this.sectiontype = sectionType; + this.map = map; + this.config = map.config; + + this.optional = true; + this.addremove = false; + this.dynamic = false; + }, + + cfgsections: function() { + L.error('InternalError', 'Not implemented'); + }, + + filter: function(section_id) { + return true; + }, + + load: function() { + var section_ids = this.cfgsections(), + tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < section_ids.length; i++) + tasks.push(this.loadChildren(section_ids[i]) + .then(Function.prototype.bind.call(function(section_id, set_values) { + for (var i = 0; i < set_values.length; i++) + this.children[i].cfgvalue(section_id, set_values[i]); + }, this, section_ids[i]))); + + return Promise.all(tasks); + }, + + parse: function() { + var section_ids = this.cfgsections(), + tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < section_ids.length; i++) + for (var j = 0; j < this.children.length; j++) + tasks.push(this.children[j].parse(section_ids[i])); + + return Promise.all(tasks); + }, + + tab: function(name, title, description) { + if (this.tabs && this.tabs[name]) + throw 'Tab already declared'; + + var entry = { + name: name, + title: title, + description: description, + children: [] + }; + + this.tabs = this.tabs || []; + this.tabs.push(entry); + this.tabs[name] = entry; + + this.tab_names = this.tab_names || []; + this.tab_names.push(name); + }, + + option: function(cbiClass /*, ... */) { + if (!CBIAbstractValue.isSubclass(cbiClass)) + throw L.error('TypeError', 'Class must be a descendent of CBIAbstractValue'); + + var obj = cbiClass.instantiate(this.varargs(arguments, 1, this.map, this)); + this.append(obj); + return obj; + }, + + taboption: function(tabName /*, ... */) { + if (!this.tabs || !this.tabs[tabName]) + throw L.error('ReferenceError', 'Associated tab not declared'); + + var obj = this.option.apply(this, this.varargs(arguments, 1)); + obj.tab = tabName; + this.tabs[tabName].children.push(obj); + return obj; + }, + + renderUCISection: function(section_id) { + var renderTasks = []; + + if (!this.tabs) + return this.renderOptions(null, section_id); + + for (var i = 0; i < this.tab_names.length; i++) + renderTasks.push(this.renderOptions(this.tab_names[i], section_id)); + + return Promise.all(renderTasks) + .then(this.renderTabContainers.bind(this, section_id)); + }, + + renderTabContainers: function(section_id, nodes) { + var config_name = this.uciconfig || this.map.config, + containerEls = E([]); + + for (var i = 0; i < nodes.length; i++) { + var tab_name = this.tab_names[i], + tab_data = this.tabs[tab_name], + containerEl = E('div', { + 'id': 'container.%s.%s.%s'.format(config_name, section_id, tab_name), + 'data-tab': tab_name, + 'data-tab-title': tab_data.title, + 'data-tab-active': tab_name === this.selected_tab + }); + + if (tab_data.description != null && tab_data.description != '') + containerEl.appendChild( + E('div', { 'class': 'cbi-tab-descr' }, tab_data.description)); + + containerEl.appendChild(nodes[i]); + containerEls.appendChild(containerEl); + } + + return containerEls; + }, + + renderOptions: function(tab_name, section_id) { + var in_table = (this instanceof CBITableSection); + return this.renderChildren(tab_name, section_id, in_table).then(function(nodes) { + var optionEls = E([]); + for (var i = 0; i < nodes.length; i++) + optionEls.appendChild(nodes[i]); + return optionEls; + }); + }, + + checkDepends: function(ev, n) { + var changed = false, + sids = this.cfgsections(); + + for (var i = 0, sid = sids[0]; (sid = sids[i]) != null; i++) { + for (var j = 0, o = this.children[0]; (o = this.children[j]) != null; j++) { + var isActive = o.isActive(sid), + isSatisified = o.checkDepends(sid); + + if (isActive != isSatisified) { + o.setActive(sid, !isActive); + changed = true; + } + + if (!n && isActive) + o.triggerValidation(sid); + } + } + + return changed; + } +}); + + +var isEqual = function(x, y) { + if (x != null && y != null && typeof(x) != typeof(y)) + return false; + + if ((x == null && y != null) || (x != null && y == null)) + return false; + + if (Array.isArray(x)) { + if (x.length != y.length) + return false; + + for (var i = 0; i < x.length; i++) + if (!isEqual(x[i], y[i])) + return false; + } + else if (typeof(x) == 'object') { + for (var k in x) { + if (x.hasOwnProperty(k) && !y.hasOwnProperty(k)) + return false; + + if (!isEqual(x[k], y[k])) + return false; + } + + for (var k in y) + if (y.hasOwnProperty(k) && !x.hasOwnProperty(k)) + return false; + } + else if (x != y) { + return false; + } + + return true; +}; + +var CBIAbstractValue = CBINode.extend({ + __init__: function(map, section, option /*, ... */) { + this.super('__init__', this.varargs(arguments, 3)); + + this.section = section; + this.option = option; + this.map = map; + this.config = map.config; + + this.deps = []; + this.initial = {}; + this.rmempty = true; + this.default = null; + this.size = null; + this.optional = false; + }, + + depends: function(field, value) { + var deps; + + if (typeof(field) === 'string') + deps = {}, deps[field] = value; + else + deps = field; + + this.deps.push(deps); + }, + + transformDepList: function(section_id, deplist) { + var list = deplist || this.deps, + deps = []; + + if (Array.isArray(list)) { + for (var i = 0; i < list.length; i++) { + var dep = {}; + + for (var k in list[i]) { + if (list[i].hasOwnProperty(k)) { + if (k.charAt(0) === '!') + dep[k] = list[i][k]; + else if (k.indexOf('.') !== -1) + dep['cbid.%s'.format(k)] = list[i][k]; + else + dep['cbid.%s.%s.%s'.format(this.config, this.ucisection || section_id, k)] = list[i][k]; + } + } + + for (var k in dep) { + if (dep.hasOwnProperty(k)) { + deps.push(dep); + break; + } + } + } + } + + return deps; + }, + + transformChoices: function() { + if (!Array.isArray(this.keylist) || this.keylist.length == 0) + return null; + + var choices = {}; + + for (var i = 0; i < this.keylist.length; i++) + choices[this.keylist[i]] = this.vallist[i]; + + return choices; + }, + + checkDepends: function(section_id) { + var def = false; + + if (!Array.isArray(this.deps) || !this.deps.length) + return true; + + for (var i = 0; i < this.deps.length; i++) { + var istat = true, + reverse = false; + + for (var dep in this.deps[i]) { + if (dep == '!reverse') { + reverse = true; + } + else if (dep == '!default') { + def = true; + istat = false; + } + else { + var res = this.map.lookupOption(dep, section_id), + val = res ? res[0].formvalue(res[1]) : null; + + istat = (istat && isEqual(val, this.deps[i][dep])); + } + } + + if (istat ^ reverse) + return true; + } + + return def; + }, + + cbid: function(section_id) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + return 'cbid.%s.%s.%s'.format(this.map.config, section_id, this.option); + }, + + load: function(section_id) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + return uci.get( + this.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option); + }, + + cfgvalue: function(section_id, set_value) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + if (arguments.length == 2) { + this.data = this.data || {}; + this.data[section_id] = set_value; + } + + return this.data ? this.data[section_id] : null; + }, + + formvalue: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)); + return node ? L.dom.callClassMethod(node, 'getValue') : null; + }, + + textvalue: function(section_id) { + var cval = this.cfgvalue(section_id); + + if (cval == null) + cval = this.default; + + return (cval != null) ? '%h'.format(cval) : null; + }, + + validate: function(section_id, value) { + return true; + }, + + isValid: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)); + return node ? L.dom.callClassMethod(node, 'isValid') : true; + }, + + isActive: function(section_id) { + var field = this.map.findElement('data-field', this.cbid(section_id)); + return (field != null && !field.classList.contains('hidden')); + }, + + setActive: function(section_id, active) { + var field = this.map.findElement('data-field', this.cbid(section_id)); + + if (field && field.classList.contains('hidden') == active) { + field.classList[active ? 'remove' : 'add']('hidden'); + return true; + } + + return false; + }, + + triggerValidation: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)); + return node ? L.dom.callClassMethod(node, 'triggerValidation') : true; + }, + + parse: function(section_id) { + var active = this.isActive(section_id), + cval = this.cfgvalue(section_id), + fval = active ? this.formvalue(section_id) : null; + + if (active && !this.isValid(section_id)) + return Promise.reject(); + + if (fval != '' && fval != null) { + if (this.forcewrite || !isEqual(cval, fval)) + return Promise.resolve(this.write(section_id, fval)); + } + else { + if (this.rmempty || this.optional) { + return Promise.resolve(this.remove(section_id)); + } + else if (!isEqual(cval, fval)) { + console.log('This should have been catched by isValid()'); + return Promise.reject(); + } + } + + return Promise.resolve(); + }, + + write: function(section_id, formvalue) { + return uci.set( + this.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option, + formvalue); + }, + + remove: function(section_id) { + return uci.unset( + this.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option); + } +}); + +var CBITypedSection = CBIAbstractSection.extend({ + __name__: 'CBI.TypedSection', + + cfgsections: function() { + return uci.sections(this.uciconfig || this.map.config, this.sectiontype) + .map(function(s) { return s['.name'] }) + .filter(L.bind(this.filter, this)); + }, + + handleAdd: function(ev, name) { + var config_name = this.uciconfig || this.map.config; + + uci.add(config_name, this.sectiontype, name); + this.map.save(); + }, + + handleRemove: function(section_id, ev) { + var config_name = this.uciconfig || this.map.config; + + uci.remove(config_name, section_id); + this.map.save(); + }, + + renderSectionAdd: function(extra_class) { + if (!this.addremove) + return E([]); + + var createEl = E('div', { 'class': 'cbi-section-create' }), + config_name = this.uciconfig || this.map.config; + + if (extra_class != null) + createEl.classList.add(extra_class); + + if (this.anonymous) { + createEl.appendChild(E('input', { + 'type': 'submit', + 'class': 'cbi-button cbi-button-add', + 'value': _('Add'), + 'title': _('Add'), + 'click': L.bind(this.handleAdd, this) + })); + } + else { + var nameEl = E('input', { + 'type': 'text', + 'class': 'cbi-section-create-name' + }); + + L.dom.append(createEl, [ + E('div', {}, nameEl), + E('input', { + 'class': 'cbi-button cbi-button-add', + 'type': 'submit', + 'value': _('Add'), + 'title': _('Add'), + 'click': L.bind(function(ev) { + if (nameEl.classList.contains('cbi-input-invalid')) + return; + + this.handleAdd(ev, nameEl.value); + }, this) + }) + ]); + + ui.addValidator(nameEl, 'uciname', true, 'blur', 'keyup'); + } + + return createEl; + }, + + renderContents: function(cfgsections, nodes) { + var section_id = null, + config_name = this.uciconfig || this.map.config, + sectionEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype), + 'class': 'cbi-section' + }); + + if (this.title != null && this.title != '') + sectionEl.appendChild(E('legend', {}, this.title)); + + if (this.description != null && this.description != '') + sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); + + for (var i = 0; i < nodes.length; i++) { + if (this.addremove) { + sectionEl.appendChild( + E('div', { 'class': 'cbi-section-remove right' }, + E('input', { + 'type': 'submit', + '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]) + }))); + } + + if (!this.anonymous) + sectionEl.appendChild(E('h3', cfgsections[i].toUpperCase())); + + sectionEl.appendChild(E('div', { + 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]), + 'class': this.tabs + ? 'cbi-section-node cbi-section-node-tabbed' : 'cbi-section-node' + }, nodes[i])); + + if (this.tabs) + ui.tabs.initTabGroup(sectionEl.lastChild.childNodes); + } + + if (nodes.length == 0) + L.dom.append(sectionEl, [ + E('em', _('This section contains no values yet')), + E('br'), E('br') + ]); + + sectionEl.appendChild(this.renderSectionAdd()); + + L.dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + render: function() { + var cfgsections = this.cfgsections(), + renderTasks = []; + + for (var i = 0; i < cfgsections.length; i++) + renderTasks.push(this.renderUCISection(cfgsections[i])); + + return Promise.all(renderTasks).then(this.renderContents.bind(this, cfgsections)); + } +}); + +var CBITableSection = CBITypedSection.extend({ + __name__: 'CBI.TableSection', + + tab: function() { + throw 'Tabs are not supported by TableSection'; + }, + + renderContents: function(cfgsections, nodes) { + var section_id = null, + config_name = this.uciconfig || this.map.config, + max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols, + has_more = max_cols < this.children.length, + sectionEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype), + 'class': 'cbi-section cbi-tblsection' + }), + tableEl = E('div', { + 'class': 'table cbi-section-table' + }); + + if (this.title != null && this.title != '') + sectionEl.appendChild(E('h3', {}, this.title)); + + if (this.description != null && this.description != '') + sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); + + tableEl.appendChild(this.renderHeaderRows(max_cols)); + + for (var i = 0; i < nodes.length; i++) { + var sectionname = this.stripTags((typeof(this.sectiontitle) == 'function') + ? String(this.sectiontitle(cfgsections[i]) || '') : cfgsections[i]).trim(); + + var trEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]), + 'class': 'tr cbi-section-table-row', + 'data-sid': cfgsections[i], + 'draggable': this.sortable ? true : null, + 'mousedown': this.sortable ? L.bind(this.handleDragInit, this) : null, + 'dragstart': this.sortable ? L.bind(this.handleDragStart, this) : null, + 'dragover': this.sortable ? L.bind(this.handleDragOver, this) : null, + 'dragenter': this.sortable ? L.bind(this.handleDragEnter, this) : null, + 'dragleave': this.sortable ? L.bind(this.handleDragLeave, this) : null, + 'dragend': this.sortable ? L.bind(this.handleDragEnd, this) : null, + 'drop': this.sortable ? L.bind(this.handleDrop, this) : null, + 'data-title': (sectionname && (!this.anonymous || this.sectiontitle)) ? sectionname : null + }); + + if (this.extedit || this.rowcolors) + trEl.classList.add(!(tableEl.childNodes.length % 2) + ? 'cbi-rowstyle-1' : 'cbi-rowstyle-2'); + + for (var j = 0; j < max_cols && nodes[i].firstChild; j++) + trEl.appendChild(nodes[i].firstChild); + + trEl.appendChild(this.renderRowActions(cfgsections[i], has_more ? _('More…') : null)); + tableEl.appendChild(trEl); + } + + if (nodes.length == 0) + tableEl.appendChild(E('div', { 'class': 'tr cbi-section-table-row placeholder' }, + E('div', { 'class': 'td' }, + E('em', {}, _('This section contains no values yet'))))); + + sectionEl.appendChild(tableEl); + + sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create')); + + L.dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + renderHeaderRows: function(max_cols) { + var has_titles = false, + has_descriptions = false, + anon_class = (!this.anonymous || this.sectiontitle) ? 'named' : 'anonymous', + trEls = E([]); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.optional || opt.modalonly) + continue; + + has_titles = has_titles || !!opt.title; + has_descriptions = has_descriptions || !!opt.description; + } + + if (has_titles) { + var trEl = E('div', { + 'class': 'tr cbi-section-table-titles ' + anon_class, + 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null + }); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.optional || opt.modalonly) + continue; + + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell', + 'data-type': opt.__name__ + })); + + if (opt.width != null) + trEl.lastElementChild.style.width = + (typeof(opt.width) == 'number') ? opt.width+'px' : opt.width; + + if (opt.titleref) + trEl.lastElementChild.appendChild(E('a', { + 'href': opt.titleref, + 'class': 'cbi-title-ref', + 'title': this.titledesc || _('Go to relevant configuration page') + }, opt.title)); + else + L.dom.content(trEl.lastElementChild, opt.title); + } + + if (this.sortable || this.extedit || this.addremove || has_more) + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell cbi-section-actions' + })); + + trEls.appendChild(trEl); + } + + if (has_descriptions) { + var trEl = E('div', { + 'class': 'tr cbi-section-table-descr ' + anon_class + }); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.optional || opt.modalonly) + continue; + + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell', + 'data-type': opt.__name__ + }, opt.description)); + + if (opt.width != null) + trEl.lastElementChild.style.width = + (typeof(opt.width) == 'number') ? opt.width+'px' : opt.width; + } + + if (this.sortable || this.extedit || this.addremove || has_more) + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell cbi-section-actions' + })); + + trEls.appendChild(trEl); + } + + return trEls; + }, + + renderRowActions: function(section_id, more_label) { + var config_name = this.uciconfig || this.map.config; + + if (!this.sortable && !this.extedit && !this.addremove && !more_label) + return E([]); + + var tdEl = E('div', { + 'class': 'td cbi-section-table-cell nowrap cbi-section-actions' + }, E('div')); + + if (this.sortable) { + L.dom.append(tdEl.lastElementChild, [ + E('div', { + 'title': _('Drag to reorder'), + 'class': 'cbi-button drag-handle center', + 'style': 'cursor:move' + }, '☰') + ]); + } + + if (this.extedit) { + var evFn = null; + + if (typeof(this.extedit) == 'function') + evFn = L.bind(this.extedit, this); + else if (typeof(this.extedit) == 'string') + evFn = L.bind(function(sid, ev) { + location.href = this.extedit.format(sid); + }, this, section_id); + + L.dom.append(tdEl.lastElementChild, + E('input', { + 'type': 'button', + 'value': _('Edit'), + 'title': _('Edit'), + 'class': 'cbi-button cbi-button-edit', + 'click': evFn + }) + ); + } + + if (more_label) { + L.dom.append(tdEl.lastElementChild, + E('input', { + 'type': 'button', + 'value': more_label, + 'title': more_label, + 'class': 'cbi-button cbi-button-edit', + 'click': L.bind(this.renderMoreOptionsModal, this, section_id) + }) + ); + } + + if (this.addremove) { + L.dom.append(tdEl.lastElementChild, + E('input', { + 'type': 'submit', + 'value': _('Delete'), + 'title': _('Delete'), + 'class': 'cbi-button cbi-button-remove', + 'click': L.bind(function(sid, ev) { + uci.remove(config_name, sid); + this.map.save(); + }, this, section_id) + }) + ); + } + + return tdEl; + }, + + handleDragInit: function(ev) { + scope.dragState = { node: ev.target }; + }, + + handleDragStart: function(ev) { + if (!scope.dragState || !scope.dragState.node.classList.contains('drag-handle')) { + scope.dragState = null; + ev.preventDefault(); + return false; + } + + scope.dragState.node = L.dom.parent(scope.dragState.node, '.tr'); + ev.dataTransfer.setData('text', 'drag'); + ev.target.style.opacity = 0.4; + }, + + handleDragOver: function(ev) { + var n = scope.dragState.targetNode, + r = scope.dragState.rect, + t = r.top + r.height / 2; + + if (ev.clientY <= t) { + n.classList.remove('drag-over-below'); + n.classList.add('drag-over-above'); + } + else { + n.classList.remove('drag-over-above'); + n.classList.add('drag-over-below'); + } + + ev.dataTransfer.dropEffect = 'move'; + ev.preventDefault(); + return false; + }, + + handleDragEnter: function(ev) { + scope.dragState.rect = ev.currentTarget.getBoundingClientRect(); + scope.dragState.targetNode = ev.currentTarget; + }, + + handleDragLeave: function(ev) { + ev.currentTarget.classList.remove('drag-over-above'); + ev.currentTarget.classList.remove('drag-over-below'); + }, + + handleDragEnd: function(ev) { + var n = ev.target; + + n.style.opacity = ''; + n.classList.add('flash'); + n.parentNode.querySelectorAll('.drag-over-above, .drag-over-below') + .forEach(function(tr) { + tr.classList.remove('drag-over-above'); + tr.classList.remove('drag-over-below'); + }); + }, + + handleDrop: function(ev) { + var s = scope.dragState; + + if (s.node && s.targetNode) { + var config_name = this.uciconfig || this.map.config, + ref_node = s.targetNode, + after = false; + + if (ref_node.classList.contains('drag-over-below')) { + ref_node = ref_node.nextElementSibling; + after = true; + } + + var sid1 = s.node.getAttribute('data-sid'), + sid2 = s.targetNode.getAttribute('data-sid'); + + s.node.parentNode.insertBefore(s.node, ref_node); + uci.move(config_name, sid1, sid2, after); + } + + scope.dragState = null; + ev.target.style.opacity = ''; + ev.stopPropagation(); + ev.preventDefault(); + return false; + }, + + handleModalCancel: function(modalMap, ev) { + return Promise.resolve(L.ui.hideModal()); + }, + + handleModalSave: function(modalMap, ev) { + return modalMap.save() + .then(L.bind(this.map.load, this.map)) + .then(L.bind(this.map.reset, this.map)) + .then(L.ui.hideModal) + .catch(function() {}); + }, + + renderMoreOptionsModal: function(section_id, ev) { + var parent = this.map, + title = parent.title, + name = null, + m = new CBIMap(this.map.config, null, null), + s = m.section(CBINamedSection, section_id, this.sectiontype); + s.tabs = this.tabs; + s.tab_names = this.tab_names; + + if (typeof(this.sectiontitle) == 'function') + name = this.stripTags(String(this.sectiontitle(section_id) || '')); + else if (!this.anonymous) + name = section_id; + + if (name) + title += ' - ' + name; + + for (var i = 0; i < this.children.length; i++) { + var o1 = this.children[i]; + + if (o1.modalonly === false) + continue; + + var o2 = s.option(o1.constructor, o1.option, o1.title, o1.description); + + for (var k in o1) { + if (!o1.hasOwnProperty(k)) + continue; + + switch (k) { + case 'map': + case 'section': + case 'option': + case 'title': + case 'description': + continue; + + default: + o2[k] = o1[k]; + } + } + } + + //ev.target.classList.add('spinning'); + m.render().then(L.bind(function(nodes) { + //ev.target.classList.remove('spinning'); + L.ui.showModal(title, [ + nodes, + E('div', { 'class': 'right' }, [ + E('input', { + 'type': 'button', + 'class': 'btn', + 'click': L.bind(this.handleModalCancel, this, m), + 'value': _('Dismiss') + }), ' ', + E('input', { + 'type': 'button', + 'class': 'cbi-button cbi-button-positive important', + 'click': L.bind(this.handleModalSave, this, m), + 'value': _('Save') + }) + ]) + ], 'cbi-modal'); + }, this)).catch(L.error); + } +}); + +var CBIGridSection = CBITableSection.extend({ + tab: function(name, title, description) { + CBIAbstractSection.prototype.tab.call(this, name, title, description); + }, + + handleAdd: function(ev) { + var config_name = this.uciconfig || this.map.config, + section_id = uci.add(config_name, this.sectiontype); + + this.addedSection = section_id; + this.renderMoreOptionsModal(section_id); + }, + + handleModalSave: function(/* ... */) { + return this.super('handleModalSave', arguments) + .then(L.bind(function() { this.addedSection = null }, this)); + }, + + handleModalCancel: function(/* ... */) { + var config_name = this.uciconfig || this.map.config; + + if (this.addedSection != null) { + uci.remove(config_name, this.addedSection); + this.addedSection = null; + } + + return this.super('handleModalCancel', arguments); + }, + + renderUCISection: function(section_id) { + return this.renderOptions(null, section_id); + }, + + renderChildren: function(tab_name, section_id, in_table) { + var tasks = [], index = 0; + + for (var i = 0, opt; (opt = this.children[i]) != null; i++) { + if (opt.disable || opt.modalonly) + continue; + + if (opt.editable) + tasks.push(opt.render(index++, section_id, in_table)); + else + tasks.push(this.renderTextValue(section_id, opt)); + } + + return Promise.all(tasks); + }, + + renderTextValue: function(section_id, opt) { + var title = this.stripTags(opt.title).trim(), + descr = this.stripTags(opt.description).trim(), + value = opt.textvalue(section_id); + + return E('div', { + 'class': 'td cbi-value-field', + 'data-title': (title != '') ? title : opt.option, + 'data-description': (descr != '') ? descr : null, + 'data-name': opt.option, + 'data-type': opt.typename || opt.__name__ + }, (value != null) ? value : E('em', _('none'))); + }, + + renderRowActions: function(section_id) { + return this.super('renderRowActions', [ section_id, _('Edit') ]); + }, + + parse: function() { + var section_ids = this.cfgsections(), + tasks = []; + + if (Array.isArray(this.children)) { + for (var i = 0; i < section_ids.length; i++) { + for (var j = 0; j < this.children.length; j++) { + if (!this.children[j].editable || this.children[j].modalonly) + continue; + + tasks.push(this.children[j].parse(section_ids[i])); + } + } + } + + return Promise.all(tasks); + } +}); + +var CBINamedSection = CBIAbstractSection.extend({ + __name__: 'CBI.NamedSection', + __init__: function(map, section_id /*, ... */) { + this.super('__init__', this.varargs(arguments, 2, map)); + + this.section = section_id; + }, + + cfgsections: function() { + return [ this.section ]; + }, + + handleAdd: function(ev) { + var section_id = this.section, + config_name = this.uciconfig || this.map.config; + + uci.add(config_name, this.sectiontype, section_id); + this.map.save(); + }, + + handleRemove: function(ev) { + var section_id = this.section, + config_name = this.uciconfig || this.map.config; + + uci.remove(config_name, section_id); + this.map.save(); + }, + + renderContents: function(data) { + var ucidata = data[0], nodes = data[1], + section_id = this.section, + config_name = this.uciconfig || this.map.config, + sectionEl = E('div', { + 'id': ucidata ? null : 'cbi-%s-%s'.format(config_name, section_id), + 'class': 'cbi-section' + }); + + if (typeof(this.title) === 'string' && this.title !== '') + sectionEl.appendChild(E('legend', {}, this.title)); + + if (typeof(this.description) === 'string' && this.description !== '') + sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); + + if (ucidata) { + if (this.addremove) { + sectionEl.appendChild( + E('div', { 'class': 'cbi-section-remove right' }, + E('input', { + 'type': 'submit', + 'class': 'cbi-button', + 'value': _('Delete'), + 'click': L.bind(this.handleRemove, this) + }))); + } + + sectionEl.appendChild(E('div', { + 'id': 'cbi-%s-%s'.format(config_name, section_id), + 'class': this.tabs + ? 'cbi-section-node cbi-section-node-tabbed' : 'cbi-section-node' + }, nodes)); + + if (this.tabs) + ui.tabs.initTabGroup(sectionEl.lastChild.childNodes); + } + else if (this.addremove) { + sectionEl.appendChild( + E('input', { + 'type': 'submit', + 'class': 'cbi-button cbi-button-add', + 'value': _('Add'), + 'click': L.bind(this.handleAdd, this) + })); + } + + L.dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + render: function() { + var config_name = this.uciconfig || this.map.config, + section_id = this.section; + + return Promise.all([ + uci.get(config_name, section_id), + this.renderUCISection(section_id) + ]).then(this.renderContents.bind(this)); + } +}); + +var CBIValue = CBIAbstractValue.extend({ + __name__: 'CBI.Value', + + value: function(key, val) { + this.keylist = this.keylist || []; + this.keylist.push(String(key)); + + this.vallist = this.vallist || []; + this.vallist.push(String(val != null ? val : key)); + }, + + render: function(option_index, section_id, in_table) { + return Promise.resolve(this.cfgvalue(section_id)) + .then(this.renderWidget.bind(this, section_id, option_index)) + .then(this.renderFrame.bind(this, section_id, in_table, option_index)); + }, + + renderFrame: function(section_id, in_table, option_index, nodes) { + var config_name = this.uciconfig || this.map.config, + depend_list = this.transformDepList(section_id), + optionEl; + + if (in_table) { + optionEl = E('div', { + 'class': 'td cbi-value-field', + 'data-title': this.stripTags(this.title).trim(), + 'data-description': this.stripTags(this.description).trim(), + 'data-name': this.option, + 'data-type': this.typename || (this.template ? this.template.replace(/^.+\//, '') : null) || this.__name__ + }, E('div', { + 'id': 'cbi-%s-%s-%s'.format(config_name, section_id, this.option), + 'data-index': option_index, + 'data-depends': depend_list, + 'data-field': this.cbid(section_id) + })); + } + else { + optionEl = E('div', { + 'class': 'cbi-value', + 'id': 'cbi-%s-%s-%s'.format(config_name, section_id, this.option), + 'data-index': option_index, + 'data-depends': depend_list, + 'data-field': this.cbid(section_id), + 'data-name': this.option, + 'data-type': this.typename || (this.template ? this.template.replace(/^.+\//, '') : null) || this.__name__ + }); + + if (this.last_child) + optionEl.classList.add('cbi-value-last'); + + if (typeof(this.title) === 'string' && this.title !== '') { + optionEl.appendChild(E('label', { + 'class': 'cbi-value-title', + 'for': 'widget.cbid.%s.%s.%s'.format(config_name, section_id, this.option) + }, + this.titleref ? E('a', { + 'class': 'cbi-title-ref', + 'href': this.titleref, + 'title': this.titledesc || _('Go to relevant configuration page') + }, this.title) : this.title)); + + optionEl.appendChild(E('div', { 'class': 'cbi-value-field' })); + } + } + + if (nodes) + (optionEl.lastChild || optionEl).appendChild(nodes); + + if (!in_table && typeof(this.description) === 'string' && this.description !== '') + L.dom.append(optionEl.lastChild || optionEl, + E('div', { 'class': 'cbi-value-description' }, this.description)); + + if (depend_list && depend_list.length) + optionEl.classList.add('hidden'); + + optionEl.addEventListener('widget-change', + L.bind(this.map.checkDepends, this.map)); + + L.dom.bindClassInstance(optionEl, this); + + return optionEl; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + choices = this.transformChoices(), + widget; + + if (choices) { + var placeholder = (this.optional || this.rmempty) + ? E('em', _('unspecified')) : _('-- Please choose --'); + + widget = new ui.Combobox(Array.isArray(value) ? value.join(' ') : value, choices, { + id: this.cbid(section_id), + sort: this.keylist, + optional: this.optional || this.rmempty, + datatype: this.datatype, + select_placeholder: this.placeholder || placeholder, + validate: L.bind(this.validate, this, section_id) + }); + } + else { + widget = new ui.Textfield(Array.isArray(value) ? value.join(' ') : value, { + id: this.cbid(section_id), + password: this.password, + optional: this.optional || this.rmempty, + datatype: this.datatype, + placeholder: this.placeholder, + validate: L.bind(this.validate, this, section_id) + }); + } + + return widget.render(); + } +}); + +var CBIDynamicList = CBIValue.extend({ + __name__: 'CBI.DynamicList', + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + choices = this.transformChoices(), + items = L.toArray(value); + + var widget = new ui.DynamicList(items, choices, { + id: this.cbid(section_id), + sort: this.keylist, + optional: this.optional || this.rmempty, + datatype: this.datatype, + placeholder: this.placeholder, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, +}); + +var CBIListValue = CBIValue.extend({ + __name__: 'CBI.ListValue', + + __init__: function() { + this.super('__init__', arguments); + this.widget = 'select'; + this.deplist = []; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var choices = this.transformChoices(); + var widget = new ui.Select((cfgvalue != null) ? cfgvalue : this.default, choices, { + id: this.cbid(section_id), + size: this.size, + sort: this.keylist, + optional: this.rmempty || this.optional, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, +}); + +var CBIFlagValue = CBIValue.extend({ + __name__: 'CBI.FlagValue', + + __init__: function() { + this.super('__init__', arguments); + + this.enabled = '1'; + this.disabled = '0'; + this.default = this.disabled; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var widget = new ui.Checkbox((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id), + value_enabled: this.enabled, + value_disabled: this.disabled, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, + + formvalue: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)), + checked = node ? L.dom.callClassMethod(node, 'isChecked') : false; + + return checked ? this.enabled : this.disabled; + }, + + textvalue: function(section_id) { + var cval = this.cfgvalue(section_id); + + if (cval == null) + cval = this.default; + + return (cval == this.enabled) ? _('Yes') : _('No'); + }, + + parse: function(section_id) { + if (this.isActive(section_id)) { + var fval = this.formvalue(section_id); + + if (!this.isValid(section_id)) + return Promise.reject(); + + if (fval == this.default && (this.optional || this.rmempty)) + return Promise.resolve(this.remove(section_id)); + else + return Promise.resolve(this.write(section_id, fval)); + } + else { + return Promise.resolve(this.remove(section_id)); + } + }, +}); + +var CBIMultiValue = CBIDynamicList.extend({ + __name__: 'CBI.MultiValue', + + __init__: function() { + this.super('__init__', arguments); + this.placeholder = _('-- Please choose --'); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + choices = this.transformChoices(); + + var widget = new ui.Dropdown(L.toArray(value), choices, { + id: this.cbid(section_id), + sort: this.keylist, + multiple: true, + optional: this.optional || this.rmempty, + select_placeholder: this.placeholder, + display_items: this.display_size || this.size || 3, + dropdown_items: this.dropdown_size || this.size || -1, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, +}); + +var CBIDummyValue = CBIValue.extend({ + __name__: 'CBI.DummyValue', + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }), + outputEl = E('div'); + + if (this.href) + outputEl.appendChild(E('a', { 'href': this.href })); + + L.dom.append(outputEl.lastChild || outputEl, + this.rawhtml ? value : [ value ]); + + return E([ + outputEl, + hiddenEl.render() + ]); + }, +}); + +var CBIButtonValue = CBIValue.extend({ + __name__: 'CBI.ButtonValue', + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default; + + if (value !== false) + return E([ + E('input', { + 'type': 'hidden', + 'id': this.cbid(section_id) + }), + E('input', { + 'class': 'cbi-button cbi-button-%s'.format(this.inputstyle || 'button'), + 'type': 'submit', + //'id': this.cbid(section_id), + //'name': this.cbid(section_id), + 'value': this.inputtitle || this.title, + 'click': L.bind(function(ev) { + ev.target.previousElementSibling.value = ev.target.value; + this.map.save(); + }, this) + }) + ]); + else + return document.createTextNode(' - '); + } +}); + +var CBIHiddenValue = CBIValue.extend({ + __name__: 'CBI.HiddenValue', + + renderWidget: function(section_id, option_index, cfgvalue) { + var widget = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id) + }); + + return widget.render(); + } +}); + +var CBISectionValue = CBIValue.extend({ + __name__: 'CBI.ContainerValue', + __init__: function(map, section, option, cbiClass /*, ... */) { + this.super('__init__', [map, section, option]); + + if (!CBIAbstractSection.isSubclass(cbiClass)) + throw 'Sub section must be a descendent of CBIAbstractSection'; + + this.subsection = cbiClass.instantiate(this.varargs(arguments, 4, this.map)); + }, + + load: function(section_id) { + return this.subsection.load(); + }, + + parse: function(section_id) { + return this.subsection.parse(); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + return this.subsection.render(); + }, + + checkDepends: function(section_id) { + this.subsection.checkDepends(); + return this.super('checkDepends'); + }, + + write: function() {}, + remove: function() {}, + cfgvalue: function() { return null }, + formvalue: function() { return null } +}); + +return L.Class.extend({ + Map: CBIMap, + AbstractSection: CBIAbstractSection, + AbstractValue: CBIAbstractValue, + + TypedSection: CBITypedSection, + TableSection: CBITableSection, + GridSection: CBIGridSection, + NamedSection: CBINamedSection, + + Value: CBIValue, + DynamicList: CBIDynamicList, + ListValue: CBIListValue, + Flag: CBIFlagValue, + MultiValue: CBIMultiValue, + DummyValue: CBIDummyValue, + Button: CBIButtonValue, + HiddenValue: CBIHiddenValue, + SectionValue: CBISectionValue +}); diff --git a/luci-base/htdocs/luci-static/resources/luci.js b/luci-base/htdocs/luci-static/resources/luci.js index 4cb8bf4e5..6659bc51b 100644 --- a/luci-base/htdocs/luci-static/resources/luci.js +++ b/luci-base/htdocs/luci-static/resources/luci.js @@ -1,11 +1,828 @@ (function(window, document, undefined) { - var modalDiv = null, - tooltipDiv = null, - tooltipTimeout = null, - dummyElem = null, - domParser = null; + 'use strict'; + + /* Object.assign polyfill for IE */ + if (typeof Object.assign !== 'function') { + Object.defineProperty(Object, 'assign', { + value: function assign(target, varArgs) { + if (target == null) + throw new TypeError('Cannot convert undefined or null to object'); + + var to = Object(target); + + for (var index = 1; index < arguments.length; index++) + if (arguments[index] != null) + for (var nextKey in arguments[index]) + if (Object.prototype.hasOwnProperty.call(arguments[index], nextKey)) + to[nextKey] = arguments[index][nextKey]; + + return to; + }, + writable: true, + configurable: true + }); + } + + /* + * Class declaration and inheritance helper + */ + + var toCamelCase = function(s) { + return s.replace(/(?:^|[\. -])(.)/g, function(m0, m1) { return m1.toUpperCase() }); + }; + + var superContext = null, Class = Object.assign(function() {}, { + extend: function(properties) { + var props = { + __base__: { value: this.prototype }, + __name__: { value: properties.__name__ || 'anonymous' } + }; + + var ClassConstructor = function() { + if (!(this instanceof ClassConstructor)) + throw new TypeError('Constructor must not be called without "new"'); + + if (Object.getPrototypeOf(this).hasOwnProperty('__init__')) { + if (typeof(this.__init__) != 'function') + throw new TypeError('Class __init__ member is not a function'); + + this.__init__.apply(this, arguments) + } + else { + this.super('__init__', arguments); + } + }; + + for (var key in properties) + if (!props[key] && properties.hasOwnProperty(key)) + props[key] = { value: properties[key], writable: true }; + + ClassConstructor.prototype = Object.create(this.prototype, props); + ClassConstructor.prototype.constructor = ClassConstructor; + Object.assign(ClassConstructor, this); + ClassConstructor.displayName = toCamelCase(props.__name__.value + 'Class'); + + return ClassConstructor; + }, + + singleton: function(properties /*, ... */) { + return Class.extend(properties) + .instantiate(Class.prototype.varargs(arguments, 1)); + }, + + instantiate: function(args) { + return new (Function.prototype.bind.apply(this, + Class.prototype.varargs(args, 0, null)))(); + }, + + call: function(self, method) { + if (typeof(this.prototype[method]) != 'function') + throw new ReferenceError(method + ' is not defined in class'); + + return this.prototype[method].apply(self, self.varargs(arguments, 1)); + }, + + isSubclass: function(_class) { + return (_class != null && + typeof(_class) == 'function' && + _class.prototype instanceof this); + }, + + prototype: { + varargs: function(args, offset /*, ... */) { + return Array.prototype.slice.call(arguments, 2) + .concat(Array.prototype.slice.call(args, offset)); + }, + + super: function(key, callArgs) { + for (superContext = Object.getPrototypeOf(superContext || + Object.getPrototypeOf(this)); + superContext && !superContext.hasOwnProperty(key); + superContext = Object.getPrototypeOf(superContext)) { } + + if (!superContext) + return null; + + var res = superContext[key]; + + if (arguments.length > 1) { + if (typeof(res) != 'function') + throw new ReferenceError(key + ' is not a function in base class'); + + if (typeof(callArgs) != 'object') + callArgs = this.varargs(arguments, 1); + + res = res.apply(this, callArgs); + } + + superContext = null; + + return res; + }, + + toString: function() { + var s = '[' + this.constructor.displayName + ']', f = true; + for (var k in this) { + if (this.hasOwnProperty(k)) { + s += (f ? ' {\n' : '') + ' ' + k + ': ' + typeof(this[k]) + '\n'; + f = false; + } + } + return s + (f ? '' : '}'); + } + } + }); + + + /* + * HTTP Request helper + */ + + var Headers = Class.extend({ + __name__: 'LuCI.XHR.Headers', + __init__: function(xhr) { + var hdrs = this.headers = {}; + xhr.getAllResponseHeaders().split(/\r\n/).forEach(function(line) { + var m = /^([^:]+):(.*)$/.exec(line); + if (m != null) + hdrs[m[1].trim().toLowerCase()] = m[2].trim(); + }); + }, + + has: function(name) { + return this.headers.hasOwnProperty(String(name).toLowerCase()); + }, + + get: function(name) { + var key = String(name).toLowerCase(); + return this.headers.hasOwnProperty(key) ? this.headers[key] : null; + } + }); + + var Response = Class.extend({ + __name__: 'LuCI.XHR.Response', + __init__: function(xhr, url, duration, headers, content) { + this.ok = (xhr.status >= 200 && xhr.status <= 299); + this.status = xhr.status; + this.statusText = xhr.statusText; + this.headers = (headers != null) ? headers : new Headers(xhr); + this.duration = duration; + this.url = url; + this.xhr = xhr; + + if (content != null && typeof(content) == 'object') { + this.responseJSON = content; + this.responseText = null; + } + else if (content != null) { + this.responseJSON = null; + this.responseText = String(content); + } + else { + this.responseJSON = null; + this.responseText = xhr.responseText; + } + }, + + clone: function(content) { + var copy = new Response(this.xhr, this.url, this.duration, this.headers, content); + + copy.ok = this.ok; + copy.status = this.status; + copy.statusText = this.statusText; + + return copy; + }, + + json: function() { + if (this.responseJSON == null) + this.responseJSON = JSON.parse(this.responseText); + + return this.responseJSON; + }, + + text: function() { + if (this.responseText == null && this.responseJSON != null) + this.responseText = JSON.stringify(this.responseJSON); + + return this.responseText; + } + }); + + + var requestQueue = []; + + function isQueueableRequest(opt) { + if (!classes.rpc) + return false; + + if (opt.method != 'POST' || typeof(opt.content) != 'object') + return false; + + if (opt.nobatch === true) + return false; + + var rpcBaseURL = Request.expandURL(classes.rpc.getBaseURL()); + + return (rpcBaseURL != null && opt.url.indexOf(rpcBaseURL) == 0); + } + + function flushRequestQueue() { + if (!requestQueue.length) + return; + + var reqopt = Object.assign({}, requestQueue[0][0], { content: [], nobatch: true }), + batch = []; + + for (var i = 0; i < requestQueue.length; i++) { + batch[i] = requestQueue[i]; + reqopt.content[i] = batch[i][0].content; + } + + requestQueue.length = 0; + + Request.request(rpcBaseURL, reqopt).then(function(reply) { + var json = null, req = null; + + try { json = reply.json() } + catch(e) { } + + while ((req = batch.shift()) != null) + if (Array.isArray(json) && json.length) + req[2].call(reqopt, reply.clone(json.shift())); + else + req[1].call(reqopt, new Error('No related RPC reply')); + }).catch(function(error) { + var req = null; + + while ((req = batch.shift()) != null) + req[1].call(reqopt, error); + }); + } + + var Request = Class.singleton({ + __name__: 'LuCI.Request', + + interceptors: [], + + expandURL: function(url) { + if (!/^(?:[^/]+:)?\/\//.test(url)) + url = location.protocol + '//' + location.host + url; + + return url; + }, + + request: function(target, options) { + var state = { xhr: new XMLHttpRequest(), url: this.expandURL(target), start: Date.now() }, + opt = Object.assign({}, options, state), + content = null, + contenttype = null, + callback = this.handleReadyStateChange; + + return new Promise(function(resolveFn, rejectFn) { + opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn); + opt.method = String(opt.method || 'GET').toUpperCase(); + + if ('query' in opt) { + var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) { + if (opt.query[k] != null) { + var v = (typeof(opt.query[k]) == 'object') + ? JSON.stringify(opt.query[k]) + : String(opt.query[k]); + + return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v)); + } + else { + return encodeURIComponent(k); + } + }).join('&') : ''; + + if (q !== '') { + switch (opt.method) { + case 'GET': + case 'HEAD': + case 'OPTIONS': + opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q; + break; + + default: + if (content == null) { + content = q; + contenttype = 'application/x-www-form-urlencoded'; + } + } + } + } + + if (!opt.cache) + opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime(); + + if (isQueueableRequest(opt)) { + requestQueue.push([opt, rejectFn, resolveFn]); + requestAnimationFrame(flushRequestQueue); + return; + } + + if ('username' in opt && 'password' in opt) + opt.xhr.open(opt.method, opt.url, true, opt.username, opt.password); + else + opt.xhr.open(opt.method, opt.url, true); + + opt.xhr.responseType = 'text'; + + if ('overrideMimeType' in opt.xhr) + opt.xhr.overrideMimeType('application/octet-stream'); + + if ('timeout' in opt) + opt.xhr.timeout = +opt.timeout; + + if ('credentials' in opt) + opt.xhr.withCredentials = !!opt.credentials; + + if (opt.content != null) { + switch (typeof(opt.content)) { + case 'function': + content = opt.content(xhr); + break; + + case 'object': + content = JSON.stringify(opt.content); + contenttype = 'application/json'; + break; + + default: + content = String(opt.content); + } + } + + if ('headers' in opt) + for (var header in opt.headers) + if (opt.headers.hasOwnProperty(header)) { + if (header.toLowerCase() != 'content-type') + opt.xhr.setRequestHeader(header, opt.headers[header]); + else + contenttype = opt.headers[header]; + } + + if (contenttype != null) + opt.xhr.setRequestHeader('Content-Type', contenttype); + + try { + opt.xhr.send(content); + } + catch (e) { + rejectFn.call(opt, e); + } + }); + }, + + handleReadyStateChange: function(resolveFn, rejectFn, ev) { + var xhr = this.xhr; + + if (xhr.readyState !== 4) + return; + + if (xhr.status === 0 && xhr.statusText === '') { + rejectFn.call(this, new Error('XHR request aborted by browser')); + } + else { + var response = new Response( + xhr, xhr.responseURL || this.url, Date.now() - this.start); + + Promise.all(Request.interceptors.map(function(fn) { return fn(response) })) + .then(resolveFn.bind(this, response)) + .catch(rejectFn.bind(this)); + } + }, + + get: function(url, options) { + return this.request(url, Object.assign({ method: 'GET' }, options)); + }, + + post: function(url, data, options) { + return this.request(url, Object.assign({ method: 'POST', content: data }, options)); + }, + + addInterceptor: function(interceptorFn) { + if (typeof(interceptorFn) == 'function') + this.interceptors.push(interceptorFn); + return interceptorFn; + }, + + removeInterceptor: function(interceptorFn) { + var oldlen = this.interceptors.length, i = oldlen; + while (i--) + if (this.interceptors[i] === interceptorFn) + this.interceptors.splice(i, 1); + return (this.interceptors.length < oldlen); + }, + + poll: { + add: function(interval, url, options, callback) { + if (isNaN(interval) || interval <= 0) + throw new TypeError('Invalid poll interval'); + + var ival = interval >>> 0, + opts = Object.assign({}, options, { timeout: ival * 1000 - 5 }); + + return Poll.add(function() { + return Request.request(url, options).then(function(res) { + if (!Poll.active()) + return; + + try { + callback(res, res.json(), res.duration); + } + catch (err) { + callback(res, null, res.duration); + } + }); + }, ival); + }, + + remove: function(entry) { return Poll.remove(entry) }, + start: function() { return Poll.start() }, + stop: function() { return Poll.stop() }, + active: function() { return Poll.active() } + } + }); + + var Poll = Class.singleton({ + __name__: 'LuCI.Poll', + + queue: [], + + add: function(fn, interval) { + if (interval == null || interval <= 0) + interval = window.L ? window.L.env.pollinterval : null; + + if (isNaN(interval) || typeof(fn) != 'function') + throw new TypeError('Invalid argument to LuCI.Poll.add()'); + + for (var i = 0; i < this.queue.length; i++) + if (this.queue[i].fn === fn) + return false; + + var e = { + r: true, + i: interval >>> 0, + fn: fn + }; + + this.queue.push(e); + + if (this.tick != null && !this.active()) + this.start(); + + return true; + }, + + remove: function(entry) { + if (typeof(fn) != 'function') + throw new TypeError('Invalid argument to LuCI.Poll.remove()'); + + var len = this.queue.length; + + for (var i = len; i > 0; i--) + if (this.queue[i-1].fn === fn) + this.queue.splice(i-1, 1); + + if (!this.queue.length && this.stop()) + this.tick = 0; + + return (this.queue.length != len); + }, + + start: function() { + if (this.active()) + return false; + + this.tick = 0; + + if (this.queue.length) { + this.timer = window.setInterval(this.step, 1000); + this.step(); + document.dispatchEvent(new CustomEvent('poll-start')); + } + + return true; + }, + + stop: function() { + if (!this.active()) + return false; + + document.dispatchEvent(new CustomEvent('poll-stop')); + window.clearInterval(this.timer); + delete this.timer; + delete this.tick; + return true; + }, + + step: function() { + for (var i = 0, e = null; (e = Poll.queue[i]) != null; i++) { + if ((Poll.tick % e.i) != 0) + continue; + + if (!e.r) + continue; + + e.r = false; + + Promise.resolve(e.fn()).finally((function() { this.r = true }).bind(e)); + } + + Poll.tick = (Poll.tick + 1) % Math.pow(2, 32); + }, + + active: function() { + return (this.timer != null); + } + }); + + + var dummyElem = null, + domParser = null, + originalCBIInit = null, + rpcBaseURL = null, + classes = {}; + + var LuCI = Class.extend({ + __name__: 'LuCI', + __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) + this.error('InternalError', 'Cannot find url of luci.js'); + + Object.assign(this.env, env); + + document.addEventListener('poll-start', function(ev) { + document.querySelectorAll('[id^="xhr_poll_status"]').forEach(function(e) { + e.style.display = (e.id == 'xhr_poll_status_off') ? 'none' : ''; + }); + }); + + document.addEventListener('poll-stop', function(ev) { + document.querySelectorAll('[id^="xhr_poll_status"]').forEach(function(e) { + e.style.display = (e.id == 'xhr_poll_status_on') ? 'none' : ''; + }); + }); + + var domReady = new Promise(function(resolveFn, rejectFn) { + document.addEventListener('DOMContentLoaded', resolveFn); + }); + + Promise.all([ + domReady, + this.require('ui'), + this.require('rpc'), + this.require('form'), + this.probeRPCBaseURL() + ]).then(this.setupDOM.bind(this)).catch(this.error); + + originalCBIInit = window.cbi_init; + window.cbi_init = function() {}; + }, + + raise: function(type, fmt /*, ...*/) { + var e = null, + msg = fmt ? String.prototype.format.apply(fmt, this.varargs(arguments, 2)) : null, + stack = null; + + if (type instanceof Error) { + e = type; + stack = (e.stack || '').split(/\n/); + + if (msg) + e.message = msg + ': ' + e.message; + } + else { + e = new (window[type || 'Error'] || Error)(msg || 'Unspecified error'); + e.name = type || 'Error'; + } + + if (window.console && console.debug) + console.debug(e); + + throw e; + }, + + error: function(type, fmt /*, ...*/) { + try { + 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 (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)); + + throw e; + } + }, + + bind: function(fn, self /*, ... */) { + return Function.prototype.bind.apply(fn, this.varargs(arguments, 2, self)); + }, + + /* Class require */ + require: function(name, from) { + var L = this, url = null, from = from || []; + + /* Class already loaded */ + if (classes[name] != null) { + /* Circular dependency */ + if (from.indexOf(name) != -1) + L.raise('DependencyError', + 'Circular dependency: class "%s" depends on "%s"', + name, from.join('" which depends on "')); + + return classes[name]; + } + + url = '%s/%s.js'.format(L.env.base_url, name.replace(/\./g, '/')); + from = [ name ].concat(from); + + var compileClass = function(res) { + if (!res.ok) + L.raise('NetworkError', + 'HTTP error %d while loading class file "%s"', res.status, url); + + var source = res.text(), + requirematch = /^require[ \t]+(\S+)(?:[ \t]+as[ \t]+([a-zA-Z_]\S*))?$/, + strictmatch = /^use[ \t]+strict$/, + depends = [], + args = ''; + + /* find require statements in source */ + for (var i = 0, off = -1, quote = -1, esc = false; i < source.length; i++) { + var chr = source.charCodeAt(i); + + if (esc) { + esc = false; + } + else if (chr == 92) { + esc = true; + } + else if (chr == quote) { + var s = source.substring(off, i), + m = requirematch.exec(s); + + if (m) { + var dep = m[1], as = m[2] || dep.replace(/[^a-zA-Z0-9_]/g, '_'); + depends.push(L.require(dep, from)); + args += ', ' + as; + } + else if (!strictmatch.exec(s)) { + break; + } + + off = -1; + quote = -1; + } + else if (quote == -1 && (chr == 34 || chr == 39)) { + off = i + 1; + quote = chr; + } + } + + /* load dependencies and instantiate class */ + return Promise.all(depends).then(function(instances) { + var _factory, _class; + + try { + _factory = eval( + '(function(window, document, L%s) { %s })\n\n//# sourceURL=%s\n' + .format(args, source, res.url)); + } + catch (error) { + L.raise('SyntaxError', '%s\n in %s:%s', + error.message, res.url, error.lineNumber || '?'); + } + + _factory.displayName = toCamelCase(name + 'ClassFactory'); + _class = _factory.apply(_factory, [window, document, L].concat(instances)); + + if (!Class.isSubclass(_class)) + L.error('TypeError', '"%s" factory yields invalid constructor', name); + + if (_class.displayName == 'AnonymousClass') + _class.displayName = toCamelCase(name + 'Class'); + + var ptr = Object.getPrototypeOf(L), + parts = name.split(/\./), + instance = new _class(); + + for (var i = 0; ptr && i < parts.length - 1; i++) + ptr = ptr[parts[i]]; + + if (ptr) + ptr[parts[i]] = instance; + + classes[name] = instance; + + return instance; + }); + }; + + /* Request class file */ + classes[name] = Request.get(url, { cache: true }).then(compileClass); + + return classes[name]; + }, + + /* DOM setup */ + probeRPCBaseURL: function() { + if (rpcBaseURL == null) { + try { + rpcBaseURL = window.sessionStorage.getItem('rpcBaseURL'); + } + catch (e) { } + } + + if (rpcBaseURL == null) { + var rpcFallbackURL = this.url('admin/ubus'); + + rpcBaseURL = Request.get('/ubus/').then(function(res) { + return (rpcBaseURL = (res.status == 400) ? '/ubus/' : rpcFallbackURL); + }, function() { + return (rpcBaseURL = rpcFallbackURL); + }).then(function(url) { + try { + window.sessionStorage.setItem('rpcBaseURL', url); + } + catch (e) { } + + return url; + }); + } + + return Promise.resolve(rpcBaseURL); + }, + + setupDOM: function(res) { + var domEv = res[0], + uiClass = res[1], + rpcClass = res[2], + formClass = res[3], + rpcBaseURL = res[4]; + + rpcClass.setBaseURL(rpcBaseURL); + + Request.addInterceptor(function(res) { + if (res.status != 403 || res.headers.get('X-LuCI-Login-Required') != 'yes') + 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…'))) + ]); + + throw 'Session expired'; + }); + + originalCBIInit(); + + Poll.start(); + + document.dispatchEvent(new CustomEvent('luci-loaded')); + }, + + env: {}, - LuCI.prototype = { /* URL construction helpers */ path: function(prefix, parts) { var url = [ prefix || '' ]; @@ -33,479 +850,455 @@ }, + /* Data helpers */ + isObject: function(val) { + return (val != null && typeof(val) == 'object'); + }, + + sortedKeys: function(obj, key, sortmode) { + if (obj == null || typeof(obj) != 'object') + return []; + + return Object.keys(obj).map(function(e) { + var v = (key != null) ? obj[e][key] : e; + + switch (sortmode) { + case 'addr': + v = (v != null) ? v.replace(/(?:^|[.:])([0-9a-fA-F]{1,4})/g, + function(m0, m1) { return ('000' + m1.toLowerCase()).substr(-4) }) : null; + break; + + case 'num': + v = (v != null) ? +v : null; + break; + } + + return [ e, v ]; + }).filter(function(e) { + return (e[1] != null); + }).sort(function(a, b) { + return (a[1] > b[1]); + }).map(function(e) { + return e[0]; + }); + }, + + toArray: function(val) { + if (val == null) + return []; + else if (Array.isArray(val)) + return val; + else if (typeof(val) == 'object') + return [ val ]; + + var s = String(val).trim(); + + if (s == '') + return []; + + return s.split(/\s+/); + }, + + /* HTTP resource fetching */ get: function(url, args, cb) { - return this.poll(0, url, args, cb, false); + return this.poll(null, url, args, cb, false); }, post: function(url, args, cb) { - return this.poll(0, url, args, cb, true); + return this.poll(null, url, args, cb, true); }, poll: function(interval, url, args, cb, post) { - var data = post ? { token: this.env.token } : null; + if (interval !== null && interval <= 0) + interval = this.env.pollinterval; + + var data = post ? { token: this.env.token } : null, + method = post ? 'POST' : 'GET'; if (!/^(?:\/|\S+:\/\/)/.test(url)) url = this.url(url); - if (typeof(args) === 'object' && args !== null) { - data = data || {}; + if (args != null) + data = Object.assign(data || {}, args); - for (var key in args) - if (args.hasOwnProperty(key)) - switch (typeof(args[key])) { - case 'string': - case 'number': - case 'boolean': - data[key] = args[key]; - break; - - case 'object': - data[key] = JSON.stringify(args[key]); - break; - } - } - - if (interval > 0) - return XHR.poll(interval, url, data, cb, post); - else if (post) - return XHR.post(url, data, cb); + if (interval !== null) + return Request.poll.add(interval, url, { method: method, query: data }, cb); else - return XHR.get(url, data, cb); + return Request.request(url, { method: method, query: data }) + .then(function(res) { + var json = null; + if (/^application\/json\b/.test(res.headers.get('Content-Type'))) + try { json = res.json() } catch(e) {} + cb(res.xhr, json, res.duration); + }); }, - stop: function(entry) { XHR.stop(entry) }, - halt: function() { XHR.halt() }, - run: function() { XHR.run() }, + stop: function(entry) { return Poll.remove(entry) }, + halt: function() { return Poll.stop() }, + run: function() { return Poll.start() }, + /* DOM manipulation */ + dom: Class.singleton({ + __name__: 'LuCI.DOM', - /* Modal dialog */ - showModal: function(title, children) { - var dlg = modalDiv.firstElementChild; + elem: function(e) { + return (e != null && typeof(e) == 'object' && 'nodeType' in e); + }, - dlg.setAttribute('class', 'modal'); + parse: function(s) { + var elem; - this.dom.content(dlg, this.dom.create('h4', {}, title)); - this.dom.append(dlg, children); - - document.body.classList.add('modal-overlay-active'); - - return dlg; - }, - - hideModal: function() { - document.body.classList.remove('modal-overlay-active'); - }, - - - /* Tooltip */ - showTooltip: function(ev) { - var target = findParent(ev.target, '[data-tooltip]'); - - if (!target) - return; - - if (tooltipTimeout !== null) { - window.clearTimeout(tooltipTimeout); - tooltipTimeout = null; - } - - var rect = target.getBoundingClientRect(), - x = rect.left + window.pageXOffset, - y = rect.top + rect.height + window.pageYOffset; - - tooltipDiv.className = 'cbi-tooltip'; - tooltipDiv.innerHTML = '▲ '; - tooltipDiv.firstChild.data += target.getAttribute('data-tooltip'); - - if (target.hasAttribute('data-tooltip-style')) - tooltipDiv.classList.add(target.getAttribute('data-tooltip-style')); - - if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) { - y -= (tooltipDiv.offsetHeight + target.offsetHeight); - tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2); - } - - tooltipDiv.style.top = y + 'px'; - tooltipDiv.style.left = x + 'px'; - tooltipDiv.style.opacity = 1; - - tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', { - bubbles: true, - detail: { target: target } - })); - }, - - hideTooltip: function(ev) { - if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv || - tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget)) - return; - - if (tooltipTimeout !== null) { - window.clearTimeout(tooltipTimeout); - tooltipTimeout = null; - } - - tooltipDiv.style.opacity = 0; - tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250); - - tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true })); - }, - - - /* Widget helper */ - itemlist: function(node, items, separators) { - var children = []; - - if (!Array.isArray(separators)) - separators = [ separators || E('br') ]; - - for (var i = 0; i < items.length; i += 2) { - if (items[i+1] !== null && items[i+1] !== undefined) { - var sep = separators[(i/2) % separators.length], - cld = []; - - children.push(E('span', { class: 'nowrap' }, [ - items[i] ? E('strong', items[i] + ': ') : '', - items[i+1] - ])); - - if ((i+2) < items.length) - children.push(this.dom.elem(sep) ? sep.cloneNode(true) : sep); + try { + domParser = domParser || new DOMParser(); + elem = domParser.parseFromString(s, 'text/html').body.firstChild; } - } + catch(e) {} - this.dom.content(node, children); - - return node; - } - }; - - /* Tabs */ - LuCI.prototype.tabs = { - init: function() { - var groups = [], prevGroup = null, currGroup = null; - - document.querySelectorAll('[data-tab]').forEach(function(tab) { - var parent = tab.parentNode; - - if (!parent.hasAttribute('data-tab-group')) - parent.setAttribute('data-tab-group', groups.length); - - currGroup = +parent.getAttribute('data-tab-group'); - - if (currGroup !== prevGroup) { - prevGroup = currGroup; - - if (!groups[currGroup]) - groups[currGroup] = []; + if (!elem) { + try { + dummyElem = dummyElem || document.createElement('div'); + dummyElem.innerHTML = s; + elem = dummyElem.firstChild; + } + catch (e) {} } - groups[currGroup].push(tab); - }); + return elem || null; + }, - for (var i = 0; i < groups.length; i++) - this.initTabGroup(groups[i]); + matches: function(node, selector) { + var m = this.elem(node) ? node.matches || node.msMatchesSelector : null; + return m ? m.call(node, selector) : false; + }, - document.addEventListener('dependency-update', this.updateTabs.bind(this)); + parent: function(node, selector) { + if (this.elem(node) && node.closest) + return node.closest(selector); - this.updateTabs(); + while (this.elem(node)) + if (this.matches(node, selector)) + return node; + else + node = node.parentNode; - if (!groups.length) - this.setActiveTabId(-1, -1); - }, + return null; + }, - initTabGroup: function(panes) { - if (!Array.isArray(panes) || panes.length === 0) - return; + append: function(node, children) { + if (!this.elem(node)) + return null; - var menu = E('ul', { 'class': 'cbi-tabmenu' }), - group = panes[0].parentNode, - groupId = +group.getAttribute('data-tab-group'), - selected = null; + if (Array.isArray(children)) { + for (var i = 0; i < children.length; i++) + if (this.elem(children[i])) + node.appendChild(children[i]); + else if (children !== null && children !== undefined) + node.appendChild(document.createTextNode('' + children[i])); - for (var i = 0, pane; pane = panes[i]; i++) { - var name = pane.getAttribute('data-tab'), - title = pane.getAttribute('data-tab-title'), - active = pane.getAttribute('data-tab-active') === 'true'; - - menu.appendChild(E('li', { - 'class': active ? 'cbi-tab' : 'cbi-tab-disabled', - 'data-tab': name - }, E('a', { - 'href': '#', - 'click': this.switchTab.bind(this) - }, title))); - - if (active) - selected = i; - } - - group.parentNode.insertBefore(menu, group); - - if (selected === null) { - selected = this.getActiveTabId(groupId); - - if (selected < 0 || selected >= panes.length) - selected = 0; - - menu.childNodes[selected].classList.add('cbi-tab'); - menu.childNodes[selected].classList.remove('cbi-tab-disabled'); - panes[selected].setAttribute('data-tab-active', 'true'); - - this.setActiveTabId(groupId, selected); - } - }, - - getActiveTabState: function() { - var page = document.body.getAttribute('data-page'); - - try { - var val = JSON.parse(window.sessionStorage.getItem('tab')); - if (val.page === page && Array.isArray(val.groups)) - return val; - } - catch(e) {} - - window.sessionStorage.removeItem('tab'); - return { page: page, groups: [] }; - }, - - getActiveTabId: function(groupId) { - return +this.getActiveTabState().groups[groupId] || 0; - }, - - setActiveTabId: function(groupId, tabIndex) { - try { - var state = this.getActiveTabState(); - state.groups[groupId] = tabIndex; - - window.sessionStorage.setItem('tab', JSON.stringify(state)); - } - catch (e) { return false; } - - return true; - }, - - updateTabs: function(ev) { - document.querySelectorAll('[data-tab-title]').forEach(function(pane) { - var menu = pane.parentNode.previousElementSibling, - tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))), - n_errors = pane.querySelectorAll('.cbi-input-invalid').length; - - if (!pane.firstElementChild) { - tab.style.display = 'none'; - tab.classList.remove('flash'); + return node.lastChild; } - else if (tab.style.display === 'none') { - tab.style.display = ''; - requestAnimationFrame(function() { tab.classList.add('flash') }); + else if (typeof(children) === 'function') { + return this.append(node, children(node)); + } + else if (this.elem(children)) { + return node.appendChild(children); + } + else if (children !== null && children !== undefined) { + node.innerHTML = '' + children; + return node.lastChild; } - if (n_errors) { - tab.setAttribute('data-errors', n_errors); - tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors)); - tab.setAttribute('data-tooltip-style', 'error'); + return null; + }, + + content: function(node, children) { + if (!this.elem(node)) + return null; + + var dataNodes = node.querySelectorAll('[data-idref]'); + + for (var i = 0; i < dataNodes.length; i++) + delete this.registry[dataNodes[i].getAttribute('data-idref')]; + + while (node.firstChild) + node.removeChild(node.firstChild); + + return this.append(node, children); + }, + + attr: function(node, key, val) { + if (!this.elem(node)) + return null; + + var attr = null; + + if (typeof(key) === 'object' && key !== null) + attr = key; + else if (typeof(key) === 'string') + attr = {}, attr[key] = val; + + for (key in attr) { + if (!attr.hasOwnProperty(key) || attr[key] == null) + continue; + + switch (typeof(attr[key])) { + case 'function': + node.addEventListener(key, attr[key]); + break; + + case 'object': + node.setAttribute(key, JSON.stringify(attr[key])); + break; + + default: + node.setAttribute(key, attr[key]); + } + } + }, + + create: function() { + var html = arguments[0], + attr = arguments[1], + data = arguments[2], + elem; + + if (!(attr instanceof Object) || Array.isArray(attr)) + data = attr, attr = null; + + if (Array.isArray(html)) { + elem = document.createDocumentFragment(); + for (var i = 0; i < html.length; i++) + elem.appendChild(this.create(html[i])); + } + else if (this.elem(html)) { + elem = html; + } + else if (html.charCodeAt(0) === 60) { + elem = this.parse(html); } else { - tab.removeAttribute('data-errors'); - tab.removeAttribute('data-tooltip'); + elem = document.createElement(html); } - }); - }, - switchTab: function(ev) { - var tab = ev.target.parentNode, - name = tab.getAttribute('data-tab'), - menu = tab.parentNode, - group = menu.nextElementSibling, - groupId = +group.getAttribute('data-tab-group'), - index = 0; + if (!elem) + return null; - ev.preventDefault(); + this.attr(elem, attr); + this.append(elem, data); - if (!tab.classList.contains('cbi-tab-disabled')) - return; + return elem; + }, - menu.querySelectorAll('[data-tab]').forEach(function(tab) { - tab.classList.remove('cbi-tab'); - tab.classList.remove('cbi-tab-disabled'); - tab.classList.add( - tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled'); - }); + registry: {}, - group.childNodes.forEach(function(pane) { - if (L.dom.matches(pane, '[data-tab]')) { - if (pane.getAttribute('data-tab') === name) { - pane.setAttribute('data-tab-active', 'true'); - L.tabs.setActiveTabId(groupId, index); - } - else { - pane.setAttribute('data-tab-active', 'false'); + data: function(node, key, val) { + var id = node.getAttribute('data-idref'); + + /* clear all data */ + if (arguments.length > 1 && key == null) { + if (id != null) { + node.removeAttribute('data-idref'); + val = this.registry[id] + delete this.registry[id]; + return val; } - index++; + return null; } - }); - } - }; - /* DOM manipulation */ - LuCI.prototype.dom = { - elem: function(e) { - return (typeof(e) === 'object' && e !== null && 'nodeType' in e); - }, + /* clear a key */ + else if (arguments.length > 2 && key != null && val == null) { + if (id != null) { + val = this.registry[id][key]; + delete this.registry[id][key]; + return val; + } - parse: function(s) { - var elem; - - try { - domParser = domParser || new DOMParser(); - elem = domParser.parseFromString(s, 'text/html').body.firstChild; - } - catch(e) {} - - if (!elem) { - try { - dummyElem = dummyElem || document.createElement('div'); - dummyElem.innerHTML = s; - elem = dummyElem.firstChild; + return null; } - catch (e) {} - } - return elem || null; - }, + /* set a key */ + else if (arguments.length > 2 && key != null && val != null) { + if (id == null) { + do { id = Math.floor(Math.random() * 0xffffffff).toString(16) } + while (this.registry.hasOwnProperty(id)); - matches: function(node, selector) { - var m = this.elem(node) ? node.matches || node.msMatchesSelector : null; - return m ? m.call(node, selector) : false; - }, + node.setAttribute('data-idref', id); + this.registry[id] = {}; + } - parent: function(node, selector) { - if (this.elem(node) && node.closest) - return node.closest(selector); + return (this.registry[id][key] = val); + } - while (this.elem(node)) - if (this.matches(node, selector)) - return node; - else + /* get all data */ + else if (arguments.length == 1) { + if (id != null) + return this.registry[id]; + + return null; + } + + /* get a key */ + else if (arguments.length == 2) { + if (id != null) + return this.registry[id][key]; + } + + return null; + }, + + bindClassInstance: function(node, inst) { + if (!(inst instanceof Class)) + L.error('TypeError', 'Argument must be a class instance'); + + return this.data(node, '_class', inst); + }, + + findClassInstance: function(node) { + var inst = null; + + do { + inst = this.data(node, '_class'); node = node.parentNode; - - return null; - }, - - append: function(node, children) { - if (!this.elem(node)) - return null; - - if (Array.isArray(children)) { - for (var i = 0; i < children.length; i++) - if (this.elem(children[i])) - node.appendChild(children[i]); - else if (children !== null && children !== undefined) - node.appendChild(document.createTextNode('' + children[i])); - - return node.lastChild; - } - else if (typeof(children) === 'function') { - return this.append(node, children(node)); - } - else if (this.elem(children)) { - return node.appendChild(children); - } - else if (children !== null && children !== undefined) { - node.innerHTML = '' + children; - return node.lastChild; - } - - return null; - }, - - content: function(node, children) { - if (!this.elem(node)) - return null; - - while (node.firstChild) - node.removeChild(node.firstChild); - - return this.append(node, children); - }, - - attr: function(node, key, val) { - if (!this.elem(node)) - return null; - - var attr = null; - - if (typeof(key) === 'object' && key !== null) - attr = key; - else if (typeof(key) === 'string') - attr = {}, attr[key] = val; - - for (key in attr) { - if (!attr.hasOwnProperty(key) || attr[key] === null || attr[key] === undefined) - continue; - - switch (typeof(attr[key])) { - case 'function': - node.addEventListener(key, attr[key]); - break; - - case 'object': - node.setAttribute(key, JSON.stringify(attr[key])); - break; - - default: - node.setAttribute(key, attr[key]); } + while (!(inst instanceof Class) && node != null); + + return inst; + }, + + callClassMethod: function(node, method /*, ... */) { + var inst = this.findClassInstance(node); + + if (inst == null || typeof(inst[method]) != 'function') + return null; + + return inst[method].apply(inst, inst.varargs(arguments, 2)); } + }), + + Poll: Poll, + Class: Class, + Request: Request, + + view: Class.extend({ + __name__: 'LuCI.View', + + __init__: function() { + var vp = document.getElementById('view'); + + L.dom.content(vp, E('div', { 'class': 'spinning' }, _('Loading view…'))); + + return Promise.resolve(this.load()) + .then(L.bind(this.render, this)) + .then(L.bind(function(nodes) { + var vp = document.getElementById('view'); + + L.dom.content(vp, nodes); + L.dom.append(vp, this.addFooter()); + }, this)).catch(L.error); + }, + + load: function() {}, + render: function() {}, + + handleSave: function(ev) { + var tasks = []; + + document.getElementById('maincontent') + .querySelectorAll('.cbi-map').forEach(function(map) { + tasks.push(L.dom.callClassMethod(map, 'save')); + }); + + return Promise.all(tasks); + }, + + handleSaveApply: function(ev) { + return this.handleSave(ev).then(function() { + L.ui.changes.apply(true); + }); + }, + + handleReset: function(ev) { + var tasks = []; + + document.getElementById('maincontent') + .querySelectorAll('.cbi-map').forEach(function(map) { + tasks.push(L.dom.callClassMethod(map, 'reset')); + }); + + return Promise.all(tasks); + }, + + addFooter: function() { + var footer = E([]), + mc = document.getElementById('maincontent'); + + if (mc.querySelector('.cbi-map')) { + footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ + E('input', { + 'class': 'cbi-button cbi-button-apply', + 'type': 'button', + 'value': _('Save & Apply'), + 'click': L.bind(this.handleSaveApply, this) + }), ' ', + E('input', { + 'class': 'cbi-button cbi-button-save', + 'type': 'submit', + 'value': _('Save'), + 'click': L.bind(this.handleSave, this) + }), ' ', + E('input', { + 'class': 'cbi-button cbi-button-reset', + 'type': 'button', + 'value': _('Reset'), + 'click': L.bind(this.handleReset, this) + }) + ])); + } + + return footer; + } + }) + }); + + var XHR = Class.extend({ + __name__: 'LuCI.XHR', + __init__: function() { + if (window.console && console.debug) + console.debug('Direct use XHR() is deprecated, please use L.Request instead'); }, - create: function() { - var html = arguments[0], - attr = (arguments[1] instanceof Object && !Array.isArray(arguments[1])) ? arguments[1] : null, - data = attr ? arguments[2] : arguments[1], - elem; + _response: function(cb, res, json, duration) { + if (this.active) + cb(res, json, duration); + delete this.active; + }, - if (this.elem(html)) - elem = html; - else if (html.charCodeAt(0) === 60) - elem = this.parse(html); - else - elem = document.createElement(html); + get: function(url, data, callback, timeout) { + this.active = true; + L.get(url, data, this._response.bind(this, callback), timeout); + }, - if (!elem) - return null; + post: function(url, data, callback, timeout) { + this.active = true; + L.post(url, data, this._response.bind(this, callback), timeout); + }, - this.attr(elem, attr); - this.append(elem, data); + cancel: function() { delete this.active }, + busy: function() { return (this.active === true) }, + abort: function() {}, + send_form: function() { L.error('InternalError', 'Not implemented') }, + }); - return elem; - } - }; - - /* Setup */ - LuCI.prototype.setupDOM = function(ev) { - this.tabs.init(); - }; - - function LuCI(env) { - this.env = env; - - modalDiv = document.body.appendChild( - this.dom.create('div', { id: 'modal_overlay' }, - this.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true }))); - - tooltipDiv = document.body.appendChild(this.dom.create('div', { class: 'cbi-tooltip' })); - - document.addEventListener('mouseover', this.showTooltip.bind(this), true); - document.addEventListener('mouseout', this.hideTooltip.bind(this), true); - document.addEventListener('focus', this.showTooltip.bind(this), true); - document.addEventListener('blur', this.hideTooltip.bind(this), true); - - document.addEventListener('DOMContentLoaded', this.setupDOM.bind(this)); - } + XHR.get = function() { return window.L.get.apply(window.L, arguments) }; + XHR.post = function() { return window.L.post.apply(window.L, arguments) }; + XHR.poll = function() { return window.L.poll.apply(window.L, arguments) }; + XHR.stop = Request.poll.remove.bind(Request.poll); + XHR.halt = Request.poll.stop.bind(Request.poll); + XHR.run = Request.poll.start.bind(Request.poll); + XHR.running = Request.poll.active.bind(Request.poll); + window.XHR = XHR; window.LuCI = LuCI; })(window, document); diff --git a/luci-base/htdocs/luci-static/resources/network.js b/luci-base/htdocs/luci-static/resources/network.js new file mode 100644 index 000000000..d3d9a1cf5 --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/network.js @@ -0,0 +1,2085 @@ +'use strict'; +'require uci'; +'require rpc'; +'require validation'; + +var proto_errors = { + CONNECT_FAILED: _('Connection attempt failed'), + INVALID_ADDRESS: _('IP address in invalid'), + INVALID_GATEWAY: _('Gateway address is invalid'), + INVALID_LOCAL_ADDRESS: _('Local IP address is invalid'), + MISSING_ADDRESS: _('IP address is missing'), + MISSING_PEER_ADDRESS: _('Peer address is missing'), + NO_DEVICE: _('Network device is not present'), + NO_IFACE: _('Unable to determine device name'), + NO_IFNAME: _('Unable to determine device name'), + NO_WAN_ADDRESS: _('Unable to determine external IP address'), + NO_WAN_LINK: _('Unable to determine upstream interface'), + PEER_RESOLVE_FAIL: _('Unable to resolve peer host name'), + PIN_FAILED: _('PIN code rejected') +}; + +var iface_patterns_ignore = [ + /^wmaster\d+/, + /^wifi\d+/, + /^hwsim\d+/, + /^imq\d+/, + /^ifb\d+/, + /^mon\.wlan\d+/, + /^sit\d+/, + /^gre\d+/, + /^gretap\d+/, + /^ip6gre\d+/, + /^ip6tnl\d+/, + /^tunl\d+/, + /^lo$/ +]; + +var iface_patterns_wireless = [ + /^wlan\d+/, + /^wl\d+/, + /^ath\d+/, + /^\w+\.network\d+/ +]; + +var iface_patterns_virtual = [ ]; + +var callNetworkWirelessStatus = rpc.declare({ + object: 'network.wireless', + method: 'status' +}); + +var callLuciNetdevs = rpc.declare({ + object: 'luci', + method: 'netdevs' +}); + +var callLuciIfaddrs = rpc.declare({ + object: 'luci', + method: 'ifaddrs', + expect: { result: [] } +}); + +var callLuciBoardjson = rpc.declare({ + object: 'luci', + method: 'boardjson' +}); + +var callIwinfoInfo = rpc.declare({ + object: 'iwinfo', + method: 'info', + params: [ 'device' ] +}); + +var callNetworkInterfaceStatus = rpc.declare({ + object: 'network.interface', + method: 'dump', + expect: { 'interface': [] } +}); + +var callNetworkDeviceStatus = rpc.declare({ + object: 'network.device', + method: 'status', + expect: { '': {} } +}); + +var _cache = {}, + _state = null, + _protocols = {}; + +function getWifiState() { + if (_cache.wifi == null) + return callNetworkWirelessStatus().then(function(state) { + if (!L.isObject(state)) + throw !1; + return (_cache.wifi = state); + }).catch(function() { + return (_cache.wifi = {}); + }); + + return Promise.resolve(_cache.wifi); +} + +function getInterfaceState() { + if (_cache.interfacedump == null) + return callNetworkInterfaceStatus().then(function(state) { + if (!Array.isArray(state)) + throw !1; + return (_cache.interfacedump = state); + }).catch(function() { + return (_cache.interfacedump = []); + }); + + return Promise.resolve(_cache.interfacedump); +} + +function getDeviceState() { + if (_cache.devicedump == null) + return callNetworkDeviceStatus().then(function(state) { + if (!L.isObject(state)) + throw !1; + return (_cache.devicedump = state); + }).catch(function() { + return (_cache.devicedump = {}); + }); + + return Promise.resolve(_cache.devicedump); +} + +function getIfaddrState() { + if (_cache.ifaddrs == null) + return callLuciIfaddrs().then(function(addrs) { + if (!Array.isArray(addrs)) + throw !1; + return (_cache.ifaddrs = addrs); + }).catch(function() { + return (_cache.ifaddrs = []); + }); + + return Promise.resolve(_cache.ifaddrs); +} + +function getNetdevState() { + if (_cache.devices == null) + return callLuciNetdevs().then(function(state) { + if (!L.isObject(state)) + throw !1; + return (_cache.devices = state); + }).catch(function() { + return (_cache.devices = {}); + }); + + return Promise.resolve(_cache.devices); +} + +function getBoardState() { + if (_cache.board == null) + return callLuciBoardjson().then(function(state) { + if (!L.isObject(state)) + throw !1; + return (_cache.board = state); + }).catch(function() { + return (_cache.board = {}); + }); + + return Promise.resolve(_cache.board); +} + +function getWifiStateBySid(sid) { + var s = uci.get('wireless', sid); + + if (s != null && s['.type'] == 'wifi-iface') { + for (var radioname in _cache.wifi) { + for (var i = 0; i < _cache.wifi[radioname].interfaces.length; i++) { + var netstate = _cache.wifi[radioname].interfaces[i]; + + if (typeof(netstate.section) != 'string') + continue; + + var s2 = uci.get('wireless', netstate.section); + + if (s2 != null && s['.type'] == s2['.type'] && s['.name'] == s2['.name']) + return [ radioname, _cache.wifi[radioname], netstate ]; + } + } + } + + return null; +} + +function getWifiStateByIfname(ifname) { + for (var radioname in _cache.wifi) { + for (var i = 0; i < _cache.wifi[radioname].interfaces.length; i++) { + var netstate = _cache.wifi[radioname].interfaces[i]; + + if (typeof(netstate.ifname) != 'string') + continue; + + if (netstate.ifname == ifname) + return [ radioname, _cache.wifi[radioname], netstate ]; + } + } + + return null; +} + +function isWifiIfname(ifname) { + for (var i = 0; i < iface_patterns_wireless.length; i++) + if (iface_patterns_wireless[i].test(ifname)) + return true; + + 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) { + var sections = uci.sections('wireless', 'wifi-iface'); + for (var i = 0, n = 0; i < sections.length; i++) { + if (sections[i].device != m[1]) + continue; + + if (++n == +m[2]) + return sections[i]['.name']; + } + } + + return null; +} + +function getWifiSidByIfname(ifname) { + var sid = getWifiSidByNetid(ifname); + + if (sid != null) + return sid; + + var res = getWifiStateByIfname(ifname); + + if (res != null && L.isObject(res[2]) && typeof(res[2].section) == 'string') + return res[2].section; + + return null; +} + +function getWifiNetidBySid(sid) { + var s = uci.get('wireless', sid); + if (s != null && s['.type'] == 'wifi-iface') { + var radioname = s.device; + if (typeof(s.device) == 'string') { + var i = 0, netid = null, sections = uci.sections('wireless', 'wifi-iface'); + for (var i = 0, n = 0; i < sections.length; i++) { + if (sections[i].device != s.device) + continue; + + n++; + + if (sections[i]['.name'] != s['.name']) + continue; + + return [ '%s.network%d'.format(s.device, n), s.device ]; + } + + } + } + + return null; +} + +function getWifiNetidByNetname(name) { + var sections = uci.sections('wireless', 'wifi-iface'); + for (var i = 0; i < sections.length; i++) { + if (typeof(sections[i].network) != 'string') + continue; + + var nets = sections[i].network.split(/\s+/); + for (var j = 0; j < nets.length; j++) { + if (nets[j] != name) + continue; + + return getWifiNetidBySid(sections[i]['.name']); + } + } + + return null; +} + +function isVirtualIfname(ifname) { + for (var i = 0; i < iface_patterns_virtual.length; i++) + if (iface_patterns_virtual[i].test(ifname)) + return true; + + return false; +} + +function isIgnoredIfname(ifname) { + for (var i = 0; i < iface_patterns_ignore.length; i++) + if (iface_patterns_ignore[i].test(ifname)) + return true; + + return false; +} + +function appendValue(config, section, option, value) { + var values = uci.get(config, section, option), + isArray = Array.isArray(values), + rv = false; + + if (isArray == false) + values = String(values || '').split(/\s+/); + + if (values.indexOf(value) == -1) { + values.push(value); + rv = true; + } + + uci.set(config, section, option, isArray ? values : values.join(' ')); + + return rv; +} + +function removeValue(config, section, option, value) { + var values = uci.get(config, section, option), + isArray = Array.isArray(values), + rv = false; + + if (isArray == false) + values = String(values || '').split(/\s+/); + + for (var i = values.length - 1; i >= 0; i--) { + if (values[i] == value) { + values.splice(i, 1); + rv = true; + } + } + + if (values.length > 0) + uci.set(config, section, option, isArray ? values : values.join(' ')); + else + uci.unset(config, section, option); + + return rv; +} + +function prefixToMask(bits, v6) { + var w = v6 ? 128 : 32, + m = []; + + if (bits > w) + return null; + + for (var i = 0; i < w / 16; i++) { + var b = Math.min(16, bits); + m.push((0xffff << (16 - b)) & 0xffff); + bits -= b; + } + + if (v6) + return String.prototype.format.apply('%x:%x:%x:%x:%x:%x:%x:%x', m).replace(/:0(?::0)+$/, '::'); + else + return '%d.%d.%d.%d'.format(m[0] >>> 8, m[0] & 0xff, m[1] >>> 8, m[1] & 0xff); +} + +function maskToPrefix(mask, v6) { + var m = v6 ? validation.parseIPv6(mask) : validation.parseIPv4(mask); + + if (!m) + return null; + + var bits = 0; + + for (var i = 0, z = false; i < m.length; i++) { + z = z || !m[i]; + + while (!z && (m[i] & (v6 ? 0x8000 : 0x80))) { + m[i] = (m[i] << 1) & (v6 ? 0xffff : 0xff); + bits++; + } + + if (m[i]) + return null; + } + + return bits; +} + +function initNetworkState() { + if (_state == null) + return (_state = Promise.all([ + getInterfaceState(), getDeviceState(), getBoardState(), + getWifiState(), getIfaddrState(), getNetdevState(), + uci.load('network'), uci.load('wireless'), uci.load('luci') + ]).finally(function() { + var ifaddrs = _cache.ifaddrs, + devices = _cache.devices, + board = _cache.board, + s = { isTunnel: {}, isBridge: {}, isSwitch: {}, isWifi: {}, interfaces: {}, bridges: {}, switches: {} }; + + for (var i = 0, a; (a = ifaddrs[i]) != null; i++) { + var name = a.name.replace(/:.+$/, ''); + + if (isVirtualIfname(name)) + s.isTunnel[name] = true; + + if (s.isTunnel[name] || !(isIgnoredIfname(name) || isVirtualIfname(name))) { + s.interfaces[name] = s.interfaces[name] || { + idx: a.ifindex || i, + name: name, + rawname: a.name, + flags: [], + ipaddrs: [], + ip6addrs: [] + }; + + if (a.family == 'packet') { + s.interfaces[name].flags = a.flags; + s.interfaces[name].stats = a.data; + s.interfaces[name].macaddr = a.addr; + } + else if (a.family == 'inet') { + s.interfaces[name].ipaddrs.push(a.addr + '/' + a.netmask); + } + else if (a.family == 'inet6') { + s.interfaces[name].ip6addrs.push(a.addr + '/' + a.netmask); + } + } + } + + for (var devname in devices) { + var dev = devices[devname]; + + if (dev.bridge) { + var b = { + name: devname, + id: dev.id, + stp: dev.stp, + ifnames: [] + }; + + for (var i = 0; dev.ports && i < dev.ports.length; i++) { + var subdev = s.interfaces[dev.ports[i]]; + + if (subdev == null) + continue; + + b.ifnames.push(subdev); + subdev.bridge = b; + } + + s.bridges[devname] = b; + } + } + + if (L.isObject(board.switch)) { + for (var switchname in board.switch) { + var layout = board.switch[switchname], + netdevs = {}, + nports = {}, + ports = [], + pnum = null, + role = null; + + if (L.isObject(layout) && Array.isArray(layout.ports)) { + for (var i = 0, port; (port = layout.ports[i]) != null; i++) { + if (typeof(port) == 'object' && typeof(port.num) == 'number' && + (typeof(port.role) == 'string' || typeof(port.device) == 'string')) { + var spec = { + num: port.num, + role: port.role || 'cpu', + index: (port.index != null) ? port.index : port.num + }; + + if (port.device != null) { + spec.device = port.device; + spec.tagged = spec.need_tag; + netdevs[port.num] = port.device; + } + + ports.push(spec); + + if (port.role != null) + nports[port.role] = (nports[port.role] || 0) + 1; + } + } + + ports.sort(function(a, b) { + if (a.role != b.role) + return (a.role < b.role) ? -1 : 1; + + return (a.index - b.index); + }); + + for (var i = 0, port; (port = ports[i]) != null; i++) { + if (port.role != role) { + role = port.role; + pnum = 1; + } + + if (role == 'cpu') + port.label = 'CPU (%s)'.format(port.device); + else if (nports[role] > 1) + port.label = '%s %d'.format(role.toUpperCase(), pnum++); + else + port.label = role.toUpperCase(); + + delete port.role; + delete port.index; + } + + s.switches[switchname] = { + ports: ports, + netdevs: netdevs + }; + } + } + } + + return (_state = s); + })); + + return Promise.resolve(_state); +} + +function ifnameOf(obj) { + if (obj instanceof Interface) + return obj.name(); + else if (obj instanceof Protocol) + return obj.ifname(); + else if (typeof(obj) == 'string') + return obj.replace(/:.+$/, ''); + + return null; +} + +function networkSort(a, b) { + return a.getName() > b.getName(); +} + +function deviceSort(a, b) { + var typeWeigth = { wifi: 2, alias: 3 }, + weightA = typeWeigth[a.getType()] || 1, + weightB = typeWeigth[b.getType()] || 1; + + if (weightA != weightB) + return weightA - weightB; + + return a.getName() > b.getName(); +} + + +var Network, Protocol, Device, WifiDevice, WifiNetwork; + +Network = L.Class.extend({ + getProtocol: function(protoname, netname) { + var v = _protocols[protoname]; + if (v != null) + return v(netname || '__dummy__'); + + return null; + }, + + getProtocols: function() { + var rv = []; + + for (var protoname in _protocols) + rv.push(_protocols[protoname]('__dummy__')); + + return rv; + }, + + registerProtocol: function(protoname, methods) { + var proto = Protocol.extend(Object.assign({}, methods, { + __init__: function(name) { + this.sid = name; + }, + + proto: function() { + return protoname; + } + })); + + _protocols[protoname] = proto; + + return proto; + }, + + registerPatternVirtual: function(pat) { + iface_patterns_virtual.push(pat); + }, + + registerErrorCode: function(code, message) { + if (typeof(code) == 'string' && + typeof(message) == 'string' && + proto_errors.hasOwnProperty(code)) { + proto_errors[code] = message; + return true; + } + + return false; + }, + + addNetwork: function(name, options) { + return this.getNetwork(name).then(L.bind(function(existingNetwork) { + if (name != null && /^[a-zA-Z0-9_]+$/.test(name) && existingNetwork == null) { + var sid = uci.add('network', 'interface', name); + + if (sid != null) { + if (L.isObject(options)) + for (var key in options) + if (options.hasOwnProperty(key)) + uci.set('network', sid, key, options[key]); + + return this.instantiateNetwork(sid); + } + } + else if (existingNetwork != null && existingNetwork.isEmpty()) { + if (L.isObject(options)) + for (var key in options) + if (options.hasOwnProperty(key)) + existingNetwork.set(key, options[key]); + + return existingNetwork; + } + }, this)); + }, + + getNetwork: function(name) { + return initNetworkState().then(L.bind(function() { + var section = (name != null) ? uci.get('network', name) : null; + + if (section != null && section['.type'] == 'interface') { + return this.instantiateNetwork(name); + } + else if (name != null) { + for (var i = 0; i < _cache.interfacedump.length; i++) + if (_cache.interfacedump[i].interface == name) + return this.instantiateNetwork(name, _cache.interfacedump[i].proto); + } + + return null; + }, this)); + }, + + 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 < _cache.interfacedump.length; i++) + if (networks[_cache.interfacedump[i].interface] == null) + networks[_cache.interfacedump[i].interface] = + this.instantiateNetwork(_cache.interfacedump[i].interface, _cache.interfacedump[i].proto); + + var rv = []; + + for (var network in networks) + if (networks.hasOwnProperty(network)) + rv.push(networks[network]); + + rv.sort(networkSort); + + return rv; + }, this)); + }, + + deleteNetwork: function(name) { + return Promise.all([ L.require('firewall').catch(function() { return null }), initNetworkState() ]).then(function() { + var uciInterface = uci.get('network', name); + + if (uciInterface != null && uciInterface['.type'] == 'interface') { + uci.remove('network', name); + + uci.sections('luci', 'ifstate', function(s) { + if (s.interface == name) + uci.remove('luci', s['.name']); + }); + + uci.sections('network', 'alias', function(s) { + if (s.interface == name) + uci.remove('network', s['.name']); + }); + + uci.sections('network', 'route', function(s) { + if (s.interface == name) + uci.remove('network', s['.name']); + }); + + uci.sections('network', 'route6', function(s) { + if (s.interface == name) + uci.remove('network', s['.name']); + }); + + uci.sections('wireless', 'wifi-iface', function(s) { + var networks = L.toArray(s.network).filter(function(network) { return network != name }); + + if (networks.length > 0) + uci.set('wireless', s['.name'], 'network', networks.join(' ')); + else + uci.unset('wireless', s['.name'], 'network'); + }); + + if (L.firewall) + return L.firewall.deleteNetwork(name).then(function() { return true }); + + return true; + } + + return false; + }); + }, + + renameNetwork: function(oldName, newName) { + return initNetworkState().then(function() { + if (newName == null || !/^[a-zA-Z0-9_]+$/.test(newName) || uci.get('network', newName) != null) + return false; + + var oldNetwork = uci.get('network', oldName); + + if (oldNetwork == null || oldNetwork['.type'] != 'interface') + return false; + + var sid = uci.add('network', 'interface', newName); + + for (var key in oldNetwork) + if (oldNetwork.hasOwnProperty(key) && key.charAt(0) != '.') + uci.set('network', sid, key, oldNetwork[key]); + + uci.sections('luci', 'ifstate', function(s) { + if (s.interface == oldName) + uci.set('luci', s['.name'], 'interface', newName); + }); + + uci.sections('network', 'alias', function(s) { + if (s.interface == oldName) + uci.set('network', s['.name'], 'interface', newName); + }); + + uci.sections('network', 'route', function(s) { + if (s.interface == oldName) + uci.set('network', s['.name'], 'interface', newName); + }); + + uci.sections('network', 'route6', function(s) { + if (s.interface == oldName) + uci.set('network', s['.name'], 'interface', newName); + }); + + uci.sections('wireless', 'wifi-iface', function(s) { + var networks = L.toArray(s.network).map(function(network) { return (network == oldName ? newName : network) }); + + if (networks.length > 0) + uci.set('wireless', s['.name'], 'network', networks.join(' ')); + }); + + uci.remove('network', oldName); + + return true; + }); + }, + + getDevice: function(name) { + return initNetworkState().then(L.bind(function() { + if (name == null) + return null; + + if (_state.interfaces.hasOwnProperty(name) || isWifiIfname(name)) + return this.instantiateDevice(name); + + var netid = getWifiNetidBySid(name); + if (netid != null) + return this.instantiateDevice(netid[0]); + + return null; + }, this)); + }, + + getDevices: function() { + return initNetworkState().then(L.bind(function() { + var devices = {}; + + /* find simple devices */ + var uciInterfaces = uci.sections('network', 'interface'); + for (var i = 0; i < uciInterfaces.length; i++) { + var ifnames = L.toArray(uciInterfaces[i].ifname); + + for (var j = 0; j < ifnames.length; j++) { + if (ifnames[j].charAt(0) == '@') + continue; + + if (isIgnoredIfname(ifnames[j]) || isVirtualIfname(ifnames[j]) || isWifiIfname(ifnames[j])) + continue; + + devices[ifnames[j]] = this.instantiateDevice(ifnames[j]); + } + } + + for (var ifname in _state.interfaces) { + if (devices.hasOwnProperty(ifname)) + continue; + + if (isIgnoredIfname(ifname) || isVirtualIfname(ifname) || isWifiIfname(ifname)) + continue; + + devices[ifname] = this.instantiateDevice(ifname); + } + + /* find VLAN devices */ + var uciSwitchVLANs = uci.sections('network', 'switch_vlan'); + for (var i = 0; i < uciSwitchVLANs.length; i++) { + if (typeof(uciSwitchVLANs[i].ports) != 'string' || + typeof(uciSwitchVLANs[i].device) != 'string' || + !_state.switches.hasOwnProperty(uciSwitchVLANs[i].device)) + continue; + + var ports = uciSwitchVLANs[i].ports.split(/\s+/); + for (var j = 0; j < ports.length; j++) { + var m = ports[j].match(/^(\d+)([tu]?)$/); + if (m == null) + continue; + + var netdev = _state.switches[uciSwitchVLANs[i].device].netdevs[m[1]]; + if (netdev == null) + continue; + + if (!devices.hasOwnProperty(netdev)) + devices[netdev] = this.instantiateDevice(netdev); + + _state.isSwitch[netdev] = true; + + if (m[2] != 't') + continue; + + var vid = uciSwitchVLANs[i].vid || uciSwitchVLANs[i].vlan; + vid = (vid != null ? +vid : null); + + if (vid == null || vid < 0 || vid > 4095) + continue; + + var vlandev = '%s.%d'.format(netdev, vid); + + if (!devices.hasOwnProperty(vlandev)) + devices[vlandev] = this.instantiateDevice(vlandev); + + _state.isSwitch[vlandev] = true; + } + } + + /* find wireless interfaces */ + var uciWifiIfaces = uci.sections('wireless', 'wifi-iface'), + networkCount = {}; + + for (var i = 0; i < uciWifiIfaces.length; i++) { + if (typeof(uciWifiIfaces[i].device) != 'string') + continue; + + networkCount[uciWifiIfaces[i].device] = (networkCount[uciWifiIfaces[i].device] || 0) + 1; + + var netid = '%s.network%d'.format(uciWifiIfaces[i].device, networkCount[uciWifiIfaces[i].device]); + + devices[netid] = this.instantiateDevice(netid); + } + + var rv = []; + + for (var netdev in devices) + if (devices.hasOwnProperty(netdev)) + rv.push(devices[netdev]); + + rv.sort(deviceSort); + + return rv; + }, this)); + }, + + isIgnoredDevice: function(name) { + return isIgnoredIfname(name); + }, + + getWifiDevice: function(devname) { + return Promise.all([ getWifiIwinfoByIfname(devname, true), initNetworkState() ]).then(L.bind(function(res) { + var existingDevice = uci.get('wireless', devname); + + if (existingDevice == null || existingDevice['.type'] != 'wifi-device') + return null; + + return this.instantiateWifiDevice(devname, res[0]); + }, this)); + }, + + getWifiDevices: function() { + var deviceNames = []; + + return initNetworkState().then(L.bind(function() { + var uciWifiDevices = uci.sections('wireless', 'wifi-device'), + tasks = []; + + for (var i = 0; i < uciWifiDevices.length; i++) { + tasks.push(callIwinfoInfo(uciWifiDevices['.name'], true)); + deviceNames.push(uciWifiDevices['.name']); + } + + 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)); + }, + + getWifiNetwork: function(netname) { + var sid, res, netid, radioname, radiostate, netstate; + + return initNetworkState().then(L.bind(function() { + sid = getWifiSidByNetid(netname); + + if (sid != null) { + res = getWifiStateBySid(sid); + netid = netname; + radioname = res ? res[0] : null; + radiostate = res ? res[1] : null; + netstate = res ? res[2] : null; + } + else { + res = getWifiStateByIfname(netname); + + if (res != null) { + radioname = res[0]; + radiostate = res[1]; + netstate = res[2]; + sid = netstate.section; + netid = L.toArray(getWifiNetidBySid(sid))[0]; + } + else { + res = getWifiStateBySid(netname); + + if (res != null) { + radioname = res[0]; + radiostate = res[1]; + netstate = res[2]; + sid = netname; + netid = L.toArray(getWifiNetidBySid(sid))[0]; + } + else { + res = getWifiNetidBySid(netname); + + if (res != null) { + netid = res[0]; + radioname = res[1]; + sid = netname; + } + } + } + } + + 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); + }, this)); + }, + + addWifiNetwork: function(options) { + return initNetworkState().then(L.bind(function() { + if (options == null || + typeof(options) != 'object' || + typeof(options.device) != 'string') + return null; + + var existingDevice = uci.get('wireless', options.device); + if (existingDevice == null || existingDevice['.type'] != 'wifi-device') + return null; + + var sid = uci.add('wireless', 'wifi-iface'); + for (var key in options) + if (options.hasOwnProperty(key)) + uci.set('wireless', sid, key, options[key]); + + var radioname = existingDevice['.name'], + netid = getWifiNetidBySid(sid) || []; + + return this.instantiateWifiNetwork(sid, radioname, _cache.wifi[radioname], netid[0], null, { ifname: netid }); + }, this)); + }, + + deleteWifiNetwork: function(netname) { + return initNetworkState().then(L.bind(function() { + var sid = getWifiSidByIfname(netname); + + if (sid == null) + return false; + + uci.remove('wireless', sid); + return true; + }, this)); + }, + + getStatusByRoute: function(addr, mask) { + return initNetworkState().then(L.bind(function() { + var rv = []; + + for (var i = 0; i < _state.interfacedump.length; i++) { + if (!Array.isArray(_state.interfacedump[i].route)) + continue; + + for (var j = 0; j < _state.interfacedump[i].route.length; j++) { + if (typeof(_state.interfacedump[i].route[j]) != 'object' || + typeof(_state.interfacedump[i].route[j].target) != 'string' || + typeof(_state.interfacedump[i].route[j].mask) != 'number') + continue; + + if (_state.interfacedump[i].route[j].table) + continue; + + rv.push(_state.interfacedump[i]); + } + } + + return rv; + }, this)); + }, + + getStatusByAddress: function(addr) { + return initNetworkState().then(L.bind(function() { + var rv = []; + + for (var i = 0; i < _state.interfacedump.length; i++) { + if (Array.isArray(_state.interfacedump[i]['ipv4-address'])) + for (var j = 0; j < _state.interfacedump[i]['ipv4-address'].length; j++) + if (typeof(_state.interfacedump[i]['ipv4-address'][j]) == 'object' && + _state.interfacedump[i]['ipv4-address'][j].address == addr) + return _state.interfacedump[i]; + + if (Array.isArray(_state.interfacedump[i]['ipv6-address'])) + for (var j = 0; j < _state.interfacedump[i]['ipv6-address'].length; j++) + if (typeof(_state.interfacedump[i]['ipv6-address'][j]) == 'object' && + _state.interfacedump[i]['ipv6-address'][j].address == addr) + return _state.interfacedump[i]; + + if (Array.isArray(_state.interfacedump[i]['ipv6-prefix-assignment'])) + for (var j = 0; j < _state.interfacedump[i]['ipv6-prefix-assignment'].length; j++) + if (typeof(_state.interfacedump[i]['ipv6-prefix-assignment'][j]) == 'object' && + typeof(_state.interfacedump[i]['ipv6-prefix-assignment'][j]['local-address']) == 'object' && + _state.interfacedump[i]['ipv6-prefix-assignment'][j]['local-address'].address == addr) + return _state.interfacedump[i]; + } + + return null; + }, this)); + }, + + getWANNetworks: function() { + return this.getStatusByRoute('0.0.0.0', 0).then(L.bind(function(statuses) { + var rv = []; + + for (var i = 0; i < statuses.length; i++) + rv.push(this.instantiateNetwork(statuses[i].interface, statuses[i].proto)); + + return rv; + }, this)); + }, + + getWAN6Networks: function() { + return this.getStatusByRoute('::', 0).then(L.bind(function(statuses) { + var rv = []; + + for (var i = 0; i < statuses.length; i++) + rv.push(this.instantiateNetwork(statuses[i].interface, statuses[i].proto)); + + return rv; + }, this)); + }, + + getSwitchTopologies: function() { + return initNetworkState().then(function() { + return _state.switches; + }); + }, + + instantiateNetwork: function(name, proto) { + if (name == null) + return null; + + proto = (proto == null ? uci.get('network', name, 'proto') : proto); + + var protoClass = _protocols[proto] || Protocol; + return new protoClass(name); + }, + + instantiateDevice: function(name, network) { + return new Device(name, network); + }, + + instantiateWifiDevice: function(radioname, iwinfo) { + return new WifiDevice(radioname, iwinfo); + }, + + instantiateWifiNetwork: function(sid, radioname, radiostate, netid, netstate, iwinfo) { + return new WifiNetwork(sid, radioname, radiostate, netid, netstate, iwinfo); + } +}); + +Protocol = L.Class.extend({ + __init__: function(name) { + this.sid = name; + }, + + _get: function(opt) { + var val = uci.get('network', this.sid, opt); + + if (Array.isArray(val)) + return val.join(' '); + + return val || ''; + }, + + _ubus: function(field) { + for (var i = 0; i < _cache.interfacedump.length; i++) { + if (_cache.interfacedump[i].interface != this.sid) + continue; + + return (field != null ? _cache.interfacedump[i][field] : _cache.interfacedump[i]); + } + }, + + get: function(opt) { + return uci.get('network', this.sid, opt); + }, + + set: function(opt, val) { + return uci.set('network', this.sid, opt, val); + }, + + getIfname: function() { + var ifname; + + if (this.isFloating()) + ifname = this._ubus('l3_device'); + else + ifname = this._ubus('device'); + + if (ifname != null) + return ifname; + + var res = getWifiNetidByNetname(this.sid); + return (res != null ? res[0] : null); + }, + + getProtocol: function() { + return 'none'; + }, + + getI18n: function() { + switch (this.getProtocol()) { + case 'none': return _('Unmanaged'); + case 'static': return _('Static address'); + case 'dhcp': return _('DHCP client'); + default: return _('Unknown'); + } + }, + + getType: function() { + return this._get('type'); + }, + + getName: function() { + return this.sid; + }, + + getUptime: function() { + return this._ubus('uptime') || 0; + }, + + getExpiry: function() { + var u = this._ubus('uptime'), + d = this._ubus('data'); + + if (typeof(u) == 'number' && d != null && + typeof(d) == 'object' && typeof(d.leasetime) == 'number') { + var r = d.leasetime - (u % d.leasetime); + return (r > 0 ? r : 0); + } + + return -1; + }, + + getMetric: function() { + return this._ubus('metric') || 0; + }, + + getZoneName: function() { + var d = this._ubus('data'); + + if (L.isObject(d) && typeof(d.zone) == 'string') + return d.zone; + + return null; + }, + + getIPAddr: function() { + var addrs = this._ubus('ipv4-address'); + return ((Array.isArray(addrs) && addrs.length) ? addrs[0].address : null); + }, + + getIPAddrs: function() { + var addrs = this._ubus('ipv4-address'), + rv = []; + + if (Array.isArray(addrs)) + for (var i = 0; i < addrs.length; i++) + rv.push('%s/%d'.format(addrs[i].address, addrs[i].mask)); + + return rv; + }, + + getNetmask: function() { + var addrs = this._ubus('ipv4-address'); + if (Array.isArray(addrs) && addrs.length) + return prefixToMask(addrs[0].mask, false); + }, + + getGatewayAddr: function() { + var routes = this._ubus('route'); + + if (Array.isArray(routes)) + for (var i = 0; i < routes.length; i++) + if (typeof(routes[i]) == 'object' && + routes[i].target == '0.0.0.0' && + routes[i].mask == 0) + return routes[i].nexthop; + + return null; + }, + + getDNSAddrs: function() { + var addrs = this._ubus('dns-server'), + rv = []; + + if (Array.isArray(addrs)) + for (var i = 0; i < addrs.length; i++) + if (!/:/.test(addrs[i])) + rv.push(addrs[i]); + + return rv; + }, + + getIP6Addr: function() { + var addrs = this._ubus('ipv6-address'); + + if (Array.isArray(addrs) && L.isObject(addrs[0])) + return '%s/%d'.format(addrs[0].address, addrs[0].mask); + + addrs = this._ubus('ipv6-prefix-assignment'); + + if (Array.isArray(addrs) && L.isObject(addrs[0]) && L.isObject(addrs[0]['local-address'])) + return '%s/%d'.format(addrs[0]['local-address'].address, addrs[0]['local-address'].mask); + + return null; + }, + + getIP6Addrs: function() { + var addrs = this._ubus('ipv6-address'), + rv = []; + + if (Array.isArray(addrs)) + for (var i = 0; i < addrs.length; i++) + if (L.isObject(addrs[i])) + rv.push('%s/%d'.format(addrs[i].address, addrs[i].mask)); + + addrs = this._ubus('ipv6-prefix-assignment'); + + if (Array.isArray(addrs)) + for (var i = 0; i < addrs.length; i++) + if (L.isObject(addrs[i]) && L.isObject(addrs[i]['local-address'])) + rv.push('%s/%d'.format(addrs[i]['local-address'].address, addrs[i]['local-address'].mask)); + + return rv; + }, + + getDNS6Addrs: function() { + var addrs = this._ubus('dns-server'), + rv = []; + + if (Array.isArray(addrs)) + for (var i = 0; i < addrs.length; i++) + if (/:/.test(addrs[i])) + rv.push(addrs[i]); + + return rv; + }, + + getIP6Prefix: function() { + var prefixes = this._ubus('ipv6-prefix'); + + if (Array.isArray(prefixes) && L.isObject(prefixes[0])) + return '%s/%d'.format(prefixes[0].address, prefixes[0].mask); + + return null; + }, + + getErrors: function() { + var errors = this._ubus('errors'), + rv = null; + + if (Array.isArray(errors)) { + for (var i = 0; i < errors.length; i++) { + if (!L.isObject(errors[i]) || typeof(errors[i].code) != 'string') + continue; + + rv = rv || []; + rv.push(proto_errors[errors[i].code] || _('Unknown error (%s)').format(errors[i].code)); + } + } + + return rv; + }, + + isBridge: function() { + return (!this.isVirtual() && this.getType() == 'bridge'); + }, + + getOpkgPackage: function() { + return null; + }, + + isInstalled: function() { + return true; + }, + + isVirtual: function() { + return false; + }, + + isFloating: function() { + return false; + }, + + isDynamic: function() { + return (this._ubus('dynamic') == true); + }, + + isAlias: function() { + var ifnames = L.toArray(uci.get('network', this.sid, 'ifname')), + parent = null; + + for (var i = 0; i < ifnames.length; i++) + if (ifnames[i].charAt(0) == '@') + parent = ifnames[i].substr(1); + else if (parent != null) + parent = null; + + return parent; + }, + + isEmpty: function() { + if (this.isFloating()) + return false; + + var empty = true, + ifname = this._get('ifname'); + + if (ifname != null && ifname.match(/\S+/)) + empty = false; + + if (empty == true && getWifiNetidBySid(this.sid) != null) + empty = false; + + return empty; + }, + + isUp: function() { + return (this._ubus('up') == true); + }, + + addDevice: function(ifname) { + ifname = ifnameOf(ifname); + + if (ifname == null || this.isFloating()) + return false; + + var wif = getWifiSidByIfname(ifname); + + if (wif != null) + return appendValue('wireless', wif, 'network', this.sid); + + return appendValue('network', this.sid, 'ifname', ifname); + }, + + deleteDevice: function(ifname) { + var rv = false; + + ifname = ifnameOf(ifname); + + if (ifname == null || this.isFloating()) + return false; + + var wif = getWifiSidByIfname(ifname); + + if (wif != null) + rv = removeValue('wireless', wif, 'network', this.sid); + + if (removeValue('network', this.sid, 'ifname', ifname)) + rv = true; + + return rv; + }, + + getDevice: function() { + if (this.isVirtual()) { + var ifname = '%s-%s'.format(this.getProtocol(), this.sid); + _state.isTunnel[this.getProtocol() + '-' + this.sid] = true; + return L.network.instantiateDevice(ifname, this); + } + else if (this.isBridge()) { + var ifname = 'br-%s'.format(this.sid); + _state.isBridge[ifname] = true; + return new Device(ifname, this); + } + else { + var ifname = this._ubus('l3_device') || this._ubus('device'); + + if (ifname != null) + return L.network.instantiateDevice(ifname, this); + + var ifnames = L.toArray(uci.get('network', this.sid, 'ifname')); + + for (var i = 0; i < ifnames.length; i++) { + var m = ifnames[i].match(/^([^:/]+)/); + return ((m && m[1]) ? L.network.instantiateDevice(m[1], this) : null); + } + + ifname = getWifiNetidByNetname(this.sid); + + return (ifname != null ? L.network.instantiateDevice(ifname[0], this) : null); + } + }, + + getDevices: function() { + var rv = []; + + if (!this.isBridge() && !(this.isVirtual() && !this.isFloating())) + return null; + + var ifnames = L.toArray(uci.get('network', this.sid, 'ifname')); + + for (var i = 0; i < ifnames.length; i++) { + if (ifnames[i].charAt(0) == '@') + continue; + + var m = ifnames[i].match(/^([^:/]+)/); + if (m != null) + rv.push(L.network.instantiateDevice(m[1], this)); + } + + var uciWifiIfaces = uci.sections('wireless', 'wifi-iface'); + + for (var i = 0; i < uciWifiIfaces.length; i++) { + if (typeof(uciWifiIfaces[i].device) != 'string') + continue; + + var networks = L.toArray(uciWifiIfaces[i].network); + + for (var j = 0; j < networks.length; j++) { + if (networks[j] != this.sid) + continue; + + var netid = getWifiNetidBySid(uciWifiIfaces[i]['.name']); + + if (netid != null) + rv.push(L.network.instantiateDevice(netid[0], this)); + } + } + + rv.sort(deviceSort); + + return rv; + }, + + containsDevice: function(ifname) { + ifname = ifnameOf(ifname); + + if (ifname == null) + return false; + else if (this.isVirtual() && '%s-%s'.format(this.getProtocol(), this.sid) == ifname) + return true; + else if (this.isBridge() && 'br-%s'.format(this.sid) == ifname) + return true; + + var ifnames = L.toArray(uci.get('network', this.sid, 'ifname')); + + for (var i = 0; i < ifnames.length; i++) { + var m = ifnames[i].match(/^([^:/]+)/); + if (m != null && m[1] == ifname) + return true; + } + + var wif = getWifiSidByIfname(ifname); + + if (wif != null) { + var networks = L.toArray(uci.get('wireless', wif, 'network')); + + for (var i = 0; i < networks.length; i++) + if (networks[i] == this.sid) + return true; + } + + return false; + } +}); + +Device = L.Class.extend({ + __init__: function(ifname, network) { + var wif = getWifiSidByIfname(ifname); + + if (wif != null) { + var res = getWifiStateBySid(wif) || [], + netid = getWifiNetidBySid(wif) || []; + + this.wif = new WifiNetwork(wif, res[0], res[1], netid[0], res[2], { ifname: ifname }); + this.ifname = this.wif.getIfname(); + } + + this.ifname = this.ifname || ifname; + this.dev = _state.interfaces[this.ifname]; + this.network = network; + }, + + _ubus: function(field) { + var dump = _cache.devicedump[this.ifname] || {}; + + return (field != null ? dump[field] : dump); + }, + + getName: function() { + return (this.wif != null ? this.wif.getIfname() : this.ifname); + }, + + getMAC: function() { + return this._ubus('macaddr'); + }, + + getIPAddrs: function() { + var addrs = (this.dev != null ? this.dev.ipaddrs : null); + return (Array.isArray(addrs) ? addrs : []); + }, + + getIP6Addrs: function() { + var addrs = (this.dev != null ? this.dev.ip6addrs : null); + return (Array.isArray(addrs) ? addrs : []); + }, + + getType: function() { + if (this.ifname != null && this.ifname.charAt(0) == '@') + return 'alias'; + else if (this.wif != null || isWifiIfname(this.ifname)) + return 'wifi'; + else if (_state.isBridge[this.ifname]) + return 'bridge'; + else if (_state.isTunnel[this.ifname]) + return 'tunnel'; + else if (this.ifname.indexOf('.') > -1) + return 'vlan'; + else if (_state.isSwitch[this.ifname]) + return 'switch'; + else + return 'ethernet'; + }, + + getShortName: function() { + if (this.wif != null) + return this.wif.getShortName(); + + return this.ifname; + }, + + getI18n: function() { + if (this.wif != null) { + return '%s: %s "%s"'.format( + _('Wireless Network'), + this.wif.getActiveMode(), + this.wif.getActiveSSID() || this.wif.getActiveBSSID() || this.wif.getID() || '?'); + } + + return '%s: "%s"'.format(this.getTypeI18n(), this.getName()); + }, + + getTypeI18n: function() { + switch (this.getType()) { + case 'alias': + return _('Alias Interface'); + + case 'wifi': + return _('Wireless Adapter'); + + case 'bridge': + return _('Bridge'); + + case 'switch': + return _('Ethernet Switch'); + + case 'vlan': + return (_state.isSwitch[this.ifname] ? _('Switch VLAN') : _('Software VLAN')); + + case 'tunnel': + return _('Tunnel Interface'); + + default: + return _('Ethernet Adapter'); + } + }, + + getPorts: function() { + var br = _state.bridges[this.ifname], + rv = []; + + if (br == null || !Array.isArray(br.ifnames)) + return null; + + for (var i = 0; i < br.ifnames.length; i++) + rv.push(L.network.instantiateDevice(br.ifnames[i])); + + return rv; + }, + + getBridgeID: function() { + var br = _state.bridges[this.ifname]; + return (br != null ? br.id : null); + }, + + getBridgeSTP: function() { + var br = _state.bridges[this.ifname]; + return (br != null ? !!br.stp : false); + }, + + isUp: function() { + var up = this._ubus('up'); + + if (up == null) + up = (this.getType() == 'alias'); + + return up; + }, + + isBridge: function() { + return (this.getType() == 'bridge'); + }, + + isBridgePort: function() { + return (this.dev != null && this.dev.bridge != null); + }, + + getTXBytes: function() { + var stat = this._ubus('statistics'); + return (stat != null ? stat.tx_bytes || 0 : 0); + }, + + getRXBytes: function() { + var stat = this._ubus('statistics'); + return (stat != null ? stat.rx_bytes || 0 : 0); + }, + + getTXPackets: function() { + var stat = this._ubus('statistics'); + return (stat != null ? stat.tx_packets || 0 : 0); + }, + + getRXPackets: function() { + var stat = this._ubus('statistics'); + return (stat != null ? stat.rx_packets || 0 : 0); + }, + + getNetwork: function() { + return this.getNetworks()[0]; + }, + + getNetworks: function() { + if (this.networks == null) { + this.networks = []; + + var networks = L.network.getNetworks(); + + for (var i = 0; i < networks.length; i++) + if (networks[i].containsDevice(this.ifname) || networks[i].getIfname() == this.ifname) + this.networks.push(networks[i]); + + this.networks.sort(networkSort); + } + + return this.networks; + }, + + getWifiNetwork: function() { + return (this.wif != null ? this.wif : null); + } +}); + +WifiDevice = L.Class.extend({ + __init__: function(name, iwinfo) { + 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 }; + }, + + get: function(opt) { + return uci.get('wireless', this.sid, opt); + }, + + set: function(opt, value) { + return uci.set('wireless', this.sid, opt, value); + }, + + getName: function() { + return this.sid; + }, + + getHWModes: function() { + if (L.isObject(this.iwinfo.hwmodelist)) + for (var k in this.iwinfo.hwmodelist) + return this.iwinfo.hwmodelist; + + return { b: true, g: true }; + }, + + getI18n: function() { + var type = this.iwinfo.hardware_name || 'Generic'; + + if (this.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'; + + return '%s 802.11%s Wireless Controller (%s)'.format(type, modestr, this.getName()); + }, + + isUp: function() { + if (L.isObject(_cache.wifi[this.sid])) + return (_cache.wifi[this.sid].up == true); + + return false; + }, + + getWifiNetwork: function(network) { + return L.network.getWifiNetwork(network).then(L.bind(function(networkInstance) { + var uciWifiIface = (networkInstance.sid ? uci.get('wireless', networkInstance.sid) : null); + + if (uciWifiIface == null || uciWifiIface['.type'] != 'wifi-iface' || uciWifiIface.device != this.sid) + return Promise.reject(); + + return networkInstance; + }, this)); + }, + + getWifiNetworks: function() { + var uciWifiIfaces = uci.sections('wireless', 'wifi-iface'), + tasks = []; + + for (var i = 0; i < uciWifiIfaces.length; i++) + if (uciWifiIfaces[i].device == this.sid) + tasks.push(L.network.getWifiNetwork(uciWifiIfaces[i]['.name'])); + + return Promise.all(tasks); + }, + + addWifiNetwork: function(options) { + if (!L.isObject(options)) + options = {}; + + options.device = this.sid; + + return L.network.addWifiNetwork(options); + }, + + deleteWifiNetwork: function(network) { + var sid = null; + + if (network instanceof WifiNetwork) { + sid = network.sid; + } + else { + var uciWifiIface = uci.get('wireless', network); + + if (uciWifiIface == null || uciWifiIface['.type'] != 'wifi-iface') + sid = getWifiSidByIfname(network); + } + + if (sid == null || uci.get('wireless', sid, 'device') != this.sid) + return Promise.resolve(false); + + uci.delete('wireless', network); + + return Promise.resolve(true); + } +}); + +WifiNetwork = L.Class.extend({ + __init__: function(sid, radioname, radiostate, netid, netstate, iwinfo) { + this.sid = sid; + this.wdev = iwinfo.ifname; + this.iwinfo = iwinfo; + this.netid = netid; + this._ubusdata = { + radio: radioname, + dev: radiostate, + net: netstate + }; + }, + + 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) { + return uci.get('wireless', this.sid, opt); + }, + + set: function(opt, value) { + return uci.set('wireless', this.sid, opt, value); + }, + + getMode: function() { + return this.ubus('net', 'config', 'mode') || this.get('mode') || 'ap'; + }, + + getSSID: function() { + return this.ubus('net', 'config', 'ssid') || this.get('ssid'); + }, + + getBSSID: function() { + return this.ubus('net', 'config', 'bssid') || this.get('bssid'); + }, + + getNetworkNames: function() { + return L.toArray(this.ubus('net', 'config', 'network') || this.get('network')); + }, + + getID: function() { + return this.netid; + }, + + getName: function() { + return this.sid; + }, + + getIfname: function() { + var ifname = this.ubus('net', 'ifname') || this.iwinfo.ifname; + + if (ifname == null || ifname.match(/^(wifi|radio)\d/)) + ifname = this.netid; + + return ifname; + }, + + getWifiDevice: function() { + var radioname = this.ubus('radio') || this.get('device'); + + if (radioname == null) + return Promise.reject(); + + return L.network.getWifiDevice(radioname); + }, + + isUp: function() { + var device = this.getDevice(); + + if (device == null) + return false; + + return device.isUp(); + }, + + getActiveMode: function() { + var mode = this.iwinfo.mode || this.ubus('net', 'config', 'mode') || this.get('mode') || 'ap'; + + switch (mode) { + case 'ap': return 'Master'; + case 'sta': return 'Client'; + case 'adhoc': return 'Ad-Hoc'; + case 'mesh': return 'Mesh'; + case 'monitor': return 'Monitor'; + default: return mode; + } + }, + + getActiveModeI18n: function() { + var mode = this.getActiveMode(); + + switch (mode) { + case 'Master': return _('Master'); + case 'Client': return _('Client'); + case 'Ad-Hoc': return _('Ad-Hoc'); + case 'Mash': return _('Mesh'); + case 'Monitor': return _('Monitor'); + default: return mode; + } + }, + + getActiveSSID: function() { + return this.iwinfo.ssid || this.ubus('net', 'config', 'ssid') || this.get('ssid'); + }, + + getActiveBSSID: function() { + return this.iwinfo.bssid || this.ubus('net', 'config', 'bssid') || this.get('bssid'); + }, + + getActiveEncryption: function() { + var encryption = this.iwinfo.encryption; + + return (L.isObject(encryption) ? encryption.description || '-' : '-'); + }, + + getAssocList: function() { + // XXX tbd + }, + + getFrequency: function() { + var freq = this.iwinfo.frequency; + + if (freq != null && freq > 0) + return '%.03f'.format(freq / 1000); + + return null; + }, + + getBitRate: function() { + var rate = this.iwinfo.bitrate; + + if (rate != null && rate > 0) + return (rate / 1000); + + return null; + }, + + getChannel: function() { + return this.iwinfo.channel || this.ubus('dev', 'config', 'channel') || this.get('channel'); + }, + + getSignal: function() { + return this.iwinfo.signal || 0; + }, + + getNoise: function() { + return this.iwinfo.noise || 0; + }, + + getCountryCode: function() { + return this.iwinfo.country || this.ubus('dev', 'config', 'country') || '00'; + }, + + getTXPower: function() { + var pwr = this.iwinfo.txpower || 0; + return (pwr + this.getTXPowerOffset()); + }, + + getTXPowerOffset: function() { + return this.iwinfo.txpower_offset || 0; + }, + + getSignalLevel: function(signal, noise) { + if (this.getActiveBSSID() == '00:00:00:00:00:00') + return -1; + + signal = signal || this.getSignal(); + noise = noise || this.getNoise(); + + if (signal < 0 && noise < 0) { + var snr = -1 * (noise - signal); + return Math.floor(snr / 5); + } + + return 0; + }, + + getSignalPercent: function() { + var qc = this.iwinfo.quality || 0, + qm = this.iwinfo.quality_max || 0; + + if (qc > 0 && qm > 0) + return Math.floor((100 / qm) * qc); + + return 0; + }, + + getShortName: function() { + return '%s "%s"'.format( + this.getActiveModeI18n(), + this.getActiveSSID() || this.getActiveBSSID() || this.getID()); + }, + + getI18n: function() { + return '%s: %s "%s" (%s)'.format( + _('Wireless Network'), + this.getActiveModeI18n(), + this.getActiveSSID() || this.getActiveBSSID() || this.getID(), + this.getIfname()); + }, + + getNetwork: function() { + return this.getNetworks()[0]; + }, + + getNetworks: function() { + var networkNames = this.getNetworkNames(), + networks = []; + + for (var i = 0; i < networkNames.length; i++) { + var uciInterface = uci.get('network', networkNames[i]); + + if (uciInterface == null || uciInterface['.type'] != 'interface') + continue; + + networks.push(L.network.instantiateNetwork(networkNames[i])); + } + + networks.sort(networkSort); + + return networks; + }, + + getDevice: function() { + return L.network.instantiateDevice(this.getIfname()); + } +}); + +return Network; diff --git a/luci-base/htdocs/luci-static/resources/promis.min.js b/luci-base/htdocs/luci-static/resources/promis.min.js new file mode 100644 index 000000000..ff71b6999 --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/promis.min.js @@ -0,0 +1,5 @@ +/* Licensed under the BSD license. Copyright 2014 - Bram Stein. All rights reserved. + * https://github.com/bramstein/promis */ +(function(){'use strict';var f,g=[];function l(a){g.push(a);1==g.length&&f()}function m(){for(;g.length;)g[0](),g.shift()}f=function(){setTimeout(m)};function n(a){this.a=p;this.b=void 0;this.f=[];var b=this;try{a(function(a){q(b,a)},function(a){r(b,a)})}catch(c){r(b,c)}}var p=2;function t(a){return new n(function(b,c){c(a)})}function u(a){return new n(function(b){b(a)})}function q(a,b){if(a.a==p){if(b==a)throw new TypeError;var c=!1;try{var d=b&&b.then;if(null!=b&&"object"==typeof b&&"function"==typeof d){d.call(b,function(b){c||q(a,b);c=!0},function(b){c||r(a,b);c=!0});return}}catch(e){c||r(a,e);return}a.a=0;a.b=b;v(a)}} +function r(a,b){if(a.a==p){if(b==a)throw new TypeError;a.a=1;a.b=b;v(a)}}function v(a){l(function(){if(a.a!=p)for(;a.f.length;){var b=a.f.shift(),c=b[0],d=b[1],e=b[2],b=b[3];try{0==a.a?"function"==typeof c?e(c.call(void 0,a.b)):e(a.b):1==a.a&&("function"==typeof d?e(d.call(void 0,a.b)):b(a.b))}catch(h){b(h)}}})}n.prototype.g=function(a){return this.c(void 0,a)};n.prototype.c=function(a,b){var c=this;return new n(function(d,e){c.f.push([a,b,d,e]);v(c)})}; +function w(a){return new n(function(b,c){function d(c){return function(d){h[c]=d;e+=1;e==a.length&&b(h)}}var e=0,h=[];0==a.length&&b(h);for(var k=0;k 1) ? msg.result[1] : msg.result[0]; + } + else { + req.reject(new Error('Invalid message frame received')); + } + + if (req.expect) { + for (var key in req.expect) { + if (ret != null && key != '') + ret = ret[key]; + + if (ret == null || type.call(ret) != type.call(req.expect[key])) + ret = req.expect[key]; + + break; + } + } + + /* apply filter */ + if (typeof(req.filter) == 'function') { + req.priv[0] = ret; + req.priv[1] = req.params; + ret = req.filter.apply(this, req.priv); + } + + req.resolve(ret); + }, + + list: function() { + var msg = { + jsonrpc: '2.0', + id: rpcRequestID++, + method: 'list', + params: arguments.length ? this.varargs(arguments) : undefined + }; + + return this.call(msg, this.handleListReply); + }, + + declare: function(options) { + return Function.prototype.bind.call(function(rpc, options) { + var args = this.varargs(arguments, 2); + return new Promise(function(resolveFn, rejectFn) { + /* build parameter object */ + var p_off = 0; + var params = { }; + if (Array.isArray(options.params)) + for (p_off = 0; p_off < options.params.length; p_off++) + params[options.params[p_off]] = args[p_off]; + + /* all remaining arguments are private args */ + var priv = [ undefined, undefined ]; + for (; p_off < args.length; p_off++) + priv.push(args[p_off]); + + /* store request info */ + var req = { + expect: options.expect, + filter: options.filter, + resolve: resolveFn, + reject: rejectFn, + params: params, + priv: priv + }; + + /* build message object */ + var msg = { + jsonrpc: '2.0', + id: rpcRequestID++, + method: 'call', + params: [ + rpcSessionID, + options.object, + options.method, + params + ] + }; + + /* call rpc */ + rpc.call(msg, rpc.handleCallReply.bind(rpc, req)); + }); + }, this, this, options); + }, + + getSessionID: function() { + return rpcSessionID; + }, + + setSessionID: function(sid) { + rpcSessionID = sid; + }, + + getBaseURL: function() { + return rpcBaseURL; + }, + + setBaseURL: function(url) { + rpcBaseURL = url; + } +}); diff --git a/luci-base/htdocs/luci-static/resources/tools/prng.js b/luci-base/htdocs/luci-static/resources/tools/prng.js new file mode 100644 index 000000000..752dc75ce --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/tools/prng.js @@ -0,0 +1,93 @@ +'use strict'; + +var s = [0x0000, 0x0000, 0x0000, 0x0000]; + +function mul(a, b) { + var r = [0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000]; + + for (var j = 0; j < 4; j++) { + var k = 0; + for (var i = 0; i < 4; i++) { + var t = a[i] * b[j] + r[i+j] + k; + r[i+j] = t & 0xffff; + k = t >>> 16; + } + r[j+4] = k; + } + + r.length = 4; + + return r; +} + +function add(a, n) { + var r = [0x0000, 0x0000, 0x0000, 0x0000], + k = n; + + for (var i = 0; i < 4; i++) { + var t = a[i] + k; + r[i] = t & 0xffff; + k = t >>> 16; + } + + return r; +} + +function shr(a, n) { + var r = [a[0], a[1], a[2], a[3], 0x0000], + i = 4, + k = 0; + + for (; n > 16; n -= 16, i--) + for (var j = 0; j < 4; j++) + r[j] = r[j+1]; + + for (; i > 0; i--) { + var s = r[i-1]; + r[i-1] = (s >>> n) | k; + k = ((s & ((1 << n) - 1)) << (16 - n)); + } + + r.length = 4; + + return r; +} + +return L.Class.extend({ + seed: function(n) { + n = (n - 1)|0; + s[0] = n & 0xffff; + s[1] = n >>> 16; + s[2] = 0; + s[3] = 0; + }, + + int: function() { + s = mul(s, [0x7f2d, 0x4c95, 0xf42d, 0x5851]); + s = add(s, 1); + + var r = shr(s, 33); + return (r[1] << 16) | r[0]; + }, + + get: function() { + var r = (this.int() % 0x7fffffff) / 0x7fffffff, l, u; + + switch (arguments.length) { + case 0: + return r; + + case 1: + l = 1; + u = arguments[0]|0; + break; + + case 2: + l = arguments[0]|0; + u = arguments[1]|0; + break; + } + + return Math.floor(r * (u - l + 1)) + l; + } +}); diff --git a/luci-base/htdocs/luci-static/resources/tools/widgets.js b/luci-base/htdocs/luci-static/resources/tools/widgets.js new file mode 100644 index 000000000..7f2997f17 --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/tools/widgets.js @@ -0,0 +1,327 @@ +'use strict'; +'require ui'; +'require form'; +'require network'; +'require firewall'; + +var CBIZoneSelect = form.ListValue.extend({ + __name__: 'CBI.ZoneSelect', + + load: function(section_id) { + return Promise.all([ firewall.getZones(), network.getNetworks() ]).then(L.bind(function(zn) { + this.zones = zn[0]; + this.networks = zn[1]; + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, + + lookupZone: function(name) { + return this.zones.filter(function(zone) { return zone.getName() == name })[0]; + }, + + lookupNetwork: function(name) { + return this.networks.filter(function(network) { return network.getName() == name })[0]; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var values = L.toArray((cfgvalue != null) ? cfgvalue : this.default), + choices = {}; + + if (this.allowlocal) { + choices[''] = E('span', { + 'class': 'zonebadge', + 'style': 'background-color:' + firewall.getColorForName(null) + }, [ + E('strong', _('Device')), + (this.allowany || this.allowlocal) + ? ' (%s)'.format(this.alias != 'dest' ? _('output') : _('input')) : '' + ]); + } + else if (!this.multiple && (this.rmempty || this.optional)) { + choices[''] = E('span', { + 'class': 'zonebadge', + 'style': 'background-color:' + firewall.getColorForName(null) + }, E('em', _('unspecified'))); + } + + if (this.allowany) { + choices['*'] = E('span', { + 'class': 'zonebadge', + 'style': 'background-color:' + firewall.getColorForName(null) + }, [ + E('strong', _('Any zone')), + (this.allowany && this.allowlocal) ? ' (%s)'.format(_('forward')) : '' + ]); + } + + for (var i = 0; i < this.zones.length; i++) { + var zone = this.zones[i], + name = zone.getName(), + networks = zone.getNetworks(), + ifaces = []; + + if (!this.filter(section_id, name)) + continue; + + for (var j = 0; j < networks.length; j++) { + var network = this.lookupNetwork(networks[j]); + + if (!network) + continue; + + var span = E('span', { + 'class': 'ifacebadge' + (network.getName() == this.network ? ' ifacebadge-active' : '') + }, network.getName() + ': '); + + var devices = network.isBridge() ? network.getDevices() : L.toArray(network.getDevice()); + + for (var k = 0; k < devices.length; k++) { + span.appendChild(E('img', { + 'title': devices[k].getI18n(), + 'src': L.resource('icons/%s%s.png'.format(devices[k].getType(), devices[k].isUp() ? '' : '_disabled')) + })); + } + + if (!devices.length) + span.appendChild(E('em', _('(empty)'))); + + ifaces.push(span); + } + + if (!ifaces.length) + ifaces.push(E('em', _('(empty)'))); + + choices[name] = E('span', { + 'class': 'zonebadge', + 'style': 'background-color:' + zone.getColor() + }, [ E('strong', name) ].concat(ifaces)); + } + + var widget = new ui.Dropdown(values, choices, { + id: this.cbid(section_id), + sort: true, + multiple: this.multiple, + optional: this.optional || this.rmempty, + select_placeholder: E('em', _('unspecified')), + display_items: this.display_size || this.size || 3, + dropdown_items: this.dropdown_size || this.size || 5, + validate: L.bind(this.validate, this, section_id), + create: !this.nocreate, + create_markup: '' + + '
  • ' + + '' + + '{{value}}: ('+_('create')+')' + + '' + + '
  • ' + }); + + return widget.render(); + }, +}); + +var CBIZoneForwards = form.DummyValue.extend({ + __name__: 'CBI.ZoneForwards', + + load: function(section_id) { + return Promise.all([ firewall.getDefaults(), firewall.getZones(), network.getNetworks() ]).then(L.bind(function(dzn) { + this.defaults = dzn[0]; + this.zones = dzn[1]; + this.networks = dzn[2]; + + return this.super('load', section_id); + }, this)); + }, + + renderZone: function(zone) { + var name = zone.getName(), + networks = zone.getNetworks(), + ifaces = []; + + for (var j = 0; j < networks.length; j++) { + var network = this.networks.filter(function(net) { return net.getName() == networks[j] })[0]; + + if (!network) + continue; + + var span = E('span', { + 'class': 'ifacebadge' + (network.getName() == this.network ? ' ifacebadge-active' : '') + }, network.getName() + ': '); + + var devices = network.isBridge() ? network.getDevices() : L.toArray(network.getDevice()); + + for (var k = 0; k < devices.length && devices[k]; k++) { + span.appendChild(E('img', { + 'title': devices[k].getI18n(), + 'src': L.resource('icons/%s%s.png'.format(devices[k].getType(), devices[k].isUp() ? '' : '_disabled')) + })); + } + + if (!devices.length) + span.appendChild(E('em', _('(empty)'))); + + ifaces.push(span); + } + + if (!ifaces.length) + ifaces.push(E('span', { 'class': 'ifacebadge' }, E('em', _('(empty)')))); + + return E('label', { + 'class': 'zonebadge cbi-tooltip-container', + 'style': 'background-color:' + zone.getColor() + }, [ + E('strong', name), + E('div', { 'class': 'cbi-tooltip' }, ifaces) + ]); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + zone = this.zones.filter(function(z) { return z.getName() == value })[0]; + + if (!zone) + return E([]); + + var forwards = zone.getForwardingsBy('src'), + dzones = []; + + for (var i = 0; i < forwards.length; i++) { + var dzone = forwards[i].getDestinationZone(); + + if (!dzone) + continue; + + dzones.push(this.renderZone(dzone)); + } + + if (!dzones.length) + dzones.push(E('label', { 'class': 'zonebadge zonebadge-empty' }, + E('strong', this.defaults.getForward()))); + + return E('div', { 'class': 'zone-forwards' }, [ + E('div', { 'class': 'zone-src' }, this.renderZone(zone)), + E('span', '⇒'), + E('div', { 'class': 'zone-dest' }, dzones) + ]); + }, +}); + +var CBINetworkSelect = form.ListValue.extend({ + __name__: 'CBI.NetworkSelect', + + load: function(section_id) { + return network.getNetworks().then(L.bind(function(networks) { + this.networks = networks; + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, + + renderIfaceBadge: function(network) { + var span = E('span', { 'class': 'ifacebadge' }, network.getName() + ': '), + devices = network.isBridge() ? network.getDevices() : L.toArray(network.getDevice()); + + for (var j = 0; j < devices.length && devices[j]; j++) { + span.appendChild(E('img', { + 'title': devices[j].getI18n(), + 'src': L.resource('icons/%s%s.png'.format(devices[j].getType(), devices[j].isUp() ? '' : '_disabled')) + })); + } + + if (!devices.length) { + span.appendChild(E('em', { 'class': 'hide-close' }, _('(no interfaces attached)'))); + span.appendChild(E('em', { 'class': 'hide-open' }, '-')); + } + + return span; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var values = L.toArray((cfgvalue != null) ? cfgvalue : this.default), + choices = {}, + checked = {}; + + for (var i = 0; i < values.length; i++) + checked[values[i]] = true; + + values = []; + + if (!this.multiple && (this.rmempty || this.optional)) + choices[''] = E('em', _('unspecified')); + + for (var i = 0; i < this.networks.length; i++) { + var network = this.networks[i], + name = network.getName(); + + if (name == 'loopback' || !this.filter(section_id, name)) + continue; + + if (this.novirtual && network.isVirtual()) + continue; + + if (checked[name]) + values.push(name); + + choices[name] = this.renderIfaceBadge(network); + } + + var widget = new ui.Dropdown(this.multiple ? values : values[0], choices, { + id: this.cbid(section_id), + sort: true, + multiple: this.multiple, + optional: this.optional || this.rmempty, + select_placeholder: E('em', _('unspecified')), + display_items: this.display_size || this.size || 3, + dropdown_items: this.dropdown_size || this.size || 5, + validate: L.bind(this.validate, this, section_id), + create: !this.nocreate, + create_markup: '' + + '
  • ' + + '' + + '{{value}}: ('+_('create')+')' + + '' + + '
  • ' + }); + + return widget.render(); + }, + + textvalue: function(section_id) { + var cfgvalue = this.cfgvalue(section_id), + values = L.toArray((cfgvalue != null) ? cfgvalue : this.default), + rv = E([]); + + for (var i = 0; i < (this.networks || []).length; i++) { + var network = this.networks[i], + name = network.getName(); + + if (values.indexOf(name) == -1) + continue; + + if (rv.length) + L.dom.append(rv, ' '); + + L.dom.append(rv, this.renderIfaceBadge(network)); + } + + if (!rv.firstChild) + rv.appendChild(E('em', _('unspecified'))); + + return rv; + }, +}); + + +return L.Class.extend({ + ZoneSelect: CBIZoneSelect, + ZoneForwards: CBIZoneForwards, + NetworkSelect: CBINetworkSelect +}); diff --git a/luci-base/htdocs/luci-static/resources/uci.js b/luci-base/htdocs/luci-static/resources/uci.js new file mode 100644 index 000000000..17f11eecb --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/uci.js @@ -0,0 +1,540 @@ +'use strict'; +'require rpc'; + +return L.Class.extend({ + __init__: function() { + this.state = { + newidx: 0, + values: { }, + creates: { }, + changes: { }, + deletes: { }, + reorder: { } + }; + + this.loaded = {}; + }, + + callLoad: rpc.declare({ + object: 'uci', + method: 'get', + params: [ 'config' ], + expect: { values: { } } + }), + + callOrder: rpc.declare({ + object: 'uci', + method: 'order', + params: [ 'config', 'sections' ] + }), + + callAdd: rpc.declare({ + object: 'uci', + method: 'add', + params: [ 'config', 'type', 'name', 'values' ], + expect: { section: '' } + }), + + callSet: rpc.declare({ + object: 'uci', + method: 'set', + params: [ 'config', 'section', 'values' ] + }), + + callDelete: rpc.declare({ + object: 'uci', + method: 'delete', + params: [ 'config', 'section', 'options' ] + }), + + callApply: rpc.declare({ + object: 'uci', + method: 'apply', + params: [ 'timeout', 'rollback' ] + }), + + callConfirm: rpc.declare({ + object: 'uci', + method: 'confirm' + }), + + createSID: function(conf) { + var v = this.state.values, + n = this.state.creates, + sid; + + do { + sid = "new%06x".format(Math.random() * 0xFFFFFF); + } while ((n[conf] && n[conf][sid]) || (v[conf] && v[conf][sid])); + + return sid; + }, + + resolveSID: function(conf, sid) { + if (typeof(sid) != 'string') + return sid; + + var m = /^@([a-zA-Z0-9_-]+)\[(-?[0-9]+)\]$/.exec(sid); + + if (m) { + var type = m[1], + pos = +m[2], + sections = this.sections(conf, type), + section = sections[pos >= 0 ? pos : sections.length + pos]; + + return section ? section['.name'] : null; + } + + return sid; + }, + + reorderSections: function() { + var v = this.state.values, + n = this.state.creates, + r = this.state.reorder, + tasks = []; + + if (Object.keys(r).length === 0) + return Promise.resolve(); + + /* + gather all created and existing sections, sort them according + to their index value and issue an uci order call + */ + for (var c in r) { + var o = [ ]; + + if (n[c]) + for (var s in n[c]) + o.push(n[c][s]); + + for (var s in v[c]) + o.push(v[c][s]); + + if (o.length > 0) { + o.sort(function(a, b) { + return (a['.index'] - b['.index']); + }); + + var sids = [ ]; + + for (var i = 0; i < o.length; i++) + sids.push(o[i]['.name']); + + tasks.push(this.callOrder(c, sids)); + } + } + + this.state.reorder = { }; + return Promise.all(tasks); + }, + + loadPackage: function(packageName) { + if (this.loaded[packageName] == null) + return (this.loaded[packageName] = this.callLoad(packageName)); + + return Promise.resolve(this.loaded[packageName]); + }, + + load: function(packages) { + var self = this, + pkgs = [ ], + tasks = []; + + if (!Array.isArray(packages)) + packages = [ packages ]; + + for (var i = 0; i < packages.length; i++) + if (!self.state.values[packages[i]]) { + pkgs.push(packages[i]); + tasks.push(self.loadPackage(packages[i])); + } + + return Promise.all(tasks).then(function(responses) { + for (var i = 0; i < responses.length; i++) + self.state.values[pkgs[i]] = responses[i]; + + if (responses.length) + document.dispatchEvent(new CustomEvent('uci-loaded')); + + return pkgs; + }); + }, + + unload: function(packages) { + if (!Array.isArray(packages)) + packages = [ packages ]; + + for (var i = 0; i < packages.length; i++) { + delete this.state.values[packages[i]]; + delete this.state.creates[packages[i]]; + delete this.state.changes[packages[i]]; + delete this.state.deletes[packages[i]]; + + delete this.loaded[packages[i]]; + } + }, + + add: function(conf, type, name) { + var n = this.state.creates, + sid = name || this.createSID(conf); + + if (!n[conf]) + n[conf] = { }; + + n[conf][sid] = { + '.type': type, + '.name': sid, + '.create': name, + '.anonymous': !name, + '.index': 1000 + this.state.newidx++ + }; + + return sid; + }, + + remove: function(conf, sid) { + var n = this.state.creates, + c = this.state.changes, + d = this.state.deletes; + + /* requested deletion of a just created section */ + if (n[conf] && n[conf][sid]) { + delete n[conf][sid]; + } + else { + if (c[conf]) + delete c[conf][sid]; + + if (!d[conf]) + d[conf] = { }; + + d[conf][sid] = true; + } + }, + + sections: function(conf, type, cb) { + var sa = [ ], + v = this.state.values[conf], + n = this.state.creates[conf], + c = this.state.changes[conf], + d = this.state.deletes[conf]; + + if (!v) + return sa; + + for (var s in v) + if (!d || d[s] !== true) + if (!type || v[s]['.type'] == type) + sa.push(Object.assign({ }, v[s], c ? c[s] : undefined)); + + if (n) + for (var s in n) + if (!type || n[s]['.type'] == type) + sa.push(Object.assign({ }, n[s])); + + sa.sort(function(a, b) { + return a['.index'] - b['.index']; + }); + + for (var i = 0; i < sa.length; i++) + sa[i]['.index'] = i; + + if (typeof(cb) == 'function') + for (var i = 0; i < sa.length; i++) + cb.call(this, sa[i], sa[i]['.name']); + + return sa; + }, + + get: function(conf, sid, opt) { + var v = this.state.values, + n = this.state.creates, + c = this.state.changes, + d = this.state.deletes; + + sid = this.resolveSID(conf, sid); + + if (sid == null) + return null; + + /* requested option in a just created section */ + if (n[conf] && n[conf][sid]) { + if (!n[conf]) + return undefined; + + if (opt == null) + return n[conf][sid]; + + return n[conf][sid][opt]; + } + + /* requested an option value */ + if (opt != null) { + /* check whether option was deleted */ + if (d[conf] && d[conf][sid]) { + if (d[conf][sid] === true) + return undefined; + + for (var i = 0; i < d[conf][sid].length; i++) + if (d[conf][sid][i] == opt) + return undefined; + } + + /* check whether option was changed */ + if (c[conf] && c[conf][sid] && c[conf][sid][opt] != null) + return c[conf][sid][opt]; + + /* return base value */ + if (v[conf] && v[conf][sid]) + return v[conf][sid][opt]; + + return undefined; + } + + /* requested an entire section */ + if (v[conf]) + return v[conf][sid]; + + return undefined; + }, + + set: function(conf, sid, opt, val) { + var v = this.state.values, + n = this.state.creates, + c = this.state.changes, + d = this.state.deletes; + + sid = this.resolveSID(conf, sid); + + if (sid == null || opt == null || opt.charAt(0) == '.') + return; + + if (n[conf] && n[conf][sid]) { + if (val != null) + n[conf][sid][opt] = val; + else + delete n[conf][sid][opt]; + } + else if (val != null && val !== '') { + /* do not set within deleted section */ + if (d[conf] && d[conf][sid] === true) + return; + + /* only set in existing sections */ + if (!v[conf] || !v[conf][sid]) + return; + + if (!c[conf]) + c[conf] = {}; + + if (!c[conf][sid]) + c[conf][sid] = {}; + + /* undelete option */ + if (d[conf] && d[conf][sid]) + d[conf][sid] = d[conf][sid].filter(function(o) { return o !== opt }); + + c[conf][sid][opt] = val; + } + else { + /* only delete in existing sections */ + if (!(v[conf] && v[conf][sid] && v[conf][sid].hasOwnProperty(opt)) && + !(c[conf] && c[conf][sid] && c[conf][sid].hasOwnProperty(opt))) + return; + + if (!d[conf]) + d[conf] = { }; + + if (!d[conf][sid]) + d[conf][sid] = [ ]; + + if (d[conf][sid] !== true) + d[conf][sid].push(opt); + } + }, + + unset: function(conf, sid, opt) { + return this.set(conf, sid, opt, null); + }, + + get_first: function(conf, type, opt) { + var sid = null; + + this.sections(conf, type, function(s) { + if (sid == null) + sid = s['.name']; + }); + + return this.get(conf, sid, opt); + }, + + set_first: function(conf, type, opt, val) { + var sid = null; + + this.sections(conf, type, function(s) { + if (sid == null) + sid = s['.name']; + }); + + return this.set(conf, sid, opt, val); + }, + + unset_first: function(conf, type, opt) { + return this.set_first(conf, type, opt, null); + }, + + move: function(conf, sid1, sid2, after) { + var sa = this.sections(conf), + s1 = null, s2 = null; + + sid1 = this.resolveSID(conf, sid1); + sid2 = this.resolveSID(conf, sid2); + + for (var i = 0; i < sa.length; i++) { + if (sa[i]['.name'] != sid1) + continue; + + s1 = sa[i]; + sa.splice(i, 1); + break; + } + + if (s1 == null) + return false; + + if (sid2 == null) { + sa.push(s1); + } + else { + for (var i = 0; i < sa.length; i++) { + if (sa[i]['.name'] != sid2) + continue; + + s2 = sa[i]; + sa.splice(i + !!after, 0, s1); + break; + } + + if (s2 == null) + return false; + } + + for (var i = 0; i < sa.length; i++) + this.get(conf, sa[i]['.name'])['.index'] = i; + + this.state.reorder[conf] = true; + + return true; + }, + + save: function() { + var v = this.state.values, + n = this.state.creates, + c = this.state.changes, + d = this.state.deletes, + r = this.state.reorder, + self = this, + snew = [ ], + pkgs = { }, + tasks = []; + + if (n) + for (var conf in n) { + for (var sid in n[conf]) { + var r = { + config: conf, + values: { } + }; + + for (var k in n[conf][sid]) { + if (k == '.type') + r.type = n[conf][sid][k]; + else if (k == '.create') + r.name = n[conf][sid][k]; + else if (k.charAt(0) != '.') + r.values[k] = n[conf][sid][k]; + } + + snew.push(n[conf][sid]); + tasks.push(self.callAdd(r.config, r.type, r.name, r.values)); + } + + pkgs[conf] = true; + } + + if (c) + for (var conf in c) { + for (var sid in c[conf]) + tasks.push(self.callSet(conf, sid, c[conf][sid])); + + pkgs[conf] = true; + } + + if (d) + for (var conf in d) { + for (var sid in d[conf]) { + var o = d[conf][sid]; + tasks.push(self.callDelete(conf, sid, (o === true) ? null : o)); + } + + pkgs[conf] = true; + } + + if (r) + for (var conf in r) + pkgs[conf] = true; + + return Promise.all(tasks).then(function(responses) { + /* + array "snew" holds references to the created uci sections, + use it to assign the returned names of the new sections + */ + for (var i = 0; i < snew.length; i++) + snew[i]['.name'] = responses[i]; + + return self.reorderSections(); + }).then(function() { + pkgs = Object.keys(pkgs); + + self.unload(pkgs); + + return self.load(pkgs); + }); + }, + + apply: function(timeout) { + var self = this, + date = new Date(); + + if (typeof(timeout) != 'number' || timeout < 1) + timeout = 10; + + return self.callApply(timeout, true).then(function(rv) { + if (rv != 0) + return Promise.reject(rv); + + var try_deadline = date.getTime() + 1000 * timeout; + var try_confirm = function() { + return self.callConfirm().then(function(rv) { + if (rv != 0) { + if (date.getTime() < try_deadline) + window.setTimeout(try_confirm, 250); + else + return Promise.reject(rv); + } + + return rv; + }); + }; + + window.setTimeout(try_confirm, 1000); + }); + }, + + changes: rpc.declare({ + object: 'uci', + method: 'changes', + expect: { changes: { } } + }) +}); diff --git a/luci-base/htdocs/luci-static/resources/ui.js b/luci-base/htdocs/luci-static/resources/ui.js new file mode 100644 index 000000000..4aac8bcfc --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/ui.js @@ -0,0 +1,2054 @@ +'use strict'; +'require uci'; +'require validation'; + +var modalDiv = null, + tooltipDiv = null, + tooltipTimeout = null; + +var UIElement = L.Class.extend({ + getValue: function() { + if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input')) + return this.node.value; + + return null; + }, + + setValue: function(value) { + if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input')) + this.node.value = value; + }, + + isValid: function() { + return (this.validState !== false); + }, + + triggerValidation: function() { + if (typeof(this.vfunc) != 'function') + return false; + + var wasValid = this.isValid(); + + this.vfunc(); + + return (wasValid != this.isValid()); + }, + + registerEvents: function(targetNode, synevent, events) { + var dispatchFn = L.bind(function(ev) { + this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true })); + }, this); + + for (var i = 0; i < events.length; i++) + targetNode.addEventListener(events[i], dispatchFn); + }, + + setUpdateEvents: function(targetNode /*, ... */) { + var datatype = this.options.datatype, + optional = this.options.hasOwnProperty('optional') ? this.options.optional : true, + validate = this.options.validate, + events = this.varargs(arguments, 1); + + this.registerEvents(targetNode, 'widget-update', events); + + if (!datatype && !validate) + return; + + this.vfunc = L.ui.addValidator.apply(L.ui, [ + targetNode, datatype || 'string', + optional, validate + ].concat(events)); + + this.node.addEventListener('validation-success', L.bind(function(ev) { + this.validState = true; + }, this)); + + this.node.addEventListener('validation-failure', L.bind(function(ev) { + this.validState = false; + }, this)); + }, + + setChangeEvents: function(targetNode /*, ... */) { + this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1)); + } +}); + +var UITextfield = UIElement.extend({ + __init__: function(value, options) { + this.value = value; + this.options = Object.assign({ + optional: true, + password: false + }, options); + }, + + render: function() { + var frameEl = E('div', { 'id': this.options.id }); + + if (this.options.password) { + frameEl.classList.add('nowrap'); + frameEl.appendChild(E('input', { + 'type': 'password', + 'style': 'position:absolute; left:-100000px', + 'aria-hidden': true, + 'tabindex': -1, + 'name': this.options.name ? 'password.%s'.format(this.options.name) : null + })); + } + + frameEl.appendChild(E('input', { + 'id': this.options.id ? 'widget.' + this.options.id : null, + 'name': this.options.name, + 'type': this.options.password ? 'password' : 'text', + 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text', + 'readonly': this.options.readonly ? '' : null, + 'maxlength': this.options.maxlength, + 'placeholder': this.options.placeholder, + 'value': this.value, + })); + + if (this.options.password) + frameEl.appendChild(E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'title': _('Reveal/hide password'), + 'aria-label': _('Reveal/hide password'), + 'click': function(ev) { + var e = this.previousElementSibling; + e.type = (e.type === 'password') ? 'text' : 'password'; + ev.preventDefault(); + } + }, '∗')); + + return this.bind(frameEl); + }, + + bind: function(frameEl) { + var inputEl = frameEl.childNodes[+!!this.options.password]; + + this.node = frameEl; + + this.setUpdateEvents(inputEl, 'keyup', 'blur'); + this.setChangeEvents(inputEl, 'change'); + + L.dom.bindClassInstance(frameEl, this); + + return frameEl; + }, + + getValue: function() { + var inputEl = this.node.childNodes[+!!this.options.password]; + return inputEl.value; + }, + + setValue: function(value) { + var inputEl = this.node.childNodes[+!!this.options.password]; + inputEl.value = value; + } +}); + +var UICheckbox = UIElement.extend({ + __init__: function(value, options) { + this.value = value; + this.options = Object.assign({ + value_enabled: '1', + value_disabled: '0' + }, options); + }, + + render: function() { + var frameEl = E('div', { + 'id': this.options.id, + 'class': 'cbi-checkbox' + }); + + if (this.options.hiddenname) + frameEl.appendChild(E('input', { + 'type': 'hidden', + 'name': this.options.hiddenname, + 'value': 1 + })); + + frameEl.appendChild(E('input', { + 'id': this.options.id ? 'widget.' + this.options.id : null, + 'name': this.options.name, + 'type': 'checkbox', + 'value': this.options.value_enabled, + 'checked': (this.value == this.options.value_enabled) ? '' : null + })); + + return this.bind(frameEl); + }, + + bind: function(frameEl) { + this.node = frameEl; + + this.setUpdateEvents(frameEl.lastElementChild, 'click', 'blur'); + this.setChangeEvents(frameEl.lastElementChild, 'change'); + + L.dom.bindClassInstance(frameEl, this); + + return frameEl; + }, + + isChecked: function() { + return this.node.lastElementChild.checked; + }, + + getValue: function() { + return this.isChecked() + ? this.options.value_enabled + : this.options.value_disabled; + }, + + setValue: function(value) { + this.node.lastElementChild.checked = (value == this.options.value_enabled); + } +}); + +var UISelect = UIElement.extend({ + __init__: function(value, choices, options) { + if (typeof(choices) != 'object') + choices = {}; + + if (!Array.isArray(value)) + value = (value != null && value != '') ? [ value ] : []; + + if (!options.multi && value.length > 1) + value.length = 1; + + this.values = value; + this.choices = choices; + this.options = Object.assign({ + multi: false, + widget: 'select', + orientation: 'horizontal' + }, options); + }, + + render: function() { + var frameEl = E('div', { 'id': this.options.id }), + keys = Object.keys(this.choices); + + if (this.options.sort === true) + keys.sort(); + else if (Array.isArray(this.options.sort)) + keys = this.options.sort; + + if (this.options.widget == 'select') { + frameEl.appendChild(E('select', { + 'id': this.options.id ? 'widget.' + this.options.id : null, + 'name': this.options.name, + 'size': this.options.size, + 'class': 'cbi-input-select', + 'multiple': this.options.multi ? '' : null + })); + + if (this.options.optional) + frameEl.lastChild.appendChild(E('option', { + 'value': '', + 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null + }, this.choices[''] || this.options.placeholder || _('-- Please choose --'))); + + for (var i = 0; i < keys.length; i++) { + if (keys[i] == null || keys[i] == '') + continue; + + frameEl.lastChild.appendChild(E('option', { + 'value': keys[i], + 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null + }, this.choices[keys[i]] || keys[i])); + } + } + else { + var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br'); + + for (var i = 0; i < keys.length; i++) { + frameEl.appendChild(E('label', {}, [ + E('input', { + 'id': this.options.id ? 'widget.' + this.options.id : null, + 'name': this.options.id || this.options.name, + 'type': this.options.multi ? 'checkbox' : 'radio', + 'class': this.options.multi ? 'cbi-input-checkbox' : 'cbi-input-radio', + 'value': keys[i], + 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null + }), + this.choices[keys[i]] || keys[i] + ])); + + if (i + 1 == this.options.size) + frameEl.appendChild(brEl); + } + } + + return this.bind(frameEl); + }, + + bind: function(frameEl) { + this.node = frameEl; + + if (this.options.widget == 'select') { + this.setUpdateEvents(frameEl, 'change', 'click', 'blur'); + this.setChangeEvents(frameEl, 'change'); + } + else { + var radioEls = frameEl.querySelectorAll('input[type="radio"]'); + for (var i = 0; i < radioEls.length; i++) { + this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur'); + this.setChangeEvents(radioEls[i], 'change', 'click', 'blur'); + } + } + + L.dom.bindClassInstance(frameEl, this); + + return frameEl; + }, + + getValue: function() { + if (this.options.widget == 'select') + return this.node.firstChild.value; + + var radioEls = frameEl.querySelectorAll('input[type="radio"]'); + for (var i = 0; i < radioEls.length; i++) + if (radioEls[i].checked) + return radioEls[i].value; + + return null; + }, + + setValue: function(value) { + if (this.options.widget == 'select') { + if (value == null) + value = ''; + + for (var i = 0; i < this.node.options.length; i++) + this.node.options[i].selected = (this.node.options[i].value == value); + + return; + } + + var radioEls = frameEl.querySelectorAll('input[type="radio"]'); + for (var i = 0; i < radioEls.length; i++) + radioEls[i].checked = (radioEls[i].value == value); + } +}); + +var UIDropdown = UIElement.extend({ + __init__: function(value, choices, options) { + if (typeof(choices) != 'object') + choices = {}; + + if (!Array.isArray(value)) + this.values = (value != null && value != '') ? [ value ] : []; + else + this.values = value; + + this.choices = choices; + this.options = Object.assign({ + sort: true, + multi: Array.isArray(value), + optional: true, + select_placeholder: _('-- Please choose --'), + custom_placeholder: _('-- custom --'), + display_items: 3, + dropdown_items: -1, + create: false, + create_query: '.create-item-input', + create_template: 'script[type="item-template"]' + }, options); + }, + + render: function() { + var sb = E('div', { + 'id': this.options.id, + 'class': 'cbi-dropdown', + 'multiple': this.options.multi ? '' : null, + 'optional': this.options.optional ? '' : null, + }, E('ul')); + + var keys = Object.keys(this.choices); + + if (this.options.sort === true) + keys.sort(); + else if (Array.isArray(this.options.sort)) + keys = this.options.sort; + + if (this.options.create) + for (var i = 0; i < this.values.length; i++) + if (!this.choices.hasOwnProperty(this.values[i])) + keys.push(this.values[i]); + + for (var i = 0; i < keys.length; i++) + sb.lastElementChild.appendChild(E('li', { + 'data-value': keys[i], + 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null + }, this.choices[keys[i]] || keys[i])); + + if (this.options.create) { + var createEl = E('input', { + 'type': 'text', + 'class': 'create-item-input', + 'readonly': this.options.readonly ? '' : null, + 'maxlength': this.options.maxlength, + 'placeholder': this.options.custom_placeholder || this.options.placeholder + }); + + if (this.options.datatype) + L.ui.addValidator(createEl, this.options.datatype, + true, null, 'blur', 'keyup'); + + sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl)); + } + + if (this.options.create_markup) + sb.appendChild(E('script', { type: 'item-template' }, + this.options.create_markup)); + + return this.bind(sb); + }, + + bind: function(sb) { + var o = this.options; + + o.multi = sb.hasAttribute('multiple'); + o.optional = sb.hasAttribute('optional'); + o.placeholder = sb.getAttribute('placeholder') || o.placeholder; + o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items); + o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items); + o.create_query = sb.getAttribute('item-create') || o.create_query; + o.create_template = sb.getAttribute('item-template') || o.create_template; + + var ul = sb.querySelector('ul'), + more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')), + open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')), + canary = sb.appendChild(E('div')), + create = sb.querySelector(this.options.create_query), + ndisplay = this.options.display_items, + n = 0; + + if (this.options.multi) { + var items = ul.querySelectorAll('li'); + + for (var i = 0; i < items.length; i++) { + this.transformItem(sb, items[i]); + + if (items[i].hasAttribute('selected') && ndisplay-- > 0) + items[i].setAttribute('display', n++); + } + } + else { + if (this.options.optional && !ul.querySelector('li[data-value=""]')) { + var placeholder = E('li', { placeholder: '' }, + this.options.select_placeholder || this.options.placeholder); + + ul.firstChild + ? ul.insertBefore(placeholder, ul.firstChild) + : ul.appendChild(placeholder); + } + + var items = ul.querySelectorAll('li'), + sel = sb.querySelectorAll('[selected]'); + + sel.forEach(function(s) { + s.removeAttribute('selected'); + }); + + var s = sel[0] || items[0]; + if (s) { + s.setAttribute('selected', ''); + s.setAttribute('display', n++); + } + + ndisplay--; + } + + this.saveValues(sb, ul); + + ul.setAttribute('tabindex', -1); + sb.setAttribute('tabindex', 0); + + if (ndisplay < 0) + sb.setAttribute('more', '') + else + sb.removeAttribute('more'); + + if (ndisplay == this.options.display_items) + sb.setAttribute('empty', '') + else + sb.removeAttribute('empty'); + + L.dom.content(more, (ndisplay == this.options.display_items) + ? (this.options.select_placeholder || this.options.placeholder) : '···'); + + + sb.addEventListener('click', this.handleClick.bind(this)); + sb.addEventListener('keydown', this.handleKeydown.bind(this)); + sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this)); + sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this)); + + if ('ontouchstart' in window) { + sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); }); + window.addEventListener('touchstart', this.closeAllDropdowns); + } + else { + sb.addEventListener('mouseover', this.handleMouseover.bind(this)); + sb.addEventListener('focus', this.handleFocus.bind(this)); + + canary.addEventListener('focus', this.handleCanaryFocus.bind(this)); + + window.addEventListener('mouseover', this.setFocus); + window.addEventListener('click', this.closeAllDropdowns); + } + + if (create) { + create.addEventListener('keydown', this.handleCreateKeydown.bind(this)); + create.addEventListener('focus', this.handleCreateFocus.bind(this)); + create.addEventListener('blur', this.handleCreateBlur.bind(this)); + + var li = findParent(create, 'li'); + + li.setAttribute('unselectable', ''); + li.addEventListener('click', this.handleCreateClick.bind(this)); + } + + this.node = sb; + + this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close'); + this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close'); + + L.dom.bindClassInstance(sb, this); + + return sb; + }, + + openDropdown: function(sb) { + var st = window.getComputedStyle(sb, null), + ul = sb.querySelector('ul'), + li = ul.querySelectorAll('li'), + fl = findParent(sb, '.cbi-value-field'), + sel = ul.querySelector('[selected]'), + rect = sb.getBoundingClientRect(), + items = Math.min(this.options.dropdown_items, li.length); + + document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { + s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); + }); + + sb.setAttribute('open', ''); + + var pv = ul.cloneNode(true); + pv.classList.add('preview'); + + if (fl) + fl.classList.add('cbi-dropdown-open'); + + if ('ontouchstart' in window) { + var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0), + vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0), + scrollFrom = window.pageYOffset, + scrollTo = scrollFrom + rect.top - vpHeight * 0.5, + start = null; + + ul.style.top = sb.offsetHeight + 'px'; + ul.style.left = -rect.left + 'px'; + ul.style.right = (rect.right - vpWidth) + 'px'; + ul.style.maxHeight = (vpHeight * 0.5) + 'px'; + ul.style.WebkitOverflowScrolling = 'touch'; + + var scrollStep = function(timestamp) { + if (!start) { + start = timestamp; + ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0; + } + + var duration = Math.max(timestamp - start, 1); + if (duration < 100) { + document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100); + window.requestAnimationFrame(scrollStep); + } + else { + document.body.scrollTop = scrollTo; + } + }; + + window.requestAnimationFrame(scrollStep); + } + else { + ul.style.maxHeight = '1px'; + ul.style.top = ul.style.bottom = ''; + + window.requestAnimationFrame(function() { + var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height, + fullHeight = 0, + spaceAbove = rect.top, + spaceBelow = window.innerHeight - rect.height - rect.top; + + for (var i = 0; i < (items == -1 ? li.length : items); i++) + fullHeight += li[i].getBoundingClientRect().height; + + if (fullHeight <= spaceBelow) { + ul.style.top = rect.height + 'px'; + ul.style.maxHeight = spaceBelow + 'px'; + } + else if (fullHeight <= spaceAbove) { + ul.style.bottom = rect.height + 'px'; + ul.style.maxHeight = spaceAbove + 'px'; + } + else if (spaceBelow >= spaceAbove) { + ul.style.top = rect.height + 'px'; + ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px'; + } + else { + ul.style.bottom = rect.height + 'px'; + ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px'; + } + + ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0; + }); + } + + var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]'); + for (var i = 0; i < cboxes.length; i++) { + cboxes[i].checked = true; + cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional); + }; + + ul.classList.add('dropdown'); + + sb.insertBefore(pv, ul.nextElementSibling); + + li.forEach(function(l) { + l.setAttribute('tabindex', 0); + }); + + sb.lastElementChild.setAttribute('tabindex', 0); + + this.setFocus(sb, sel || li[0], true); + }, + + closeDropdown: function(sb, no_focus) { + if (!sb.hasAttribute('open')) + return; + + var pv = sb.querySelector('ul.preview'), + ul = sb.querySelector('ul.dropdown'), + li = ul.querySelectorAll('li'), + fl = findParent(sb, '.cbi-value-field'); + + li.forEach(function(l) { l.removeAttribute('tabindex'); }); + sb.lastElementChild.removeAttribute('tabindex'); + + sb.removeChild(pv); + sb.removeAttribute('open'); + sb.style.width = sb.style.height = ''; + + ul.classList.remove('dropdown'); + ul.style.top = ul.style.bottom = ul.style.maxHeight = ''; + + if (fl) + fl.classList.remove('cbi-dropdown-open'); + + if (!no_focus) + this.setFocus(sb, sb); + + this.saveValues(sb, ul); + }, + + toggleItem: function(sb, li, force_state) { + if (li.hasAttribute('unselectable')) + return; + + if (this.options.multi) { + var cbox = li.querySelector('input[type="checkbox"]'), + items = li.parentNode.querySelectorAll('li'), + label = sb.querySelector('ul.preview'), + sel = li.parentNode.querySelectorAll('[selected]').length, + more = sb.querySelector('.more'), + ndisplay = this.options.display_items, + n = 0; + + if (li.hasAttribute('selected')) { + if (force_state !== true) { + if (sel > 1 || this.options.optional) { + li.removeAttribute('selected'); + cbox.checked = cbox.disabled = false; + sel--; + } + else { + cbox.disabled = true; + } + } + } + else { + if (force_state !== false) { + li.setAttribute('selected', ''); + cbox.checked = true; + cbox.disabled = false; + sel++; + } + } + + while (label && label.firstElementChild) + label.removeChild(label.firstElementChild); + + for (var i = 0; i < items.length; i++) { + items[i].removeAttribute('display'); + if (items[i].hasAttribute('selected')) { + if (ndisplay-- > 0) { + items[i].setAttribute('display', n++); + if (label) + label.appendChild(items[i].cloneNode(true)); + } + var c = items[i].querySelector('input[type="checkbox"]'); + if (c) + c.disabled = (sel == 1 && !this.options.optional); + } + } + + if (ndisplay < 0) + sb.setAttribute('more', ''); + else + sb.removeAttribute('more'); + + if (ndisplay === this.options.display_items) + sb.setAttribute('empty', ''); + else + sb.removeAttribute('empty'); + + L.dom.content(more, (ndisplay === this.options.display_items) + ? (this.options.select_placeholder || this.options.placeholder) : '···'); + } + else { + var sel = li.parentNode.querySelector('[selected]'); + if (sel) { + sel.removeAttribute('display'); + sel.removeAttribute('selected'); + } + + li.setAttribute('display', 0); + li.setAttribute('selected', ''); + + this.closeDropdown(sb, true); + } + + this.saveValues(sb, li.parentNode); + }, + + transformItem: function(sb, li) { + var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })), + label = E('label'); + + while (li.firstChild) + label.appendChild(li.firstChild); + + li.appendChild(cbox); + li.appendChild(label); + }, + + saveValues: function(sb, ul) { + var sel = ul.querySelectorAll('li[selected]'), + div = sb.lastElementChild, + name = this.options.name, + strval = '', + values = []; + + while (div.lastElementChild) + div.removeChild(div.lastElementChild); + + sel.forEach(function (s) { + if (s.hasAttribute('placeholder')) + return; + + var v = { + text: s.innerText, + value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText, + element: s + }; + + div.appendChild(E('input', { + type: 'hidden', + name: name, + value: v.value + })); + + values.push(v); + + strval += strval.length ? ' ' + v.value : v.value; + }); + + var detail = { + instance: this, + element: sb + }; + + if (this.options.multi) + detail.values = values; + else + detail.value = values.length ? values[0] : null; + + sb.value = strval; + + sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', { + bubbles: true, + detail: detail + })); + }, + + setValues: function(sb, values) { + var ul = sb.querySelector('ul'); + + if (this.options.create) { + for (var value in values) { + this.createItems(sb, value); + + if (!this.options.multi) + break; + } + } + + if (this.options.multi) { + var lis = ul.querySelectorAll('li[data-value]'); + for (var i = 0; i < lis.length; i++) { + var value = lis[i].getAttribute('data-value'); + if (values === null || !(value in values)) + this.toggleItem(sb, lis[i], false); + else + this.toggleItem(sb, lis[i], true); + } + } + else { + var ph = ul.querySelector('li[placeholder]'); + if (ph) + this.toggleItem(sb, ph); + + var lis = ul.querySelectorAll('li[data-value]'); + for (var i = 0; i < lis.length; i++) { + var value = lis[i].getAttribute('data-value'); + if (values !== null && (value in values)) + this.toggleItem(sb, lis[i]); + } + } + }, + + setFocus: function(sb, elem, scroll) { + if (sb && sb.hasAttribute && sb.hasAttribute('locked-in')) + return; + + if (sb.target && findParent(sb.target, 'ul.dropdown')) + return; + + document.querySelectorAll('.focus').forEach(function(e) { + if (!matchesElem(e, 'input')) { + e.classList.remove('focus'); + e.blur(); + } + }); + + if (elem) { + elem.focus(); + elem.classList.add('focus'); + + if (scroll) + elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop; + } + }, + + createItems: function(sb, value) { + var sbox = this, + val = (value || '').trim(), + ul = sb.querySelector('ul'); + + if (!sbox.options.multi) + val = val.length ? [ val ] : []; + else + val = val.length ? val.split(/\s+/) : []; + + val.forEach(function(item) { + var new_item = null; + + ul.childNodes.forEach(function(li) { + if (li.getAttribute && li.getAttribute('data-value') === item) + new_item = li; + }); + + if (!new_item) { + var markup, + tpl = sb.querySelector(sbox.options.create_template); + + if (tpl) + markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^$/, '').trim(); + else + markup = '
  • {{value}}
  • '; + + new_item = E(markup.replace(/{{value}}/g, item)); + + if (sbox.options.multi) { + sbox.transformItem(sb, new_item); + } + else { + var old = ul.querySelector('li[created]'); + if (old) + ul.removeChild(old); + + new_item.setAttribute('created', ''); + } + + new_item = ul.insertBefore(new_item, ul.lastElementChild); + } + + sbox.toggleItem(sb, new_item, true); + sbox.setFocus(sb, new_item, true); + }); + }, + + closeAllDropdowns: function() { + document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { + s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); + }); + }, + + handleClick: function(ev) { + var sb = ev.currentTarget; + + if (!sb.hasAttribute('open')) { + if (!matchesElem(ev.target, 'input')) + this.openDropdown(sb); + } + else { + var li = findParent(ev.target, 'li'); + if (li && li.parentNode.classList.contains('dropdown')) + this.toggleItem(sb, li); + else if (li && li.parentNode.classList.contains('preview')) + this.closeDropdown(sb); + else if (matchesElem(ev.target, 'span.open, span.more')) + this.closeDropdown(sb); + } + + ev.preventDefault(); + ev.stopPropagation(); + }, + + handleKeydown: function(ev) { + var sb = ev.currentTarget; + + if (matchesElem(ev.target, 'input')) + return; + + if (!sb.hasAttribute('open')) { + switch (ev.keyCode) { + case 37: + case 38: + case 39: + case 40: + this.openDropdown(sb); + ev.preventDefault(); + } + } + else { + var active = findParent(document.activeElement, 'li'); + + switch (ev.keyCode) { + case 27: + this.closeDropdown(sb); + break; + + case 13: + if (active) { + if (!active.hasAttribute('selected')) + this.toggleItem(sb, active); + this.closeDropdown(sb); + ev.preventDefault(); + } + break; + + case 32: + if (active) { + this.toggleItem(sb, active); + ev.preventDefault(); + } + break; + + case 38: + if (active && active.previousElementSibling) { + this.setFocus(sb, active.previousElementSibling); + ev.preventDefault(); + } + break; + + case 40: + if (active && active.nextElementSibling) { + this.setFocus(sb, active.nextElementSibling); + ev.preventDefault(); + } + break; + } + } + }, + + handleDropdownClose: function(ev) { + var sb = ev.currentTarget; + + this.closeDropdown(sb, true); + }, + + handleDropdownSelect: function(ev) { + var sb = ev.currentTarget, + li = findParent(ev.target, 'li'); + + if (!li) + return; + + this.toggleItem(sb, li); + this.closeDropdown(sb, true); + }, + + handleMouseover: function(ev) { + var sb = ev.currentTarget; + + if (!sb.hasAttribute('open')) + return; + + var li = findParent(ev.target, 'li'); + + if (li && li.parentNode.classList.contains('dropdown')) + this.setFocus(sb, li); + }, + + handleFocus: function(ev) { + var sb = ev.currentTarget; + + document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { + if (s !== sb || sb.hasAttribute('open')) + s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); + }); + }, + + handleCanaryFocus: function(ev) { + this.closeDropdown(ev.currentTarget.parentNode); + }, + + handleCreateKeydown: function(ev) { + var input = ev.currentTarget, + sb = findParent(input, '.cbi-dropdown'); + + switch (ev.keyCode) { + case 13: + ev.preventDefault(); + + if (input.classList.contains('cbi-input-invalid')) + return; + + this.createItems(sb, input.value); + input.value = ''; + input.blur(); + break; + } + }, + + handleCreateFocus: function(ev) { + var input = ev.currentTarget, + cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'), + sb = findParent(input, '.cbi-dropdown'); + + if (cbox) + cbox.checked = true; + + sb.setAttribute('locked-in', ''); + }, + + handleCreateBlur: function(ev) { + var input = ev.currentTarget, + cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'), + sb = findParent(input, '.cbi-dropdown'); + + if (cbox) + cbox.checked = false; + + sb.removeAttribute('locked-in'); + }, + + handleCreateClick: function(ev) { + ev.currentTarget.querySelector(this.options.create_query).focus(); + }, + + setValue: function(values) { + if (this.options.multi) { + if (!Array.isArray(values)) + values = (values != null && values != '') ? [ values ] : []; + + var v = {}; + + for (var i = 0; i < values.length; i++) + v[values[i]] = true; + + this.setValues(this.node, v); + } + else { + var v = {}; + + if (values != null) { + if (Array.isArray(values)) + v[values[0]] = true; + else + v[values] = true; + } + + this.setValues(this.node, v); + } + }, + + getValue: function() { + var div = this.node.lastElementChild, + h = div.querySelectorAll('input[type="hidden"]'), + v = []; + + for (var i = 0; i < h.length; i++) + v.push(h[i].value); + + return this.options.multi ? v : v[0]; + } +}); + +var UICombobox = UIDropdown.extend({ + __init__: function(value, choices, options) { + this.super('__init__', [ value, choices, Object.assign({ + select_placeholder: _('-- Please choose --'), + custom_placeholder: _('-- custom --'), + dropdown_items: -1, + sort: true + }, options, { + multi: false, + create: true, + optional: true + }) ]); + } +}); + +var UIDynamicList = UIElement.extend({ + __init__: function(values, choices, options) { + if (!Array.isArray(values)) + values = (values != null && values != '') ? [ values ] : []; + + if (typeof(choices) != 'object') + choices = null; + + this.values = values; + this.choices = choices; + this.options = Object.assign({}, options, { + multi: false, + optional: true + }); + }, + + render: function() { + var dl = E('div', { + 'id': this.options.id, + 'class': 'cbi-dynlist' + }, E('div', { 'class': 'add-item' })); + + if (this.choices) { + var cbox = new UICombobox(null, this.choices, this.options); + dl.lastElementChild.appendChild(cbox.render()); + } + else { + var inputEl = E('input', { + 'id': this.options.id ? 'widget.' + this.options.id : null, + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': this.options.placeholder + }); + + dl.lastElementChild.appendChild(inputEl); + dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+')); + + if (this.options.datatype) + L.ui.addValidator(inputEl, this.options.datatype, + true, null, 'blur', 'keyup'); + } + + for (var i = 0; i < this.values.length; i++) + this.addItem(dl, this.values[i], + this.choices ? this.choices[this.values[i]] : null); + + return this.bind(dl); + }, + + bind: function(dl) { + dl.addEventListener('click', L.bind(this.handleClick, this)); + dl.addEventListener('keydown', L.bind(this.handleKeydown, this)); + dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this)); + + this.node = dl; + + this.setUpdateEvents(dl, 'cbi-dynlist-change'); + this.setChangeEvents(dl, 'cbi-dynlist-change'); + + L.dom.bindClassInstance(dl, this); + + return dl; + }, + + addItem: function(dl, value, text, flash) { + var exists = false, + new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [ + E('span', {}, text || value), + E('input', { + 'type': 'hidden', + 'name': this.options.name, + 'value': value })]); + + dl.querySelectorAll('.item, .add-item').forEach(function(item) { + if (exists) + return; + + var hidden = item.querySelector('input[type="hidden"]'); + + if (hidden && hidden.parentNode !== item) + hidden = null; + + if (hidden && hidden.value === value) + exists = true; + else if (!hidden || hidden.value >= value) + exists = !!item.parentNode.insertBefore(new_item, item); + }); + + dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', { + bubbles: true, + detail: { + instance: this, + element: dl, + value: value, + add: true + } + })); + }, + + removeItem: function(dl, item) { + var value = item.querySelector('input[type="hidden"]').value; + var sb = dl.querySelector('.cbi-dropdown'); + if (sb) + sb.querySelectorAll('ul > li').forEach(function(li) { + if (li.getAttribute('data-value') === value) { + if (li.hasAttribute('dynlistcustom')) + li.parentNode.removeChild(li); + else + li.removeAttribute('unselectable'); + } + }); + + item.parentNode.removeChild(item); + + dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', { + bubbles: true, + detail: { + instance: this, + element: dl, + value: value, + remove: true + } + })); + }, + + handleClick: function(ev) { + var dl = ev.currentTarget, + item = findParent(ev.target, '.item'); + + if (item) { + this.removeItem(dl, item); + } + else if (matchesElem(ev.target, '.cbi-button-add')) { + var input = ev.target.previousElementSibling; + if (input.value.length && !input.classList.contains('cbi-input-invalid')) { + this.addItem(dl, input.value, null, true); + input.value = ''; + } + } + }, + + handleDropdownChange: function(ev) { + var dl = ev.currentTarget, + sbIn = ev.detail.instance, + sbEl = ev.detail.element, + sbVal = ev.detail.value; + + if (sbVal === null) + return; + + sbIn.setValues(sbEl, null); + sbVal.element.setAttribute('unselectable', ''); + + if (sbVal.element.hasAttribute('created')) { + sbVal.element.removeAttribute('created'); + sbVal.element.setAttribute('dynlistcustom', ''); + } + + this.addItem(dl, sbVal.value, sbVal.text, true); + }, + + handleKeydown: function(ev) { + var dl = ev.currentTarget, + item = findParent(ev.target, '.item'); + + if (item) { + switch (ev.keyCode) { + case 8: /* backspace */ + if (item.previousElementSibling) + item.previousElementSibling.focus(); + + this.removeItem(dl, item); + break; + + case 46: /* delete */ + if (item.nextElementSibling) { + if (item.nextElementSibling.classList.contains('item')) + item.nextElementSibling.focus(); + else + item.nextElementSibling.firstElementChild.focus(); + } + + this.removeItem(dl, item); + break; + } + } + else if (matchesElem(ev.target, '.cbi-input-text')) { + switch (ev.keyCode) { + case 13: /* enter */ + if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) { + this.addItem(dl, ev.target.value, null, true); + ev.target.value = ''; + ev.target.blur(); + ev.target.focus(); + } + + ev.preventDefault(); + break; + } + } + }, + + getValue: function() { + var items = this.node.querySelectorAll('.item > input[type="hidden"]'), + v = []; + + for (var i = 0; i < items.length; i++) + v.push(items[i].value); + + return v; + }, + + setValue: function(values) { + if (!Array.isArray(values)) + values = (values != null && values != '') ? [ values ] : []; + + var items = this.node.querySelectorAll('.item'); + + for (var i = 0; i < items.length; i++) + if (items[i].parentNode === this.node) + this.removeItem(this.node, items[i]); + + for (var i = 0; i < values.length; i++) + this.addItem(this.node, values[i], + this.choices ? this.choices[values[i]] : null); + } +}); + +var UIHiddenfield = UIElement.extend({ + __init__: function(value, options) { + this.value = value; + this.options = Object.assign({ + + }, options); + }, + + render: function() { + var hiddenEl = E('input', { + 'id': this.options.id, + 'type': 'hidden', + 'value': this.value + }); + + return this.bind(hiddenEl); + }, + + bind: function(hiddenEl) { + this.node = hiddenEl; + + L.dom.bindClassInstance(hiddenEl, this); + + return hiddenEl; + }, + + getValue: function() { + return this.node.value; + }, + + setValue: function(value) { + this.node.value = value; + } +}); + + +return L.Class.extend({ + __init__: function() { + modalDiv = document.body.appendChild( + L.dom.create('div', { id: 'modal_overlay' }, + L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true }))); + + tooltipDiv = document.body.appendChild( + L.dom.create('div', { class: 'cbi-tooltip' })); + + /* setup old aliases */ + L.showModal = this.showModal; + L.hideModal = this.hideModal; + L.showTooltip = this.showTooltip; + L.hideTooltip = this.hideTooltip; + L.itemlist = this.itemlist; + + document.addEventListener('mouseover', this.showTooltip.bind(this), true); + document.addEventListener('mouseout', this.hideTooltip.bind(this), true); + document.addEventListener('focus', this.showTooltip.bind(this), true); + document.addEventListener('blur', this.hideTooltip.bind(this), true); + + document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs)); + document.addEventListener('luci-loaded', this.changes.init.bind(this.changes)); + document.addEventListener('uci-loaded', this.changes.init.bind(this.changes)); + }, + + /* Modal dialog */ + showModal: function(title, children /* , ... */) { + var dlg = modalDiv.firstElementChild; + + dlg.setAttribute('class', 'modal'); + + for (var i = 2; i < arguments.length; i++) + dlg.classList.add(arguments[i]); + + L.dom.content(dlg, L.dom.create('h4', {}, title)); + L.dom.append(dlg, children); + + document.body.classList.add('modal-overlay-active'); + + return dlg; + }, + + hideModal: function() { + document.body.classList.remove('modal-overlay-active'); + }, + + /* Tooltip */ + showTooltip: function(ev) { + var target = findParent(ev.target, '[data-tooltip]'); + + if (!target) + return; + + if (tooltipTimeout !== null) { + window.clearTimeout(tooltipTimeout); + tooltipTimeout = null; + } + + var rect = target.getBoundingClientRect(), + x = rect.left + window.pageXOffset, + y = rect.top + rect.height + window.pageYOffset; + + tooltipDiv.className = 'cbi-tooltip'; + tooltipDiv.innerHTML = '▲ '; + tooltipDiv.firstChild.data += target.getAttribute('data-tooltip'); + + if (target.hasAttribute('data-tooltip-style')) + tooltipDiv.classList.add(target.getAttribute('data-tooltip-style')); + + if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) { + y -= (tooltipDiv.offsetHeight + target.offsetHeight); + tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2); + } + + tooltipDiv.style.top = y + 'px'; + tooltipDiv.style.left = x + 'px'; + tooltipDiv.style.opacity = 1; + + tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', { + bubbles: true, + detail: { target: target } + })); + }, + + hideTooltip: function(ev) { + if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv || + tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget)) + return; + + if (tooltipTimeout !== null) { + window.clearTimeout(tooltipTimeout); + tooltipTimeout = null; + } + + tooltipDiv.style.opacity = 0; + tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250); + + tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true })); + }, + + /* Widget helper */ + itemlist: function(node, items, separators) { + var children = []; + + if (!Array.isArray(separators)) + separators = [ separators || E('br') ]; + + for (var i = 0; i < items.length; i += 2) { + if (items[i+1] !== null && items[i+1] !== undefined) { + var sep = separators[(i/2) % separators.length], + cld = []; + + children.push(E('span', { class: 'nowrap' }, [ + items[i] ? E('strong', items[i] + ': ') : '', + items[i+1] + ])); + + if ((i+2) < items.length) + children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep); + } + } + + L.dom.content(node, children); + + return node; + }, + + /* Tabs */ + tabs: L.Class.singleton({ + init: function() { + var groups = [], prevGroup = null, currGroup = null; + + document.querySelectorAll('[data-tab]').forEach(function(tab) { + var parent = tab.parentNode; + + if (!parent.hasAttribute('data-tab-group')) + parent.setAttribute('data-tab-group', groups.length); + + currGroup = +parent.getAttribute('data-tab-group'); + + if (currGroup !== prevGroup) { + prevGroup = currGroup; + + if (!groups[currGroup]) + groups[currGroup] = []; + } + + groups[currGroup].push(tab); + }); + + for (var i = 0; i < groups.length; i++) + this.initTabGroup(groups[i]); + + document.addEventListener('dependency-update', this.updateTabs.bind(this)); + + this.updateTabs(); + + if (!groups.length) + this.setActiveTabId(-1, -1); + }, + + initTabGroup: function(panes) { + if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0) + return; + + var menu = E('ul', { 'class': 'cbi-tabmenu' }), + group = panes[0].parentNode, + groupId = +group.getAttribute('data-tab-group'), + selected = null; + + for (var i = 0, pane; pane = panes[i]; i++) { + var name = pane.getAttribute('data-tab'), + title = pane.getAttribute('data-tab-title'), + active = pane.getAttribute('data-tab-active') === 'true'; + + menu.appendChild(E('li', { + 'class': active ? 'cbi-tab' : 'cbi-tab-disabled', + 'data-tab': name + }, E('a', { + 'href': '#', + 'click': this.switchTab.bind(this) + }, title))); + + if (active) + selected = i; + } + + group.parentNode.insertBefore(menu, group); + + if (selected === null) { + selected = this.getActiveTabId(groupId); + + if (selected < 0 || selected >= panes.length) + selected = 0; + + menu.childNodes[selected].classList.add('cbi-tab'); + menu.childNodes[selected].classList.remove('cbi-tab-disabled'); + panes[selected].setAttribute('data-tab-active', 'true'); + + this.setActiveTabId(groupId, selected); + } + }, + + getActiveTabState: function() { + var page = document.body.getAttribute('data-page'); + + try { + var val = JSON.parse(window.sessionStorage.getItem('tab')); + if (val.page === page && Array.isArray(val.groups)) + return val; + } + catch(e) {} + + window.sessionStorage.removeItem('tab'); + return { page: page, groups: [] }; + }, + + getActiveTabId: function(groupId) { + return +this.getActiveTabState().groups[groupId] || 0; + }, + + setActiveTabId: function(groupId, tabIndex) { + try { + var state = this.getActiveTabState(); + state.groups[groupId] = tabIndex; + + window.sessionStorage.setItem('tab', JSON.stringify(state)); + } + catch (e) { return false; } + + return true; + }, + + updateTabs: function(ev) { + document.querySelectorAll('[data-tab-title]').forEach(function(pane) { + var menu = pane.parentNode.previousElementSibling, + tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))), + n_errors = pane.querySelectorAll('.cbi-input-invalid').length; + + if (!pane.firstElementChild) { + tab.style.display = 'none'; + tab.classList.remove('flash'); + } + else if (tab.style.display === 'none') { + tab.style.display = ''; + requestAnimationFrame(function() { tab.classList.add('flash') }); + } + + if (n_errors) { + tab.setAttribute('data-errors', n_errors); + tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors)); + tab.setAttribute('data-tooltip-style', 'error'); + } + else { + tab.removeAttribute('data-errors'); + tab.removeAttribute('data-tooltip'); + } + }); + }, + + switchTab: function(ev) { + var tab = ev.target.parentNode, + name = tab.getAttribute('data-tab'), + menu = tab.parentNode, + group = menu.nextElementSibling, + groupId = +group.getAttribute('data-tab-group'), + index = 0; + + ev.preventDefault(); + + if (!tab.classList.contains('cbi-tab-disabled')) + return; + + menu.querySelectorAll('[data-tab]').forEach(function(tab) { + tab.classList.remove('cbi-tab'); + tab.classList.remove('cbi-tab-disabled'); + tab.classList.add( + tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled'); + }); + + group.childNodes.forEach(function(pane) { + if (L.dom.matches(pane, '[data-tab]')) { + if (pane.getAttribute('data-tab') === name) { + pane.setAttribute('data-tab-active', 'true'); + L.ui.tabs.setActiveTabId(groupId, index); + } + else { + pane.setAttribute('data-tab-active', 'false'); + } + + index++; + } + }); + } + }), + + /* UCI Changes */ + changes: L.Class.singleton({ + init: function() { + if (!L.env.sessionid) + return; + + return uci.changes().then(L.bind(this.renderChangeIndicator, this)); + }, + + setIndicator: function(n) { + var i = document.querySelector('.uci_change_indicator'); + if (i == null) { + var poll = document.getElementById('xhr_poll_status'); + i = poll.parentNode.insertBefore(E('a', { + 'href': '#', + 'class': 'uci_change_indicator label notice', + 'click': L.bind(this.displayChanges, this) + }), poll); + } + + if (n > 0) { + L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]); + i.classList.add('flash'); + i.style.display = ''; + } + else { + i.classList.remove('flash'); + i.style.display = 'none'; + } + }, + + renderChangeIndicator: function(changes) { + var n_changes = 0; + + for (var config in changes) + if (changes.hasOwnProperty(config)) + n_changes += changes[config].length; + + this.changes = changes; + this.setIndicator(n_changes); + }, + + changeTemplates: { + 'add-3': 'uci add %0 %3 # =%2', + 'set-3': 'uci set %0.%2=%3', + 'set-4': 'uci set %0.%2.%3=%4', + 'remove-2': 'uci del %0.%2', + 'remove-3': 'uci del %0.%2.%3', + 'order-3': 'uci reorder %0.%2=%3', + 'list-add-4': 'uci add_list %0.%2.%3=%4', + 'list-del-4': 'uci del_list %0.%2.%3=%4', + 'rename-3': 'uci rename %0.%2=%3', + 'rename-4': 'uci rename %0.%2.%3=%4' + }, + + displayChanges: function() { + var list = E('div', { 'class': 'uci-change-list' }), + dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [ + E('div', { 'class': 'cbi-section' }, [ + E('strong', _('Legend:')), + E('div', { 'class': 'uci-change-legend' }, [ + E('div', { 'class': 'uci-change-legend-label' }, [ + E('ins', ' '), ' ', _('Section added') ]), + E('div', { 'class': 'uci-change-legend-label' }, [ + E('del', ' '), ' ', _('Section removed') ]), + E('div', { 'class': 'uci-change-legend-label' }, [ + E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]), + E('div', { 'class': 'uci-change-legend-label' }, [ + E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]), + E('br'), list, + E('div', { 'class': 'right' }, [ + E('input', { + 'type': 'button', + 'class': 'btn', + 'click': L.ui.hideModal, + 'value': _('Dismiss') + }), ' ', + E('input', { + 'type': 'button', + 'class': 'cbi-button cbi-button-positive important', + 'click': L.bind(this.apply, this, true), + 'value': _('Save & Apply') + }), ' ', + E('input', { + 'type': 'button', + 'class': 'cbi-button cbi-button-reset', + 'click': L.bind(this.revert, this), + 'value': _('Revert') + })])]) + ]); + + for (var config in this.changes) { + if (!this.changes.hasOwnProperty(config)) + continue; + + list.appendChild(E('h5', '# /etc/config/%s'.format(config))); + + for (var i = 0, added = null; i < this.changes[config].length; i++) { + var chg = this.changes[config][i], + tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)]; + + list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) { + switch (+m1) { + case 0: + return config; + + case 2: + if (added != null && chg[1] == added[0]) + return '@' + added[1] + '[-1]'; + else + return chg[1]; + + case 4: + return "'" + chg[3].replace(/'/g, "'\"'\"'") + "'"; + + default: + return chg[m1-1]; + } + }))); + + if (chg[0] == 'add') + added = [ chg[1], chg[2] ]; + } + } + + list.appendChild(E('br')); + dlg.classList.add('uci-dialog'); + }, + + displayStatus: function(type, content) { + if (type) { + var message = L.ui.showModal('', ''); + + message.classList.add('alert-message'); + DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/)); + + if (content) + L.dom.content(message, content); + + if (!this.was_polling) { + this.was_polling = L.Request.poll.active(); + L.Request.poll.stop(); + } + } + else { + L.ui.hideModal(); + + if (this.was_polling) + L.Request.poll.start(); + } + }, + + rollback: function(checked) { + if (checked) { + this.displayStatus('warning spinning', + E('p', _('Failed to confirm apply within %ds, waiting for rollback…') + .format(L.env.apply_rollback))); + + var call = function(r, data, duration) { + if (r.status === 204) { + L.ui.changes.displayStatus('warning', [ + E('h4', _('Configuration has been rolled back!')), + E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)), + E('div', { 'class': 'right' }, [ + E('input', { + 'type': 'button', + 'class': 'btn', + 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false), + 'value': _('Dismiss') + }), ' ', + E('input', { + 'type': 'button', + 'class': 'btn cbi-button-action important', + 'click': L.bind(L.ui.changes.revert, L.ui.changes), + 'value': _('Revert changes') + }), ' ', + E('input', { + 'type': 'button', + 'class': 'btn cbi-button-negative important', + 'click': L.bind(L.ui.changes.apply, L.ui.changes, false), + 'value': _('Apply unchecked') + }) + ]) + ]); + + return; + } + + var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0); + window.setTimeout(function() { + L.Request.request(L.url('admin/uci/confirm'), { + method: 'post', + timeout: L.env.apply_timeout * 1000, + query: { sid: L.env.sessionid, token: L.env.token } + }).then(call); + }, delay); + }; + + call({ status: 0 }); + } + else { + this.displayStatus('warning', [ + E('h4', _('Device unreachable!')), + E('p', _('Could not regain access to the device after applying the configuration changes. You might need to reconnect if you modified network related settings such as the IP address or wireless security credentials.')) + ]); + } + }, + + confirm: function(checked, deadline, override_token) { + var tt; + var ts = Date.now(); + + this.displayStatus('notice'); + + if (override_token) + this.confirm_auth = { token: override_token }; + + var call = function(r, data, duration) { + if (Date.now() >= deadline) { + window.clearTimeout(tt); + L.ui.changes.rollback(checked); + return; + } + else if (r && (r.status === 200 || r.status === 204)) { + document.dispatchEvent(new CustomEvent('uci-applied')); + + L.ui.changes.setIndicator(0); + L.ui.changes.displayStatus('notice', + E('p', _('Configuration has been applied.'))); + + window.clearTimeout(tt); + window.setTimeout(function() { + //L.ui.changes.displayStatus(false); + window.location = window.location.href.split('#')[0]; + }, L.env.apply_display * 1000); + + return; + } + + var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0); + window.setTimeout(function() { + L.Request.request(L.url('admin/uci/confirm'), { + method: 'post', + timeout: L.env.apply_timeout * 1000, + query: L.ui.changes.confirm_auth + }).then(call); + }, delay); + }; + + var tick = function() { + var now = Date.now(); + + L.ui.changes.displayStatus('notice spinning', + E('p', _('Waiting for configuration to get applied… %ds') + .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0)))); + + if (now >= deadline) + return; + + tt = window.setTimeout(tick, 1000 - (now - ts)); + ts = now; + }; + + tick(); + + /* wait a few seconds for the settings to become effective */ + window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1)); + }, + + apply: function(checked) { + this.displayStatus('notice spinning', + E('p', _('Starting configuration apply…'))); + + L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), { + method: 'post', + query: { sid: L.env.sessionid, token: L.env.token } + }).then(function(r) { + if (r.status === (checked ? 200 : 204)) { + var tok = null; try { tok = r.json(); } catch(e) {} + if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string') + L.ui.changes.confirm_auth = tok; + + L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000); + } + else if (checked && r.status === 204) { + L.ui.changes.displayStatus('notice', + E('p', _('There are no changes to apply'))); + + window.setTimeout(function() { + L.ui.changes.displayStatus(false); + }, L.env.apply_display * 1000); + } + else { + L.ui.changes.displayStatus('warning', + E('p', _('Apply request failed with status %h%>') + .format(r.responseText || r.statusText || r.status))); + + window.setTimeout(function() { + L.ui.changes.displayStatus(false); + }, L.env.apply_display * 1000); + } + }); + }, + + revert: function() { + this.displayStatus('notice spinning', + E('p', _('Reverting configuration…'))); + + L.Request.request(L.url('admin/uci/revert'), { + method: 'post', + query: { sid: L.env.sessionid, token: L.env.token } + }).then(function(r) { + if (r.status === 200) { + document.dispatchEvent(new CustomEvent('uci-reverted')); + + L.ui.changes.setIndicator(0); + L.ui.changes.displayStatus('notice', + E('p', _('Changes have been reverted.'))); + + window.setTimeout(function() { + //L.ui.changes.displayStatus(false); + window.location = window.location.href.split('#')[0]; + }, L.env.apply_display * 1000); + } + else { + L.ui.changes.displayStatus('warning', + E('p', _('Revert request failed with status %h') + .format(r.statusText || r.status))); + + window.setTimeout(function() { + L.ui.changes.displayStatus(false); + }, L.env.apply_display * 1000); + } + }); + } + }), + + addValidator: function(field, type, optional, vfunc /*, ... */) { + if (type == null) + return; + + var events = this.varargs(arguments, 3); + if (events.length == 0) + events.push('blur', 'keyup'); + + try { + var cbiValidator = L.validation.create(field, type, optional, vfunc), + validatorFn = cbiValidator.validate.bind(cbiValidator); + + for (var i = 0; i < events.length; i++) + field.addEventListener(events[i], validatorFn); + + validatorFn(); + + return validatorFn; + } + catch (e) { } + }, + + /* Widgets */ + Textfield: UITextfield, + Checkbox: UICheckbox, + Select: UISelect, + Dropdown: UIDropdown, + DynamicList: UIDynamicList, + Combobox: UICombobox, + Hiddenfield: UIHiddenfield +}); diff --git a/luci-base/htdocs/luci-static/resources/validation.js b/luci-base/htdocs/luci-static/resources/validation.js new file mode 100644 index 000000000..ca544cb15 --- /dev/null +++ b/luci-base/htdocs/luci-static/resources/validation.js @@ -0,0 +1,568 @@ +'use strict'; + +var Validator = L.Class.extend({ + __name__: 'Validation', + + __init__: function(field, type, optional, vfunc, validatorFactory) { + this.field = field; + this.optional = optional; + this.vfunc = vfunc; + this.vstack = validatorFactory.compile(type); + this.factory = validatorFactory; + }, + + assert: function(condition, message) { + if (!condition) { + this.field.classList.add('cbi-input-invalid'); + this.error = message; + return false; + } + + this.field.classList.remove('cbi-input-invalid'); + this.error = null; + return true; + }, + + apply: function(name, value, args) { + var func; + + if (typeof(name) === 'function') + func = name; + else if (typeof(this.factory.types[name]) === 'function') + func = this.factory.types[name]; + else + return false; + + if (value != null) + this.value = value; + + return func.apply(this, args); + }, + + validate: function() { + /* element is detached */ + if (!findParent(this.field, 'body') && !findParent(this.field, '[data-field]')) + return true; + + this.field.classList.remove('cbi-input-invalid'); + this.value = (this.field.value != null) ? this.field.value : ''; + this.error = null; + + var valid; + + if (this.value.length === 0) + valid = this.assert(this.optional, _('non-empty value')); + else + valid = this.vstack[0].apply(this, this.vstack[1]); + + if (valid !== true) { + this.field.setAttribute('data-tooltip', _('Expecting: %s').format(this.error)); + this.field.setAttribute('data-tooltip-style', 'error'); + this.field.dispatchEvent(new CustomEvent('validation-failure', { bubbles: true })); + return false; + } + + if (typeof(this.vfunc) == 'function') + valid = this.vfunc(this.value); + + if (valid !== true) { + this.assert(false, valid); + this.field.setAttribute('data-tooltip', valid); + this.field.setAttribute('data-tooltip-style', 'error'); + this.field.dispatchEvent(new CustomEvent('validation-failure', { bubbles: true })); + return false; + } + + this.field.removeAttribute('data-tooltip'); + this.field.removeAttribute('data-tooltip-style'); + this.field.dispatchEvent(new CustomEvent('validation-success', { bubbles: true })); + return true; + }, + +}); + +var ValidatorFactory = L.Class.extend({ + __name__: 'ValidatorFactory', + + create: function(field, type, optional, vfunc) { + return new Validator(field, type, optional, vfunc, this); + }, + + compile: function(code) { + var pos = 0; + var esc = false; + var depth = 0; + var stack = [ ]; + + code += ','; + + for (var i = 0; i < code.length; i++) { + if (esc) { + esc = false; + continue; + } + + switch (code.charCodeAt(i)) + { + case 92: + esc = true; + break; + + case 40: + case 44: + if (depth <= 0) { + if (pos < i) { + var label = code.substring(pos, i); + label = label.replace(/\\(.)/g, '$1'); + label = label.replace(/^[ \t]+/g, ''); + label = label.replace(/[ \t]+$/g, ''); + + if (label && !isNaN(label)) { + stack.push(parseFloat(label)); + } + else if (label.match(/^(['"]).*\1$/)) { + stack.push(label.replace(/^(['"])(.*)\1$/, '$2')); + } + else if (typeof this.types[label] == 'function') { + stack.push(this.types[label]); + stack.push(null); + } + else { + L.raise('SyntaxError', 'Unhandled token "%s"', label); + } + } + + pos = i+1; + } + + depth += (code.charCodeAt(i) == 40); + break; + + case 41: + if (--depth <= 0) { + if (typeof stack[stack.length-2] != 'function') + L.raise('SyntaxError', 'Argument list follows non-function'); + + stack[stack.length-1] = this.compile(code.substring(pos, i)); + pos = i+1; + } + + break; + } + } + + return stack; + }, + + parseInteger: function(x) { + return (/^-?\d+$/.test(x) ? +x : NaN); + }, + + parseDecimal: function(x) { + return (/^-?\d+(?:\.\d+)?$/.test(x) ? +x : NaN); + }, + + parseIPv4: function(x) { + if (!x.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)) + return null; + + if (RegExp.$1 > 255 || RegExp.$2 > 255 || RegExp.$3 > 255 || RegExp.$4 > 255) + return null; + + return [ +RegExp.$1, +RegExp.$2, +RegExp.$3, +RegExp.$4 ]; + }, + + parseIPv6: function(x) { + if (x.match(/^([a-fA-F0-9:]+):(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)) { + var v6 = RegExp.$1, v4 = this.parseIPv4(RegExp.$2); + + if (!v4) + return null; + + x = v6 + ':' + (v4[0] * 256 + v4[1]).toString(16) + + ':' + (v4[2] * 256 + v4[3]).toString(16); + } + + if (!x.match(/^[a-fA-F0-9:]+$/)) + return null; + + var prefix_suffix = x.split(/::/); + + if (prefix_suffix.length > 2) + return null; + + var prefix = (prefix_suffix[0] || '0').split(/:/); + var suffix = prefix_suffix.length > 1 ? (prefix_suffix[1] || '0').split(/:/) : []; + + if (suffix.length ? (prefix.length + suffix.length > 7) + : ((prefix_suffix.length < 2 && prefix.length < 8) || prefix.length > 8)) + return null; + + var i, word; + var words = []; + + for (i = 0, word = parseInt(prefix[0], 16); i < prefix.length; word = parseInt(prefix[++i], 16)) + if (prefix[i].length <= 4 && !isNaN(word) && word <= 0xFFFF) + words.push(word); + else + return null; + + for (i = 0; i < (8 - prefix.length - suffix.length); i++) + words.push(0); + + for (i = 0, word = parseInt(suffix[0], 16); i < suffix.length; word = parseInt(suffix[++i], 16)) + if (suffix[i].length <= 4 && !isNaN(word) && word <= 0xFFFF) + words.push(word); + else + return null; + + return words; + }, + + types: { + integer: function() { + return this.assert(this.factory.parseInteger(this.value) !== NaN, _('valid integer value')); + }, + + uinteger: function() { + return this.assert(this.factory.parseInteger(this.value) >= 0, _('positive integer value')); + }, + + float: function() { + return this.assert(this.factory.parseDecimal(this.value) !== NaN, _('valid decimal value')); + }, + + ufloat: function() { + return this.assert(this.factory.parseDecimal(this.value) >= 0, _('positive decimal value')); + }, + + ipaddr: function(nomask) { + return this.assert(this.apply('ip4addr', null, [nomask]) || this.apply('ip6addr', null, [nomask]), + nomask ? _('valid IP address') : _('valid IP address or prefix')); + }, + + ip4addr: function(nomask) { + var re = nomask ? /^(\d+\.\d+\.\d+\.\d+)$/ : /^(\d+\.\d+\.\d+\.\d+)(?:\/(\d+\.\d+\.\d+\.\d+)|\/(\d{1,2}))?$/, + m = this.value.match(re); + + return this.assert(m && this.factory.parseIPv4(m[1]) && (m[2] ? this.factory.parseIPv4(m[2]) : (m[3] ? this.apply('ip4prefix', m[3]) : true)), + nomask ? _('valid IPv4 address') : _('valid IPv4 address or network')); + }, + + ip6addr: function(nomask) { + var re = nomask ? /^([0-9a-fA-F:.]+)$/ : /^([0-9a-fA-F:.]+)(?:\/(\d{1,3}))?$/, + m = this.value.match(re); + + return this.assert(m && this.factory.parseIPv6(m[1]) && (m[2] ? this.apply('ip6prefix', m[2]) : true), + nomask ? _('valid IPv6 address') : _('valid IPv6 address or prefix')); + }, + + ip4prefix: function() { + return this.assert(!isNaN(this.value) && this.value >= 0 && this.value <= 32, + _('valid IPv4 prefix value (0-32)')); + }, + + ip6prefix: function() { + return this.assert(!isNaN(this.value) && this.value >= 0 && this.value <= 128, + _('valid IPv6 prefix value (0-128)')); + }, + + cidr: function() { + return this.assert(this.apply('cidr4') || this.apply('cidr6'), _('valid IPv4 or IPv6 CIDR')); + }, + + cidr4: function() { + var m = this.value.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/); + return this.assert(m && this.factory.parseIPv4(m[1]) && this.apply('ip4prefix', m[2]), _('valid IPv4 CIDR')); + }, + + cidr6: function() { + var m = this.value.match(/^([0-9a-fA-F:.]+)\/(\d{1,3})$/); + return this.assert(m && this.factory.parseIPv6(m[1]) && this.apply('ip6prefix', m[2]), _('valid IPv6 CIDR')); + }, + + ipnet4: function() { + var m = this.value.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + return this.assert(m && this.factory.parseIPv4(m[1]) && this.factory.parseIPv4(m[2]), _('IPv4 network in address/netmask notation')); + }, + + ipnet6: function() { + var m = this.value.match(/^([0-9a-fA-F:.]+)\/([0-9a-fA-F:.]+)$/); + return this.assert(m && this.factory.parseIPv6(m[1]) && this.factory.parseIPv6(m[2]), _('IPv6 network in address/netmask notation')); + }, + + ip6hostid: function() { + if (this.value == "eui64" || this.value == "random") + return true; + + var v6 = this.factory.parseIPv6(this.value); + return this.assert(!(!v6 || v6[0] || v6[1] || v6[2] || v6[3]), _('valid IPv6 host id')); + }, + + ipmask: function() { + return this.assert(this.apply('ipmask4') || this.apply('ipmask6'), + _('valid network in address/netmask notation')); + }, + + ipmask4: function() { + return this.assert(this.apply('cidr4') || this.apply('ipnet4') || this.apply('ip4addr'), + _('valid IPv4 network')); + }, + + ipmask6: function() { + return this.assert(this.apply('cidr6') || this.apply('ipnet6') || this.apply('ip6addr'), + _('valid IPv6 network')); + }, + + port: function() { + var p = this.factory.parseInteger(this.value); + return this.assert(p >= 0 && p <= 65535, _('valid port value')); + }, + + portrange: function() { + if (this.value.match(/^(\d+)-(\d+)$/)) { + var p1 = +RegExp.$1; + var p2 = +RegExp.$2; + return this.assert(p1 <= p2 && p2 <= 65535, + _('valid port or port range (port1-port2)')); + } + + return this.assert(this.apply('port'), _('valid port or port range (port1-port2)')); + }, + + macaddr: function() { + return this.assert(this.value.match(/^([a-fA-F0-9]{2}:){5}[a-fA-F0-9]{2}$/) != null, + _('valid MAC address')); + }, + + host: function(ipv4only) { + return this.assert(this.apply('hostname') || this.apply(ipv4only == 1 ? 'ip4addr' : 'ipaddr'), + _('valid hostname or IP address')); + }, + + hostname: function(strict) { + if (this.value.length <= 253) + return this.assert( + (this.value.match(/^[a-zA-Z0-9_]+$/) != null || + (this.value.match(/^[a-zA-Z0-9_][a-zA-Z0-9_\-.]*[a-zA-Z0-9]$/) && + this.value.match(/[^0-9.]/))) && + (!strict || !this.value.match(/^_/)), + _('valid hostname')); + + return this.assert(false, _('valid hostname')); + }, + + network: function() { + return this.assert(this.apply('uciname') || this.apply('host'), + _('valid UCI identifier, hostname or IP address')); + }, + + hostport: function(ipv4only) { + var hp = this.value.split(/:/); + return this.assert(hp.length == 2 && this.apply('host', hp[0], [ipv4only]) && this.apply('port', hp[1]), + _('valid host:port')); + }, + + ip4addrport: function() { + var hp = this.value.split(/:/); + return this.assert(hp.length == 2 && this.apply('ip4addr', hp[0], [true]) && this.apply('port', hp[1]), + _('valid IPv4 address:port')); + }, + + ipaddrport: function(bracket) { + var m4 = this.value.match(/^([^\[\]:]+):(\d+)$/), + m6 = this.value.match((bracket == 1) ? /^\[(.+)\]:(\d+)$/ : /^([^\[\]]+):(\d+)$/); + + if (m4) + return this.assert(this.apply('ip4addr', m4[1], [true]) && this.apply('port', m4[2]), + _('valid address:port')); + + return this.assert(m6 && this.apply('ip6addr', m6[1], [true]) && this.apply('port', m6[2]), + _('valid address:port')); + }, + + wpakey: function() { + var v = this.value; + + if (v.length == 64) + return this.assert(v.match(/^[a-fA-F0-9]{64}$/), _('valid hexadecimal WPA key')); + + return this.assert((v.length >= 8) && (v.length <= 63), _('key between 8 and 63 characters')); + }, + + wepkey: function() { + var v = this.value; + + if (v.substr(0, 2) === 's:') + v = v.substr(2); + + if ((v.length == 10) || (v.length == 26)) + return this.assert(v.match(/^[a-fA-F0-9]{10,26}$/), _('valid hexadecimal WEP key')); + + return this.assert((v.length === 5) || (v.length === 13), _('key with either 5 or 13 characters')); + }, + + uciname: function() { + return this.assert(this.value.match(/^[a-zA-Z0-9_]+$/), _('valid UCI identifier')); + }, + + range: function(min, max) { + var val = this.factory.parseDecimal(this.value); + return this.assert(val >= +min && val <= +max, _('value between %f and %f').format(min, max)); + }, + + min: function(min) { + return this.assert(this.factory.parseDecimal(this.value) >= +min, _('value greater or equal to %f').format(min)); + }, + + max: function(max) { + return this.assert(this.factory.parseDecimal(this.value) <= +max, _('value smaller or equal to %f').format(max)); + }, + + rangelength: function(min, max) { + var val = '' + this.value; + return this.assert((val.length >= +min) && (val.length <= +max), + _('value between %d and %d characters').format(min, max)); + }, + + minlength: function(min) { + return this.assert((''+this.value).length >= +min, + _('value with at least %d characters').format(min)); + }, + + maxlength: function(max) { + return this.assert((''+this.value).length <= +max, + _('value with at most %d characters').format(max)); + }, + + or: function() { + var errors = []; + + for (var i = 0; i < arguments.length; i += 2) { + if (typeof arguments[i] != 'function') { + if (arguments[i] == this.value) + return this.assert(true); + errors.push('"%s"'.format(arguments[i])); + i--; + } + else if (arguments[i].apply(this, arguments[i+1])) { + return this.assert(true); + } + else { + errors.push(this.error); + } + } + + var t = _('One of the following: %s'); + + return this.assert(false, t.format('\n - ' + errors.join('\n - '))); + }, + + and: function() { + for (var i = 0; i < arguments.length; i += 2) { + if (typeof arguments[i] != 'function') { + if (arguments[i] != this.value) + return this.assert(false, '"%s"'.format(arguments[i])); + i--; + } + else if (!arguments[i].apply(this, arguments[i+1])) { + return this.assert(false, this.error); + } + } + + return this.assert(true); + }, + + neg: function() { + this.value = this.value.replace(/^[ \t]*![ \t]*/, ''); + + if (arguments[0].apply(this, arguments[1])) + return this.assert(true); + + return this.assert(false, _('Potential negation of: %s').format(this.error)); + }, + + list: function(subvalidator, subargs) { + this.field.setAttribute('data-is-list', 'true'); + + var tokens = this.value.match(/[^ \t]+/g); + for (var i = 0; i < tokens.length; i++) + if (!this.apply(subvalidator, tokens[i], subargs)) + return this.assert(false, this.error); + + return this.assert(true); + }, + + phonedigit: function() { + return this.assert(this.value.match(/^[0-9\*#!\.]+$/), + _('valid phone digit (0-9, "*", "#", "!" or ".")')); + }, + + timehhmmss: function() { + return this.assert(this.value.match(/^[0-6][0-9]:[0-6][0-9]:[0-6][0-9]$/), + _('valid time (HH:MM:SS)')); + }, + + dateyyyymmdd: function() { + if (this.value.match(/^(\d\d\d\d)-(\d\d)-(\d\d)/)) { + var year = +RegExp.$1, + month = +RegExp.$2, + day = +RegExp.$3, + days_in_month = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; + + var is_leap_year = function(year) { + return ((!(year % 4) && (year % 100)) || !(year % 400)); + } + + var get_days_in_month = function(month, year) { + return (month === 2 && is_leap_year(year)) ? 29 : days_in_month[month - 1]; + } + + /* Firewall rules in the past don't make sense */ + return this.assert(year >= 2015 && month && month <= 12 && day && day <= get_days_in_month(month, year), + _('valid date (YYYY-MM-DD)')); + + } + + return this.assert(false, _('valid date (YYYY-MM-DD)')); + }, + + unique: function(subvalidator, subargs) { + var ctx = this, + option = findParent(ctx.field, '[data-type][data-name]'), + section = findParent(option, '.cbi-section'), + query = '[data-type="%s"][data-name="%s"]'.format(option.getAttribute('data-type'), option.getAttribute('data-name')), + unique = true; + + section.querySelectorAll(query).forEach(function(sibling) { + if (sibling === option) + return; + + var input = sibling.querySelector('[data-type]'), + values = input ? (input.getAttribute('data-is-list') ? input.value.match(/[^ \t]+/g) : [ input.value ]) : null; + + if (values !== null && values.indexOf(ctx.value) !== -1) + unique = false; + }); + + if (!unique) + return this.assert(false, _('unique value')); + + if (typeof(subvalidator) === 'function') + return this.apply(subvalidator, null, subargs); + + return this.assert(true); + }, + + hexstring: function() { + return this.assert(this.value.match(/^([a-f0-9][a-f0-9]|[A-F0-9][A-F0-9])+$/), + _('hexadecimal encoded value')); + }, + + string: function() { + return true; + } + } +}); + +return ValidatorFactory; diff --git a/luci-base/htdocs/luci-static/resources/xhr.js b/luci-base/htdocs/luci-static/resources/xhr.js index 3133898b5..10bc88e1f 100644 --- a/luci-base/htdocs/luci-static/resources/xhr.js +++ b/luci-base/htdocs/luci-static/resources/xhr.js @@ -1,250 +1 @@ -/* - * xhr.js - XMLHttpRequest helper class - * (c) 2008-2018 Jo-Philipp Wich - */ - -XHR.prototype = { - _encode: function(obj) { - obj = obj ? obj : { }; - obj['_'] = Math.random(); - - if (typeof obj == 'object') { - var code = ''; - var self = this; - - for (var k in obj) - code += (code ? '&' : '') + - k + '=' + encodeURIComponent(obj[k]); - - return code; - } - - return obj; - }, - - _response: function(callback, ts) { - if (this._xmlHttp.readyState !== 4) - return; - - var status = this._xmlHttp.status, - login = this._xmlHttp.getResponseHeader("X-LuCI-Login-Required"), - type = this._xmlHttp.getResponseHeader("Content-Type"), - json = null; - - if (status === 403 && login === 'yes') { - XHR.halt(); - - 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…'))) - ]); - } - else if (type && type.toLowerCase().match(/^application\/json\b/)) { - try { - json = JSON.parse(this._xmlHttp.responseText); - } - catch(e) { - json = null; - } - } - - callback(this._xmlHttp, json, Date.now() - ts); - }, - - busy: function() { - if (!this._xmlHttp) - return false; - - switch (this._xmlHttp.readyState) - { - case 1: - case 2: - case 3: - return true; - - default: - return false; - } - }, - - abort: function() { - if (this.busy()) - this._xmlHttp.abort(); - }, - - get: function(url, data, callback, timeout) { - this._xmlHttp = new XMLHttpRequest(); - - var xhr = this._xmlHttp, - code = this._encode(data); - - url = location.protocol + '//' + location.host + url; - - if (code) - if (url.substr(url.length-1,1) == '&') - url += code; - else - url += '?' + code; - - xhr.open('GET', url, true); - - if (!isNaN(timeout)) - xhr.timeout = timeout; - - xhr.onreadystatechange = this._response.bind(this, callback, Date.now()); - xhr.send(null); - }, - - post: function(url, data, callback, timeout) { - this._xmlHttp = new XMLHttpRequest(); - - var xhr = this._xmlHttp, - code = this._encode(data); - - xhr.open('POST', url, true); - - if (!isNaN(timeout)) - xhr.timeout = timeout; - - xhr.onreadystatechange = this._response.bind(this, callback, Date.now()); - xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - xhr.send(code); - }, - - cancel: function() { - this._xmlHttp.onreadystatechange = function() {}; - this._xmlHttp.abort(); - }, - - send_form: function(form, callback, extra_values) { - var code = ''; - - for (var i = 0; i < form.elements.length; i++) { - var e = form.elements[i]; - - if (e.options) { - code += (code ? '&' : '') + - form.elements[i].name + '=' + encodeURIComponent( - e.options[e.selectedIndex].value - ); - } - else if (e.length) { - for (var j = 0; j < e.length; j++) - if (e[j].name) { - code += (code ? '&' : '') + - e[j].name + '=' + encodeURIComponent(e[j].value); - } - } - else { - code += (code ? '&' : '') + - e.name + '=' + encodeURIComponent(e.value); - } - } - - if (typeof extra_values == 'object') - for (var key in extra_values) - code += (code ? '&' : '') + - key + '=' + encodeURIComponent(extra_values[key]); - - return (form.method == 'get' - ? this.get(form.getAttribute('action'), code, callback) - : this.post(form.getAttribute('action'), code, callback)); - } -} - -XHR.get = function(url, data, callback) { - (new XHR()).get(url, data, callback); -} - -XHR.post = function(url, data, callback) { - (new XHR()).post(url, data, callback); -} - -XHR.poll = function(interval, url, data, callback, post) { - if (isNaN(interval) || interval <= 0) - interval = L.env.pollinterval; - - if (!XHR._q) { - XHR._t = 0; - XHR._q = [ ]; - XHR._r = function() { - for (var i = 0, e = XHR._q[0]; i < XHR._q.length; e = XHR._q[++i]) - { - if (!(XHR._t % e.interval) && !e.xhr.busy()) - e.xhr[post ? 'post' : 'get'](e.url, e.data, e.callback, e.interval * 1000 * 5 - 5); - } - - XHR._t++; - }; - } - - var e = { - interval: interval, - callback: callback, - url: url, - data: data, - xhr: new XHR() - }; - - XHR._q.push(e); - - return e; -} - -XHR.stop = function(e) { - for (var i = 0; XHR._q && XHR._q[i]; i++) { - if (XHR._q[i] === e) { - e.xhr.cancel(); - XHR._q.splice(i, 1); - return true; - } - } - - return false; -} - -XHR.halt = function() { - if (XHR._i) { - /* show & set poll indicator */ - try { - document.getElementById('xhr_poll_status').style.display = ''; - document.getElementById('xhr_poll_status_on').style.display = 'none'; - document.getElementById('xhr_poll_status_off').style.display = ''; - } catch(e) { } - - window.clearInterval(XHR._i); - XHR._i = null; - } -} - -XHR.run = function() { - if (XHR._r && !XHR._i) { - /* show & set poll indicator */ - try { - document.getElementById('xhr_poll_status').style.display = ''; - document.getElementById('xhr_poll_status_on').style.display = ''; - document.getElementById('xhr_poll_status_off').style.display = 'none'; - } catch(e) { } - - /* kick first round manually to prevent one second lag when setting up - * the poll interval */ - XHR._r(); - XHR._i = window.setInterval(XHR._r, 1000); - } -} - -XHR.running = function() { - return !!(XHR._r && XHR._i); -} - -function XHR() {} - -document.addEventListener('DOMContentLoaded', XHR.run); +/* replaced by luci.js */ diff --git a/luci-base/luasrc/cbi.lua b/luci-base/luasrc/cbi.lua index 971830fe8..450e41391 100644 --- a/luci-base/luasrc/cbi.lua +++ b/luci-base/luasrc/cbi.lua @@ -1347,6 +1347,18 @@ function AbstractValue.deplist2json(self, section, deplist) return util.serialize_json(deps) end +-- Serialize choices +function AbstractValue.choices(self) + if type(self.keylist) == "table" and #self.keylist > 0 then + local i, k, v = nil, nil, {} + for i, k in ipairs(self.keylist) do + v[k] = self.vallist[i] or k + end + return v + end + return nil +end + -- Generates the unique CBID function AbstractValue.cbid(self, section) return "cbid."..self.map.config.."."..section.."."..self.option diff --git a/luci-base/luasrc/cbi/datatypes.lua b/luci-base/luasrc/cbi/datatypes.lua index 33d018d2d..c1cf01f9c 100644 --- a/luci-base/luasrc/cbi/datatypes.lua +++ b/luci-base/luasrc/cbi/datatypes.lua @@ -418,7 +418,7 @@ function maxlength(val, max) end function phonedigit(val) - return (val:match("^[0-9\*#!%.]+$") ~= nil) + return (val:match("^[0-9%*#!%.]+$") ~= nil) end function timehhmmss(val) diff --git a/luci-base/luasrc/controller/admin/index.lua b/luci-base/luasrc/controller/admin/index.lua index 1f7db0cb3..9fcfe4a30 100644 --- a/luci-base/luasrc/controller/admin/index.lua +++ b/luci-base/luasrc/controller/admin/index.lua @@ -88,6 +88,9 @@ function index() page = entry({"admin", "translations"}, call("action_translations"), nil) page.leaf = true + page = entry({"admin", "ubus"}, call("action_ubus"), nil) + page.leaf = true + -- Logout is last entry({"admin", "logout"}, call("action_logout"), _("Logout"), 999) end @@ -129,6 +132,124 @@ function action_translations(lang) http.write_json(i18n.dump()) end +local function ubus_reply(id, data, code, errmsg) + local reply = { jsonrpc = "2.0", id = id } + if errmsg then + reply.error = { + code = code, + message = errmsg + } + else + reply.result = { code, data } + end + + return reply +end + +local ubus_types = { + nil, + "array", + "object", + "string", + nil, -- INT64 + "number", + nil, -- INT16, + "boolean", + "double" +} + +local function ubus_request(req) + if type(req) ~= "table" or type(req.method) ~= "string" or type(req.params) ~= "table" or + #req.params < 2 or req.jsonrpc ~= "2.0" or req.id == nil then + return ubus_reply(nil, nil, -32600, "Invalid request") + + elseif req.method == "call" then + local sid, obj, fun, arg = + req.params[1], req.params[2], req.params[3], req.params[4] or {} + if type(arg) ~= "table" or arg.ubus_rpc_session ~= nil then + return ubus_reply(req.id, nil, -32602, "Invalid parameters") + end + + if sid == "00000000000000000000000000000000" then + sid = luci.dispatcher.context.authsession + end + + arg.ubus_rpc_session = sid + + local res, code = luci.util.ubus(obj, fun, arg) + return ubus_reply(req.id, res, code or 0) + + elseif req.method == "list" then + if type(params) ~= "table" or #params == 0 then + local objs = { luci.util.ubus() } + return ubus_reply(req.id, objs, 0) + else + local n, rv = nil, {} + for n = 1, #params do + if type(params[n]) ~= "string" then + return ubus_reply(req.id, nil, -32602, "Invalid parameters") + end + + local sig = luci.util.ubus(params[n]) + if sig and type(sig) == "table" then + rv[params[n]] = {} + + local m, p + for m, p in pairs(sig) do + if type(p) == "table" then + rv[params[n]][m] = {} + + local pn, pt + for pn, pt in pairs(p) do + rv[params[n]][m][pn] = ubus_types[pt] or "unknown" + end + end + end + end + end + return ubus_reply(req.id, rv, 0) + end + end + + return ubus_reply(req.id, nil, -32601, "Method not found") +end + +function action_ubus() + local parser = require "luci.jsonc".new() + + luci.http.context.request:setfilehandler(function(_, s) + if not s then + return nil + end + + local ok, err = parser:parse(s) + return (not err or nil) + end) + + luci.http.context.request:content() + + local json = parser:get() + if json == nil or type(json) ~= "table" then + luci.http.prepare_content("application/json") + luci.http.write_json(ubus_reply(nil, nil, -32700, "Parse error")) + return + end + + local response + if #json == 0 then + response = ubus_request(json) + else + response = {} + + local _, request + for _, request in ipairs(json) do + response[_] = ubus_request(request) + end + end + + luci.http.prepare_content("application/json") + luci.http.write_json(response) +end function lease_status() local s = require "luci.tools.status" diff --git a/luci-base/luasrc/controller/admin/uci.lua b/luci-base/luasrc/controller/admin/uci.lua index 1d955dd98..6b19c62f8 100644 --- a/luci-base/luasrc/controller/admin/uci.lua +++ b/luci-base/luasrc/controller/admin/uci.lua @@ -1,5 +1,5 @@ -- Copyright 2008 Steven Barth --- Copyright 2010-2015 Jo-Philipp Wich +-- Copyright 2010-2019 Jo-Philipp Wich -- Licensed to the public under the Apache License 2.0. module("luci.controller.admin.uci", package.seeall) @@ -9,8 +9,7 @@ function index() or table.concat(luci.dispatcher.context.request, "/") entry({"admin", "uci"}, nil, _("Configuration")) - entry({"admin", "uci", "changes"}, post_on({ trigger_apply = true }, "action_changes"), _("Changes"), 40).query = {redir=redir} - entry({"admin", "uci", "revert"}, post("action_revert"), _("Revert"), 30).query = {redir=redir} + entry({"admin", "uci", "revert"}, post("action_revert"), nil) local node local authen = function(checkpass, allowed_users) @@ -31,34 +30,6 @@ function index() end -function action_changes() - local uci = require "luci.model.uci" - local changes = uci:changes() - - luci.template.render("admin_uci/changes", { - changes = next(changes) and changes, - timeout = timeout, - trigger_apply = luci.http.formvalue("trigger_apply") and true or false - }) -end - -function action_revert() - local uci = require "luci.model.uci" - local changes = uci:changes() - - -- Collect files to be reverted - local r, tbl - for r, tbl in pairs(changes) do - uci:revert(r) - end - - luci.template.render("admin_uci/revert", { - changes = next(changes) and changes, - trigger_revert = true - }) -end - - local function ubus_state_to_http(errstr) local map = { ["Invalid command"] = 400, @@ -107,3 +78,19 @@ function action_confirm() local _, errstr = uci:confirm(token) ubus_state_to_http(errstr) end + +function action_revert() + local uci = require "luci.model.uci" + local changes = uci:changes() + + -- Collect files to be reverted + local _, errstr, r, tbl + for r, tbl in pairs(changes) do + _, errstr = uci:revert(r) + if errstr then + break + end + end + + ubus_state_to_http(errstr or "OK") +end diff --git a/luci-base/luasrc/dispatcher.lua b/luci-base/luasrc/dispatcher.lua index 626a46dfd..e8106b741 100644 --- a/luci-base/luasrc/dispatcher.lua +++ b/luci-base/luasrc/dispatcher.lua @@ -857,6 +857,15 @@ function template(name) end +local _view = function(self, ...) + require "luci.template".render("view", { view = self.view }) +end + +function view(name) + return {type = "view", view = name, target = _view} +end + + local function _cbi(self, ...) local cbi = require "luci.cbi" local tpl = require "luci.template" diff --git a/luci-base/luasrc/sys.lua b/luci-base/luasrc/sys.lua index 7e4a9d63c..d0df27572 100644 --- a/luci-base/luasrc/sys.lua +++ b/luci-base/luasrc/sys.lua @@ -137,7 +137,7 @@ end net = {} local function _nethints(what, callback) - local _, k, e, mac, ip, name + local _, k, e, mac, ip, name, duid, iaid local cur = uci.cursor() local ifn = { } local hosts = { } @@ -189,6 +189,24 @@ local function _nethints(what, callback) end end ) + + cur:foreach("dhcp", "odhcpd", + function(s) + if type(s.leasefile) == "string" and fs.access(s.leasefile) then + for e in io.lines(s.leasefile) do + duid, iaid, name, _, ip = e:match("^# %S+ (%S+) (%S+) (%S+) (-?%d+) %S+ %S+ ([0-9a-f:.]+)/[0-9]+") + mac = net.duid_to_mac(duid) + if mac then + if ip and iaid == "ipv4" then + _add(what, mac, ip, nil, name ~= "*" and name) + elseif ip then + _add(what, mac, nil, ip, name ~= "*" and name) + end + end + end + end + end + ) cur:foreach("dhcp", "host", function(s) @@ -386,6 +404,26 @@ function net.devices() return devs end +function net.duid_to_mac(duid) + local b1, b2, b3, b4, b5, b6 + + if type(duid) == "string" then + -- DUID-LLT / Ethernet + if #duid == 28 then + b1, b2, b3, b4, b5, b6 = duid:match("^00010001(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)%x%x%x%x%x%x%x%x$") + + -- DUID-LL / Ethernet + elseif #duid == 20 then + b1, b2, b3, b4, b5, b6 = duid:match("^00030001(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)$") + + -- DUID-LL / Ethernet (Without Header) + elseif #duid == 12 then + b1, b2, b3, b4, b5, b6 = duid:match("^(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)$") + end + end + + return b1 and luci.ip.checkmac(table.concat({ b1, b2, b3, b4, b5, b6 }, ":")) +end process = {} @@ -405,11 +443,11 @@ function process.list() for line in ps do local pid, ppid, user, stat, vsz, mem, cpu, cmd = line:match( - "^ *(%d+) +(%d+) +(%S.-%S) +([RSDZTW][W ][-1' }, { 'Africa/Ceuta', 'CET-1CEST,M3.5.0,M10.5.0/3' }, { 'Africa/Conakry', 'GMT0' }, { 'Africa/Dakar', 'GMT0' }, { 'Africa/Dar es Salaam', 'EAT-3' }, { 'Africa/Djibouti', 'EAT-3' }, { 'Africa/Douala', 'WAT-1' }, + { 'Africa/El Aaiun', '<+01>-1' }, { 'Africa/Freetown', 'GMT0' }, { 'Africa/Gaborone', 'CAT-2' }, { 'Africa/Harare', 'CAT-2' }, @@ -83,7 +85,7 @@ TZ = { { 'America/Bogota', '<-05>5' }, { 'America/Boise', 'MST7MDT,M3.2.0,M11.1.0' }, { 'America/Cambridge Bay', 'MST7MDT,M3.2.0,M11.1.0' }, - { 'America/Campo Grande', '<-04>4<-03>,M11.1.0/0,M2.3.0/0' }, + { 'America/Campo Grande', '<-04>4' }, { 'America/Cancun', 'EST5' }, { 'America/Caracas', '<-04>4' }, { 'America/Cayenne', '<-03>3' }, @@ -92,7 +94,7 @@ TZ = { { 'America/Chihuahua', 'MST7MDT,M4.1.0,M10.5.0' }, { 'America/Costa Rica', 'CST6' }, { 'America/Creston', 'MST7' }, - { 'America/Cuiaba', '<-04>4<-03>,M11.1.0/0,M2.3.0/0' }, + { 'America/Cuiaba', '<-04>4' }, { 'America/Curacao', 'AST4' }, { 'America/Danmarkshavn', 'GMT0' }, { 'America/Dawson', 'PST8PDT,M3.2.0,M11.1.0' }, @@ -179,7 +181,7 @@ TZ = { { 'America/Santarem', '<-03>3' }, { 'America/Santiago', '<-04>4<-03>,M9.1.6/24,M4.1.6/24' }, { 'America/Santo Domingo', 'AST4' }, - { 'America/Sao Paulo', '<-03>3<-02>,M11.1.0/0,M2.3.0/0' }, + { 'America/Sao Paulo', '<-03>3' }, { 'America/Scoresbysund', '<-01>1<+00>,M3.5.0/0,M10.5.0/1' }, { 'America/Sitka', 'AKST9AKDT,M3.2.0,M11.1.0' }, { 'America/St Barthelemy', 'AST4' }, @@ -237,8 +239,8 @@ TZ = { { 'Asia/Dubai', '<+04>-4' }, { 'Asia/Dushanbe', '<+05>-5' }, { 'Asia/Famagusta', 'EET-2EEST,M3.5.0/3,M10.5.0/4' }, - { 'Asia/Gaza', 'EET-2EEST,M3.4.6/1,M10.5.6/1' }, - { 'Asia/Hebron', 'EET-2EEST,M3.4.6/1,M10.5.6/1' }, + { 'Asia/Gaza', 'EET-2EEST,M3.5.5/0,M10.5.6/1' }, + { 'Asia/Hebron', 'EET-2EEST,M3.5.5/0,M10.5.6/1' }, { 'Asia/Ho Chi Minh', '<+07>-7' }, { 'Asia/Hong Kong', 'HKT-8' }, { 'Asia/Hovd', '<+07>-7' }, diff --git a/luci-base/luasrc/tools/status.lua b/luci-base/luasrc/tools/status.lua index 635995310..e4bc4451a 100644 --- a/luci-base/luasrc/tools/status.lua +++ b/luci-base/luasrc/tools/status.lua @@ -6,21 +6,6 @@ module("luci.tools.status", package.seeall) local uci = require "luci.model.uci".cursor() local ipc = require "luci.ip" -local function duid_to_mac(duid) - local b1, b2, b3, b4, b5, b6 - - -- DUID-LLT / Ethernet - if type(duid) == "string" and #duid == 28 then - b1, b2, b3, b4, b5, b6 = duid:match("^00010001(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)%x%x%x%x%x%x%x%x$") - - -- DUID-LL / Ethernet - elseif type(duid) == "string" and #duid == 20 then - b1, b2, b3, b4, b5, b6 = duid:match("^00030001(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)$") - end - - return b1 and ipc.checkmac(table.concat({ b1, b2, b3, b4, b5, b6 }, ":")) -end - local function dhcp_leases_common(family) local rv = { } local nfs = require "nixio.fs" @@ -93,7 +78,7 @@ local function dhcp_leases_common(family) elseif ip and iaid == "ipv4" and family == 4 then rv[#rv+1] = { expires = (expire >= 0) and os.difftime(expire, os.time()), - macaddr = ipc.checkmac(duid:gsub("^(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)(%x%x)$", "%1:%2:%3:%4:%5:%6")) or "00:00:00:00:00:00", + macaddr = sys.net.duid_to_mac(duid) or "00:00:00:00:00:00", ipaddr = ip, hostname = (name ~= "-") and name } @@ -107,7 +92,7 @@ local function dhcp_leases_common(family) local _, lease local hosts = sys.net.host_hints() for _, lease in ipairs(rv) do - local mac = duid_to_mac(lease.duid) + local mac = sys.net.duid_to_mac(lease.duid) local host = mac and hosts[mac] if host then if not lease.name then diff --git a/luci-base/luasrc/util.lua b/luci-base/luasrc/util.lua index 1a329f3f2..a30e8b72f 100644 --- a/luci-base/luasrc/util.lua +++ b/luci-base/luasrc/util.lua @@ -207,9 +207,8 @@ end -- handling. It may actually be a property of the getopt function -- rather than the shell proper. function shellstartsqescape(value) - res, _ = string.gsub(value, "^\-", "\\-") - res, _ = string.gsub(res, "^-", "\-") - return shellsqescape(value) + res, _ = string.gsub(value, "^%-", "\\-") + return shellsqescape(res) end -- containing the resulting substrings. The optional max parameter specifies diff --git a/luci-base/luasrc/view/admin_uci/changelog.htm b/luci-base/luasrc/view/admin_uci/changelog.htm deleted file mode 100644 index 9d6267cf3..000000000 --- a/luci-base/luasrc/view/admin_uci/changelog.htm +++ /dev/null @@ -1,66 +0,0 @@ -<%# - Copyright 2010 Jo-Philipp Wich - Licensed to the public under the Apache License 2.0. --%> - -<% export("uci_changelog", function(changes) -%> -
    - <%:Legend:%> -
    -
      <%:Section added%>
    -
      <%:Section removed%>
    -
      <%:Option changed%>
    -
      <%:Option removed%>
    -
    -
    -
    - -
    <% - local util = luci.util - local tpl = { - ["add-3"] = "uci add %0 %3 # =%2", - ["set-3"] = "uci set %0.%2=%3", - ["set-4"] = "uci set %0.%2.%3=%4", - ["remove-2"] = "uci del %0.%2", - ["remove-3"] = "uci del %0.%2.%3", - ["order-3"] = "uci reorder %0.%2=%3", - ["list-add-4"] = "uci add_list %0.%2.%3=%4", - ["list-del-4"] = "uci del_list %0.%2.%3=%4", - ["rename-3"] = "uci rename %0.%2=%3", - ["rename-4"] = "uci rename %0.%2.%3=%4" - } - - local conf, deltas - for conf, deltas in util.kspairs(changes) do - write("

    # /etc/config/%s

    " % conf) - - local _, delta, added - for _, delta in pairs(deltas) do - local t = tpl["%s-%d" %{ delta[1], #delta }] - - write(t:gsub("%%(%d)", function(n) - if n == "0" then - return conf - elseif n == "2" then - if added and delta[2] == added[1] then - return "@%s[-1]" % added[2] - else - return delta[2] - end - elseif n == "4" then - return util.shellquote(delta[4]) - else - return delta[tonumber(n)] - end - end)) - - if delta[1] == "add" then - added = { delta[2], delta[3] } - end - end - - write("
    ") - end - %>
    -
    -<%- end) %> diff --git a/luci-base/luasrc/view/admin_uci/changes.htm b/luci-base/luasrc/view/admin_uci/changes.htm deleted file mode 100644 index 43bd7c23f..000000000 --- a/luci-base/luasrc/view/admin_uci/changes.htm +++ /dev/null @@ -1,45 +0,0 @@ -<%# - Copyright 2008 Steven Barth - Copyright 2008-2018 Jo-Philipp Wich - Licensed to the public under the Apache License 2.0. --%> - -<%+header%> - -<%- - local node, redir_url = luci.dispatcher.lookup(luci.http.formvalue("redir")) - export("redirect", redir_url or url("admin/uci/changes")) - - include("admin_uci/changelog") --%> - -

    <%:Configuration%> / <%:Changes%>

    - -<% if changes then %> - <%- uci_changelog(changes) -%> -<% else %> -

    <%:There are no pending changes!%>

    -<% end %> - - - -
    - <% if redir_url then %> -
    - -
    - <% end %> - -
    "> - - " /> - -
    -
    "> - - " /> - -
    -
    - -<%+footer%> diff --git a/luci-base/luasrc/view/admin_uci/revert.htm b/luci-base/luasrc/view/admin_uci/revert.htm deleted file mode 100644 index d8fd3de01..000000000 --- a/luci-base/luasrc/view/admin_uci/revert.htm +++ /dev/null @@ -1,33 +0,0 @@ -<%# - Copyright 2008 Steven Barth - Copyright 2008-2018 Jo-Philipp Wich - Licensed to the public under the Apache License 2.0. --%> - -<%+header%> - -<%- - local node, redir_url = luci.dispatcher.lookup(luci.http.formvalue("redir")) - export("redirect", redir_url or url("admin/uci/changes")) - - include("admin_uci/changelog") --%> - -

    <%:Configuration%> / <%:Revert%>

    - -<% if changes then %> -

    <%:The following changes have been reverted%>:

    - <%- uci_changelog(changes) -%> -<% else %> -

    <%:There are no pending changes to revert!%>

    -<% end %> - -<% if redir_url then %> -
    -
    - -
    -
    -<% end %> - -<%+footer%> diff --git a/luci-base/luasrc/view/cbi/apply_widget.htm b/luci-base/luasrc/view/cbi/apply_widget.htm deleted file mode 100644 index 0f9667390..000000000 --- a/luci-base/luasrc/view/cbi/apply_widget.htm +++ /dev/null @@ -1,172 +0,0 @@ -<% export("cbi_apply_widget", function(redirect_ok, rollback_token) -%> - -<%- end) %> diff --git a/luci-base/luasrc/view/cbi/cell_valueheader.htm b/luci-base/luasrc/view/cbi/cell_valueheader.htm index cb11d8f61..4b7095754 100644 --- a/luci-base/luasrc/view/cbi/cell_valueheader.htm +++ b/luci-base/luasrc/view/cbi/cell_valueheader.htm @@ -3,7 +3,7 @@ local descr = luci.util.trim(striptags(self.description)) local ftype = self.typename or (self.template and self.template:gsub("^.+/", "")) -%> -
    0, "data-type", ftype) .. ifattr(title and #title > 0, "data-title", title, true) .. diff --git a/luci-base/luasrc/view/cbi/dropdown.htm b/luci-base/luasrc/view/cbi/dropdown.htm index 6f4b89905..40bd8e953 100644 --- a/luci-base/luasrc/view/cbi/dropdown.htm +++ b/luci-base/luasrc/view/cbi/dropdown.htm @@ -1,54 +1,19 @@ <%+cbi/valueheader%> - -<%- - local selected = { } - - if self.multiple then - local val - for val in luci.util.imatch(self:cfgvalue(section)) do - selected[val] = true - end - else - selected[self:cfgvalue(section)] = true - end - - if not next(selected) and self.default then - selected[self.default] = true - end --%> - -
    > -
      - <% local i, key; for i, key in pairs(self.keylist) do %> - > - <%=pcdata(self.vallist[i])%> - - <% end %> - <% if self.custom then %> -
    • - - /> -
    • - <% end %> -
    -
    - +>
    <%+cbi/valuefooter%> diff --git a/luci-base/luasrc/view/cbi/dynlist.htm b/luci-base/luasrc/view/cbi/dynlist.htm index fa7dbdb41..d50328d79 100644 --- a/luci-base/luasrc/view/cbi/dynlist.htm +++ b/luci-base/luasrc/view/cbi/dynlist.htm @@ -1,13 +1,12 @@ <%+cbi/valueheader%> -> +> <%+cbi/valuefooter%> diff --git a/luci-base/luasrc/view/cbi/full_valueheader.htm b/luci-base/luasrc/view/cbi/full_valueheader.htm index 10a554329..1d9ebeba9 100644 --- a/luci-base/luasrc/view/cbi/full_valueheader.htm +++ b/luci-base/luasrc/view/cbi/full_valueheader.htm @@ -1,4 +1,4 @@ - <%+cbi/valuefooter%> diff --git a/luci-base/luasrc/view/cbi/mvalue.htm b/luci-base/luasrc/view/cbi/mvalue.htm index db17450d2..97e40abfa 100644 --- a/luci-base/luasrc/view/cbi/mvalue.htm +++ b/luci-base/luasrc/view/cbi/mvalue.htm @@ -1,43 +1,24 @@ -<% - local i, key - local v = self:valuelist(section) or {} --%> - <%+cbi/valueheader%> -<% if self.widget == "select" then %> - -<% elseif self.widget == "checkbox" then %> -
    - <% for i, key in pairs(self.keylist) do %> - > - /> - > - <%=pcdata(self.vallist[i])%> - - <% if self.size and (i % self.size) == 0 then write('
    ') end %> - <% end %> -
    -<% end %> +<% + local util = require "luci.util" + local values = {} + local value + for value in util.imatch(self:cfgvalue(section) or self.default) do + values[#values+1] = value + end +%> +> <%+cbi/valuefooter%> diff --git a/luci-base/luasrc/view/cbi/value.htm b/luci-base/luasrc/view/cbi/value.htm index a20af38b8..6060310b1 100644 --- a/luci-base/luasrc/view/cbi/value.htm +++ b/luci-base/luasrc/view/cbi/value.htm @@ -1,26 +1,35 @@ <%+cbi/valueheader%> - <%- if self.password then -%> - /> - <%- end -%> - 0, "data-choices", { self.keylist, self.vallist }) - %> /> - <%- if self.password then -%> - - <% end %> + +<% local choices = self:choices() + if choices then %> + > +<% else %> + > +<% end %> + <%+cbi/valuefooter%> diff --git a/luci-base/luasrc/view/cbi/wireless_modefreq.htm b/luci-base/luasrc/view/cbi/wireless_modefreq.htm index ebb02e489..eeb1d5c5c 100644 --- a/luci-base/luasrc/view/cbi/wireless_modefreq.htm +++ b/luci-base/luasrc/view/cbi/wireless_modefreq.htm @@ -4,6 +4,7 @@ var freqlist = <%= luci.http.write_json(self.iwinfo.freqlist) %>; var hwmodes = <%= luci.http.write_json(self.iwinfo.hwmodelist or {}) %>; var htmodes = <%= luci.http.write_json(self.iwinfo.htmodelist) %>; + var acs = <%= luci.http.write_json(self.hostapd_acs or 0) %>; var channels = { '11g': [ @@ -14,6 +15,10 @@ ] }; + if (acs < 1) { + channels[(freqlist[freqlist.length - 1].mhz > 2484) ? '11a' : '11g'].length = 0; + } + for (var i = 0; i < freqlist.length; i++) channels[(freqlist[i].mhz > 2484) ? '11a' : '11g'].push( freqlist[i].channel, diff --git a/luci-base/luasrc/view/footer.htm b/luci-base/luasrc/view/footer.htm index 1667d3aa9..ba14ec867 100644 --- a/luci-base/luasrc/view/footer.htm +++ b/luci-base/luasrc/view/footer.htm @@ -1,6 +1,6 @@ <%# Copyright 2008 Steven Barth - Copyright 2008 Jo-Philipp Wich + Copyright 2008-2019 Jo-Philipp Wich Licensed to the public under the Apache License 2.0. -%> @@ -8,18 +8,15 @@ local is_rollback_pending, rollback_time_remaining, rollback_session, rollback_token = luci.model.uci:rollback_pending() if is_rollback_pending or trigger_apply or trigger_revert then - include("cbi/apply_widget") - cbi_apply_widget(redirect, rollback_token) %> - diff --git a/luci-base/luasrc/view/header.htm b/luci-base/luasrc/view/header.htm index d68e39f91..1ef0e5b01 100644 --- a/luci-base/luasrc/view/header.htm +++ b/luci-base/luasrc/view/header.htm @@ -1,6 +1,6 @@ <%# Copyright 2008 Steven Barth - Copyright 2008 Jo-Philipp Wich + Copyright 2008-2019 Jo-Philipp Wich Licensed to the public under the Apache License 2.0. -%> @@ -9,16 +9,25 @@ include("themes/" .. theme .. "/header") luci.dispatcher.context.template_header_sent = true end + + local applyconf = luci.config and luci.config.apply %> + diff --git a/luci-base/luasrc/view/lease_status.htm b/luci-base/luasrc/view/lease_status.htm index bf2a8968d..bbaf5986b 100644 --- a/luci-base/luasrc/view/lease_status.htm +++ b/luci-base/luasrc/view/lease_status.htm @@ -79,17 +79,24 @@ -