Merge pull request #1735 from sumpfralle/olsr-jsoninfo-parser-handle-empty-result
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / luci.js
index bf7f65a7d120b44dc33459a7f56185a6f8c10a6e..4e3c8445a94dc0013a20708e4744f622e7c9c951 100644 (file)
@@ -1,4 +1,6 @@
 (function(window, document, undefined) {
+       'use strict';
+
        /* Object.assign polyfill for IE */
        if (typeof Object.assign !== 'function') {
                Object.defineProperty(Object, 'assign', {
                });
        }
 
+       /* 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) }) }
+                       );
+               };
+       }
+
        /*
         * Class declaration and inheritance helper
         */
         * HTTP Request helper
         */
 
-       Headers = Class.extend({
+       var Headers = Class.extend({
                __name__: 'LuCI.XHR.Headers',
                __init__: function(xhr) {
                        var hdrs = this.headers = {};
                }
        });
 
-       Response = Class.extend({
+       var Response = Class.extend({
                __name__: 'LuCI.XHR.Response',
-               __init__: function(xhr, url, duration) {
+               __init__: function(xhr, url, duration, headers, content) {
                        this.ok = (xhr.status >= 200 && xhr.status <= 299);
                        this.status = xhr.status;
                        this.statusText = xhr.statusText;
-                       this.responseText = xhr.responseText;
-                       this.headers = new Headers(xhr);
+                       this.headers = (headers != null) ? headers : new Headers(xhr);
                        this.duration = duration;
                        this.url = url;
                        this.xhr = xhr;
+
+                       if (content != null && typeof(content) == 'object') {
+                               this.responseJSON = content;
+                               this.responseText = null;
+                       }
+                       else if (content != null) {
+                               this.responseJSON = null;
+                               this.responseText = String(content);
+                       }
+                       else {
+                               this.responseJSON = null;
+                               this.responseText = xhr.responseText;
+                       }
+               },
+
+               clone: function(content) {
+                       var copy = new Response(this.xhr, this.url, this.duration, this.headers, content);
+
+                       copy.ok = this.ok;
+                       copy.status = this.status;
+                       copy.statusText = this.statusText;
+
+                       return copy;
                },
 
                json: function() {
-                       return JSON.parse(this.responseText);
+                       if (this.responseJSON == null)
+                               this.responseJSON = JSON.parse(this.responseText);
+
+                       return this.responseJSON;
                },
 
                text: function() {
+                       if (this.responseText == null && this.responseJSON != null)
+                               this.responseText = JSON.stringify(this.responseJSON);
+
                        return this.responseText;
                }
        });
 
-       Request = Class.singleton({
+
+       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: target, start: Date.now() },
+                       var state = { xhr: new XMLHttpRequest(), url: this.expandURL(target), start: Date.now() },
                            opt = Object.assign({}, options, state),
                            content = null,
                            contenttype = null,
                                if (!opt.cache)
                                        opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime();
 
-                               if (!/^(?:[^/]+:)?\/\//.test(opt.url))
-                                       opt.url = location.protocol + '//' + location.host + opt.url;
+                               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);
                                        opt.xhr.open(opt.method, opt.url, true);
 
                                opt.xhr.responseType = 'text';
-                               opt.xhr.overrideMimeType('application/octet-stream');
+
+                               if ('overrideMimeType' in opt.xhr)
+                                       opt.xhr.overrideMimeType('application/octet-stream');
 
                                if ('timeout' in opt)
                                        opt.xhr.timeout = +opt.timeout;
                                                break;
 
                                        case 'object':
-                                               content = JSON.stringify(opt.content);
-                                               contenttype = 'application/json';
+                                               if (!(opt.content instanceof FormData)) {
+                                                       content = JSON.stringify(opt.content);
+                                                       contenttype = 'application/json';
+                                               }
+                                               else {
+                                                       content = opt.content;
+                                               }
                                                break;
 
                                        default:
                                                                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);
 
                },
 
                handleReadyStateChange: function(resolveFn, rejectFn, ev) {
-                       var xhr = this.xhr;
+                       var xhr = this.xhr,
+                           duration = Date.now() - this.start;
 
                        if (xhr.readyState !== 4)
                                return;
 
                        if (xhr.status === 0 && xhr.statusText === '') {
-                               rejectFn.call(this, new Error('XHR request aborted by browser'));
+                               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, Date.now() - this.start);
+                                       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));
                        }
-
-                       try {
-                               xhr.abort();
-                       }
-                       catch(e) {}
                },
 
                get: function(url, options) {
                        return (this.interceptors.length < oldlen);
                },
 
-               poll: Class.singleton({
-                       __name__: 'LuCI.Request.Poll',
-
-                       queue: [],
-
+               poll: {
                        add: function(interval, url, options, callback) {
                                if (isNaN(interval) || interval <= 0)
                                        throw new TypeError('Invalid poll interval');
 
-                               var e = {
-                                       interval: interval,
-                                       url: url,
-                                       options: options,
-                                       callback: callback
-                               };
+                               var ival = interval >>> 0,
+                                   opts = Object.assign({}, options, { timeout: ival * 1000 - 5 });
+
+                               return Poll.add(function() {
+                                       return Request.request(url, options).then(function(res) {
+                                               if (!Poll.active())
+                                                       return;
 
-                               this.queue.push(e);
-                               return e;
+                                               try {
+                                                       callback(res, res.json(), res.duration);
+                                               }
+                                               catch (err) {
+                                                       callback(res, null, res.duration);
+                                               }
+                                       });
+                               }, ival);
                        },
 
-                       remove: function(entry) {
-                               var oldlen = this.queue.length, i = oldlen;
+                       remove: function(entry) { return Poll.remove(entry) },
+                       start: function() { return Poll.start() },
+                       stop: function() { return Poll.stop() },
+                       active: function() { return Poll.active() }
+               }
+       });
 
-                               while (i--)
-                                       if (this.queue[i] === entry) {
-                                               delete this.queue[i].running;
-                                               this.queue.splice(i, 1);
-                                       }
+       var Poll = Class.singleton({
+               __name__: 'LuCI.Poll',
 
-                               if (!this.queue.length)
-                                       this.stop();
+               queue: [],
 
-                               return (this.queue.length < oldlen);
-                       },
+               add: function(fn, interval) {
+                       if (interval == null || interval <= 0)
+                               interval = window.L ? window.L.env.pollinterval : null;
 
-                       start: function() {
-                               if (!this.queue.length || this.active())
+                       if (isNaN(interval) || typeof(fn) != 'function')
+                               throw new TypeError('Invalid argument to LuCI.Poll.add()');
+
+                       for (var i = 0; i < this.queue.length; i++)
+                               if (this.queue[i].fn === fn)
                                        return false;
 
+                       var e = {
+                               r: true,
+                               i: interval >>> 0,
+                               fn: fn
+                       };
+
+                       this.queue.push(e);
+
+                       if (this.tick != null && !this.active())
+                               this.start();
+
+                       return true;
+               },
+
+               remove: function(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);
+               },
+
+               start: function() {
+                       if (this.active())
+                               return false;
+
+                       this.tick = 0;
+
+                       if (this.queue.length) {
                                this.timer = window.setInterval(this.step, 1000);
                                this.step();
                                document.dispatchEvent(new CustomEvent('poll-start'));
-                               return true;
-                       },
+                       }
 
-                       stop: function() {
-                               if (!this.active())
-                                       return false;
+                       return true;
+               },
 
-                               document.dispatchEvent(new CustomEvent('poll-stop'));
-                               window.clearInterval(this.timer);
-                               delete this.timer;
-                               delete this.tick;
-                               return true;
-                       },
+               stop: function() {
+                       if (!this.active())
+                               return false;
 
-                       step: function() {
-                               Request.poll.queue.forEach(function(e) {
-                                       if ((Request.poll.tick % e.interval) != 0)
-                                               return;
+                       document.dispatchEvent(new CustomEvent('poll-stop'));
+                       window.clearInterval(this.timer);
+                       delete this.timer;
+                       delete this.tick;
+                       return true;
+               },
 
-                                       if (e.running)
-                                               return;
+               step: function() {
+                       for (var i = 0, e = null; (e = Poll.queue[i]) != null; i++) {
+                               if ((Poll.tick % e.i) != 0)
+                                       continue;
 
-                                       var opts = Object.assign({}, e.options,
-                                               { timeout: e.interval * 1000 - 5 });
+                               if (!e.r)
+                                       continue;
 
-                                       e.running = true;
-                                       Request.request(e.url, opts)
-                                               .then(function(res) {
-                                                       if (!e.running || !Request.poll.active())
-                                                               return;
+                               e.r = false;
 
-                                                       try {
-                                                               e.callback(res, res.json(), res.duration);
-                                                       }
-                                                       catch (err) {
-                                                               e.callback(res, null, res.duration);
-                                                       }
-                                               })
-                                               .finally(function() { delete e.running });
-                               });
+                               Promise.resolve(e.fn()).finally((function() { this.r = true }).bind(e));
+                       }
 
-                               Request.poll.tick = (Request.poll.tick + 1) % Math.pow(2, 32);
-                       },
+                       Poll.tick = (Poll.tick + 1) % Math.pow(2, 32);
+               },
 
-                       active: function() {
-                               return (this.timer != null);
-                       }
-               })
+               active: function() {
+                       return (this.timer != null);
+               }
        });
 
 
        var dummyElem = null,
            domParser = null,
            originalCBIInit = null,
+           rpcBaseURL = null,
+           sysFeatures = null,
            classes = {};
 
-       LuCI = Class.extend({
+       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 || env.base_url == '') {
+                                       var m = (s.getAttribute('src') || '').match(/^(.*)\/luci\.js(?:\?v=([^?]+))?$/);
+                                       if (m) {
+                                               env.base_url = m[1];
+                                               env.resource_version = m[2];
+                                       }
+                               }
                        });
 
                        if (env.base_url == null)
                        Promise.all([
                                domReady,
                                this.require('ui'),
-                               this.require('form')
+                               this.require('rpc'),
+                               this.require('form'),
+                               this.probeRPCBaseURL()
                        ]).then(this.setupDOM.bind(this)).catch(this.error);
 
                        originalCBIInit = window.cbi_init;
                        window.cbi_init = function() {};
                },
 
-               error: function(type, fmt /*, ...*/) {
+               raise: function(type, fmt /*, ...*/) {
                        var e = null,
                            msg = fmt ? String.prototype.format.apply(fmt, this.varargs(arguments, 2)) : null,
                            stack = null;
 
                        if (type instanceof Error) {
                                e = type;
-                               stack = (e.stack || '').split(/\n/);
 
                                if (msg)
                                        e.message = msg + ': ' + e.message;
                        }
                        else {
+                               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';
+                       }
 
-                               try { throw new Error('stacktrace') }
-                               catch (e2) { stack = (e2.stack || '').split(/\n/) }
+                       stack = (stack || []).map(function(frame) {
+                               frame = frame.replace(/(.*?)@(.+):(\d+):(\d+)/g, 'at $1 ($2:$3:$4)').trim();
+                               return frame ? '  ' + frame : '';
+                       });
 
-                               /* IE puts the exception message into the first line */
-                               if (stack[0] == 'Error: stacktrace')
-                                       stack.shift();
+                       if (!/^  at /.test(stack[0]))
+                               stack.shift();
 
-                               /* Pop L.error() invocation from stack */
+                       if (/\braise /.test(stack[0]))
                                stack.shift();
-                       }
 
-                       /* Append shortened & beautified stacktrace to message */
-                       var trace = stack.join('\n')
-                               .replace(/(.*?)@(.+):(\d+):(\d+)/g, '  at $1 ($2:$3:$4)');
+                       if (/\berror /.test(stack[0]))
+                               stack.shift();
 
-                       if (e.message.indexOf(trace) == -1)
-                               e.message += '\n' + trace;
+                       if (stack.length)
+                               e.message += '\n' + stack.join('\n');
 
                        if (window.console && console.debug)
                                console.debug(e);
 
-                       if (this.ui)
-                               this.ui.showModal(_('Runtime error'),
-                                       E('pre', { 'class': 'alert-message error' }, e));
-                       else
-                               L.dom.content(document.querySelector('#maincontent'),
-                                       E('pre', { 'class': 'alert-message error' }, e));
-
                        throw e;
                },
 
+               error: function(type, fmt /*, ...*/) {
+                       try {
+                               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;
+                               }
+
+                               throw e;
+                       }
+               },
+
                bind: function(fn, self /*, ... */) {
                        return Function.prototype.bind.apply(fn, this.varargs(arguments, 2, self));
                },
                        if (classes[name] != null) {
                                /* Circular dependency */
                                if (from.indexOf(name) != -1)
-                                       L.error('DependencyError',
+                                       L.raise('DependencyError',
                                                'Circular dependency: class "%s" depends on "%s"',
                                                name, from.join('" which depends on "'));
 
                                return classes[name];
                        }
 
-                       url = '%s/%s.js'.format(L.env.base_url, name.replace(/\./g, '/'));
+                       url = '%s/%s.js%s'.format(L.env.base_url, name.replace(/\./g, '/'), (L.env.resource_version ? '?v=' + L.env.resource_version : ''));
                        from = [ name ].concat(from);
 
                        var compileClass = function(res) {
                                if (!res.ok)
-                                       L.error('NetworkError',
+                                       L.raise('NetworkError',
                                                'HTTP error %d while loading class file "%s"', res.status, url);
 
                                var source = res.text(),
 
                                /* 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.error('SyntaxError', '%s\n  in %s:%s',
+                                               L.raise('SyntaxError', '%s\n  in %s:%s',
                                                        error.message, res.url, error.lineNumber || '?');
                                        }
 
                        };
 
                        /* Request class file */
-                       classes[name] = Request.get(url, { cache: true })
-                               .then(compileClass)
-                               .catch(L.error);
+                       classes[name] = Request.get(url, { cache: true }).then(compileClass);
 
                        return classes[name];
                },
 
                /* DOM setup */
-               setupDOM: function(ev) {
-                       Request.addInterceptor(function(res) {
-                               if (res.status != 403 || res.headers.get('X-LuCI-Login-Required') != 'yes')
+               probeRPCBaseURL: function() {
+                       if (rpcBaseURL == null) {
+                               try {
+                                       rpcBaseURL = window.sessionStorage.getItem('rpcBaseURL');
+                               }
+                               catch (e) { }
+                       }
+
+                       if (rpcBaseURL == null) {
+                               var rpcFallbackURL = this.url('admin/ubus');
+
+                               rpcBaseURL = Request.get('/ubus/').then(function(res) {
+                                       return (rpcBaseURL = (res.status == 400) ? '/ubus/' : rpcFallbackURL);
+                               }, function() {
+                                       return (rpcBaseURL = rpcFallbackURL);
+                               }).then(function(url) {
+                                       try {
+                                               window.sessionStorage.setItem('rpcBaseURL', url);
+                                       }
+                                       catch (e) { }
+
+                                       return url;
+                               });
+                       }
+
+                       return Promise.resolve(rpcBaseURL);
+               },
+
+               probeSystemFeatures: function() {
+                       var sessionid = classes.rpc.getSessionID();
+
+                       if (sysFeatures == null) {
+                               try {
+                                       var data = JSON.parse(window.sessionStorage.getItem('sysFeatures'));
+
+                                       if (this.isObject(data) && this.isObject(data[sessionid]))
+                                               sysFeatures = data[sessionid];
+                               }
+                               catch (e) {}
+                       }
+
+                       if (!this.isObject(sysFeatures)) {
+                               sysFeatures = classes.rpc.declare({
+                                       object: 'luci',
+                                       method: 'getFeatures',
+                                       expect: { '': {} }
+                               })().then(function(features) {
+                                       try {
+                                               var data = {};
+                                                   data[sessionid] = features;
+
+                                               window.sessionStorage.setItem('sysFeatures', JSON.stringify(data));
+                                       }
+                                       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);
+               },
+
+               notifySessionExpiry: function() {
+                       Poll.stop();
+
+                       L.ui.showModal(_('Session expired'), [
+                               E('div', { class: 'alert-message warning' },
+                                       _('A new login is required since the authentication session expired.')),
+                               E('div', { class: 'right' },
+                                       E('div', {
+                                               class: 'btn primary',
+                                               click: function() {
+                                                       var loc = window.location;
+                                                       window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search;
+                                               }
+                                       }, _('To login…')))
+                       ]);
+
+                       L.raise('SessionError', 'Login session is expired');
+               },
+
+               setupDOM: function(res) {
+                       var domEv = res[0],
+                           uiClass = res[1],
+                           rpcClass = res[2],
+                           formClass = res[3],
+                           rpcBaseURL = res[4];
+
+                       rpcClass.setBaseURL(rpcBaseURL);
+
+                       rpcClass.addInterceptor(function(msg, req) {
+                               if (!L.isObject(msg) || !L.isObject(msg.error) || msg.error.code != -32002)
                                        return;
 
-                               Request.poll.stop();
-
-                               L.ui.showModal(_('Session expired'), [
-                                       E('div', { class: 'alert-message warning' },
-                                               _('A new login is required since the authentication session expired.')),
-                                       E('div', { class: 'right' },
-                                               E('div', {
-                                                       class: 'btn primary',
-                                                       click: function() {
-                                                               var loc = window.location;
-                                                               window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search;
-                                                       }
-                                               }, _('To login…')))
-                               ]);
+                               if (!L.isObject(req) || (req.object == 'session' && req.method == 'access'))
+                                       return;
 
-                               throw 'Session expired';
+                               return rpcClass.declare({
+                                       'object': 'session',
+                                       'method': 'access',
+                                       'params': [ 'scope', 'object', 'function' ],
+                                       'expect': { access: true }
+                               })('uci', 'luci', 'read').catch(L.notifySessionExpiry);
                        });
 
-                       originalCBIInit();
-                       Request.poll.start();
+                       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);
+               },
+
+               initDOM: function() {
+                       originalCBIInit();
+                       Poll.start();
                        document.dispatchEvent(new CustomEvent('luci-loaded'));
                },
 
                },
 
 
+               /* Data helpers */
+               isObject: function(val) {
+                       return (val != null && typeof(val) == 'object');
+               },
+
+               sortedKeys: function(obj, key, sortmode) {
+                       if (obj == null || typeof(obj) != 'object')
+                               return [];
+
+                       return Object.keys(obj).map(function(e) {
+                               var v = (key != null) ? obj[e][key] : e;
+
+                               switch (sortmode) {
+                               case 'addr':
+                                       v = (v != null) ? v.replace(/(?:^|[.:])([0-9a-fA-F]{1,4})/g,
+                                               function(m0, m1) { return ('000' + m1.toLowerCase()).substr(-4) }) : null;
+                                       break;
+
+                               case 'num':
+                                       v = (v != null) ? +v : null;
+                                       break;
+                               }
+
+                               return [ e, v ];
+                       }).filter(function(e) {
+                               return (e[1] != null);
+                       }).sort(function(a, b) {
+                               return (a[1] > b[1]);
+                       }).map(function(e) {
+                               return e[0];
+                       });
+               },
+
+               toArray: function(val) {
+                       if (val == null)
+                               return [];
+                       else if (Array.isArray(val))
+                               return val;
+                       else if (typeof(val) == 'object')
+                               return [ val ];
+
+                       var s = String(val).trim();
+
+                       if (s == '')
+                               return [];
+
+                       return s.split(/\s+/);
+               },
+
+
                /* HTTP resource fetching */
                get: function(url, args, cb) {
                        return this.poll(null, url, args, cb, false);
                                        });
                },
 
-               stop: function(entry) { return Request.poll.remove(entry) },
-               halt: function() { return Request.poll.stop() },
-               run: function() { return Request.poll.start() },
+               stop: function(entry) { return Poll.remove(entry) },
+               halt: function() { return Poll.stop() },
+               run: function() { return Poll.start() },
 
                /* DOM manipulation */
                dom: Class.singleton({
                                        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,
 
                        },
 
                        addFooter: function() {
-                               var footer = E([]),
-                                   mc = document.getElementById('maincontent');
+                               var footer = E([]);
 
-                               if (mc.querySelector('.cbi-map')) {
+                               if (this.handleSaveApply || this.handleSave || this.handleReset) {
                                        footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [
-                                               E('input', {
+                                               this.handleSaveApply ? E('button', {
                                                        'class': 'cbi-button cbi-button-apply',
-                                                       'type': 'button',
-                                                       'value': _('Save & Apply'),
-                                                       'click': L.bind(this.handleSaveApply, this)
-                                               }), ' ',
-                                               E('input', {
+                                                       'click': L.ui.createHandlerFn(this, 'handleSaveApply')
+                                               }, [ _('Save & Apply') ]) : '', ' ',
+                                               this.handleSave ? E('button', {
                                                        'class': 'cbi-button cbi-button-save',
-                                                       'type': 'submit',
-                                                       'value': _('Save'),
-                                                       'click': L.bind(this.handleSave, this)
-                                               }), ' ',
-                                               E('input', {
+                                                       'click': L.ui.createHandlerFn(this, 'handleSave')
+                                               }, [ _('Save') ]) : '', ' ',
+                                               this.handleReset ? E('button', {
                                                        'class': 'cbi-button cbi-button-reset',
-                                                       'type': 'button',
-                                                       'value': _('Reset'),
-                                                       'click': L.bind(this.handleReset, this)
-                                               })
+                                                       'click': L.ui.createHandlerFn(this, 'handleReset')
+                                               }, [ _('Reset') ]) : ''
                                        ]));
                                }
 
                })
        });
 
-       XHR = Class.extend({
+       var XHR = Class.extend({
                __name__: 'LuCI.XHR',
                __init__: function() {
                        if (window.console && console.debug)