From 261246b44405345f02c6e134b3e90554c0fe7db1 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 14 Sep 2023 15:39:45 -0400 Subject: [PATCH] first commit --- Makefile | 46 + README.md | 0 htdocs/cgi-bin/luci | 5 + htdocs/luci-static/resources/cbi.js | 802 ++ htdocs/luci-static/resources/cbi/file.svg | 12 + htdocs/luci-static/resources/cbi/folder.svg | 12 + htdocs/luci-static/resources/cbi/link.svg | 13 + htdocs/luci-static/resources/firewall.js | 560 + htdocs/luci-static/resources/form.js | 4861 +++++++ htdocs/luci-static/resources/fs.js | 429 + htdocs/luci-static/resources/icons/alias.png | Bin 0 -> 651 bytes .../resources/icons/alias_disabled.png | Bin 0 -> 371 bytes htdocs/luci-static/resources/icons/bridge.png | Bin 0 -> 646 bytes .../resources/icons/bridge_disabled.png | Bin 0 -> 385 bytes .../luci-static/resources/icons/ethernet.png | Bin 0 -> 664 bytes .../resources/icons/ethernet_disabled.png | Bin 0 -> 384 bytes .../luci-static/resources/icons/loading.gif | Bin 0 -> 1786 bytes .../luci-static/resources/icons/port_down.png | Bin 0 -> 489 bytes .../luci-static/resources/icons/port_up.png | Bin 0 -> 561 bytes .../resources/icons/signal-0-25.png | Bin 0 -> 451 bytes .../luci-static/resources/icons/signal-0.png | Bin 0 -> 430 bytes .../resources/icons/signal-25-50.png | Bin 0 -> 454 bytes .../resources/icons/signal-50-75.png | Bin 0 -> 454 bytes .../resources/icons/signal-75-100.png | Bin 0 -> 440 bytes .../resources/icons/signal-none.png | Bin 0 -> 624 bytes htdocs/luci-static/resources/icons/switch.png | Bin 0 -> 656 bytes .../resources/icons/switch_disabled.png | Bin 0 -> 388 bytes htdocs/luci-static/resources/icons/tunnel.png | Bin 0 -> 339 bytes .../resources/icons/tunnel_disabled.png | Bin 0 -> 234 bytes htdocs/luci-static/resources/icons/vlan.png | Bin 0 -> 656 bytes .../resources/icons/vlan_disabled.png | Bin 0 -> 388 bytes htdocs/luci-static/resources/icons/wifi.png | Bin 0 -> 745 bytes .../resources/icons/wifi_disabled.png | Bin 0 -> 480 bytes htdocs/luci-static/resources/luci.js | 3462 +++++ htdocs/luci-static/resources/network.js | 4380 ++++++ htdocs/luci-static/resources/promis.min.js | 5 + htdocs/luci-static/resources/protocol/dhcp.js | 42 + htdocs/luci-static/resources/protocol/none.js | 8 + .../luci-static/resources/protocol/static.js | 196 + htdocs/luci-static/resources/rpc.js | 485 + htdocs/luci-static/resources/tools/prng.js | 111 + htdocs/luci-static/resources/tools/widgets.js | 629 + htdocs/luci-static/resources/uci.js | 988 ++ htdocs/luci-static/resources/ui.js | 4949 +++++++ htdocs/luci-static/resources/validation.js | 614 + htdocs/luci-static/resources/xhr.js | 1 + luasrc/cacheloader.lua | 12 + luasrc/ccache.lua | 76 + luasrc/config.lua | 18 + luasrc/controller/admin/index.lua | 199 + luasrc/controller/admin/uci.lua | 70 + luasrc/dispatcher.lua | 1564 +++ luasrc/dispatcher.luadoc | 220 + luasrc/i18n.lua | 55 + luasrc/i18n.luadoc | 42 + luasrc/model/uci.lua | 508 + luasrc/model/uci.luadoc | 369 + luasrc/sgi/cgi.lua | 73 + luasrc/sgi/uhttpd.lua | 99 + luasrc/store.lua | 6 + luasrc/sys.lua | 615 + luasrc/sys.luadoc | 392 + luasrc/sys/zoneinfo.lua | 19 + luasrc/sys/zoneinfo/tzdata.lua | 451 + luasrc/sys/zoneinfo/tzoffset.lua | 46 + luasrc/template.lua | 100 + luasrc/version.lua | 9 + luasrc/view/csrftoken.htm | 24 + luasrc/view/empty_node_placeholder.htm | 11 + luasrc/view/error404.htm | 12 + luasrc/view/error500.htm | 11 + luasrc/view/footer.htm | 27 + luasrc/view/header.htm | 38 + luasrc/view/indexer.htm | 7 + luasrc/view/sysauth.htm | 75 + luasrc/view/view.htm | 12 + luasrc/xml.lua | 26 + luasrc/xml.luadoc | 23 + po/ar/base.po | 10518 +++++++++++++++ po/bg/base.po | 10302 ++++++++++++++ po/bn_BD/base.po | 10230 ++++++++++++++ po/ca/base.po | 10401 ++++++++++++++ po/cs/base.po | 10544 +++++++++++++++ po/da/base.po | 10878 +++++++++++++++ po/de/base.po | 11029 +++++++++++++++ po/el/base.po | 10368 ++++++++++++++ po/en/base.po | 10234 ++++++++++++++ po/es/base.po | 11043 +++++++++++++++ po/fa/base.po | 11188 ++++++++++++++++ po/fi/base.po | 10610 +++++++++++++++ po/fr/base.po | 11001 +++++++++++++++ po/he/base.po | 10259 ++++++++++++++ po/hi/base.po | 10232 ++++++++++++++ po/hu/base.po | 10585 +++++++++++++++ po/it/base.po | 10969 +++++++++++++++ po/ja/base.po | 10647 +++++++++++++++ po/ko/base.po | 10401 ++++++++++++++ po/mr/base.po | 10230 ++++++++++++++ po/ms/base.po | 10271 ++++++++++++++ po/nb_NO/base.po | 10387 ++++++++++++++ po/nl/base.po | 10939 +++++++++++++++ po/pl/base.po | 10939 +++++++++++++++ po/pt/base.po | 10984 +++++++++++++++ po/pt_BR/base.po | 10996 +++++++++++++++ po/ro/base.po | 10974 +++++++++++++++ po/ru/base.po | 10956 +++++++++++++++ po/sk/base.po | 10532 +++++++++++++++ po/sv/base.po | 10276 ++++++++++++++ po/templates/base.pot | 10221 ++++++++++++++ po/tr/base.po | 10842 +++++++++++++++ po/uk/base.po | 10950 +++++++++++++++ po/vi/base.po | 10853 +++++++++++++++ po/zh_Hans/base.po | 10488 +++++++++++++++ po/zh_Hant/base.po | 10491 +++++++++++++++ root/etc/config/luci | 31 + root/etc/config/ucitrack | 56 + root/etc/init.d/ucitrack | 57 + root/etc/luci-uploads/.placeholder | 0 root/sbin/luci-reload | 45 + root/usr/libexec/rpcd/luci | 706 + root/usr/share/acl.d/luci-base.json | 8 + root/usr/share/luci/menu.d/luci-base.json | 153 + root/usr/share/rpcd/acl.d/luci-base.json | 46 + root/www/index.html | 22 + src/Makefile | 32 + src/contrib/lemon.c | 5040 +++++++ src/contrib/lempar.c | 851 ++ src/jsmin.c | 319 + src/mkversion.sh | 24 + src/plural_formula.y | 43 + src/po2lmo.c | 332 + src/template_lmo.c | 637 + src/template_lmo.h | 104 + src/template_lualib.c | 224 + src/template_lualib.h | 30 + src/template_parser.c | 419 + src/template_parser.h | 80 + src/template_utils.c | 500 + src/template_utils.h | 49 + 139 files changed, 420395 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100755 htdocs/cgi-bin/luci create mode 100644 htdocs/luci-static/resources/cbi.js create mode 100644 htdocs/luci-static/resources/cbi/file.svg create mode 100644 htdocs/luci-static/resources/cbi/folder.svg create mode 100644 htdocs/luci-static/resources/cbi/link.svg create mode 100644 htdocs/luci-static/resources/firewall.js create mode 100644 htdocs/luci-static/resources/form.js create mode 100644 htdocs/luci-static/resources/fs.js create mode 100644 htdocs/luci-static/resources/icons/alias.png create mode 100644 htdocs/luci-static/resources/icons/alias_disabled.png create mode 100644 htdocs/luci-static/resources/icons/bridge.png create mode 100644 htdocs/luci-static/resources/icons/bridge_disabled.png create mode 100644 htdocs/luci-static/resources/icons/ethernet.png create mode 100644 htdocs/luci-static/resources/icons/ethernet_disabled.png create mode 100644 htdocs/luci-static/resources/icons/loading.gif create mode 100644 htdocs/luci-static/resources/icons/port_down.png create mode 100644 htdocs/luci-static/resources/icons/port_up.png create mode 100644 htdocs/luci-static/resources/icons/signal-0-25.png create mode 100644 htdocs/luci-static/resources/icons/signal-0.png create mode 100644 htdocs/luci-static/resources/icons/signal-25-50.png create mode 100644 htdocs/luci-static/resources/icons/signal-50-75.png create mode 100644 htdocs/luci-static/resources/icons/signal-75-100.png create mode 100644 htdocs/luci-static/resources/icons/signal-none.png create mode 100644 htdocs/luci-static/resources/icons/switch.png create mode 100644 htdocs/luci-static/resources/icons/switch_disabled.png create mode 100644 htdocs/luci-static/resources/icons/tunnel.png create mode 100644 htdocs/luci-static/resources/icons/tunnel_disabled.png create mode 100644 htdocs/luci-static/resources/icons/vlan.png create mode 100644 htdocs/luci-static/resources/icons/vlan_disabled.png create mode 100644 htdocs/luci-static/resources/icons/wifi.png create mode 100644 htdocs/luci-static/resources/icons/wifi_disabled.png create mode 100644 htdocs/luci-static/resources/luci.js create mode 100644 htdocs/luci-static/resources/network.js create mode 100644 htdocs/luci-static/resources/promis.min.js create mode 100644 htdocs/luci-static/resources/protocol/dhcp.js create mode 100644 htdocs/luci-static/resources/protocol/none.js create mode 100644 htdocs/luci-static/resources/protocol/static.js create mode 100644 htdocs/luci-static/resources/rpc.js create mode 100644 htdocs/luci-static/resources/tools/prng.js create mode 100644 htdocs/luci-static/resources/tools/widgets.js create mode 100644 htdocs/luci-static/resources/uci.js create mode 100644 htdocs/luci-static/resources/ui.js create mode 100644 htdocs/luci-static/resources/validation.js create mode 100644 htdocs/luci-static/resources/xhr.js create mode 100644 luasrc/cacheloader.lua create mode 100644 luasrc/ccache.lua create mode 100644 luasrc/config.lua create mode 100644 luasrc/controller/admin/index.lua create mode 100644 luasrc/controller/admin/uci.lua create mode 100644 luasrc/dispatcher.lua create mode 100644 luasrc/dispatcher.luadoc create mode 100644 luasrc/i18n.lua create mode 100644 luasrc/i18n.luadoc create mode 100644 luasrc/model/uci.lua create mode 100644 luasrc/model/uci.luadoc create mode 100644 luasrc/sgi/cgi.lua create mode 100644 luasrc/sgi/uhttpd.lua create mode 100644 luasrc/store.lua create mode 100644 luasrc/sys.lua create mode 100644 luasrc/sys.luadoc create mode 100644 luasrc/sys/zoneinfo.lua create mode 100644 luasrc/sys/zoneinfo/tzdata.lua create mode 100644 luasrc/sys/zoneinfo/tzoffset.lua create mode 100644 luasrc/template.lua create mode 100644 luasrc/version.lua create mode 100644 luasrc/view/csrftoken.htm create mode 100644 luasrc/view/empty_node_placeholder.htm create mode 100644 luasrc/view/error404.htm create mode 100644 luasrc/view/error500.htm create mode 100644 luasrc/view/footer.htm create mode 100644 luasrc/view/header.htm create mode 100644 luasrc/view/indexer.htm create mode 100644 luasrc/view/sysauth.htm create mode 100644 luasrc/view/view.htm create mode 100644 luasrc/xml.lua create mode 100644 luasrc/xml.luadoc create mode 100644 po/ar/base.po create mode 100644 po/bg/base.po create mode 100644 po/bn_BD/base.po create mode 100644 po/ca/base.po create mode 100644 po/cs/base.po create mode 100644 po/da/base.po create mode 100644 po/de/base.po create mode 100644 po/el/base.po create mode 100644 po/en/base.po create mode 100644 po/es/base.po create mode 100644 po/fa/base.po create mode 100644 po/fi/base.po create mode 100644 po/fr/base.po create mode 100644 po/he/base.po create mode 100644 po/hi/base.po create mode 100644 po/hu/base.po create mode 100644 po/it/base.po create mode 100644 po/ja/base.po create mode 100644 po/ko/base.po create mode 100644 po/mr/base.po create mode 100644 po/ms/base.po create mode 100644 po/nb_NO/base.po create mode 100644 po/nl/base.po create mode 100644 po/pl/base.po create mode 100644 po/pt/base.po create mode 100644 po/pt_BR/base.po create mode 100644 po/ro/base.po create mode 100644 po/ru/base.po create mode 100644 po/sk/base.po create mode 100644 po/sv/base.po create mode 100644 po/templates/base.pot create mode 100644 po/tr/base.po create mode 100644 po/uk/base.po create mode 100644 po/vi/base.po create mode 100644 po/zh_Hans/base.po create mode 100644 po/zh_Hant/base.po create mode 100644 root/etc/config/luci create mode 100644 root/etc/config/ucitrack create mode 100755 root/etc/init.d/ucitrack create mode 100644 root/etc/luci-uploads/.placeholder create mode 100755 root/sbin/luci-reload create mode 100755 root/usr/libexec/rpcd/luci create mode 100644 root/usr/share/acl.d/luci-base.json create mode 100644 root/usr/share/luci/menu.d/luci-base.json create mode 100644 root/usr/share/rpcd/acl.d/luci-base.json create mode 100644 root/www/index.html create mode 100644 src/Makefile create mode 100644 src/contrib/lemon.c create mode 100644 src/contrib/lempar.c create mode 100644 src/jsmin.c create mode 100755 src/mkversion.sh create mode 100644 src/plural_formula.y create mode 100644 src/po2lmo.c create mode 100644 src/template_lmo.c create mode 100644 src/template_lmo.h create mode 100644 src/template_lualib.c create mode 100644 src/template_lualib.h create mode 100644 src/template_parser.c create mode 100644 src/template_parser.h create mode 100644 src/template_utils.c create mode 100644 src/template_utils.h diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6cb2b64 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# +# Copyright (C) 2008-2015 The LuCI Team +# +# This is free software, licensed under the Apache License, Version 2.0 . +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-base + +LUCI_TYPE:=mod +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-lib-base +rpcd-mod-file +rpcd-mod-luci +cgi-io + +PKG_LICENSE:=MIT + +HOST_BUILD_DIR:=$(BUILD_DIR_HOST)/$(PKG_NAME) + +include $(INCLUDE_DIR)/host-build.mk + +define Package/luci-base/conffiles +/etc/luci-uploads +/etc/config/luci +/etc/config/ucitrack +endef + +include ../../luci.mk + +define Host/Configure +endef + +define Host/Compile + $(MAKE) -C src/ clean po2lmo jsmin +endef + +define Host/Install + $(INSTALL_DIR) $(1)/bin + $(INSTALL_BIN) src/po2lmo $(1)/bin/po2lmo + $(INSTALL_BIN) src/jsmin $(1)/bin/jsmin +endef + +$(eval $(call HostBuild)) + +# call BuildPackage - OpenWrt buildroot signature diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/htdocs/cgi-bin/luci b/htdocs/cgi-bin/luci new file mode 100755 index 0000000..c5c9847 --- /dev/null +++ b/htdocs/cgi-bin/luci @@ -0,0 +1,5 @@ +#!/usr/bin/lua +require "luci.cacheloader" +require "luci.sgi.cgi" +luci.dispatcher.indexcache = "/tmp/luci-indexcache" +luci.sgi.cgi.run() diff --git a/htdocs/luci-static/resources/cbi.js b/htdocs/luci-static/resources/cbi.js new file mode 100644 index 0000000..3fc6edf --- /dev/null +++ b/htdocs/luci-static/resources/cbi.js @@ -0,0 +1,802 @@ +/* + LuCI - Lua Configuration Interface + + Copyright 2008 Steven Barth + Copyright 2008-2018 Jo-Philipp Wich + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +var cbi_d = []; +var cbi_strings = { path: {}, label: {} }; + +function s8(bytes, off) { + var n = bytes[off]; + return (n > 0x7F) ? (n - 256) >>> 0 : n; +} + +function u16(bytes, off) { + return ((bytes[off + 1] << 8) + bytes[off]) >>> 0; +} + +function sfh(s) { + if (s === null || s.length === 0) + return null; + + var bytes = []; + + for (var i = 0; i < s.length; i++) { + var ch = s.charCodeAt(i); + + if (ch <= 0x7F) + bytes.push(ch); + else if (ch <= 0x7FF) + bytes.push(((ch >>> 6) & 0x1F) | 0xC0, + ( ch & 0x3F) | 0x80); + else if (ch <= 0xFFFF) + bytes.push(((ch >>> 12) & 0x0F) | 0xE0, + ((ch >>> 6) & 0x3F) | 0x80, + ( ch & 0x3F) | 0x80); + else if (code <= 0x10FFFF) + bytes.push(((ch >>> 18) & 0x07) | 0xF0, + ((ch >>> 12) & 0x3F) | 0x80, + ((ch >> 6) & 0x3F) | 0x80, + ( ch & 0x3F) | 0x80); + } + + if (!bytes.length) + return null; + + var hash = (bytes.length >>> 0), + len = (bytes.length >>> 2), + off = 0, tmp; + + while (len--) { + hash += u16(bytes, off); + tmp = ((u16(bytes, off + 2) << 11) ^ hash) >>> 0; + hash = ((hash << 16) ^ tmp) >>> 0; + hash += hash >>> 11; + off += 4; + } + + switch ((bytes.length & 3) >>> 0) { + case 3: + hash += u16(bytes, off); + hash = (hash ^ (hash << 16)) >>> 0; + hash = (hash ^ (s8(bytes, off + 2) << 18)) >>> 0; + hash += hash >>> 11; + break; + + case 2: + hash += u16(bytes, off); + hash = (hash ^ (hash << 11)) >>> 0; + hash += hash >>> 17; + break; + + case 1: + hash += s8(bytes, off); + hash = (hash ^ (hash << 10)) >>> 0; + hash += hash >>> 1; + break; + } + + hash = (hash ^ (hash << 3)) >>> 0; + hash += hash >>> 5; + hash = (hash ^ (hash << 4)) >>> 0; + hash += hash >>> 17; + hash = (hash ^ (hash << 25)) >>> 0; + hash += hash >>> 6; + + return (0x100000000 + hash).toString(16).substr(1); +} + +var plural_function = null; + +function trimws(s) { + return String(s).trim().replace(/[ \t\n]+/g, ' '); +} + +function _(s, c) { + var k = (c != null ? trimws(c) + '\u0001' : '') + trimws(s); + return (window.TR && TR[sfh(k)]) || s; +} + +function N_(n, s, p, c) { + if (plural_function == null && window.TR) + plural_function = new Function('n', (TR['00000000'] || 'plural=(n != 1);') + 'return +plural'); + + var i = plural_function ? plural_function(n) : (n != 1), + k = (c != null ? trimws(c) + '\u0001' : '') + trimws(s) + '\u0002' + i.toString(); + + return (window.TR && TR[sfh(k)]) || (i ? p : s); +} + + +function cbi_d_add(field, dep, index) { + var obj = (typeof(field) === 'string') ? document.getElementById(field) : field; + if (obj) { + var entry + for (var i=0; i entry.index) + break; + } + + if (!next) + parent.appendChild(entry.node); + else + parent.insertBefore(entry.node, next); + + state = true; + } + + // hide optionals widget if no choices remaining + if (parent && parent.parentNode && parent.getAttribute('data-optionals')) + parent.parentNode.style.display = (parent.options.length <= 1) ? 'none' : ''; + } + + if (entry && entry.parent) + cbi_tag_last(parent); + + if (state) + cbi_d_update(); + else if (parent) + parent.dispatchEvent(new CustomEvent('dependency-update', { bubbles: true })); +} + +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++) { + var str = JSON.parse(node.getAttribute('data-strings')); + for (var key in str) { + for (var key2 in str[key]) { + var dst = cbi_strings[key] || (cbi_strings[key] = { }); + dst[key2] = str[key][key2]; + } + } + } + + nodes = document.querySelectorAll('[data-depends]'); + + for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { + var index = parseInt(node.getAttribute('data-index'), 10); + var depends = JSON.parse(node.getAttribute('data-depends')); + if (!isNaN(index) && depends.length > 0) { + for (var alt = 0; alt < depends.length; alt++) + cbi_d_add(node, depends[alt], index); + } + } + + nodes = document.querySelectorAll('[data-update]'); + + for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { + var events = node.getAttribute('data-update').split(' '); + for (var j = 0, event; (event = events[j]) !== undefined; j++) + node.addEventListener(event, cbi_d_update); + } + + nodes = document.querySelectorAll('[data-choices]'); + + for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { + 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]; + + var def = (node.getAttribute('data-optional') === 'true') + ? node.placeholder || '' : null; + + 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')), + values = JSON.parse(node.getAttribute('data-values') || '[]'), + options = null; + + if (choices[0] && choices[0].length) { + options = {}; + + for (var j = 0; j < choices[0].length; j++) + options[choices[0][j]] = choices[1][j]; + } + + 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]'); + + for (var i = 0, node; (node = nodes[i]) !== undefined; i++) { + cbi_validate_field(node, node.getAttribute('data-optional') === 'true', + node.getAttribute('data-type')); + } + + document.querySelectorAll('.cbi-tooltip:not(:empty)').forEach(function(s) { + s.parentNode.classList.add('cbi-tooltip-container'); + }); + + document.querySelectorAll('.cbi-section-remove > input[name^="cbi.rts"]').forEach(function(i) { + var handler = function(ev) { + var bits = this.name.split(/\./), + section = document.getElementById('cbi-' + bits[2] + '-' + bits[3]); + + section.style.opacity = (ev.type === 'mouseover') ? 0.5 : ''; + }; + + i.addEventListener('mouseover', handler); + i.addEventListener('mouseout', handler); + }); + + var tasks = []; + + document.querySelectorAll('[data-ui-widget]').forEach(function(node) { + var args = JSON.parse(node.getAttribute('data-ui-widget') || '[]'), + widget = new (Function.prototype.bind.apply(L.ui[args[0]], args)), + markup = widget.render(); + + tasks.push(Promise.resolve(markup).then(function(markup) { + markup.addEventListener('widget-change', cbi_d_update); + node.parentNode.replaceChild(markup, node); + })); + }); + + Promise.all(tasks).then(cbi_d_update); +} + +function cbi_validate_form(form, errmsg) +{ + /* if triggered by a section removal or addition, don't validate */ + if (form.cbi_state == 'add-section' || form.cbi_state == 'del-section') + return true; + + if (form.cbi_validators) { + for (var i = 0; i < form.cbi_validators.length; i++) { + var validator = form.cbi_validators[i]; + + if (!validator() && errmsg) { + alert(errmsg); + return false; + } + } + } + + return true; +} + +function cbi_validate_named_section_add(input) +{ + var button = input.parentNode.parentNode.querySelector('.cbi-button-add'); + if (input.value !== '') { + button.disabled = false; + } + else { + button.disabled = true; + } +} + +function cbi_validate_reset(form) +{ + window.setTimeout( + function() { cbi_validate_form(form, null) }, 100 + ); + + return true; +} + +function cbi_validate_field(cbid, optional, type) +{ + var field = isElem(cbid) ? cbid : document.getElementById(cbid); + var validatorFn; + + try { + var cbiValidator = L.validation.create(field, type, optional); + validatorFn = cbiValidator.validate.bind(cbiValidator); + } + catch(e) { + validatorFn = null; + }; + + if (validatorFn !== null) { + var form = findParent(field, 'form'); + + if (!form.cbi_validators) + form.cbi_validators = [ ]; + + form.cbi_validators.push(validatorFn); + + field.addEventListener("blur", validatorFn); + field.addEventListener("keyup", validatorFn); + field.addEventListener("cbi-dropdown-change", validatorFn); + + if (matchesElem(field, 'select')) { + field.addEventListener("change", validatorFn); + field.addEventListener("click", validatorFn); + } + + validatorFn(); + } +} + +function cbi_row_swap(elem, up, store) +{ + var tr = findParent(elem.parentNode, '.cbi-section-table-row'); + + if (!tr) + return false; + + tr.classList.remove('flash'); + + if (up) { + var prev = tr.previousElementSibling; + + if (prev && prev.classList.contains('cbi-section-table-row')) + tr.parentNode.insertBefore(tr, prev); + else + return; + } + else { + var next = tr.nextElementSibling ? tr.nextElementSibling.nextElementSibling : null; + + if (next && next.classList.contains('cbi-section-table-row')) + tr.parentNode.insertBefore(tr, next); + else if (!next) + tr.parentNode.appendChild(tr); + else + return; + } + + var ids = [ ]; + + for (var i = 0, n = 0; i < tr.parentNode.childNodes.length; i++) { + var node = tr.parentNode.childNodes[i]; + if (node.classList && node.classList.contains('cbi-section-table-row')) { + node.classList.remove('cbi-rowstyle-1'); + node.classList.remove('cbi-rowstyle-2'); + node.classList.add((n++ % 2) ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1'); + + if (/-([^\-]+)$/.test(node.id)) + ids.push(RegExp.$1); + } + } + + var input = document.getElementById(store); + if (input) + input.value = ids.join(' '); + + window.scrollTo(0, tr.offsetTop); + void tr.offsetWidth; + tr.classList.add('flash'); + + return false; +} + +function cbi_tag_last(container) +{ + var last; + + for (var i = 0; i < container.childNodes.length; i++) { + var c = container.childNodes[i]; + if (matchesElem(c, 'div')) { + c.classList.remove('cbi-value-last'); + last = c; + } + } + + if (last) + last.classList.add('cbi-value-last'); +} + +function cbi_submit(elem, name, value, action) +{ + var form = elem.form || findParent(elem, 'form'); + + if (!form) + return false; + + if (action) + form.action = action; + + if (name) { + var hidden = form.querySelector('input[type="hidden"][name="%s"]'.format(name)) || + E('input', { type: 'hidden', name: name }); + + hidden.value = value || '1'; + form.appendChild(hidden); + } + + form.submit(); + return true; +} + +String.prototype.format = function() +{ + if (!RegExp) + return; + + var html_esc = [/&/g, '&', /"/g, '"', /'/g, ''', //g, '>']; + var quot_esc = [/"/g, '"', /'/g, ''']; + + function esc(s, r) { + var t = typeof(s); + + if (s == null || t === 'object' || t === 'function') + return ''; + + if (t !== 'string') + s = String(s); + + for (var i = 0; i < r.length; i += 2) + s = s.replace(r[i], r[i+1]); + + return s; + } + + var str = this; + var out = ''; + var re = /^(([^%]*)%('.|0|\x20)?(-)?(\d+)?(\.\d+)?(%|b|c|d|u|f|o|s|x|X|q|h|j|t|m))/; + var a = b = [], numSubstitutions = 0, numMatches = 0; + + while (a = re.exec(str)) { + var m = a[1]; + var leftpart = a[2], pPad = a[3], pJustify = a[4], pMinLength = a[5]; + var pPrecision = a[6], pType = a[7]; + + numMatches++; + + if (pType == '%') { + subst = '%'; + } + else { + if (numSubstitutions < arguments.length) { + var param = arguments[numSubstitutions++]; + + var pad = ''; + if (pPad && pPad.substr(0,1) == "'") + pad = leftpart.substr(1,1); + else if (pPad) + pad = pPad; + else + pad = ' '; + + var justifyRight = true; + if (pJustify && pJustify === "-") + justifyRight = false; + + var minLength = -1; + if (pMinLength) + minLength = +pMinLength; + + var precision = -1; + if (pPrecision && pType == 'f') + precision = +pPrecision.substring(1); + + var subst = param; + + switch(pType) { + case 'b': + subst = Math.floor(+param || 0).toString(2); + break; + + case 'c': + subst = String.fromCharCode(+param || 0); + break; + + case 'd': + subst = Math.floor(+param || 0).toFixed(0); + break; + + case 'u': + var n = +param || 0; + subst = Math.floor((n < 0) ? 0x100000000 + n : n).toFixed(0); + break; + + case 'f': + subst = (precision > -1) + ? ((+param || 0.0)).toFixed(precision) + : (+param || 0.0); + break; + + case 'o': + subst = Math.floor(+param || 0).toString(8); + break; + + case 's': + subst = param; + break; + + case 'x': + subst = Math.floor(+param || 0).toString(16).toLowerCase(); + break; + + case 'X': + subst = Math.floor(+param || 0).toString(16).toUpperCase(); + break; + + case 'h': + subst = esc(param, html_esc); + break; + + case 'q': + subst = esc(param, quot_esc); + break; + + case 't': + var td = 0; + var th = 0; + var tm = 0; + var ts = (param || 0); + + if (ts > 59) { + tm = Math.floor(ts / 60); + ts = (ts % 60); + } + + if (tm > 59) { + th = Math.floor(tm / 60); + tm = (tm % 60); + } + + if (th > 23) { + td = Math.floor(th / 24); + th = (th % 24); + } + + subst = (td > 0) + ? String.format('%dd %dh %dm %ds', td, th, tm, ts) + : String.format('%dh %dm %ds', th, tm, ts); + + break; + + case 'm': + var mf = pMinLength ? +pMinLength : 1000; + var pr = pPrecision ? ~~(10 * +('0' + pPrecision)) : 2; + + var i = 0; + var val = (+param || 0); + var units = [ ' ', ' K', ' M', ' G', ' T', ' P', ' E' ]; + + for (i = 0; (i < units.length) && (val > mf); i++) + val /= mf; + + if (i) + subst = val.toFixed(pr) + units[i] + (mf == 1024 ? 'i' : ''); + else + subst = val + ' '; + + pMinLength = null; + break; + } + } + } + + if (pMinLength) { + subst = subst.toString(); + for (var i = subst.length; i < pMinLength; i++) + if (pJustify == '-') + subst = subst + ' '; + else + subst = pad + subst; + } + + out += leftpart + subst; + str = str.substr(m.length); + } + + return out + str; +} + +String.prototype.nobr = function() +{ + return this.replace(/[\s\n]+/g, ' '); +} + +String.format = function() +{ + var a = [ ]; + + for (var i = 1; i < arguments.length; i++) + a.push(arguments[i]); + + return ''.format.apply(arguments[0], a); +} + +String.nobr = function() +{ + var a = [ ]; + + for (var i = 1; i < arguments.length; i++) + a.push(arguments[i]); + + return ''.nobr.apply(arguments[0], a); +} + +if (window.NodeList && !NodeList.prototype.forEach) { + NodeList.prototype.forEach = function (callback, thisArg) { + thisArg = thisArg || window; + for (var i = 0; i < this.length; i++) { + callback.call(thisArg, this[i], i, this); + } + }; +} + +if (!window.requestAnimationFrame) { + window.requestAnimationFrame = function(f) { + window.setTimeout(function() { + f(new Date().getTime()) + }, 1000/30); + }; +} + + +function isElem(e) { return L.dom.elem(e) } +function toElem(s) { return L.dom.parse(s) } +function matchesElem(node, selector) { return L.dom.matches(node, selector) } +function findParent(node, selector) { return L.dom.parent(node, selector) } +function E() { return L.dom.create.apply(L.dom, arguments) } + +if (typeof(window.CustomEvent) !== 'function') { + function CustomEvent(event, params) { + params = params || { bubbles: false, cancelable: false, detail: undefined }; + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail ); + return evt; + } + + CustomEvent.prototype = window.Event.prototype; + window.CustomEvent = CustomEvent; +} + +function cbi_dropdown_init(sb) { + if (sb && L.dom.findClassInstance(sb) instanceof L.ui.Dropdown) + return; + + var dl = new L.ui.Dropdown(sb, null, { name: sb.getAttribute('name') }); + return dl.bind(sb); +} + +function cbi_update_table(table, data, placeholder) { + var target = isElem(table) ? table : document.querySelector(table); + + if (!isElem(target)) + return; + + var t = L.dom.findClassInstance(target); + + if (!(t instanceof L.ui.Table)) { + t = new L.ui.Table(target); + L.dom.bindClassInstance(target, t); + } + + t.update(data, placeholder); +} + +function showModal(title, children) +{ + return L.showModal(title, children); +} + +function hideModal() +{ + return L.hideModal(); +} + + +document.addEventListener('DOMContentLoaded', function() { + document.addEventListener('validation-failure', function(ev) { + if (ev.target === document.activeElement) + L.showTooltip(ev); + }); + + document.addEventListener('validation-success', function(ev) { + if (ev.target === document.activeElement) + L.hideTooltip(ev); + }); + + L.require('ui').then(function(ui) { + document.querySelectorAll('.table').forEach(cbi_update_table); + }); +}); diff --git a/htdocs/luci-static/resources/cbi/file.svg b/htdocs/luci-static/resources/cbi/file.svg new file mode 100644 index 0000000..9feedcf --- /dev/null +++ b/htdocs/luci-static/resources/cbi/file.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/htdocs/luci-static/resources/cbi/folder.svg b/htdocs/luci-static/resources/cbi/folder.svg new file mode 100644 index 0000000..9a5beca --- /dev/null +++ b/htdocs/luci-static/resources/cbi/folder.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/htdocs/luci-static/resources/cbi/link.svg b/htdocs/luci-static/resources/cbi/link.svg new file mode 100644 index 0000000..3f556fb --- /dev/null +++ b/htdocs/luci-static/resources/cbi/link.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/htdocs/luci-static/resources/firewall.js b/htdocs/luci-static/resources/firewall.js new file mode 100644 index 0000000..a682af4 --- /dev/null +++ b/htdocs/luci-static/resources/firewall.js @@ -0,0 +1,560 @@ +'use strict'; +'require uci'; +'require rpc'; +'require tools.prng as random'; + + +function initFirewallState() { + return L.resolveDefault(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'; + + return random.derive_color(forName); +} + + +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 (lookupZone(name) != null) + return null; + + var d = new Defaults(), + z = uci.add('firewall', 'zone'); + + uci.set('firewall', z, 'name', name); + 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).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 = section.name; + uci.remove('firewall', section['.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; + + 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, + + getZoneColorStyle: function(zone) { + var hex = (zone instanceof Zone) ? zone.getColor() : getColorForName((zone != null && zone != '*') ? zone : null); + + return '--zone-color-rgb:%d, %d, %d; background-color:rgb(var(--zone-color-rgb))'.format( + parseInt(hex.substring(1, 3), 16), + parseInt(hex.substring(3, 5), 16), + parseInt(hex.substring(5, 7), 16) + ); + }, +}); + + +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); + + 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); + else + this.set('network', null); + + return (newNetworks.length < oldNetworks.length); + }, + + getNetworks: function() { + return L.toArray(this.get('network')); + }, + + clearNetworks: function() { + this.set('network', null); + }, + + getDevices: function() { + return L.toArray(this.get('device')); + }, + + getSubnets: function() { + return L.toArray(this.get('subnet')); + }, + + 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 Rule(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/htdocs/luci-static/resources/form.js b/htdocs/luci-static/resources/form.js new file mode 100644 index 0000000..317b49f --- /dev/null +++ b/htdocs/luci-static/resources/form.js @@ -0,0 +1,4861 @@ +'use strict'; +'require ui'; +'require uci'; +'require rpc'; +'require dom'; +'require baseclass'; + +var scope = this; + +var callSessionAccess = rpc.declare({ + object: 'session', + method: 'access', + params: [ 'scope', 'object', 'function' ], + expect: { 'access': false } +}); + +var CBIJSONConfig = baseclass.extend({ + __init__: function(data) { + data = Object.assign({}, data); + + this.data = {}; + + var num_sections = 0, + section_ids = []; + + for (var sectiontype in data) { + if (!data.hasOwnProperty(sectiontype)) + continue; + + if (Array.isArray(data[sectiontype])) { + for (var i = 0, index = 0; i < data[sectiontype].length; i++) { + var item = data[sectiontype][i], + anonymous, name; + + if (!L.isObject(item)) + continue; + + if (typeof(item['.name']) == 'string') { + name = item['.name']; + anonymous = false; + } + else { + name = sectiontype + num_sections; + anonymous = true; + } + + if (!this.data.hasOwnProperty(name)) + section_ids.push(name); + + this.data[name] = Object.assign(item, { + '.index': num_sections++, + '.anonymous': anonymous, + '.name': name, + '.type': sectiontype + }); + } + } + else if (L.isObject(data[sectiontype])) { + this.data[sectiontype] = Object.assign(data[sectiontype], { + '.anonymous': false, + '.name': sectiontype, + '.type': sectiontype + }); + + section_ids.push(sectiontype); + num_sections++; + } + } + + section_ids.sort(L.bind(function(a, b) { + var indexA = (this.data[a]['.index'] != null) ? +this.data[a]['.index'] : 9999, + indexB = (this.data[b]['.index'] != null) ? +this.data[b]['.index'] : 9999; + + if (indexA != indexB) + return (indexA - indexB); + + return L.naturalCompare(a, b); + }, this)); + + for (var i = 0; i < section_ids.length; i++) + this.data[section_ids[i]]['.index'] = i; + }, + + load: function() { + return Promise.resolve(this.data); + }, + + save: function() { + return Promise.resolve(); + }, + + get: function(config, section, option) { + if (section == null) + return null; + + if (option == null) + return this.data[section]; + + if (!this.data.hasOwnProperty(section)) + return null; + + var value = this.data[section][option]; + + if (Array.isArray(value)) + return value; + + if (value != null) + return String(value); + + return null; + }, + + set: function(config, section, option, value) { + if (section == null || option == null || option.charAt(0) == '.') + return; + + if (!this.data.hasOwnProperty(section)) + return; + + if (value == null) + delete this.data[section][option]; + else if (Array.isArray(value)) + this.data[section][option] = value; + else + this.data[section][option] = String(value); + }, + + unset: function(config, section, option) { + return this.set(config, section, option, null); + }, + + sections: function(config, sectiontype, callback) { + var rv = []; + + for (var section_id in this.data) + if (sectiontype == null || this.data[section_id]['.type'] == sectiontype) + rv.push(this.data[section_id]); + + rv.sort(function(a, b) { return a['.index'] - b['.index'] }); + + if (typeof(callback) == 'function') + for (var i = 0; i < rv.length; i++) + callback.call(this, rv[i], rv[i]['.name']); + + return rv; + }, + + add: function(config, sectiontype, sectionname) { + var num_sections_type = 0, next_index = 0; + + for (var name in this.data) { + num_sections_type += (this.data[name]['.type'] == sectiontype); + next_index = Math.max(next_index, this.data[name]['.index']); + } + + var section_id = sectionname || sectiontype + num_sections_type; + + if (!this.data.hasOwnProperty(section_id)) { + this.data[section_id] = { + '.name': section_id, + '.type': sectiontype, + '.anonymous': (sectionname == null), + '.index': next_index + 1 + }; + } + + return section_id; + }, + + remove: function(config, section) { + if (this.data.hasOwnProperty(section)) + delete this.data[section]; + }, + + resolveSID: function(config, section_id) { + return section_id; + }, + + move: function(config, section_id1, section_id2, after) { + return uci.move.apply(this, [config, section_id1, section_id2, after]); + } +}); + +/** + * @class AbstractElement + * @memberof LuCI.form + * @hideconstructor + * @classdesc + * + * The `AbstractElement` class serves as abstract base for the different form + * elements implemented by `LuCI.form`. It provides the common logic for + * loading and rendering values, for nesting elements and for defining common + * properties. + * + * This class is private and not directly accessible by user code. + */ +var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.prototype */ { + __init__: function(title, description) { + this.title = title || ''; + this.description = description || ''; + this.children = []; + }, + + /** + * Add another form element as children to this element. + * + * @param {AbstractElement} element + * The form element to add. + */ + append: function(obj) { + this.children.push(obj); + }, + + /** + * Parse this elements form input. + * + * The `parse()` function recursively walks the form element tree and + * triggers input value reading and validation for each encountered element. + * + * Elements which are hidden due to unsatisified dependencies are skipped. + * + * @returns {Promise} + * Returns a promise resolving once this element's value and the values of + * all child elements have been parsed. The returned promise is rejected + * if any parsed values are not meeting the validation constraints of their + * respective elements. + */ + parse: function() { + var args = arguments; + this.children.forEach(function(child) { + child.parse.apply(child, args); + }); + }, + + /** + * Render the form element. + * + * The `render()` function recursively walks the form element tree and + * renders the markup for each element, returning the assembled DOM tree. + * + * @abstract + * @returns {Node|Promise} + * May return a DOM Node or a promise resolving to a DOM node containing + * the form element's markup, including the markup of any child elements. + */ + render: function() { + L.error('InternalError', 'Not implemented'); + }, + + /** @private */ + 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); + }, + + /** @private */ + 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); + }, + + /** + * Strip any HTML tags from the given input string. + * + * @param {string} input + * The input string to clean. + * + * @returns {string} + * The cleaned input string with HTML tags removed. + */ + stripTags: function(s) { + if (typeof(s) == 'string' && !s.match(/[<>]/)) + return s; + + var x = dom.elem(s) ? s : dom.parse('
' + s + '
'); + + x.querySelectorAll('br').forEach(function(br) { + x.replaceChild(document.createTextNode('\n'), br); + }); + + return (x.textContent || x.innerText || '').replace(/([ \t]*\n)+/g, '\n'); + }, + + /** + * Format the given named property as title string. + * + * This function looks up the given named property and formats its value + * suitable for use as element caption or description string. It also + * strips any HTML tags from the result. + * + * If the property value is a string, it is passed to `String.format()` + * along with any additional parameters passed to `titleFn()`. + * + * If the property value is a function, it is invoked with any additional + * `titleFn()` parameters as arguments and the obtained return value is + * converted to a string. + * + * In all other cases, `null` is returned. + * + * @param {string} property + * The name of the element property to use. + * + * @param {...*} fmt_args + * Extra values to format the title string with. + * + * @returns {string|null} + * The formatted title string or `null` if the property did not exist or + * was neither a string nor a function. + */ + titleFn: function(attr /*, ... */) { + var s = null; + + if (typeof(this[attr]) == 'function') + s = this[attr].apply(this, this.varargs(arguments, 1)); + else if (typeof(this[attr]) == 'string') + s = (arguments.length > 1) ? ''.format.apply(this[attr], this.varargs(arguments, 1)) : this[attr]; + + if (s != null) + s = this.stripTags(String(s)).trim(); + + if (s == null || s == '') + return null; + + return s; + } +}); + +/** + * @constructor Map + * @memberof LuCI.form + * @augments LuCI.form.AbstractElement + * + * @classdesc + * + * The `Map` class represents one complete form. A form usually maps one UCI + * configuraton file and is divided into multiple sections containing multiple + * fields each. + * + * It serves as main entry point into the `LuCI.form` for typical view code. + * + * @param {string} config + * The UCI configuration to map. It is automatically loaded along when the + * resulting map instance. + * + * @param {string} [title] + * The title caption of the form. A form title is usually rendered as separate + * headline element before the actual form contents. If omitted, the + * corresponding headline element will not be rendered. + * + * @param {string} [description] + * The description text of the form which is usually rendered as text + * paragraph below the form title and before the actual form conents. + * If omitted, the corresponding paragraph element will not be rendered. + */ +var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { + __init__: function(config /*, ... */) { + this.super('__init__', this.varargs(arguments, 1)); + + this.config = config; + this.parsechain = [ config ]; + this.data = uci; + }, + + /** + * Toggle readonly state of the form. + * + * If set to `true`, the Map instance is marked readonly and any form + * option elements added to it will inherit the readonly state. + * + * If left unset, the Map will test the access permission of the primary + * uci configuration upon loading and mark the form readonly if no write + * permissions are granted. + * + * @name LuCI.form.Map.prototype#readonly + * @type boolean + */ + + /** + * Find all DOM nodes within this Map which match the given search + * parameters. This function is essentially a convenience wrapper around + * `querySelectorAll()`. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, it is used as selector-expression + * as-is. When two arguments are passed, the first argument is treated + * as attribute name, the second one as attribute value to match. + * + * As an example, `map.findElements('input')` would find all `` + * nodes while `map.findElements('type', 'text')` would find any DOM node + * with a `type="text"` attribute. + * + * @param {string} selector_or_attrname + * If invoked with only one parameter, this argument is a + * `querySelectorAll()` compatible selector expression. If invoked with + * two parameters, this argument is the attribute name to filter for. + * + * @param {string} [attrvalue] + * In case the function is invoked with two parameters, this argument + * specifies the attribute value to match. + * + * @throws {InternalError} + * Throws an `InternalError` if more than two function parameters are + * passed. + * + * @returns {NodeList} + * Returns a (possibly empty) DOM `NodeList` containing the found DOM nodes. + */ + 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); + }, + + /** + * Find the first DOM node within this Map which matches the given search + * parameters. This function is essentially a convenience wrapper around + * `findElements()` which only returns the first found node. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, it is used as selector-expression + * as-is. When two arguments are passed, the first argument is treated + * as attribute name, the second one as attribute value to match. + * + * As an example, `map.findElement('input')` would find the first `` + * node while `map.findElement('type', 'text')` would find the first DOM + * node with a `type="text"` attribute. + * + * @param {string} selector_or_attrname + * If invoked with only one parameter, this argument is a `querySelector()` + * compatible selector expression. If invoked with two parameters, this + * argument is the attribute name to filter for. + * + * @param {string} [attrvalue] + * In case the function is invoked with two parameters, this argument + * specifies the attribute value to match. + * + * @throws {InternalError} + * Throws an `InternalError` if more than two function parameters are + * passed. + * + * @returns {Node|null} + * Returns the first found DOM node or `null` if no element matched. + */ + findElement: function(/* ... */) { + var res = this.findElements.apply(this, arguments); + return res.length ? res[0] : null; + }, + + /** + * Tie another UCI configuration to the map. + * + * By default, a map instance will only load the UCI configuration file + * specified in the constructor but sometimes access to values from + * further configuration files is required. This function allows for such + * use cases by registering further UCI configuration files which are + * needed by the map. + * + * @param {string} config + * The additional UCI configuration file to tie to the map. If the given + * config already is in the list of required files, it will be ignored. + */ + chain: function(config) { + if (this.parsechain.indexOf(config) == -1) + this.parsechain.push(config); + }, + + /** + * Add a configuration section to the map. + * + * LuCI forms follow the structure of the underlying UCI configurations, + * means that a map, which represents a single UCI configuration, is + * divided into multiple sections which in turn contain an arbitrary + * number of options. + * + * While UCI itself only knows two kinds of sections - named and anonymous + * ones - the form class offers various flavors of form section elements + * to present configuration sections in different ways. Refer to the + * documentation of the different section classes for details. + * + * @param {LuCI.form.AbstractSection} sectionclass + * The section class to use for rendering the configuration section. + * Note that this value must be the class itself, not a class instance + * obtained from calling `new`. It must also be a class dervied from + * `LuCI.form.AbstractSection`. + * + * @param {...string} classargs + * Additional arguments which are passed as-is to the contructor of the + * given section class. Refer to the class specific constructor + * documentation for details. + * + * @returns {LuCI.form.AbstractSection} + * Returns the instantiated section class instance. + */ + 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 the configuration covered by this map. + * + * The `load()` function first loads all referenced UCI configurations, + * then it recursively walks the form element tree and invokes the + * load function of each child element. + * + * @returns {Promise} + * Returns a promise resolving once the entire form completed loading all + * data. The promise may reject with an error if any configuration failed + * to load or if any of the child elements load functions rejected with + * an error. + */ + load: function() { + var doCheckACL = (!(this instanceof CBIJSONMap) && this.readonly == null), + loadTasks = [ doCheckACL ? callSessionAccess('uci', this.config, 'write') : true ], + configs = this.parsechain || [ this.config ]; + + loadTasks.push.apply(loadTasks, configs.map(L.bind(function(config, i) { + return i ? L.resolveDefault(this.data.load(config)) : this.data.load(config); + }, this))); + + return Promise.all(loadTasks).then(L.bind(function(res) { + if (res[0] === false) + this.readonly = true; + + return this.loadChildren(); + }, this)); + }, + + /** + * Parse the form input values. + * + * The `parse()` function recursively walks the form element tree and + * triggers input value reading and validation for each child element. + * + * Elements which are hidden due to unsatisified dependencies are skipped. + * + * @returns {Promise} + * Returns a promise resolving once the entire form completed parsing all + * input values. The returned promise is rejected if any parsed values are + * not meeting the validation constraints of their respective elements. + */ + 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 the form input values. + * + * This function parses the current form, saves the resulting UCI changes, + * reloads the UCI configuration data and redraws the form elements. + * + * @param {function} [cb] + * An optional callback function that is invoked after the form is parsed + * but before the changed UCI data is saved. This is useful to perform + * additional data manipulation steps before saving the changes. + * + * @param {boolean} [silent=false] + * If set to `true`, trigger an alert message to the user in case saving + * the form data failes. Otherwise fail silently. + * + * @returns {Promise} + * Returns a promise resolving once the entire save operation is complete. + * The returned promise is rejected if any step of the save operation + * failed. + */ + save: function(cb, silent) { + this.checkDepends(); + + return this.parse() + .then(cb) + .then(this.data.save.bind(this.data)) + .then(this.load.bind(this)) + .catch(function(e) { + if (!silent) { + ui.showModal(_('Save error'), [ + E('p', {}, [ _('An error occurred while saving the form:') ]), + E('p', {}, [ E('em', { 'style': 'white-space:pre-wrap' }, [ e.message ]) ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Dismiss') ]) + ]) + ]); + } + + return Promise.reject(e); + }).then(this.renderContents.bind(this)); + }, + + /** + * Reset the form by re-rendering its contents. This will revert all + * unsaved user inputs to their initial form state. + * + * @returns {Promise} + * Returns a promise resolving to the toplevel form DOM node once the + * re-rendering is complete. + */ + reset: function() { + return this.renderContents(); + }, + + /** + * Render the form markup. + * + * @returns {Promise} + * Returns a promise resolving to the toplevel form DOM node once the + * rendering is complete. + */ + render: function() { + return this.load().then(this.renderContents.bind(this)); + }, + + /** @private */ + 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) + })); + + dom.bindClassInstance(mapEl, this); + + return this.renderChildren(null).then(L.bind(function(nodes) { + var initialRender = !mapEl.firstChild; + + 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)); + + if (this.tabbed) + dom.append(mapEl, E('div', { 'class': 'cbi-map-tabbed' }, nodes)); + else + dom.append(mapEl, nodes); + + if (!initialRender) { + mapEl.classList.remove('flash'); + + window.setTimeout(function() { + mapEl.classList.add('flash'); + }, 1); + } + + this.checkDepends(); + + var tabGroups = mapEl.querySelectorAll('.cbi-map-tabbed, .cbi-section-node-tabbed'); + + for (var i = 0; i < tabGroups.length; i++) + ui.tabs.initTabGroup(tabGroups[i].childNodes); + + return mapEl; + }, this)); + }, + + /** + * Find a form option element instance. + * + * @param {string} name_or_id + * The name or the full ID of the option element to look up. + * + * @param {string} [section_id] + * The ID of the UCI section containing the option to look up. May be + * omitted if a full ID is passed as first argument. + * + * @param {string} [config] + * The name of the UCI configuration the option instance is belonging to. + * Defaults to the main UCI configuration of the map if omitted. + * + * @returns {Array|null} + * Returns a two-element array containing the form option instance as + * first item and the corresponding UCI section ID as second item. + * Returns `null` if the option could not be found. + */ + lookupOption: function(name, section_id, config_name) { + var id, elem, sid, inst; + + if (name.indexOf('.') > -1) + id = 'cbid.%s'.format(name); + else + id = 'cbid.%s.%s.%s'.format(config_name || this.config, section_id, name); + + elem = this.findElement('data-field', id); + sid = elem ? id.split(/\./)[2] : null; + inst = elem ? dom.findClassInstance(elem) : null; + + return (inst instanceof CBIAbstractValue) ? [ inst, sid ] : null; + }, + + /** @private */ + 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); + + ui.tabs.updateTabs(ev, this.root); + }, + + /** @private */ + isDependencySatisfied: function(depends, config_name, section_id) { + var def = false; + + if (!Array.isArray(depends) || !depends.length) + return true; + + for (var i = 0; i < depends.length; i++) { + var istat = true, + reverse = depends[i]['!reverse'], + contains = depends[i]['!contains']; + + for (var dep in depends[i]) { + if (dep == '!reverse' || dep == '!contains') { + continue; + } + else if (dep == '!default') { + def = true; + istat = false; + } + else { + var res = this.lookupOption(dep, section_id, config_name), + val = (res && res[0].isActive(res[1])) ? res[0].formvalue(res[1]) : null; + + var equal = contains + ? isContained(val, depends[i][dep]) + : isEqual(val, depends[i][dep]); + + istat = (istat && equal); + } + } + + if (istat ^ reverse) + return true; + } + + return def; + } +}); + +/** + * @constructor JSONMap + * @memberof LuCI.form + * @augments LuCI.form.Map + * + * @classdesc + * + * A `JSONMap` class functions similar to [LuCI.form.Map]{@link LuCI.form.Map} + * but uses a multidimensional JavaScript object instead of UCI configuration + * as data source. + * + * @param {Object|Array>>} data + * The JavaScript object to use as data source. Internally, the object is + * converted into an UCI-like format. Its toplevel keys are treated like UCI + * section types while the object or array-of-object values are treated as + * section contents. + * + * @param {string} [title] + * The title caption of the form. A form title is usually rendered as separate + * headline element before the actual form contents. If omitted, the + * corresponding headline element will not be rendered. + * + * @param {string} [description] + * The description text of the form which is usually rendered as text + * paragraph below the form title and before the actual form conents. + * If omitted, the corresponding paragraph element will not be rendered. + */ +var CBIJSONMap = CBIMap.extend(/** @lends LuCI.form.JSONMap.prototype */ { + __init__: function(data /*, ... */) { + this.super('__init__', this.varargs(arguments, 1, 'json')); + + this.config = 'json'; + this.parsechain = [ 'json' ]; + this.data = new CBIJSONConfig(data); + } +}); + +/** + * @class AbstractSection + * @memberof LuCI.form + * @augments LuCI.form.AbstractElement + * @hideconstructor + * @classdesc + * + * The `AbstractSection` class serves as abstract base for the different form + * section styles implemented by `LuCI.form`. It provides the common logic for + * enumerating underlying configuration section instances, for registering + * form options and for handling tabs to segment child options. + * + * This class is private and not directly accessible by user code. + */ +var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractSection.prototype */ { + __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; + }, + + /** + * Access the parent option container instance. + * + * In case this section is nested within an option element container, + * this property will hold a reference to the parent option instance. + * + * If this section is not nested, the property is `null`. + * + * @name LuCI.form.AbstractSection.prototype#parentoption + * @type LuCI.form.AbstractValue + * @readonly + */ + + /** + * Enumerate the UCI section IDs covered by this form section element. + * + * @abstract + * @throws {InternalError} + * Throws an `InternalError` exception if the function is not implemented. + * + * @returns {string[]} + * Returns an array of UCI section IDs covered by this form element. + * The sections will be rendered in the same order as the returned array. + */ + cfgsections: function() { + L.error('InternalError', 'Not implemented'); + }, + + /** + * Filter UCI section IDs to render. + * + * The filter function is invoked for each UCI section ID of a given type + * and controls whether the given UCI section is rendered or ignored by + * the form section element. + * + * The default implementation always returns `true`. User code or + * classes extending `AbstractSection` may overwrite this function with + * custom implementations. + * + * @abstract + * @param {string} section_id + * The UCI section ID to test. + * + * @returns {boolean} + * Returns `true` when the given UCI section ID should be handled and + * `false` when it should be ignored. + */ + filter: function(section_id) { + return true; + }, + + /** + * Load the configuration covered by this section. + * + * The `load()` function recursively walks the section element tree and + * invokes the load function of each child option element. + * + * @returns {Promise} + * Returns a promise resolving once the values of all child elements have + * been loaded. The promise may reject with an error if any of the child + * elements load functions rejected with an error. + */ + 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 this sections form input. + * + * The `parse()` function recursively walks the section element tree and + * triggers input value reading and validation for each encountered child + * option element. + * + * Options which are hidden due to unsatisified dependencies are skipped. + * + * @returns {Promise} + * Returns a promise resolving once the values of all child elements have + * been parsed. The returned promise is rejected if any parsed values are + * not meeting the validation constraints of their respective elements. + */ + 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); + }, + + /** + * Add an option tab to the section. + * + * The child option elements of a section may be divided into multiple + * tabs to provide a better overview to the user. + * + * Before options can be moved into a tab pane, the corresponding tab + * has to be defined first, which is done by calling this function. + * + * Note that once tabs are defined, user code must use the `taboption()` + * method to add options to specific tabs. Option elements added by + * `option()` will not be assigned to any tab and not be rendered in this + * case. + * + * @param {string} name + * The name of the tab to register. It may be freely chosen and just serves + * as an identifier to differentiate tabs. + * + * @param {string} title + * The human readable caption of the tab. + * + * @param {string} [description] + * An additional description text for the corresponding tab pane. It is + * displayed as text paragraph below the tab but before the tab pane + * contents. If omitted, no description will be rendered. + * + * @throws {Error} + * Throws an exeption if a tab with the same `name` already exists. + */ + 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); + }, + + /** + * Add a configuration option widget to the section. + * + * Note that [taboption()]{@link LuCI.form.AbstractSection#taboption} + * should be used instead if this form section element uses tabs. + * + * @param {LuCI.form.AbstractValue} optionclass + * The option class to use for rendering the configuration option. Note + * that this value must be the class itself, not a class instance obtained + * from calling `new`. It must also be a class dervied from + * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}. + * + * @param {...*} classargs + * Additional arguments which are passed as-is to the contructor of the + * given option class. Refer to the class specific constructor + * documentation for details. + * + * @throws {TypeError} + * Throws a `TypeError` exception in case the passed class value is not a + * descendent of `AbstractValue`. + * + * @returns {LuCI.form.AbstractValue} + * Returns the instantiated option class instance. + */ + 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; + }, + + /** + * Add a configuration option widget to a tab of the section. + * + * @param {string} tabname + * The name of the section tab to add the option element to. + * + * @param {LuCI.form.AbstractValue} optionclass + * The option class to use for rendering the configuration option. Note + * that this value must be the class itself, not a class instance obtained + * from calling `new`. It must also be a class dervied from + * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}. + * + * @param {...*} classargs + * Additional arguments which are passed as-is to the contructor of the + * given option class. Refer to the class specific constructor + * documentation for details. + * + * @throws {ReferenceError} + * Throws a `ReferenceError` exception when the given tab name does not + * exist. + * + * @throws {TypeError} + * Throws a `TypeError` exception in case the passed class value is not a + * descendent of `AbstractValue`. + * + * @returns {LuCI.form.AbstractValue} + * Returns the instantiated option class instance. + */ + 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; + }, + + /** + * Query underlying option configuration values. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, the configuration values of all + * options within this section are returned as dictionary. + * + * If both the section ID and an option name are supplied, this function + * returns the configuration value of the specified option only. + * + * @param {string} section_id + * The configuration section ID + * + * @param {string} [option] + * The name of the option to query + * + * @returns {null|string|string[]|Object} + * Returns either a dictionary of option names and their corresponding + * configuration values or just a single configuration value, depending + * on the amount of passed arguments. + */ + cfgvalue: function(section_id, option) { + var rv = (arguments.length == 1) ? {} : null; + + for (var i = 0, o; (o = this.children[i]) != null; i++) + if (rv) + rv[o.option] = o.cfgvalue(section_id); + else if (o.option == option) + return o.cfgvalue(section_id); + + return rv; + }, + + /** + * Query underlying option widget input values. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, the widget input values of all + * options within this section are returned as dictionary. + * + * If both the section ID and an option name are supplied, this function + * returns the widget input value of the specified option only. + * + * @param {string} section_id + * The configuration section ID + * + * @param {string} [option] + * The name of the option to query + * + * @returns {null|string|string[]|Object} + * Returns either a dictionary of option names and their corresponding + * widget input values or just a single widget input value, depending + * on the amount of passed arguments. + */ + formvalue: function(section_id, option) { + var rv = (arguments.length == 1) ? {} : null; + + for (var i = 0, o; (o = this.children[i]) != null; i++) { + var func = this.map.root ? this.children[i].formvalue : this.children[i].cfgvalue; + + if (rv) + rv[o.option] = func.call(o, section_id); + else if (o.option == option) + return func.call(o, section_id); + } + + return rv; + }, + + /** + * Obtain underlying option LuCI.ui widget instances. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, the LuCI.ui widget instances of all + * options within this section are returned as dictionary. + * + * If both the section ID and an option name are supplied, this function + * returns the LuCI.ui widget instance value of the specified option only. + * + * @param {string} section_id + * The configuration section ID + * + * @param {string} [option] + * The name of the option to query + * + * @returns {null|LuCI.ui.AbstractElement|Object} + * Returns either a dictionary of option names and their corresponding + * widget input values or just a single widget input value, depending + * on the amount of passed arguments. + */ + getUIElement: function(section_id, option) { + var rv = (arguments.length == 1) ? {} : null; + + for (var i = 0, o; (o = this.children[i]) != null; i++) + if (rv) + rv[o.option] = o.getUIElement(section_id); + else if (o.option == option) + return o.getUIElement(section_id); + + return rv; + }, + + /** + * Obtain underlying option objects. + * + * This function is sensitive to the amount of arguments passed to it; + * if no option name is specified, all options within this section are + * returned as dictionary. + * + * If an option name is supplied, this function returns the matching + * LuCI.form.AbstractValue instance only. + * + * @param {string} [option] + * The name of the option object to obtain + * + * @returns {null|LuCI.form.AbstractValue|Object} + * Returns either a dictionary of option names and their corresponding + * option instance objects or just a single object instance value, + * depending on the amount of passed arguments. + */ + getOption: function(option) { + var rv = (arguments.length == 0) ? {} : null; + + for (var i = 0, o; (o = this.children[i]) != null; i++) + if (rv) + rv[o.option] = o; + else if (o.option == option) + return o; + + return rv; + }, + + /** @private */ + 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)); + }, + + /** @private */ + 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; + }, + + /** @private */ + 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; + }); + }, + + /** @private */ + 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); + isActive = !isActive; + changed = true; + } + + if (!n && isActive) + o.triggerValidation(sid); + } + } + + return changed; + } +}); + + +var isEqual = function(x, y) { + if (typeof(y) == 'object' && y instanceof RegExp) + return (x == null) ? false : y.test(x); + + 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 isContained = function(x, y) { + if (Array.isArray(x)) { + for (var i = 0; i < x.length; i++) + if (x[i] == y) + return true; + } + else if (L.isObject(x)) { + if (x.hasOwnProperty(y) && x[y] != null) + return true; + } + else if (typeof(x) == 'string') { + return (x.indexOf(y) > -1); + } + + return false; +}; + +/** + * @class AbstractValue + * @memberof LuCI.form + * @augments LuCI.form.AbstractElement + * @hideconstructor + * @classdesc + * + * The `AbstractValue` class serves as abstract base for the different form + * option styles implemented by `LuCI.form`. It provides the common logic for + * handling option input values, for dependencies among options and for + * validation constraints that should be applied to entered values. + * + * This class is private and not directly accessible by user code. + */ +var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractValue.prototype */ { + __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; + this.retain = false; + }, + + /** + * If set to `false`, the underlying option value is retained upon saving + * the form when the option element is disabled due to unsatisfied + * dependency constraints. + * + * @name LuCI.form.AbstractValue.prototype#rmempty + * @type boolean + * @default true + */ + + /** + * If set to `true`, the underlying ui input widget is allowed to be empty, + * otherwise the option element is marked invalid when no value is entered + * or selected by the user. + * + * @name LuCI.form.AbstractValue.prototype#optional + * @type boolean + * @default false + */ + + /** + * If set to `true`, the underlying ui input widget value is not cleared + * from the configuration on unsatisfied depedencies. The default behavior + * is to remove the values of all options whose dependencies are not + * fulfilled. + * + * @name LuCI.form.AbstractValue.prototype#retain + * @type boolean + * @default false + */ + + /** + * Sets a default value to use when the underlying UCI option is not set. + * + * @name LuCI.form.AbstractValue.prototype#default + * @type * + * @default null + */ + + /** + * Specifies a datatype constraint expression to validate input values + * against. Refer to {@link LuCI.validation} for details on the format. + * + * If the user entered input does not match the datatype validation, the + * option element is marked as invalid. + * + * @name LuCI.form.AbstractValue.prototype#datatype + * @type string + * @default null + */ + + /** + * Specifies a custom validation function to test the user input for + * validity. The validation function must return `true` to accept the + * value. Any other return value type is converted to a string and + * displayed to the user as validation error message. + * + * If the user entered input does not pass the validation function, the + * option element is marked as invalid. + * + * @name LuCI.form.AbstractValue.prototype#validate + * @type function + * @default null + */ + + /** + * Override the UCI configuration name to read the option value from. + * + * By default, the configuration name is inherited from the parent Map. + * By setting this property, a deviating configuration may be specified. + * + * The default is null, means inheriting from the parent form. + * + * @name LuCI.form.AbstractValue.prototype#uciconfig + * @type string + * @default null + */ + + /** + * Override the UCI section name to read the option value from. + * + * By default, the section ID is inherited from the parent section element. + * By setting this property, a deviating section may be specified. + * + * The default is null, means inheriting from the parent section. + * + * @name LuCI.form.AbstractValue.prototype#ucisection + * @type string + * @default null + */ + + /** + * Override the UCI option name to read the value from. + * + * By default, the elements name, which is passed as third argument to + * the constructor, is used as UCI option name. By setting this property, + * a deviating UCI option may be specified. + * + * The default is null, means using the option element name. + * + * @name LuCI.form.AbstractValue.prototype#ucioption + * @type string + * @default null + */ + + /** + * Mark grid section option element as editable. + * + * Options which are displayed in the table portion of a `GridSection` + * instance are rendered as readonly text by default. By setting the + * `editable` property of a child option element to `true`, that element + * is rendered as full input widget within its cell instead of a text only + * preview. + * + * This property has no effect on options that are not children of grid + * section elements. + * + * @name LuCI.form.AbstractValue.prototype#editable + * @type boolean + * @default false + */ + + /** + * Move grid section option element into the table, the modal popup or both. + * + * If this property is `null` (the default), the option element is + * displayed in both the table preview area and the per-section instance + * modal popup of a grid section. When it is set to `false` the option + * is only shown in the table but not the modal popup. When set to `true`, + * the option is only visible in the modal popup but not the table. + * + * This property has no effect on options that are not children of grid + * section elements. + * + * @name LuCI.form.AbstractValue.prototype#modalonly + * @type boolean + * @default null + */ + + /** + * Make option element readonly. + * + * This property defaults to the readonly state of the parent form element. + * When set to `true`, the underlying widget is rendered in disabled state, + * means its contents cannot be changed and the widget cannot be interacted + * with. + * + * @name LuCI.form.AbstractValue.prototype#readonly + * @type boolean + * @default false + */ + + /** + * Override the cell width of a table or grid section child option. + * + * If the property is set to a numeric value, it is treated as pixel width + * which is set on the containing cell element of the option, essentially + * forcing a certain column width. When the property is set to a string + * value, it is applied as-is to the CSS `width` property. + * + * This property has no effect on options that are not children of grid or + * table section elements. + * + * @name LuCI.form.AbstractValue.prototype#width + * @type number|string + * @default null + */ + + /** + * Register a custom value change handler. + * + * If this property is set to a function value, the function is invoked + * whenever the value of the underlying UI input element is changing. + * + * The invoked handler function will receive the DOM click element as + * first and the underlying configuration section ID as well as the input + * value as second and third argument respectively. + * + * @name LuCI.form.AbstractValue.prototype#onchange + * @type function + * @default null + */ + + /** + * Add a dependency contraint to the option. + * + * Dependency constraints allow making the presence of option elements + * dependant on the current values of certain other options within the + * same form. An option element with unsatisfied dependencies will be + * hidden from the view and its current value is omitted when saving. + * + * Multiple constraints (that is, multiple calls to `depends()`) are + * treated as alternatives, forming a logical "or" expression. + * + * By passing an object of name => value pairs as first argument, it is + * possible to depend on multiple options simultaneously, allowing to form + * a logical "and" expression. + * + * Option names may be given in "dot notation" which allows to reference + * option elements outside of the current form section. If a name without + * dot is specified, it refers to an option within the same configuration + * section. If specified as configname.sectionid.optionname, + * options anywhere within the same form may be specified. + * + * The object notation also allows for a number of special keys which are + * not treated as option names but as modifiers to influence the dependency + * constraint evaluation. The associated value of these special "tag" keys + * is ignored. The recognized tags are: + * + *
    + *
  • + * !reverse
    + * Invert the dependency, instead of requiring another option to be + * equal to the dependency value, that option should not be + * equal. + *
  • + *
  • + * !contains
    + * Instead of requiring an exact match, the dependency is considered + * satisfied when the dependency value is contained within the option + * value. + *
  • + *
  • + * !default
    + * The dependency is always satisfied + *
  • + *
+ * + * Examples: + * + *
    + *
  • + * opt.depends("foo", "test")
    + * Require the value of `foo` to be `test`. + *
  • + *
  • + * opt.depends({ foo: "test" })
    + * Equivalent to the previous example. + *
  • + *
  • + * opt.depends({ foo: /test/ })
    + * Require the value of `foo` to match the regular expression `/test/`. + *
  • + *
  • + * opt.depends({ foo: "test", bar: "qrx" })
    + * Require the value of `foo` to be `test` and the value of `bar` to be + * `qrx`. + *
  • + *
  • + * opt.depends({ foo: "test" })
    + * opt.depends({ bar: "qrx" })

    + * Require either foo to be set to test, + * or the bar option to be qrx. + *
  • + *
  • + * opt.depends("test.section1.foo", "bar")
    + * Require the "foo" form option within the "section1" section to be + * set to "bar". + *
  • + *
  • + * opt.depends({ foo: "test", "!contains": true })
    + * Require the "foo" option value to contain the substring "test". + *
  • + *
+ * + * @param {string|Object} optionname_or_depends + * The name of the option to depend on or an object describing multiple + * dependencies which must be satified (a logical "and" expression). + * + * @param {string} optionvalue|RegExp + * When invoked with a plain option name as first argument, this parameter + * specifies the expected value. In case an object is passed as first + * argument, this parameter is ignored. + */ + depends: function(field, value) { + var deps; + + if (typeof(field) === 'string') + deps = {}, deps[field] = value; + else + deps = field; + + this.deps.push(deps); + }, + + /** @private */ + 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.uciconfig || this.section.uciconfig || this.map.config, + this.ucisection || section_id, + k + )] = list[i][k]; + } + } + + for (var k in dep) { + if (dep.hasOwnProperty(k)) { + deps.push(dep); + break; + } + } + } + } + + return deps; + }, + + /** @private */ + 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; + }, + + /** @private */ + checkDepends: function(section_id) { + var config_name = this.uciconfig || this.section.uciconfig || this.map.config, + active = this.map.isDependencySatisfied(this.deps, config_name, section_id); + + if (active) + this.updateDefaultValue(section_id); + + return active; + }, + + /** @private */ + updateDefaultValue: function(section_id) { + if (!L.isObject(this.defaults)) + return; + + var config_name = this.uciconfig || this.section.uciconfig || this.map.config, + cfgvalue = L.toArray(this.cfgvalue(section_id))[0], + default_defval = null, satisified_defval = null; + + for (var value in this.defaults) { + if (!this.defaults[value] || this.defaults[value].length == 0) { + default_defval = value; + continue; + } + else if (this.map.isDependencySatisfied(this.defaults[value], config_name, section_id)) { + satisified_defval = value; + break; + } + } + + if (satisified_defval == null) + satisified_defval = default_defval; + + var node = this.map.findElement('id', this.cbid(section_id)); + if (node && node.getAttribute('data-changed') != 'true' && satisified_defval != null && cfgvalue == null) + dom.callClassMethod(node, 'setValue', satisified_defval); + + this.default = satisified_defval; + }, + + /** + * Obtain the internal ID ("cbid") of the element instance. + * + * Since each form section element may map multiple underlying + * configuration sections, the configuration section ID is required to + * form a fully qualified ID pointing to the specific element instance + * within the given specific section. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {string} + * Returns the element ID. + */ + cbid: function(section_id) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + return 'cbid.%s.%s.%s'.format( + this.uciconfig || this.section.uciconfig || this.map.config, + section_id, this.option); + }, + + /** + * Load the underlying configuration value. + * + * The default implementation of this method reads and returns the + * underlying UCI option value (or the related JavaScript property for + * `JSONMap` instances). It may be overwritten by user code to load data + * from nonstandard sources. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {*|Promise<*>} + * Returns the configuration value to initialize the option element with. + * The return value of this function is filtered through `Promise.resolve()` + * so it may return promises if overridden by user code. + */ + load: function(section_id) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + return this.map.data.get( + this.uciconfig || this.section.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option); + }, + + /** + * Obtain the underlying `LuCI.ui` element instance. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @return {LuCI.ui.AbstractElement|null} + * Returns the `LuCI.ui` element instance or `null` in case the form + * option implementation does not use `LuCI.ui` widgets. + */ + getUIElement: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)), + inst = node ? dom.findClassInstance(node) : null; + return (inst instanceof ui.AbstractElement) ? inst : null; + }, + + /** + * Query the underlying configuration value. + * + * The default implementation of this method returns the cached return + * value of [load()]{@link LuCI.form.AbstractValue#load}. It may be + * overwritten by user code to obtain the configuration value in a + * different way. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {*} + * Returns the configuration value. + */ + 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; + }, + + /** + * Query the current form input value. + * + * The default implementation of this method returns the current input + * value of the underlying [LuCI.ui]{@link LuCI.ui.AbstractElement} widget. + * It may be overwritten by user code to handle input values differently. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {*} + * Returns the current input value. + */ + formvalue: function(section_id) { + var elem = this.getUIElement(section_id); + return elem ? elem.getValue() : null; + }, + + /** + * Obtain a textual input representation. + * + * The default implementation of this method returns the HTML escaped + * current input value of the underlying + * [LuCI.ui]{@link LuCI.ui.AbstractElement} widget. User code or specific + * option element implementations may overwrite this function to apply a + * different logic, e.g. to return `Yes` or `No` depending on the checked + * state of checkbox elements. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {string} + * Returns the text representation of the current input value. + */ + textvalue: function(section_id) { + var cval = this.cfgvalue(section_id); + + if (cval == null) + cval = this.default; + + if (Array.isArray(cval)) + cval = cval.join(' '); + + return (cval != null) ? '%h'.format(cval) : null; + }, + + /** + * Apply custom validation logic. + * + * This method is invoked whenever incremental validation is performed on + * the user input, e.g. on keyup or blur events. + * + * The default implementation of this method does nothing and always + * returns `true`. User code may overwrite this method to provide + * additional validation logic which is not covered by data type + * constraints. + * + * @abstract + * @param {string} section_id + * The configuration section ID + * + * @param {*} value + * The value to validate + * + * @returns {*} + * The method shall return `true` to accept the given value. Any other + * return value is treated as failure, converted to a string and displayed + * as error message to the user. + */ + validate: function(section_id, value) { + return true; + }, + + /** + * Test whether the input value is currently valid. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {boolean} + * Returns `true` if the input value currently is valid, otherwise it + * returns `false`. + */ + isValid: function(section_id) { + var elem = this.getUIElement(section_id); + return elem ? elem.isValid() : true; + }, + + /** + * Returns the current validation error for this input. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {string} + * The validation error at this time + */ + getValidationError: function (section_id) { + var elem = this.getUIElement(section_id); + return elem ? elem.getValidationError() : ''; + }, + + /** + * Test whether the option element is currently active. + * + * An element is active when it is not hidden due to unsatisfied dependency + * constraints. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {boolean} + * Returns `true` if the option element currently is active, otherwise it + * returns `false`. + */ + isActive: function(section_id) { + var field = this.map.findElement('data-field', this.cbid(section_id)); + return (field != null && !field.classList.contains('hidden')); + }, + + /** @private */ + 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'); + + if (dom.matches(field.parentNode, '.td.cbi-value-field')) + field.parentNode.classList[active ? 'remove' : 'add']('inactive'); + + return true; + } + + return false; + }, + + /** @private */ + triggerValidation: function(section_id) { + var elem = this.getUIElement(section_id); + return elem ? elem.triggerValidation() : true; + }, + + /** + * Parse the option element input. + * + * The function is invoked when the `parse()` method has been invoked on + * the parent form and triggers input value reading and validation. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {Promise} + * Returns a promise resolving once the input value has been read and + * validated or rejecting in case the input value does not meet the + * validation constraints. + */ + parse: function(section_id) { + var active = this.isActive(section_id); + + if (active && !this.isValid(section_id)) { + var title = this.stripTags(this.title).trim(), + error = this.getValidationError(section_id); + + return Promise.reject(new TypeError( + _('Option "%s" contains an invalid input value.').format(title || this.option) + ' ' + error)); + } + + if (active) { + var cval = this.cfgvalue(section_id), + fval = this.formvalue(section_id); + + if (fval == null || fval == '') { + if (this.rmempty || this.optional) { + return Promise.resolve(this.remove(section_id)); + } + else { + var title = this.stripTags(this.title).trim(); + + return Promise.reject(new TypeError( + _('Option "%s" must not be empty.').format(title || this.option))); + } + } + else if (this.forcewrite || !isEqual(cval, fval)) { + return Promise.resolve(this.write(section_id, fval)); + } + } + else if (!this.retain) { + return Promise.resolve(this.remove(section_id)); + } + + return Promise.resolve(); + }, + + /** + * Write the current input value into the configuration. + * + * This function is invoked upon saving the parent form when the option + * element is valid and when its input value has been changed compared to + * the initial value returned by + * [cfgvalue()]{@link LuCI.form.AbstractValue#cfgvalue}. + * + * The default implementation simply sets the given input value in the + * UCI configuration (or the associated JavaScript object property in + * case of `JSONMap` forms). It may be overwritten by user code to + * implement alternative save logic, e.g. to transform the input value + * before it is written. + * + * @param {string} section_id + * The configuration section ID + * + * @param {string|string[]} formvalue + * The input value to write. + */ + write: function(section_id, formvalue) { + return this.map.data.set( + this.uciconfig || this.section.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option, + formvalue); + }, + + /** + * Remove the corresponding value from the configuration. + * + * This function is invoked upon saving the parent form when the option + * element has been hidden due to unsatisfied dependencies or when the + * user cleared the input value and the option is marked optional. + * + * The default implementation simply removes the associated option from the + * UCI configuration (or the associated JavaScript object property in + * case of `JSONMap` forms). It may be overwritten by user code to + * implement alternative removal logic, e.g. to retain the original value. + * + * @param {string} section_id + * The configuration section ID + */ + remove: function(section_id) { + var this_cfg = this.uciconfig || this.section.uciconfig || this.map.config, + this_sid = this.ucisection || section_id, + this_opt = this.ucioption || this.option; + + for (var i = 0; i < this.section.children.length; i++) { + var sibling = this.section.children[i]; + + if (sibling === this || sibling.ucioption == null) + continue; + + var sibling_cfg = sibling.uciconfig || sibling.section.uciconfig || sibling.map.config, + sibling_sid = sibling.ucisection || section_id, + sibling_opt = sibling.ucioption || sibling.option; + + if (this_cfg != sibling_cfg || this_sid != sibling_sid || this_opt != sibling_opt) + continue; + + if (!sibling.isActive(section_id)) + continue; + + /* found another active option aliasing the same uci option name, + * so we can't remove the value */ + return; + } + + this.map.data.unset(this_cfg, this_sid, this_opt); + } +}); + +/** + * @class TypedSection + * @memberof LuCI.form + * @augments LuCI.form.AbstractSection + * @hideconstructor + * @classdesc + * + * The `TypedSection` class maps all or - if `filter()` is overwritten - a + * subset of the underlying UCI configuration sections of a given type. + * + * Layout wise, the configuration section instances mapped by the section + * element (sometimes referred to as "section nodes") are stacked beneath + * each other in a single column, with an optional section remove button next + * to each section node and a section add button at the end, depending on the + * value of the `addremove` property. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSection.prototype */ { + __name__: 'CBI.TypedSection', + + /** + * If set to `true`, the user may add or remove instances from the form + * section widget, otherwise only preexisting sections may be edited. + * The default is `false`. + * + * @name LuCI.form.TypedSection.prototype#addremove + * @type boolean + * @default false + */ + + /** + * If set to `true`, mapped section instances are treated as anonymous + * UCI sections, which means that section instance elements will be + * rendered without title element and that no name is required when adding + * new sections. The default is `false`. + * + * @name LuCI.form.TypedSection.prototype#anonymous + * @type boolean + * @default false + */ + + /** + * When set to `true`, instead of rendering section instances one below + * another, treat each instance as separate tab pane and render a tab menu + * at the top of the form section element, allowing the user to switch + * among instances. The default is `false`. + * + * @name LuCI.form.TypedSection.prototype#tabbed + * @type boolean + * @default false + */ + + /** + * Override the caption used for the section add button at the bottom of + * the section form element. If set to a string, it will be used as-is, + * if set to a function, the function will be invoked and its return value + * is used as caption, after converting it to a string. If this property + * is not set, the default is `Add`. + * + * @name LuCI.form.TypedSection.prototype#addbtntitle + * @type string|function + * @default null + */ + + /** + * Override the UCI configuration name to read the section IDs from. By + * default, the configuration name is inherited from the parent `Map`. + * By setting this property, a deviating configuration may be specified. + * The default is `null`, means inheriting from the parent form. + * + * @name LuCI.form.TypedSection.prototype#uciconfig + * @type string + * @default null + */ + + /** @override */ + cfgsections: function() { + return this.map.data.sections(this.uciconfig || this.map.config, this.sectiontype) + .map(function(s) { return s['.name'] }) + .filter(L.bind(this.filter, this)); + }, + + /** @private */ + handleAdd: function(ev, name) { + var config_name = this.uciconfig || this.map.config; + + this.map.data.add(config_name, this.sectiontype, name); + return this.map.save(null, true); + }, + + /** @private */ + handleRemove: function(section_id, ev) { + var config_name = this.uciconfig || this.map.config; + + this.map.data.remove(config_name, section_id); + return this.map.save(null, true); + }, + + /** @private */ + renderSectionAdd: function(extra_class) { + if (!this.addremove) + return E([]); + + var createEl = E('div', { 'class': 'cbi-section-create' }), + config_name = this.uciconfig || this.map.config, + btn_title = this.titleFn('addbtntitle'); + + if (extra_class != null) + createEl.classList.add(extra_class); + + if (this.anonymous) { + createEl.appendChild(E('button', { + 'class': 'cbi-button cbi-button-add', + 'title': btn_title || _('Add'), + 'click': ui.createHandlerFn(this, 'handleAdd'), + 'disabled': this.map.readonly || null + }, [ btn_title || _('Add') ])); + } + else { + var nameEl = E('input', { + 'type': 'text', + 'class': 'cbi-section-create-name', + 'disabled': this.map.readonly || null + }); + + dom.append(createEl, [ + E('div', {}, nameEl), + E('button', { + 'class': 'cbi-button cbi-button-add', + 'title': btn_title || _('Add'), + 'click': ui.createHandlerFn(this, function(ev) { + if (nameEl.classList.contains('cbi-input-invalid')) + return; + + return this.handleAdd(ev, nameEl.value); + }), + 'disabled': this.map.readonly || true + }, [ btn_title || _('Add') ]) + ]); + + if (this.map.readonly !== true) { + ui.addValidator(nameEl, 'uciname', true, function(v) { + var button = createEl.querySelector('.cbi-section-create > .cbi-button-add'); + if (v !== '') { + button.disabled = null; + return true; + } + else { + button.disabled = true; + return _('Expecting: %s').format(_('non-empty value')); + } + }, 'blur', 'keyup'); + } + } + + return createEl; + }, + + /** @private */ + renderSectionPlaceholder: function() { + return E('em', _('This section contains no values yet')); + }, + + /** @private */ + 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', + 'data-tab': (this.map.tabbed && !this.parentoption) ? this.sectiontype : null, + 'data-tab-title': (this.map.tabbed && !this.parentoption) ? this.title || this.sectiontype : null + }); + + 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)); + + for (var i = 0; i < nodes.length; i++) { + if (this.addremove) { + sectionEl.appendChild( + E('div', { 'class': 'cbi-section-remove right' }, + E('button', { + 'class': 'cbi-button', + 'name': 'cbi.rts.%s.%s'.format(config_name, cfgsections[i]), + 'data-section-id': cfgsections[i], + 'click': ui.createHandlerFn(this, 'handleRemove', cfgsections[i]), + 'disabled': this.map.readonly || null + }, [ _('Delete') ]))); + } + + 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', + 'data-section-id': cfgsections[i] + }, nodes[i])); + } + + if (nodes.length == 0) + sectionEl.appendChild(this.renderSectionPlaceholder()); + + sectionEl.appendChild(this.renderSectionAdd()); + + dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + /** @override */ + 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)); + } +}); + +/** + * @class TableSection + * @memberof LuCI.form + * @augments LuCI.form.TypedSection + * @hideconstructor + * @classdesc + * + * The `TableSection` class maps all or - if `filter()` is overwritten - a + * subset of the underlying UCI configuration sections of a given type. + * + * Layout wise, the configuration section instances mapped by the section + * element (sometimes referred to as "section nodes") are rendered as rows + * within an HTML table element, with an optional section remove button in the + * last column and a section add button below the table, depending on the + * value of the `addremove` property. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.prototype */ { + __name__: 'CBI.TableSection', + + /** + * If set to `true`, the user may add or remove instances from the form + * section widget, otherwise only preexisting sections may be edited. + * The default is `false`. + * + * @name LuCI.form.TableSection.prototype#addremove + * @type boolean + * @default false + */ + + /** + * If set to `true`, mapped section instances are treated as anonymous + * UCI sections, which means that section instance elements will be + * rendered without title element and that no name is required when adding + * new sections. The default is `false`. + * + * @name LuCI.form.TableSection.prototype#anonymous + * @type boolean + * @default false + */ + + /** + * Override the caption used for the section add button at the bottom of + * the section form element. If set to a string, it will be used as-is, + * if set to a function, the function will be invoked and its return value + * is used as caption, after converting it to a string. If this property + * is not set, the default is `Add`. + * + * @name LuCI.form.TableSection.prototype#addbtntitle + * @type string|function + * @default null + */ + + /** + * Override the per-section instance title caption shown in the first + * column of the table unless `anonymous` is set to true. If set to a + * string, it will be used as `String.format()` pattern with the name of + * the underlying UCI section as first argument, if set to a function, the + * function will be invoked with the section name as first argument and + * its return value is used as caption, after converting it to a string. + * If this property is not set, the default is the name of the underlying + * UCI configuration section. + * + * @name LuCI.form.TableSection.prototype#sectiontitle + * @type string|function + * @default null + */ + + /** + * Override the per-section instance modal popup title caption shown when + * clicking the `More…` button in a section specifying `max_cols`. If set + * to a string, it will be used as `String.format()` pattern with the name + * of the underlying UCI section as first argument, if set to a function, + * the function will be invoked with the section name as first argument and + * its return value is used as caption, after converting it to a string. + * If this property is not set, the default is the name of the underlying + * UCI configuration section. + * + * @name LuCI.form.TableSection.prototype#modaltitle + * @type string|function + * @default null + */ + + /** + * Override the UCI configuration name to read the section IDs from. By + * default, the configuration name is inherited from the parent `Map`. + * By setting this property, a deviating configuration may be specified. + * The default is `null`, means inheriting from the parent form. + * + * @name LuCI.form.TableSection.prototype#uciconfig + * @type string + * @default null + */ + + /** + * Specify a maximum amount of columns to display. By default, one table + * column is rendered for each child option of the form section element. + * When this option is set to a positive number, then no more columns than + * the given amount are rendered. When the number of child options exceeds + * the specified amount, a `More…` button is rendered in the last column, + * opening a modal dialog presenting all options elements in `NamedSection` + * style when clicked. + * + * @name LuCI.form.TableSection.prototype#max_cols + * @type number + * @default null + */ + + /** + * If set to `true`, alternating `cbi-rowstyle-1` and `cbi-rowstyle-2` CSS + * classes are added to the table row elements. Not all LuCI themes + * implement these row style classes. The default is `false`. + * + * @name LuCI.form.TableSection.prototype#rowcolors + * @type boolean + * @default false + */ + + /** + * Enables a per-section instance row `Edit` button which triggers a certain + * action when clicked. If set to a string, the string value is used + * as `String.format()` pattern with the name of the underlying UCI section + * as first format argument. The result is then interpreted as URL which + * LuCI will navigate to when the user clicks the edit button. + * + * If set to a function, this function will be registered as click event + * handler on the rendered edit button, receiving the section instance + * name as first and the DOM click event as second argument. + * + * @name LuCI.form.TableSection.prototype#extedit + * @type string|function + * @default null + */ + + /** + * If set to `true`, a sort button is added to the last column, allowing + * the user to reorder the section instances mapped by the section form + * element. + * + * @name LuCI.form.TableSection.prototype#sortable + * @type boolean + * @default false + */ + + /** + * If set to `true`, the header row with the options descriptions will + * not be displayed. By default, descriptions row is automatically displayed + * when at least one option has a description. + * + * @name LuCI.form.TableSection.prototype#nodescriptions + * @type boolean + * @default false + */ + + /** + * The `TableSection` implementation does not support option tabbing, so + * its implementation of `tab()` will always throw an exception when + * invoked. + * + * @override + * @throws Throws an exception when invoked. + */ + tab: function() { + throw 'Tabs are not supported by TableSection'; + }, + + /** @private */ + 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, + drag_sort = this.sortable && !('ontouchstart' in window), + touch_sort = this.sortable && ('ontouchstart' in window), + sectionEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype), + 'class': 'cbi-section cbi-tblsection', + 'data-tab': (this.map.tabbed && !this.parentoption) ? this.sectiontype : null, + 'data-tab-title': (this.map.tabbed && !this.parentoption) ? this.title || this.sectiontype : null + }), + tableEl = E('table', { + '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.titleFn('sectiontitle', cfgsections[i]); + + if (sectionname == null) + sectionname = cfgsections[i]; + + var trEl = E('tr', { + 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]), + 'class': 'tr cbi-section-table-row', + 'data-sid': cfgsections[i], + 'draggable': (drag_sort || touch_sort) ? true : null, + 'mousedown': drag_sort ? L.bind(this.handleDragInit, this) : null, + 'dragstart': drag_sort ? L.bind(this.handleDragStart, this) : null, + 'dragover': drag_sort ? L.bind(this.handleDragOver, this) : null, + 'dragenter': drag_sort ? L.bind(this.handleDragEnter, this) : null, + 'dragleave': drag_sort ? L.bind(this.handleDragLeave, this) : null, + 'dragend': drag_sort ? L.bind(this.handleDragEnd, this) : null, + 'drop': drag_sort ? L.bind(this.handleDrop, this) : null, + 'touchmove': touch_sort ? L.bind(this.handleTouchMove, this) : null, + 'touchend': touch_sort ? L.bind(this.handleTouchEnd, this) : null, + 'data-title': (sectionname && (!this.anonymous || this.sectiontitle)) ? sectionname : null, + 'data-section-id': cfgsections[i] + }); + + 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('tr', { 'class': 'tr cbi-section-table-row placeholder' }, + E('td', { 'class': 'td' }, this.renderSectionPlaceholder()))); + + sectionEl.appendChild(tableEl); + + sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create')); + + dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + /** @private */ + renderHeaderRows: function(max_cols, has_action) { + var has_titles = false, + has_descriptions = false, + max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols, + has_more = max_cols < this.children.length, + 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.modalonly) + continue; + + has_titles = has_titles || !!opt.title; + has_descriptions = has_descriptions || !!opt.description; + } + + if (has_titles) { + var trEl = E('tr', { + 'class': 'tr cbi-section-table-titles ' + anon_class, + 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null, + 'click': this.sortable ? ui.createHandlerFn(this, 'handleSort') : null + }); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.modalonly) + continue; + + trEl.appendChild(E('th', { + 'class': 'th cbi-section-table-cell', + 'data-widget': opt.__name__, + 'data-sortable-row': this.sortable ? '' : null + })); + + 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 + dom.content(trEl.lastElementChild, opt.title); + } + + if (this.sortable || this.extedit || this.addremove || has_more || has_action) + trEl.appendChild(E('th', { + 'class': 'th cbi-section-table-cell cbi-section-actions' + })); + + trEls.appendChild(trEl); + } + + if (has_descriptions && !this.nodescriptions) { + var trEl = E('tr', { + 'class': 'tr cbi-section-table-descr ' + anon_class + }); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.modalonly) + continue; + + trEl.appendChild(E('th', { + 'class': 'th cbi-section-table-cell', + 'data-widget': 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 || has_action) + trEl.appendChild(E('th', { + 'class': 'th cbi-section-table-cell cbi-section-actions' + })); + + trEls.appendChild(trEl); + } + + return trEls; + }, + + /** @private */ + 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('td', { + 'class': 'td cbi-section-table-cell nowrap cbi-section-actions' + }, E('div')); + + if (this.sortable) { + dom.append(tdEl.lastElementChild, [ + E('button', { + 'title': _('Drag to reorder'), + 'class': 'cbi-button drag-handle center', + 'style': 'cursor:move', + 'disabled': this.map.readonly || null + }, '☰') + ]); + } + + 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); + + dom.append(tdEl.lastElementChild, + E('button', { + 'title': _('Edit'), + 'class': 'cbi-button cbi-button-edit', + 'click': evFn + }, [ _('Edit') ]) + ); + } + + if (more_label) { + dom.append(tdEl.lastElementChild, + E('button', { + 'title': more_label, + 'class': 'cbi-button cbi-button-edit', + 'click': ui.createHandlerFn(this, 'renderMoreOptionsModal', section_id) + }, [ more_label ]) + ); + } + + if (this.addremove) { + var btn_title = this.titleFn('removebtntitle', section_id); + + dom.append(tdEl.lastElementChild, + E('button', { + 'title': btn_title || _('Delete'), + 'class': 'cbi-button cbi-button-remove', + 'click': ui.createHandlerFn(this, 'handleRemove', section_id), + 'disabled': this.map.readonly || null + }, [ btn_title || _('Delete') ]) + ); + } + + return tdEl; + }, + + /** @private */ + handleDragInit: function(ev) { + scope.dragState = { node: ev.target }; + }, + + /** @private */ + handleDragStart: function(ev) { + if (!scope.dragState || !scope.dragState.node.classList.contains('drag-handle')) { + scope.dragState = null; + ev.preventDefault(); + return false; + } + + scope.dragState.node = dom.parent(scope.dragState.node, '.tr'); + ev.dataTransfer.setData('text', 'drag'); + ev.target.style.opacity = 0.4; + }, + + /** @private */ + 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; + }, + + /** @private */ + handleDragEnter: function(ev) { + scope.dragState.rect = ev.currentTarget.getBoundingClientRect(); + scope.dragState.targetNode = ev.currentTarget; + }, + + /** @private */ + handleDragLeave: function(ev) { + ev.currentTarget.classList.remove('drag-over-above'); + ev.currentTarget.classList.remove('drag-over-below'); + }, + + /** @private */ + 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'); + }); + }, + + /** @private */ + 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); + this.map.data.move(config_name, sid1, sid2, after); + } + + scope.dragState = null; + ev.target.style.opacity = ''; + ev.stopPropagation(); + ev.preventDefault(); + return false; + }, + + /** @private */ + determineBackgroundColor: function(node) { + var r = 255, g = 255, b = 255; + + while (node) { + var s = window.getComputedStyle(node), + c = (s.getPropertyValue('background-color') || '').replace(/ /g, ''); + + if (c != '' && c != 'transparent' && c != 'rgba(0,0,0,0)') { + if (/^#([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.test(c)) { + r = parseInt(RegExp.$1, 16); + g = parseInt(RegExp.$2, 16); + b = parseInt(RegExp.$3, 16); + } + else if (/^rgba?\(([0-9]+),([0-9]+),([0-9]+)[,)]$/.test(c)) { + r = +RegExp.$1; + g = +RegExp.$2; + b = +RegExp.$3; + } + + break; + } + + node = node.parentNode; + } + + return [ r, g, b ]; + }, + + /** @private */ + handleTouchMove: function(ev) { + if (!ev.target.classList.contains('drag-handle')) + return; + + var touchLoc = ev.targetTouches[0], + rowBtn = ev.target, + rowElem = dom.parent(rowBtn, '.tr'), + htmlElem = document.querySelector('html'), + dragHandle = document.querySelector('.touchsort-element'), + viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); + + if (!dragHandle) { + var rowRect = rowElem.getBoundingClientRect(), + btnRect = rowBtn.getBoundingClientRect(), + paddingLeft = btnRect.left - rowRect.left, + paddingRight = rowRect.right - btnRect.right, + colorBg = this.determineBackgroundColor(rowElem), + colorFg = (colorBg[0] * 0.299 + colorBg[1] * 0.587 + colorBg[2] * 0.114) > 186 ? [ 0, 0, 0 ] : [ 255, 255, 255 ]; + + dragHandle = E('div', { 'class': 'touchsort-element' }, [ + E('strong', [ rowElem.getAttribute('data-title') ]), + rowBtn.cloneNode(true) + ]); + + Object.assign(dragHandle.style, { + position: 'absolute', + boxShadow: '0 0 3px rgba(%d, %d, %d, 1)'.format(colorFg[0], colorFg[1], colorFg[2]), + background: 'rgba(%d, %d, %d, 0.8)'.format(colorBg[0], colorBg[1], colorBg[2]), + top: rowRect.top + 'px', + left: rowRect.left + 'px', + width: rowRect.width + 'px', + height: (rowBtn.offsetHeight + 4) + 'px' + }); + + Object.assign(dragHandle.firstElementChild.style, { + position: 'absolute', + lineHeight: dragHandle.style.height, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + left: (paddingRight > paddingLeft) ? '' : '5px', + right: (paddingRight > paddingLeft) ? '5px' : '', + width: (Math.max(paddingLeft, paddingRight) - 10) + 'px' + }); + + Object.assign(dragHandle.lastElementChild.style, { + position: 'absolute', + top: '2px', + left: paddingLeft + 'px', + width: rowBtn.offsetWidth + 'px' + }); + + document.body.appendChild(dragHandle); + + rowElem.classList.remove('flash'); + rowBtn.blur(); + } + + dragHandle.style.top = (touchLoc.pageY - (parseInt(dragHandle.style.height) / 2)) + 'px'; + + rowElem.parentNode.querySelectorAll('[draggable]').forEach(function(tr, i, trs) { + var trRect = tr.getBoundingClientRect(), + yTop = trRect.top + window.scrollY, + yBottom = trRect.bottom + window.scrollY, + yMiddle = yTop + ((yBottom - yTop) / 2); + + tr.classList.remove('drag-over-above', 'drag-over-below'); + + if ((i == 0 || touchLoc.pageY >= yTop) && touchLoc.pageY <= yMiddle) + tr.classList.add('drag-over-above'); + else if ((i == (trs.length - 1) || touchLoc.pageY <= yBottom) && touchLoc.pageY > yMiddle) + tr.classList.add('drag-over-below'); + }); + + /* prevent standard scrolling and scroll page when drag handle is + * moved very close (~30px) to the viewport edge */ + + ev.preventDefault(); + + if (touchLoc.clientY < 30) + window.requestAnimationFrame(function() { htmlElem.scrollTop -= 30 }); + else if (touchLoc.clientY > viewportHeight - 30) + window.requestAnimationFrame(function() { htmlElem.scrollTop += 30 }); + }, + + /** @private */ + handleTouchEnd: function(ev) { + var rowElem = dom.parent(ev.target, '.tr'), + htmlElem = document.querySelector('html'), + dragHandle = document.querySelector('.touchsort-element'), + targetElem = rowElem.parentNode.querySelector('.drag-over-above, .drag-over-below'), + viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); + + if (!dragHandle) + return; + + if (targetElem) { + var isBelow = targetElem.classList.contains('drag-over-below'); + + rowElem.parentNode.insertBefore(rowElem, isBelow ? targetElem.nextElementSibling : targetElem); + + this.map.data.move( + this.uciconfig || this.map.config, + rowElem.getAttribute('data-sid'), + targetElem.getAttribute('data-sid'), + isBelow); + + window.requestAnimationFrame(function() { + var rowRect = rowElem.getBoundingClientRect(); + + if (rowRect.top < 50) + htmlElem.scrollTop = (htmlElem.scrollTop + rowRect.top - 50); + else if (rowRect.bottom > viewportHeight - 50) + htmlElem.scrollTop = (htmlElem.scrollTop + viewportHeight - 50 - rowRect.height); + + rowElem.classList.add('flash'); + }); + + targetElem.classList.remove('drag-over-above', 'drag-over-below'); + } + + document.body.removeChild(dragHandle); + }, + + /** @private */ + handleModalCancel: function(modalMap, ev) { + var prevNode = this.getPreviousModalMap(), + resetTasks = Promise.resolve(); + + if (prevNode) { + var heading = prevNode.parentNode.querySelector('h4'), + prevMap = dom.findClassInstance(prevNode); + + while (prevMap) { + resetTasks = resetTasks + .then(L.bind(prevMap.load, prevMap)) + .then(L.bind(prevMap.reset, prevMap)); + + prevMap = prevMap.parent; + } + + prevNode.classList.add('flash'); + prevNode.classList.remove('hidden'); + prevNode.parentNode.removeChild(prevNode.nextElementSibling); + + heading.removeChild(heading.lastElementChild); + + if (!this.getPreviousModalMap()) + prevNode.parentNode + .querySelector('div.right > button') + .firstChild.data = _('Dismiss'); + } + else { + ui.hideModal(); + } + + return resetTasks; + }, + + /** @private */ + handleModalSave: function(modalMap, ev) { + var mapNode = this.getActiveModalMap(), + activeMap = dom.findClassInstance(mapNode), + saveTasks = activeMap.save(null, true); + + while (activeMap.parent) { + activeMap = activeMap.parent; + saveTasks = saveTasks + .then(L.bind(activeMap.load, activeMap)) + .then(L.bind(activeMap.reset, activeMap)); + } + + return saveTasks + .then(L.bind(this.handleModalCancel, this, modalMap, ev, true)) + .catch(function() {}); + }, + + /** @private */ + handleSort: function(ev) { + if (!ev.target.matches('th[data-sortable-row]')) + return; + + var th = ev.target, + descending = (th.getAttribute('data-sort-direction') == 'desc'), + config_name = this.uciconfig || this.map.config, + index = 0, + list = []; + + ev.currentTarget.querySelectorAll('th').forEach(function(other_th, i) { + if (other_th !== th) + other_th.removeAttribute('data-sort-direction'); + else + index = i; + }); + + ev.currentTarget.parentNode.querySelectorAll('tr.cbi-section-table-row').forEach(L.bind(function(tr, i) { + var sid = tr.getAttribute('data-sid'), + opt = tr.childNodes[index].getAttribute('data-name'), + val = this.cfgvalue(sid, opt); + + tr.querySelectorAll('.flash').forEach(function(n) { + n.classList.remove('flash') + }); + + list.push([ + ui.Table.prototype.deriveSortKey((val != null) ? val.trim() : ''), + tr + ]); + }, this)); + + list.sort(function(a, b) { + return descending + ? -L.naturalCompare(a[0], b[0]) + : L.naturalCompare(a[0], b[0]); + }); + + window.requestAnimationFrame(L.bind(function() { + var ref_sid, cur_sid; + + for (var i = 0; i < list.length; i++) { + list[i][1].childNodes[index].classList.add('flash'); + th.parentNode.parentNode.appendChild(list[i][1]); + + cur_sid = list[i][1].getAttribute('data-sid'); + + if (ref_sid) + this.map.data.move(config_name, cur_sid, ref_sid, true); + + ref_sid = cur_sid; + } + + th.setAttribute('data-sort-direction', descending ? 'asc' : 'desc'); + }, this)); + }, + + /** + * Add further options to the per-section instanced modal popup. + * + * This function may be overwritten by user code to perform additional + * setup steps before displaying the more options modal which is useful to + * e.g. query additional data or to inject further option elements. + * + * The default implementation of this function does nothing. + * + * @abstract + * @param {LuCI.form.NamedSection} modalSection + * The `NamedSection` instance about to be rendered in the modal popup. + * + * @param {string} section_id + * The ID of the underlying UCI section the modal popup belongs to. + * + * @param {Event} ev + * The DOM event emitted by clicking the `More…` button. + * + * @returns {*|Promise<*>} + * Return values of this function are ignored but if a promise is returned, + * it is run to completion before the rendering is continued, allowing + * custom logic to perform asynchroneous work before the modal dialog + * is shown. + */ + addModalOptions: function(modalSection, section_id, ev) { + + }, + + /** @private */ + getActiveModalMap: function() { + return document.querySelector('body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map:not(.hidden)'); + }, + + /** @private */ + getPreviousModalMap: function() { + var mapNode = this.getActiveModalMap(), + prevNode = mapNode ? mapNode.previousElementSibling : null; + + return (prevNode && prevNode.matches('.cbi-map.hidden')) ? prevNode : null; + }, + + /** @private */ + cloneOptions: function(src_section, dest_section) { + for (var i = 0; i < src_section.children.length; i++) { + var o1 = src_section.children[i]; + + if (o1.modalonly === false && src_section === this) + continue; + + var o2; + + if (o1.subsection) { + o2 = dest_section.option(o1.constructor, o1.option, o1.subsection.constructor, o1.subsection.sectiontype, o1.subsection.title, o1.subsection.description); + + for (var k in o1.subsection) { + if (!o1.subsection.hasOwnProperty(k)) + continue; + + switch (k) { + case 'map': + case 'children': + case 'parentoption': + continue; + + default: + o2.subsection[k] = o1.subsection[k]; + } + } + + this.cloneOptions(o1.subsection, o2.subsection); + } + else { + o2 = dest_section.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': + case 'subsection': + continue; + + default: + o2[k] = o1[k]; + } + } + } + }, + + /** @private */ + renderMoreOptionsModal: function(section_id, ev) { + var parent = this.map, + sref = parent.data.get(parent.config, section_id), + mapNode = this.getActiveModalMap(), + activeMap = mapNode ? dom.findClassInstance(mapNode) : null, + stackedMap = activeMap && (activeMap.parent !== parent || activeMap.section !== section_id); + + return (stackedMap ? activeMap.save(null, true) : Promise.resolve()).then(L.bind(function() { + section_id = sref['.name']; + + var m; + + if (parent instanceof CBIJSONMap) { + m = new CBIJSONMap(null, null, null); + m.data = parent.data; + } + else { + m = new CBIMap(parent.config, null, null); + } + + var s = m.section(CBINamedSection, section_id, this.sectiontype); + + m.parent = parent; + m.section = section_id; + m.readonly = parent.readonly; + + s.tabs = this.tabs; + s.tab_names = this.tab_names; + + this.cloneOptions(this, s); + + return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(function() { + return m.render(); + }).then(L.bind(function(nodes) { + var title = parent.title, + name = null; + + if ((name = this.titleFn('modaltitle', section_id)) != null) + title = name; + else if ((name = this.titleFn('sectiontitle', section_id)) != null) + title = '%s - %s'.format(parent.title, name); + else if (!this.anonymous) + title = '%s - %s'.format(parent.title, section_id); + + if (stackedMap) { + mapNode.parentNode + .querySelector('h4') + .appendChild(E('span', title ? ' » ' + title : '')); + + mapNode.parentNode + .querySelector('div.right > button') + .firstChild.data = _('Back'); + + mapNode.classList.add('hidden'); + mapNode.parentNode.insertBefore(nodes, mapNode.nextElementSibling); + + nodes.classList.add('flash'); + } + else { + ui.showModal(title, [ + nodes, + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(this, 'handleModalCancel', m) + }, [ _('Dismiss') ]), ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive important', + 'click': ui.createHandlerFn(this, 'handleModalSave', m), + 'disabled': m.readonly || null + }, [ _('Save') ]) + ]) + ], 'cbi-modal'); + } + }, this)); + }, this)).catch(L.error); + } +}); + +/** + * @class GridSection + * @memberof LuCI.form + * @augments LuCI.form.TableSection + * @hideconstructor + * @classdesc + * + * The `GridSection` class maps all or - if `filter()` is overwritten - a + * subset of the underlying UCI configuration sections of a given type. + * + * A grid section functions similar to a {@link LuCI.form.TableSection} but + * supports tabbing in the modal overlay. Option elements added with + * [option()]{@link LuCI.form.GridSection#option} are shown in the table while + * elements added with [taboption()]{@link LuCI.form.GridSection#taboption} + * are displayed in the modal popup. + * + * Another important difference is that the table cells show a readonly text + * preview of the corresponding option elements by default, unless the child + * option element is explicitely made writable by setting the `editable` + * property to `true`. + * + * Additionally, the grid section honours a `modalonly` property of child + * option elements. Refer to the [AbstractValue]{@link LuCI.form.AbstractValue} + * documentation for details. + * + * Layout wise, a grid section looks mostly identical to table sections. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.prototype */ { + /** + * Add an option tab to the section. + * + * The modal option elements of a grid section may be divided into multiple + * tabs to provide a better overview to the user. + * + * Before options can be moved into a tab pane, the corresponding tab + * has to be defined first, which is done by calling this function. + * + * Note that tabs are only effective in modal popups, options added with + * `option()` will not be assigned to a specific tab and are rendered in + * the table view only. + * + * @param {string} name + * The name of the tab to register. It may be freely chosen and just serves + * as an identifier to differentiate tabs. + * + * @param {string} title + * The human readable caption of the tab. + * + * @param {string} [description] + * An additional description text for the corresponding tab pane. It is + * displayed as text paragraph below the tab but before the tab pane + * contents. If omitted, no description will be rendered. + * + * @throws {Error} + * Throws an exeption if a tab with the same `name` already exists. + */ + tab: function(name, title, description) { + CBIAbstractSection.prototype.tab.call(this, name, title, description); + }, + + /** @private */ + handleAdd: function(ev, name) { + var config_name = this.uciconfig || this.map.config, + section_id = this.map.data.add(config_name, this.sectiontype, name), + mapNode = this.getPreviousModalMap(), + prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map; + + prevMap.addedSection = section_id; + + return this.renderMoreOptionsModal(section_id); + }, + + /** @private */ + handleModalSave: function(/* ... */) { + var mapNode = this.getPreviousModalMap(), + prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map; + + return this.super('handleModalSave', arguments); + }, + + /** @private */ + handleModalCancel: function(modalMap, ev, isSaving) { + var config_name = this.uciconfig || this.map.config, + mapNode = this.getPreviousModalMap(), + prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map; + + if (prevMap.addedSection != null && !isSaving) + this.map.data.remove(config_name, prevMap.addedSection); + + delete prevMap.addedSection; + + return this.super('handleModalCancel', arguments); + }, + + /** @private */ + renderUCISection: function(section_id) { + return this.renderOptions(null, section_id); + }, + + /** @private */ + 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); + }, + + /** @private */ + 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('td', { + 'class': 'td cbi-value-field', + 'data-title': (title != '') ? title : null, + 'data-description': (descr != '') ? descr : null, + 'data-name': opt.option, + 'data-widget': 'CBI.DummyValue' + }, (value != null) ? value : E('em', _('none'))); + }, + + /** @private */ + renderHeaderRows: function(section_id) { + return this.super('renderHeaderRows', [ NaN, true ]); + }, + + /** @private */ + renderRowActions: function(section_id) { + return this.super('renderRowActions', [ section_id, _('Edit') ]); + }, + + /** @override */ + 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); + } +}); + +/** + * @class NamedSection + * @memberof LuCI.form + * @augments LuCI.form.AbstractSection + * @hideconstructor + * @classdesc + * + * The `NamedSection` class maps exactly one UCI section instance which is + * specified when constructing the class instance. + * + * Layout and functionality wise, a named section is essentially a + * `TypedSection` which allows exactly one section node. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_id + * The name (ID) of the UCI section to map. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBINamedSection = CBIAbstractSection.extend(/** @lends LuCI.form.NamedSection.prototype */ { + __name__: 'CBI.NamedSection', + __init__: function(map, section_id /*, ... */) { + this.super('__init__', this.varargs(arguments, 2, map)); + + this.section = section_id; + }, + + /** + * If set to `true`, the user may remove or recreate the sole mapped + * configuration instance from the form section widget, otherwise only a + * preexisting section may be edited. The default is `false`. + * + * @name LuCI.form.NamedSection.prototype#addremove + * @type boolean + * @default false + */ + + /** + * Override the UCI configuration name to read the section IDs from. By + * default, the configuration name is inherited from the parent `Map`. + * By setting this property, a deviating configuration may be specified. + * The default is `null`, means inheriting from the parent form. + * + * @name LuCI.form.NamedSection.prototype#uciconfig + * @type string + * @default null + */ + + /** + * The `NamedSection` class overwrites the generic `cfgsections()` + * implementation to return a one-element array containing the mapped + * section ID as sole element. User code should not normally change this. + * + * @returns {string[]} + * Returns a one-element array containing the mapped section ID. + */ + cfgsections: function() { + return [ this.section ]; + }, + + /** @private */ + handleAdd: function(ev) { + var section_id = this.section, + config_name = this.uciconfig || this.map.config; + + this.map.data.add(config_name, this.sectiontype, section_id); + return this.map.save(null, true); + }, + + /** @private */ + handleRemove: function(ev) { + var section_id = this.section, + config_name = this.uciconfig || this.map.config; + + this.map.data.remove(config_name, section_id); + return this.map.save(null, true); + }, + + /** @private */ + 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', + 'data-tab': (this.map.tabbed && !this.parentoption) ? this.sectiontype : null, + 'data-tab-title': (this.map.tabbed && !this.parentoption) ? this.title || this.sectiontype : null + }); + + if (typeof(this.title) === 'string' && this.title !== '') + sectionEl.appendChild(E('h3', {}, 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('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(this, 'handleRemove'), + 'disabled': this.map.readonly || null + }, [ _('Delete') ]))); + } + + 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', + 'data-section-id': section_id + }, nodes)); + } + else if (this.addremove) { + sectionEl.appendChild( + E('button', { + 'class': 'cbi-button cbi-button-add', + 'click': ui.createHandlerFn(this, 'handleAdd'), + 'disabled': this.map.readonly || null + }, [ _('Add') ])); + } + + dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + /** @override */ + render: function() { + var config_name = this.uciconfig || this.map.config, + section_id = this.section; + + return Promise.all([ + this.map.data.get(config_name, section_id), + this.renderUCISection(section_id) + ]).then(this.renderContents.bind(this)); + } +}); + +/** + * @class Value + * @memberof LuCI.form + * @augments LuCI.form.AbstractValue + * @hideconstructor + * @classdesc + * + * The `Value` class represents a simple one-line form input using the + * {@link LuCI.ui.Textfield} or - in case choices are added - the + * {@link LuCI.ui.Combobox} class as underlying widget. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */ { + __name__: 'CBI.Value', + + /** + * If set to `true`, the field is rendered as password input, otherwise + * as plain text input. + * + * @name LuCI.form.Value.prototype#password + * @type boolean + * @default false + */ + + /** + * Set a placeholder string to use when the input field is empty. + * + * @name LuCI.form.Value.prototype#placeholder + * @type string + * @default null + */ + + /** + * Add a predefined choice to the form option. By adding one or more + * choices, the plain text input field is turned into a combobox widget + * which prompts the user to select a predefined choice, or to enter a + * custom value. + * + * @param {string} key + * The choice value to add. + * + * @param {Node|string} value + * The caption for the choice value. May be a DOM node, a document fragment + * or a plain text string. If omitted, the `key` value is used as caption. + */ + value: function(key, val) { + this.keylist = this.keylist || []; + this.keylist.push(String(key)); + + this.vallist = this.vallist || []; + this.vallist.push(dom.elem(val) ? val : String(val != null ? val : key)); + }, + + /** @override */ + 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)); + }, + + /** @private */ + handleValueChange: function(section_id, state, ev) { + if (typeof(this.onchange) != 'function') + return; + + var value = this.formvalue(section_id); + + if (isEqual(value, state.previousValue)) + return; + + state.previousValue = value; + this.onchange.call(this, ev, section_id, value); + }, + + /** @private */ + renderFrame: function(section_id, in_table, option_index, nodes) { + var config_name = this.uciconfig || this.section.uciconfig || this.map.config, + depend_list = this.transformDepList(section_id), + optionEl; + + if (in_table) { + var title = this.stripTags(this.title).trim(); + optionEl = E('td', { + 'class': 'td cbi-value-field', + 'data-title': (title != '') ? title : null, + 'data-description': this.stripTags(this.description).trim(), + 'data-name': this.option, + 'data-widget': 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-widget': 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), + 'click': function(ev) { + var node = ev.currentTarget, + elem = node.nextElementSibling.querySelector('#' + node.getAttribute('for')) || node.nextElementSibling.querySelector('[data-widget-id="' + node.getAttribute('for') + '"]'); + + if (elem) { + elem.click(); + elem.focus(); + } + } + }, + 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 !== '') + dom.append(optionEl.lastChild || optionEl, + E('div', { 'class': 'cbi-value-description' }, this.description.trim())); + + if (depend_list && depend_list.length) + optionEl.classList.add('hidden'); + + optionEl.addEventListener('widget-change', + L.bind(this.map.checkDepends, this.map)); + + optionEl.addEventListener('widget-change', + L.bind(this.handleValueChange, this, section_id, {})); + + dom.bindClassInstance(optionEl, this); + + return optionEl; + }, + + /** @private */ + 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), + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + } + 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), + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + } + + return widget.render(); + } +}); + +/** + * @class DynamicList + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DynamicList` class represents a multi value widget allowing the user + * to enter multiple unique values, optionally selected from a set of + * predefined choices. It builds upon the {@link LuCI.ui.DynamicList} widget. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIDynamicList = CBIValue.extend(/** @lends LuCI.form.DynamicList.prototype */ { + __name__: 'CBI.DynamicList', + + /** @private */ + 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), + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return widget.render(); + }, +}); + +/** + * @class ListValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `ListValue` class implements a simple static HTML select element + * allowing the user to chose a single value from a set of predefined choices. + * It builds upon the {@link LuCI.ui.Select} widget. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIListValue = CBIValue.extend(/** @lends LuCI.form.ListValue.prototype */ { + __name__: 'CBI.ListValue', + + __init__: function() { + this.super('__init__', arguments); + this.widget = 'select'; + this.orientation = 'horizontal'; + this.deplist = []; + }, + + /** + * Set the size attribute of the underlying HTML select element. + * + * @name LuCI.form.ListValue.prototype#size + * @type number + * @default null + */ + + /** + * Set the type of the underlying form controls. + * + * May be one of `select` or `radio`. If set to `select`, an HTML + * select element is rendered, otherwise a collection of `radio` + * elements is used. + * + * @name LuCI.form.ListValue.prototype#widget + * @type string + * @default select + */ + + /** + * Set the orientation of the underlying radio or checkbox elements. + * + * May be one of `horizontal` or `vertical`. Only applies to non-select + * widget types. + * + * @name LuCI.form.ListValue.prototype#orientation + * @type string + * @default horizontal + */ + + /** @private */ + 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, + widget: this.widget, + optional: this.optional, + orientation: this.orientation, + placeholder: this.placeholder, + validate: L.bind(this.validate, this, section_id), + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return widget.render(); + }, +}); + +/** + * @class FlagValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `FlagValue` element builds upon the {@link LuCI.ui.Checkbox} widget to + * implement a simple checkbox element. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.FlagValue.prototype */ { + __name__: 'CBI.FlagValue', + + __init__: function() { + this.super('__init__', arguments); + + this.enabled = '1'; + this.disabled = '0'; + this.default = this.disabled; + }, + + /** + * Sets the input value to use for the checkbox checked state. + * + * @name LuCI.form.FlagValue.prototype#enabled + * @type number + * @default 1 + */ + + /** + * Sets the input value to use for the checkbox unchecked state. + * + * @name LuCI.form.FlagValue.prototype#disabled + * @type number + * @default 0 + */ + + /** + * Set a tooltip for the flag option. + * + * If set to a string, it will be used as-is as a tooltip. + * + * If set to a function, the function will be invoked and the return + * value will be shown as a tooltip. If the return value of the function + * is `null` no tooltip will be set. + * + * @name LuCI.form.TypedSection.prototype#tooltip + * @type string|function + * @default null + */ + + /** + * Set a tooltip icon. + * + * If set, this icon will be shown for the default one. + * This could also be a png icon from the resources directory. + * + * @name LuCI.form.TypedSection.prototype#tooltipicon + * @type string + * @default 'ℹ️'; + */ + + /** @private */ + renderWidget: function(section_id, option_index, cfgvalue) { + var tooltip = null; + + if (typeof(this.tooltip) == 'function') + tooltip = this.tooltip.apply(this, [section_id]); + else if (typeof(this.tooltip) == 'string') + tooltip = (arguments.length > 1) ? ''.format.apply(this.tooltip, this.varargs(arguments, 1)) : this.tooltip; + + 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), + tooltip: tooltip, + tooltipicon: this.tooltipicon, + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return widget.render(); + }, + + /** + * Query the checked state of the underlying checkbox widget and return + * either the `enabled` or the `disabled` property value, depending on + * the checked state. + * + * @override + */ + formvalue: function(section_id) { + var elem = this.getUIElement(section_id), + checked = elem ? elem.isChecked() : false; + return checked ? this.enabled : this.disabled; + }, + + /** + * Query the checked state of the underlying checkbox widget and return + * either a localized `Yes` or `No` string, depending on the checked state. + * + * @override + */ + textvalue: function(section_id) { + var cval = this.cfgvalue(section_id); + + if (cval == null) + cval = this.default; + + return (cval == this.enabled) ? _('Yes') : _('No'); + }, + + /** @override */ + parse: function(section_id) { + if (this.isActive(section_id)) { + var fval = this.formvalue(section_id); + + if (!this.isValid(section_id)) { + var title = this.stripTags(this.title).trim(); + var error = this.getValidationError(section_id); + return Promise.reject(new TypeError( + _('Option "%s" contains an invalid input value.').format(title || this.option) + ' ' + error)); + } + + 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 if (!this.retain) { + return Promise.resolve(this.remove(section_id)); + } + }, +}); + +/** + * @class MultiValue + * @memberof LuCI.form + * @augments LuCI.form.DynamicList + * @hideconstructor + * @classdesc + * + * The `MultiValue` class is a modified variant of the `DynamicList` element + * which leverages the {@link LuCI.ui.Dropdown} widget to implement a multi + * select dropdown element. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIMultiValue = CBIDynamicList.extend(/** @lends LuCI.form.MultiValue.prototype */ { + __name__: 'CBI.MultiValue', + + __init__: function() { + this.super('__init__', arguments); + this.placeholder = _('-- Please choose --'); + }, + + /** + * Allows to specify the [display_items]{@link LuCI.ui.Dropdown.InitOptions} + * property of the underlying dropdown widget. If omitted, the value of + * the `size` property is used or `3` when `size` is unspecified as well. + * + * @name LuCI.form.MultiValue.prototype#display_size + * @type number + * @default null + */ + + /** + * Allows to specify the [dropdown_items]{@link LuCI.ui.Dropdown.InitOptions} + * property of the underlying dropdown widget. If omitted, the value of + * the `size` property is used or `-1` when `size` is unspecified as well. + * + * @name LuCI.form.MultiValue.prototype#dropdown_size + * @type number + * @default null + */ + + /** @private */ + 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), + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return widget.render(); + }, +}); + +/** + * @class TextValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `TextValue` class implements a multi-line textarea input using + * {@link LuCI.ui.Textarea}. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBITextValue = CBIValue.extend(/** @lends LuCI.form.TextValue.prototype */ { + __name__: 'CBI.TextValue', + + /** @ignore */ + value: null, + + /** + * Enforces the use of a monospace font for the textarea contents when set + * to `true`. + * + * @name LuCI.form.TextValue.prototype#monospace + * @type boolean + * @default false + */ + + /** + * Allows to specify the [cols]{@link LuCI.ui.Textarea.InitOptions} + * property of the underlying textarea widget. + * + * @name LuCI.form.TextValue.prototype#cols + * @type number + * @default null + */ + + /** + * Allows to specify the [rows]{@link LuCI.ui.Textarea.InitOptions} + * property of the underlying textarea widget. + * + * @name LuCI.form.TextValue.prototype#rows + * @type number + * @default null + */ + + /** + * Allows to specify the [wrap]{@link LuCI.ui.Textarea.InitOptions} + * property of the underlying textarea widget. + * + * @name LuCI.form.TextValue.prototype#wrap + * @type number + * @default null + */ + + /** @private */ + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default; + + var widget = new ui.Textarea(value, { + id: this.cbid(section_id), + optional: this.optional || this.rmempty, + placeholder: this.placeholder, + monospace: this.monospace, + cols: this.cols, + rows: this.rows, + wrap: this.wrap, + validate: L.bind(this.validate, this, section_id), + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return widget.render(); + } +}); + +/** + * @class DummyValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DummyValue` element wraps an {@link LuCI.ui.Hiddenfield} widget and + * renders the underlying UCI option or default value as readonly text. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIDummyValue = CBIValue.extend(/** @lends LuCI.form.DummyValue.prototype */ { + __name__: 'CBI.DummyValue', + + /** + * Set an URL which is opened when clicking on the dummy value text. + * + * By setting this property, the dummy value text is wrapped in an `` + * element with the property value used as `href` attribute. + * + * @name LuCI.form.DummyValue.prototype#href + * @type string + * @default null + */ + + /** + * Treat the UCI option value (or the `default` property value) as HTML. + * + * By default, the value text is HTML escaped before being rendered as + * text. In some cases it may be needed to actually interpret and render + * HTML contents as-is. When set to `true`, HTML escaping is disabled. + * + * @name LuCI.form.DummyValue.prototype#rawhtml + * @type boolean + * @default null + */ + + /** + * Render the UCI option value as hidden using the HTML display: none style property. + * + * By default, the value is displayed + * + * @name LuCI.form.DummyValue.prototype#hidden + * @type boolean + * @default null + */ + + /** @private */ + 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', { 'style': this.hidden ? 'display:none' : null }); + + if (this.href && !((this.readonly != null) ? this.readonly : this.map.readonly)) + outputEl.appendChild(E('a', { 'href': this.href })); + + dom.append(outputEl.lastChild || outputEl, + this.rawhtml ? value : [ value ]); + + return E([ + outputEl, + hiddenEl.render() + ]); + }, + + /** @override */ + remove: function() {}, + + /** @override */ + write: function() {} +}); + +/** + * @class ButtonValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DummyValue` element wraps an {@link LuCI.ui.Hiddenfield} widget and + * renders the underlying UCI option or default value as readonly text. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIButtonValue = CBIValue.extend(/** @lends LuCI.form.ButtonValue.prototype */ { + __name__: 'CBI.ButtonValue', + + /** + * Override the rendered button caption. + * + * By default, the option title - which is passed as fourth argument to the + * constructor - is used as caption for the button element. When setting + * this property to a string, it is used as `String.format()` pattern with + * the underlying UCI section name passed as first format argument. When + * set to a function, it is invoked passing the section ID as sole argument + * and the resulting return value is converted to a string before being + * used as button caption. + * + * The default is `null`, means the option title is used as caption. + * + * @name LuCI.form.ButtonValue.prototype#inputtitle + * @type string|function + * @default null + */ + + /** + * Override the button style class. + * + * By setting this property, a specific `cbi-button-*` CSS class can be + * selected to influence the style of the resulting button. + * + * Suitable values which are implemented by most themes are `positive`, + * `negative` and `primary`. + * + * The default is `null`, means a neutral button styling is used. + * + * @name LuCI.form.ButtonValue.prototype#inputstyle + * @type string + * @default null + */ + + /** + * Override the button click action. + * + * By default, the underlying UCI option (or default property) value is + * copied into a hidden field tied to the button element and the save + * action is triggered on the parent form element. + * + * When this property is set to a function, it is invoked instead of + * performing the default actions. The handler function will receive the + * DOM click element as first and the underlying configuration section ID + * as second argument. + * + * @name LuCI.form.ButtonValue.prototype#onclick + * @type function + * @default null + */ + + /** @private */ + 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'), + btn_title = this.titleFn('inputtitle', section_id) || this.titleFn('title', section_id); + + if (value !== false) + dom.content(outputEl, [ + E('button', { + 'class': 'cbi-button cbi-button-%s'.format(this.inputstyle || 'button'), + 'click': ui.createHandlerFn(this, function(section_id, ev) { + if (this.onclick) + return this.onclick(ev, section_id); + + ev.currentTarget.parentNode.nextElementSibling.value = value; + return this.map.save(); + }, section_id), + 'disabled': ((this.readonly != null) ? this.readonly : this.map.readonly) || null + }, [ btn_title ]) + ]); + else + dom.content(outputEl, ' - '); + + return E([ + outputEl, + hiddenEl.render() + ]); + } +}); + +/** + * @class HiddenValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `HiddenValue` element wraps an {@link LuCI.ui.Hiddenfield} widget. + * + * Hidden value widgets used to be necessary in legacy code which actually + * submitted the underlying HTML form the server. With client side handling of + * forms, there are more efficient ways to store hidden state data. + * + * Since this widget has no visible content, the title and description values + * of this form element should be set to `null` as well to avoid a broken or + * distorted form layout when rendering the option element. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIHiddenValue = CBIValue.extend(/** @lends LuCI.form.HiddenValue.prototype */ { + __name__: 'CBI.HiddenValue', + + /** @private */ + 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(); + } +}); + +/** + * @class FileUpload + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `FileUpload` element wraps an {@link LuCI.ui.FileUpload} widget and + * offers the ability to browse, upload and select remote files. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype */ { + __name__: 'CBI.FileSelect', + + __init__: function(/* ... */) { + this.super('__init__', arguments); + + this.show_hidden = false; + this.enable_upload = true; + this.enable_remove = true; + this.root_directory = '/etc/luci-uploads'; + }, + + /** + * Toggle display of hidden files. + * + * Display hidden files when rendering the remote directory listing. + * Note that this is merely a cosmetic feature, hidden files are always + * included in received remote file listings. + * + * The default is `false`, means hidden files are not displayed. + * + * @name LuCI.form.FileUpload.prototype#show_hidden + * @type boolean + * @default false + */ + + /** + * Toggle file upload functionality. + * + * When set to `true`, the underlying widget provides a button which lets + * the user select and upload local files to the remote system. + * Note that this is merely a cosmetic feature, remote upload access is + * controlled by the session ACL rules. + * + * The default is `true`, means file upload functionality is displayed. + * + * @name LuCI.form.FileUpload.prototype#enable_upload + * @type boolean + * @default true + */ + + /** + * Toggle remote file delete functionality. + * + * When set to `true`, the underlying widget provides a buttons which let + * the user delete files from remote directories. Note that this is merely + * a cosmetic feature, remote delete permissions are controlled by the + * session ACL rules. + * + * The default is `true`, means file removal buttons are displayed. + * + * @name LuCI.form.FileUpload.prototype#enable_remove + * @type boolean + * @default true + */ + + /** + * Specify the root directory for file browsing. + * + * This property defines the topmost directory the file browser widget may + * navigate to, the UI will not allow browsing directories outside this + * prefix. Note that this is merely a cosmetic feature, remote file access + * and directory listing permissions are controlled by the session ACL + * rules. + * + * The default is `/etc/luci-uploads`. + * + * @name LuCI.form.FileUpload.prototype#root_directory + * @type string + * @default /etc/luci-uploads + */ + + /** @private */ + renderWidget: function(section_id, option_index, cfgvalue) { + var browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id), + name: this.cbid(section_id), + show_hidden: this.show_hidden, + enable_upload: this.enable_upload, + enable_remove: this.enable_remove, + root_directory: this.root_directory, + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return browserEl.render(); + } +}); + +/** + * @class SectionValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `SectionValue` widget embeds a form section element within an option + * element container, allowing to nest form sections into other sections. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The internal name of the option element holding the section. Since a section + * container element does not read or write any configuration itself, the name + * is only used internally and does not need to relate to any underlying UCI + * option name. + * + * @param {LuCI.form.AbstractSection} subsection_class + * The class to use for instantiating the nested section element. Note that + * the class value itself is expected here, not a class instance obtained by + * calling `new`. The given class argument must be a subclass of the + * `AbstractSection` class. + * + * @param {...*} [class_args] + * All further arguments are passed as-is to the subclass constructor. Refer + * to the corresponding class constructor documentations for details. + */ +var CBISectionValue = CBIValue.extend(/** @lends LuCI.form.SectionValue.prototype */ { + __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)); + this.subsection.parentoption = this; + }, + + /** + * Access the embedded section instance. + * + * This property holds a reference to the instantiated nested section. + * + * @name LuCI.form.SectionValue.prototype#subsection + * @type LuCI.form.AbstractSection + * @readonly + */ + + /** @override */ + load: function(section_id) { + return this.subsection.load(section_id); + }, + + /** @override */ + parse: function(section_id) { + return this.subsection.parse(section_id); + }, + + /** @private */ + renderWidget: function(section_id, option_index, cfgvalue) { + return this.subsection.render(section_id); + }, + + /** @private */ + checkDepends: function(section_id) { + this.subsection.checkDepends(section_id); + return CBIValue.prototype.checkDepends.apply(this, [ section_id ]); + }, + + /** + * Since the section container is not rendering an own widget, + * its `value()` implementation is a no-op. + * + * @override + */ + value: function() {}, + + /** + * Since the section container is not tied to any UCI configuration, + * its `write()` implementation is a no-op. + * + * @override + */ + write: function() {}, + + /** + * Since the section container is not tied to any UCI configuration, + * its `remove()` implementation is a no-op. + * + * @override + */ + remove: function() {}, + + /** + * Since the section container is not tied to any UCI configuration, + * its `cfgvalue()` implementation will always return `null`. + * + * @override + * @returns {null} + */ + cfgvalue: function() { return null }, + + /** + * Since the section container is not tied to any UCI configuration, + * its `formvalue()` implementation will always return `null`. + * + * @override + * @returns {null} + */ + formvalue: function() { return null } +}); + +/** + * @class form + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The LuCI form class provides high level abstractions for creating creating + * UCI- or JSON backed configurations forms. + * + * To import the class in views, use `'require form'`, to import it in + * external JavaScript, use `L.require("form").then(...)`. + * + * A typical form is created by first constructing a + * {@link LuCI.form.Map} or {@link LuCI.form.JSONMap} instance using `new` and + * by subsequently adding sections and options to it. Finally + * [render()]{@link LuCI.form.Map#render} is invoked on the instance to + * assemble the HTML markup and insert it into the DOM. + * + * Example: + * + *
+ * 'use strict';
+ * 'require form';
+ *
+ * var m, s, o;
+ *
+ * m = new form.Map('example', 'Example form',
+ *	'This is an example form mapping the contents of /etc/config/example');
+ *
+ * s = m.section(form.NamedSection, 'first_section', 'example', 'The first section',
+ * 	'This sections maps "config example first_section" of /etc/config/example');
+ *
+ * o = s.option(form.Flag, 'some_bool', 'A checkbox option');
+ *
+ * o = s.option(form.ListValue, 'some_choice', 'A select element');
+ * o.value('choice1', 'The first choice');
+ * o.value('choice2', 'The second choice');
+ *
+ * m.render().then(function(node) {
+ * 	document.body.appendChild(node);
+ * });
+ * 
+ */ +return baseclass.extend(/** @lends LuCI.form.prototype */ { + Map: CBIMap, + JSONMap: CBIJSONMap, + AbstractSection: CBIAbstractSection, + AbstractValue: CBIAbstractValue, + + TypedSection: CBITypedSection, + TableSection: CBITableSection, + GridSection: CBIGridSection, + NamedSection: CBINamedSection, + + Value: CBIValue, + DynamicList: CBIDynamicList, + ListValue: CBIListValue, + Flag: CBIFlagValue, + MultiValue: CBIMultiValue, + TextValue: CBITextValue, + DummyValue: CBIDummyValue, + Button: CBIButtonValue, + HiddenValue: CBIHiddenValue, + FileUpload: CBIFileUpload, + SectionValue: CBISectionValue +}); diff --git a/htdocs/luci-static/resources/fs.js b/htdocs/luci-static/resources/fs.js new file mode 100644 index 0000000..99defb7 --- /dev/null +++ b/htdocs/luci-static/resources/fs.js @@ -0,0 +1,429 @@ +'use strict'; +'require rpc'; +'require request'; +'require baseclass'; + +/** + * @typedef {Object} FileStatEntry + * @memberof LuCI.fs + + * @property {string} name - Name of the directory entry + * @property {string} type - Type of the entry, one of `block`, `char`, `directory`, `fifo`, `symlink`, `file`, `socket` or `unknown` + * @property {number} size - Size in bytes + * @property {number} mode - Access permissions + * @property {number} atime - Last access time in seconds since epoch + * @property {number} mtime - Last modification time in seconds since epoch + * @property {number} ctime - Last change time in seconds since epoch + * @property {number} inode - Inode number + * @property {number} uid - Numeric owner id + * @property {number} gid - Numeric group id + */ + +/** + * @typedef {Object} FileExecResult + * @memberof LuCI.fs + * + * @property {number} code - The exit code of the invoked command + * @property {string} [stdout] - The stdout produced by the command, if any + * @property {string} [stderr] - The stderr produced by the command, if any + */ + +var callFileList, callFileStat, callFileRead, callFileWrite, callFileRemove, + callFileExec, callFileMD5; + +callFileList = rpc.declare({ + object: 'file', + method: 'list', + params: [ 'path' ] +}); + +callFileStat = rpc.declare({ + object: 'file', + method: 'stat', + params: [ 'path' ] +}); + +callFileRead = rpc.declare({ + object: 'file', + method: 'read', + params: [ 'path' ] +}); + +callFileWrite = rpc.declare({ + object: 'file', + method: 'write', + params: [ 'path', 'data', 'mode' ] +}); + +callFileRemove = rpc.declare({ + object: 'file', + method: 'remove', + params: [ 'path' ] +}); + +callFileExec = rpc.declare({ + object: 'file', + method: 'exec', + params: [ 'command', 'params', 'env' ] +}); + +callFileMD5 = rpc.declare({ + object: 'file', + method: 'md5', + params: [ 'path' ] +}); + +var rpcErrors = [ + null, + 'InvalidCommandError', + 'InvalidArgumentError', + 'MethodNotFoundError', + 'NotFoundError', + 'NoDataError', + 'PermissionError', + 'TimeoutError', + 'UnsupportedError' +]; + +function handleRpcReply(expect, rc) { + if (typeof(rc) == 'number' && rc != 0) { + var e = new Error(rpc.getStatusText(rc)); e.name = rpcErrors[rc] || 'Error'; + throw e; + } + + if (expect) { + var type = Object.prototype.toString; + + for (var key in expect) { + if (rc != null && key != '') + rc = rc[key]; + + if (rc == null || type.call(rc) != type.call(expect[key])) { + var e = new Error(_('Unexpected reply data format')); e.name = 'TypeError'; + throw e; + } + + break; + } + } + + return rc; +} + +function handleCgiIoReply(res) { + if (!res.ok || res.status != 200) { + var e = new Error(res.statusText); + switch (res.status) { + case 400: + e.name = 'InvalidArgumentError'; + break; + + case 403: + e.name = 'PermissionError'; + break; + + case 404: + e.name = 'NotFoundError'; + break; + + default: + e.name = 'Error'; + } + throw e; + } + + switch (this.type) { + case 'blob': + return res.blob(); + + case 'json': + return res.json(); + + default: + return res.text(); + } +} + +/** + * @class fs + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * Provides high level utilities to wrap file system related RPC calls. + * To import the class in views, use `'require fs'`, to import it in + * external JavaScript, use `L.require("fs").then(...)`. + */ +var FileSystem = baseclass.extend(/** @lends LuCI.fs.prototype */ { + /** + * Obtains a listing of the specified directory. + * + * @param {string} path + * The directory path to list. + * + * @returns {Promise} + * Returns a promise resolving to an array of stat detail objects or + * rejecting with an error stating the failure reason. + */ + list: function(path) { + return callFileList(path).then(handleRpcReply.bind(this, { entries: [] })); + }, + + /** + * Return file stat information on the specified path. + * + * @param {string} path + * The filesystem path to stat. + * + * @returns {Promise} + * Returns a promise resolving to a stat detail object or + * rejecting with an error stating the failure reason. + */ + stat: function(path) { + return callFileStat(path).then(handleRpcReply.bind(this, { '': {} })); + }, + + /** + * Read the contents of the given file and return them. + * Note: this function is unsuitable for obtaining binary data. + * + * @param {string} path + * The file path to read. + * + * @returns {Promise} + * Returns a promise resolving to a string containing the file contents or + * rejecting with an error stating the failure reason. + */ + read: function(path) { + return callFileRead(path).then(handleRpcReply.bind(this, { data: '' })); + }, + + /** + * Write the given data to the specified file path. + * If the specified file path does not exist, it will be created, given + * sufficient permissions. + * + * Note: `data` will be converted to a string using `String(data)` or to + * `''` when it is `null`. + * + * @param {string} path + * The file path to write to. + * + * @param {*} [data] + * The file data to write. If it is null, it will be set to an empty + * string. + * + * @param {number} [mode] + * The permissions to use on file creation. Default is 420 (0644). + * + * @returns {Promise} + * Returns a promise resolving to `0` or rejecting with an error stating + * the failure reason. + */ + write: function(path, data, mode) { + data = (data != null) ? String(data) : ''; + mode = (mode != null) ? mode : 420; // 0644 + return callFileWrite(path, data, mode).then(handleRpcReply.bind(this, { '': 0 })); + }, + + /** + * Unlink the given file. + * + * @param {string} + * The file path to remove. + * + * @returns {Promise} + * Returns a promise resolving to `0` or rejecting with an error stating + * the failure reason. + */ + remove: function(path) { + return callFileRemove(path).then(handleRpcReply.bind(this, { '': 0 })); + }, + + /** + * Execute the specified command, optionally passing params and + * environment variables. + * + * Note: The `command` must be either the path to an executable, + * or a basename without arguments in which case it will be searched + * in $PATH. If specified, the values given in `params` will be passed + * as arguments to the command. + * + * The key/value pairs in the optional `env` table are translated to + * `setenv()` calls prior to running the command. + * + * @param {string} command + * The command to invoke. + * + * @param {string[]} [params] + * The arguments to pass to the command. + * + * @param {Object.} [env] + * Environment variables to set. + * + * @returns {Promise} + * Returns a promise resolving to an object describing the execution + * results or rejecting with an error stating the failure reason. + */ + exec: function(command, params, env) { + if (!Array.isArray(params)) + params = null; + + if (!L.isObject(env)) + env = null; + + return callFileExec(command, params, env).then(handleRpcReply.bind(this, { '': {} })); + }, + + /** + * Read the contents of the given file, trim leading and trailing white + * space and return the trimmed result. In case of errors, return an empty + * string instead. + * + * Note: this function is useful to read single-value files in `/sys` + * or `/proc`. + * + * This function is guaranteed to not reject its promises, on failure, + * an empty string will be returned. + * + * @param {string} path + * The file path to read. + * + * @returns {Promise} + * Returns a promise resolving to the file contents or the empty string + * on failure. + */ + trimmed: function(path) { + return L.resolveDefault(this.read(path), '').then(function(s) { + return s.trim(); + }); + }, + + /** + * Read the contents of the given file, split it into lines, trim + * leading and trailing white space of each line and return the + * resulting array. + * + * This function is guaranteed to not reject its promises, on failure, + * an empty array will be returned. + * + * @param {string} path + * The file path to read. + * + * @returns {Promise} + * Returns a promise resolving to an array containing the stripped lines + * of the given file or `[]` on failure. + */ + lines: function(path) { + return L.resolveDefault(this.read(path), '').then(function(s) { + var lines = []; + + s = s.trim(); + + if (s != '') { + var l = s.split(/\n/); + + for (var i = 0; i < l.length; i++) + lines.push(l[i].trim()); + } + + return lines; + }); + }, + + /** + * Read the contents of the given file and return them, bypassing ubus. + * + * This function will read the requested file through the cgi-io + * helper applet at `/cgi-bin/cgi-download` which bypasses the ubus rpc + * transport. This is useful to fetch large file contents which might + * exceed the ubus message size limits or which contain binary data. + * + * The cgi-io helper will enforce the same access permission rules as + * the ubus based read call. + * + * @param {string} path + * The file path to read. + * + * @param {string} [type=text] + * The expected type of read file contents. Valid values are `text` to + * interpret the contents as string, `json` to parse the contents as JSON + * or `blob` to return the contents as Blob instance. + * + * @returns {Promise<*>} + * Returns a promise resolving with the file contents interpreted according + * to the specified type or rejecting with an error stating the failure + * reason. + */ + read_direct: function(path, type) { + var postdata = 'sessionid=%s&path=%s' + .format(encodeURIComponent(L.env.sessionid), encodeURIComponent(path)); + + return request.post(L.env.cgi_base + '/cgi-download', postdata, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + responseType: (type == 'blob') ? 'blob' : 'text' + }).then(handleCgiIoReply.bind({ type: type })); + }, + + /** + * Execute the specified command, bypassing ubus. + * + * Note: The `command` must be either the path to an executable, + * or a basename without arguments in which case it will be searched + * in $PATH. If specified, the values given in `params` will be passed + * as arguments to the command. + * + * This function will invoke the requested commands through the cgi-io + * helper applet at `/cgi-bin/cgi-exec` which bypasses the ubus rpc + * transport. This is useful to fetch large command outputs which might + * exceed the ubus message size limits or which contain binary data. + * + * The cgi-io helper will enforce the same access permission rules as + * the ubus based exec call. + * + * @param {string} command + * The command to invoke. + * + * @param {string[]} [params] + * The arguments to pass to the command. + * + * @param {string} [type=text] + * The expected output type of the invoked program. Valid values are + * `text` to interpret the output as string, `json` to parse the output + * as JSON or `blob` to return the output as Blob instance. + * + * @param {boolean} [latin1=false] + * Whether to encode the command line as Latin1 instead of UTF-8. This + * is usually not needed but can be useful for programs that cannot + * handle UTF-8 input. + * + * @returns {Promise<*>} + * Returns a promise resolving with the command stdout output interpreted + * according to the specified type or rejecting with an error stating the + * failure reason. + */ + exec_direct: function(command, params, type, latin1) { + var cmdstr = String(command) + .replace(/\\/g, '\\\\').replace(/(\s)/g, '\\$1'); + + if (Array.isArray(params)) + for (var i = 0; i < params.length; i++) + cmdstr += ' ' + String(params[i]) + .replace(/\\/g, '\\\\').replace(/(\s)/g, '\\$1'); + + if (latin1) + cmdstr = escape(cmdstr).replace(/\+/g, '%2b'); + else + cmdstr = encodeURIComponent(cmdstr); + + var postdata = 'sessionid=%s&command=%s' + .format(encodeURIComponent(L.env.sessionid), cmdstr); + + return request.post(L.env.cgi_base + '/cgi-exec', postdata, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + responseType: (type == 'blob') ? 'blob' : 'text' + }).then(handleCgiIoReply.bind({ type: type })); + } +}); + +return FileSystem; diff --git a/htdocs/luci-static/resources/icons/alias.png b/htdocs/luci-static/resources/icons/alias.png new file mode 100644 index 0000000000000000000000000000000000000000..94d556afa8dad4108c2b1ce0cbad57f642191dfe GIT binary patch literal 651 zcmV;60(AX}P)G&~454|g0rbqY+ZEGR86 zCC5AF%dPEAWF5OwFgZDawvJZNbN148Jbn2FkDk4Ry#qzxJYQ~VtzShT!WS(eB?c>N z3utPqLqT*uw3RLr&cK#(BjT3%a(#2{Qh}tjctlQ4HoChzQQugL0rKIr^)%t+;t6|4 zca)J=XliMgFV{9yP0@d4UhwnJ5BuVhJhXQ2}vg&r95*7$6J4@6xR->+AhFZdk^4#2Fwv3dZvLWk`i-!6duyeAK z{~d?exM)Jm94pK8JQMP)kfbtk;e(vpo2S<$?%cV-Id|^hN0Za%e%J^-;`eaXA}wN|@em(H4ME*AeTSh;aTt%bUns+6iKYwm{xahfH9`>%3}eRem~ZIDO= z+zEF@aQDe27oGA!F|ia#vw~hDMNqazVdDhn4R;F=GH$@^1BWj5T+(T9iyke|qr~8T zGnTA9yyf)LeZxx(XUgh>7i;$@nRjTzAtRRV-g$BN#XT1{otis$)Y3zPM>QJ7`iuK6 z?#aM=GjrLAo{QV}RFDw`7d4t#VBTvivMrn^_ny zBR`^Npyh~fP;QljSlO7l)JUpPSyh4Rnkq=8HLPCF>XnF#kA{kxPzX>QJaho-H*Z46 zZe5X?or#B!?^C%#2BO2R1Lg&NhFyDh!`;gh+?{*3A(crWQrRR16B-_dF%u?Y`s_Iv zI$|Wa*Bj{SYgvMciiyU6Aww{J@?;DfH4+@ZnOIdI2IJ=8i51HRWA(}rP*w>BBcqFtaqa%+1S2 zW@Z`^l9CY;76waeYrMn@wm~(tbTrfgg8Xpx`Z?UVbp<*38Pwgw1)PF^;@tVOEe{;r zk89Vi;LwqMIC<(AT-}{<>+W^R|3Fo|qD??hAes(ZirBP5DpOSQO<`!Pt8ZYWyNbBD zJGw_(3n(TRL)l6G2|apVrn z5IP|{GZ{1IOfTg9jb?ha)eMkgf_nUI)~rLmWu0>=KRu0%O=|P5ul2&9flI6k%LMKf z=&&@bC;6T+g5d6}ZF8aSCxO7d5^8=mQ1g{wja@EAZV2c?ep2nWv+U=L5QN9~SRHQM gajwGx-ben2S9XK521e@QOaK4?07*qoM6N<$f~q|*1poj5 literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/bridge_disabled.png b/htdocs/luci-static/resources/icons/bridge_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..dce152a1dc852fdb47b6607117280b0d2f68955f GIT binary patch literal 385 zcmV-{0e=38P)oGxM{~ z8?&$Mp~ZujCniWF@krdO8SEgzA>kZ4oMD(s5@o|<`i3MW*r35i!YLE4D?lGd+Hr#M z0wlmQmhzQ8R6`+wFzQi_P5?*%MH`yZ7!Y{!gZW(KAsvWSg0hwQN_8d@ffouz>oTsY zD;4>x5~)y52x&!Y`p}UfR3TJJ8Er^~VfywwtqGF^7_l=Z-#o$ z%qh1|^U6>EpSv|1cOuydN&%PbZd{@{<~QFgW;G% zFtpw!{gtnfv2y literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/ethernet.png b/htdocs/luci-static/resources/icons/ethernet.png new file mode 100644 index 0000000000000000000000000000000000000000..e7d37504aae0ef5fe3c2954b907c41a5941d1cd0 GIT binary patch literal 664 zcmV;J0%!e+P)6~!={!RD1byF@r#X#!+*(N0>Frn2g){(AjmDCL{x-d9Xf;OSvt>yKrqlZ zgpQ6jc%E0iS6!Jvgkl5$kpN``=xAx<$dO}k_3}d3?mhA4>o*XQ`WpjE5)kBa0lGSR z7&Ue>JUzVe>NSUJSFZpdRMMbk}9P|y-0c3!e>ng=^b%p>dS|DOfE)t6& z5{Xoegm=UWYHZ4uu2_mDO`E7Q0OcJdkupP;Pw#Tk-WW$uaefI|WsE`siX4uU2Cf61e~9cCIz9 zMru~ZLw-V zxxPELHlKL(P5T)qrFBOhcpB7ZjTclT_1t=3_?jD}7v8ly9NU}tw%Sz9r{&tQbw}>M z-evAt>M{R3^;~eC+D<$6x$2;utHau_-#%jP_2J>|*H?#%mXnXskd;@d$ASygef~M> zweW)azR{>XAHC~N-r`buXb(Va1tlSE)?3vYzOP1Hx6O&j#yqfgoNJBo%ROt2o8nP@ yrkh<1gmT{WBNI>cX9r$f}dHcNjn&xrBl^b2- zC?RwpuVi>bQ*$jjs&+*BFo`}Dx#a-BzMD1?dxP^rqN3IvWgzaExkq|NigmS zo$qc}?`#H)Bb-AVqU{qLlN^$N_?-TF=rrTt*0?WQ9lBg00009mk)%ByVmI6GFHrBot#n64OX#lwk?TC4pdolmdpDpq4;E13@H+B?$?a zfR&p-5nW(l!{w|hN&$Z~%g9tB!2H8Qqwn78e?m(!jz&i4HKJm>j*e&_pq zp9D#~XsenDm|&R*Mn*XXo zXOYBCv@#bQe?5P0uNUD^c|uecaLe-o)rS&uYv^Sdaa0Q*%20MI>mnf>9^Kd9{kzEi z!4rvgYwytT$q{-dfAm7>`sPGkeM3psZxgF{M(T9^=k3s?mfSC?+5Wj-?~Kda20E(} zvT}HisJr3^J5#%LHIIl-mAPMOcw3)E>GD>pPVH%2-nT$U-wyLUHe2%n*s|k)X7>d? z$m}J^>@Jkhm6bBE<6Qksb(%%3nSSq7S}&t8GMUY#hxM%IVo33wMc!8L(9dyp_deOT z(+ay%2F=$v)ArWQL=Sl+LYQmK5VIETk~tO|4qP#x`M3T4>mMOEP;IUvn#`6eV|{ha zv8Lvh)`W_~l>bJsng<<_e%08(5xOQ7seTUGWjT%0ANVzgVixsuqR*`0sF%B?nD!*W z?UyBEJt3&gTn!QBVxGYL2KD3SIF*r&u4b`-dj<7_yR%a3fI+rF zJg{&Bf*Pe({{rJ|^oCs_3hUk6>tWnn&s);aF^P?s{YVSsWSNI;E>O2FH&0f)J>>X_ zf3_`H0>;Zktd6DELfB}+zH`J>3s({AVDlO}GCuGGz6gCEzwgZ8q;JR<@WZ6w_wi!G zd?w}*-n#1+iKF!3c3oL~{xL`&ZsB;zaVAGE-@ArsUA7M;)6NgB3@lcBdAzzj`PR|z zYQtX1fngH8yF;tluOY#_RM=0 zN8D+40jlpWR$@&1qXpifkF2BUApw&Nop$h55hazT9j%6C*|F_J=O@$d7v~Dq$r+XJ z{8TC)&M-glz8YC9a=2EUTL&CL_tB4{l>$9FJ%t^1sn5 z4C%?e%570*dj!QS{mlMh+pA~WP?i{{A8F&MhfYqhjdOJmVO}fRdF81^y{RW?8{;rc zE!F#(sF>2X>dXzbl~lqp-=kax7A{J`!^KH!d~nG7^hE*!=)%>iK%#Z(*VpFe^vuzt z8Y>iLC7W+&Yw6e8nr`k8xLJM(cC>QIaLlK2ySvI6cUdJ5CuQPCwo2t5fzQM(s_ZE2 z;6J*Kb2_*C9}K6YkDffw`qSy1opgorU=g*F7D>Zkt3^Q{g{={Q5cn@8DMti`a^`Q? z&U=8S9J89t5SkrKxkKF*YB>XfX(Opm80h&67(OL=b}?WR{P1p{W4{l({^#gRFYYg96bnnSK)2shuMFKyudSlG1c>~SVI;74b(f`tz!cOnAULT7 z#-wl`(_>E?C-bvUhBD%N_K)BA8%f9{lYei}FPFMj}=j!I>=;h_-C}Rf`4~X|EMW#0eZ9EyqlYagYis`fY54{iZ;XuDy++bZqJZlKueoxet%jjFaknl?S9v8 zZNPxoe1E?K;74G2n-{Ico=;i3@=kM|E^7Zn_%E$Kk_5J_+`TY6o?(40z zyt%x%-QClOiigzL+mVuvqotyesl<$_%a^FMIa7E#Sa(TZbTC0%AT2*0FiCB1Y%w%8 zd3$($i<)(kvx1VI%F4l?xYlorsl3C>FJ+}hbFV>dsXJ+;R)Dueb*f2ttv6tmY?a7j zjJ;fiwj@e;d7jH%g06$6(l}p;gs#+$uGV^+!XLYGkN^MyFG)l}RCwBj$yI{nAQS+= z2!SU^82APehzKEkVVx3vUsxuhwjm1&3eC=; zNOLb^6Ol57Fvb8t0iaC!b0Y{vIEd1wyRnrN$lM_4^>GPLY-Az*=y|RRevDv;%7yHNK_R(@!>9K>c5d>eRv)y4lTE00OHt`%A zG4y&8b~2jxlw&Egd*ST&zFJH+=iO+u0kY0YA(eC7cD?JQRa?hQ8 zXlEWhy0!N1{hMc!D_5>esiTdVIcwhWZCiF`ZpXIWS!-nDNLv9yRgnsg6!;7p;1#=9 z{`LF&R8=AwGQcZt-?l5;x@E_I6DLfbuck-*gJqTQG0oW@XpTMzd6(0ib;Y%CgY1DiBrHy@8r^Ye3Lf z*n8pm+MO4loJoHDVVx8>D2?d@XQn+)!B<$ZaN4n*>*r@~-^Rt+i2dVIMF54pw&NR6wO+c<(5{?Dg8G0YNdVTyInc z0j8KQ1A^k(>gN8+^19O7(?1OX#Sk%lIRbl6-Z=%#0)P~bc+cSQb z>PR5G6EHVBv%9jiz8{#Gnq2~?GYgBv`m7`1ZGg1~))`WqldJ*6XAGD)jPQDM*tW}u ziUI@{&L~3!*%ow!;gQMh<;AuAK-eIUmB)XcJq4U+V))oVL$fVtjm}wan(7YMJSic=WyGI8Azcyl Y4Cz9)zp{TFm;e9(07*qoM6N<$f<=tIrvLx| literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/signal-25-50.png b/htdocs/luci-static/resources/icons/signal-25-50.png new file mode 100644 index 0000000000000000000000000000000000000000..43387ff6669dfb902f9f05d8b0981592e5f5fd83 GIT binary patch literal 454 zcmV;%0XhDOP)e(Ee(P0Rz4Jc>%U7(qbNb}D0M4Gi5R98J=H`DD5Q`9n2AIjbYlc zAHv7<#GUM>dgLJNq2Z^3J?j@eKYwIh0CVTBIw6cuL|N6(X9o>G71Ww6g_<=`Z-x5p z0#W+90I%T)m!5t&=qE$V+R?;ffLWD&08|A*uXBWL>*wA+cX(|8myc}>@}=fvVl`kL z7IHYkg!x`{85?cpY^7}=Uuzpugd&7*Vh;s=4SX(WS`znfpk)O?^+yN^$8ZD?QuSeG zj()^e>uW*NvOzJ4MhHQQ&J0TW(}GkwFa1O5c>ho;(cPa&)@$`}Xe6D0VxeriyL%E| wHk%vO)6<)P-rn9g3FrLPo90aaPg4>61pKJT=fyc#VE_OC07*qoM6N<$g7{U^i~s-t literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/signal-50-75.png b/htdocs/luci-static/resources/icons/signal-50-75.png new file mode 100644 index 0000000000000000000000000000000000000000..48eeaa831b874dd38363b8faec6e3c461d0e9cbd GIT binary patch literal 454 zcmV;%0XhDOP)kUk?gRAb~i`&Z3=XyYZh~!> zt{?OxhoQ_v5+H6Pbjcp@Zwf4&GW_z6m6KDoaLTZaNZcua0^-Lfw*voKAS;RAWd_ve z+RFD6pw-SE0qF|6FWy_G<9d}bp>|b-Ec(qXN)uQUJ-5Q_DMOBLUok0Fd)H4(-+m~2 zX;)=@cMr*p?`ecZP4xOx4eD)H1Imv{R=)+57X9%MwLoc*4Kk?8uqKIL!CGu5IqsfM zCZey0SXJK&l!ibHBTL5*Z-GgEo*;hl@-2VJ%~Jpg;xGvEEKeRje)&vq=6Rje#y->T w8hzno&}@Ek_rfp`WO@Vb=-bk-i~4Bd7bj+__5y6r>i_@%07*qoM6N<$g4*NM8vpdVe^7G?p3h=F;C;O6JAkA2DW=gUDP0pb?u zaHoJ|L<>i@Z~1cj%(g%|vVHCf61N38^;Y0ig8~~9S6#V^NptC`fC$<%P?1*9|8OK( zHea(Uge>~*vx>~)?shch@7uod#jVp@1Lgjuok67@FW0O})|Fk!kvlW+f+!^_HB(S3 zHU*Tvn;d@k+Cpar{{?Y|><%bNRt!vh3we%ON#`a6@iRc?mLN$wf;iI*Q1s{N-U2d7 zcLlnL1#|mu-7|5%q1LdrR=2j%3?WIgx_dW`Mg(z^HtG#)^t)+ViIOaA8uy=xn^Bzn i_S;+KSUn>v9q2dVIMF54pw&NR6wQV0Bc7Oh2ekZ>e&cu z+qP{4WsJH}9o21In^A4sX7p^E`2+e-+piM~)b?ZuqcK4&h;u&9u?@450AW5|osXLO5upjMf?s%FnMdICS*T?9VAv z7wM$5LPc6Ca?YMZ<&B%cRT&xhEP?)opk<5ZYlaUUwa(J4QPZL6TeqWh=N`z{uMwP* z9#X!0A9D8Wa~cWgp9P#V%>{#{Fi(QbC zvXUU_7nD4C3P}jUdiQ|sL;mrT2!8f5l=kn9`m2@#L4k0;ecy-*!Bs7*hK2DOXf2_X zg3~C*i~&$|>I`t>HY(b*tcad4)|rUM=K}}~<$`NJaNr!;z}O4|+qZ>_F_N)vGd%Yn zL-{iyg9nydapk=Spo~GZZkb3K7heQSFlWPT54`cQsBYSbM*g^40000< KMNUMnLSTYguOQ0+ literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/switch.png b/htdocs/luci-static/resources/icons/switch.png new file mode 100644 index 0000000000000000000000000000000000000000..5c780fe66fbfcf18b1ab845f4077aeebe65676bd GIT binary patch literal 656 zcmV;B0&o3^P)zOZ4z zvOE9*BPV|XV`XJQow{{Gv!>0El#~cHHFf0W=7ET?Wa(lwZ{7leC;&uY85RJ6z(5f4 zz;ir!FCWi>K!lsSt1xcNcy7z4Ey&EuM9UVf=*iQ^%-lJ13t|x!F#rfv+`N5*yu4k- z*!Xbr@$(YREDUMNv`ObsT~j9vJa_roZyGdfhZq{_OIq6MG=0{z1E{Xa`@ZO3mN?Rl zKh6{u8bmW@O_N7;%|9#ecST}PLss6DSvqg@<*8H096^b$tKRn?wDtSh*{9^Bx7^bX z+VfODbJaC9@8LUt_D7-V$E4M+{IsLSV-6ibw>>QfZU47;;5NyvRo7H)556*{qi;;< z*jrQD^;jo*(RukFqxbxly(+Q2GG@>3B`CD*feu~#W=%)mn$eLrCUpFrS<$@yx>DqT zRS%?kF1U0Q;M$|W$eq92FTbL)bn@Z<(@?6-uoaVgExq%l-}2iJ`!2b5uFdFeO8_(l qz@UP$`~P+txBqYN!CSvKLk0Qu-%WPGK!*YV0000l1IY4^CKcIcH1Mqfv1RK`fS zyk9N4i+4g3FGN?dPPhwaQBdv)S;kQEpesj^*hg2qvA~3aauO9}#-or3Os5`@QLIcr z)fh(&UDa!5!X#4UG-WuTq9ErDGPU(ibt0Q&w0(>$*Y+Lfmmy0`Mn@-ru}Ba8b=JbV zkFA!h)g(ds7%?zHql|gCKH2QE{A>Bwe$qIqpy-$7H{rkAV z!Ox9a0>I=(^M@^3uy_&xGy7dJ+;W>*0JFNzox5}2iC-r_AOCRb=H2+R{+;-C;O(~a)3%S9P7?rFIe1H>*$v58 i4F!o}mN$a=`6Ua-7<`KF%=DE20000I+qP{tXWO1K=DtANJ$2%X=jyM=I`_KN)YL5&7ni~4($7U& zG^(l!y+u3yQim?Vn>KhkW0!y{RH@RuT=e^N_xJVn3uF>8K}yRe(z|w$)w_q>zI~*2 zY}+UJS(*vrnl_Q#wu{2rUYNzjEJ_*;VG{L<){$Tta>waq1v lZvMze$9tfk*Z~b@?+55Yqv4$cDJlQ}002ovPDHLkV1j0spgaHo literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/tunnel_disabled.png b/htdocs/luci-static/resources/icons/tunnel_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..406b3508c1fe9f5adee1509d6f0950cc1d826638 GIT binary patch literal 234 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6HhQ`^hE&`t?cbVsI6%O`Ik?50 zJ2dm^(o;+~6?13&Z}l~vv2*j@1}oc*eii;}n#;45^q&YuUamN(D9xvDV)M#8kSQj$ zLHyjd^X1YRO!0pLKmX_z`eQ$n<)QS#D{4L!#{%*e^G&(^LM-G)N2Q`mE|346W1Ppb zZgX3m37oCZbKaA^t>neUl|=$dtF~PTkaj&2m3<`edz16FD{3~=x8x?THGOc&etme> i<=%J6Eg|d+dBkh7ryVbFESUpzJcFmJpUXO@geCy1TV8em literal 0 HcmV?d00001 diff --git a/htdocs/luci-static/resources/icons/vlan.png b/htdocs/luci-static/resources/icons/vlan.png new file mode 100644 index 0000000000000000000000000000000000000000..5c780fe66fbfcf18b1ab845f4077aeebe65676bd GIT binary patch literal 656 zcmV;B0&o3^P)zOZ4z zvOE9*BPV|XV`XJQow{{Gv!>0El#~cHHFf0W=7ET?Wa(lwZ{7leC;&uY85RJ6z(5f4 zz;ir!FCWi>K!lsSt1xcNcy7z4Ey&EuM9UVf=*iQ^%-lJ13t|x!F#rfv+`N5*yu4k- z*!Xbr@$(YREDUMNv`ObsT~j9vJa_roZyGdfhZq{_OIq6MG=0{z1E{Xa`@ZO3mN?Rl zKh6{u8bmW@O_N7;%|9#ecST}PLss6DSvqg@<*8H096^b$tKRn?wDtSh*{9^Bx7^bX z+VfODbJaC9@8LUt_D7-V$E4M+{IsLSV-6ibw>>QfZU47;;5NyvRo7H)556*{qi;;< z*jrQD^;jo*(RukFqxbxly(+Q2GG@>3B`CD*feu~#W=%)mn$eLrCUpFrS<$@yx>DqT zRS%?kF1U0Q;M$|W$eq92FTbL)bn@Z<(@?6-uoaVgExq%l-}2iJ`!2b5uFdFeO8_(l qz@UP$`~P+txBqYN!CSvKLk0Qu-%WPGK!*YV0000l1IY4^CKcIcH1Mqfv1RK`fS zyk9N4i+4g3FGN?dPPhwaQBdv)S;kQEpesj^*hg2qvA~3aauO9}#-or3Os5`@QLIcr z)fh(&UDa!5!X#4UG-WuTq9ErDGPU(ibt0Q&w0(>$*Y+Lfmmy0`Mn@-ru}Ba8b=JbV zkFA!h)g(ds7%?zHql|gCKH2QE{A>Bwe$qIqpy-$7H{rkAV z!Ox9a0>I=(^M@^3uy_&xGy7dJ+;W>*0JFNzox5}2iC-r_AOCRb=H2+R{+;-C;O(~a)3%S9P7?rFIe1H>*$v58 i4F!o}mN$a=`6Ua-7<`KF%=DE20000oFL5hrmz)T;R8m1)is&4Bis%3*E%Dj^L@Shz!jhs>WH04_*`T*rA| zC&N@bx{XKHvnEv8zrIL4xU~)ocWCgC`LhE~iA0_|q!j)gmq`vkpH+#qqIYAOm}}*< zdU*E~x3bkHud2Pwgs~Wg%-CTuj>5o~(|I7w!ZN z9x}+HgDwN^s3j$u#`fww&`R0UiELkdWtPj@hZT{ELky|7q>@8eAwh$O46^7j0Gt#J z^3e!tHW6_yD|n=zMb2TV`_;8|B&O|5iWb3aZc!<@gr)4mLk3xNn7?yLE_%curqi9< zBBOg|Vu)nT{aFR4WVWy9SPNO6J)ZW@enc4Xi5F9X4R$f9;(cz8%Tjt^8 zzBV~ELGlVRGe7?PL+HKxcdMT~eN>^QuCx&p;!pVnIaE-XM?=Gd#3#UeiTO8X=0+_+ zAp!LC`QvAtC;sCh&N{fMp$25h6zdIWjTjGF4O)#D4`>zZWy$ZyeuwNW06pzw>zGXfF%< zA4giylzCBXsxI@)>7&1z^}e_Racb5d{hv9h%M_c+ybv@YSW-GYXu_wWWTY_m2ziIe zM@9-ON+x{LgD5RnN=wUcQ;^pDr2EMXCdvkv@c?oLF!^NmNmp9)>ybyOX`NX~aAR_3 zL)re3`?aj)$o;bYoeho2CBb!Op{4+mLL`;pP`7UfPxBhn#6`E5DEuNQ zhzWD6d${k>HO^fss5s2IRWU(Oz~8R%47;8rr(_3Yt;^eUlla%l=s6zYZC>Rm?z^0PIW-Y>bLv(Cg{^I1F&D_Rogff1qY?-?gXb4#-K^e-B1J?UY->PZG|M&u; WB(pm1} properties + * An object describing the properties to add to the new + * subclass. + * + * @returns {LuCI.baseclass} + * Returns a new LuCI.baseclass sublassed from this class, extended + * by the given properties and with its prototype set to this base + * class to enable inheritance. The resulting value represents a + * class constructor and can be instantiated with `new`. + */ + extend: function(properties) { + var props = { + __id__: { value: classIndex }, + __base__: { value: this.prototype }, + __name__: { value: properties.__name__ || 'anonymous' + classIndex++ } + }; + + 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; + }, + + /** + * Extends this base class with the properties described in + * `properties`, instantiates the resulting subclass using + * the additional optional arguments passed to this function + * and returns the resulting subclassed Class instance. + * + * This function serves as a convenience shortcut for + * {@link LuCI.baseclass.extend Class.extend()} and subsequent + * `new`. + * + * @memberof LuCI.baseclass + * + * @param {Object} properties + * An object describing the properties to add to the new + * subclass. + * + * @param {...*} [new_args] + * Specifies arguments to be passed to the subclass constructor + * as-is in order to instantiate the new subclass. + * + * @returns {LuCI.baseclass} + * Returns a new LuCI.baseclass instance extended by the given + * properties with its prototype set to this base class to + * enable inheritance. + */ + singleton: function(properties /*, ... */) { + return Class.extend(properties) + .instantiate(Class.prototype.varargs(arguments, 1)); + }, + + /** + * Calls the class constructor using `new` with the given argument + * array being passed as variadic parameters to the constructor. + * + * @memberof LuCI.baseclass + * + * @param {Array<*>} params + * An array of arbitrary values which will be passed as arguments + * to the constructor function. + * + * @param {...*} [new_args] + * Specifies arguments to be passed to the subclass constructor + * as-is in order to instantiate the new subclass. + * + * @returns {LuCI.baseclass} + * Returns a new LuCI.baseclass instance extended by the given + * properties with its prototype set to this base class to + * enable inheritance. + */ + instantiate: function(args) { + return new (Function.prototype.bind.apply(this, + Class.prototype.varargs(args, 0, null)))(); + }, + + /* unused */ + 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)); + }, + + /** + * Checks whether the given class value is a subclass of this class. + * + * @memberof LuCI.baseclass + * + * @param {LuCI.baseclass} classValue + * The class object to test. + * + * @returns {boolean} + * Returns `true` when the given `classValue` is a subclass of this + * class or `false` if the given value is not a valid class or not + * a subclass of this class'. + */ + isSubclass: function(classValue) { + return (classValue != null && + typeof(classValue) == 'function' && + classValue.prototype instanceof this); + }, + + prototype: { + /** + * Extract all values from the given argument array beginning from + * `offset` and prepend any further given optional parameters to + * the beginning of the resulting array copy. + * + * @memberof LuCI.baseclass + * @instance + * + * @param {Array<*>} args + * The array to extract the values from. + * + * @param {number} offset + * The offset from which to extract the values. An offset of `0` + * would copy all values till the end. + * + * @param {...*} [extra_args] + * Extra arguments to add to prepend to the resultung array. + * + * @returns {Array<*>} + * Returns a new array consisting of the optional extra arguments + * and the values extracted from the `args` array beginning with + * `offset`. + */ + varargs: function(args, offset /*, ... */) { + return Array.prototype.slice.call(arguments, 2) + .concat(Array.prototype.slice.call(args, offset)); + }, + + /** + * Walks up the parent class chain and looks for a class member + * called `key` in any of the parent classes this class inherits + * from. Returns the member value of the superclass or calls the + * member as function and returns its return value when the + * optional `callArgs` array is given. + * + * This function has two signatures and is sensitive to the + * amount of arguments passed to it: + * - `super('key')` - + * Returns the value of `key` when found within one of the + * parent classes. + * - `super('key', ['arg1', 'arg2'])` - + * Calls the `key()` method with parameters `arg1` and `arg2` + * when found within one of the parent classes. + * + * @memberof LuCI.baseclass + * @instance + * + * @param {string} key + * The name of the superclass member to retrieve. + * + * @param {Array<*>} [callArgs] + * An optional array of function call parameters to use. When + * this parameter is specified, the found member value is called + * as function using the values of this array as arguments. + * + * @throws {ReferenceError} + * Throws a `ReferenceError` when `callArgs` are specified and + * the found member named by `key` is not a function value. + * + * @returns {*|null} + * Returns the value of the found member or the return value of + * the call to the found method. Returns `null` when no member + * was found in the parent class chain or when the call to the + * superclass method returned `null`. + */ + super: function(key, callArgs) { + if (key == null) + return null; + + var slotIdx = this.__id__ + '.' + key, + symStack = superContext[slotIdx], + protoCtx = null; + + for (protoCtx = Object.getPrototypeOf(symStack ? symStack[0] : Object.getPrototypeOf(this)); + protoCtx != null && !protoCtx.hasOwnProperty(key); + protoCtx = Object.getPrototypeOf(protoCtx)) {} + + if (protoCtx == null) + return null; + + var res = protoCtx[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); + + if (symStack) + symStack.unshift(protoCtx); + else + superContext[slotIdx] = [ protoCtx ]; + + res = res.apply(this, callArgs); + + if (symStack && symStack.length > 1) + symStack.shift(protoCtx); + else + delete superContext[slotIdx]; + } + + return res; + }, + + /** + * Returns a string representation of this class. + * + * @returns {string} + * Returns a string representation of this class containing the + * constructor functions `displayName` and describing the class + * members and their respective types. + */ + 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 ? '' : '}'); + } + } + }); + + + /** + * @class headers + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `Headers` class is an internal utility class exposed in HTTP + * response objects using the `response.headers` property. + */ + var Headers = Class.extend(/** @lends LuCI.headers.prototype */ { + __name__: 'LuCI.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(); + }); + }, + + /** + * Checks whether the given header name is present. + * Note: Header-Names are case-insensitive. + * + * @instance + * @memberof LuCI.headers + * @param {string} name + * The header name to check + * + * @returns {boolean} + * Returns `true` if the header name is present, `false` otherwise + */ + has: function(name) { + return this.headers.hasOwnProperty(String(name).toLowerCase()); + }, + + /** + * Returns the value of the given header name. + * Note: Header-Names are case-insensitive. + * + * @instance + * @memberof LuCI.headers + * @param {string} name + * The header name to read + * + * @returns {string|null} + * The value of the given header name or `null` if the header isn't present. + */ + get: function(name) { + var key = String(name).toLowerCase(); + return this.headers.hasOwnProperty(key) ? this.headers[key] : null; + } + }); + + /** + * @class response + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `Response` class is an internal utility class representing HTTP responses. + */ + var Response = Class.extend({ + __name__: 'LuCI.response', + __init__: function(xhr, url, duration, headers, content) { + /** + * Describes whether the response is successful (status codes `200..299`) or not + * @instance + * @memberof LuCI.response + * @name ok + * @type {boolean} + */ + this.ok = (xhr.status >= 200 && xhr.status <= 299); + + /** + * The numeric HTTP status code of the response + * @instance + * @memberof LuCI.response + * @name status + * @type {number} + */ + this.status = xhr.status; + + /** + * The HTTP status description message of the response + * @instance + * @memberof LuCI.response + * @name statusText + * @type {string} + */ + this.statusText = xhr.statusText; + + /** + * The HTTP headers of the response + * @instance + * @memberof LuCI.response + * @name headers + * @type {LuCI.headers} + */ + this.headers = (headers != null) ? headers : new Headers(xhr); + + /** + * The total duration of the HTTP request in milliseconds + * @instance + * @memberof LuCI.response + * @name duration + * @type {number} + */ + this.duration = duration; + + /** + * The final URL of the request, i.e. after following redirects. + * @instance + * @memberof LuCI.response + * @name url + * @type {string} + */ + this.url = url; + + /* privates */ + this.xhr = xhr; + + if (content instanceof Blob) { + this.responseBlob = content; + this.responseJSON = null; + this.responseText = null; + } + else if (content != null && typeof(content) == 'object') { + this.responseBlob = null; + this.responseJSON = content; + this.responseText = null; + } + else if (content != null) { + this.responseBlob = null; + this.responseJSON = null; + this.responseText = String(content); + } + else { + this.responseJSON = null; + + if (xhr.responseType == 'blob') { + this.responseBlob = xhr.response; + this.responseText = null; + } + else { + this.responseBlob = null; + this.responseText = xhr.responseText; + } + } + }, + + /** + * Clones the given response object, optionally overriding the content + * of the cloned instance. + * + * @instance + * @memberof LuCI.response + * @param {*} [content] + * Override the content of the cloned response. Object values will be + * treated as JSON response data, all other types will be converted + * using `String()` and treated as response text. + * + * @returns {LuCI.response} + * The cloned `Response` instance. + */ + 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; + }, + + /** + * Access the response content as JSON data. + * + * @instance + * @memberof LuCI.response + * @throws {SyntaxError} + * Throws `SyntaxError` if the content isn't valid JSON. + * + * @returns {*} + * The parsed JSON data. + */ + json: function() { + if (this.responseJSON == null) + this.responseJSON = JSON.parse(this.responseText); + + return this.responseJSON; + }, + + /** + * Access the response content as string. + * + * @instance + * @memberof LuCI.response + * @returns {string} + * The response content. + */ + text: function() { + if (this.responseText == null && this.responseJSON != null) + this.responseText = JSON.stringify(this.responseJSON); + + return this.responseText; + }, + + /** + * Access the response content as blob. + * + * @instance + * @memberof LuCI.response + * @returns {Blob} + * The response content as blob. + */ + blob: function() { + return this.responseBlob; + } + }); + + + 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); + }); + } + + /** + * @class request + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `Request` class allows initiating HTTP requests and provides utilities + * for dealing with responses. + */ + var Request = Class.singleton(/** @lends LuCI.request.prototype */ { + __name__: 'LuCI.request', + + interceptors: [], + + /** + * Turn the given relative URL into an absolute URL if necessary. + * + * @instance + * @memberof LuCI.request + * @param {string} url + * The URL to convert. + * + * @returns {string} + * The absolute URL derived from the given one, or the original URL + * if it already was absolute. + */ + expandURL: function(url) { + if (!/^(?:[^/]+:)?\/\//.test(url)) + url = location.protocol + '//' + location.host + url; + + return url; + }, + + /** + * @typedef {Object} RequestOptions + * @memberof LuCI.request + * + * @property {string} [method=GET] + * The HTTP method to use, e.g. `GET` or `POST`. + * + * @property {Object} [query] + * Query string data to append to the URL. Non-string values of the + * given object will be converted to JSON. + * + * @property {boolean} [cache=false] + * Specifies whether the HTTP response may be retrieved from cache. + * + * @property {string} [username] + * Provides a username for HTTP basic authentication. + * + * @property {string} [password] + * Provides a password for HTTP basic authentication. + * + * @property {number} [timeout] + * Specifies the request timeout in milliseconds. + * + * @property {boolean} [credentials=false] + * Whether to include credentials such as cookies in the request. + * + * @property {string} [responseType=text] + * Overrides the request response type. Valid values or `text` to + * interpret the response as UTF-8 string or `blob` to handle the + * response as binary `Blob` data. + * + * @property {*} [content] + * Specifies the HTTP message body to send along with the request. + * If the value is a function, it is invoked and the return value + * used as content, if it is a FormData instance, it is used as-is, + * if it is an object, it will be converted to JSON, in all other + * cases it is converted to a string. + * + * @property {Object} [header] + * Specifies HTTP headers to set for the request. + * + * @property {function} [progress] + * An optional request callback function which receives ProgressEvent + * instances as sole argument during the HTTP request transfer. + */ + + /** + * Initiate an HTTP request to the given target. + * + * @instance + * @memberof LuCI.request + * @param {string} target + * The URL to request. + * + * @param {LuCI.request.RequestOptions} [options] + * Additional options to configure the request. + * + * @returns {Promise} + * The resulting HTTP response. + */ + request: function(target, options) { + return Promise.resolve(target).then((function(url) { + var state = { xhr: new XMLHttpRequest(), url: this.expandURL(url), 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 = opt.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(opt.xhr); + break; + + case 'object': + if (!(opt.content instanceof FormData)) { + content = JSON.stringify(opt.content); + contenttype = 'application/json'; + } + else { + content = opt.content; + } + 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 ('progress' in opt && 'upload' in opt.xhr) + opt.xhr.upload.addEventListener('progress', opt.progress); + + if (contenttype != null) + opt.xhr.setRequestHeader('Content-Type', contenttype); + + try { + opt.xhr.send(content); + } + catch (e) { + rejectFn.call(opt, e); + } + }); + }).bind(this)); + }, + + handleReadyStateChange: function(resolveFn, rejectFn, ev) { + var xhr = this.xhr, + duration = Date.now() - this.start; + + if (xhr.readyState !== 4) + return; + + if (xhr.status === 0 && xhr.statusText === '') { + if (duration >= this.timeout) + rejectFn.call(this, new Error('XHR request timed out')); + else + rejectFn.call(this, new Error('XHR request aborted by browser')); + } + else { + var response = new Response( + xhr, xhr.responseURL || this.url, duration); + + Promise.all(Request.interceptors.map(function(fn) { return fn(response) })) + .then(resolveFn.bind(this, response)) + .catch(rejectFn.bind(this)); + } + }, + + /** + * Initiate an HTTP GET request to the given target. + * + * @instance + * @memberof LuCI.request + * @param {string} target + * The URL to request. + * + * @param {LuCI.request.RequestOptions} [options] + * Additional options to configure the request. + * + * @returns {Promise} + * The resulting HTTP response. + */ + get: function(url, options) { + return this.request(url, Object.assign({ method: 'GET' }, options)); + }, + + /** + * Initiate an HTTP POST request to the given target. + * + * @instance + * @memberof LuCI.request + * @param {string} target + * The URL to request. + * + * @param {*} [data] + * The request data to send, see {@link LuCI.request.RequestOptions} for details. + * + * @param {LuCI.request.RequestOptions} [options] + * Additional options to configure the request. + * + * @returns {Promise} + * The resulting HTTP response. + */ + post: function(url, data, options) { + return this.request(url, Object.assign({ method: 'POST', content: data }, options)); + }, + + /** + * Interceptor functions are invoked whenever an HTTP reply is received, in the order + * these functions have been registered. + * @callback LuCI.request.interceptorFn + * @param {LuCI.response} res + * The HTTP response object + */ + + /** + * Register an HTTP response interceptor function. Interceptor + * functions are useful to perform default actions on incoming HTTP + * responses, such as checking for expired authentication or for + * implementing request retries before returning a failure. + * + * @instance + * @memberof LuCI.request + * @param {LuCI.request.interceptorFn} interceptorFn + * The interceptor function to register. + * + * @returns {LuCI.request.interceptorFn} + * The registered function. + */ + addInterceptor: function(interceptorFn) { + if (typeof(interceptorFn) == 'function') + this.interceptors.push(interceptorFn); + return interceptorFn; + }, + + /** + * Remove an HTTP response interceptor function. The passed function + * value must be the very same value that was used to register the + * function. + * + * @instance + * @memberof LuCI.request + * @param {LuCI.request.interceptorFn} interceptorFn + * The interceptor function to remove. + * + * @returns {boolean} + * Returns `true` if any function has been removed, else `false`. + */ + 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); + }, + + /** + * @class + * @memberof LuCI.request + * @hideconstructor + * @classdesc + * + * The `Request.poll` class provides some convience wrappers around + * {@link LuCI.poll} mainly to simplify registering repeating HTTP + * request calls as polling functions. + */ + poll: { + /** + * The callback function is invoked whenever an HTTP reply to a + * polled request is received or when the polled request timed + * out. + * + * @callback LuCI.request.poll~callbackFn + * @param {LuCI.response} res + * The HTTP response object. + * + * @param {*} data + * The response JSON if the response could be parsed as such, + * else `null`. + * + * @param {number} duration + * The total duration of the request in milliseconds. + */ + + /** + * Register a repeating HTTP request with an optional callback + * to invoke whenever a response for the request is received. + * + * @instance + * @memberof LuCI.request.poll + * @param {number} interval + * The poll interval in seconds. + * + * @param {string} url + * The URL to request on each poll. + * + * @param {LuCI.request.RequestOptions} [options] + * Additional options to configure the request. + * + * @param {LuCI.request.poll~callbackFn} [callback] + * {@link LuCI.request.poll~callbackFn Callback} function to + * invoke for each HTTP reply. + * + * @throws {TypeError} + * Throws `TypeError` when an invalid interval was passed. + * + * @returns {function} + * Returns the internally created poll function. + */ + 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 }); + + var fn = function() { + return Request.request(url, options).then(function(res) { + if (!Poll.active()) + return; + + var res_json = null; + try { + res_json = res.json(); + } + catch (err) {} + + callback(res, res_json, res.duration); + }); + }; + + return (Poll.add(fn, ival) ? fn : null); + }, + + /** + * Remove a polling request that has been previously added using `add()`. + * This function is essentially a wrapper around + * {@link LuCI.poll.remove LuCI.poll.remove()}. + * + * @instance + * @memberof LuCI.request.poll + * @param {function} entry + * The poll function returned by {@link LuCI.request.poll#add add()}. + * + * @returns {boolean} + * Returns `true` if any function has been removed, else `false`. + */ + remove: function(entry) { return Poll.remove(entry) }, + + /** + * Alias for {@link LuCI.poll.start LuCI.poll.start()}. + * + * @instance + * @memberof LuCI.request.poll + */ + start: function() { return Poll.start() }, + + /** + * Alias for {@link LuCI.poll.stop LuCI.poll.stop()}. + * + * @instance + * @memberof LuCI.request.poll + */ + stop: function() { return Poll.stop() }, + + /** + * Alias for {@link LuCI.poll.active LuCI.poll.active()}. + * + * @instance + * @memberof LuCI.request.poll + */ + active: function() { return Poll.active() } + } + }); + + /** + * @class poll + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `Poll` class allows registering and unregistering poll actions, + * as well as starting, stopping and querying the state of the polling + * loop. + */ + var Poll = Class.singleton(/** @lends LuCI.poll.prototype */ { + __name__: 'LuCI.poll', + + queue: [], + + /** + * Add a new operation to the polling loop. If the polling loop is not + * already started at this point, it will be implicitely started. + * + * @instance + * @memberof LuCI.poll + * @param {function} fn + * The function to invoke on each poll interval. + * + * @param {number} interval + * The poll interval in seconds. + * + * @throws {TypeError} + * Throws `TypeError` when an invalid interval was passed. + * + * @returns {boolean} + * Returns `true` if the function has been added or `false` if it + * already is registered. + */ + add: function(fn, interval) { + if (interval == null || interval <= 0) + interval = 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 an operation from the polling loop. If no further operatons + * are registered, the polling loop is implicitely stopped. + * + * @instance + * @memberof LuCI.poll + * @param {function} fn + * The function to remove. + * + * @throws {TypeError} + * Throws `TypeError` when the given argument isn't a function. + * + * @returns {boolean} + * Returns `true` if the function has been removed or `false` if it + * wasn't found. + */ + remove: function(fn) { + 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); + }, + + /** + * (Re)start the polling loop. Dispatches a custom `poll-start` event + * to the `document` object upon successful start. + * + * @instance + * @memberof LuCI.poll + * @returns {boolean} + * Returns `true` if polling has been started (or if no functions + * where registered) or `false` when the polling loop already runs. + */ + 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 the polling loop. Dispatches a custom `poll-stop` event + * to the `document` object upon successful stop. + * + * @instance + * @memberof LuCI.poll + * @returns {boolean} + * Returns `true` if polling has been stopped or `false` if it din't + * run to begin with. + */ + 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; + }, + + /* private */ + 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); + }, + + /** + * Test whether the polling loop is running. + * + * @instance + * @memberof LuCI.poll + * @returns {boolean} - Returns `true` if polling is active, else `false`. + */ + active: function() { + return (this.timer != null); + } + }); + + /** + * @class dom + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `dom` class provides convenience method for creating and + * manipulating DOM elements. + * + * To import the class in views, use `'require dom'`, to import it in + * external JavaScript, use `L.require("dom").then(...)`. + */ + var DOM = Class.singleton(/** @lends LuCI.dom.prototype */ { + __name__: 'LuCI.dom', + + /** + * Tests whether the given argument is a valid DOM `Node`. + * + * @instance + * @memberof LuCI.dom + * @param {*} e + * The value to test. + * + * @returns {boolean} + * Returns `true` if the value is a DOM `Node`, else `false`. + */ + elem: function(e) { + return (e != null && typeof(e) == 'object' && 'nodeType' in e); + }, + + /** + * Parses a given string as HTML and returns the first child node. + * + * @instance + * @memberof LuCI.dom + * @param {string} s + * A string containing an HTML fragment to parse. Note that only + * the first result of the resulting structure is returned, so an + * input value of `
foo
bar
` will only return + * the first `div` element node. + * + * @returns {Node} + * Returns the first DOM `Node` extracted from the HTML fragment or + * `null` on parsing failures or if no element could be found. + */ + parse: function(s) { + var elem = null; + + try { + domParser = domParser || new DOMParser(); + elem = domParser.parseFromString(s, 'text/html').body.firstChild; + } + catch(e) {} + + return elem; + }, + + /** + * Tests whether a given `Node` matches the given query selector. + * + * This function is a convenience wrapper around the standard + * `Node.matches("selector")` function with the added benefit that + * the `node` argument may be a non-`Node` value, in which case + * this function simply returns `false`. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to test the selector against. + * + * @param {string} [selector] + * The query selector expression to test against the given node. + * + * @returns {boolean} + * Returns `true` if the given node matches the specified selector + * or `false` when the node argument is no valid DOM `Node` or the + * selector didn't match. + */ + matches: function(node, selector) { + var m = this.elem(node) ? node.matches || node.msMatchesSelector : null; + return m ? m.call(node, selector) : false; + }, + + /** + * Returns the closest parent node that matches the given query + * selector expression. + * + * This function is a convenience wrapper around the standard + * `Node.closest("selector")` function with the added benefit that + * the `node` argument may be a non-`Node` value, in which case + * this function simply returns `null`. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to find the closest parent for. + * + * @param {string} [selector] + * The query selector expression to test against each parent. + * + * @returns {Node|null} + * Returns the closest parent node matching the selector or + * `null` when the node argument is no valid DOM `Node` or the + * selector didn't match any parent. + */ + parent: function(node, selector) { + if (this.elem(node) && node.closest) + return node.closest(selector); + + while (this.elem(node)) + if (this.matches(node, selector)) + return node; + else + node = node.parentNode; + + return null; + }, + + /** + * Appends the given children data to the given node. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to append the children to. + * + * @param {*} [children] + * The childrens to append to the given node. + * + * When `children` is an array, then each item of the array + * will be either appended as child element or text node, + * depending on whether the item is a DOM `Node` instance or + * some other non-`null` value. Non-`Node`, non-`null` values + * will be converted to strings first before being passed as + * argument to `createTextNode()`. + * + * When `children` is a function, it will be invoked with + * the passed `node` argument as sole parameter and the `append` + * function will be invoked again, with the given `node` argument + * as first and the return value of the `children` function as + * second parameter. + * + * When `children` is is a DOM `Node` instance, it will be + * appended to the given `node`. + * + * When `children` is any other non-`null` value, it will be + * converted to a string and appened to the `innerHTML` property + * of the given `node`. + * + * @returns {Node|null} + * Returns the last children `Node` appended to the node or `null` + * if either the `node` argument was no valid DOM `node` or if the + * `children` was `null` or didn't result in further DOM nodes. + */ + 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; + }, + + /** + * Replaces the content of the given node with the given children. + * + * This function first removes any children of the given DOM + * `Node` and then adds the given given children following the + * rules outlined below. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to replace the children of. + * + * @param {*} [children] + * The childrens to replace into the given node. + * + * When `children` is an array, then each item of the array + * will be either appended as child element or text node, + * depending on whether the item is a DOM `Node` instance or + * some other non-`null` value. Non-`Node`, non-`null` values + * will be converted to strings first before being passed as + * argument to `createTextNode()`. + * + * When `children` is a function, it will be invoked with + * the passed `node` argument as sole parameter and the `append` + * function will be invoked again, with the given `node` argument + * as first and the return value of the `children` function as + * second parameter. + * + * When `children` is is a DOM `Node` instance, it will be + * appended to the given `node`. + * + * When `children` is any other non-`null` value, it will be + * converted to a string and appened to the `innerHTML` property + * of the given `node`. + * + * @returns {Node|null} + * Returns the last children `Node` appended to the node or `null` + * if either the `node` argument was no valid DOM `node` or if the + * `children` was `null` or didn't result in further DOM nodes. + */ + 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); + }, + + /** + * Sets attributes or registers event listeners on element nodes. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to set the attributes or add the event + * listeners for. When the given `node` value is not a valid + * DOM `Node`, the function returns and does nothing. + * + * @param {string|Object} key + * Specifies either the attribute or event handler name to use, + * or an object containing multiple key, value pairs which are + * each added to the node as either attribute or event handler, + * depending on the respective value. + * + * @param {*} [val] + * Specifies the attribute value or event handler function to add. + * If the `key` parameter is an `Object`, this parameter will be + * ignored. + * + * When `val` is of type function, it will be registered as event + * handler on the given `node` with the `key` parameter being the + * event name. + * + * When `val` is of type object, it will be serialized as JSON and + * added as attribute to the given `node`, using the given `key` + * as attribute name. + * + * When `val` is of any other type, it will be added as attribute + * to the given `node` as-is, with the underlying `setAttribute()` + * call implicitely turning it into a string. + */ + 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]); + } + } + }, + + /** + * Creates a new DOM `Node` from the given `html`, `attr` and + * `data` parameters. + * + * This function has multiple signatures, it can be either invoked + * in the form `create(html[, attr[, data]])` or in the form + * `create(html[, data])`. The used variant is determined from the + * type of the second argument. + * + * @instance + * @memberof LuCI.dom + * @param {*} html + * Describes the node to create. + * + * When the value of `html` is of type array, a `DocumentFragment` + * node is created and each item of the array is first converted + * to a DOM `Node` by passing it through `create()` and then added + * as child to the fragment. + * + * When the value of `html` is a DOM `Node` instance, no new + * element will be created but the node will be used as-is. + * + * When the value of `html` is a string starting with `<`, it will + * be passed to `dom.parse()` and the resulting value is used. + * + * When the value of `html` is any other string, it will be passed + * to `document.createElement()` for creating a new DOM `Node` of + * the given name. + * + * @param {Object} [attr] + * Specifies an Object of key, value pairs to set as attributes + * or event handlers on the created node. Refer to + * {@link LuCI.dom#attr dom.attr()} for details. + * + * @param {*} [data] + * Specifies children to append to the newly created element. + * Refer to {@link LuCI.dom#append dom.append()} for details. + * + * @throws {InvalidCharacterError} + * Throws an `InvalidCharacterError` when the given `html` + * argument contained malformed markup (such as not escaped + * `&` characters in XHTML mode) or when the given node name + * in `html` contains characters which are not legal in DOM + * element names, such as spaces. + * + * @returns {Node} + * Returns the newly created `Node`. + */ + 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 { + elem = document.createElement(html); + } + + if (!elem) + return null; + + this.attr(elem, attr); + this.append(elem, data); + + return elem; + }, + + registry: {}, + + /** + * Attaches or detaches arbitrary data to and from a DOM `Node`. + * + * This function is useful to attach non-string values or runtime + * data that is not serializable to DOM nodes. To decouple data + * from the DOM, values are not added directly to nodes, but + * inserted into a registry instead which is then referenced by a + * string key stored as `data-idref` attribute in the node. + * + * This function has multiple signatures and is sensitive to the + * number of arguments passed to it. + * + * - `dom.data(node)` - + * Fetches all data associated with the given node. + * - `dom.data(node, key)` - + * Fetches a specific key associated with the given node. + * - `dom.data(node, key, val)` - + * Sets a specific key to the given value associated with the + * given node. + * - `dom.data(node, null)` - + * Clears any data associated with the node. + * - `dom.data(node, key, null)` - + * Clears the given key associated with the node. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to set or retrieve the data for. + * + * @param {string|null} [key] + * This is either a string specifying the key to retrieve, or + * `null` to unset the entire node data. + * + * @param {*|null} [val] + * This is either a non-`null` value to set for a given key or + * `null` to remove the given `key` from the specified node. + * + * @returns {*} + * Returns the get or set value, or `null` when no value could + * be found. + */ + data: function(node, key, val) { + if (!node || !node.getAttribute) + return null; + + 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; + } + + return null; + } + + /* 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; + } + + return 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)); + + node.setAttribute('data-idref', id); + this.registry[id] = {}; + } + + return (this.registry[id][key] = val); + } + + /* 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; + }, + + /** + * Binds the given class instance ot the specified DOM `Node`. + * + * This function uses the `dom.data()` facility to attach the + * passed instance of a Class to a node. This is needed for + * complex widget elements or similar where the corresponding + * class instance responsible for the element must be retrieved + * from DOM nodes obtained by `querySelector()` or similar means. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to bind the class to. + * + * @param {Class} inst + * The Class instance to bind to the node. + * + * @throws {TypeError} + * Throws a `TypeError` when the given instance argument isn't + * a valid Class instance. + * + * @returns {Class} + * Returns the bound class instance. + */ + bindClassInstance: function(node, inst) { + if (!(inst instanceof Class)) + LuCI.prototype.error('TypeError', 'Argument must be a class instance'); + + return this.data(node, '_class', inst); + }, + + /** + * Finds a bound class instance on the given node itself or the + * first bound instance on its closest parent node. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to start from. + * + * @returns {Class|null} + * Returns the founds class instance if any or `null` if no bound + * class could be found on the node itself or any of its parents. + */ + findClassInstance: function(node) { + var inst = null; + + do { + inst = this.data(node, '_class'); + node = node.parentNode; + } + while (!(inst instanceof Class) && node != null); + + return inst; + }, + + /** + * Finds a bound class instance on the given node itself or the + * first bound instance on its closest parent node and invokes + * the specified method name on the found class instance. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to start from. + * + * @param {string} method + * The name of the method to invoke on the found class instance. + * + * @param {...*} params + * Additional arguments to pass to the invoked method as-is. + * + * @returns {*|null} + * Returns the return value of the invoked method if a class + * instance and method has been found. Returns `null` if either + * no bound class instance could be found, or if the found + * instance didn't have the requested `method`. + */ + 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)); + }, + + /** + * The ignore callback function is invoked by `isEmpty()` for each + * child node to decide whether to ignore a child node or not. + * + * When this function returns `false`, the node passed to it is + * ignored, else not. + * + * @callback LuCI.dom~ignoreCallbackFn + * @param {Node} node + * The child node to test. + * + * @returns {boolean} + * Boolean indicating whether to ignore the node or not. + */ + + /** + * Tests whether a given DOM `Node` instance is empty or appears + * empty. + * + * Any element child nodes which have the CSS class `hidden` set + * or for which the optionally passed `ignoreFn` callback function + * returns `false` are ignored. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to test. + * + * @param {LuCI.dom~ignoreCallbackFn} [ignoreFn] + * Specifies an optional function which is invoked for each child + * node to decide whether the child node should be ignored or not. + * + * @returns {boolean} + * Returns `true` if the node does not have any children or if + * any children node either has a `hidden` CSS class or a `false` + * result when testing it using the given `ignoreFn`. + */ + isEmpty: function(node, ignoreFn) { + for (var child = node.firstElementChild; child != null; child = child.nextElementSibling) + if (!child.classList.contains('hidden') && (!ignoreFn || !ignoreFn(child))) + return false; + + return true; + } + }); + + /** + * @class session + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `session` class provides various session related functionality. + */ + var Session = Class.singleton(/** @lends LuCI.session.prototype */ { + __name__: 'LuCI.session', + + /** + * Retrieve the current session ID. + * + * @returns {string} + * Returns the current session ID. + */ + getID: function() { + return env.sessionid || '00000000000000000000000000000000'; + }, + + /** + * Retrieve the current session token. + * + * @returns {string|null} + * Returns the current session token or `null` if not logged in. + */ + getToken: function() { + return env.token || null; + }, + + /** + * Retrieve data from the local session storage. + * + * @param {string} [key] + * The key to retrieve from the session data store. If omitted, all + * session data will be returned. + * + * @returns {*} + * Returns the stored session data or `null` if the given key wasn't + * found. + */ + getLocalData: function(key) { + try { + var sid = this.getID(), + item = 'luci-session-store', + data = JSON.parse(window.sessionStorage.getItem(item)); + + if (!LuCI.prototype.isObject(data) || !data.hasOwnProperty(sid)) { + data = {}; + data[sid] = {}; + } + + if (key != null) + return data[sid].hasOwnProperty(key) ? data[sid][key] : null; + + return data[sid]; + } + catch (e) { + return (key != null) ? null : {}; + } + }, + + /** + * Set data in the local session storage. + * + * @param {string} key + * The key to set in the session data store. + * + * @param {*} value + * The value to store. It will be internally converted to JSON before + * being put in the session store. + * + * @returns {boolean} + * Returns `true` if the data could be stored or `false` on error. + */ + setLocalData: function(key, value) { + if (key == null) + return false; + + try { + var sid = this.getID(), + item = 'luci-session-store', + data = JSON.parse(window.sessionStorage.getItem(item)); + + if (!LuCI.prototype.isObject(data) || !data.hasOwnProperty(sid)) { + data = {}; + data[sid] = {}; + } + + if (value != null) + data[sid][key] = value; + else + delete data[sid][key]; + + window.sessionStorage.setItem(item, JSON.stringify(data)); + + return true; + } + catch (e) { + return false; + } + } + }); + + /** + * @class view + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `view` class forms the basis of views and provides a standard + * set of methods to inherit from. + */ + var View = Class.extend(/** @lends LuCI.view.prototype */ { + __name__: 'LuCI.view', + + __init__: function() { + var vp = document.getElementById('view'); + + DOM.content(vp, E('div', { 'class': 'spinning' }, _('Loading view…'))); + + return Promise.resolve(this.load()) + .then(LuCI.prototype.bind(this.render, this)) + .then(LuCI.prototype.bind(function(nodes) { + var vp = document.getElementById('view'); + + DOM.content(vp, nodes); + DOM.append(vp, this.addFooter()); + }, this)).catch(LuCI.prototype.error); + }, + + /** + * The load function is invoked before the view is rendered. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * The return value of the function (or the resolved values + * of the promise returned by it) will be passed as first + * argument to `render()`. + * + * This function is supposed to be overwritten by subclasses, + * the default implementation does nothing. + * + * @instance + * @abstract + * @memberof LuCI.view + * + * @returns {*|Promise<*>} + * May return any value or a Promise resolving to any value. + */ + load: function() {}, + + /** + * The render function is invoked after the + * {@link LuCI.view#load load()} function and responsible + * for setting up the view contents. It must return a DOM + * `Node` or `DocumentFragment` holding the contents to + * insert into the view area. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * The return value of the function (or the resolved values + * of the promise returned by it) will be inserted into the + * main content area using + * {@link LuCI.dom#append dom.append()}. + * + * This function is supposed to be overwritten by subclasses, + * the default implementation does nothing. + * + * @instance + * @abstract + * @memberof LuCI.view + * @param {*|null} load_results + * This function will receive the return value of the + * {@link LuCI.view#load view.load()} function as first + * argument. + * + * @returns {Node|Promise} + * Should return a DOM `Node` value or a `Promise` resolving + * to a `Node` value. + */ + render: function() {}, + + /** + * The handleSave function is invoked when the user clicks + * the `Save` button in the page action footer. + * + * The default implementation should be sufficient for most + * views using {@link form#Map form.Map()} based forms - it + * will iterate all forms present in the view and invoke + * the {@link form#Map#save Map.save()} method on each form. + * + * Views not using `Map` instances or requiring other special + * logic should overwrite `handleSave()` with a custom + * implementation. + * + * To disable the `Save` page footer button, views extending + * this base class should overwrite the `handleSave` function + * with `null`. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * @instance + * @memberof LuCI.view + * @param {Event} ev + * The DOM event that triggered the function. + * + * @returns {*|Promise<*>} + * Any return values of this function are discarded, but + * passed through `Promise.resolve()` to ensure that any + * returned promise runs to completion before the button + * is reenabled. + */ + handleSave: function(ev) { + var tasks = []; + + document.getElementById('maincontent') + .querySelectorAll('.cbi-map').forEach(function(map) { + tasks.push(DOM.callClassMethod(map, 'save')); + }); + + return Promise.all(tasks); + }, + + /** + * The handleSaveApply function is invoked when the user clicks + * the `Save & Apply` button in the page action footer. + * + * The default implementation should be sufficient for most + * views using {@link form#Map form.Map()} based forms - it + * will first invoke + * {@link LuCI.view.handleSave view.handleSave()} and then + * call {@link ui#changes#apply ui.changes.apply()} to start the + * modal config apply and page reload flow. + * + * Views not using `Map` instances or requiring other special + * logic should overwrite `handleSaveApply()` with a custom + * implementation. + * + * To disable the `Save & Apply` page footer button, views + * extending this base class should overwrite the + * `handleSaveApply` function with `null`. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * @instance + * @memberof LuCI.view + * @param {Event} ev + * The DOM event that triggered the function. + * + * @returns {*|Promise<*>} + * Any return values of this function are discarded, but + * passed through `Promise.resolve()` to ensure that any + * returned promise runs to completion before the button + * is reenabled. + */ + handleSaveApply: function(ev, mode) { + return this.handleSave(ev).then(function() { + classes.ui.changes.apply(mode == '0'); + }); + }, + + /** + * The handleReset function is invoked when the user clicks + * the `Reset` button in the page action footer. + * + * The default implementation should be sufficient for most + * views using {@link form#Map form.Map()} based forms - it + * will iterate all forms present in the view and invoke + * the {@link form#Map#save Map.reset()} method on each form. + * + * Views not using `Map` instances or requiring other special + * logic should overwrite `handleReset()` with a custom + * implementation. + * + * To disable the `Reset` page footer button, views extending + * this base class should overwrite the `handleReset` function + * with `null`. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * @instance + * @memberof LuCI.view + * @param {Event} ev + * The DOM event that triggered the function. + * + * @returns {*|Promise<*>} + * Any return values of this function are discarded, but + * passed through `Promise.resolve()` to ensure that any + * returned promise runs to completion before the button + * is reenabled. + */ + handleReset: function(ev) { + var tasks = []; + + document.getElementById('maincontent') + .querySelectorAll('.cbi-map').forEach(function(map) { + tasks.push(DOM.callClassMethod(map, 'reset')); + }); + + return Promise.all(tasks); + }, + + /** + * Renders a standard page action footer if any of the + * `handleSave()`, `handleSaveApply()` or `handleReset()` + * functions are defined. + * + * The default implementation should be sufficient for most + * views - it will render a standard page footer with action + * buttons labeled `Save`, `Save & Apply` and `Reset` + * triggering the `handleSave()`, `handleSaveApply()` and + * `handleReset()` functions respectively. + * + * When any of these `handle*()` functions is overwritten + * with `null` by a view extending this class, the + * corresponding button will not be rendered. + * + * @instance + * @memberof LuCI.view + * @returns {DocumentFragment} + * Returns a `DocumentFragment` containing the footer bar + * with buttons for each corresponding `handle*()` action + * or an empty `DocumentFragment` if all three `handle*()` + * methods are overwritten with `null`. + */ + addFooter: function() { + var footer = E([]), + vp = document.getElementById('view'), + hasmap = false, + readonly = true; + + vp.querySelectorAll('.cbi-map').forEach(function(map) { + var m = DOM.findClassInstance(map); + if (m) { + hasmap = true; + + if (!m.readonly) + readonly = false; + } + }); + + if (!hasmap) + readonly = !LuCI.prototype.hasViewPermission(); + + var saveApplyBtn = this.handleSaveApply ? new classes.ui.ComboButton('0', { + 0: [ _('Save & Apply') ], + 1: [ _('Apply unchecked') ] + }, { + classes: { + 0: 'btn cbi-button cbi-button-apply important', + 1: 'btn cbi-button cbi-button-negative important' + }, + click: classes.ui.createHandlerFn(this, 'handleSaveApply'), + disabled: readonly || null + }).render() : E([]); + + if (this.handleSaveApply || this.handleSave || this.handleReset) { + footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ + saveApplyBtn, ' ', + this.handleSave ? E('button', { + 'class': 'cbi-button cbi-button-save', + 'click': classes.ui.createHandlerFn(this, 'handleSave'), + 'disabled': readonly || null + }, [ _('Save') ]) : '', ' ', + this.handleReset ? E('button', { + 'class': 'cbi-button cbi-button-reset', + 'click': classes.ui.createHandlerFn(this, 'handleReset'), + 'disabled': readonly || null + }, [ _('Reset') ]) : '' + ])); + } + + return footer; + } + }); + + + var dummyElem = null, + domParser = null, + originalCBIInit = null, + rpcBaseURL = null, + sysFeatures = null, + preloadClasses = null; + + /* "preload" builtin classes to make the available via require */ + var classes = { + baseclass: Class, + dom: DOM, + poll: Poll, + request: Request, + session: Session, + view: View + }; + + var naturalCompare = new Intl.Collator(undefined, { numeric: true }).compare; + + var LuCI = Class.extend(/** @lends LuCI.prototype */ { + __name__: 'LuCI', + __init__: function(setenv) { + + document.querySelectorAll('script[src*="/luci.js"]').forEach(function(s) { + if (setenv.base_url == null || setenv.base_url == '') { + var m = (s.getAttribute('src') || '').match(/^(.*)\/luci\.js(?:\?v=([^?]+))?$/); + if (m) { + setenv.base_url = m[1]; + setenv.resource_version = m[2]; + } + } + }); + + if (setenv.base_url == null) + this.error('InternalError', 'Cannot find url of luci.js'); + + setenv.cgi_base = setenv.scriptname.replace(/\/[^\/]+$/, ''); + + Object.assign(env, setenv); + + 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() {}; + }, + + /** + * Captures the current stack trace and throws an error of the + * specified type as a new exception. Also logs the exception as + * error to the debug console if it is available. + * + * @instance + * @memberof LuCI + * + * @param {Error|string} [type=Error] + * Either a string specifying the type of the error to throw or an + * existing `Error` instance to copy. + * + * @param {string} [fmt=Unspecified error] + * A format string which is used to form the error message, together + * with all subsequent optional arguments. + * + * @param {...*} [args] + * Zero or more variable arguments to the supplied format string. + * + * @throws {Error} + * Throws the created error object with the captured stack trace + * appended to the message and the type set to the given type + * argument or copied from the given error instance. + */ + 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; + + if (msg) + e.message = msg + ': ' + e.message; + } + else { + try { throw new Error('stacktrace') } + catch (e2) { stack = (e2.stack || '').split(/\n/) } + + e = new (window[type || 'Error'] || Error)(msg || 'Unspecified error'); + e.name = type || 'Error'; + } + + stack = (stack || []).map(function(frame) { + frame = frame.replace(/(.*?)@(.+):(\d+):(\d+)/g, 'at $1 ($2:$3:$4)').trim(); + return frame ? ' ' + frame : ''; + }); + + if (!/^ at /.test(stack[0])) + stack.shift(); + + if (/\braise /.test(stack[0])) + stack.shift(); + + if (/\berror /.test(stack[0])) + stack.shift(); + + if (stack.length) + e.message += '\n' + stack.join('\n'); + + if (window.console && console.debug) + console.debug(e); + + throw e; + }, + + /** + * A wrapper around {@link LuCI#raise raise()} which also renders + * the error either as modal overlay when `ui.js` is already loaed + * or directly into the view body. + * + * @instance + * @memberof LuCI + * + * @param {Error|string} [type=Error] + * Either a string specifying the type of the error to throw or an + * existing `Error` instance to copy. + * + * @param {string} [fmt=Unspecified error] + * A format string which is used to form the error message, together + * with all subsequent optional arguments. + * + * @param {...*} [args] + * Zero or more variable arguments to the supplied format string. + * + * @throws {Error} + * Throws the created error object with the captured stack trace + * appended to the message and the type set to the given type + * argument or copied from the given error instance. + */ + error: function(type, fmt /*, ...*/) { + try { + LuCI.prototype.raise.apply(LuCI.prototype, + Array.prototype.slice.call(arguments)); + } + catch (e) { + if (!e.reported) { + if (classes.ui) + classes.ui.addNotification(e.name || _('Runtime error'), + E('pre', {}, e.message), 'danger'); + else + DOM.content(document.querySelector('#maincontent'), + E('pre', { 'class': 'alert-message error' }, e.message)); + + e.reported = true; + } + + throw e; + } + }, + + /** + * Return a bound function using the given `self` as `this` context + * and any further arguments as parameters to the bound function. + * + * @instance + * @memberof LuCI + * + * @param {function} fn + * The function to bind. + * + * @param {*} self + * The value to bind as `this` context to the specified function. + * + * @param {...*} [args] + * Zero or more variable arguments which are bound to the function + * as parameters. + * + * @returns {function} + * Returns the bound function. + */ + bind: function(fn, self /*, ... */) { + return Function.prototype.bind.apply(fn, this.varargs(arguments, 2, self)); + }, + + /** + * Load an additional LuCI JavaScript class and its dependencies, + * instantiate it and return the resulting class instance. Each + * class is only loaded once. Subsequent attempts to load the same + * class will return the already instantiated class. + * + * @instance + * @memberof LuCI + * + * @param {string} name + * The name of the class to load in dotted notation. Dots will + * be replaced by spaces and joined with the runtime-determined + * base URL of LuCI.js to form an absolute URL to load the class + * file from. + * + * @throws {DependencyError} + * Throws a `DependencyError` when the class to load includes + * circular dependencies. + * + * @throws {NetworkError} + * Throws `NetworkError` when the underlying {@link LuCI.request} + * call failed. + * + * @throws {SyntaxError} + * Throws `SyntaxError` when the loaded class file code cannot + * be interpreted by `eval`. + * + * @throws {TypeError} + * Throws `TypeError` when the class file could be loaded and + * interpreted, but when invoking its code did not yield a valid + * class instance. + * + * @returns {Promise} + * Returns the instantiated class. + */ + 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) + LuCI.prototype.raise('DependencyError', + 'Circular dependency: class "%s" depends on "%s"', + name, from.join('" which depends on "')); + + return Promise.resolve(classes[name]); + } + + url = '%s/%s.js%s'.format(env.base_url, name.replace(/\./g, '/'), (env.resource_version ? '?v=' + env.resource_version : '')); + from = [ name ].concat(from); + + var compileClass = function(res) { + if (!res.ok) + LuCI.prototype.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, prev = -1, quote = -1, comment = -1, esc = false; i < source.length; i++) { + var chr = source.charCodeAt(i); + + if (esc) { + esc = false; + } + else if (comment != -1) { + if ((comment == 47 && chr == 10) || (comment == 42 && prev == 42 && chr == 47)) + comment = -1; + } + else if ((chr == 42 || chr == 47) && prev == 47) { + comment = chr; + } + 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(LuCI.prototype.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; + } + + prev = 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) { + LuCI.prototype.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)) + LuCI.prototype.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) + rpcBaseURL = Session.getLocalData('rpcBaseURL'); + + if (rpcBaseURL == null) { + var msg = { + jsonrpc: '2.0', + id: 'init', + method: 'list', + params: undefined + }; + var rpcFallbackURL = this.url('admin/ubus'); + + rpcBaseURL = Request.post(env.ubuspath, msg, { nobatch: true }).then(function(res) { + return (rpcBaseURL = res.status == 200 ? env.ubuspath : rpcFallbackURL); + }, function() { + return (rpcBaseURL = rpcFallbackURL); + }).then(function(url) { + Session.setLocalData('rpcBaseURL', url); + return url; + }); + } + + return Promise.resolve(rpcBaseURL); + }, + + probeSystemFeatures: function() { + if (sysFeatures == null) + sysFeatures = Session.getLocalData('features'); + + if (!this.isObject(sysFeatures)) { + sysFeatures = classes.rpc.declare({ + object: 'luci', + method: 'getFeatures', + expect: { '': {} } + })().then(function(features) { + Session.setLocalData('features', features); + sysFeatures = features; + + return features; + }); + } + + return Promise.resolve(sysFeatures); + }, + + probePreloadClasses: function() { + if (preloadClasses == null) + preloadClasses = Session.getLocalData('preload'); + + if (!Array.isArray(preloadClasses)) { + preloadClasses = this.resolveDefault(classes.rpc.declare({ + object: 'file', + method: 'list', + params: [ 'path' ], + expect: { 'entries': [] } + })(this.fspath(this.resource('preload'))), []).then(function(entries) { + var classes = []; + + for (var i = 0; i < entries.length; i++) { + if (entries[i].type != 'file') + continue; + + var m = entries[i].name.match(/(.+)\.js$/); + + if (m) + classes.push('preload.%s'.format(m[1])); + } + + Session.setLocalData('preload', classes); + preloadClasses = classes; + + return classes; + }); + } + + return Promise.resolve(preloadClasses); + }, + + /** + * Test whether a particular system feature is available, such as + * hostapd SAE support or an installed firewall. The features are + * queried once at the beginning of the LuCI session and cached in + * `SessionStorage` throughout the lifetime of the associated tab or + * browser window. + * + * @instance + * @memberof LuCI + * + * @param {string} feature + * The feature to test. For detailed list of known feature flags, + * see `/modules/luci-base/root/usr/libexec/rpcd/luci`. + * + * @param {string} [subfeature] + * Some feature classes like `hostapd` provide sub-feature flags, + * such as `sae` or `11w` support. The `subfeature` argument can + * be used to query these. + * + * @return {boolean|null} + * Return `true` if the queried feature (and sub-feature) is available + * or `false` if the requested feature isn't present or known. + * Return `null` when a sub-feature was queried for a feature which + * has no sub-features. + */ + hasSystemFeature: function() { + var ft = sysFeatures[arguments[0]]; + + if (arguments.length == 2) + return this.isObject(ft) ? ft[arguments[1]] : null; + + return (ft != null && ft != false); + }, + + /* private */ + notifySessionExpiry: function() { + Poll.stop(); + + classes.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…'))) + ]); + + LuCI.prototype.raise('SessionError', 'Login session is expired'); + }, + + /* private */ + setupDOM: function(res) { + var domEv = res[0], + uiClass = res[1], + rpcClass = res[2], + formClass = res[3], + rpcBaseURL = res[4]; + + rpcClass.setBaseURL(rpcBaseURL); + + rpcClass.addInterceptor(function(msg, req) { + if (!LuCI.prototype.isObject(msg) || + !LuCI.prototype.isObject(msg.error) || + msg.error.code != -32002) + return; + + if (!LuCI.prototype.isObject(req) || + (req.object == 'session' && req.method == 'access')) + return; + + return rpcClass.declare({ + 'object': 'session', + 'method': 'access', + 'params': [ 'scope', 'object', 'function' ], + 'expect': { access: true } + })('uci', 'luci', 'read').catch(LuCI.prototype.notifySessionExpiry); + }); + + Request.addInterceptor(function(res) { + var isDenied = false; + + if (res.status == 403 && res.headers.get('X-LuCI-Login-Required') == 'yes') + isDenied = true; + + if (!isDenied) + return; + + LuCI.prototype.notifySessionExpiry(); + }); + + document.addEventListener('poll-start', function(ev) { + uiClass.showIndicator('poll-status', _('Refreshing'), function(ev) { + Request.poll.active() ? Request.poll.stop() : Request.poll.start(); + }); + }); + + document.addEventListener('poll-stop', function(ev) { + uiClass.showIndicator('poll-status', _('Paused'), null, 'inactive'); + }); + + return Promise.all([ + this.probeSystemFeatures(), + this.probePreloadClasses() + ]).finally(LuCI.prototype.bind(function() { + var tasks = []; + + if (Array.isArray(preloadClasses)) + for (var i = 0; i < preloadClasses.length; i++) + tasks.push(this.require(preloadClasses[i])); + + return Promise.all(tasks); + }, this)).finally(this.initDOM); + }, + + /* private */ + initDOM: function() { + originalCBIInit(); + Poll.start(); + document.dispatchEvent(new CustomEvent('luci-loaded')); + }, + + /** + * The `env` object holds environment settings used by LuCI, such + * as request timeouts, base URLs etc. + * + * @instance + * @memberof LuCI + */ + env: env, + + /** + * Construct an absolute filesystem path relative to the server + * document root. + * + * @instance + * @memberof LuCI + * + * @param {...string} [parts] + * An array of parts to join into a path. + * + * @return {string} + * Return the joined path. + */ + fspath: function(/* ... */) { + var path = env.documentroot; + + for (var i = 0; i < arguments.length; i++) + path += '/' + arguments[i]; + + var p = path.replace(/\/+$/, '').replace(/\/+/g, '/').split(/\//), + res = []; + + for (var i = 0; i < p.length; i++) + if (p[i] == '..') + res.pop(); + else if (p[i] != '.') + res.push(p[i]); + + return res.join('/'); + }, + + /** + * Construct a relative URL path from the given prefix and parts. + * The resulting URL is guaranteed to only contain the characters + * `a-z`, `A-Z`, `0-9`, `_`, `.`, `%`, `,`, `;`, and `-` as well + * as `/` for the path separator. + * + * @instance + * @memberof LuCI + * + * @param {string} [prefix] + * The prefix to join the given parts with. If the `prefix` is + * omitted, it defaults to an empty string. + * + * @param {string[]} [parts] + * An array of parts to join into an URL path. Parts may contain + * slashes and any of the other characters mentioned above. + * + * @return {string} + * Return the joined URL path. + */ + path: function(prefix, parts) { + var url = [ prefix || '' ]; + + for (var i = 0; i < parts.length; i++) + if (/^(?:[a-zA-Z0-9_.%,;-]+\/)*[a-zA-Z0-9_.%,;-]+$/.test(parts[i])) + url.push('/', parts[i]); + + if (url.length === 1) + url.push('/'); + + return url.join(''); + }, + + /** + * Construct an URL pathrelative to the script path of the server + * side LuCI application (usually `/cgi-bin/luci`). + * + * The resulting URL is guaranteed to only contain the characters + * `a-z`, `A-Z`, `0-9`, `_`, `.`, `%`, `,`, `;`, and `-` as well + * as `/` for the path separator. + * + * @instance + * @memberof LuCI + * + * @param {string[]} [parts] + * An array of parts to join into an URL path. Parts may contain + * slashes and any of the other characters mentioned above. + * + * @return {string} + * Returns the resulting URL path. + */ + url: function() { + return this.path(env.scriptname, arguments); + }, + + /** + * Construct an URL path relative to the global static resource path + * of the LuCI ui (usually `/luci-static/resources`). + * + * The resulting URL is guaranteed to only contain the characters + * `a-z`, `A-Z`, `0-9`, `_`, `.`, `%`, `,`, `;`, and `-` as well + * as `/` for the path separator. + * + * @instance + * @memberof LuCI + * + * @param {string[]} [parts] + * An array of parts to join into an URL path. Parts may contain + * slashes and any of the other characters mentioned above. + * + * @return {string} + * Returns the resulting URL path. + */ + resource: function() { + return this.path(env.resource, arguments); + }, + + /** + * Construct an URL path relative to the media resource path of the + * LuCI ui (usually `/luci-static/$theme_name`). + * + * The resulting URL is guaranteed to only contain the characters + * `a-z`, `A-Z`, `0-9`, `_`, `.`, `%`, `,`, `;`, and `-` as well + * as `/` for the path separator. + * + * @instance + * @memberof LuCI + * + * @param {string[]} [parts] + * An array of parts to join into an URL path. Parts may contain + * slashes and any of the other characters mentioned above. + * + * @return {string} + * Returns the resulting URL path. + */ + media: function() { + return this.path(env.media, arguments); + }, + + /** + * Return the complete URL path to the current view. + * + * @instance + * @memberof LuCI + * + * @return {string} + * Returns the URL path to the current view. + */ + location: function() { + return this.path(env.scriptname, env.requestpath); + }, + + + /** + * Tests whether the passed argument is a JavaScript object. + * This function is meant to be an object counterpart to the + * standard `Array.isArray()` function. + * + * @instance + * @memberof LuCI + * + * @param {*} [val] + * The value to test + * + * @return {boolean} + * Returns `true` if the given value is of type object and + * not `null`, else returns `false`. + */ + isObject: function(val) { + return (val != null && typeof(val) == 'object'); + }, + + /** + * Return an array of sorted object keys, optionally sorted by + * a different key or a different sorting mode. + * + * @instance + * @memberof LuCI + * + * @param {object} obj + * The object to extract the keys from. If the given value is + * not an object, the function will return an empty array. + * + * @param {string} [key] + * Specifies the key to order by. This is mainly useful for + * nested objects of objects or objects of arrays when sorting + * shall not be performed by the primary object keys but by + * some other key pointing to a value within the nested values. + * + * @param {string} [sortmode] + * May be either `addr` or `num` to override the natural + * lexicographic sorting with a sorting suitable for IP/MAC style + * addresses or numeric values respectively. + * + * @return {string[]} + * Returns an array containing the sorted keys of the given 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 naturalCompare(a[1], b[1]); + }).map(function(e) { + return e[0]; + }); + }, + + /** + * Compares two values numerically and returns -1, 0 or 1 depending + * on whether the first value is smaller, equal to or larger than the + * second one respectively. + * + * This function is meant to be used as comparator function for + * Array.sort(). + * + * @type {function} + * + * @param {*} a + * The first value + * + * @param {*} b + * The second value. + * + * @return {number} + * Returns -1 if the first value is smaller than the second one. + * Returns 0 if both values are equal. + * Returns 1 if the first value is larger than the second one. + */ + naturalCompare: naturalCompare, + + /** + * Converts the given value to an array using toArray() if needed, + * performs a numerical sort using naturalCompare() and returns the + * result. If the input already is an array, no copy is being made + * and the sorting is performed in-place. + * + * @see toArray + * @see naturalCompare + * + * @param {*} val + * The input value to sort (and convert to an array if needed). + * + * @return {Array<*>} + * Returns the resulting, numerically sorted array. + */ + sortedArray: function(val) { + return this.toArray(val).sort(naturalCompare); + }, + + /** + * Converts the given value to an array. If the given value is of + * type array, it is returned as-is, values of type object are + * returned as one-element array containing the object, empty + * strings and `null` values are returned as empty array, all other + * values are converted using `String()`, trimmed, split on white + * space and returned as array. + * + * @instance + * @memberof LuCI + * + * @param {*} val + * The value to convert into an array. + * + * @return {Array<*>} + * Returns the resulting array. + */ + 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+/); + }, + + /** + * Returns a promise resolving with either the given value or or with + * the given default in case the input value is a rejecting promise. + * + * @instance + * @memberof LuCI + * + * @param {*} value + * The value to resolve the promise with. + * + * @param {*} defvalue + * The default value to resolve the promise with in case the given + * input value is a rejecting promise. + * + * @returns {Promise<*>} + * Returns a new promise resolving either to the given input value or + * to the given default value on error. + */ + resolveDefault: function(value, defvalue) { + return Promise.resolve(value).catch(function() { return defvalue }); + }, + + /** + * The request callback function is invoked whenever an HTTP + * reply to a request made using the `L.get()`, `L.post()` or + * `L.poll()` function is timed out or received successfully. + * + * @instance + * @memberof LuCI + * + * @callback LuCI.requestCallbackFn + * @param {XMLHTTPRequest} xhr + * The XMLHTTPRequest instance used to make the request. + * + * @param {*} data + * The response JSON if the response could be parsed as such, + * else `null`. + * + * @param {number} duration + * The total duration of the request in milliseconds. + */ + + /** + * Issues a GET request to the given url and invokes the specified + * callback function. The function is a wrapper around + * {@link LuCI.request#request Request.request()}. + * + * @deprecated + * @instance + * @memberof LuCI + * + * @param {string} url + * The URL to request. + * + * @param {Object} [args] + * Additional query string arguments to append to the URL. + * + * @param {LuCI.requestCallbackFn} cb + * The callback function to invoke when the request finishes. + * + * @return {Promise} + * Returns a promise resolving to `null` when concluded. + */ + get: function(url, args, cb) { + return this.poll(null, url, args, cb, false); + }, + + /** + * Issues a POST request to the given url and invokes the specified + * callback function. The function is a wrapper around + * {@link LuCI.request#request Request.request()}. The request is + * sent using `application/x-www-form-urlencoded` encoding and will + * contain a field `token` with the current value of `LuCI.env.token` + * by default. + * + * @deprecated + * @instance + * @memberof LuCI + * + * @param {string} url + * The URL to request. + * + * @param {Object} [args] + * Additional post arguments to append to the request body. + * + * @param {LuCI.requestCallbackFn} cb + * The callback function to invoke when the request finishes. + * + * @return {Promise} + * Returns a promise resolving to `null` when concluded. + */ + post: function(url, args, cb) { + return this.poll(null, url, args, cb, true); + }, + + /** + * Register a polling HTTP request that invokes the specified + * callback function. The function is a wrapper around + * {@link LuCI.request.poll#add Request.poll.add()}. + * + * @deprecated + * @instance + * @memberof LuCI + * + * @param {number} interval + * The poll interval to use. If set to a value less than or equal + * to `0`, it will default to the global poll interval configured + * in `LuCI.env.pollinterval`. + * + * @param {string} url + * The URL to request. + * + * @param {Object} [args] + * Specifies additional arguments for the request. For GET requests, + * the arguments are appended to the URL as query string, for POST + * requests, they'll be added to the request body. + * + * @param {LuCI.requestCallbackFn} cb + * The callback function to invoke whenever a request finishes. + * + * @param {boolean} [post=false] + * When set to `false` or not specified, poll requests will be made + * using the GET method. When set to `true`, POST requests will be + * issued. In case of POST requests, the request body will contain + * an argument `token` with the current value of `LuCI.env.token` by + * default, regardless of the parameters specified with `args`. + * + * @return {function} + * Returns the internally created function that has been passed to + * {@link LuCI.request.poll#add Request.poll.add()}. This value can + * be passed to {@link LuCI.poll.remove Poll.remove()} to remove the + * polling request. + */ + poll: function(interval, url, args, cb, post) { + if (interval !== null && interval <= 0) + interval = env.pollinterval; + + var data = post ? { token: env.token } : null, + method = post ? 'POST' : 'GET'; + + if (!/^(?:\/|\S+:\/\/)/.test(url)) + url = this.url(url); + + if (args != null) + data = Object.assign(data || {}, args); + + if (interval !== null) + return Request.poll.add(interval, url, { method: method, query: data }, cb); + else + 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); + }); + }, + + /** + * Check whether a view has sufficient permissions. + * + * @return {boolean|null} + * Returns `null` if the current session has no permission at all to + * load resources required by the view. Returns `false` if readonly + * permissions are granted or `true` if at least one required ACL + * group is granted with write permissions. + */ + hasViewPermission: function() { + if (!this.isObject(env.nodespec) || !env.nodespec.satisfied) + return null; + + return !env.nodespec.readonly; + }, + + /** + * Deprecated wrapper around {@link LuCI.poll.remove Poll.remove()}. + * + * @deprecated + * @instance + * @memberof LuCI + * + * @param {function} entry + * The polling function to remove. + * + * @return {boolean} + * Returns `true` when the function has been removed or `false` if + * it could not be found. + */ + stop: function(entry) { return Poll.remove(entry) }, + + /** + * Deprecated wrapper around {@link LuCI.poll.stop Poll.stop()}. + * + * @deprecated + * @instance + * @memberof LuCI + * + * @return {boolean} + * Returns `true` when the polling loop has been stopped or `false` + * when it didn't run to begin with. + */ + halt: function() { return Poll.stop() }, + + /** + * Deprecated wrapper around {@link LuCI.poll.start Poll.start()}. + * + * @deprecated + * @instance + * @memberof LuCI + * + * @return {boolean} + * Returns `true` when the polling loop has been started or `false` + * when it was already running. + */ + run: function() { return Poll.start() }, + + /** + * Legacy `L.dom` class alias. New view code should use `'require dom';` + * to request the `LuCI.dom` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + dom: DOM, + + /** + * Legacy `L.view` class alias. New view code should use `'require view';` + * to request the `LuCI.view` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + view: View, + + /** + * Legacy `L.Poll` class alias. New view code should use `'require poll';` + * to request the `LuCI.poll` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + Poll: Poll, + + /** + * Legacy `L.Request` class alias. New view code should use `'require request';` + * to request the `LuCI.request` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + Request: Request, + + /** + * Legacy `L.Class` class alias. New view code should use `'require baseclass';` + * to request the `LuCI.baseclass` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + Class: Class + }); + + /** + * @class xhr + * @memberof LuCI + * @deprecated + * @classdesc + * + * The `LuCI.xhr` class is a legacy compatibility shim for the + * functionality formerly provided by `xhr.js`. It is registered as global + * `window.XHR` symbol for compatibility with legacy code. + * + * New code should use {@link LuCI.request} instead to implement HTTP + * request handling. + */ + var XHR = Class.extend(/** @lends LuCI.xhr.prototype */ { + __name__: 'LuCI.xhr', + __init__: function() { + if (window.console && console.debug) + console.debug('Direct use XHR() is deprecated, please use L.Request instead'); + }, + + _response: function(cb, res, json, duration) { + if (this.active) + cb(res, json, duration); + delete this.active; + }, + + /** + * This function is a legacy wrapper around + * {@link LuCI#get LuCI.get()}. + * + * @instance + * @deprecated + * @memberof LuCI.xhr + * + * @param {string} url + * The URL to request + * + * @param {Object} [data] + * Additional query string data + * + * @param {LuCI.requestCallbackFn} [callback] + * Callback function to invoke on completion + * + * @param {number} [timeout] + * Request timeout to use + * + * @return {Promise} + */ + get: function(url, data, callback, timeout) { + this.active = true; + LuCI.prototype.get(url, data, this._response.bind(this, callback), timeout); + }, + + /** + * This function is a legacy wrapper around + * {@link LuCI#post LuCI.post()}. + * + * @instance + * @deprecated + * @memberof LuCI.xhr + * + * @param {string} url + * The URL to request + * + * @param {Object} [data] + * Additional data to append to the request body. + * + * @param {LuCI.requestCallbackFn} [callback] + * Callback function to invoke on completion + * + * @param {number} [timeout] + * Request timeout to use + * + * @return {Promise} + */ + post: function(url, data, callback, timeout) { + this.active = true; + LuCI.prototype.post(url, data, this._response.bind(this, callback), timeout); + }, + + /** + * Cancels a running request. + * + * This function does not actually cancel the underlying + * `XMLHTTPRequest` request but it sets a flag which prevents the + * invocation of the callback function when the request eventually + * finishes or timed out. + * + * @instance + * @deprecated + * @memberof LuCI.xhr + */ + cancel: function() { delete this.active }, + + /** + * Checks the running state of the request. + * + * @instance + * @deprecated + * @memberof LuCI.xhr + * + * @returns {boolean} + * Returns `true` if the request is still running or `false` if it + * already completed. + */ + busy: function() { return (this.active === true) }, + + /** + * Ignored for backwards compatibility. + * + * This function does nothing. + * + * @instance + * @deprecated + * @memberof LuCI.xhr + */ + abort: function() {}, + + /** + * Existing for backwards compatibility. + * + * This function simply throws an `InternalError` when invoked. + * + * @instance + * @deprecated + * @memberof LuCI.xhr + * + * @throws {InternalError} + * Throws an `InternalError` with the message `Not implemented` + * when invoked. + */ + send_form: function() { LuCI.prototype.error('InternalError', 'Not implemented') }, + }); + + XHR.get = function() { return LuCI.prototype.get.apply(LuCI.prototype, arguments) }; + XHR.post = function() { return LuCI.prototype.post.apply(LuCI.prototype, arguments) }; + XHR.poll = function() { return LuCI.prototype.poll.apply(LuCI.prototype, 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/htdocs/luci-static/resources/network.js b/htdocs/luci-static/resources/network.js new file mode 100644 index 0000000..e0013c8 --- /dev/null +++ b/htdocs/luci-static/resources/network.js @@ -0,0 +1,4380 @@ +'use strict'; +'require uci'; +'require rpc'; +'require validation'; +'require baseclass'; +'require firewall'; + +var proto_errors = { + CONNECT_FAILED: _('Connection attempt failed'), + INVALID_ADDRESS: _('IP address is 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 callLuciNetworkDevices = rpc.declare({ + object: 'luci-rpc', + method: 'getNetworkDevices', + expect: { '': {} } +}); + +var callLuciWirelessDevices = rpc.declare({ + object: 'luci-rpc', + method: 'getWirelessDevices', + expect: { '': {} } +}); + +var callLuciBoardJSON = rpc.declare({ + object: 'luci-rpc', + method: 'getBoardJSON' +}); + +var callLuciHostHints = rpc.declare({ + object: 'luci-rpc', + method: 'getHostHints', + expect: { '': {} } +}); + +var callIwinfoAssoclist = rpc.declare({ + object: 'iwinfo', + method: 'assoclist', + params: [ 'device', 'mac' ], + expect: { results: [] } +}); + +var callIwinfoScan = rpc.declare({ + object: 'iwinfo', + method: 'scan', + params: [ 'device' ], + nobatch: true, + expect: { results: [] } +}); + +var callNetworkInterfaceDump = rpc.declare({ + object: 'network.interface', + method: 'dump', + expect: { 'interface': [] } +}); + +var callNetworkProtoHandlers = rpc.declare({ + object: 'network', + method: 'get_proto_handlers', + expect: { '': {} } +}); + +var _init = null, + _state = null, + _protocols = {}, + _protospecs = {}; + +function getProtocolHandlers(cache) { + return callNetworkProtoHandlers().then(function(protos) { + /* Register "none" protocol */ + if (!protos.hasOwnProperty('none')) + Object.assign(protos, { none: { no_device: false } }); + + /* Hack: emulate relayd protocol */ + if (!protos.hasOwnProperty('relay') && L.hasSystemFeature('relayd')) + Object.assign(protos, { relay: { no_device: true } }); + + Object.assign(_protospecs, protos); + + return Promise.all(Object.keys(protos).map(function(p) { + return Promise.resolve(L.require('protocol.%s'.format(p))).catch(function(err) { + if (L.isObject(err) && err.name != 'NetworkError') + L.error(err); + }); + })).then(function() { + return protos; + }); + }).catch(function() { + return {}; + }); +} + +function getWifiStateBySid(sid) { + var s = uci.get('wireless', sid); + + if (s != null && s['.type'] == 'wifi-iface') { + for (var radioname in _state.radios) { + for (var i = 0; i < _state.radios[radioname].interfaces.length; i++) { + var netstate = _state.radios[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']) { + if (s2['.anonymous'] == false && netstate.section.charAt(0) == '@') + return null; + + return [ radioname, _state.radios[radioname], netstate ]; + } + } + } + } + + return null; +} + +function getWifiStateByIfname(ifname) { + for (var radioname in _state.radios) { + for (var i = 0; i < _state.radios[radioname].interfaces.length; i++) { + var netstate = _state.radios[radioname].interfaces[i]; + + if (typeof(netstate.ifname) != 'string') + continue; + + if (netstate.ifname == ifname) + return [ radioname, _state.radios[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 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 = L.toArray(values); + + 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 = L.toArray(values); + + 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(refresh) { + if (_state == null || refresh) { + _init = _init || Promise.all([ + L.resolveDefault(callNetworkInterfaceDump(), []), + L.resolveDefault(callLuciBoardJSON(), {}), + L.resolveDefault(callLuciNetworkDevices(), {}), + L.resolveDefault(callLuciWirelessDevices(), {}), + L.resolveDefault(callLuciHostHints(), {}), + getProtocolHandlers(), + L.resolveDefault(uci.load('network')), + L.resolveDefault(uci.load('wireless')), + L.resolveDefault(uci.load('luci')) + ]).then(function(data) { + var netifd_ifaces = data[0], + board_json = data[1], + luci_devs = data[2]; + + var s = { + isTunnel: {}, isBridge: {}, isSwitch: {}, isWifi: {}, + ifaces: netifd_ifaces, radios: data[3], hosts: data[4], + netdevs: {}, bridges: {}, switches: {}, hostapd: {} + }; + + for (var name in luci_devs) { + var dev = luci_devs[name]; + + if (isVirtualIfname(name)) + s.isTunnel[name] = true; + + if (!s.isTunnel[name] && isIgnoredIfname(name)) + continue; + + s.netdevs[name] = s.netdevs[name] || { + idx: dev.ifindex, + name: name, + rawname: name, + flags: dev.flags, + link: dev.link, + stats: dev.stats, + macaddr: dev.mac, + type: dev.type, + devtype: dev.devtype, + mtu: dev.mtu, + qlen: dev.qlen, + wireless: dev.wireless, + parent: dev.parent, + ipaddrs: [], + ip6addrs: [] + }; + + if (Array.isArray(dev.ipaddrs)) + for (var i = 0; i < dev.ipaddrs.length; i++) + s.netdevs[name].ipaddrs.push(dev.ipaddrs[i].address + '/' + dev.ipaddrs[i].netmask); + + if (Array.isArray(dev.ip6addrs)) + for (var i = 0; i < dev.ip6addrs.length; i++) + s.netdevs[name].ip6addrs.push(dev.ip6addrs[i].address + '/' + dev.ip6addrs[i].netmask); + } + + for (var name in luci_devs) { + var dev = luci_devs[name]; + + if (!dev.bridge) + continue; + + var b = { + name: name, + id: dev.id, + stp: dev.stp, + ifnames: [] + }; + + for (var i = 0; dev.ports && i < dev.ports.length; i++) { + var subdev = s.netdevs[dev.ports[i]]; + + if (subdev == null) + continue; + + b.ifnames.push(subdev); + subdev.bridge = b; + } + + s.bridges[name] = b; + s.isBridge[name] = true; + } + + for (var name in luci_devs) { + var dev = luci_devs[name]; + + if (!dev.parent || dev.devtype != 'dsa') + continue; + + s.isSwitch[dev.parent] = true; + s.isSwitch[name] = true; + } + + if (L.isObject(board_json.switch)) { + for (var switchname in board_json.switch) { + var layout = board_json.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) { + return L.naturalCompare(a.role, b.role) || L.naturalCompare(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 + }; + } + } + } + + if (L.isObject(board_json.dsl) && L.isObject(board_json.dsl.modem)) { + s.hasDSLModem = board_json.dsl.modem; + } + + _init = null; + + var objects = []; + + if (L.isObject(s.radios)) + for (var radio in s.radios) + if (L.isObject(s.radios[radio]) && Array.isArray(s.radios[radio].interfaces)) + for (var i = 0; i < s.radios[radio].interfaces.length; i++) + if (L.isObject(s.radios[radio].interfaces[i]) && s.radios[radio].interfaces[i].ifname) + objects.push('hostapd.%s'.format(s.radios[radio].interfaces[i].ifname)); + + return (objects.length ? L.resolveDefault(rpc.list.apply(rpc, objects), {}) : Promise.resolve({})).then(function(res) { + for (var k in res) { + var m = k.match(/^hostapd\.(.+)$/); + if (m) + s.hostapd[m[1]] = res[k]; + } + + return (_state = s); + }); + }); + } + + return (_state != null ? Promise.resolve(_state) : _init); +} + +function ifnameOf(obj) { + if (obj instanceof Protocol) + return obj.getIfname(); + else if (obj instanceof Device) + return obj.getName(); + else if (obj instanceof WifiDevice) + return obj.getName(); + else if (obj instanceof WifiNetwork) + return obj.getIfname(); + else if (typeof(obj) == 'string') + return obj.replace(/:.+$/, ''); + + return null; +} + +function networkSort(a, b) { + return L.naturalCompare(a.getName(), b.getName()); +} + +function deviceSort(a, b) { + var typeWeigth = { wifi: 2, alias: 3 }; + + return L.naturalCompare(typeWeigth[a.getType()] || 1, typeWeigth[b.getType()] || 1) || + L.naturalCompare(a.getName(), b.getName()); +} + +function formatWifiEncryption(enc) { + if (!L.isObject(enc)) + return null; + + if (!enc.enabled) + return 'None'; + + var ciphers = Array.isArray(enc.ciphers) + ? enc.ciphers.map(function(c) { return c.toUpperCase() }) : [ 'NONE' ]; + + if (Array.isArray(enc.wep)) { + var has_open = false, + has_shared = false; + + for (var i = 0; i < enc.wep.length; i++) + if (enc.wep[i] == 'open') + has_open = true; + else if (enc.wep[i] == 'shared') + has_shared = true; + + if (has_open && has_shared) + return 'WEP Open/Shared (%s)'.format(ciphers.join(', ')); + else if (has_open) + return 'WEP Open System (%s)'.format(ciphers.join(', ')); + else if (has_shared) + return 'WEP Shared Auth (%s)'.format(ciphers.join(', ')); + + return 'WEP'; + } + + if (Array.isArray(enc.wpa)) { + var versions = [], + suites = Array.isArray(enc.authentication) + ? enc.authentication.map(function(a) { return a.toUpperCase() }) : [ 'NONE' ]; + + for (var i = 0; i < enc.wpa.length; i++) + switch (enc.wpa[i]) { + case 1: + versions.push('WPA'); + break; + + default: + versions.push('WPA%d'.format(enc.wpa[i])); + break; + } + + if (versions.length > 1) + return 'mixed %s %s (%s)'.format(versions.join('/'), suites.join(', '), ciphers.join(', ')); + + return '%s %s (%s)'.format(versions[0], suites.join(', '), ciphers.join(', ')); + } + + return 'Unknown'; +} + +function enumerateNetworks() { + var uciInterfaces = uci.sections('network', 'interface'), + networks = {}; + + for (var i = 0; i < uciInterfaces.length; i++) + networks[uciInterfaces[i]['.name']] = this.instantiateNetwork(uciInterfaces[i]['.name']); + + for (var i = 0; i < _state.ifaces.length; i++) + if (networks[_state.ifaces[i].interface] == null) + networks[_state.ifaces[i].interface] = + this.instantiateNetwork(_state.ifaces[i].interface, _state.ifaces[i].proto); + + var rv = []; + + for (var network in networks) + if (networks.hasOwnProperty(network)) + rv.push(networks[network]); + + rv.sort(networkSort); + + return rv; +} + + +var Hosts, Network, Protocol, Device, WifiDevice, WifiNetwork; + +/** + * @class network + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `LuCI.network` class combines data from multiple `ubus` apis to + * provide an abstraction of the current network configuration state. + * + * It provides methods to enumerate interfaces and devices, to query + * current configuration details and to manipulate settings. + */ +Network = baseclass.extend(/** @lends LuCI.network.prototype */ { + /** + * Converts the given prefix size in bits to a netmask. + * + * @method + * + * @param {number} bits + * The prefix size in bits. + * + * @param {boolean} [v6=false] + * Whether to convert the bits value into an IPv4 netmask (`false`) or + * an IPv6 netmask (`true`). + * + * @returns {null|string} + * Returns a string containing the netmask corresponding to the bit count + * or `null` when the given amount of bits exceeds the maximum possible + * value of `32` for IPv4 or `128` for IPv6. + */ + prefixToMask: prefixToMask, + + /** + * Converts the given netmask to a prefix size in bits. + * + * @method + * + * @param {string} netmask + * The netmask to convert into a bit count. + * + * @param {boolean} [v6=false] + * Whether to parse the given netmask as IPv4 (`false`) or IPv6 (`true`) + * address. + * + * @returns {null|number} + * Returns the number of prefix bits contained in the netmask or `null` + * if the given netmask value was invalid. + */ + maskToPrefix: maskToPrefix, + + /** + * An encryption entry describes active wireless encryption settings + * such as the used key management protocols, active ciphers and + * protocol versions. + * + * @typedef {Object>} LuCI.network.WifiEncryption + * @memberof LuCI.network + * + * @property {boolean} enabled + * Specifies whether any kind of encryption, such as `WEP` or `WPA` is + * enabled. If set to `false`, then no encryption is active and the + * corresponding network is open. + * + * @property {string[]} [wep] + * When the `wep` property exists, the network uses WEP encryption. + * In this case, the property is set to an array of active WEP modes + * which might be either `open`, `shared` or both. + * + * @property {number[]} [wpa] + * When the `wpa` property exists, the network uses WPA security. + * In this case, the property is set to an array containing the WPA + * protocol versions used, e.g. `[ 1, 2 ]` for WPA/WPA2 mixed mode or + * `[ 3 ]` for WPA3-SAE. + * + * @property {string[]} [authentication] + * The `authentication` property only applies to WPA encryption and + * is defined when the `wpa` property is set as well. It points to + * an array of active authentication suites used by the network, e.g. + * `[ "psk" ]` for a WPA(2)-PSK network or `[ "psk", "sae" ]` for + * mixed WPA2-PSK/WPA3-SAE encryption. + * + * @property {string[]} [ciphers] + * If either WEP or WPA encryption is active, then the `ciphers` + * property will be set to an array describing the active encryption + * ciphers used by the network, e.g. `[ "tkip", "ccmp" ]` for a + * WPA/WPA2-PSK mixed network or `[ "wep-40", "wep-104" ]` for an + * WEP network. + */ + + /** + * Converts a given {@link LuCI.network.WifiEncryption encryption entry} + * into a human readable string such as `mixed WPA/WPA2 PSK (TKIP, CCMP)` + * or `WPA3 SAE (CCMP)`. + * + * @method + * + * @param {LuCI.network.WifiEncryption} encryption + * The wireless encryption entry to convert. + * + * @returns {null|string} + * Returns the description string for the given encryption entry or + * `null` if the given entry was invalid. + */ + formatWifiEncryption: formatWifiEncryption, + + /** + * Flushes the local network state cache and fetches updated information + * from the remote `ubus` apis. + * + * @returns {Promise} + * Returns a promise resolving to the internal network state object. + */ + flushCache: function() { + initNetworkState(true); + return _init; + }, + + /** + * Instantiates the given {@link LuCI.network.Protocol Protocol} backend, + * optionally using the given network name. + * + * @param {string} protoname + * The protocol backend to use, e.g. `static` or `dhcp`. + * + * @param {string} [netname=__dummy__] + * The network name to use for the instantiated protocol. This should be + * usually set to one of the interfaces described in /etc/config/network + * but it is allowed to omit it, e.g. to query protocol capabilities + * without the need for an existing interface. + * + * @returns {null|LuCI.network.Protocol} + * Returns the instantiated protocol backend class or `null` if the given + * protocol isn't known. + */ + getProtocol: function(protoname, netname) { + var v = _protocols[protoname]; + if (v != null) + return new v(netname || '__dummy__'); + + return null; + }, + + /** + * Obtains instances of all known {@link LuCI.network.Protocol Protocol} + * backend classes. + * + * @returns {Array} + * Returns an array of protocol class instances. + */ + getProtocols: function() { + var rv = []; + + for (var protoname in _protocols) + rv.push(new _protocols[protoname]('__dummy__')); + + return rv; + }, + + /** + * Registers a new {@link LuCI.network.Protocol Protocol} subclass + * with the given methods and returns the resulting subclass value. + * + * This functions internally calls + * {@link LuCI.Class.extend Class.extend()} on the `Network.Protocol` + * base class. + * + * @param {string} protoname + * The name of the new protocol to register. + * + * @param {Object} methods + * The member methods and values of the new `Protocol` subclass to + * be passed to {@link LuCI.Class.extend Class.extend()}. + * + * @returns {LuCI.network.Protocol} + * Returns the new `Protocol` subclass. + */ + registerProtocol: function(protoname, methods) { + var spec = L.isObject(_protospecs) ? _protospecs[protoname] : null; + var proto = Protocol.extend(Object.assign({ + getI18n: function() { + return protoname; + }, + + isFloating: function() { + return false; + }, + + isVirtual: function() { + return (L.isObject(spec) && spec.no_device == true); + }, + + renderFormOptions: function(section) { + + } + }, methods, { + __init__: function(name) { + this.sid = name; + }, + + getProtocol: function() { + return protoname; + } + })); + + _protocols[protoname] = proto; + + return proto; + }, + + /** + * Registers a new regular expression pattern to recognize + * virtual interfaces. + * + * @param {RegExp} pat + * A `RegExp` instance to match a virtual interface name + * such as `6in4-wan` or `tun0`. + */ + registerPatternVirtual: function(pat) { + iface_patterns_virtual.push(pat); + }, + + /** + * Registers a new human readable translation string for a `Protocol` + * error code. + * + * @param {string} code + * The `ubus` protocol error code to register a translation for, e.g. + * `NO_DEVICE`. + * + * @param {string} message + * The message to use as translation for the given protocol error code. + * + * @returns {boolean} + * Returns `true` if the error code description has been added or `false` + * if either the arguments were invalid or if there already was a + * description for the given code. + */ + registerErrorCode: function(code, message) { + if (typeof(code) == 'string' && + typeof(message) == 'string' && + !proto_errors.hasOwnProperty(code)) { + proto_errors[code] = message; + return true; + } + + return false; + }, + + /** + * Adds a new network of the given name and update it with the given + * uci option values. + * + * If a network with the given name already exist but is empty, then + * this function will update its option, otherwise it will do nothing. + * + * @param {string} name + * The name of the network to add. Must be in the format `[a-zA-Z0-9_]+`. + * + * @param {Object} [options] + * An object of uci option values to set on the new network or to + * update in an existing, empty network. + * + * @returns {Promise} + * Returns a promise resolving to the `Protocol` subclass instance + * describing the added network or resolving to `null` if the name + * was invalid or if a non-empty network of the given name already + * existed. + */ + 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)); + }, + + /** + * Get a {@link LuCI.network.Protocol Protocol} instance describing + * the network with the given name. + * + * @param {string} name + * The logical interface name of the network get, e.g. `lan` or `wan`. + * + * @returns {Promise} + * Returns a promise resolving to a + * {@link LuCI.network.Protocol Protocol} subclass instance describing + * the network or `null` if the network did not exist. + */ + 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 < _state.ifaces.length; i++) + if (_state.ifaces[i].interface == name) + return this.instantiateNetwork(name, _state.ifaces[i].proto); + } + + return null; + }, this)); + }, + + /** + * Gets an array containing all known networks. + * + * @returns {Promise>} + * Returns a promise resolving to a name-sorted array of + * {@link LuCI.network.Protocol Protocol} subclass instances + * describing all known networks. + */ + getNetworks: function() { + return initNetworkState().then(L.bind(enumerateNetworks, this)); + }, + + /** + * Deletes the given network and its references from the network and + * firewall configuration. + * + * @param {string} name + * The name of the network to delete. + * + * @returns {Promise} + * Returns a promise resolving to either `true` if the network and + * references to it were successfully deleted from the configuration or + * `false` if the given network could not be found. + */ + deleteNetwork: function(name) { + var requireFirewall = Promise.resolve(L.require('firewall')).catch(function() {}), + loadDHCP = L.resolveDefault(uci.load('dhcp')), + network = this.instantiateNetwork(name); + + return Promise.all([ requireFirewall, loadDHCP, initNetworkState() ]).then(function(res) { + var uciInterface = uci.get('network', name), + firewall = res[0]; + + if (uciInterface != null && uciInterface['.type'] == 'interface') { + return Promise.resolve(network ? network.deleteConfiguration() : null).then(function() { + uci.remove('network', name); + + uci.sections('luci', 'ifstate', function(s) { + if (s.interface == name) + uci.remove('luci', s['.name']); + }); + + uci.sections('network', null, function(s) { + switch (s['.type']) { + case 'alias': + case 'route': + case 'route6': + if (s.interface == name) + uci.remove('network', s['.name']); + + break; + + case 'rule': + case 'rule6': + if (s.in == name || s.out == name) + uci.remove('network', s['.name']); + + break; + } + }); + + 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'); + }); + + uci.sections('dhcp', 'dhcp', function(s) { + if (s.interface == name) + uci.remove('dhcp', s['.name']); + }); + + if (firewall) + return firewall.deleteNetwork(name).then(function() { return true }); + + return true; + }).catch(function() { + return false; + }); + } + + return false; + }); + }, + + /** + * Rename the given network and its references to a new name. + * + * @param {string} oldName + * The current name of the network. + * + * @param {string} newName + * The name to rename the network to, must be in the format + * `[a-z-A-Z0-9_]+`. + * + * @returns {Promise} + * Returns a promise resolving to either `true` if the network was + * successfully renamed or `false` if the new name was invalid, if + * a network with the new name already exists or if the network to + * rename could not be found. + */ + 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; + }); + }, + + /** + * Get a {@link LuCI.network.Device Device} instance describing the + * given network device. + * + * @param {string} name + * The name of the network device to get, e.g. `eth0` or `br-lan`. + * + * @returns {Promise} + * Returns a promise resolving to the `Device` instance describing + * the network device or `null` if the given device name could not + * be found. + */ + getDevice: function(name) { + return initNetworkState().then(L.bind(function() { + if (name == null) + return null; + + if (_state.netdevs.hasOwnProperty(name)) + return this.instantiateDevice(name); + + var netid = getWifiNetidBySid(name); + if (netid != null) + return this.instantiateDevice(netid[0]); + + return null; + }, this)); + }, + + /** + * Get a sorted list of all found network devices. + * + * @returns {Promise>} + * Returns a promise resolving to a sorted array of `Device` class + * instances describing the network devices found on the system. + */ + 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.netdevs) { + if (devices.hasOwnProperty(ifname)) + continue; + + if (isIgnoredIfname(ifname) || isWifiIfname(ifname)) + continue; + + if (_state.netdevs[ifname].wireless) + 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 bridge VLAN devices */ + var uciBridgeVLANs = uci.sections('network', 'bridge-vlan'); + for (var i = 0; i < uciBridgeVLANs.length; i++) { + var basedev = uciBridgeVLANs[i].device, + local = uciBridgeVLANs[i].local, + alias = uciBridgeVLANs[i].alias, + vid = +uciBridgeVLANs[i].vlan, + ports = L.toArray(uciBridgeVLANs[i].ports); + + if (local == '0') + continue; + + if (isNaN(vid) || vid < 0 || vid > 4095) + continue; + + var vlandev = '%s.%s'.format(basedev, alias || vid); + + _state.isBridge[basedev] = true; + + if (!_state.bridges.hasOwnProperty(basedev)) + _state.bridges[basedev] = { + name: basedev, + ifnames: [] + }; + + if (!devices.hasOwnProperty(vlandev)) + devices[vlandev] = this.instantiateDevice(vlandev); + + ports.forEach(function(port_name) { + var m = port_name.match(/^([^:]+)(?::[ut*]+)?$/), + p = m ? m[1] : null; + + if (!p) + return; + + if (_state.bridges[basedev].ifnames.filter(function(sd) { return sd.name == p }).length) + return; + + _state.netdevs[p] = _state.netdevs[p] || { + name: p, + ipaddrs: [], + ip6addrs: [], + type: 1, + devtype: 'ethernet', + stats: {}, + flags: {} + }; + + _state.bridges[basedev].ifnames.push(_state.netdevs[p]); + _state.netdevs[p].bridge = _state.bridges[basedev]; + }); + } + + /* 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); + } + + /* find uci declared devices */ + var uciDevices = uci.sections('network', 'device'); + + for (var i = 0; i < uciDevices.length; i++) { + var type = uciDevices[i].type, + name = uciDevices[i].name; + + if (!type || !name || devices.hasOwnProperty(name)) + continue; + + if (type == 'bridge') + _state.isBridge[name] = true; + + devices[name] = this.instantiateDevice(name); + } + + var rv = []; + + for (var netdev in devices) + if (devices.hasOwnProperty(netdev)) + rv.push(devices[netdev]); + + rv.sort(deviceSort); + + return rv; + }, this)); + }, + + /** + * Test if a given network device name is in the list of patterns for + * device names to ignore. + * + * Ignored device names are usually Linux network devices which are + * spawned implicitly by kernel modules such as `tunl0` or `hwsim0` + * and which are unsuitable for use in network configuration. + * + * @param {string} name + * The device name to test. + * + * @returns {boolean} + * Returns `true` if the given name is in the ignore pattern list, + * else returns `false`. + */ + isIgnoredDevice: function(name) { + return isIgnoredIfname(name); + }, + + /** + * Get a {@link LuCI.network.WifiDevice WifiDevice} instance describing + * the given wireless radio. + * + * @param {string} devname + * The configuration name of the wireless radio to lookup, e.g. `radio0` + * for the first mac80211 phy on the system. + * + * @returns {Promise} + * Returns a promise resolving to the `WifiDevice` instance describing + * the underlying radio device or `null` if the wireless radio could not + * be found. + */ + getWifiDevice: function(devname) { + return initNetworkState().then(L.bind(function() { + var existingDevice = uci.get('wireless', devname); + + if (existingDevice == null || existingDevice['.type'] != 'wifi-device') + return null; + + return this.instantiateWifiDevice(devname, _state.radios[devname] || {}); + }, this)); + }, + + /** + * Obtain a list of all configured radio devices. + * + * @returns {Promise>} + * Returns a promise resolving to an array of `WifiDevice` instances + * describing the wireless radios configured in the system. + * The order of the array corresponds to the order of the radios in + * the configuration. + */ + getWifiDevices: function() { + return initNetworkState().then(L.bind(function() { + var uciWifiDevices = uci.sections('wireless', 'wifi-device'), + rv = []; + + for (var i = 0; i < uciWifiDevices.length; i++) { + var devname = uciWifiDevices[i]['.name']; + rv.push(this.instantiateWifiDevice(devname, _state.radios[devname] || {})); + } + + return rv; + }, this)); + }, + + /** + * Get a {@link LuCI.network.WifiNetwork WifiNetwork} instance describing + * the given wireless network. + * + * @param {string} netname + * The name of the wireless network to lookup. This may be either an uci + * configuration section ID, a network ID in the form `radio#.network#` + * or a Linux network device name like `wlan0` which is resolved to the + * corresponding configuration section through `ubus` runtime information. + * + * @returns {Promise} + * Returns a promise resolving to the `WifiNetwork` instance describing + * the wireless network or `null` if the corresponding network could not + * be found. + */ + getWifiNetwork: function(netname) { + return initNetworkState() + .then(L.bind(this.lookupWifiNetwork, this, netname)); + }, + + /** + * Get an array of all {@link LuCI.network.WifiNetwork WifiNetwork} + * instances describing the wireless networks present on the system. + * + * @returns {Promise>} + * Returns a promise resolving to an array of `WifiNetwork` instances + * describing the wireless networks. The array will be empty if no networks + * are found. + */ + getWifiNetworks: function() { + return initNetworkState().then(L.bind(function() { + var wifiIfaces = uci.sections('wireless', 'wifi-iface'), + rv = []; + + for (var i = 0; i < wifiIfaces.length; i++) + rv.push(this.lookupWifiNetwork(wifiIfaces[i]['.name'])); + + rv.sort(function(a, b) { + return L.naturalCompare(a.getID(), b.getID()); + }); + + return rv; + }, this)); + }, + + /** + * Adds a new wireless network to the configuration and sets its options + * to the provided values. + * + * @param {Object} options + * The options to set for the newly added wireless network. This object + * must at least contain a `device` property which is set to the radio + * name the new network belongs to. + * + * @returns {Promise} + * Returns a promise resolving to a `WifiNetwork` instance describing + * the newly added wireless network or `null` if the given options + * were invalid or if the associated radio device could not be found. + */ + 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; + + /* XXX: need to add a named section (wifinet#) here */ + 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, _state.radios[radioname], netid[0], null); + }, this)); + }, + + /** + * Deletes the given wireless network from the configuration. + * + * @param {string} netname + * The name of the network to remove. This may be either a + * network ID in the form `radio#.network#` or a Linux network device + * name like `wlan0` which is resolved to the corresponding configuration + * section through `ubus` runtime information. + * + * @returns {Promise} + * Returns a promise resolving to `true` if the wireless network has been + * successfully deleted from the configuration or `false` if it could not + * be found. + */ + 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)); + }, + + /* private */ + getStatusByRoute: function(addr, mask) { + return initNetworkState().then(L.bind(function() { + var rv = []; + + for (var i = 0; i < _state.ifaces.length; i++) { + if (!Array.isArray(_state.ifaces[i].route)) + continue; + + for (var j = 0; j < _state.ifaces[i].route.length; j++) { + if (typeof(_state.ifaces[i].route[j]) != 'object' || + typeof(_state.ifaces[i].route[j].target) != 'string' || + typeof(_state.ifaces[i].route[j].mask) != 'number') + continue; + + if (_state.ifaces[i].route[j].table) + continue; + + if (_state.ifaces[i].route[j].target != addr || + _state.ifaces[i].route[j].mask != mask) + continue; + + rv.push(_state.ifaces[i]); + } + } + + rv.sort(function(a, b) { + return L.naturalCompare(a.metric, b.metric) || L.naturalCompare(a.interface, b.interface); + }); + + return rv; + }, this)); + }, + + /* private */ + getStatusByAddress: function(addr) { + return initNetworkState().then(L.bind(function() { + var rv = []; + + for (var i = 0; i < _state.ifaces.length; i++) { + if (Array.isArray(_state.ifaces[i]['ipv4-address'])) + for (var j = 0; j < _state.ifaces[i]['ipv4-address'].length; j++) + if (typeof(_state.ifaces[i]['ipv4-address'][j]) == 'object' && + _state.ifaces[i]['ipv4-address'][j].address == addr) + return _state.ifaces[i]; + + if (Array.isArray(_state.ifaces[i]['ipv6-address'])) + for (var j = 0; j < _state.ifaces[i]['ipv6-address'].length; j++) + if (typeof(_state.ifaces[i]['ipv6-address'][j]) == 'object' && + _state.ifaces[i]['ipv6-address'][j].address == addr) + return _state.ifaces[i]; + + if (Array.isArray(_state.ifaces[i]['ipv6-prefix-assignment'])) + for (var j = 0; j < _state.ifaces[i]['ipv6-prefix-assignment'].length; j++) + if (typeof(_state.ifaces[i]['ipv6-prefix-assignment'][j]) == 'object' && + typeof(_state.ifaces[i]['ipv6-prefix-assignment'][j]['local-address']) == 'object' && + _state.ifaces[i]['ipv6-prefix-assignment'][j]['local-address'].address == addr) + return _state.ifaces[i]; + } + + return null; + }, this)); + }, + + /** + * Get IPv4 wan networks. + * + * This function looks up all networks having a default `0.0.0.0/0` route + * and returns them as array. + * + * @returns {Promise>} + * Returns a promise resolving to an array of `Protocol` subclass + * instances describing the found default route interfaces. + */ + getWANNetworks: function() { + return this.getStatusByRoute('0.0.0.0', 0).then(L.bind(function(statuses) { + var rv = [], seen = {}; + + for (var i = 0; i < statuses.length; i++) { + if (!seen.hasOwnProperty(statuses[i].interface)) { + rv.push(this.instantiateNetwork(statuses[i].interface, statuses[i].proto)); + seen[statuses[i].interface] = true; + } + } + + return rv; + }, this)); + }, + + /** + * Get IPv6 wan networks. + * + * This function looks up all networks having a default `::/0` route + * and returns them as array. + * + * @returns {Promise>} + * Returns a promise resolving to an array of `Protocol` subclass + * instances describing the found IPv6 default route interfaces. + */ + getWAN6Networks: function() { + return this.getStatusByRoute('::', 0).then(L.bind(function(statuses) { + var rv = [], seen = {}; + + for (var i = 0; i < statuses.length; i++) { + if (!seen.hasOwnProperty(statuses[i].interface)) { + rv.push(this.instantiateNetwork(statuses[i].interface, statuses[i].proto)); + seen[statuses[i].interface] = true; + } + } + + return rv; + }, this)); + }, + + /** + * Describes an swconfig switch topology by specifying the CPU + * connections and external port labels of a switch. + * + * @typedef {Object} SwitchTopology + * @memberof LuCI.network + * + * @property {Object} netdevs + * The `netdevs` property points to an object describing the CPU port + * connections of the switch. The numeric key of the enclosed object is + * the port number, the value contains the Linux network device name the + * port is hardwired to. + * + * @property {Array>} ports + * The `ports` property points to an array describing the populated + * ports of the switch in the external label order. Each array item is + * an object containg the following keys: + * - `num` - the internal switch port number + * - `label` - the label of the port, e.g. `LAN 1` or `CPU (eth0)` + * - `device` - the connected Linux network device name (CPU ports only) + * - `tagged` - a boolean indicating whether the port must be tagged to + * function (CPU ports only) + */ + + /** + * Returns the topologies of all swconfig switches found on the system. + * + * @returns {Promise>} + * Returns a promise resolving to an object containing the topologies + * of each switch. The object keys correspond to the name of the switches + * such as `switch0`, the values are + * {@link LuCI.network.SwitchTopology SwitchTopology} objects describing + * the layout. + */ + getSwitchTopologies: function() { + return initNetworkState().then(function() { + return _state.switches; + }); + }, + + /* private */ + 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); + }, + + /* private */ + instantiateDevice: function(name, network, extend) { + if (extend != null) + return new (Device.extend(extend))(name, network); + + return new Device(name, network); + }, + + /* private */ + instantiateWifiDevice: function(radioname, radiostate) { + return new WifiDevice(radioname, radiostate); + }, + + /* private */ + instantiateWifiNetwork: function(sid, radioname, radiostate, netid, netstate, hostapd) { + return new WifiNetwork(sid, radioname, radiostate, netid, netstate, hostapd); + }, + + /* private */ + lookupWifiNetwork: function(netname) { + var sid, res, netid, radioname, radiostate, netstate; + + 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 this.instantiateWifiNetwork(sid || netname, radioname, + radiostate, netid, netstate, + netstate ? _state.hostapd[netstate.ifname] : null); + }, + + /** + * Obtains the the network device name of the given object. + * + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} obj + * The object to get the device name from. + * + * @returns {null|string} + * Returns a string containing the device name or `null` if the given + * object could not be converted to a name. + */ + getIfnameOf: function(obj) { + return ifnameOf(obj); + }, + + /** + * Queries the internal DSL modem type from board information. + * + * @returns {Promise} + * Returns a promise resolving to the type of the internal modem + * (e.g. `vdsl`) or to `null` if no internal modem is present. + */ + getDSLModemType: function() { + return initNetworkState().then(function() { + return _state.hasDSLModem ? _state.hasDSLModem.type : null; + }); + }, + + /** + * Queries aggregated information about known hosts. + * + * This function aggregates information from various sources such as + * DHCP lease databases, ARP and IPv6 neighbour entries, wireless + * association list etc. and returns a {@link LuCI.network.Hosts Hosts} + * class instance describing the found hosts. + * + * @returns {Promise} + * Returns a `Hosts` instance describing host known on the system. + */ + getHostHints: function() { + return initNetworkState().then(function() { + return new Hosts(_state.hosts); + }); + } +}); + +/** + * @class + * @memberof LuCI.network + * @hideconstructor + * @classdesc + * + * The `LuCI.network.Hosts` class encapsulates host information aggregated + * from multiple sources and provides convenience functions to access the + * host information by different criteria. + */ +Hosts = baseclass.extend(/** @lends LuCI.network.Hosts.prototype */ { + __init__: function(hosts) { + this.hosts = hosts; + }, + + /** + * Lookup the hostname associated with the given MAC address. + * + * @param {string} mac + * The MAC address to lookup. + * + * @returns {null|string} + * Returns the hostname associated with the given MAC or `null` if + * no matching host could be found or if no hostname is known for + * the corresponding host. + */ + getHostnameByMACAddr: function(mac) { + return this.hosts[mac] + ? (this.hosts[mac].name || null) + : null; + }, + + /** + * Lookup the IPv4 address associated with the given MAC address. + * + * @param {string} mac + * The MAC address to lookup. + * + * @returns {null|string} + * Returns the IPv4 address associated with the given MAC or `null` if + * no matching host could be found or if no IPv4 address is known for + * the corresponding host. + */ + getIPAddrByMACAddr: function(mac) { + return this.hosts[mac] + ? (L.toArray(this.hosts[mac].ipaddrs || this.hosts[mac].ipv4)[0] || null) + : null; + }, + + /** + * Lookup the IPv6 address associated with the given MAC address. + * + * @param {string} mac + * The MAC address to lookup. + * + * @returns {null|string} + * Returns the IPv6 address associated with the given MAC or `null` if + * no matching host could be found or if no IPv6 address is known for + * the corresponding host. + */ + getIP6AddrByMACAddr: function(mac) { + return this.hosts[mac] + ? (L.toArray(this.hosts[mac].ip6addrs || this.hosts[mac].ipv6)[0] || null) + : null; + }, + + /** + * Lookup the hostname associated with the given IPv4 address. + * + * @param {string} ipaddr + * The IPv4 address to lookup. + * + * @returns {null|string} + * Returns the hostname associated with the given IPv4 or `null` if + * no matching host could be found or if no hostname is known for + * the corresponding host. + */ + getHostnameByIPAddr: function(ipaddr) { + for (var mac in this.hosts) { + if (this.hosts[mac].name == null) + continue; + + var addrs = L.toArray(this.hosts[mac].ipaddrs || this.hosts[mac].ipv4); + + for (var i = 0; i < addrs.length; i++) + if (addrs[i] == ipaddr) + return this.hosts[mac].name; + } + + return null; + }, + + /** + * Lookup the MAC address associated with the given IPv4 address. + * + * @param {string} ipaddr + * The IPv4 address to lookup. + * + * @returns {null|string} + * Returns the MAC address associated with the given IPv4 or `null` if + * no matching host could be found or if no MAC address is known for + * the corresponding host. + */ + getMACAddrByIPAddr: function(ipaddr) { + for (var mac in this.hosts) { + var addrs = L.toArray(this.hosts[mac].ipaddrs || this.hosts[mac].ipv4); + + for (var i = 0; i < addrs.length; i++) + if (addrs[i] == ipaddr) + return mac; + } + + return null; + }, + + /** + * Lookup the hostname associated with the given IPv6 address. + * + * @param {string} ip6addr + * The IPv6 address to lookup. + * + * @returns {null|string} + * Returns the hostname associated with the given IPv6 or `null` if + * no matching host could be found or if no hostname is known for + * the corresponding host. + */ + getHostnameByIP6Addr: function(ip6addr) { + for (var mac in this.hosts) { + if (this.hosts[mac].name == null) + continue; + + var addrs = L.toArray(this.hosts[mac].ip6addrs || this.hosts[mac].ipv6); + + for (var i = 0; i < addrs.length; i++) + if (addrs[i] == ip6addr) + return this.hosts[mac].name; + } + + return null; + }, + + /** + * Lookup the MAC address associated with the given IPv6 address. + * + * @param {string} ip6addr + * The IPv6 address to lookup. + * + * @returns {null|string} + * Returns the MAC address associated with the given IPv6 or `null` if + * no matching host could be found or if no MAC address is known for + * the corresponding host. + */ + getMACAddrByIP6Addr: function(ip6addr) { + for (var mac in this.hosts) { + var addrs = L.toArray(this.hosts[mac].ip6addrs || this.hosts[mac].ipv6); + + for (var i = 0; i < addrs.length; i++) + if (addrs[i] == ip6addr) + return mac; + } + + return null; + }, + + /** + * Return an array of (MAC address, name hint) tuples sorted by + * MAC address. + * + * @param {boolean} [preferIp6=false] + * Whether to prefer IPv6 addresses (`true`) or IPv4 addresses (`false`) + * as name hint when no hostname is known for a specific MAC address. + * + * @returns {Array>} + * Returns an array of arrays containing a name hint for each found + * MAC address on the system. The array is sorted ascending by MAC. + * + * Each item of the resulting array is a two element array with the + * MAC being the first element and the name hint being the second + * element. The name hint is either the hostname, an IPv4 or an IPv6 + * address related to the MAC address. + * + * If no hostname but both IPv4 and IPv6 addresses are known, the + * `preferIP6` flag specifies whether the IPv6 or the IPv4 address + * is used as hint. + */ + getMACHints: function(preferIp6) { + var rv = []; + + for (var mac in this.hosts) { + var hint = this.hosts[mac].name || + L.toArray(this.hosts[mac][preferIp6 ? 'ip6addrs' : 'ipaddrs'] || this.hosts[mac][preferIp6 ? 'ipv6' : 'ipv4'])[0] || + L.toArray(this.hosts[mac][preferIp6 ? 'ipaddrs' : 'ip6addrs'] || this.hosts[mac][preferIp6 ? 'ipv4' : 'ipv6'])[0]; + + rv.push([mac, hint]); + } + + return rv.sort(function(a, b) { + return L.naturalCompare(a[0], b[0]); + }); + } +}); + +/** + * @class + * @memberof LuCI.network + * @hideconstructor + * @classdesc + * + * The `Network.Protocol` class serves as base for protocol specific + * subclasses which describe logical UCI networks defined by `config + * interface` sections in `/etc/config/network`. + */ +Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { + __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 < _state.ifaces.length; i++) { + if (_state.ifaces[i].interface != this.sid) + continue; + + return (field != null ? _state.ifaces[i][field] : _state.ifaces[i]); + } + }, + + /** + * Read the given UCI option value of this network. + * + * @param {string} opt + * The UCI option name to read. + * + * @returns {null|string|string[]} + * Returns the UCI option value or `null` if the requested option is + * not found. + */ + get: function(opt) { + return uci.get('network', this.sid, opt); + }, + + /** + * Set the given UCI option of this network to the given value. + * + * @param {string} opt + * The name of the UCI option to set. + * + * @param {null|string|string[]} val + * The value to set or `null` to remove the given option from the + * configuration. + */ + set: function(opt, val) { + return uci.set('network', this.sid, opt, val); + }, + + /** + * Get the associared Linux network device of this network. + * + * @returns {null|string} + * Returns the name of the associated network device or `null` if + * it could not be determined. + */ + getIfname: function() { + var ifname; + + if (this.isFloating()) + ifname = this._ubus('l3_device'); + else + ifname = this._ubus('device') || this._ubus('l3_device'); + + if (ifname != null) + return ifname; + + var res = getWifiNetidByNetname(this.sid); + return (res != null ? res[0] : null); + }, + + /** + * Get the name of this network protocol class. + * + * This function will be overwritten by subclasses created by + * {@link LuCI.network#registerProtocol Network.registerProtocol()}. + * + * @abstract + * @returns {string} + * Returns the name of the network protocol implementation, e.g. + * `static` or `dhcp`. + */ + getProtocol: function() { + return null; + }, + + /** + * Return a human readable description for the protcol, such as + * `Static address` or `DHCP client`. + * + * This function should be overwritten by subclasses. + * + * @abstract + * @returns {string} + * Returns the description string. + */ + getI18n: function() { + switch (this.getProtocol()) { + case 'none': return _('Unmanaged'); + case 'static': return _('Static address'); + case 'dhcp': return _('DHCP client'); + default: return _('Unknown'); + } + }, + + /** + * Get the type of the underlying interface. + * + * This function actually is a convenience wrapper around + * `proto.get("type")` and is mainly used by other `LuCI.network` code + * to check whether the interface is declared as bridge in UCI. + * + * @returns {null|string} + * Returns the value of the `type` option of the associated logical + * interface or `null` if no `type` option is set. + */ + getType: function() { + return this._get('type'); + }, + + /** + * Get the name of the associated logical interface. + * + * @returns {string} + * Returns the logical interface name, such as `lan` or `wan`. + */ + getName: function() { + return this.sid; + }, + + /** + * Get the uptime of the logical interface. + * + * @returns {number} + * Returns the uptime of the associated interface in seconds. + */ + getUptime: function() { + return this._ubus('uptime') || 0; + }, + + /** + * Get the logical interface expiry time in seconds. + * + * For protocols that have a concept of a lease, such as DHCP or + * DHCPv6, this function returns the remaining time in seconds + * until the lease expires. + * + * @returns {number} + * Returns the amount of seconds until the lease expires or `-1` + * if it isn't applicable to the associated protocol. + */ + 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; + }, + + /** + * Get the metric value of the logical interface. + * + * @returns {number} + * Returns the current metric value used for device and network + * routes spawned by the associated logical interface. + */ + getMetric: function() { + return this._ubus('metric') || 0; + }, + + /** + * Get the requested firewall zone name of the logical interface. + * + * Some protocol implementations request a specific firewall zone + * to trigger inclusion of their resulting network devices into the + * firewall rule set. + * + * @returns {null|string} + * Returns the requested firewall zone name as published in the + * `ubus` runtime information or `null` if the remote protocol + * handler didn't request a zone. + */ + getZoneName: function() { + var d = this._ubus('data'); + + if (L.isObject(d) && typeof(d.zone) == 'string') + return d.zone; + + return null; + }, + + /** + * Query the first (primary) IPv4 address of the logical interface. + * + * @returns {null|string} + * Returns the primary IPv4 address registered by the protocol handler + * or `null` if no IPv4 addresses were set. + */ + getIPAddr: function() { + var addrs = this._ubus('ipv4-address'); + return ((Array.isArray(addrs) && addrs.length) ? addrs[0].address : null); + }, + + /** + * Query all IPv4 addresses of the logical interface. + * + * @returns {string[]} + * Returns an array of IPv4 addresses in CIDR notation which have been + * registered by the protocol handler. The order of the resulting array + * follows the order of the addresses in `ubus` runtime information. + */ + 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; + }, + + /** + * Query the first (primary) IPv4 netmask of the logical interface. + * + * @returns {null|string} + * Returns the netmask of the primary IPv4 address registered by the + * protocol handler or `null` if no IPv4 addresses were set. + */ + getNetmask: function() { + var addrs = this._ubus('ipv4-address'); + if (Array.isArray(addrs) && addrs.length) + return prefixToMask(addrs[0].mask, false); + }, + + /** + * Query the gateway (nexthop) of the default route associated with + * this logical interface. + * + * @returns {string} + * Returns a string containing the IPv4 nexthop address of the associated + * default route or `null` if no default route was found. + */ + 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; + }, + + /** + * Query the IPv4 DNS servers associated with the logical interface. + * + * @returns {string[]} + * Returns an array of IPv4 DNS servers registered by the remote + * protocol backend. + */ + 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; + }, + + /** + * Query the first (primary) IPv6 address of the logical interface. + * + * @returns {null|string} + * Returns the primary IPv6 address registered by the protocol handler + * in CIDR notation or `null` if no IPv6 addresses were set. + */ + 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; + }, + + /** + * Query all IPv6 addresses of the logical interface. + * + * @returns {string[]} + * Returns an array of IPv6 addresses in CIDR notation which have been + * registered by the protocol handler. The order of the resulting array + * follows the order of the addresses in `ubus` runtime information. + */ + 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; + }, + + /** + * Query the gateway (nexthop) of the IPv6 default route associated with + * this logical interface. + * + * @returns {string} + * Returns a string containing the IPv6 nexthop address of the associated + * default route or `null` if no default route was found. + */ + getGateway6Addr: 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 == '::' && + routes[i].mask == 0) + return routes[i].nexthop; + + return null; + }, + + /** + * Query the IPv6 DNS servers associated with the logical interface. + * + * @returns {string[]} + * Returns an array of IPv6 DNS servers registered by the remote + * protocol backend. + */ + 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; + }, + + /** + * Query the routed IPv6 prefix associated with the logical interface. + * + * @returns {null|string} + * Returns the routed IPv6 prefix registered by the remote protocol + * handler or `null` if no prefix is present. + */ + 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; + }, + + /** + * Query interface error messages published in `ubus` runtime state. + * + * Interface errors are emitted by remote protocol handlers if the setup + * of the underlying logical interface failed, e.g. due to bad + * configuration or network connectivity issues. + * + * This function will translate the found error codes to human readable + * messages using the descriptions registered by + * {@link LuCI.network#registerErrorCode Network.registerErrorCode()} + * and fall back to `"Unknown error (%s)"` where `%s` is replaced by the + * error code in case no translation can be found. + * + * @returns {string[]} + * Returns an array of translated interface error messages. + */ + 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; + }, + + /** + * Checks whether the underlying logical interface is declared as bridge. + * + * @returns {boolean} + * Returns `true` when the interface is declared with `option type bridge` + * and when the associated protocol implementation is not marked virtual + * or `false` when the logical interface is no bridge. + */ + isBridge: function() { + return (!this.isVirtual() && this.getType() == 'bridge'); + }, + + /** + * Get the name of the opkg package providing the protocol functionality. + * + * This function should be overwritten by protocol specific subclasses. + * + * @abstract + * + * @returns {string} + * Returns the name of the opkg package required for the protocol to + * function, e.g. `odhcp6c` for the `dhcpv6` prototocol. + */ + getOpkgPackage: function() { + return null; + }, + + /** + * Check function for the protocol handler if a new interface is createable. + * + * This function should be overwritten by protocol specific subclasses. + * + * @abstract + * + * @param {string} ifname + * The name of the interface to be created. + * + * @returns {Promise} + * Returns a promise resolving if new interface is createable, else + * rejects with an error message string. + */ + isCreateable: function(ifname) { + return Promise.resolve(null); + }, + + /** + * Checks whether the protocol functionality is installed. + * + * This function exists for compatibility with old code, it always + * returns `true`. + * + * @deprecated + * @abstract + * + * @returns {boolean} + * Returns `true` if the protocol support is installed, else `false`. + */ + isInstalled: function() { + return true; + }, + + /** + * Checks whether this protocol is "virtual". + * + * A "virtual" protocol is a protocol which spawns its own interfaces + * on demand instead of using existing physical interfaces. + * + * Examples for virtual protocols are `6in4` which `gre` spawn tunnel + * network device on startup, examples for non-virtual protcols are + * `dhcp` or `static` which apply IP configuration to existing interfaces. + * + * This function should be overwritten by subclasses. + * + * @returns {boolean} + * Returns a boolean indicating whether the underlying protocol spawns + * dynamic interfaces (`true`) or not (`false`). + */ + isVirtual: function() { + return false; + }, + + /** + * Checks whether this protocol is "floating". + * + * A "floating" protocol is a protocol which spawns its own interfaces + * on demand, like a virtual one but which relies on an existinf lower + * level interface to initiate the connection. + * + * An example for such a protocol is "pppoe". + * + * This function exists for backwards compatibility with older code + * but should not be used anymore. + * + * @deprecated + * @returns {boolean} + * Returns a boolean indicating whether this protocol is floating (`true`) + * or not (`false`). + */ + isFloating: function() { + return false; + }, + + /** + * Checks whether this logical interface is dynamic. + * + * A dynamic interface is an interface which has been created at runtime, + * e.g. as sub-interface of another interface, but which is not backed by + * any user configuration. Such dynamic interfaces cannot be edited but + * only brought down or restarted. + * + * @returns {boolean} + * Returns a boolean indicating whether this interface is dynamic (`true`) + * or not (`false`). + */ + isDynamic: function() { + return (this._ubus('dynamic') == true); + }, + + /** + * Checks whether this interface is an alias interface. + * + * Alias interfaces are interfaces layering on top of another interface + * and are denoted by a special `@interfacename` notation in the + * underlying `device` option. + * + * @returns {null|string} + * Returns the name of the parent interface if this logical interface + * is an alias or `null` if it is not an alias interface. + */ + isAlias: function() { + var ifnames = L.toArray(uci.get('network', this.sid, 'device')), + 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; + }, + + /** + * Checks whether this logical interface is "empty", meaning that ut + * has no network devices attached. + * + * @returns {boolean} + * Returns `true` if this logical interface is empty, else `false`. + */ + isEmpty: function() { + if (this.isFloating()) + return false; + + var empty = true, + device = this._get('device'); + + if (device != null && device.match(/\S+/)) + empty = false; + + if (empty == true && getWifiNetidBySid(this.sid) != null) + empty = false; + + return empty; + }, + + /** + * Checks whether this logical interface is configured and running. + * + * @returns {boolean} + * Returns `true` when the interface is active or `false` when it is not. + */ + isUp: function() { + return (this._ubus('up') == true); + }, + + /** + * Add the given network device to the logical interface. + * + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} device + * The object or device name to add to the logical interface. In case the + * given argument is not a string, it is resolved though the + * {@link LuCI.network#getIfnameOf Network.getIfnameOf()} function. + * + * @returns {boolean} + * Returns `true` if the device name has been added or `false` if any + * argument was invalid, if the device was already part of the logical + * interface or if the logical interface is virtual. + */ + addDevice: function(device) { + device = ifnameOf(device); + + if (device == null || this.isFloating()) + return false; + + var wif = getWifiSidByIfname(device); + + if (wif != null) + return appendValue('wireless', wif, 'network', this.sid); + + return appendValue('network', this.sid, 'device', device); + }, + + /** + * Remove the given network device from the logical interface. + * + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} device + * The object or device name to remove from the logical interface. In case + * the given argument is not a string, it is resolved though the + * {@link LuCI.network#getIfnameOf Network.getIfnameOf()} function. + * + * @returns {boolean} + * Returns `true` if the device name has been added or `false` if any + * argument was invalid, if the device was already part of the logical + * interface or if the logical interface is virtual. + */ + deleteDevice: function(device) { + var rv = false; + + device = ifnameOf(device); + + if (device == null || this.isFloating()) + return false; + + var wif = getWifiSidByIfname(device); + + if (wif != null) + rv = removeValue('wireless', wif, 'network', this.sid); + + if (removeValue('network', this.sid, 'device', device)) + rv = true; + + return rv; + }, + + /** + * Returns the Linux network device associated with this logical + * interface. + * + * @returns {LuCI.network.Device} + * Returns a `Network.Device` class instance representing the + * expected Linux network device according to the configuration. + */ + getDevice: function() { + if (this.isVirtual()) { + var ifname = '%s-%s'.format(this.getProtocol(), this.sid); + _state.isTunnel[this.getProtocol() + '-' + this.sid] = true; + return Network.prototype.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 ifnames = L.toArray(uci.get('network', this.sid, 'device')); + + for (var i = 0; i < ifnames.length; i++) { + var m = ifnames[i].match(/^([^:/]+)/); + return ((m && m[1]) ? Network.prototype.instantiateDevice(m[1], this) : null); + } + + ifname = getWifiNetidByNetname(this.sid); + + return (ifname != null ? Network.prototype.instantiateDevice(ifname[0], this) : null); + } + }, + + /** + * Returns the layer 2 linux network device currently associated + * with this logical interface. + * + * @returns {LuCI.network.Device} + * Returns a `Network.Device` class instance representing the Linux + * network device currently associated with the logical interface. + */ + getL2Device: function() { + var ifname = this._ubus('device'); + return (ifname != null ? Network.prototype.instantiateDevice(ifname, this) : null); + }, + + /** + * Returns the layer 3 linux network device currently associated + * with this logical interface. + * + * @returns {LuCI.network.Device} + * Returns a `Network.Device` class instance representing the Linux + * network device currently associated with the logical interface. + */ + getL3Device: function() { + var ifname = this._ubus('l3_device'); + return (ifname != null ? Network.prototype.instantiateDevice(ifname, this) : null); + }, + + /** + * Returns a list of network sub-devices associated with this logical + * interface. + * + * @returns {null|Array} + * Returns an array of of `Network.Device` class instances representing + * the sub-devices attached to this logical interface or `null` if the + * logical interface does not support sub-devices, e.g. because it is + * virtual and not a bridge. + */ + getDevices: function() { + var rv = []; + + if (!this.isBridge() && !(this.isVirtual() && !this.isFloating())) + return null; + + var device = uci.get('network', this.sid, 'device'); + + if (device && device.charAt(0) != '@') { + var m = device.match(/^([^:/]+)/); + if (m != null) + rv.push(Network.prototype.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(Network.prototype.instantiateDevice(netid[0], this)); + } + } + + rv.sort(deviceSort); + + return rv; + }, + + /** + * Checks whether this logical interface contains the given device + * object. + * + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} device + * The object or device name to check. In case the given argument is not + * a string, it is resolved though the + * {@link LuCI.network#getIfnameOf Network.getIfnameOf()} function. + * + * @returns {boolean} + * Returns `true` when this logical interface contains the given network + * device or `false` if not. + */ + containsDevice: function(device) { + device = ifnameOf(device); + + if (device == null) + return false; + else if (this.isVirtual() && '%s-%s'.format(this.getProtocol(), this.sid) == device) + return true; + else if (this.isBridge() && 'br-%s'.format(this.sid) == device) + return true; + + var name = uci.get('network', this.sid, 'device'); + if (name) { + var m = name.match(/^([^:/]+)/); + if (m != null && m[1] == device) + return true; + } + + var wif = getWifiSidByIfname(device); + + 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; + }, + + /** + * Cleanup related configuration entries. + * + * This function will be invoked if an interface is about to be removed + * from the configuration and is responsible for performing any required + * cleanup tasks, such as unsetting uci entries in related configurations. + * + * It should be overwritten by protocol specific subclasses. + * + * @abstract + * + * @returns {*|Promise<*>} + * This function may return a promise which is awaited before the rest of + * the configuration is removed. Any non-promise return value and any + * resolved promise value is ignored. If the returned promise is rejected, + * the interface removal will be aborted. + */ + deleteConfiguration: function() {} +}); + +/** + * @class + * @memberof LuCI.network + * @hideconstructor + * @classdesc + * + * A `Network.Device` class instance represents an underlying Linux network + * device and allows querying device details such as packet statistics or MTU. + */ +Device = baseclass.extend(/** @lends LuCI.network.Device.prototype */ { + __init__: function(device, network) { + var wif = getWifiSidByIfname(device); + + if (wif != null) { + var res = getWifiStateBySid(wif) || [], + netid = getWifiNetidBySid(wif) || []; + + this.wif = new WifiNetwork(wif, res[0], res[1], netid[0], res[2], { ifname: device }); + this.device = this.wif.getIfname(); + } + + this.device = this.device || device; + this.dev = Object.assign({}, _state.netdevs[this.device]); + this.network = network; + + var conf; + + uci.sections('network', 'device', function(s) { + if (s.name == device) + conf = s; + }); + + this.config = Object.assign({}, conf); + }, + + _devstate: function(/* ... */) { + var rv = this.dev; + + for (var i = 0; i < arguments.length; i++) + if (L.isObject(rv)) + rv = rv[arguments[i]]; + else + return null; + + return rv; + }, + + /** + * Get the name of the network device. + * + * @returns {string} + * Returns the name of the device, e.g. `eth0` or `wlan0`. + */ + getName: function() { + return (this.wif != null ? this.wif.getIfname() : this.device); + }, + + /** + * Get the MAC address of the device. + * + * @returns {null|string} + * Returns the MAC address of the device or `null` if not applicable, + * e.g. for non-ethernet tunnel devices. + */ + getMAC: function() { + var mac = this._devstate('macaddr'); + return mac ? mac.toUpperCase() : null; + }, + + /** + * Get the MTU of the device. + * + * @returns {number} + * Returns the MTU of the device. + */ + getMTU: function() { + return this._devstate('mtu'); + }, + + /** + * Get the IPv4 addresses configured on the device. + * + * @returns {string[]} + * Returns an array of IPv4 address strings. + */ + getIPAddrs: function() { + var addrs = this._devstate('ipaddrs'); + return (Array.isArray(addrs) ? addrs : []); + }, + + /** + * Get the IPv6 addresses configured on the device. + * + * @returns {string[]} + * Returns an array of IPv6 address strings. + */ + getIP6Addrs: function() { + var addrs = this._devstate('ip6addrs'); + return (Array.isArray(addrs) ? addrs : []); + }, + + /** + * Get the type of the device. + * + * @returns {string} + * Returns a string describing the type of the network device: + * - `alias` if it is an abstract alias device (`@` notation) + * - `wifi` if it is a wireless interface (e.g. `wlan0`) + * - `bridge` if it is a bridge device (e.g. `br-lan`) + * - `tunnel` if it is a tun or tap device (e.g. `tun0`) + * - `vlan` if it is a vlan device (e.g. `eth0.1`) + * - `switch` if it is a switch device (e.g.`eth1` connected to switch0) + * - `ethernet` for all other device types + */ + getType: function() { + if (this.device != null && this.device.charAt(0) == '@') + return 'alias'; + else if (this.dev.devtype == 'wlan' || this.wif != null || isWifiIfname(this.device)) + return 'wifi'; + else if (this.dev.devtype == 'bridge' || _state.isBridge[this.device]) + return 'bridge'; + else if (_state.isTunnel[this.device]) + return 'tunnel'; + else if (this.dev.devtype == 'vlan' || this.device.indexOf('.') > -1) + return 'vlan'; + else if (this.dev.devtype == 'dsa' || _state.isSwitch[this.device]) + return 'switch'; + else if (this.config.type == '8021q' || this.config.type == '8021ad') + return 'vlan'; + else if (this.config.type == 'bridge') + return 'bridge'; + else + return 'ethernet'; + }, + + /** + * Get a short description string for the device. + * + * @returns {string} + * Returns the device name for non-wifi devices or a string containing + * the operation mode and SSID for wifi devices. + */ + getShortName: function() { + if (this.wif != null) + return this.wif.getShortName(); + + return this.device; + }, + + /** + * Get a long description string for the device. + * + * @returns {string} + * Returns a string containing the type description and device name + * for non-wifi devices or operation mode and ssid for wifi ones. + */ + 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()); + }, + + /** + * Get a string describing the device type. + * + * @returns {string} + * Returns a string describing the type, e.g. "Wireless Adapter" or + * "Bridge". + */ + getTypeI18n: function() { + switch (this.getType()) { + case 'alias': + return _('Alias Interface'); + + case 'wifi': + return _('Wireless Adapter'); + + case 'bridge': + return _('Bridge'); + + case 'switch': + return (_state.netdevs[this.device] && _state.netdevs[this.device].devtype == 'dsa') + ? _('Switch port') : _('Ethernet Switch'); + + case 'vlan': + return (_state.isSwitch[this.device] ? _('Switch VLAN') : _('Software VLAN')); + + case 'tunnel': + return _('Tunnel Interface'); + + default: + return _('Ethernet Adapter'); + } + }, + + /** + * Get the associated bridge ports of the device. + * + * @returns {null|Array} + * Returns an array of `Network.Device` instances representing the ports + * (slave interfaces) of the bridge or `null` when this device isn't + * a Linux bridge. + */ + getPorts: function() { + var br = _state.bridges[this.device], + rv = []; + + if (br == null || !Array.isArray(br.ifnames)) + return null; + + for (var i = 0; i < br.ifnames.length; i++) + rv.push(Network.prototype.instantiateDevice(br.ifnames[i].name)); + + rv.sort(deviceSort); + + return rv; + }, + + /** + * Get the bridge ID + * + * @returns {null|string} + * Returns the ID of this network bridge or `null` if this network + * device is not a Linux bridge. + */ + getBridgeID: function() { + var br = _state.bridges[this.device]; + return (br != null ? br.id : null); + }, + + /** + * Get the bridge STP setting + * + * @returns {boolean} + * Returns `true` when this device is a Linux bridge and has `stp` + * enabled, else `false`. + */ + getBridgeSTP: function() { + var br = _state.bridges[this.device]; + return (br != null ? !!br.stp : false); + }, + + /** + * Checks whether this device is up. + * + * @returns {boolean} + * Returns `true` when the associated device is running pr `false` + * when it is down or absent. + */ + isUp: function() { + var up = this._devstate('flags', 'up'); + + if (up == null) + up = (this.getType() == 'alias'); + + return up; + }, + + /** + * Checks whether this device is a Linux bridge. + * + * @returns {boolean} + * Returns `true` when the network device is present and a Linux bridge, + * else `false`. + */ + isBridge: function() { + return (this.getType() == 'bridge'); + }, + + /** + * Checks whether this device is part of a Linux bridge. + * + * @returns {boolean} + * Returns `true` when this network device is part of a bridge, + * else `false`. + */ + isBridgePort: function() { + return (this._devstate('bridge') != null); + }, + + /** + * Get the amount of transmitted bytes. + * + * @returns {number} + * Returns the amount of bytes transmitted by the network device. + */ + getTXBytes: function() { + var stat = this._devstate('stats'); + return (stat != null ? stat.tx_bytes || 0 : 0); + }, + + /** + * Get the amount of received bytes. + * + * @returns {number} + * Returns the amount of bytes received by the network device. + */ + getRXBytes: function() { + var stat = this._devstate('stats'); + return (stat != null ? stat.rx_bytes || 0 : 0); + }, + + /** + * Get the amount of transmitted packets. + * + * @returns {number} + * Returns the amount of packets transmitted by the network device. + */ + getTXPackets: function() { + var stat = this._devstate('stats'); + return (stat != null ? stat.tx_packets || 0 : 0); + }, + + /** + * Get the amount of received packets. + * + * @returns {number} + * Returns the amount of packets received by the network device. + */ + getRXPackets: function() { + var stat = this._devstate('stats'); + return (stat != null ? stat.rx_packets || 0 : 0); + }, + + /** + * Get the carrier state of the network device. + * + * @returns {boolean} + * Returns true if the device has a carrier, e.g. when a cable is + * inserted into an ethernet port of false if there is none. + */ + getCarrier: function() { + var link = this._devstate('link'); + return (link != null ? link.carrier || false : false); + }, + + /** + * Get the current link speed of the network device if available. + * + * @returns {number|null} + * Returns the current speed of the network device in Mbps. If the + * device supports no ethernet speed levels, null is returned. + * If the device supports ethernet speeds but has no carrier, -1 is + * returned. + */ + getSpeed: function() { + var link = this._devstate('link'); + return (link != null ? link.speed || null : null); + }, + + /** + * Get the current duplex mode of the network device if available. + * + * @returns {string|null} + * Returns the current duplex mode of the network device. Returns + * either "full" or "half" if the device supports duplex modes or + * null if the duplex mode is unknown or unsupported. + */ + getDuplex: function() { + var link = this._devstate('link'), + duplex = link ? link.duplex : null; + + return (duplex != 'unknown') ? duplex : null; + }, + + /** + * Get the primary logical interface this device is assigned to. + * + * @returns {null|LuCI.network.Protocol} + * Returns a `Network.Protocol` instance representing the logical + * interface this device is attached to or `null` if it is not + * assigned to any logical interface. + */ + getNetwork: function() { + return this.getNetworks()[0]; + }, + + /** + * Get the logical interfaces this device is assigned to. + * + * @returns {Array} + * Returns an array of `Network.Protocol` instances representing the + * logical interfaces this device is assigned to. + */ + getNetworks: function() { + if (this.networks == null) { + this.networks = []; + + var networks = enumerateNetworks.apply(L.network); + + for (var i = 0; i < networks.length; i++) + if (networks[i].containsDevice(this.device) || networks[i].getIfname() == this.device) + this.networks.push(networks[i]); + + this.networks.sort(networkSort); + } + + return this.networks; + }, + + /** + * Get the related wireless network this device is related to. + * + * @returns {null|LuCI.network.WifiNetwork} + * Returns a `Network.WifiNetwork` instance representing the wireless + * network corresponding to this network device or `null` if this device + * is not a wireless device. + */ + getWifiNetwork: function() { + return (this.wif != null ? this.wif : null); + }, + + /** + * Get the logical parent device of this device. + * + * In case of DSA switch ports, the parent device will be the DSA switch + * device itself, for VLAN devices, the parent refers to the base device + * etc. + * + * @returns {null|LuCI.network.Device} + * Returns a `Network.Device` instance representing the parent device or + * `null` when this device has no parent, as it is the case for e.g. + * ordinary ethernet interfaces. + */ + getParent: function() { + if (this.dev.parent) + return Network.prototype.instantiateDevice(this.dev.parent); + + if ((this.config.type == '8021q' || this.config.type == '802ad') && typeof(this.config.ifname) == 'string') + return Network.prototype.instantiateDevice(this.config.ifname); + + return null; + } +}); + +/** + * @class + * @memberof LuCI.network + * @hideconstructor + * @classdesc + * + * A `Network.WifiDevice` class instance represents a wireless radio device + * present on the system and provides wireless capability information as + * well as methods for enumerating related wireless networks. + */ +WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { + __init__: function(name, radiostate) { + var uciWifiDevice = uci.get('wireless', name); + + if (uciWifiDevice != null && + uciWifiDevice['.type'] == 'wifi-device' && + uciWifiDevice['.name'] != null) { + this.sid = uciWifiDevice['.name']; + } + + this.sid = this.sid || name; + this._ubusdata = { + radio: name, + dev: radiostate + }; + }, + + /* private */ + 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; + }, + + /** + * Read the given UCI option value of this wireless device. + * + * @param {string} opt + * The UCI option name to read. + * + * @returns {null|string|string[]} + * Returns the UCI option value or `null` if the requested option is + * not found. + */ + get: function(opt) { + return uci.get('wireless', this.sid, opt); + }, + + /** + * Set the given UCI option of this network to the given value. + * + * @param {string} opt + * The name of the UCI option to set. + * + * @param {null|string|string[]} val + * The value to set or `null` to remove the given option from the + * configuration. + */ + set: function(opt, value) { + return uci.set('wireless', this.sid, opt, value); + }, + + /** + * Checks whether this wireless radio is disabled. + * + * @returns {boolean} + * Returns `true` when the wireless radio is marked as disabled in `ubus` + * runtime state or when the `disabled` option is set in the corresponding + * UCI configuration. + */ + isDisabled: function() { + return this.ubus('dev', 'disabled') || this.get('disabled') == '1'; + }, + + /** + * Get the configuration name of this wireless radio. + * + * @returns {string} + * Returns the UCI section name (e.g. `radio0`) of the corresponding + * radio configuration which also serves as unique logical identifier + * for the wireless phy. + */ + getName: function() { + return this.sid; + }, + + /** + * Gets a list of supported hwmodes. + * + * The hwmode values describe the frequency band and wireless standard + * versions supported by the wireless phy. + * + * @returns {string[]} + * Returns an array of valid hwmode values for this radio. Currently + * known mode values are: + * - `a` - Legacy 802.11a mode, 5 GHz, up to 54 Mbit/s + * - `b` - Legacy 802.11b mode, 2.4 GHz, up to 11 Mbit/s + * - `g` - Legacy 802.11g mode, 2.4 GHz, up to 54 Mbit/s + * - `n` - IEEE 802.11n mode, 2.4 or 5 GHz, up to 600 Mbit/s + * - `ac` - IEEE 802.11ac mode, 5 GHz, up to 6770 Mbit/s + * - `ax` - IEEE 802.11ax mode, 2.4 or 5 GHz + */ + getHWModes: function() { + var hwmodes = this.ubus('dev', 'iwinfo', 'hwmodes'); + return Array.isArray(hwmodes) ? hwmodes : [ 'b', 'g' ]; + }, + + /** + * Gets a list of supported htmodes. + * + * The htmode values describe the wide-frequency options supported by + * the wireless phy. + * + * @returns {string[]} + * Returns an array of valid htmode values for this radio. Currently + * known mode values are: + * - `HT20` - applicable to IEEE 802.11n, 20 MHz wide channels + * - `HT40` - applicable to IEEE 802.11n, 40 MHz wide channels + * - `VHT20` - applicable to IEEE 802.11ac, 20 MHz wide channels + * - `VHT40` - applicable to IEEE 802.11ac, 40 MHz wide channels + * - `VHT80` - applicable to IEEE 802.11ac, 80 MHz wide channels + * - `VHT160` - applicable to IEEE 802.11ac, 160 MHz wide channels + * - `HE20` - applicable to IEEE 802.11ax, 20 MHz wide channels + * - `HE40` - applicable to IEEE 802.11ax, 40 MHz wide channels + * - `HE80` - applicable to IEEE 802.11ax, 80 MHz wide channels + * - `HE160` - applicable to IEEE 802.11ax, 160 MHz wide channels + */ + getHTModes: function() { + var htmodes = this.ubus('dev', 'iwinfo', 'htmodes'); + return (Array.isArray(htmodes) && htmodes.length) ? htmodes : null; + }, + + /** + * Get a string describing the wireless radio hardware. + * + * @returns {string} + * Returns the description string. + */ + getI18n: function() { + var hw = this.ubus('dev', 'iwinfo', 'hardware'), + type = L.isObject(hw) ? hw.name : null; + + if (this.ubus('dev', 'iwinfo', 'type') == 'wl') + type = 'Broadcom'; + + return '%s 802.11%s Wireless Controller (%s)'.format( + type || 'Generic', + this.getHWModes().sort(L.naturalCompare).join(''), + this.getName()); + }, + + /** + * A wireless scan result object describes a neighbouring wireless + * network found in the vincinity. + * + * @typedef {Object} WifiScanResult + * @memberof LuCI.network + * + * @property {string} ssid + * The SSID / Mesh ID of the network. + * + * @property {string} bssid + * The BSSID if the network. + * + * @property {string} mode + * The operation mode of the network (`Master`, `Ad-Hoc`, `Mesh Point`). + * + * @property {number} channel + * The wireless channel of the network. + * + * @property {number} signal + * The received signal strength of the network in dBm. + * + * @property {number} quality + * The numeric quality level of the signal, can be used in conjunction + * with `quality_max` to calculate a quality percentage. + * + * @property {number} quality_max + * The maximum possible quality level of the signal, can be used in + * conjunction with `quality` to calculate a quality percentage. + * + * @property {LuCI.network.WifiEncryption} encryption + * The encryption used by the wireless network. + */ + + /** + * Trigger a wireless scan on this radio device and obtain a list of + * nearby networks. + * + * @returns {Promise>} + * Returns a promise resolving to an array of scan result objects + * describing the networks found in the vincinity. + */ + getScanList: function() { + return callIwinfoScan(this.sid); + }, + + /** + * Check whether the wireless radio is marked as up in the `ubus` + * runtime state. + * + * @returns {boolean} + * Returns `true` when the radio device is up, else `false`. + */ + isUp: function() { + if (L.isObject(_state.radios[this.sid])) + return (_state.radios[this.sid].up == true); + + return false; + }, + + /** + * Get the wifi network of the given name belonging to this radio device + * + * @param {string} network + * The name of the wireless network to lookup. This may be either an uci + * configuration section ID, a network ID in the form `radio#.network#` + * or a Linux network device name like `wlan0` which is resolved to the + * corresponding configuration section through `ubus` runtime information. + * + * @returns {Promise} + * Returns a promise resolving to a `Network.WifiNetwork` instance + * representing the wireless network and rejecting with `null` if + * the given network could not be found or is not associated with + * this radio device. + */ + getWifiNetwork: function(network) { + return Network.prototype.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)); + }, + + /** + * Get all wireless networks associated with this wireless radio device. + * + * @returns {Promise>} + * Returns a promise resolving to an array of `Network.WifiNetwork` + * instances respresenting the wireless networks associated with this + * radio device. + */ + getWifiNetworks: function() { + return Network.prototype.getWifiNetworks().then(L.bind(function(networks) { + var rv = []; + + for (var i = 0; i < networks.length; i++) + if (networks[i].getWifiDeviceName() == this.getName()) + rv.push(networks[i]); + + return rv; + }, this)); + }, + + /** + * Adds a new wireless network associated with this radio device to the + * configuration and sets its options to the provided values. + * + * @param {Object} [options] + * The options to set for the newly added wireless network. + * + * @returns {Promise} + * Returns a promise resolving to a `WifiNetwork` instance describing + * the newly added wireless network or `null` if the given options + * were invalid. + */ + addWifiNetwork: function(options) { + if (!L.isObject(options)) + options = {}; + + options.device = this.sid; + + return Network.prototype.addWifiNetwork(options); + }, + + /** + * Deletes the wireless network with the given name associated with this + * radio device. + * + * @param {string} network + * The name of the wireless network to lookup. This may be either an uci + * configuration section ID, a network ID in the form `radio#.network#` + * or a Linux network device name like `wlan0` which is resolved to the + * corresponding configuration section through `ubus` runtime information. + * + * @returns {Promise} + * Returns a promise resolving to `true` when the wireless network was + * successfully deleted from the configuration or `false` when the given + * network could not be found or if the found network was not associated + * with this wireless radio device. + */ + 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); + } +}); + +/** + * @class + * @memberof LuCI.network + * @hideconstructor + * @classdesc + * + * A `Network.WifiNetwork` instance represents a wireless network (vif) + * configured on top of a radio device and provides functions for querying + * the runtime state of the network. Most radio devices support multiple + * such networks in parallel. + */ +WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ { + __init__: function(sid, radioname, radiostate, netid, netstate, hostapd) { + this.sid = sid; + this.netid = netid; + this._ubusdata = { + hostapd: hostapd, + 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; + }, + + /** + * Read the given UCI option value of this wireless network. + * + * @param {string} opt + * The UCI option name to read. + * + * @returns {null|string|string[]} + * Returns the UCI option value or `null` if the requested option is + * not found. + */ + get: function(opt) { + return uci.get('wireless', this.sid, opt); + }, + + /** + * Set the given UCI option of this network to the given value. + * + * @param {string} opt + * The name of the UCI option to set. + * + * @param {null|string|string[]} val + * The value to set or `null` to remove the given option from the + * configuration. + */ + set: function(opt, value) { + return uci.set('wireless', this.sid, opt, value); + }, + + /** + * Checks whether this wireless network is disabled. + * + * @returns {boolean} + * Returns `true` when the wireless radio is marked as disabled in `ubus` + * runtime state or when the `disabled` option is set in the corresponding + * UCI configuration. + */ + isDisabled: function() { + return this.ubus('dev', 'disabled') || this.get('disabled') == '1'; + }, + + /** + * Get the configured operation mode of the wireless network. + * + * @returns {string} + * Returns the configured operation mode. Possible values are: + * - `ap` - Master (Access Point) mode + * - `sta` - Station (client) mode + * - `adhoc` - Ad-Hoc (IBSS) mode + * - `mesh` - Mesh (IEEE 802.11s) mode + * - `monitor` - Monitor mode + */ + getMode: function() { + return this.ubus('net', 'config', 'mode') || this.get('mode') || 'ap'; + }, + + /** + * Get the configured SSID of the wireless network. + * + * @returns {null|string} + * Returns the configured SSID value or `null` when this network is + * in mesh mode. + */ + getSSID: function() { + if (this.getMode() == 'mesh') + return null; + + return this.ubus('net', 'config', 'ssid') || this.get('ssid'); + }, + + /** + * Get the configured Mesh ID of the wireless network. + * + * @returns {null|string} + * Returns the configured mesh ID value or `null` when this network + * is not in mesh mode. + */ + getMeshID: function() { + if (this.getMode() != 'mesh') + return null; + + return this.ubus('net', 'config', 'mesh_id') || this.get('mesh_id'); + }, + + /** + * Get the configured BSSID of the wireless network. + * + * @returns {null|string} + * Returns the BSSID value or `null` if none has been specified. + */ + getBSSID: function() { + return this.ubus('net', 'config', 'bssid') || this.get('bssid'); + }, + + /** + * Get the names of the logical interfaces this wireless network is + * attached to. + * + * @returns {string[]} + * Returns an array of logical interface names. + */ + getNetworkNames: function() { + return L.toArray(this.ubus('net', 'config', 'network') || this.get('network')); + }, + + /** + * Get the internal network ID of this wireless network. + * + * The network ID is a LuCI specific identifer in the form + * `radio#.network#` to identify wireless networks by their corresponding + * radio and network index numbers. + * + * @returns {string} + * Returns the LuCI specific network ID. + */ + getID: function() { + return this.netid; + }, + + /** + * Get the configuration ID of this wireless network. + * + * @returns {string} + * Returns the corresponding UCI section ID of the network. + */ + getName: function() { + return this.sid; + }, + + /** + * Get the Linux network device name. + * + * @returns {null|string} + * Returns the current Linux network device name as resolved from + * `ubus` runtime information or `null` if this network has no + * associated network device, e.g. when not configured or up. + */ + getIfname: function() { + var ifname = this.ubus('net', 'ifname') || this.ubus('net', 'iwinfo', 'ifname'); + + if (ifname == null || ifname.match(/^(wifi|radio)\d/)) + ifname = this.netid; + + return ifname; + }, + + /** + * Get the Linux VLAN network device names. + * + * @returns {string[]} + * Returns the current Linux VLAN network device name as resolved + * from `ubus` runtime information or empty array if this network + * has no associated VLAN network devices. + */ + getVlanIfnames: function() { + var vlans = L.toArray(this.ubus('net', 'vlans')), + ifnames = []; + + for (var i = 0; i < vlans.length; i++) + ifnames.push(vlans[i]['ifname']); + + return ifnames; + }, + + /** + * Get the name of the corresponding wifi radio device. + * + * @returns {null|string} + * Returns the name of the radio device this network is configured on + * or `null` if it cannot be determined. + */ + getWifiDeviceName: function() { + return this.ubus('radio') || this.get('device'); + }, + + /** + * Get the corresponding wifi radio device. + * + * @returns {null|LuCI.network.WifiDevice} + * Returns a `Network.WifiDevice` instance representing the corresponding + * wifi radio device or `null` if the related radio device could not be + * found. + */ + getWifiDevice: function() { + var radioname = this.getWifiDeviceName(); + + if (radioname == null) + return Promise.reject(); + + return Network.prototype.getWifiDevice(radioname); + }, + + /** + * Check whether the radio network is up. + * + * This function actually queries the up state of the related radio + * device and assumes this network to be up as well when the parent + * radio is up. This is due to the fact that OpenWrt does not control + * virtual interfaces individually but within one common hostapd + * instance. + * + * @returns {boolean} + * Returns `true` when the network is up, else `false`. + */ + isUp: function() { + var device = this.getDevice(); + + if (device == null) + return false; + + return device.isUp(); + }, + + /** + * Query the current operation mode from runtime information. + * + * @returns {string} + * Returns the human readable mode name as reported by `ubus` runtime + * state. Possible returned values are: + * - `Master` + * - `Ad-Hoc` + * - `Client` + * - `Monitor` + * - `Master (VLAN)` + * - `WDS` + * - `Mesh Point` + * - `P2P Client` + * - `P2P Go` + * - `Unknown` + */ + getActiveMode: function() { + var mode = this.ubus('net', '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; + } + }, + + /** + * Query the current operation mode from runtime information as + * translated string. + * + * @returns {string} + * Returns the translated, human readable mode name as reported by + *`ubus` runtime state. + */ + 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; + } + }, + + /** + * Query the current SSID from runtime information. + * + * @returns {string} + * Returns the current SSID or Mesh ID as reported by `ubus` runtime + * information. + */ + getActiveSSID: function() { + return this.ubus('net', 'iwinfo', 'ssid') || this.ubus('net', 'config', 'ssid') || this.get('ssid'); + }, + + /** + * Query the current BSSID from runtime information. + * + * @returns {string} + * Returns the current BSSID or Mesh ID as reported by `ubus` runtime + * information. + */ + getActiveBSSID: function() { + return this.ubus('net', 'iwinfo', 'bssid') || this.ubus('net', 'config', 'bssid') || this.get('bssid'); + }, + + /** + * Query the current encryption settings from runtime information. + * + * @returns {string} + * Returns a string describing the current encryption or `-` if the the + * encryption state could not be found in `ubus` runtime information. + */ + getActiveEncryption: function() { + return formatWifiEncryption(this.ubus('net', 'iwinfo', 'encryption')) || '-'; + }, + + /** + * A wireless peer entry describes the properties of a remote wireless + * peer associated with a local network. + * + * @typedef {Object} WifiPeerEntry + * @memberof LuCI.network + * + * @property {string} mac + * The MAC address (BSSID). + * + * @property {number} signal + * The received signal strength. + * + * @property {number} [signal_avg] + * The average signal strength if supported by the driver. + * + * @property {number} [noise] + * The current noise floor of the radio. May be `0` or absent if not + * supported by the driver. + * + * @property {number} inactive + * The amount of milliseconds the peer has been inactive, e.g. due + * to powersave. + * + * @property {number} connected_time + * The amount of milliseconds the peer is associated to this network. + * + * @property {number} [thr] + * The estimated throughput of the peer, May be `0` or absent if not + * supported by the driver. + * + * @property {boolean} authorized + * Specifies whether the peer is authorized to associate to this network. + * + * @property {boolean} authenticated + * Specifies whether the peer completed authentication to this network. + * + * @property {string} preamble + * The preamble mode used by the peer. May be `long` or `short`. + * + * @property {boolean} wme + * Specifies whether the peer supports WME/WMM capabilities. + * + * @property {boolean} mfp + * Specifies whether management frame protection is active. + * + * @property {boolean} tdls + * Specifies whether TDLS is active. + * + * @property {number} [mesh llid] + * The mesh LLID, may be `0` or absent if not applicable or supported + * by the driver. + * + * @property {number} [mesh plid] + * The mesh PLID, may be `0` or absent if not applicable or supported + * by the driver. + * + * @property {string} [mesh plink] + * The mesh peer link state description, may be an empty string (`''`) + * or absent if not applicable or supported by the driver. + * + * The following states are known: + * - `LISTEN` + * - `OPN_SNT` + * - `OPN_RCVD` + * - `CNF_RCVD` + * - `ESTAB` + * - `HOLDING` + * - `BLOCKED` + * - `UNKNOWN` + * + * @property {number} [mesh local PS] + * The local powersafe mode for the peer link, may be an empty + * string (`''`) or absent if not applicable or supported by + * the driver. + * + * The following modes are known: + * - `ACTIVE` (no power save) + * - `LIGHT SLEEP` + * - `DEEP SLEEP` + * - `UNKNOWN` + * + * @property {number} [mesh peer PS] + * The remote powersafe mode for the peer link, may be an empty + * string (`''`) or absent if not applicable or supported by + * the driver. + * + * The following modes are known: + * - `ACTIVE` (no power save) + * - `LIGHT SLEEP` + * - `DEEP SLEEP` + * - `UNKNOWN` + * + * @property {number} [mesh non-peer PS] + * The powersafe mode for all non-peer neigbours, may be an empty + * string (`''`) or absent if not applicable or supported by the driver. + * + * The following modes are known: + * - `ACTIVE` (no power save) + * - `LIGHT SLEEP` + * - `DEEP SLEEP` + * - `UNKNOWN` + * + * @property {LuCI.network.WifiRateEntry} rx + * Describes the receiving wireless rate from the peer. + * + * @property {LuCI.network.WifiRateEntry} tx + * Describes the transmitting wireless rate to the peer. + */ + + /** + * A wireless rate entry describes the properties of a wireless + * transmission rate to or from a peer. + * + * @typedef {Object} WifiRateEntry + * @memberof LuCI.network + * + * @property {number} [drop_misc] + * The amount of received misc. packages that have been dropped, e.g. + * due to corruption or missing authentication. Only applicable to + * receiving rates. + * + * @property {number} packets + * The amount of packets that have been received or sent. + * + * @property {number} bytes + * The amount of bytes that have been received or sent. + * + * @property {number} [failed] + * The amount of failed tranmission attempts. Only applicable to + * transmit rates. + * + * @property {number} [retries] + * The amount of retried transmissions. Only applicable to transmit + * rates. + * + * @property {boolean} is_ht + * Specifies whether this rate is an HT (IEEE 802.11n) rate. + * + * @property {boolean} is_vht + * Specifies whether this rate is an VHT (IEEE 802.11ac) rate. + * + * @property {number} mhz + * The channel width in MHz used for the transmission. + * + * @property {number} rate + * The bitrate in bit/s of the transmission. + * + * @property {number} [mcs] + * The MCS index of the used transmission rate. Only applicable to + * HT or VHT rates. + * + * @property {number} [40mhz] + * Specifies whether the tranmission rate used 40MHz wide channel. + * Only applicable to HT or VHT rates. + * + * Note: this option exists for backwards compatibility only and its + * use is discouraged. The `mhz` field should be used instead to + * determine the channel width. + * + * @property {boolean} [short_gi] + * Specifies whether a short guard interval is used for the transmission. + * Only applicable to HT or VHT rates. + * + * @property {number} [nss] + * Specifies the number of spatial streams used by the transmission. + * Only applicable to VHT rates. + * + * @property {boolean} [he] + * Specifies whether this rate is an HE (IEEE 802.11ax) rate. + * + * @property {number} [he_gi] + * Specifies whether the guard interval used for the transmission. + * Only applicable to HE rates. + * + * @property {number} [he_dcm] + * Specifies whether dual concurrent modulation is used for the transmission. + * Only applicable to HE rates. + */ + + /** + * Fetch the list of associated peers. + * + * @returns {Promise>} + * Returns a promise resolving to an array of wireless peers associated + * with this network. + */ + getAssocList: function() { + var tasks = []; + var ifnames = [ this.getIfname() ].concat(this.getVlanIfnames()); + + for (var i = 0; i < ifnames.length; i++) + tasks.push(callIwinfoAssoclist(ifnames[i])); + + return Promise.all(tasks).then(function(values) { + return Array.prototype.concat.apply([], values); + }); + }, + + /** + * Query the current operating frequency of the wireless network. + * + * @returns {null|string} + * Returns the current operating frequency of the network from `ubus` + * runtime information in GHz or `null` if the information is not + * available. + */ + getFrequency: function() { + var freq = this.ubus('net', 'iwinfo', 'frequency'); + + if (freq != null && freq > 0) + return '%.03f'.format(freq / 1000); + + return null; + }, + + /** + * Query the current average bitrate of all peers associated to this + * wireless network. + * + * @returns {null|number} + * Returns the average bit rate among all peers associated to the network + * as reported by `ubus` runtime information or `null` if the information + * is not available. + */ + getBitRate: function() { + var rate = this.ubus('net', 'iwinfo', 'bitrate'); + + if (rate != null && rate > 0) + return (rate / 1000); + + return null; + }, + + /** + * Query the current wireless channel. + * + * @returns {null|number} + * Returns the wireless channel as reported by `ubus` runtime information + * or `null` if it cannot be determined. + */ + getChannel: function() { + return this.ubus('net', 'iwinfo', 'channel') || this.ubus('dev', 'config', 'channel') || this.get('channel'); + }, + + /** + * Query the current wireless signal. + * + * @returns {null|number} + * Returns the wireless signal in dBm as reported by `ubus` runtime + * information or `null` if it cannot be determined. + */ + getSignal: function() { + return this.ubus('net', 'iwinfo', 'signal') || 0; + }, + + /** + * Query the current radio noise floor. + * + * @returns {number} + * Returns the radio noise floor in dBm as reported by `ubus` runtime + * information or `0` if it cannot be determined. + */ + getNoise: function() { + return this.ubus('net', 'iwinfo', 'noise') || 0; + }, + + /** + * Query the current country code. + * + * @returns {string} + * Returns the wireless country code as reported by `ubus` runtime + * information or `00` if it cannot be determined. + */ + getCountryCode: function() { + return this.ubus('net', 'iwinfo', 'country') || this.ubus('dev', 'config', 'country') || '00'; + }, + + /** + * Query the current radio TX power. + * + * @returns {null|number} + * Returns the wireless network transmit power in dBm as reported by + * `ubus` runtime information or `null` if it cannot be determined. + */ + getTXPower: function() { + return this.ubus('net', 'iwinfo', 'txpower'); + }, + + /** + * Query the radio TX power offset. + * + * Some wireless radios have a fixed power offset, e.g. due to the + * use of external amplifiers. + * + * @returns {number} + * Returns the wireless network transmit power offset in dBm as reported + * by `ubus` runtime information or `0` if there is no offset, or if it + * cannot be determined. + */ + getTXPowerOffset: function() { + return this.ubus('net', 'iwinfo', 'txpower_offset') || 0; + }, + + /** + * Calculate the current signal. + * + * @deprecated + * @returns {number} + * Returns the calculated signal level, which is the difference between + * noise and signal (SNR), divided by 5. + */ + 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; + }, + + /** + * Calculate the current signal quality percentage. + * + * @returns {number} + * Returns the calculated signal quality in percent. The value is + * calculated from the `quality` and `quality_max` indicators reported + * by `ubus` runtime state. + */ + getSignalPercent: function() { + var qc = this.ubus('net', 'iwinfo', 'quality') || 0, + qm = this.ubus('net', 'iwinfo', 'quality_max') || 0; + + if (qc > 0 && qm > 0) + return Math.floor((100 / qm) * qc); + + return 0; + }, + + /** + * Get a short description string for this wireless network. + * + * @returns {string} + * Returns a string describing this network, consisting of the + * active operation mode, followed by either the SSID, BSSID or + * internal network ID, depending on which information is available. + */ + getShortName: function() { + return '%s "%s"'.format( + this.getActiveModeI18n(), + this.getActiveSSID() || this.getActiveBSSID() || this.getID()); + }, + + /** + * Get a description string for this wireless network. + * + * @returns {string} + * Returns a string describing this network, consisting of the + * term `Wireless Network`, followed by the active operation mode, + * the SSID, BSSID or internal network ID and the Linux network device + * name, depending on which information is available. + */ + getI18n: function() { + return '%s: %s "%s" (%s)'.format( + _('Wireless Network'), + this.getActiveModeI18n(), + this.getActiveSSID() || this.getActiveBSSID() || this.getID(), + this.getIfname()); + }, + + /** + * Get the primary logical interface this wireless network is attached to. + * + * @returns {null|LuCI.network.Protocol} + * Returns a `Network.Protocol` instance representing the logical + * interface or `null` if this network is not attached to any logical + * interface. + */ + getNetwork: function() { + return this.getNetworks()[0]; + }, + + /** + * Get the logical interfaces this wireless network is attached to. + * + * @returns {Array} + * Returns an array of `Network.Protocol` instances representing the + * logical interfaces this wireless network is attached to. + */ + 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(Network.prototype.instantiateNetwork(networkNames[i])); + } + + networks.sort(networkSort); + + return networks; + }, + + /** + * Get the associated Linux network device. + * + * @returns {LuCI.network.Device} + * Returns a `Network.Device` instance representing the Linux network + * device associted with this wireless network. + */ + getDevice: function() { + return Network.prototype.instantiateDevice(this.getIfname()); + }, + + /** + * Check whether this wifi network supports deauthenticating clients. + * + * @returns {boolean} + * Returns `true` when this wifi network instance supports forcibly + * deauthenticating clients, otherwise `false`. + */ + isClientDisconnectSupported: function() { + return L.isObject(this.ubus('hostapd', 'del_client')); + }, + + /** + * Forcibly disconnect the given client from the wireless network. + * + * @param {string} mac + * The MAC address of the client to disconnect. + * + * @param {boolean} [deauth=false] + * Specifies whether to deauthenticate (`true`) or disassociate (`false`) + * the client. + * + * @param {number} [reason=1] + * Specifies the IEEE 802.11 reason code to disassoc/deauth the client + * with. Default is `1` which corresponds to `Unspecified reason`. + * + * @param {number} [ban_time=0] + * Specifies the amount of milliseconds to ban the client from + * reconnecting. By default, no ban time is set which allows the client + * to reassociate / reauthenticate immediately. + * + * @returns {Promise} + * Returns a promise resolving to the underlying ubus call result code + * which is typically `0`, even for not existing MAC addresses. + * The promise might reject with an error in case invalid arguments + * are passed. + */ + disconnectClient: function(mac, deauth, reason, ban_time) { + if (reason == null || reason == 0) + reason = 1; + + if (ban_time == 0) + ban_time = null; + + return rpc.declare({ + object: 'hostapd.%s'.format(this.getIfname()), + method: 'del_client', + params: [ 'addr', 'deauth', 'reason', 'ban_time' ] + })(mac, deauth, reason, ban_time); + } +}); + +return Network; diff --git a/htdocs/luci-static/resources/promis.min.js b/htdocs/luci-static/resources/promis.min.js new file mode 100644 index 0000000..ff71b69 --- /dev/null +++ b/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 0 })[0]; + + if (firstsubnet == null) + return null; + + var addr_mask = firstsubnet.split('/'), + addr = validation.parseIPv4(addr_mask[0]), + mask = addr_mask[1]; + + if (!isNaN(mask)) + mask = validation.parseIPv4(network.prefixToMask(+mask)); + else + mask = validation.parseIPv4(mask); + + var bc = [ + addr[0] | (~mask[0] >>> 0 & 255), + addr[1] | (~mask[1] >>> 0 & 255), + addr[2] | (~mask[2] >>> 0 & 255), + addr[3] | (~mask[3] >>> 0 & 255) + ]; + + return bc.join('.'); +} + +function validateBroadcast(section_id, value) { + var opt = this.map.lookupOption('broadcast', section_id), + node = opt ? this.map.findElement('id', opt[0].cbid(section_id)) : null, + addr = node ? calculateBroadcast(this.section, false) : null; + + if (node != null) { + if (addr != null) + node.querySelector('input').setAttribute('placeholder', addr); + else + node.querySelector('input').removeAttribute('placeholder'); + } + + return true; +} + +return network.registerProtocol('static', { + CBIIPValue: form.Value.extend({ + handleSwitch: function(section_id, option_index, ev) { + var maskopt = this.map.lookupOption('netmask', section_id); + + if (maskopt == null || !this.isValid(section_id)) + return; + + var maskval = maskopt[0].formvalue(section_id), + addrval = this.formvalue(section_id), + prefix = maskval ? network.maskToPrefix(maskval) : 32; + + if (prefix == null) + return; + + this.datatype = 'or(cidr4,ipmask4)'; + + var parent = L.dom.parent(ev.target, '.cbi-value-field'); + L.dom.content(parent, form.DynamicList.prototype.renderWidget.apply(this, [ + section_id, + option_index, + addrval ? '%s/%d'.format(addrval, prefix) : '' + ])); + + var masknode = this.map.findElement('id', maskopt[0].cbid(section_id)); + if (masknode) { + parent = L.dom.parent(masknode, '.cbi-value'); + parent.parentNode.removeChild(parent); + } + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var maskopt = this.map.lookupOption('netmask', section_id), + widget = isCIDR(cfgvalue) ? 'DynamicList' : 'Value'; + + if (widget == 'DynamicList') { + this.datatype = 'or(cidr4,ipmask4)'; + this.placeholder = _('Add IPv4 address…'); + } + else { + this.datatype = 'ip4addr("nomask")'; + } + + var node = form[widget].prototype.renderWidget.apply(this, [ section_id, option_index, cfgvalue ]); + + if (widget == 'Value') + L.dom.append(node, E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'title': _('Switch to CIDR list notation'), + 'aria-label': _('Switch to CIDR list notation'), + 'click': L.bind(this.handleSwitch, this, section_id, option_index) + }, '…')); + + return node; + }, + + validate: validateBroadcast + }), + + CBINetmaskValue: form.Value.extend({ + render: function(option_index, section_id, in_table) { + var addropt = this.section.children.filter(function(o) { return o.option == 'ipaddr' })[0], + addrval = addropt ? addropt.cfgvalue(section_id) : null; + + if (addrval != null && isCIDR(addrval)) + return E([]); + + this.value('255.255.255.0'); + this.value('255.255.0.0'); + this.value('255.0.0.0'); + + return form.Value.prototype.render.apply(this, [ option_index, section_id, in_table ]); + }, + + validate: validateBroadcast + }), + + CBIGatewayValue: form.Value.extend({ + datatype: 'ip4addr("nomask")', + + render: function(option_index, section_id, in_table) { + return network.getWANNetworks().then(L.bind(function(wans) { + if (wans.length == 1) { + var gwaddr = wans[0].getGatewayAddr(); + this.placeholder = gwaddr ? '%s (%s)'.format(gwaddr, wans[0].getName()) : ''; + } + + return form.Value.prototype.render.apply(this, [ option_index, section_id, in_table ]); + }, this)); + }, + + validate: function(section_id, value) { + var addropt = this.section.children.filter(function(o) { return o.option == 'ipaddr' })[0], + addrval = addropt ? L.toArray(addropt.cfgvalue(section_id)) : null; + + if (addrval != null) { + for (var i = 0; i < addrval.length; i++) { + var addr = addrval[i].split('/')[0]; + if (value == addr) + return _('The gateway address must not be a local IP address'); + } + } + + return true; + } + }), + + CBIBroadcastValue: form.Value.extend({ + datatype: 'ip4addr("nomask")', + + render: function(option_index, section_id, in_table) { + this.placeholder = calculateBroadcast(this.section, true); + return form.Value.prototype.render.apply(this, [ option_index, section_id, in_table ]); + } + }), + + getI18n: function() { + return _('Static address'); + }, + + renderFormOptions: function(s) { + var o; + + s.taboption('general', this.CBIIPValue, 'ipaddr', _('IPv4 address')); + s.taboption('general', this.CBINetmaskValue, 'netmask', _('IPv4 netmask')); + s.taboption('general', this.CBIGatewayValue, 'gateway', _('IPv4 gateway')); + s.taboption('general', this.CBIBroadcastValue, 'broadcast', _('IPv4 broadcast')); + + o = s.taboption('general', form.DynamicList, 'ip6addr', _('IPv6 address')); + o.datatype = 'ip6addr'; + o.placeholder = _('Add IPv6 address…'); + o.depends('ip6assign', ''); + + o = s.taboption('general', form.Value, 'ip6gw', _('IPv6 gateway')); + o.datatype = 'ip6addr("nomask")'; + o.depends('ip6assign', ''); + + o = s.taboption('general', form.Value, 'ip6prefix', _('IPv6 routed prefix'), _('Public prefix routed to this device for distribution to clients.')); + o.datatype = 'ip6addr'; + o.depends('ip6assign', ''); + } +}); diff --git a/htdocs/luci-static/resources/rpc.js b/htdocs/luci-static/resources/rpc.js new file mode 100644 index 0000000..f37f7bb --- /dev/null +++ b/htdocs/luci-static/resources/rpc.js @@ -0,0 +1,485 @@ +'use strict'; +'require baseclass'; +'require request'; + +var rpcRequestID = 1, + rpcSessionID = L.env.sessionid || '00000000000000000000000000000000', + rpcBaseURL = L.url('admin/ubus'), + rpcInterceptorFns = []; + +/** + * @class rpc + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `LuCI.rpc` class provides high level ubus JSON-RPC abstractions + * and means for listing and invoking remove RPC methods. + */ +return baseclass.extend(/** @lends LuCI.rpc.prototype */ { + /* privates */ + call: function(req, cb, nobatch) { + var q = ''; + + if (Array.isArray(req)) { + if (req.length == 0) + return Promise.resolve([]); + + for (var i = 0; i < req.length; i++) + if (req[i].params) + q += '%s%s.%s'.format( + q ? ';' : '/', + req[i].params[1], + req[i].params[2] + ); + } + + return request.post(rpcBaseURL + q, req, { + timeout: (L.env.rpctimeout || 20) * 1000, + nobatch: nobatch, + credentials: true + }).then(cb, cb); + }, + + parseCallReply: function(req, res) { + var msg = null; + + if (res instanceof Error) + return req.reject(res); + + try { + if (!res.ok) + L.raise('RPCError', 'RPC call to %s/%s failed with HTTP error %d: %s', + req.object, req.method, res.status, res.statusText || '?'); + + msg = res.json(); + } + catch (e) { + return req.reject(e); + } + + /* + * The interceptor args are intentionally swapped. + * Response is passed as first arg to align with Request class interceptors + */ + Promise.all(rpcInterceptorFns.map(function(fn) { return fn(msg, req) })) + .then(this.handleCallReply.bind(this, req, msg)) + .catch(req.reject); + }, + + handleCallReply: function(req, msg) { + var type = Object.prototype.toString, + ret = null; + + try { + /* verify message frame */ + if (!L.isObject(msg) || msg.jsonrpc != '2.0') + L.raise('RPCError', 'RPC call to %s/%s returned invalid message frame', + req.object, req.method); + + /* check error condition */ + if (L.isObject(msg.error) && msg.error.code && msg.error.message) + L.raise('RPCError', 'RPC call to %s/%s failed with error %d: %s', + req.object, req.method, msg.error.code, msg.error.message || '?'); + } + catch (e) { + return req.reject(e); + } + + if (!req.object && !req.method) { + ret = msg.result; + } + else if (Array.isArray(msg.result)) { + if (req.raise && msg.result[0] !== 0) + L.raise('RPCError', 'RPC call to %s/%s failed with ubus code %d: %s', + req.object, req.method, msg.result[0], this.getStatusText(msg.result[0])); + + ret = (msg.result.length > 1) ? msg.result[1] : msg.result[0]; + } + + 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); + }, + + /** + * Lists available remote ubus objects or the method signatures of + * specific objects. + * + * This function has two signatures and is sensitive to the number of + * arguments passed to it: + * - `list()` - + * Returns an array containing the names of all remote `ubus` objects + * - `list("objname", ...)` + * Returns method signatures for each given `ubus` object name. + * + * @param {...string} [objectNames] + * If any object names are given, this function will return the method + * signatures of each given object. + * + * @returns {Promise|Object>>>} + * When invoked without arguments, this function will return a promise + * resolving to an array of `ubus` object names. When invoked with one or + * more arguments, a promise resolving to an object describing the method + * signatures of each requested `ubus` object name will be returned. + */ + list: function() { + var msg = { + jsonrpc: '2.0', + id: rpcRequestID++, + method: 'list', + params: arguments.length ? this.varargs(arguments) : undefined + }; + + return new Promise(L.bind(function(resolveFn, rejectFn) { + /* store request info */ + var req = { + resolve: resolveFn, + reject: rejectFn + }; + + /* call rpc */ + this.call(msg, this.parseCallReply.bind(this, req)); + }, this)); + }, + + /** + * @typedef {Object} DeclareOptions + * @memberof LuCI.rpc + * + * @property {string} object + * The name of the remote `ubus` object to invoke. + * + * @property {string} method + * The name of the remote `ubus` method to invoke. + * + * @property {string[]} [params] + * Lists the named parameters expected by the remote `ubus` RPC method. + * The arguments passed to the resulting generated method call function + * will be mapped to named parameters in the order they appear in this + * array. + * + * Extraneous parameters passed to the generated function will not be + * sent to the remote procedure but are passed to the + * {@link LuCI.rpc~filterFn filter function} if one is specified. + * + * Examples: + * - `params: [ "foo", "bar" ]` - + * When the resulting call function is invoked with `fn(true, false)`, + * the corresponding args object sent to the remote procedure will be + * `{ foo: true, bar: false }`. + * - `params: [ "test" ], filter: function(reply, args, extra) { ... }` - + * When the resultung generated function is invoked with + * `fn("foo", "bar", "baz")` then `{ "test": "foo" }` will be sent as + * argument to the remote procedure and the filter function will be + * invoked with `filterFn(reply, [ "foo" ], "bar", "baz")` + * + * @property {Object} [expect] + * Describes the expected return data structure. The given object is + * supposed to contain a single key selecting the value to use from + * the returned `ubus` reply object. The value of the sole key within + * the `expect` object is used to infer the expected type of the received + * `ubus` reply data. + * + * If the received data does not contain `expect`'s key, or if the + * type of the data differs from the type of the value in the expect + * object, the expect object's value is returned as default instead. + * + * The key in the `expect` object may be an empty string (`''`) in which + * case the entire reply object is selected instead of one of its subkeys. + * + * If the `expect` option is omitted, the received reply will be returned + * as-is, regardless of its format or type. + * + * Examples: + * - `expect: { '': { error: 'Invalid response' } }` - + * This requires the entire `ubus` reply to be a plain JavaScript + * object. If the reply isn't an object but e.g. an array or a numeric + * error code instead, it will get replaced with + * `{ error: 'Invalid response' }` instead. + * - `expect: { results: [] }` - + * This requires the received `ubus` reply to be an object containing + * a key `results` with an array as value. If the received reply does + * not contain such a key, or if `reply.results` points to a non-array + * value, the empty array (`[]`) will be used instead. + * - `expect: { success: false }` - + * This requires the received `ubus` reply to be an object containing + * a key `success` with a boolean value. If the reply does not contain + * `success` or if `reply.success` is not a boolean value, `false` will + * be returned as default instead. + * + * @property {LuCI.rpc~filterFn} [filter] + * Specfies an optional filter function which is invoked to transform the + * received reply data before it is returned to the caller. + * + * @property {boolean} [reject=false] + * If set to `true`, non-zero ubus call status codes are treated as fatal + * error and lead to the rejection of the call promise. The default + * behaviour is to resolve with the call return code value instead. + */ + + /** + * The filter function is invoked to transform a received `ubus` RPC call + * reply before returning it to the caller. + * + * @callback LuCI.rpc~filterFn + * + * @param {*} data + * The received `ubus` reply data or a subset of it as described in the + * `expect` option of the RPC call declaration. In case of remote call + * errors, `data` is numeric `ubus` error code instead. + * + * @param {Array<*>} args + * The arguments the RPC method has been invoked with. + * + * @param {...*} extraArgs + * All extraneous arguments passed to the RPC method exceeding the number + * of arguments describes in the RPC call declaration. + * + * @return {*} + * The return value of the filter function will be returned to the caller + * of the RPC method as-is. + */ + + /** + * The generated invocation function is returned by + * {@link LuCI.rpc#declare rpc.declare()} and encapsulates a single + * RPC method call. + * + * Calling this function will execute a remote `ubus` HTTP call request + * using the arguments passed to it as arguments and return a promise + * resolving to the received reply values. + * + * @callback LuCI.rpc~invokeFn + * + * @param {...*} params + * The parameters to pass to the remote procedure call. The given + * positional arguments will be named to named RPC parameters according + * to the names specified in the `params` array of the method declaration. + * + * Any additional parameters exceeding the amount of arguments in the + * `params` declaration are passed as private extra arguments to the + * declared filter function. + * + * @return {Promise<*>} + * Returns a promise resolving to the result data of the remote `ubus` + * RPC method invocation, optionally substituted and filtered according + * to the `expect` and `filter` declarations. + */ + + /** + * Describes a remote RPC call procedure and returns a function + * implementing it. + * + * @param {LuCI.rpc.DeclareOptions} options + * If any object names are given, this function will return the method + * signatures of each given object. + * + * @returns {LuCI.rpc~invokeFn} + * Returns a new function implementing the method call described in + * `options`. + */ + 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, + object: options.object, + method: options.method, + raise: options.reject + }; + + /* 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.parseCallReply.bind(rpc, req), options.nobatch); + }); + }, this, this, options); + }, + + /** + * Returns the current RPC session id. + * + * @returns {string} + * Returns the 32 byte session ID string used for authenticating remote + * requests. + */ + getSessionID: function() { + return rpcSessionID; + }, + + /** + * Set the RPC session id to use. + * + * @param {string} sid + * Sets the 32 byte session ID string used for authenticating remote + * requests. + */ + setSessionID: function(sid) { + rpcSessionID = sid; + }, + + /** + * Returns the current RPC base URL. + * + * @returns {string} + * Returns the RPC URL endpoint to issue requests against. + */ + getBaseURL: function() { + return rpcBaseURL; + }, + + /** + * Set the RPC base URL to use. + * + * @param {string} sid + * Sets the RPC URL endpoint to issue requests against. + */ + setBaseURL: function(url) { + rpcBaseURL = url; + }, + + /** + * Translates a numeric `ubus` error code into a human readable + * description. + * + * @param {number} statusCode + * The numeric status code. + * + * @returns {string} + * Returns the textual description of the code. + */ + getStatusText: function(statusCode) { + switch (statusCode) { + case 0: return _('Command OK'); + case 1: return _('Invalid command'); + case 2: return _('Invalid argument'); + case 3: return _('Method not found'); + case 4: return _('Resource not found'); + case 5: return _('No data received'); + case 6: return _('Permission denied'); + case 7: return _('Request timeout'); + case 8: return _('Not supported'); + case 9: return _('Unspecified error'); + case 10: return _('Connection lost'); + default: return _('Unknown error code'); + } + }, + + /** + * Registered interceptor functions are invoked before the standard reply + * parsing and handling logic. + * + * By returning rejected promises, interceptor functions can cause the + * invocation function to fail, regardless of the received reply. + * + * Interceptors may also modify their message argument in-place to + * rewrite received replies before they're processed by the standard + * response handling code. + * + * A common use case for such functions is to detect failing RPC replies + * due to expired authentication in order to trigger a new login. + * + * @callback LuCI.rpc~interceptorFn + * + * @param {*} msg + * The unprocessed, JSON decoded remote RPC method call reply. + * + * Since interceptors run before the standard parsing logic, the reply + * data is not verified for correctness or filtered according to + * `expect` and `filter` specifications in the declarations. + * + * @param {Object} req + * The related request object which is an extended variant of the + * declaration object, allowing access to internals of the invocation + * function such as `filter`, `expect` or `params` values. + * + * @return {Promise<*>|*} + * Interceptor functions may return a promise to defer response + * processing until some delayed work completed. Any values the returned + * promise resolves to are ignored. + * + * When the returned promise rejects with an error, the invocation + * function will fail too, forwarding the error to the caller. + */ + + /** + * Registers a new interceptor function. + * + * @param {LuCI.rpc~interceptorFn} interceptorFn + * The inteceptor function to register. + * + * @returns {LuCI.rpc~interceptorFn} + * Returns the given function value. + */ + addInterceptor: function(interceptorFn) { + if (typeof(interceptorFn) == 'function') + rpcInterceptorFns.push(interceptorFn); + return interceptorFn; + }, + + /** + * Removes a registered interceptor function. + * + * @param {LuCI.rpc~interceptorFn} interceptorFn + * The inteceptor function to remove. + * + * @returns {boolean} + * Returns `true` if the given function has been removed or `false` + * if it has not been found. + */ + removeInterceptor: function(interceptorFn) { + var oldlen = rpcInterceptorFns.length, i = oldlen; + while (i--) + if (rpcInterceptorFns[i] === interceptorFn) + rpcInterceptorFns.splice(i, 1); + return (rpcInterceptorFns.length < oldlen); + } +}); diff --git a/htdocs/luci-static/resources/tools/prng.js b/htdocs/luci-static/resources/tools/prng.js new file mode 100644 index 0000000..b916cc7 --- /dev/null +++ b/htdocs/luci-static/resources/tools/prng.js @@ -0,0 +1,111 @@ +'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; + }, + + derive_color: function(string) { + this.seed(parseInt(sfh(string), 16)); + + var r = this.get(128), + g = this.get(128), + min = 0, + max = 128; + + if ((r + g) < 128) + min = 128 - r - g; + else + max = 255 - r - g; + + var b = min + Math.floor(this.get() * (max - min)); + + return '#%02x%02x%02x'.format(0xff - r, 0xff - g, 0xff - b); + } +}); diff --git a/htdocs/luci-static/resources/tools/widgets.js b/htdocs/luci-static/resources/tools/widgets.js new file mode 100644 index 0000000..14948bb --- /dev/null +++ b/htdocs/luci-static/resources/tools/widgets.js @@ -0,0 +1,629 @@ +'use strict'; +'require ui'; +'require form'; +'require network'; +'require firewall'; +'require fs'; + +function getUsers() { + return fs.lines('/etc/passwd').then(function(lines) { + return lines.map(function(line) { return line.split(/:/)[0] }); + }); +} + +function getGroups() { + return fs.lines('/etc/group').then(function(lines) { + return lines.map(function(line) { return line.split(/:/)[0] }); + }); +} + +var CBIZoneSelect = form.ListValue.extend({ + __name__: 'CBI.ZoneSelect', + + 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), + isOutputOnly = false, + choices = {}; + + if (this.option == 'dest') { + for (var i = 0; i < this.section.children.length; i++) { + var opt = this.section.children[i]; + if (opt.option == 'src') { + var val = opt.cfgvalue(section_id) || opt.default; + isOutputOnly = (val == null || val == ''); + break; + } + } + + this.title = isOutputOnly ? _('Output zone') : _('Destination zone'); + } + + if (this.allowlocal) { + choices[''] = E('span', { + 'class': 'zonebadge', + 'style': firewall.getZoneColorStyle(null) + }, [ + E('strong', _('Device')), + (this.allowany || this.allowlocal) + ? E('span', ' (%s)'.format(this.option != 'dest' ? _('output') : _('input'))) : '' + ]); + } + else if (!this.multiple && (this.rmempty || this.optional)) { + choices[''] = E('span', { + 'class': 'zonebadge', + 'style': firewall.getZoneColorStyle(null) + }, E('em', _('unspecified'))); + } + + if (this.allowany) { + choices['*'] = E('span', { + 'class': 'zonebadge', + 'style': firewall.getZoneColorStyle(null) + }, [ + E('strong', _('Any zone')), + (this.allowany && this.allowlocal && !isOutputOnly) ? E('span', ' (%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': firewall.getZoneColorStyle(zone) + }, [ 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, + disabled: (this.readonly != null) ? this.readonly : this.map.readonly, + 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')+')' + + '' + + '
  • ' + }); + + var elem = widget.render(); + + if (this.option == 'src') { + elem.addEventListener('cbi-dropdown-change', L.bind(function(ev) { + var opt = this.map.lookupOption('dest', section_id), + val = ev.detail.instance.getValue(); + + if (opt == null) + return; + + var cbid = opt[0].cbid(section_id), + label = document.querySelector('label[for="widget.%s"]'.format(cbid)), + node = document.getElementById(cbid); + + L.dom.content(label, val == '' ? _('Output zone') : _('Destination zone')); + + if (val == '') { + if (L.dom.callClassMethod(node, 'getValue') == '') + L.dom.callClassMethod(node, 'setValue', '*'); + + var emptyval = node.querySelector('[data-value=""]'), + anyval = node.querySelector('[data-value="*"]'); + + L.dom.content(anyval.querySelector('span'), E('strong', _('Any zone'))); + + if (emptyval != null) + emptyval.parentNode.removeChild(emptyval); + } + else { + var anyval = node.querySelector('[data-value="*"]'), + emptyval = node.querySelector('[data-value=""]'); + + if (emptyval == null) { + emptyval = anyval.cloneNode(true); + emptyval.removeAttribute('display'); + emptyval.removeAttribute('selected'); + emptyval.setAttribute('data-value', ''); + } + + if (opt[0].allowlocal) + L.dom.content(emptyval.querySelector('span'), [ + E('strong', _('Device')), E('span', ' (%s)'.format(_('input'))) + ]); + + L.dom.content(anyval.querySelector('span'), [ + E('strong', _('Any zone')), E('span', ' (%s)'.format(_('forward'))) + ]); + + anyval.parentNode.insertBefore(emptyval, anyval); + } + + }, this)); + } + else if (isOutputOnly) { + var emptyval = elem.querySelector('[data-value=""]'); + emptyval.parentNode.removeChild(emptyval); + } + + return elem; + }, +}); + +var CBIZoneForwards = form.DummyValue.extend({ + __name__: 'CBI.ZoneForwards', + + load: function(section_id) { + return Promise.all([ + firewall.getDefaults(), + firewall.getZones(), + network.getNetworks(), + network.getDevices() + ]).then(L.bind(function(dznd) { + this.defaults = dznd[0]; + this.zones = dznd[1]; + this.networks = dznd[2]; + this.devices = dznd[3]; + + return this.super('load', section_id); + }, this)); + }, + + renderZone: function(zone) { + var name = zone.getName(), + networks = zone.getNetworks(), + devices = zone.getDevices(), + subnets = zone.getSubnets(), + 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 subdevs = network.isBridge() ? network.getDevices() : L.toArray(network.getDevice()); + + for (var k = 0; k < subdevs.length && subdevs[k]; k++) { + span.appendChild(E('img', { + 'title': subdevs[k].getI18n(), + 'src': L.resource('icons/%s%s.png'.format(subdevs[k].getType(), subdevs[k].isUp() ? '' : '_disabled')) + })); + } + + if (!subdevs.length) + span.appendChild(E('em', _('(empty)'))); + + ifaces.push(span); + } + + for (var i = 0; i < devices.length; i++) { + var device = this.devices.filter(function(dev) { return dev.getName() == devices[i] })[0], + title = device ? device.getI18n() : _('Absent Interface'), + type = device ? device.getType() : 'ethernet', + up = device ? device.isUp() : false; + + ifaces.push(E('span', { 'class': 'ifacebadge' }, [ + E('img', { + 'title': title, + 'src': L.resource('icons/%s%s.png'.format(type, up ? '' : '_disabled')) + }), + device ? device.getName() : devices[i] + ])); + } + + if (subnets.length > 0) + ifaces.push(E('span', { 'class': 'ifacebadge' }, [ '{ %s }'.format(subnets.join('; ')) ])); + + if (!ifaces.length) + ifaces.push(E('span', { 'class': 'ifacebadge' }, E('em', _('(empty)')))); + + return E('label', { + 'class': 'zonebadge cbi-tooltip-container', + 'style': firewall.getZoneColorStyle(zone) + }, [ + 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 == this.exclude || !this.filter(section_id, name)) + continue; + + if (name == 'loopback' && !this.loopback) + 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, + disabled: (this.readonly != null) ? this.readonly : this.map.readonly, + select_placeholder: E('em', _('unspecified')), + display_items: this.display_size || this.size || 3, + dropdown_items: this.dropdown_size || this.size || 5, + datatype: this.multiple ? 'list(uciname)' : 'uciname', + 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; + }, +}); + +var CBIDeviceSelect = form.ListValue.extend({ + __name__: 'CBI.DeviceSelect', + + load: function(section_id) { + return Promise.all([ + network.getDevices(), + this.noaliases ? null : network.getNetworks() + ]).then(L.bind(function(data) { + this.devices = data[0]; + this.networks = data[1]; + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var values = L.toArray((cfgvalue != null) ? cfgvalue : this.default), + choices = {}, + checked = {}, + order = []; + + 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.devices.length; i++) { + var device = this.devices[i], + name = device.getName(), + type = device.getType(); + + if (name == 'lo' || name == this.exclude || !this.filter(section_id, name)) + continue; + + if (this.noaliases && type == 'alias') + continue; + + if (this.nobridges && type == 'bridge') + continue; + + if (this.noinactive && device.isUp() == false) + continue; + + var item = E([ + E('img', { + 'title': device.getI18n(), + 'src': L.resource('icons/%s%s.png'.format(type, device.isUp() ? '' : '_disabled')) + }), + E('span', { 'class': 'hide-open' }, [ name ]), + E('span', { 'class': 'hide-close'}, [ device.getI18n() ]) + ]); + + var networks = device.getNetworks(); + + if (networks.length > 0) + L.dom.append(item.lastChild, [ ' (', networks.map(function(n) { return n.getName() }).join(', '), ')' ]); + + if (checked[name]) + values.push(name); + + choices[name] = item; + order.push(name); + } + + if (this.networks != null) { + for (var i = 0; i < this.networks.length; i++) { + var net = this.networks[i], + device = network.instantiateDevice('@%s'.format(net.getName()), net), + name = device.getName(); + + if (name == '@loopback' || name == this.exclude || !this.filter(section_id, name)) + continue; + + if (this.noinactive && net.isUp() == false) + continue; + + var item = E([ + E('img', { + 'title': device.getI18n(), + 'src': L.resource('icons/alias%s.png'.format(net.isUp() ? '' : '_disabled')) + }), + E('span', { 'class': 'hide-open' }, [ name ]), + E('span', { 'class': 'hide-close'}, [ device.getI18n() ]) + ]); + + if (checked[name]) + values.push(name); + + choices[name] = item; + order.push(name); + } + } + + if (!this.nocreate) { + var keys = Object.keys(checked).sort(L.naturalCompare); + + for (var i = 0; i < keys.length; i++) { + if (choices.hasOwnProperty(keys[i])) + continue; + + choices[keys[i]] = E([ + E('img', { + 'title': _('Absent Interface'), + 'src': L.resource('icons/ethernet_disabled.png') + }), + E('span', { 'class': 'hide-open' }, [ keys[i] ]), + E('span', { 'class': 'hide-close'}, [ '%s: "%h"'.format(_('Absent Interface'), keys[i]) ]) + ]); + + values.push(keys[i]); + order.push(keys[i]); + } + } + + var widget = new ui.Dropdown(this.multiple ? values : values[0], choices, { + id: this.cbid(section_id), + sort: order, + multiple: this.multiple, + optional: this.optional || this.rmempty, + disabled: (this.readonly != null) ? this.readonly : this.map.readonly, + 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}}' + + ''+_('Custom Interface')+': "{{value}}"' + + '
  • ' + }); + + return widget.render(); + }, +}); + +var CBIUserSelect = form.ListValue.extend({ + __name__: 'CBI.UserSelect', + + load: function(section_id) { + return getUsers().then(L.bind(function(users) { + delete this.keylist; + delete this.vallist; + for (var i = 0; i < users.length; i++) { + this.value(users[i]); + } + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, +}); + +var CBIGroupSelect = form.ListValue.extend({ + __name__: 'CBI.GroupSelect', + + load: function(section_id) { + return getGroups().then(L.bind(function(groups) { + for (var i = 0; i < groups.length; i++) { + this.value(groups[i]); + } + + return this.super('load', section_id); + }, this)); + }, + + filter: function(section_id, value) { + return true; + }, +}); + + +return L.Class.extend({ + ZoneSelect: CBIZoneSelect, + ZoneForwards: CBIZoneForwards, + NetworkSelect: CBINetworkSelect, + DeviceSelect: CBIDeviceSelect, + UserSelect: CBIUserSelect, + GroupSelect: CBIGroupSelect, +}); diff --git a/htdocs/luci-static/resources/uci.js b/htdocs/luci-static/resources/uci.js new file mode 100644 index 0000000..a3a0061 --- /dev/null +++ b/htdocs/luci-static/resources/uci.js @@ -0,0 +1,988 @@ +'use strict'; +'require rpc'; +'require baseclass'; + +function isEmpty(object, ignore) { + for (var property in object) + if (object.hasOwnProperty(property) && property != ignore) + return false; + + return true; +} + +/** + * @class uci + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `LuCI.uci` class utilizes {@link LuCI.rpc} to declare low level + * remote UCI `ubus` procedures and implements a local caching and data + * manipulation layer on top to allow for synchroneous operations on + * UCI configuration data. + */ +return baseclass.extend(/** @lends LuCI.uci.prototype */ { + __init__: function() { + this.state = { + newidx: 0, + values: { }, + creates: { }, + changes: { }, + deletes: { }, + reorder: { } + }; + + this.loaded = {}; + }, + + callLoad: rpc.declare({ + object: 'uci', + method: 'get', + params: [ 'config' ], + expect: { values: { } }, + reject: true + }), + + callOrder: rpc.declare({ + object: 'uci', + method: 'order', + params: [ 'config', 'sections' ], + reject: true + }), + + callAdd: rpc.declare({ + object: 'uci', + method: 'add', + params: [ 'config', 'type', 'name', 'values' ], + expect: { section: '' }, + reject: true + }), + + callSet: rpc.declare({ + object: 'uci', + method: 'set', + params: [ 'config', 'section', 'values' ], + reject: true + }), + + callDelete: rpc.declare({ + object: 'uci', + method: 'delete', + params: [ 'config', 'section', 'options' ], + reject: true + }), + + callApply: rpc.declare({ + object: 'uci', + method: 'apply', + params: [ 'timeout', 'rollback' ], + reject: true + }), + + callConfirm: rpc.declare({ + object: 'uci', + method: 'confirm', + reject: true + }), + + + /** + * Generates a new, unique section ID for the given configuration. + * + * Note that the generated ID is temporary, it will get replaced by an + * identifier in the form `cfgXXXXXX` once the configuration is saved + * by the remote `ubus` UCI api. + * + * @param {string} config + * The configuration to generate the new section ID for. + * + * @returns {string} + * A newly generated, unique section ID in the form `newXXXXXX` + * where `X` denotes a hexadecimal digit. + */ + 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; + }, + + /** + * Resolves a given section ID in extended notation to the internal + * section ID value. + * + * @param {string} config + * The configuration to resolve the section ID for. + * + * @param {string} sid + * The section ID to resolve. If the ID is in the form `@typename[#]`, + * it will get resolved to an internal anonymous ID in the forms + * `cfgXXXXXX`/`newXXXXXX` or to the name of a section in case it points + * to a named section. When the given ID is not in extended notation, + * it will be returned as-is. + * + * @returns {string|null} + * Returns the resolved section ID or the original given ID if it was + * not in extended notation. Returns `null` when an extended ID could + * not be resolved to existing section ID. + */ + 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; + }, + + /* private */ + 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); + }, + + /* private */ + loadPackage: function(packageName) { + if (this.loaded[packageName] == null) + return (this.loaded[packageName] = this.callLoad(packageName)); + + return Promise.resolve(this.loaded[packageName]); + }, + + /** + * Loads the given UCI configurations from the remote `ubus` api. + * + * Loaded configurations are cached and only loaded once. Subsequent + * load operations of the same configurations will return the cached + * data. + * + * To force reloading a configuration, it has to be unloaded with + * {@link LuCI.uci#unload uci.unload()} first. + * + * @param {string|string[]} config + * The name of the configuration or an array of configuration + * names to load. + * + * @returns {Promise} + * Returns a promise resolving to the names of the configurations + * that have been successfully loaded. + */ + 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; + }); + }, + + /** + * Unloads the given UCI configurations from the local cache. + * + * @param {string|string[]} config + * The name of the configuration or an array of configuration + * names to unload. + */ + 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]]; + } + }, + + /** + * Adds a new section of the given type to the given configuration, + * optionally named according to the given name. + * + * @param {string} config + * The name of the configuration to add the section to. + * + * @param {string} type + * The type of the section to add. + * + * @param {string} [name] + * The name of the section to add. If the name is omitted, an anonymous + * section will be added instead. + * + * @returns {string} + * Returns the section ID of the newly added section which is equivalent + * to the given name for non-anonymous sections. + */ + 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; + }, + + /** + * Removes the section with the given ID from the given configuration. + * + * @param {string} config + * The name of the configuration to remove the section from. + * + * @param {string} sid + * The ID of the section to remove. + */ + remove: function(conf, sid) { + var v = this.state.values, + 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 (v[conf] && v[conf][sid]) { + if (c[conf]) + delete c[conf][sid]; + + if (!d[conf]) + d[conf] = { }; + + d[conf][sid] = true; + } + }, + + /** + * A section object represents the options and their corresponding values + * enclosed within a configuration section, as well as some additional + * meta data such as sort indexes and internal ID. + * + * Any internal metadata fields are prefixed with a dot which is isn't + * an allowed character for normal option names. + * + * @typedef {Object} SectionObject + * @memberof LuCI.uci + * + * @property {boolean} .anonymous + * The `.anonymous` property specifies whether the configuration is + * anonymous (`true`) or named (`false`). + * + * @property {number} .index + * The `.index` property specifes the sort order of the section. + * + * @property {string} .name + * The `.name` property holds the name of the section object. It may be + * either an anonymous ID in the form `cfgXXXXXX` or `newXXXXXX` with `X` + * being a hexadecimal digit or a string holding the name of the section. + * + * @property {string} .type + * The `.type` property contains the type of the corresponding uci + * section. + * + * @property {string|string[]} * + * A section object may contain an arbitrary number of further properties + * representing the uci option enclosed in the section. + * + * All option property names will be in the form `[A-Za-z0-9_]+` and + * either contain a string value or an array of strings, in case the + * underlying option is an UCI list. + */ + + /** + * The sections callback is invoked for each section found within + * the given configuration and receives the section object and its + * associated name as arguments. + * + * @callback LuCI.uci~sectionsFn + * + * @param {LuCI.uci.SectionObject} section + * The section object. + * + * @param {string} sid + * The name or ID of the section. + */ + + /** + * Enumerates the sections of the given configuration, optionally + * filtered by type. + * + * @param {string} config + * The name of the configuration to enumerate the sections for. + * + * @param {string} [type] + * Enumerate only sections of the given type. If omitted, enumerate + * all sections. + * + * @param {LuCI.uci~sectionsFn} [cb] + * An optional callback to invoke for each enumerated section. + * + * @returns {Array} + * Returns a sorted array of the section objects within the given + * configuration, filtered by type of a type has been specified. + */ + 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] : null)); + + 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; + }, + + /** + * Gets the value of the given option within the specified section + * of the given configuration or the entire section object if the + * option name is omitted. + * + * @param {string} config + * The name of the configuration to read the value from. + * + * @param {string} sid + * The name or ID of the section to read. + * + * @param {string} [option] + * The option name to read the value from. If the option name is + * omitted or `null`, the entire section is returned instead. + * + * @returns {null|string|string[]|LuCI.uci.SectionObject} + * - Returns a string containing the option value in case of a + * plain UCI option. + * - Returns an array of strings containing the option values in + * case of `option` pointing to an UCI list. + * - Returns a {@link LuCI.uci.SectionObject section object} if + * the `option` argument has been omitted or is `null`. + * - Returns `null` if the config, section or option has not been + * found or if the corresponding configuration is not loaded. + */ + 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 null; + + 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 || d[conf][sid][opt]) + return null; + + /* 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 null; + } + + /* requested an entire section */ + if (v[conf]) { + /* check whether entire section was deleted */ + if (d[conf] && d[conf][sid] === true) + return null; + + var s = v[conf][sid] || null; + + if (s) { + /* merge changes */ + if (c[conf] && c[conf][sid]) + for (var opt in c[conf][sid]) + if (c[conf][sid][opt] != null) + s[opt] = c[conf][sid][opt]; + + /* merge deletions */ + if (d[conf] && d[conf][sid]) + for (var opt in d[conf][sid]) + delete s[opt]; + } + + return s; + } + + return null; + }, + + /** + * Sets the value of the given option within the specified section + * of the given configuration. + * + * If either config, section or option is null, or if `option` begins + * with a dot, the function will do nothing. + * + * @param {string} config + * The name of the configuration to set the option value in. + * + * @param {string} sid + * The name or ID of the section to set the option value in. + * + * @param {string} option + * The option name to set the value for. + * + * @param {null|string|string[]} value + * The option value to set. If the value is `null` or an empty string, + * the option will be removed, otherwise it will be set or overwritten + * with the given value. + */ + 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]) { + if (isEmpty(d[conf][sid], opt)) + delete d[conf][sid]; + else + delete d[conf][sid][opt]; + } + + c[conf][sid][opt] = val; + } + else { + /* revert any change for to-be-deleted option */ + if (c[conf] && c[conf][sid]) { + if (isEmpty(c[conf][sid], opt)) + delete c[conf][sid]; + else + delete c[conf][sid][opt]; + } + + /* only delete existing options */ + if (v[conf] && v[conf][sid] && v[conf][sid].hasOwnProperty(opt)) { + if (!d[conf]) + d[conf] = { }; + + if (!d[conf][sid]) + d[conf][sid] = { }; + + if (d[conf][sid] !== true) + d[conf][sid][opt] = true; + } + } + }, + + /** + * Remove the given option within the specified section of the given + * configuration. + * + * This function is a convenience wrapper around + * `uci.set(config, section, option, null)`. + * + * @param {string} config + * The name of the configuration to remove the option from. + * + * @param {string} sid + * The name or ID of the section to remove the option from. + * + * @param {string} option + * The name of the option to remove. + */ + unset: function(conf, sid, opt) { + return this.set(conf, sid, opt, null); + }, + + /** + * Gets the value of the given option or the entire section object of + * the first found section of the specified type or the first found + * section of the entire configuration if no type is specfied. + * + * @param {string} config + * The name of the configuration to read the value from. + * + * @param {string} [type] + * The type of the first section to find. If it is `null`, the first + * section of the entire config is read, otherwise the first section + * matching the given type. + * + * @param {string} [option] + * The option name to read the value from. If the option name is + * omitted or `null`, the entire section is returned instead. + * + * @returns {null|string|string[]|LuCI.uci.SectionObject} + * - Returns a string containing the option value in case of a + * plain UCI option. + * - Returns an array of strings containing the option values in + * case of `option` pointing to an UCI list. + * - Returns a {@link LuCI.uci.SectionObject section object} if + * the `option` argument has been omitted or is `null`. + * - Returns `null` if the config, section or option has not been + * found or if the corresponding configuration is not loaded. + */ + 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); + }, + + /** + * Sets the value of the given option within the first found section + * of the given configuration matching the specified type or within + * the first section of the entire config when no type has is specified. + * + * If either config, type or option is null, or if `option` begins + * with a dot, the function will do nothing. + * + * @param {string} config + * The name of the configuration to set the option value in. + * + * @param {string} [type] + * The type of the first section to find. If it is `null`, the first + * section of the entire config is written to, otherwise the first + * section matching the given type is used. + * + * @param {string} option + * The option name to set the value for. + * + * @param {null|string|string[]} value + * The option value to set. If the value is `null` or an empty string, + * the option will be removed, otherwise it will be set or overwritten + * with the given value. + */ + 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); + }, + + /** + * Removes the given option within the first found section of the given + * configuration matching the specified type or within the first section + * of the entire config when no type has is specified. + * + * This function is a convenience wrapper around + * `uci.set_first(config, type, option, null)`. + * + * @param {string} config + * The name of the configuration to set the option value in. + * + * @param {string} [type] + * The type of the first section to find. If it is `null`, the first + * section of the entire config is written to, otherwise the first + * section matching the given type is used. + * + * @param {string} option + * The option name to set the value for. + */ + unset_first: function(conf, type, opt) { + return this.set_first(conf, type, opt, null); + }, + + /** + * Move the first specified section within the given configuration + * before or after the second specified section. + * + * @param {string} config + * The configuration to move the section within. + * + * @param {string} sid1 + * The ID of the section to move within the configuration. + * + * @param {string} [sid2] + * The ID of the target section for the move operation. If the + * `after` argument is `false` or not specified, the section named by + * `sid1` will be moved before this target section, if the `after` + * argument is `true`, the `sid1` section will be moved after this + * section. + * + * When the `sid2` argument is `null`, the section specified by `sid1` + * is moved to the end of the configuration. + * + * @param {boolean} [after=false] + * When `true`, the section `sid1` is moved after the section `sid2`, + * when `false`, the section `sid1` is moved before `sid2`. + * + * If `sid2` is null, then this parameter has no effect and the section + * `sid1` is moved to the end of the configuration instead. + * + * @returns {boolean} + * Returns `true` when the section was successfully moved, or `false` + * when either the section specified by `sid1` or by `sid2` is not found. + */ + 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; + }, + + /** + * Submits all local configuration changes to the remove `ubus` api, + * adds, removes and reorders remote sections as needed and reloads + * all loaded configurations to resynchronize the local state with + * the remote configuration values. + * + * @returns {string[]} + * Returns a promise resolving to an array of configuration names which + * have been reloaded by the save operation. + */ + 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 (d) + for (var conf in d) { + for (var sid in d[conf]) { + var o = d[conf][sid]; + + if (o === true) + tasks.push(self.callDelete(conf, sid, null)); + else + tasks.push(self.callDelete(conf, sid, Object.keys(o))); + } + + pkgs[conf] = true; + } + + if (n) + for (var conf in n) { + for (var sid in n[conf]) { + var p = { + config: conf, + values: { } + }; + + for (var k in n[conf][sid]) { + if (k == '.type') + p.type = n[conf][sid][k]; + else if (k == '.create') + p.name = n[conf][sid][k]; + else if (k.charAt(0) != '.') + p.values[k] = n[conf][sid][k]; + } + + snew.push(n[conf][sid]); + tasks.push(self.callAdd(p.config, p.type, p.name, p.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 (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); + }); + }, + + /** + * Instructs the remote `ubus` UCI api to commit all saved changes with + * rollback protection and attempts to confirm the pending commit + * operation to cancel the rollback timer. + * + * @param {number} [timeout=10] + * Override the confirmation timeout after which a rollback is triggered. + * + * @returns {Promise} + * Returns a promise resolving/rejecting with the `ubus` RPC status code. + */ + 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); + }); + }, + + /** + * An UCI change record is a plain array containing the change operation + * name as first element, the affected section ID as second argument + * and an optional third and fourth argument whose meanings depend on + * the operation. + * + * @typedef {string[]} ChangeRecord + * @memberof LuCI.uci + * + * @property {string} 0 + * The operation name - may be one of `add`, `set`, `remove`, `order`, + * `list-add`, `list-del` or `rename`. + * + * @property {string} 1 + * The section ID targeted by the operation. + * + * @property {string} 2 + * The meaning of the third element depends on the operation. + * - For `add` it is type of the section that has been added + * - For `set` it either is the option name if a fourth element exists, + * or the type of a named section which has been added when the change + * entry only contains three elements. + * - For `remove` it contains the name of the option that has been + * removed. + * - For `order` it specifies the new sort index of the section. + * - For `list-add` it contains the name of the list option a new value + * has been added to. + * - For `list-del` it contains the name of the list option a value has + * been removed from. + * - For `rename` it contains the name of the option that has been + * renamed if a fourth element exists, else it contains the new name + * a section has been renamed to if the change entry only contains + * three elements. + * + * @property {string} 4 + * The meaning of the fourth element depends on the operation. + * - For `set` it is the value an option has been set to. + * - For `list-add` it is the new value that has been added to a + * list option. + * - For `rename` it is the new name of an option that has been + * renamed. + */ + + /** + * Fetches uncommitted UCI changes from the remote `ubus` RPC api. + * + * @method + * @returns {Promise>>} + * Returns a promise resolving to an object containing the configuration + * names as keys and arrays of related change records as values. + */ + changes: rpc.declare({ + object: 'uci', + method: 'changes', + expect: { changes: { } } + }) +}); diff --git a/htdocs/luci-static/resources/ui.js b/htdocs/luci-static/resources/ui.js new file mode 100644 index 0000000..9ecadb0 --- /dev/null +++ b/htdocs/luci-static/resources/ui.js @@ -0,0 +1,4949 @@ +'use strict'; +'require validation'; +'require baseclass'; +'require request'; +'require session'; +'require poll'; +'require dom'; +'require rpc'; +'require uci'; +'require fs'; + +var modalDiv = null, + tooltipDiv = null, + indicatorDiv = null, + tooltipTimeout = null; + +/** + * @class AbstractElement + * @memberof LuCI.ui + * @hideconstructor + * @classdesc + * + * The `AbstractElement` class serves as abstract base for the different widgets + * implemented by `LuCI.ui`. It provides the common logic for getting and + * setting values, for checking the validity state and for wiring up required + * events. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import + * it in external JavaScript, use `L.require("ui").then(...)` and access the + * `AbstractElement` property of the class instance value. + */ +var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ { + /** + * @typedef {Object} InitOptions + * @memberof LuCI.ui.AbstractElement + * + * @property {string} [id] + * Specifies the widget ID to use. It will be used as HTML `id` attribute + * on the toplevel widget DOM node. + * + * @property {string} [name] + * Specifies the widget name which is set as HTML `name` attribute on the + * corresponding `` element. + * + * @property {boolean} [optional=true] + * Specifies whether the input field allows empty values. + * + * @property {string} [datatype=string] + * An expression describing the input data validation constraints. + * It defaults to `string` which will allow any value. + * See {@link LuCI.validation} for details on the expression format. + * + * @property {function} [validator] + * Specifies a custom validator function which is invoked after the + * standard validation constraints are checked. The function should return + * `true` to accept the given input value. Any other return value type is + * converted to a string and treated as validation error message. + * + * @property {boolean} [disabled=false] + * Specifies whether the widget should be rendered in disabled state + * (`true`) or not (`false`). Disabled widgets cannot be interacted with + * and are displayed in a slightly faded style. + */ + + /** + * Read the current value of the input widget. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @returns {string|string[]|null} + * The current value of the input element. For simple inputs like text + * fields or selects, the return value type will be a - possibly empty - + * string. Complex widgets such as `DynamicList` instances may result in + * an array of strings or `null` for unset values. + */ + getValue: function() { + if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input')) + return this.node.value; + + return null; + }, + + /** + * Set the current value of the input widget. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {string|string[]|null} value + * The value to set the input element to. For simple inputs like text + * fields or selects, the value should be a - possibly empty - string. + * Complex widgets such as `DynamicList` instances may accept string array + * or `null` values. + */ + setValue: function(value) { + if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input')) + this.node.value = value; + }, + + /** + * Set the current placeholder value of the input widget. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {string|string[]|null} value + * The placeholder to set for the input element. Only applicable to text + * inputs, not to radio buttons, selects or similar. + */ + setPlaceholder: function(value) { + var node = this.node ? this.node.querySelector('input,textarea') : null; + if (node) { + switch (node.getAttribute('type') || 'text') { + case 'password': + case 'search': + case 'tel': + case 'text': + case 'url': + if (value != null && value != '') + node.setAttribute('placeholder', value); + else + node.removeAttribute('placeholder'); + } + } + }, + + /** + * Check whether the input value was altered by the user. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @returns {boolean} + * Returns `true` if the input value has been altered by the user or + * `false` if it is unchaged. Note that if the user modifies the initial + * value and changes it back to the original state, it is still reported + * as changed. + */ + isChanged: function() { + return (this.node ? this.node.getAttribute('data-changed') : null) == 'true'; + }, + + /** + * Check whether the current input value is valid. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @returns {boolean} + * Returns `true` if the current input value is valid or `false` if it does + * not meet the validation constraints. + */ + isValid: function() { + return (this.validState !== false); + }, + + /** + * Returns the current validation error + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @returns {string} + * The validation error at this time + */ + getValidationError: function() { + return this.validationError || ''; + }, + + /** + * Force validation of the current input value. + * + * Usually input validation is automatically triggered by various DOM events + * bound to the input widget. In some cases it is required though to manually + * trigger validation runs, e.g. when programmatically altering values. + * + * @instance + * @memberof LuCI.ui.AbstractElement + */ + triggerValidation: function() { + if (typeof(this.vfunc) != 'function') + return false; + + var wasValid = this.isValid(); + + this.vfunc(); + + return (wasValid != this.isValid()); + }, + + /** + * Dispatch a custom (synthetic) event in response to received events. + * + * Sets up event handlers on the given target DOM node for the given event + * names that dispatch a custom event of the given type to the widget root + * DOM node. + * + * The primary purpose of this function is to set up a series of custom + * uniform standard events such as `widget-update`, `validation-success`, + * `validation-failure` etc. which are triggered by various different + * widget specific native DOM events. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {Node} targetNode + * Specifies the DOM node on which the native event listeners should be + * registered. + * + * @param {string} synevent + * The name of the custom event to dispatch to the widget root DOM node. + * + * @param {string[]} events + * The native DOM events for which event handlers should be registered. + */ + 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); + }, + + /** + * Setup listeners for native DOM events that may update the widget value. + * + * Sets up event handlers on the given target DOM node for the given event + * names which may cause the input value to update, such as `keyup` or + * `onclick` events. In contrast to change events, such update events will + * trigger input value validation. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {Node} targetNode + * Specifies the DOM node on which the event listeners should be registered. + * + * @param {...string} events + * The DOM events for which event handlers should be registered. + */ + 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 = UI.prototype.addValidator.apply(UI.prototype, [ + targetNode, datatype || 'string', + optional, validate + ].concat(events)); + + this.node.addEventListener('validation-success', L.bind(function(ev) { + this.validState = true; + this.validationError = ''; + }, this)); + + this.node.addEventListener('validation-failure', L.bind(function(ev) { + this.validState = false; + this.validationError = ev.detail.message; + }, this)); + }, + + /** + * Setup listeners for native DOM events that may change the widget value. + * + * Sets up event handlers on the given target DOM node for the given event + * names which may cause the input value to change completely, such as + * `change` events in a select menu. In contrast to update events, such + * change events will not trigger input value validation but they may cause + * field dependencies to get re-evaluated and will mark the input widget + * as dirty. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {Node} targetNode + * Specifies the DOM node on which the event listeners should be registered. + * + * @param {...string} events + * The DOM events for which event handlers should be registered. + */ + setChangeEvents: function(targetNode /*, ... */) { + var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node); + + for (var i = 1; i < arguments.length; i++) + targetNode.addEventListener(arguments[i], tag_changed); + + this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1)); + }, + + /** + * Render the widget, setup event listeners and return resulting markup. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * + * @returns {Node} + * Returns a DOM Node or DocumentFragment containing the rendered + * widget markup. + */ + render: function() {} +}); + +/** + * Instantiate a text input widget. + * + * @constructor Textfield + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Textfield` class implements a standard single line text input field. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Textfield` property of the class instance value. + * + * @param {string} [value=null] + * The initial input value. + * + * @param {LuCI.ui.Textfield.InitOptions} [options] + * Object describing the widget specific options to initialize the input. + */ +var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Textfield + * + * @property {boolean} [password=false] + * Specifies whether the input should be rendered as concealed password field. + * + * @property {boolean} [readonly=false] + * Specifies whether the input widget should be rendered readonly. + * + * @property {number} [maxlength] + * Specifies the HTML `maxlength` attribute to set on the corresponding + * `` element. Note that this a legacy property that exists for + * compatibility reasons. It is usually better to `maxlength(N)` validation + * expression. + * + * @property {string} [placeholder] + * Specifies the HTML `placeholder` attribute which is displayed when the + * corresponding `` element is empty. + */ + __init__: function(value, options) { + this.value = value; + this.options = Object.assign({ + optional: true, + password: false + }, options); + }, + + /** @override */ + render: function() { + var frameEl = E('div', { 'id': this.options.id }); + var inputEl = E('input', { + 'id': this.options.id ? 'widget.' + this.options.id : null, + 'name': this.options.name, + 'type': 'text', + 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text', + 'readonly': this.options.readonly ? '' : null, + 'disabled': this.options.disabled ? '' : null, + 'maxlength': this.options.maxlength, + 'placeholder': this.options.placeholder, + 'value': this.value, + }); + + if (this.options.password) { + frameEl.appendChild(E('div', { 'class': 'control-group' }, [ + inputEl, + 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(); + } + }, '∗') + ])); + + window.requestAnimationFrame(function() { inputEl.type = 'password' }); + } + else { + frameEl.appendChild(inputEl); + } + + return this.bind(frameEl); + }, + + /** @private */ + bind: function(frameEl) { + var inputEl = frameEl.querySelector('input'); + + this.node = frameEl; + + this.setUpdateEvents(inputEl, 'keyup', 'blur'); + this.setChangeEvents(inputEl, 'change'); + + dom.bindClassInstance(frameEl, this); + + return frameEl; + }, + + /** @override */ + getValue: function() { + var inputEl = this.node.querySelector('input'); + return inputEl.value; + }, + + /** @override */ + setValue: function(value) { + var inputEl = this.node.querySelector('input'); + inputEl.value = value; + } +}); + +/** + * Instantiate a textarea widget. + * + * @constructor Textarea + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Textarea` class implements a multiline text area input field. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Textarea` property of the class instance value. + * + * @param {string} [value=null] + * The initial input value. + * + * @param {LuCI.ui.Textarea.InitOptions} [options] + * Object describing the widget specific options to initialize the input. + */ +var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Textarea + * + * @property {boolean} [readonly=false] + * Specifies whether the input widget should be rendered readonly. + * + * @property {string} [placeholder] + * Specifies the HTML `placeholder` attribute which is displayed when the + * corresponding `