Merge pull request #3024 from TDT-AG/pr/20190829-material-logo
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / luci.js
index 4cb8bf4e5d08cb0cb293ade5a5afd719dde6611a..687ac0e678646b77cbe6eb27425ce2d17689fe87 100644 (file)
 (function(window, document, undefined) {
-       var modalDiv = null,
-           tooltipDiv = null,
-           tooltipTimeout = null,
-           dummyElem = null,
-           domParser = null;
+       'use strict';
+
+       /* Object.assign polyfill for IE */
+       if (typeof Object.assign !== 'function') {
+               Object.defineProperty(Object, 'assign', {
+                       value: function assign(target, varArgs) {
+                               if (target == null)
+                                       throw new TypeError('Cannot convert undefined or null to object');
+
+                               var to = Object(target);
+
+                               for (var index = 1; index < arguments.length; index++)
+                                       if (arguments[index] != null)
+                                               for (var nextKey in arguments[index])
+                                                       if (Object.prototype.hasOwnProperty.call(arguments[index], nextKey))
+                                                               to[nextKey] = arguments[index][nextKey];
+
+                               return to;
+                       },
+                       writable: true,
+                       configurable: true
+               });
+       }
 
-       LuCI.prototype = {
-               /* URL construction helpers */
-               path: function(prefix, parts) {
-                       var url = [ prefix || '' ];
+       /* Promise.finally polyfill */
+       if (typeof Promise.prototype.finally !== 'function') {
+               Promise.prototype.finally = function(fn) {
+                       var onFinally = function(cb) {
+                               return Promise.resolve(fn.call(this)).then(cb);
+                       };
+
+                       return this.then(
+                               function(result) { return onFinally.call(this, function() { return result }) },
+                               function(reason) { return onFinally.call(this, function() { return Promise.reject(reason) }) }
+                       );
+               };
+       }
 
-                       for (var i = 0; i < parts.length; i++)
-                               if (/^(?:[a-zA-Z0-9_.%,;-]+\/)*[a-zA-Z0-9_.%,;-]+$/.test(parts[i]))
-                                       url.push('/', parts[i]);
+       /*
+        * Class declaration and inheritance helper
+        */
 
-                       if (url.length === 1)
-                               url.push('/');
+       var toCamelCase = function(s) {
+               return s.replace(/(?:^|[\. -])(.)/g, function(m0, m1) { return m1.toUpperCase() });
+       };
 
-                       return url.join('');
-               },
+       var superContext = null, Class = Object.assign(function() {}, {
+               extend: function(properties) {
+                       var props = {
+                               __base__: { value: this.prototype },
+                               __name__: { value: properties.__name__ || 'anonymous' }
+                       };
 
-               url: function() {
-                       return this.path(this.env.scriptname, arguments);
+                       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;
                },
 
-               resource: function() {
-                       return this.path(this.env.resource, arguments);
+               singleton: function(properties /*, ... */) {
+                       return Class.extend(properties)
+                               .instantiate(Class.prototype.varargs(arguments, 1));
                },
 
-               location: function() {
-                       return this.path(this.env.scriptname, this.env.requestpath);
+               instantiate: function(args) {
+                       return new (Function.prototype.bind.apply(this,
+                               Class.prototype.varargs(args, 0, null)))();
                },
 
+               call: function(self, method) {
+                       if (typeof(this.prototype[method]) != 'function')
+                               throw new ReferenceError(method + ' is not defined in class');
 
-               /* HTTP resource fetching */
-               get: function(url, args, cb) {
-                       return this.poll(0, url, args, cb, false);
+                       return this.prototype[method].apply(self, self.varargs(arguments, 1));
                },
 
-               post: function(url, args, cb) {
-                       return this.poll(0, url, args, cb, true);
+               isSubclass: function(_class) {
+                       return (_class != null &&
+                               typeof(_class) == 'function' &&
+                               _class.prototype instanceof this);
                },
 
-               poll: function(interval, url, args, cb, post) {
-                       var data = post ? { token: this.env.token } : null;
+               prototype: {
+                       varargs: function(args, offset /*, ... */) {
+                               return Array.prototype.slice.call(arguments, 2)
+                                       .concat(Array.prototype.slice.call(args, offset));
+                       },
 
-                       if (!/^(?:\/|\S+:\/\/)/.test(url))
-                               url = this.url(url);
+                       super: function(key, callArgs) {
+                               for (superContext = Object.getPrototypeOf(superContext ||
+                                                                         Object.getPrototypeOf(this));
+                                    superContext && !superContext.hasOwnProperty(key);
+                                    superContext = Object.getPrototypeOf(superContext)) { }
 
-                       if (typeof(args) === 'object' && args !== null) {
-                               data = data || {};
+                               if (!superContext)
+                                       return null;
 
-                               for (var key in args)
-                                       if (args.hasOwnProperty(key))
-                                               switch (typeof(args[key])) {
-                                               case 'string':
-                                               case 'number':
-                                               case 'boolean':
-                                                       data[key] = args[key];
-                                                       break;
+                               var res = superContext[key];
 
-                                               case 'object':
-                                                       data[key] = JSON.stringify(args[key]);
-                                                       break;
-                                               }
+                               if (arguments.length > 1) {
+                                       if (typeof(res) != 'function')
+                                               throw new ReferenceError(key + ' is not a function in base class');
+
+                                       if (typeof(callArgs) != 'object')
+                                               callArgs = this.varargs(arguments, 1);
+
+                                       res = res.apply(this, callArgs);
+                               }
+
+                               superContext = null;
+
+                               return res;
+                       },
+
+                       toString: function() {
+                               var s = '[' + this.constructor.displayName + ']', f = true;
+                               for (var k in this) {
+                                       if (this.hasOwnProperty(k)) {
+                                               s += (f ? ' {\n' : '') + '  ' + k + ': ' + typeof(this[k]) + '\n';
+                                               f = false;
+                                       }
+                               }
+                               return s + (f ? '' : '}');
                        }
+               }
+       });
 
-                       if (interval > 0)
-                               return XHR.poll(interval, url, data, cb, post);
-                       else if (post)
-                               return XHR.post(url, data, cb);
-                       else
-                               return XHR.get(url, data, cb);
+
+       /*
+        * HTTP Request helper
+        */
+
+       var Headers = Class.extend({
+               __name__: 'LuCI.XHR.Headers',
+               __init__: function(xhr) {
+                       var hdrs = this.headers = {};
+                       xhr.getAllResponseHeaders().split(/\r\n/).forEach(function(line) {
+                               var m = /^([^:]+):(.*)$/.exec(line);
+                               if (m != null)
+                                       hdrs[m[1].trim().toLowerCase()] = m[2].trim();
+                       });
                },
 
-               stop: function(entry) { XHR.stop(entry) },
-               halt: function() { XHR.halt() },
-               run: function() { XHR.run() },
+               has: function(name) {
+                       return this.headers.hasOwnProperty(String(name).toLowerCase());
+               },
 
+               get: function(name) {
+                       var key = String(name).toLowerCase();
+                       return this.headers.hasOwnProperty(key) ? this.headers[key] : null;
+               }
+       });
+
+       var Response = Class.extend({
+               __name__: 'LuCI.XHR.Response',
+               __init__: function(xhr, url, duration, headers, content) {
+                       this.ok = (xhr.status >= 200 && xhr.status <= 299);
+                       this.status = xhr.status;
+                       this.statusText = xhr.statusText;
+                       this.headers = (headers != null) ? headers : new Headers(xhr);
+                       this.duration = duration;
+                       this.url = url;
+                       this.xhr = xhr;
+
+                       if (content != null && typeof(content) == 'object') {
+                               this.responseJSON = content;
+                               this.responseText = null;
+                       }
+                       else if (content != null) {
+                               this.responseJSON = null;
+                               this.responseText = String(content);
+                       }
+                       else {
+                               this.responseJSON = null;
+                               this.responseText = xhr.responseText;
+                       }
+               },
 
-               /* Modal dialog */
-               showModal: function(title, children) {
-                       var dlg = modalDiv.firstElementChild;
+               clone: function(content) {
+                       var copy = new Response(this.xhr, this.url, this.duration, this.headers, content);
 
-                       dlg.setAttribute('class', 'modal');
+                       copy.ok = this.ok;
+                       copy.status = this.status;
+                       copy.statusText = this.statusText;
 
-                       this.dom.content(dlg, this.dom.create('h4', {}, title));
-                       this.dom.append(dlg, children);
+                       return copy;
+               },
 
-                       document.body.classList.add('modal-overlay-active');
+               json: function() {
+                       if (this.responseJSON == null)
+                               this.responseJSON = JSON.parse(this.responseText);
 
-                       return dlg;
+                       return this.responseJSON;
                },
 
-               hideModal: function() {
-                       document.body.classList.remove('modal-overlay-active');
+               text: function() {
+                       if (this.responseText == null && this.responseJSON != null)
+                               this.responseText = JSON.stringify(this.responseJSON);
+
+                       return this.responseText;
+               }
+       });
+
+
+       var requestQueue = [];
+
+       function isQueueableRequest(opt) {
+               if (!classes.rpc)
+                       return false;
+
+               if (opt.method != 'POST' || typeof(opt.content) != 'object')
+                       return false;
+
+               if (opt.nobatch === true)
+                       return false;
+
+               var rpcBaseURL = Request.expandURL(classes.rpc.getBaseURL());
+
+               return (rpcBaseURL != null && opt.url.indexOf(rpcBaseURL) == 0);
+       }
+
+       function flushRequestQueue() {
+               if (!requestQueue.length)
+                       return;
+
+               var reqopt = Object.assign({}, requestQueue[0][0], { content: [], nobatch: true }),
+                   batch = [];
+
+               for (var i = 0; i < requestQueue.length; i++) {
+                       batch[i] = requestQueue[i];
+                       reqopt.content[i] = batch[i][0].content;
+               }
+
+               requestQueue.length = 0;
+
+               Request.request(rpcBaseURL, reqopt).then(function(reply) {
+                       var json = null, req = null;
+
+                       try { json = reply.json() }
+                       catch(e) { }
+
+                       while ((req = batch.shift()) != null)
+                               if (Array.isArray(json) && json.length)
+                                       req[2].call(reqopt, reply.clone(json.shift()));
+                               else
+                                       req[1].call(reqopt, new Error('No related RPC reply'));
+               }).catch(function(error) {
+                       var req = null;
+
+                       while ((req = batch.shift()) != null)
+                               req[1].call(reqopt, error);
+               });
+       }
+
+       var Request = Class.singleton({
+               __name__: 'LuCI.Request',
+
+               interceptors: [],
+
+               expandURL: function(url) {
+                       if (!/^(?:[^/]+:)?\/\//.test(url))
+                               url = location.protocol + '//' + location.host + url;
+
+                       return url;
                },
 
+               request: function(target, options) {
+                       var state = { xhr: new XMLHttpRequest(), url: this.expandURL(target), start: Date.now() },
+                           opt = Object.assign({}, options, state),
+                           content = null,
+                           contenttype = null,
+                           callback = this.handleReadyStateChange;
+
+                       return new Promise(function(resolveFn, rejectFn) {
+                               opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn);
+                               opt.method = String(opt.method || 'GET').toUpperCase();
+
+                               if ('query' in opt) {
+                                       var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) {
+                                               if (opt.query[k] != null) {
+                                                       var v = (typeof(opt.query[k]) == 'object')
+                                                               ? JSON.stringify(opt.query[k])
+                                                               : String(opt.query[k]);
+
+                                                       return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v));
+                                               }
+                                               else {
+                                                       return encodeURIComponent(k);
+                                               }
+                                       }).join('&') : '';
+
+                                       if (q !== '') {
+                                               switch (opt.method) {
+                                               case 'GET':
+                                               case 'HEAD':
+                                               case 'OPTIONS':
+                                                       opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q;
+                                                       break;
 
-               /* Tooltip */
-               showTooltip: function(ev) {
-                       var target = findParent(ev.target, '[data-tooltip]');
+                                               default:
+                                                       if (content == null) {
+                                                               content = q;
+                                                               contenttype = 'application/x-www-form-urlencoded';
+                                                       }
+                                               }
+                                       }
+                               }
 
-                       if (!target)
-                               return;
+                               if (!opt.cache)
+                                       opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime();
 
-                       if (tooltipTimeout !== null) {
-                               window.clearTimeout(tooltipTimeout);
-                               tooltipTimeout = null;
-                       }
+                               if (isQueueableRequest(opt)) {
+                                       requestQueue.push([opt, rejectFn, resolveFn]);
+                                       requestAnimationFrame(flushRequestQueue);
+                                       return;
+                               }
 
-                       var rect = target.getBoundingClientRect(),
-                           x = rect.left              + window.pageXOffset,
-                           y = rect.top + rect.height + window.pageYOffset;
+                               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);
 
-                       tooltipDiv.className = 'cbi-tooltip';
-                       tooltipDiv.innerHTML = '▲ ';
-                       tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
+                               opt.xhr.responseType = 'text';
 
-                       if (target.hasAttribute('data-tooltip-style'))
-                               tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
+                               if ('overrideMimeType' in opt.xhr)
+                                       opt.xhr.overrideMimeType('application/octet-stream');
 
-                       if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
-                               y -= (tooltipDiv.offsetHeight + target.offsetHeight);
-                               tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
-                       }
+                               if ('timeout' in opt)
+                                       opt.xhr.timeout = +opt.timeout;
+
+                               if ('credentials' in opt)
+                                       opt.xhr.withCredentials = !!opt.credentials;
+
+                               if (opt.content != null) {
+                                       switch (typeof(opt.content)) {
+                                       case 'function':
+                                               content = opt.content(xhr);
+                                               break;
 
-                       tooltipDiv.style.top = y + 'px';
-                       tooltipDiv.style.left = x + 'px';
-                       tooltipDiv.style.opacity = 1;
+                                       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);
 
-                       tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
-                               bubbles: true,
-                               detail: { target: target }
-                       }));
+                               try {
+                                       opt.xhr.send(content);
+                               }
+                               catch (e) {
+                                       rejectFn.call(opt, e);
+                               }
+                       });
                },
 
-               hideTooltip: function(ev) {
-                       if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
-                           tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
+               handleReadyStateChange: function(resolveFn, rejectFn, ev) {
+                       var xhr = this.xhr;
+
+                       if (xhr.readyState !== 4)
                                return;
 
-                       if (tooltipTimeout !== null) {
-                               window.clearTimeout(tooltipTimeout);
-                               tooltipTimeout = null;
+                       if (xhr.status === 0 && xhr.statusText === '') {
+                               rejectFn.call(this, new Error('XHR request aborted by browser'));
                        }
+                       else {
+                               var response = new Response(
+                                       xhr, xhr.responseURL || this.url, Date.now() - this.start);
 
-                       tooltipDiv.style.opacity = 0;
-                       tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
-
-                       tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
+                               Promise.all(Request.interceptors.map(function(fn) { return fn(response) }))
+                                       .then(resolveFn.bind(this, response))
+                                       .catch(rejectFn.bind(this));
+                       }
                },
 
+               get: function(url, options) {
+                       return this.request(url, Object.assign({ method: 'GET' }, options));
+               },
 
-               /* Widget helper */
-               itemlist: function(node, items, separators) {
-                       var children = [];
+               post: function(url, data, options) {
+                       return this.request(url, Object.assign({ method: 'POST', content: data }, options));
+               },
 
-                       if (!Array.isArray(separators))
-                               separators = [ separators || E('br') ];
+               addInterceptor: function(interceptorFn) {
+                       if (typeof(interceptorFn) == 'function')
+                               this.interceptors.push(interceptorFn);
+                       return interceptorFn;
+               },
 
-                       for (var i = 0; i < items.length; i += 2) {
-                               if (items[i+1] !== null && items[i+1] !== undefined) {
-                                       var sep = separators[(i/2) % separators.length],
-                                           cld = [];
+               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);
+               },
 
-                                       children.push(E('span', { class: 'nowrap' }, [
-                                               items[i] ? E('strong', items[i] + ': ') : '',
-                                               items[i+1]
-                                       ]));
+               poll: {
+                       add: function(interval, url, options, callback) {
+                               if (isNaN(interval) || interval <= 0)
+                                       throw new TypeError('Invalid poll interval');
 
-                                       if ((i+2) < items.length)
-                                               children.push(this.dom.elem(sep) ? sep.cloneNode(true) : sep);
-                               }
-                       }
+                               var ival = interval >>> 0,
+                                   opts = Object.assign({}, options, { timeout: ival * 1000 - 5 });
 
-                       this.dom.content(node, children);
+                               return Poll.add(function() {
+                                       return Request.request(url, options).then(function(res) {
+                                               if (!Poll.active())
+                                                       return;
 
-                       return node;
+                                               try {
+                                                       callback(res, res.json(), res.duration);
+                                               }
+                                               catch (err) {
+                                                       callback(res, null, res.duration);
+                                               }
+                                       });
+                               }, ival);
+                       },
+
+                       remove: function(entry) { return Poll.remove(entry) },
+                       start: function() { return Poll.start() },
+                       stop: function() { return Poll.stop() },
+                       active: function() { return Poll.active() }
                }
-       };
+       });
 
-       /* Tabs */
-       LuCI.prototype.tabs = {
-               init: function() {
-                       var groups = [], prevGroup = null, currGroup = null;
+       var Poll = Class.singleton({
+               __name__: 'LuCI.Poll',
 
-                       document.querySelectorAll('[data-tab]').forEach(function(tab) {
-                               var parent = tab.parentNode;
+               queue: [],
 
-                               if (!parent.hasAttribute('data-tab-group'))
-                                       parent.setAttribute('data-tab-group', groups.length);
+               add: function(fn, interval) {
+                       if (interval == null || interval <= 0)
+                               interval = window.L ? window.L.env.pollinterval : null;
 
-                               currGroup = +parent.getAttribute('data-tab-group');
+                       if (isNaN(interval) || typeof(fn) != 'function')
+                               throw new TypeError('Invalid argument to LuCI.Poll.add()');
 
-                               if (currGroup !== prevGroup) {
-                                       prevGroup = currGroup;
+                       for (var i = 0; i < this.queue.length; i++)
+                               if (this.queue[i].fn === fn)
+                                       return false;
 
-                                       if (!groups[currGroup])
-                                               groups[currGroup] = [];
-                               }
+                       var e = {
+                               r: true,
+                               i: interval >>> 0,
+                               fn: fn
+                       };
 
-                               groups[currGroup].push(tab);
-                       });
+                       this.queue.push(e);
+
+                       if (this.tick != null && !this.active())
+                               this.start();
+
+                       return true;
+               },
+
+               remove: function(fn) {
+                       if (typeof(fn) != 'function')
+                               throw new TypeError('Invalid argument to LuCI.Poll.remove()');
 
-                       for (var i = 0; i < groups.length; i++)
-                               this.initTabGroup(groups[i]);
+                       var len = this.queue.length;
 
-                       document.addEventListener('dependency-update', this.updateTabs.bind(this));
+                       for (var i = len; i > 0; i--)
+                               if (this.queue[i-1].fn === fn)
+                                       this.queue.splice(i-1, 1);
 
-                       this.updateTabs();
+                       if (!this.queue.length && this.stop())
+                               this.tick = 0;
 
-                       if (!groups.length)
-                               this.setActiveTabId(-1, -1);
+                       return (this.queue.length != len);
                },
 
-               initTabGroup: function(panes) {
-                       if (!Array.isArray(panes) || panes.length === 0)
-                               return;
+               start: function() {
+                       if (this.active())
+                               return false;
+
+                       this.tick = 0;
 
-                       var menu = E('ul', { 'class': 'cbi-tabmenu' }),
-                           group = panes[0].parentNode,
-                           groupId = +group.getAttribute('data-tab-group'),
-                           selected = null;
-
-                       for (var i = 0, pane; pane = panes[i]; i++) {
-                               var name = pane.getAttribute('data-tab'),
-                                   title = pane.getAttribute('data-tab-title'),
-                                   active = pane.getAttribute('data-tab-active') === 'true';
-
-                               menu.appendChild(E('li', {
-                                       'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
-                                       'data-tab': name
-                               }, E('a', {
-                                       'href': '#',
-                                       'click': this.switchTab.bind(this)
-                               }, title)));
-
-                               if (active)
-                                       selected = i;
+                       if (this.queue.length) {
+                               this.timer = window.setInterval(this.step, 1000);
+                               this.step();
+                               document.dispatchEvent(new CustomEvent('poll-start'));
                        }
 
-                       group.parentNode.insertBefore(menu, group);
+                       return true;
+               },
+
+               stop: function() {
+                       if (!this.active())
+                               return false;
 
-                       if (selected === null) {
-                               selected = this.getActiveTabId(groupId);
+                       document.dispatchEvent(new CustomEvent('poll-stop'));
+                       window.clearInterval(this.timer);
+                       delete this.timer;
+                       delete this.tick;
+                       return true;
+               },
 
-                               if (selected < 0 || selected >= panes.length)
-                                       selected = 0;
+               step: function() {
+                       for (var i = 0, e = null; (e = Poll.queue[i]) != null; i++) {
+                               if ((Poll.tick % e.i) != 0)
+                                       continue;
 
-                               menu.childNodes[selected].classList.add('cbi-tab');
-                               menu.childNodes[selected].classList.remove('cbi-tab-disabled');
-                               panes[selected].setAttribute('data-tab-active', 'true');
+                               if (!e.r)
+                                       continue;
 
-                               this.setActiveTabId(groupId, selected);
+                               e.r = false;
+
+                               Promise.resolve(e.fn()).finally((function() { this.r = true }).bind(e));
                        }
+
+                       Poll.tick = (Poll.tick + 1) % Math.pow(2, 32);
                },
 
-               getActiveTabState: function() {
-                       var page = document.body.getAttribute('data-page');
+               active: function() {
+                       return (this.timer != null);
+               }
+       });
 
-                       try {
-                               var val = JSON.parse(window.sessionStorage.getItem('tab'));
-                               if (val.page === page && Array.isArray(val.groups))
-                                       return val;
-                       }
-                       catch(e) {}
 
-                       window.sessionStorage.removeItem('tab');
-                       return { page: page, groups: [] };
+       var dummyElem = null,
+           domParser = null,
+           originalCBIInit = null,
+           rpcBaseURL = null,
+           sysFeatures = null,
+           classes = {};
+
+       var LuCI = Class.extend({
+               __name__: 'LuCI',
+               __init__: function(env) {
+
+                       document.querySelectorAll('script[src*="/luci.js"]').forEach(function(s) {
+                               if (env.base_url == null || env.base_url == '')
+                                       env.base_url = s.getAttribute('src').replace(/\/luci\.js(?:\?v=[^?]+)?$/, '');
+                       });
+
+                       if (env.base_url == null)
+                               this.error('InternalError', 'Cannot find url of luci.js');
+
+                       Object.assign(this.env, env);
+
+                       document.addEventListener('poll-start', function(ev) {
+                               document.querySelectorAll('[id^="xhr_poll_status"]').forEach(function(e) {
+                                       e.style.display = (e.id == 'xhr_poll_status_off') ? 'none' : '';
+                               });
+                       });
+
+                       document.addEventListener('poll-stop', function(ev) {
+                               document.querySelectorAll('[id^="xhr_poll_status"]').forEach(function(e) {
+                                       e.style.display = (e.id == 'xhr_poll_status_on') ? 'none' : '';
+                               });
+                       });
+
+                       var domReady = new Promise(function(resolveFn, rejectFn) {
+                               document.addEventListener('DOMContentLoaded', resolveFn);
+                       });
+
+                       Promise.all([
+                               domReady,
+                               this.require('ui'),
+                               this.require('rpc'),
+                               this.require('form'),
+                               this.probeRPCBaseURL()
+                       ]).then(this.setupDOM.bind(this)).catch(this.error);
+
+                       originalCBIInit = window.cbi_init;
+                       window.cbi_init = function() {};
                },
 
-               getActiveTabId: function(groupId) {
-                       return +this.getActiveTabState().groups[groupId] || 0;
+               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;
                },
 
-               setActiveTabId: function(groupId, tabIndex) {
+               error: function(type, fmt /*, ...*/) {
                        try {
-                               var state = this.getActiveTabState();
-                                   state.groups[groupId] = tabIndex;
+                               L.raise.apply(L, Array.prototype.slice.call(arguments));
+                       }
+                       catch (e) {
+                               if (!e.reported) {
+                                       if (L.ui)
+                                               L.ui.addNotification(e.name || _('Runtime error'),
+                                                       E('pre', {}, e.message), 'danger');
+                                       else
+                                               L.dom.content(document.querySelector('#maincontent'),
+                                                       E('pre', { 'class': 'alert-message error' }, e.message));
+
+                                       e.reported = true;
+                               }
 
-                           window.sessionStorage.setItem('tab', JSON.stringify(state));
+                               throw e;
                        }
-                       catch (e) { return false; }
+               },
 
-                       return true;
+               bind: function(fn, self /*, ... */) {
+                       return Function.prototype.bind.apply(fn, this.varargs(arguments, 2, self));
                },
 
-               updateTabs: function(ev) {
-                       document.querySelectorAll('[data-tab-title]').forEach(function(pane) {
-                               var menu = pane.parentNode.previousElementSibling,
-                                   tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))),
-                                   n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
+               /* Class require */
+               require: function(name, from) {
+                       var L = this, url = null, from = from || [];
 
-                               if (!pane.firstElementChild) {
-                                       tab.style.display = 'none';
-                                       tab.classList.remove('flash');
-                               }
-                               else if (tab.style.display === 'none') {
-                                       tab.style.display = '';
-                                       requestAnimationFrame(function() { tab.classList.add('flash') });
-                               }
+                       /* Class already loaded */
+                       if (classes[name] != null) {
+                               /* Circular dependency */
+                               if (from.indexOf(name) != -1)
+                                       L.raise('DependencyError',
+                                               'Circular dependency: class "%s" depends on "%s"',
+                                               name, from.join('" which depends on "'));
 
-                               if (n_errors) {
-                                       tab.setAttribute('data-errors', n_errors);
-                                       tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
-                                       tab.setAttribute('data-tooltip-style', 'error');
-                               }
-                               else {
-                                       tab.removeAttribute('data-errors');
-                                       tab.removeAttribute('data-tooltip');
-                               }
-                       });
-               },
+                               return classes[name];
+                       }
 
-               switchTab: function(ev) {
-                       var tab = ev.target.parentNode,
-                           name = tab.getAttribute('data-tab'),
-                           menu = tab.parentNode,
-                           group = menu.nextElementSibling,
-                           groupId = +group.getAttribute('data-tab-group'),
-                           index = 0;
+                       url = '%s/%s.js'.format(L.env.base_url, name.replace(/\./g, '/'));
+                       from = [ name ].concat(from);
 
-                       ev.preventDefault();
+                       var compileClass = function(res) {
+                               if (!res.ok)
+                                       L.raise('NetworkError',
+                                               'HTTP error %d while loading class file "%s"', res.status, url);
 
-                       if (!tab.classList.contains('cbi-tab-disabled'))
-                               return;
+                               var source = res.text(),
+                                   requirematch = /^require[ \t]+(\S+)(?:[ \t]+as[ \t]+([a-zA-Z_]\S*))?$/,
+                                   strictmatch = /^use[ \t]+strict$/,
+                                   depends = [],
+                                   args = '';
 
-                       menu.querySelectorAll('[data-tab]').forEach(function(tab) {
-                               tab.classList.remove('cbi-tab');
-                               tab.classList.remove('cbi-tab-disabled');
-                               tab.classList.add(
-                                       tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
-                       });
+                               /* find require statements in source */
+                               for (var i = 0, off = -1, quote = -1, esc = false; i < source.length; i++) {
+                                       var chr = source.charCodeAt(i);
 
-                       group.childNodes.forEach(function(pane) {
-                               if (L.dom.matches(pane, '[data-tab]')) {
-                                       if (pane.getAttribute('data-tab') === name) {
-                                               pane.setAttribute('data-tab-active', 'true');
-                                               L.tabs.setActiveTabId(groupId, index);
+                                       if (esc) {
+                                               esc = false;
                                        }
-                                       else {
-                                               pane.setAttribute('data-tab-active', 'false');
+                                       else if (chr == 92) {
+                                               esc = true;
                                        }
+                                       else if (chr == quote) {
+                                               var s = source.substring(off, i),
+                                                   m = requirematch.exec(s);
+
+                                               if (m) {
+                                                       var dep = m[1], as = m[2] || dep.replace(/[^a-zA-Z0-9_]/g, '_');
+                                                       depends.push(L.require(dep, from));
+                                                       args += ', ' + as;
+                                               }
+                                               else if (!strictmatch.exec(s)) {
+                                                       break;
+                                               }
 
-                                       index++;
+                                               off = -1;
+                                               quote = -1;
+                                       }
+                                       else if (quote == -1 && (chr == 34 || chr == 39)) {
+                                               off = i + 1;
+                                               quote = chr;
+                                       }
                                }
-                       });
-               }
-       };
 
-       /* DOM manipulation */
-       LuCI.prototype.dom = {
-               elem: function(e) {
-                       return (typeof(e) === 'object' && e !== null && 'nodeType' in e);
+                               /* load dependencies and instantiate class */
+                               return Promise.all(depends).then(function(instances) {
+                                       var _factory, _class;
+
+                                       try {
+                                               _factory = eval(
+                                                       '(function(window, document, L%s) { %s })\n\n//# sourceURL=%s\n'
+                                                               .format(args, source, res.url));
+                                       }
+                                       catch (error) {
+                                               L.raise('SyntaxError', '%s\n  in %s:%s',
+                                                       error.message, res.url, error.lineNumber || '?');
+                                       }
+
+                                       _factory.displayName = toCamelCase(name + 'ClassFactory');
+                                       _class = _factory.apply(_factory, [window, document, L].concat(instances));
+
+                                       if (!Class.isSubclass(_class))
+                                           L.error('TypeError', '"%s" factory yields invalid constructor', name);
+
+                                       if (_class.displayName == 'AnonymousClass')
+                                               _class.displayName = toCamelCase(name + 'Class');
+
+                                       var ptr = Object.getPrototypeOf(L),
+                                           parts = name.split(/\./),
+                                           instance = new _class();
+
+                                       for (var i = 0; ptr && i < parts.length - 1; i++)
+                                               ptr = ptr[parts[i]];
+
+                                       if (ptr)
+                                               ptr[parts[i]] = instance;
+
+                                       classes[name] = instance;
+
+                                       return instance;
+                               });
+                       };
+
+                       /* Request class file */
+                       classes[name] = Request.get(url, { cache: true }).then(compileClass);
+
+                       return classes[name];
                },
 
-               parse: function(s) {
-                       var elem;
+               /* DOM setup */
+               probeRPCBaseURL: function() {
+                       if (rpcBaseURL == null) {
+                               try {
+                                       rpcBaseURL = window.sessionStorage.getItem('rpcBaseURL');
+                               }
+                               catch (e) { }
+                       }
 
-                       try {
-                               domParser = domParser || new DOMParser();
-                               elem = domParser.parseFromString(s, 'text/html').body.firstChild;
+                       if (rpcBaseURL == null) {
+                               var rpcFallbackURL = this.url('admin/ubus');
+
+                               rpcBaseURL = Request.get('/ubus/').then(function(res) {
+                                       return (rpcBaseURL = (res.status == 400) ? '/ubus/' : rpcFallbackURL);
+                               }, function() {
+                                       return (rpcBaseURL = rpcFallbackURL);
+                               }).then(function(url) {
+                                       try {
+                                               window.sessionStorage.setItem('rpcBaseURL', url);
+                                       }
+                                       catch (e) { }
+
+                                       return url;
+                               });
                        }
-                       catch(e) {}
 
-                       if (!elem) {
+                       return Promise.resolve(rpcBaseURL);
+               },
+
+               probeSystemFeatures: function() {
+                       if (sysFeatures == null) {
                                try {
-                                       dummyElem = dummyElem || document.createElement('div');
-                                       dummyElem.innerHTML = s;
-                                       elem = dummyElem.firstChild;
+                                       sysFeatures = JSON.parse(window.sessionStorage.getItem('sysFeatures'));
                                }
                                catch (e) {}
                        }
 
-                       return elem || null;
+                       if (!this.isObject(sysFeatures)) {
+                               sysFeatures = classes.rpc.declare({
+                                       object: 'luci',
+                                       method: 'getFeatures',
+                                       expect: { '': {} }
+                               })().then(function(features) {
+                                       try {
+                                               window.sessionStorage.setItem('sysFeatures', JSON.stringify(features));
+                                       }
+                                       catch (e) {}
+
+                                       sysFeatures = features;
+
+                                       return features;
+                               });
+                       }
+
+                       return Promise.resolve(sysFeatures);
+               },
+
+               hasSystemFeature: function() {
+                       var ft = sysFeatures[arguments[0]];
+
+                       if (arguments.length == 2)
+                               return this.isObject(ft) ? ft[arguments[1]] : null;
+
+                       return (ft != null && ft != false);
                },
 
-               matches: function(node, selector) {
-                       var m = this.elem(node) ? node.matches || node.msMatchesSelector : null;
-                       return m ? m.call(node, selector) : false;
+               notifySessionExpiry: function() {
+                       Poll.stop();
+
+                       L.ui.showModal(_('Session expired'), [
+                               E('div', { class: 'alert-message warning' },
+                                       _('A new login is required since the authentication session expired.')),
+                               E('div', { class: 'right' },
+                                       E('div', {
+                                               class: 'btn primary',
+                                               click: function() {
+                                                       var loc = window.location;
+                                                       window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search;
+                                               }
+                                       }, _('To login…')))
+                       ]);
+
+                       L.raise('SessionError', 'Login session is expired');
                },
 
-               parent: function(node, selector) {
-                       if (this.elem(node) && node.closest)
-                               return node.closest(selector);
+               setupDOM: function(res) {
+                       var domEv = res[0],
+                           uiClass = res[1],
+                           rpcClass = res[2],
+                           formClass = res[3],
+                           rpcBaseURL = res[4];
 
-                       while (this.elem(node))
-                               if (this.matches(node, selector))
-                                       return node;
-                               else
-                                       node = node.parentNode;
+                       rpcClass.setBaseURL(rpcBaseURL);
 
-                       return null;
+                       rpcClass.addInterceptor(function(msg, req) {
+                               if (!L.isObject(msg) || !L.isObject(msg.error) || msg.error.code != -32002)
+                                       return;
+
+                               if (!L.isObject(req) || (req.object == 'session' && req.method == 'access'))
+                                       return;
+
+                               return rpcClass.declare({
+                                       'object': 'session',
+                                       'method': 'access',
+                                       'params': [ 'scope', 'object', 'function' ],
+                                       'expect': { access: true }
+                               })('uci', 'luci', 'read').catch(L.notifySessionExpiry);
+                       });
+
+                       Request.addInterceptor(function(res) {
+                               var isDenied = false;
+
+                               if (res.status == 403 && res.headers.get('X-LuCI-Login-Required') == 'yes')
+                                       isDenied = true;
+
+                               if (!isDenied)
+                                       return;
+
+                               L.notifySessionExpiry();
+                       });
+
+                       return this.probeSystemFeatures().finally(this.initDOM);
                },
 
-               append: function(node, children) {
-                       if (!this.elem(node))
-                               return null;
+               initDOM: function() {
+                       originalCBIInit();
+                       Poll.start();
+                       document.dispatchEvent(new CustomEvent('luci-loaded'));
+               },
 
-                       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]));
+               env: {},
 
-                               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;
-                       }
+               /* URL construction helpers */
+               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 null;
+                       return url.join('');
                },
 
-               content: function(node, children) {
-                       if (!this.elem(node))
-                               return null;
+               url: function() {
+                       return this.path(this.env.scriptname, arguments);
+               },
 
-                       while (node.firstChild)
-                               node.removeChild(node.firstChild);
+               resource: function() {
+                       return this.path(this.env.resource, arguments);
+               },
 
-                       return this.append(node, children);
+               location: function() {
+                       return this.path(this.env.scriptname, this.env.requestpath);
                },
 
-               attr: function(node, key, val) {
-                       if (!this.elem(node))
-                               return null;
 
-                       var attr = null;
+               /* Data helpers */
+               isObject: function(val) {
+                       return (val != null && typeof(val) == 'object');
+               },
 
-                       if (typeof(key) === 'object' && key !== null)
-                               attr = key;
-                       else if (typeof(key) === 'string')
-                               attr = {}, attr[key] = val;
+               sortedKeys: function(obj, key, sortmode) {
+                       if (obj == null || typeof(obj) != 'object')
+                               return [];
 
-                       for (key in attr) {
-                               if (!attr.hasOwnProperty(key) || attr[key] === null || attr[key] === undefined)
-                                       continue;
+                       return Object.keys(obj).map(function(e) {
+                               var v = (key != null) ? obj[e][key] : e;
 
-                               switch (typeof(attr[key])) {
-                               case 'function':
-                                       node.addEventListener(key, attr[key]);
+                               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 'object':
-                                       node.setAttribute(key, JSON.stringify(attr[key]));
+                               case 'num':
+                                       v = (v != null) ? +v : null;
                                        break;
-
-                               default:
-                                       node.setAttribute(key, attr[key]);
                                }
-                       }
+
+                               return [ e, v ];
+                       }).filter(function(e) {
+                               return (e[1] != null);
+                       }).sort(function(a, b) {
+                               return (a[1] > b[1]);
+                       }).map(function(e) {
+                               return e[0];
+                       });
+               },
+
+               toArray: function(val) {
+                       if (val == null)
+                               return [];
+                       else if (Array.isArray(val))
+                               return val;
+                       else if (typeof(val) == 'object')
+                               return [ val ];
+
+                       var s = String(val).trim();
+
+                       if (s == '')
+                               return [];
+
+                       return s.split(/\s+/);
                },
 
-               create: function() {
-                       var html = arguments[0],
-                           attr = (arguments[1] instanceof Object && !Array.isArray(arguments[1])) ? arguments[1] : null,
-                           data = attr ? arguments[2] : arguments[1],
-                           elem;
 
-                       if (this.elem(html))
-                               elem = html;
-                       else if (html.charCodeAt(0) === 60)
-                               elem = this.parse(html);
+               /* HTTP resource fetching */
+               get: function(url, args, cb) {
+                       return this.poll(null, url, args, cb, false);
+               },
+
+               post: function(url, args, cb) {
+                       return this.poll(null, url, args, cb, true);
+               },
+
+               poll: function(interval, url, args, cb, post) {
+                       if (interval !== null && interval <= 0)
+                               interval = this.env.pollinterval;
+
+                       var data = post ? { token: this.env.token } : null,
+                           method = post ? 'POST' : 'GET';
+
+                       if (!/^(?:\/|\S+:\/\/)/.test(url))
+                               url = this.url(url);
+
+                       if (args != null)
+                               data = Object.assign(data || {}, args);
+
+                       if (interval !== null)
+                               return Request.poll.add(interval, url, { method: method, query: data }, cb);
                        else
-                               elem = document.createElement(html);
+                               return Request.request(url, { method: method, query: data })
+                                       .then(function(res) {
+                                               var json = null;
+                                               if (/^application\/json\b/.test(res.headers.get('Content-Type')))
+                                                       try { json = res.json() } catch(e) {}
+                                               cb(res.xhr, json, res.duration);
+                                       });
+               },
+
+               stop: function(entry) { return Poll.remove(entry) },
+               halt: function() { return Poll.stop() },
+               run: function() { return Poll.start() },
+
+               /* DOM manipulation */
+               dom: Class.singleton({
+                       __name__: 'LuCI.DOM',
+
+                       elem: function(e) {
+                               return (e != null && typeof(e) == 'object' && 'nodeType' in e);
+                       },
+
+                       parse: function(s) {
+                               var elem;
+
+                               try {
+                                       domParser = domParser || new DOMParser();
+                                       elem = domParser.parseFromString(s, 'text/html').body.firstChild;
+                               }
+                               catch(e) {}
+
+                               if (!elem) {
+                                       try {
+                                               dummyElem = dummyElem || document.createElement('div');
+                                               dummyElem.innerHTML = s;
+                                               elem = dummyElem.firstChild;
+                                       }
+                                       catch (e) {}
+                               }
+
+                               return elem || null;
+                       },
+
+                       matches: function(node, selector) {
+                               var m = this.elem(node) ? node.matches || node.msMatchesSelector : null;
+                               return m ? m.call(node, selector) : false;
+                       },
+
+                       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;
 
-                       if (!elem)
                                return null;
+                       },
 
-                       this.attr(elem, attr);
-                       this.append(elem, data);
+                       append: function(node, children) {
+                               if (!this.elem(node))
+                                       return null;
 
-                       return elem;
-               }
-       };
+                               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]));
 
-       /* Setup */
-       LuCI.prototype.setupDOM = function(ev) {
-               this.tabs.init();
-       };
+                                       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;
+                               }
 
-       function LuCI(env) {
-               this.env = env;
+                               return null;
+                       },
 
-               modalDiv = document.body.appendChild(
-                       this.dom.create('div', { id: 'modal_overlay' },
-                               this.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
+                       content: function(node, children) {
+                               if (!this.elem(node))
+                                       return null;
 
-               tooltipDiv = document.body.appendChild(this.dom.create('div', { class: 'cbi-tooltip' }));
+                               var dataNodes = node.querySelectorAll('[data-idref]');
 
-               document.addEventListener('mouseover', this.showTooltip.bind(this), true);
-               document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
-               document.addEventListener('focus', this.showTooltip.bind(this), true);
-               document.addEventListener('blur', this.hideTooltip.bind(this), true);
+                               for (var i = 0; i < dataNodes.length; i++)
+                                       delete this.registry[dataNodes[i].getAttribute('data-idref')];
 
-               document.addEventListener('DOMContentLoaded', this.setupDOM.bind(this));
-       }
+                               while (node.firstChild)
+                                       node.removeChild(node.firstChild);
+
+                               return this.append(node, children);
+                       },
+
+                       attr: function(node, key, val) {
+                               if (!this.elem(node))
+                                       return null;
+
+                               var attr = null;
+
+                               if (typeof(key) === 'object' && key !== null)
+                                       attr = key;
+                               else if (typeof(key) === 'string')
+                                       attr = {}, attr[key] = val;
+
+                               for (key in attr) {
+                                       if (!attr.hasOwnProperty(key) || attr[key] == null)
+                                               continue;
+
+                                       switch (typeof(attr[key])) {
+                                       case 'function':
+                                               node.addEventListener(key, attr[key]);
+                                               break;
+
+                                       case 'object':
+                                               node.setAttribute(key, JSON.stringify(attr[key]));
+                                               break;
+
+                                       default:
+                                               node.setAttribute(key, attr[key]);
+                                       }
+                               }
+                       },
+
+                       create: function() {
+                               var html = arguments[0],
+                                   attr = arguments[1],
+                                   data = arguments[2],
+                                   elem;
+
+                               if (!(attr instanceof Object) || Array.isArray(attr))
+                                       data = attr, attr = null;
+
+                               if (Array.isArray(html)) {
+                                       elem = document.createDocumentFragment();
+                                       for (var i = 0; i < html.length; i++)
+                                               elem.appendChild(this.create(html[i]));
+                               }
+                               else if (this.elem(html)) {
+                                       elem = html;
+                               }
+                               else if (html.charCodeAt(0) === 60) {
+                                       elem = this.parse(html);
+                               }
+                               else {
+                                       elem = document.createElement(html);
+                               }
+
+                               if (!elem)
+                                       return null;
+
+                               this.attr(elem, attr);
+                               this.append(elem, data);
+
+                               return elem;
+                       },
+
+                       registry: {},
+
+                       data: function(node, key, val) {
+                               var id = node.getAttribute('data-idref');
+
+                               /* clear all data */
+                               if (arguments.length > 1 && key == null) {
+                                       if (id != null) {
+                                               node.removeAttribute('data-idref');
+                                               val = this.registry[id]
+                                               delete this.registry[id];
+                                               return val;
+                                       }
+
+                                       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;
+                       },
+
+                       bindClassInstance: function(node, inst) {
+                               if (!(inst instanceof Class))
+                                       L.error('TypeError', 'Argument must be a class instance');
+
+                               return this.data(node, '_class', inst);
+                       },
+
+                       findClassInstance: function(node) {
+                               var inst = null;
+
+                               do {
+                                       inst = this.data(node, '_class');
+                                       node = node.parentNode;
+                               }
+                               while (!(inst instanceof Class) && node != null);
+
+                               return inst;
+                       },
+
+                       callClassMethod: function(node, method /*, ... */) {
+                               var inst = this.findClassInstance(node);
+
+                               if (inst == null || typeof(inst[method]) != 'function')
+                                       return null;
+
+                               return inst[method].apply(inst, inst.varargs(arguments, 2));
+                       },
+
+                       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;
+                       }
+               }),
+
+               Poll: Poll,
+               Class: Class,
+               Request: Request,
+
+               view: Class.extend({
+                       __name__: 'LuCI.View',
+
+                       __init__: function() {
+                               var vp = document.getElementById('view');
+
+                               L.dom.content(vp, E('div', { 'class': 'spinning' }, _('Loading view…')));
+
+                               return Promise.resolve(this.load())
+                                       .then(L.bind(this.render, this))
+                                       .then(L.bind(function(nodes) {
+                                               var vp = document.getElementById('view');
+
+                                               L.dom.content(vp, nodes);
+                                               L.dom.append(vp, this.addFooter());
+                                       }, this)).catch(L.error);
+                       },
+
+                       load: function() {},
+                       render: function() {},
+
+                       handleSave: function(ev) {
+                               var tasks = [];
+
+                               document.getElementById('maincontent')
+                                       .querySelectorAll('.cbi-map').forEach(function(map) {
+                                               tasks.push(L.dom.callClassMethod(map, 'save'));
+                                       });
+
+                               return Promise.all(tasks);
+                       },
+
+                       handleSaveApply: function(ev) {
+                               return this.handleSave(ev).then(function() {
+                                       L.ui.changes.apply(true);
+                               });
+                       },
+
+                       handleReset: function(ev) {
+                               var tasks = [];
+
+                               document.getElementById('maincontent')
+                                       .querySelectorAll('.cbi-map').forEach(function(map) {
+                                               tasks.push(L.dom.callClassMethod(map, 'reset'));
+                                       });
+
+                               return Promise.all(tasks);
+                       },
+
+                       addFooter: function() {
+                               var footer = E([]),
+                                   mc = document.getElementById('maincontent');
+
+                               if (mc.querySelector('.cbi-map')) {
+                                       footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [
+                                               E('button', {
+                                                       'class': 'cbi-button cbi-button-apply',
+                                                       'click': L.ui.createHandlerFn(this, 'handleSaveApply')
+                                               }, _('Save & Apply')), ' ',
+                                               E('button', {
+                                                       'class': 'cbi-button cbi-button-save',
+                                                       'click': L.ui.createHandlerFn(this, 'handleSave')
+                                               }, _('Save')), ' ',
+                                               E('button', {
+                                                       'class': 'cbi-button cbi-button-reset',
+                                                       'click': L.ui.createHandlerFn(this, 'handleReset')
+                                               }, _('Reset'))
+                                       ]));
+                               }
+
+                               return footer;
+                       }
+               })
+       });
+
+       var XHR = Class.extend({
+               __name__: 'LuCI.XHR',
+               __init__: function() {
+                       if (window.console && console.debug)
+                               console.debug('Direct use XHR() is deprecated, please use L.Request instead');
+               },
+
+               _response: function(cb, res, json, duration) {
+                       if (this.active)
+                               cb(res, json, duration);
+                       delete this.active;
+               },
+
+               get: function(url, data, callback, timeout) {
+                       this.active = true;
+                       L.get(url, data, this._response.bind(this, callback), timeout);
+               },
+
+               post: function(url, data, callback, timeout) {
+                       this.active = true;
+                       L.post(url, data, this._response.bind(this, callback), timeout);
+               },
 
+               cancel: function() { delete this.active },
+               busy: function() { return (this.active === true) },
+               abort: function() {},
+               send_form: function() { L.error('InternalError', 'Not implemented') },
+       });
+
+       XHR.get = function() { return window.L.get.apply(window.L, arguments) };
+       XHR.post = function() { return window.L.post.apply(window.L, arguments) };
+       XHR.poll = function() { return window.L.poll.apply(window.L, arguments) };
+       XHR.stop = Request.poll.remove.bind(Request.poll);
+       XHR.halt = Request.poll.stop.bind(Request.poll);
+       XHR.run = Request.poll.start.bind(Request.poll);
+       XHR.running = Request.poll.active.bind(Request.poll);
+
+       window.XHR = XHR;
        window.LuCI = LuCI;
 })(window, document);