luci2: split into submodules
[project/luci2/ui.git] / luci2 / htdocs / luci2 / cbi.js
diff --git a/luci2/htdocs/luci2/cbi.js b/luci2/htdocs/luci2/cbi.js
new file mode 100644 (file)
index 0000000..cfc677d
--- /dev/null
@@ -0,0 +1,3200 @@
+(function() {
+       var type = function(f, l)
+       {
+               f.message = l;
+               return f;
+       };
+
+       var cbi_class = {
+               validation: {
+                       i18n: function(msg)
+                       {
+                               L.cbi.validation.message = L.tr(msg);
+                       },
+
+                       compile: function(code)
+                       {
+                               var pos = 0;
+                               var esc = false;
+                               var depth = 0;
+                               var types = L.cbi.validation.types;
+                               var stack = [ ];
+
+                               code += ',';
+
+                               for (var i = 0; i < code.length; i++)
+                               {
+                                       if (esc)
+                                       {
+                                               esc = false;
+                                               continue;
+                                       }
+
+                                       switch (code.charCodeAt(i))
+                                       {
+                                       case 92:
+                                               esc = true;
+                                               break;
+
+                                       case 40:
+                                       case 44:
+                                               if (depth <= 0)
+                                               {
+                                                       if (pos < i)
+                                                       {
+                                                               var label = code.substring(pos, i);
+                                                                       label = label.replace(/\\(.)/g, '$1');
+                                                                       label = label.replace(/^[ \t]+/g, '');
+                                                                       label = label.replace(/[ \t]+$/g, '');
+
+                                                               if (label && !isNaN(label))
+                                                               {
+                                                                       stack.push(parseFloat(label));
+                                                               }
+                                                               else if (label.match(/^(['"]).*\1$/))
+                                                               {
+                                                                       stack.push(label.replace(/^(['"])(.*)\1$/, '$2'));
+                                                               }
+                                                               else if (typeof types[label] == 'function')
+                                                               {
+                                                                       stack.push(types[label]);
+                                                                       stack.push([ ]);
+                                                               }
+                                                               else
+                                                               {
+                                                                       throw "Syntax error, unhandled token '"+label+"'";
+                                                               }
+                                                       }
+                                                       pos = i+1;
+                                               }
+                                               depth += (code.charCodeAt(i) == 40);
+                                               break;
+
+                                       case 41:
+                                               if (--depth <= 0)
+                                               {
+                                                       if (typeof stack[stack.length-2] != 'function')
+                                                               throw "Syntax error, argument list follows non-function";
+
+                                                       stack[stack.length-1] =
+                                                               L.cbi.validation.compile(code.substring(pos, i));
+
+                                                       pos = i+1;
+                                               }
+                                               break;
+                                       }
+                               }
+
+                               return stack;
+                       }
+               }
+       };
+
+       var validation = cbi_class.validation;
+
+       validation.types = {
+               'integer': function()
+               {
+                       if (this.match(/^-?[0-9]+$/) != null)
+                               return true;
+
+                       validation.i18n('Must be a valid integer');
+                       return false;
+               },
+
+               'uinteger': function()
+               {
+                       if (validation.types['integer'].apply(this) && (this >= 0))
+                               return true;
+
+                       validation.i18n('Must be a positive integer');
+                       return false;
+               },
+
+               'float': function()
+               {
+                       if (!isNaN(parseFloat(this)))
+                               return true;
+
+                       validation.i18n('Must be a valid number');
+                       return false;
+               },
+
+               'ufloat': function()
+               {
+                       if (validation.types['float'].apply(this) && (this >= 0))
+                               return true;
+
+                       validation.i18n('Must be a positive number');
+                       return false;
+               },
+
+               'ipaddr': function()
+               {
+                       if (L.parseIPv4(this) || L.parseIPv6(this))
+                               return true;
+
+                       validation.i18n('Must be a valid IP address');
+                       return false;
+               },
+
+               'ip4addr': function()
+               {
+                       if (L.parseIPv4(this))
+                               return true;
+
+                       validation.i18n('Must be a valid IPv4 address');
+                       return false;
+               },
+
+               'ip6addr': function()
+               {
+                       if (L.parseIPv6(this))
+                               return true;
+
+                       validation.i18n('Must be a valid IPv6 address');
+                       return false;
+               },
+
+               'netmask4': function()
+               {
+                       if (L.isNetmask(L.parseIPv4(this)))
+                               return true;
+
+                       validation.i18n('Must be a valid IPv4 netmask');
+                       return false;
+               },
+
+               'netmask6': function()
+               {
+                       if (L.isNetmask(L.parseIPv6(this)))
+                               return true;
+
+                       validation.i18n('Must be a valid IPv6 netmask6');
+                       return false;
+               },
+
+               'cidr4': function()
+               {
+                       if (this.match(/^([0-9.]+)\/(\d{1,2})$/))
+                               if (RegExp.$2 <= 32 && L.parseIPv4(RegExp.$1))
+                                       return true;
+
+                       validation.i18n('Must be a valid IPv4 prefix');
+                       return false;
+               },
+
+               'cidr6': function()
+               {
+                       if (this.match(/^([a-fA-F0-9:.]+)\/(\d{1,3})$/))
+                               if (RegExp.$2 <= 128 && L.parseIPv6(RegExp.$1))
+                                       return true;
+
+                       validation.i18n('Must be a valid IPv6 prefix');
+                       return false;
+               },
+
+               'ipmask4': function()
+               {
+                       if (this.match(/^([0-9.]+)\/([0-9.]+)$/))
+                       {
+                               var addr = RegExp.$1, mask = RegExp.$2;
+                               if (L.parseIPv4(addr) && L.isNetmask(L.parseIPv4(mask)))
+                                       return true;
+                       }
+
+                       validation.i18n('Must be a valid IPv4 address/netmask pair');
+                       return false;
+               },
+
+               'ipmask6': function()
+               {
+                       if (this.match(/^([a-fA-F0-9:.]+)\/([a-fA-F0-9:.]+)$/))
+                       {
+                               var addr = RegExp.$1, mask = RegExp.$2;
+                               if (L.parseIPv6(addr) && L.isNetmask(L.parseIPv6(mask)))
+                                       return true;
+                       }
+
+                       validation.i18n('Must be a valid IPv6 address/netmask pair');
+                       return false;
+               },
+
+               'port': function()
+               {
+                       if (validation.types['integer'].apply(this) &&
+                               (this >= 0) && (this <= 65535))
+                               return true;
+
+                       validation.i18n('Must be a valid port number');
+                       return false;
+               },
+
+               'portrange': function()
+               {
+                       if (this.match(/^(\d+)-(\d+)$/))
+                       {
+                               var p1 = RegExp.$1;
+                               var p2 = RegExp.$2;
+
+                               if (validation.types['port'].apply(p1) &&
+                                   validation.types['port'].apply(p2) &&
+                                   (parseInt(p1) <= parseInt(p2)))
+                                       return true;
+                       }
+                       else if (validation.types['port'].apply(this))
+                       {
+                               return true;
+                       }
+
+                       validation.i18n('Must be a valid port range');
+                       return false;
+               },
+
+               'macaddr': function()
+               {
+                       if (this.match(/^([a-fA-F0-9]{2}:){5}[a-fA-F0-9]{2}$/) != null)
+                               return true;
+
+                       validation.i18n('Must be a valid MAC address');
+                       return false;
+               },
+
+               'host': function()
+               {
+                       if (validation.types['hostname'].apply(this) ||
+                           validation.types['ipaddr'].apply(this))
+                               return true;
+
+                       validation.i18n('Must be a valid hostname or IP address');
+                       return false;
+               },
+
+               'hostname': function()
+               {
+                       if ((this.length <= 253) &&
+                           ((this.match(/^[a-zA-Z0-9]+$/) != null ||
+                            (this.match(/^[a-zA-Z0-9_][a-zA-Z0-9_\-.]*[a-zA-Z0-9]$/) &&
+                             this.match(/[^0-9.]/)))))
+                               return true;
+
+                       validation.i18n('Must be a valid host name');
+                       return false;
+               },
+
+               'network': function()
+               {
+                       if (validation.types['uciname'].apply(this) ||
+                           validation.types['host'].apply(this))
+                               return true;
+
+                       validation.i18n('Must be a valid network name');
+                       return false;
+               },
+
+               'wpakey': function()
+               {
+                       var v = this;
+
+                       if ((v.length == 64)
+                             ? (v.match(/^[a-fA-F0-9]{64}$/) != null)
+                                 : ((v.length >= 8) && (v.length <= 63)))
+                               return true;
+
+                       validation.i18n('Must be a valid WPA key');
+                       return false;
+               },
+
+               'wepkey': function()
+               {
+                       var v = this;
+
+                       if (v.substr(0,2) == 's:')
+                               v = v.substr(2);
+
+                       if (((v.length == 10) || (v.length == 26))
+                             ? (v.match(/^[a-fA-F0-9]{10,26}$/) != null)
+                             : ((v.length == 5) || (v.length == 13)))
+                               return true;
+
+                       validation.i18n('Must be a valid WEP key');
+                       return false;
+               },
+
+               'uciname': function()
+               {
+                       if (this.match(/^[a-zA-Z0-9_]+$/) != null)
+                               return true;
+
+                       validation.i18n('Must be a valid UCI identifier');
+                       return false;
+               },
+
+               'range': function(min, max)
+               {
+                       var val = parseFloat(this);
+
+                       if (validation.types['integer'].apply(this) &&
+                           !isNaN(min) && !isNaN(max) && ((val >= min) && (val <= max)))
+                               return true;
+
+                       validation.i18n('Must be a number between %d and %d');
+                       return false;
+               },
+
+               'min': function(min)
+               {
+                       var val = parseFloat(this);
+
+                       if (validation.types['integer'].apply(this) &&
+                           !isNaN(min) && !isNaN(val) && (val >= min))
+                               return true;
+
+                       validation.i18n('Must be a number greater or equal to %d');
+                       return false;
+               },
+
+               'max': function(max)
+               {
+                       var val = parseFloat(this);
+
+                       if (validation.types['integer'].apply(this) &&
+                           !isNaN(max) && !isNaN(val) && (val <= max))
+                               return true;
+
+                       validation.i18n('Must be a number lower or equal to %d');
+                       return false;
+               },
+
+               'rangelength': function(min, max)
+               {
+                       var val = '' + this;
+
+                       if (!isNaN(min) && !isNaN(max) &&
+                           (val.length >= min) && (val.length <= max))
+                               return true;
+
+                       if (min != max)
+                               validation.i18n('Must be between %d and %d characters');
+                       else
+                               validation.i18n('Must be %d characters');
+                       return false;
+               },
+
+               'minlength': function(min)
+               {
+                       var val = '' + this;
+
+                       if (!isNaN(min) && (val.length >= min))
+                               return true;
+
+                       validation.i18n('Must be at least %d characters');
+                       return false;
+               },
+
+               'maxlength': function(max)
+               {
+                       var val = '' + this;
+
+                       if (!isNaN(max) && (val.length <= max))
+                               return true;
+
+                       validation.i18n('Must be at most %d characters');
+                       return false;
+               },
+
+               'or': function()
+               {
+                       var msgs = [ ];
+
+                       for (var i = 0; i < arguments.length; i += 2)
+                       {
+                               delete validation.message;
+
+                               if (typeof(arguments[i]) != 'function')
+                               {
+                                       if (arguments[i] == this)
+                                               return true;
+                                       i--;
+                               }
+                               else if (arguments[i].apply(this, arguments[i+1]))
+                               {
+                                       return true;
+                               }
+
+                               if (validation.message)
+                                       msgs.push(validation.message.format.apply(validation.message, arguments[i+1]));
+                       }
+
+                       validation.message = msgs.join( L.tr(' - or - '));
+                       return false;
+               },
+
+               'and': function()
+               {
+                       var msgs = [ ];
+
+                       for (var i = 0; i < arguments.length; i += 2)
+                       {
+                               delete validation.message;
+
+                               if (typeof arguments[i] != 'function')
+                               {
+                                       if (arguments[i] != this)
+                                               return false;
+                                       i--;
+                               }
+                               else if (!arguments[i].apply(this, arguments[i+1]))
+                               {
+                                       return false;
+                               }
+
+                               if (validation.message)
+                                       msgs.push(validation.message.format.apply(validation.message, arguments[i+1]));
+                       }
+
+                       validation.message = msgs.join(', ');
+                       return true;
+               },
+
+               'neg': function()
+               {
+                       return validation.types['or'].apply(
+                               this.replace(/^[ \t]*![ \t]*/, ''), arguments);
+               },
+
+               'list': function(subvalidator, subargs)
+               {
+                       if (typeof subvalidator != 'function')
+                               return false;
+
+                       var tokens = this.match(/[^ \t]+/g);
+                       for (var i = 0; i < tokens.length; i++)
+                               if (!subvalidator.apply(tokens[i], subargs))
+                                       return false;
+
+                       return true;
+               },
+
+               'phonedigit': function()
+               {
+                       if (this.match(/^[0-9\*#!\.]+$/) != null)
+                               return true;
+
+                       validation.i18n('Must be a valid phone number digit');
+                       return false;
+               },
+
+               'string': function()
+               {
+                       return true;
+               }
+       };
+
+       cbi_class.AbstractValue = L.ui.AbstractWidget.extend({
+               init: function(name, options)
+               {
+                       this.name = name;
+                       this.instance = { };
+                       this.dependencies = [ ];
+                       this.rdependency = { };
+
+                       this.options = L.defaults(options, {
+                               placeholder: '',
+                               datatype: 'string',
+                               optional: false,
+                               keep: true
+                       });
+               },
+
+               id: function(sid)
+               {
+                       return this.ownerSection.id('field', sid || '__unknown__', this.name);
+               },
+
+               render: function(sid, condensed)
+               {
+                       var i = this.instance[sid] = { };
+
+                       i.top = $('<div />')
+                               .addClass('luci2-field');
+
+                       if (!condensed)
+                       {
+                               i.top.addClass('form-group');
+
+                               if (typeof(this.options.caption) == 'string')
+                                       $('<label />')
+                                               .addClass('col-lg-2 control-label')
+                                               .attr('for', this.id(sid))
+                                               .text(this.options.caption)
+                                               .appendTo(i.top);
+                       }
+
+                       i.error = $('<div />')
+                               .hide()
+                               .addClass('luci2-field-error label label-danger');
+
+                       i.widget = $('<div />')
+                               .addClass('luci2-field-widget')
+                               .append(this.widget(sid))
+                               .append(i.error)
+                               .appendTo(i.top);
+
+                       if (!condensed)
+                       {
+                               i.widget.addClass('col-lg-5');
+
+                               $('<div />')
+                                       .addClass('col-lg-5')
+                                       .text((typeof(this.options.description) == 'string') ? this.options.description : '')
+                                       .appendTo(i.top);
+                       }
+
+                       return i.top;
+               },
+
+               active: function(sid)
+               {
+                       return (this.instance[sid] && !this.instance[sid].disabled);
+               },
+
+               ucipath: function(sid)
+               {
+                       return {
+                               config:  (this.options.uci_package || this.ownerMap.uci_package),
+                               section: (this.options.uci_section || sid),
+                               option:  (this.options.uci_option  || this.name)
+                       };
+               },
+
+               ucivalue: function(sid)
+               {
+                       var uci = this.ucipath(sid);
+                       var val = this.ownerMap.get(uci.config, uci.section, uci.option);
+
+                       if (typeof(val) == 'undefined')
+                               return this.options.initial;
+
+                       return val;
+               },
+
+               formvalue: function(sid)
+               {
+                       var v = $('#' + this.id(sid)).val();
+                       return (v === '') ? undefined : v;
+               },
+
+               textvalue: function(sid)
+               {
+                       var v = this.formvalue(sid);
+
+                       if (typeof(v) == 'undefined' || ($.isArray(v) && !v.length))
+                               v = this.ucivalue(sid);
+
+                       if (typeof(v) == 'undefined' || ($.isArray(v) && !v.length))
+                               v = this.options.placeholder;
+
+                       if (typeof(v) == 'undefined' || v === '')
+                               return undefined;
+
+                       if (typeof(v) == 'string' && $.isArray(this.choices))
+                       {
+                               for (var i = 0; i < this.choices.length; i++)
+                                       if (v === this.choices[i][0])
+                                               return this.choices[i][1];
+                       }
+                       else if (v === true)
+                               return L.tr('yes');
+                       else if (v === false)
+                               return L.tr('no');
+                       else if ($.isArray(v))
+                               return v.join(', ');
+
+                       return v;
+               },
+
+               changed: function(sid)
+               {
+                       var a = this.ucivalue(sid);
+                       var b = this.formvalue(sid);
+
+                       if (typeof(a) != typeof(b))
+                               return true;
+
+                       if ($.isArray(a))
+                       {
+                               if (a.length != b.length)
+                                       return true;
+
+                               for (var i = 0; i < a.length; i++)
+                                       if (a[i] != b[i])
+                                               return true;
+
+                               return false;
+                       }
+                       else if ($.isPlainObject(a))
+                       {
+                               for (var k in a)
+                                       if (!(k in b))
+                                               return true;
+
+                               for (var k in b)
+                                       if (!(k in a) || a[k] !== b[k])
+                                               return true;
+
+                               return false;
+                       }
+
+                       return (a != b);
+               },
+
+               save: function(sid)
+               {
+                       var uci = this.ucipath(sid);
+
+                       if (this.instance[sid].disabled)
+                       {
+                               if (!this.options.keep)
+                                       return this.ownerMap.set(uci.config, uci.section, uci.option, undefined);
+
+                               return false;
+                       }
+
+                       var chg = this.changed(sid);
+                       var val = this.formvalue(sid);
+
+                       if (chg)
+                               this.ownerMap.set(uci.config, uci.section, uci.option, val);
+
+                       return chg;
+               },
+
+               findSectionID: function($elem)
+               {
+                       return this.ownerSection.findParentSectionIDs($elem)[0];
+               },
+
+               setError: function($elem, msg, msgargs)
+               {
+                       var $field = $elem.parents('.luci2-field:first');
+                       var $error = $field.find('.luci2-field-error:first');
+
+                       if (typeof(msg) == 'string' && msg.length > 0)
+                       {
+                               $field.addClass('luci2-form-error');
+                               $elem.parent().addClass('has-error');
+
+                               $error.text(msg.format.apply(msg, msgargs)).show();
+                               $field.trigger('validate');
+
+                               return false;
+                       }
+                       else
+                       {
+                               $elem.parent().removeClass('has-error');
+
+                               var $other_errors = $field.find('.has-error');
+                               if ($other_errors.length == 0)
+                               {
+                                       $field.removeClass('luci2-form-error');
+                                       $error.text('').hide();
+                                       $field.trigger('validate');
+
+                                       return true;
+                               }
+
+                               return false;
+                       }
+               },
+
+               handleValidate: function(ev)
+               {
+                       var $elem = $(this);
+
+                       var d = ev.data;
+                       var rv = true;
+                       var val = $elem.val();
+                       var vstack = d.vstack;
+
+                       if (vstack && typeof(vstack[0]) == 'function')
+                       {
+                               delete validation.message;
+
+                               if ((val.length == 0 && !d.opt))
+                               {
+                                       rv = d.self.setError($elem, L.tr('Field must not be empty'));
+                               }
+                               else if (val.length > 0 && !vstack[0].apply(val, vstack[1]))
+                               {
+                                       rv = d.self.setError($elem, validation.message, vstack[1]);
+                               }
+                               else
+                               {
+                                       rv = d.self.setError($elem);
+                               }
+                       }
+
+                       if (rv)
+                       {
+                               var sid = d.self.findSectionID($elem);
+
+                               for (var field in d.self.rdependency)
+                               {
+                                       d.self.rdependency[field].toggle(sid);
+                                       d.self.rdependency[field].validate(sid);
+                               }
+
+                               d.self.ownerSection.tabtoggle(sid);
+                       }
+
+                       return rv;
+               },
+
+               attachEvents: function(sid, elem)
+               {
+                       var evdata = {
+                               self:   this,
+                               opt:    this.options.optional
+                       };
+
+                       if (this.events)
+                               for (var evname in this.events)
+                                       elem.on(evname, evdata, this.events[evname]);
+
+                       if (typeof(this.options.datatype) == 'undefined' && $.isEmptyObject(this.rdependency))
+                               return elem;
+
+                       var vstack;
+                       if (typeof(this.options.datatype) == 'string')
+                       {
+                               try {
+                                       evdata.vstack = L.cbi.validation.compile(this.options.datatype);
+                               } catch(e) { };
+                       }
+                       else if (typeof(this.options.datatype) == 'function')
+                       {
+                               var vfunc = this.options.datatype;
+                               evdata.vstack = [ function(elem) {
+                                       var rv = vfunc(this, elem);
+                                       if (rv !== true)
+                                               validation.message = rv;
+                                       return (rv === true);
+                               }, [ elem ] ];
+                       }
+
+                       if (elem.prop('tagName') == 'SELECT')
+                       {
+                               elem.change(evdata, this.handleValidate);
+                       }
+                       else if (elem.prop('tagName') == 'INPUT' && elem.attr('type') == 'checkbox')
+                       {
+                               elem.click(evdata, this.handleValidate);
+                               elem.blur(evdata, this.handleValidate);
+                       }
+                       else
+                       {
+                               elem.keyup(evdata, this.handleValidate);
+                               elem.blur(evdata, this.handleValidate);
+                       }
+
+                       elem.addClass('luci2-field-validate')
+                               .on('validate', evdata, this.handleValidate);
+
+                       return elem;
+               },
+
+               validate: function(sid)
+               {
+                       var i = this.instance[sid];
+
+                       i.widget.find('.luci2-field-validate').trigger('validate');
+
+                       return (i.disabled || i.error.text() == '');
+               },
+
+               depends: function(d, v, add)
+               {
+                       var dep;
+
+                       if ($.isArray(d))
+                       {
+                               dep = { };
+                               for (var i = 0; i < d.length; i++)
+                               {
+                                       if (typeof(d[i]) == 'string')
+                                               dep[d[i]] = true;
+                                       else if (d[i] instanceof L.cbi.AbstractValue)
+                                               dep[d[i].name] = true;
+                               }
+                       }
+                       else if (d instanceof L.cbi.AbstractValue)
+                       {
+                               dep = { };
+                               dep[d.name] = (typeof(v) == 'undefined') ? true : v;
+                       }
+                       else if (typeof(d) == 'object')
+                       {
+                               dep = d;
+                       }
+                       else if (typeof(d) == 'string')
+                       {
+                               dep = { };
+                               dep[d] = (typeof(v) == 'undefined') ? true : v;
+                       }
+
+                       if (!dep || $.isEmptyObject(dep))
+                               return this;
+
+                       for (var field in dep)
+                       {
+                               var f = this.ownerSection.fields[field];
+                               if (f)
+                                       f.rdependency[this.name] = this;
+                               else
+                                       delete dep[field];
+                       }
+
+                       if ($.isEmptyObject(dep))
+                               return this;
+
+                       if (!add || !this.dependencies.length)
+                               this.dependencies.push(dep);
+                       else
+                               for (var i = 0; i < this.dependencies.length; i++)
+                                       $.extend(this.dependencies[i], dep);
+
+                       return this;
+               },
+
+               toggle: function(sid)
+               {
+                       var d = this.dependencies;
+                       var i = this.instance[sid];
+
+                       if (!d.length)
+                               return true;
+
+                       for (var n = 0; n < d.length; n++)
+                       {
+                               var rv = true;
+
+                               for (var field in d[n])
+                               {
+                                       var val = this.ownerSection.fields[field].formvalue(sid);
+                                       var cmp = d[n][field];
+
+                                       if (typeof(cmp) == 'boolean')
+                                       {
+                                               if (cmp == (typeof(val) == 'undefined' || val === '' || val === false))
+                                               {
+                                                       rv = false;
+                                                       break;
+                                               }
+                                       }
+                                       else if (typeof(cmp) == 'string' || typeof(cmp) == 'number')
+                                       {
+                                               if (val != cmp)
+                                               {
+                                                       rv = false;
+                                                       break;
+                                               }
+                                       }
+                                       else if (typeof(cmp) == 'function')
+                                       {
+                                               if (!cmp(val))
+                                               {
+                                                       rv = false;
+                                                       break;
+                                               }
+                                       }
+                                       else if (cmp instanceof RegExp)
+                                       {
+                                               if (!cmp.test(val))
+                                               {
+                                                       rv = false;
+                                                       break;
+                                               }
+                                       }
+                               }
+
+                               if (rv)
+                               {
+                                       if (i.disabled)
+                                       {
+                                               i.disabled = false;
+                                               i.top.removeClass('luci2-field-disabled');
+                                               i.top.fadeIn();
+                                       }
+
+                                       return true;
+                               }
+                       }
+
+                       if (!i.disabled)
+                       {
+                               i.disabled = true;
+                               i.top.is(':visible') ? i.top.fadeOut() : i.top.hide();
+                               i.top.addClass('luci2-field-disabled');
+                       }
+
+                       return false;
+               }
+       });
+
+       cbi_class.CheckboxValue = cbi_class.AbstractValue.extend({
+               widget: function(sid)
+               {
+                       var o = this.options;
+
+                       if (typeof(o.enabled)  == 'undefined') o.enabled  = '1';
+                       if (typeof(o.disabled) == 'undefined') o.disabled = '0';
+
+                       var i = $('<input />')
+                               .attr('id', this.id(sid))
+                               .attr('type', 'checkbox')
+                               .prop('checked', this.ucivalue(sid));
+
+                       return $('<div />')
+                               .addClass('checkbox')
+                               .append(this.attachEvents(sid, i));
+               },
+
+               ucivalue: function(sid)
+               {
+                       var v = this.callSuper('ucivalue', sid);
+
+                       if (typeof(v) == 'boolean')
+                               return v;
+
+                       return (v == this.options.enabled);
+               },
+
+               formvalue: function(sid)
+               {
+                       var v = $('#' + this.id(sid)).prop('checked');
+
+                       if (typeof(v) == 'undefined')
+                               return !!this.options.initial;
+
+                       return v;
+               },
+
+               save: function(sid)
+               {
+                       var uci = this.ucipath(sid);
+
+                       if (this.instance[sid].disabled)
+                       {
+                               if (!this.options.keep)
+                                       return this.ownerMap.set(uci.config, uci.section, uci.option, undefined);
+
+                               return false;
+                       }
+
+                       var chg = this.changed(sid);
+                       var val = this.formvalue(sid);
+
+                       if (chg)
+                       {
+                               if (this.options.optional && val == this.options.initial)
+                                       this.ownerMap.set(uci.config, uci.section, uci.option, undefined);
+                               else
+                                       this.ownerMap.set(uci.config, uci.section, uci.option, val ? this.options.enabled : this.options.disabled);
+                       }
+
+                       return chg;
+               }
+       });
+
+       cbi_class.InputValue = cbi_class.AbstractValue.extend({
+               widget: function(sid)
+               {
+                       var i = $('<input />')
+                               .addClass('form-control')
+                               .attr('id', this.id(sid))
+                               .attr('type', 'text')
+                               .attr('placeholder', this.options.placeholder)
+                               .val(this.ucivalue(sid));
+
+                       return this.attachEvents(sid, i);
+               }
+       });
+
+       cbi_class.PasswordValue = cbi_class.AbstractValue.extend({
+               widget: function(sid)
+               {
+                       var i = $('<input />')
+                               .addClass('form-control')
+                               .attr('id', this.id(sid))
+                               .attr('type', 'password')
+                               .attr('placeholder', this.options.placeholder)
+                               .val(this.ucivalue(sid));
+
+                       var t = $('<span />')
+                               .addClass('input-group-btn')
+                               .append(L.ui.button(L.tr('Reveal'), 'default')
+                                       .click(function(ev) {
+                                               var b = $(this);
+                                               var i = b.parent().prev();
+                                               var t = i.attr('type');
+                                               b.text(t == 'password' ? L.tr('Hide') : L.tr('Reveal'));
+                                               i.attr('type', (t == 'password') ? 'text' : 'password');
+                                               b = i = t = null;
+                                       }));
+
+                       this.attachEvents(sid, i);
+
+                       return $('<div />')
+                               .addClass('input-group')
+                               .append(i)
+                               .append(t);
+               }
+       });
+
+       cbi_class.ListValue = cbi_class.AbstractValue.extend({
+               widget: function(sid)
+               {
+                       var s = $('<select />')
+                               .addClass('form-control');
+
+                       if (this.options.optional && !this.has_empty)
+                               $('<option />')
+                                       .attr('value', '')
+                                       .text(L.tr('-- Please choose --'))
+                                       .appendTo(s);
+
+                       if (this.choices)
+                               for (var i = 0; i < this.choices.length; i++)
+                                       $('<option />')
+                                               .attr('value', this.choices[i][0])
+                                               .text(this.choices[i][1])
+                                               .appendTo(s);
+
+                       s.attr('id', this.id(sid)).val(this.ucivalue(sid));
+
+                       return this.attachEvents(sid, s);
+               },
+
+               value: function(k, v)
+               {
+                       if (!this.choices)
+                               this.choices = [ ];
+
+                       if (k == '')
+                               this.has_empty = true;
+
+                       this.choices.push([k, v || k]);
+                       return this;
+               }
+       });
+
+       cbi_class.MultiValue = cbi_class.ListValue.extend({
+               widget: function(sid)
+               {
+                       var v = this.ucivalue(sid);
+                       var t = $('<div />').attr('id', this.id(sid));
+
+                       if (!$.isArray(v))
+                               v = (typeof(v) != 'undefined') ? v.toString().split(/\s+/) : [ ];
+
+                       var s = { };
+                       for (var i = 0; i < v.length; i++)
+                               s[v[i]] = true;
+
+                       if (this.choices)
+                               for (var i = 0; i < this.choices.length; i++)
+                               {
+                                       $('<label />')
+                                               .addClass('checkbox')
+                                               .append($('<input />')
+                                                       .attr('type', 'checkbox')
+                                                       .attr('value', this.choices[i][0])
+                                                       .prop('checked', s[this.choices[i][0]]))
+                                               .append(this.choices[i][1])
+                                               .appendTo(t);
+                               }
+
+                       return t;
+               },
+
+               formvalue: function(sid)
+               {
+                       var rv = [ ];
+                       var fields = $('#' + this.id(sid) + ' > label > input');
+
+                       for (var i = 0; i < fields.length; i++)
+                               if (fields[i].checked)
+                                       rv.push(fields[i].getAttribute('value'));
+
+                       return rv;
+               },
+
+               textvalue: function(sid)
+               {
+                       var v = this.formvalue(sid);
+                       var c = { };
+
+                       if (this.choices)
+                               for (var i = 0; i < this.choices.length; i++)
+                                       c[this.choices[i][0]] = this.choices[i][1];
+
+                       var t = [ ];
+
+                       for (var i = 0; i < v.length; i++)
+                               t.push(c[v[i]] || v[i]);
+
+                       return t.join(', ');
+               }
+       });
+
+       cbi_class.ComboBox = cbi_class.AbstractValue.extend({
+               _change: function(ev)
+               {
+                       var s = ev.target;
+                       var self = ev.data.self;
+
+                       if (s.selectedIndex == (s.options.length - 1))
+                       {
+                               ev.data.select.hide();
+                               ev.data.input.show().focus();
+                               ev.data.input.val('');
+                       }
+                       else if (self.options.optional && s.selectedIndex == 0)
+                       {
+                               ev.data.input.val('');
+                       }
+                       else
+                       {
+                               ev.data.input.val(ev.data.select.val());
+                       }
+
+                       ev.stopPropagation();
+               },
+
+               _blur: function(ev)
+               {
+                       var seen = false;
+                       var val = this.value;
+                       var self = ev.data.self;
+
+                       ev.data.select.empty();
+
+                       if (self.options.optional && !self.has_empty)
+                               $('<option />')
+                                       .attr('value', '')
+                                       .text(L.tr('-- please choose --'))
+                                       .appendTo(ev.data.select);
+
+                       if (self.choices)
+                               for (var i = 0; i < self.choices.length; i++)
+                               {
+                                       if (self.choices[i][0] == val)
+                                               seen = true;
+
+                                       $('<option />')
+                                               .attr('value', self.choices[i][0])
+                                               .text(self.choices[i][1])
+                                               .appendTo(ev.data.select);
+                               }
+
+                       if (!seen && val != '')
+                               $('<option />')
+                                       .attr('value', val)
+                                       .text(val)
+                                       .appendTo(ev.data.select);
+
+                       $('<option />')
+                               .attr('value', ' ')
+                               .text(L.tr('-- custom --'))
+                               .appendTo(ev.data.select);
+
+                       ev.data.input.hide();
+                       ev.data.select.val(val).show().blur();
+               },
+
+               _enter: function(ev)
+               {
+                       if (ev.which != 13)
+                               return true;
+
+                       ev.preventDefault();
+                       ev.data.self._blur(ev);
+                       return false;
+               },
+
+               widget: function(sid)
+               {
+                       var d = $('<div />')
+                               .attr('id', this.id(sid));
+
+                       var t = $('<input />')
+                               .addClass('form-control')
+                               .attr('type', 'text')
+                               .hide()
+                               .appendTo(d);
+
+                       var s = $('<select />')
+                               .addClass('form-control')
+                               .appendTo(d);
+
+                       var evdata = {
+                               self: this,
+                               input: t,
+                               select: s
+                       };
+
+                       s.change(evdata, this._change);
+                       t.blur(evdata, this._blur);
+                       t.keydown(evdata, this._enter);
+
+                       t.val(this.ucivalue(sid));
+                       t.blur();
+
+                       this.attachEvents(sid, t);
+                       this.attachEvents(sid, s);
+
+                       return d;
+               },
+
+               value: function(k, v)
+               {
+                       if (!this.choices)
+                               this.choices = [ ];
+
+                       if (k == '')
+                               this.has_empty = true;
+
+                       this.choices.push([k, v || k]);
+                       return this;
+               },
+
+               formvalue: function(sid)
+               {
+                       var v = $('#' + this.id(sid)).children('input').val();
+                       return (v == '') ? undefined : v;
+               }
+       });
+
+       cbi_class.DynamicList = cbi_class.ComboBox.extend({
+               _redraw: function(focus, add, del, s)
+               {
+                       var v = s.values || [ ];
+                       delete s.values;
+
+                       $(s.parent).children('div.input-group').children('input').each(function(i) {
+                               if (i != del)
+                                       v.push(this.value || '');
+                       });
+
+                       $(s.parent).empty();
+
+                       if (add >= 0)
+                       {
+                               focus = add + 1;
+                               v.splice(focus, 0, '');
+                       }
+                       else if (v.length == 0)
+                       {
+                               focus = 0;
+                               v.push('');
+                       }
+
+                       for (var i = 0; i < v.length; i++)
+                       {
+                               var evdata = {
+                                       sid: s.sid,
+                                       self: s.self,
+                                       parent: s.parent,
+                                       index: i,
+                                       remove: ((i+1) < v.length)
+                               };
+
+                               var btn;
+                               if (evdata.remove)
+                                       btn = L.ui.button('–', 'danger').click(evdata, this._btnclick);
+                               else
+                                       btn = L.ui.button('+', 'success').click(evdata, this._btnclick);
+
+                               if (this.choices)
+                               {
+                                       var txt = $('<input />')
+                                               .addClass('form-control')
+                                               .attr('type', 'text')
+                                               .hide();
+
+                                       var sel = $('<select />')
+                                               .addClass('form-control');
+
+                                       $('<div />')
+                                               .addClass('input-group')
+                                               .append(txt)
+                                               .append(sel)
+                                               .append($('<span />')
+                                                       .addClass('input-group-btn')
+                                                       .append(btn))
+                                               .appendTo(s.parent);
+
+                                       evdata.input = this.attachEvents(s.sid, txt);
+                                       evdata.select = this.attachEvents(s.sid, sel);
+
+                                       sel.change(evdata, this._change);
+                                       txt.blur(evdata, this._blur);
+                                       txt.keydown(evdata, this._keydown);
+
+                                       txt.val(v[i]);
+                                       txt.blur();
+
+                                       if (i == focus || -(i+1) == focus)
+                                               sel.focus();
+
+                                       sel = txt = null;
+                               }
+                               else
+                               {
+                                       var f = $('<input />')
+                                               .attr('type', 'text')
+                                               .attr('index', i)
+                                               .attr('placeholder', (i == 0) ? this.options.placeholder : '')
+                                               .addClass('form-control')
+                                               .keydown(evdata, this._keydown)
+                                               .keypress(evdata, this._keypress)
+                                               .val(v[i]);
+
+                                       $('<div />')
+                                               .addClass('input-group')
+                                               .append(f)
+                                               .append($('<span />')
+                                                       .addClass('input-group-btn')
+                                                       .append(btn))
+                                               .appendTo(s.parent);
+
+                                       if (i == focus)
+                                       {
+                                               f.focus();
+                                       }
+                                       else if (-(i+1) == focus)
+                                       {
+                                               f.focus();
+
+                                               /* force cursor to end */
+                                               var val = f.val();
+                                               f.val(' ');
+                                               f.val(val);
+                                       }
+
+                                       evdata.input = this.attachEvents(s.sid, f);
+
+                                       f = null;
+                               }
+
+                               evdata = null;
+                       }
+
+                       s = null;
+               },
+
+               _keypress: function(ev)
+               {
+                       switch (ev.which)
+                       {
+                               /* backspace, delete */
+                               case 8:
+                               case 46:
+                                       if (ev.data.input.val() == '')
+                                       {
+                                               ev.preventDefault();
+                                               return false;
+                                       }
+
+                                       return true;
+
+                               /* enter, arrow up, arrow down */
+                               case 13:
+                               case 38:
+                               case 40:
+                                       ev.preventDefault();
+                                       return false;
+                       }
+
+                       return true;
+               },
+
+               _keydown: function(ev)
+               {
+                       var input = ev.data.input;
+
+                       switch (ev.which)
+                       {
+                               /* backspace, delete */
+                               case 8:
+                               case 46:
+                                       if (input.val().length == 0)
+                                       {
+                                               ev.preventDefault();
+
+                                               var index = ev.data.index;
+                                               var focus = index;
+
+                                               if (ev.which == 8)
+                                                       focus = -focus;
+
+                                               ev.data.self._redraw(focus, -1, index, ev.data);
+                                               return false;
+                                       }
+
+                                       break;
+
+                               /* enter */
+                               case 13:
+                                       ev.data.self._redraw(NaN, ev.data.index, -1, ev.data);
+                                       break;
+
+                               /* arrow up */
+                               case 38:
+                                       var prev = input.parent().prevAll('div.input-group:first').children('input');
+                                       if (prev.is(':visible'))
+                                               prev.focus();
+                                       else
+                                               prev.next('select').focus();
+                                       break;
+
+                               /* arrow down */
+                               case 40:
+                                       var next = input.parent().nextAll('div.input-group:first').children('input');
+                                       if (next.is(':visible'))
+                                               next.focus();
+                                       else
+                                               next.next('select').focus();
+                                       break;
+                       }
+
+                       return true;
+               },
+
+               _btnclick: function(ev)
+               {
+                       if (!this.getAttribute('disabled'))
+                       {
+                               if (ev.data.remove)
+                               {
+                                       var index = ev.data.index;
+                                       ev.data.self._redraw(-index, -1, index, ev.data);
+                               }
+                               else
+                               {
+                                       ev.data.self._redraw(NaN, ev.data.index, -1, ev.data);
+                               }
+                       }
+
+                       return false;
+               },
+
+               widget: function(sid)
+               {
+                       this.options.optional = true;
+
+                       var v = this.ucivalue(sid);
+
+                       if (!$.isArray(v))
+                               v = (typeof(v) != 'undefined') ? v.toString().split(/\s+/) : [ ];
+
+                       var d = $('<div />')
+                               .attr('id', this.id(sid))
+                               .addClass('cbi-input-dynlist');
+
+                       this._redraw(NaN, -1, -1, {
+                               self:      this,
+                               parent:    d[0],
+                               values:    v,
+                               sid:       sid
+                       });
+
+                       return d;
+               },
+
+               ucivalue: function(sid)
+               {
+                       var v = this.callSuper('ucivalue', sid);
+
+                       if (!$.isArray(v))
+                               v = (typeof(v) != 'undefined') ? v.toString().split(/\s+/) : [ ];
+
+                       return v;
+               },
+
+               formvalue: function(sid)
+               {
+                       var rv = [ ];
+                       var fields = $('#' + this.id(sid) + ' input');
+
+                       for (var i = 0; i < fields.length; i++)
+                               if (typeof(fields[i].value) == 'string' && fields[i].value.length)
+                                       rv.push(fields[i].value);
+
+                       return rv;
+               }
+       });
+
+       cbi_class.DummyValue = cbi_class.AbstractValue.extend({
+               widget: function(sid)
+               {
+                       return $('<div />')
+                               .addClass('form-control-static')
+                               .attr('id', this.id(sid))
+                               .html(this.ucivalue(sid) || this.label('placeholder'));
+               },
+
+               formvalue: function(sid)
+               {
+                       return this.ucivalue(sid);
+               }
+       });
+
+       cbi_class.ButtonValue = cbi_class.AbstractValue.extend({
+               widget: function(sid)
+               {
+                       this.options.optional = true;
+
+                       var btn = $('<button />')
+                               .addClass('btn btn-default')
+                               .attr('id', this.id(sid))
+                               .attr('type', 'button')
+                               .text(this.label('text'));
+
+                       return this.attachEvents(sid, btn);
+               }
+       });
+
+       cbi_class.NetworkList = cbi_class.AbstractValue.extend({
+               load: function(sid)
+               {
+                       return L.network.load();
+               },
+
+               _device_icon: function(dev)
+               {
+                       return $('<img />')
+                               .attr('src', dev.icon())
+                               .attr('title', '%s (%s)'.format(dev.description(), dev.name() || '?'));
+               },
+
+               widget: function(sid)
+               {
+                       var id = this.id(sid);
+                       var ul = $('<ul />')
+                               .attr('id', id)
+                               .addClass('list-unstyled');
+
+                       var itype = this.options.multiple ? 'checkbox' : 'radio';
+                       var value = this.ucivalue(sid);
+                       var check = { };
+
+                       if (!this.options.multiple)
+                               check[value] = true;
+                       else
+                               for (var i = 0; i < value.length; i++)
+                                       check[value[i]] = true;
+
+                       var interfaces = L.network.getInterfaces();
+
+                       for (var i = 0; i < interfaces.length; i++)
+                       {
+                               var iface = interfaces[i];
+
+                               $('<li />')
+                                       .append($('<label />')
+                                               .addClass(itype + ' inline')
+                                               .append(this.attachEvents(sid, $('<input />')
+                                                       .attr('name', itype + id)
+                                                       .attr('type', itype)
+                                                       .attr('value', iface.name())
+                                                       .prop('checked', !!check[iface.name()])))
+                                               .append(iface.renderBadge()))
+                                       .appendTo(ul);
+                       }
+
+                       if (!this.options.multiple)
+                       {
+                               $('<li />')
+                                       .append($('<label />')
+                                               .addClass(itype + ' inline text-muted')
+                                               .append(this.attachEvents(sid, $('<input />')
+                                                       .attr('name', itype + id)
+                                                       .attr('type', itype)
+                                                       .attr('value', '')
+                                                       .prop('checked', $.isEmptyObject(check))))
+                                               .append(L.tr('unspecified')))
+                                       .appendTo(ul);
+                       }
+
+                       return ul;
+               },
+
+               ucivalue: function(sid)
+               {
+                       var v = this.callSuper('ucivalue', sid);
+
+                       if (!this.options.multiple)
+                       {
+                               if ($.isArray(v))
+                               {
+                                       return v[0];
+                               }
+                               else if (typeof(v) == 'string')
+                               {
+                                       v = v.match(/\S+/);
+                                       return v ? v[0] : undefined;
+                               }
+
+                               return v;
+                       }
+                       else
+                       {
+                               if (typeof(v) == 'string')
+                                       v = v.match(/\S+/g);
+
+                               return v || [ ];
+                       }
+               },
+
+               formvalue: function(sid)
+               {
+                       var inputs = $('#' + this.id(sid) + ' input');
+
+                       if (!this.options.multiple)
+                       {
+                               for (var i = 0; i < inputs.length; i++)
+                                       if (inputs[i].checked && inputs[i].value !== '')
+                                               return inputs[i].value;
+
+                               return undefined;
+                       }
+
+                       var rv = [ ];
+
+                       for (var i = 0; i < inputs.length; i++)
+                               if (inputs[i].checked)
+                                       rv.push(inputs[i].value);
+
+                       return rv.length ? rv : undefined;
+               }
+       });
+
+       cbi_class.DeviceList = cbi_class.NetworkList.extend({
+               handleFocus: function(ev)
+               {
+                       var self = ev.data.self;
+                       var input = $(this);
+
+                       input.parent().prev().prop('checked', true);
+               },
+
+               handleBlur: function(ev)
+               {
+                       ev.which = 10;
+                       ev.data.self.handleKeydown.call(this, ev);
+               },
+
+               handleKeydown: function(ev)
+               {
+                       if (ev.which != 10 && ev.which != 13)
+                               return;
+
+                       var sid = ev.data.sid;
+                       var self = ev.data.self;
+                       var input = $(this);
+                       var ifnames = L.toArray(input.val());
+
+                       if (!ifnames.length)
+                               return;
+
+                       L.network.createDevice(ifnames[0]);
+
+                       self._redraw(sid, $('#' + self.id(sid)), ifnames[0]);
+               },
+
+               load: function(sid)
+               {
+                       return L.network.load();
+               },
+
+               _redraw: function(sid, ul, sel)
+               {
+                       var id = ul.attr('id');
+                       var devs = L.network.getDevices();
+                       var iface = L.network.getInterface(sid);
+                       var itype = this.options.multiple ? 'checkbox' : 'radio';
+                       var check = { };
+
+                       if (!sel)
+                       {
+                               for (var i = 0; i < devs.length; i++)
+                                       if (devs[i].isInNetwork(iface))
+                                               check[devs[i].name()] = true;
+                       }
+                       else
+                       {
+                               if (this.options.multiple)
+                                       check = L.toObject(this.formvalue(sid));
+
+                               check[sel] = true;
+                       }
+
+                       ul.empty();
+
+                       for (var i = 0; i < devs.length; i++)
+                       {
+                               var dev = devs[i];
+
+                               if (dev.isBridge() && this.options.bridges === false)
+                                       continue;
+
+                               if (!dev.isBridgeable() && this.options.multiple)
+                                       continue;
+
+                               var badge = $('<span />')
+                                       .addClass('badge')
+                                       .append($('<img />').attr('src', dev.icon()))
+                                       .append(' %s: %s'.format(dev.name(), dev.description()));
+
+                               //var ifcs = dev.getInterfaces();
+                               //if (ifcs.length)
+                               //{
+                               //      for (var j = 0; j < ifcs.length; j++)
+                               //              badge.append((j ? ', ' : ' (') + ifcs[j].name());
+                               //
+                               //      badge.append(')');
+                               //}
+
+                               $('<li />')
+                                       .append($('<label />')
+                                               .addClass(itype + ' inline')
+                                               .append($('<input />')
+                                                       .attr('name', itype + id)
+                                                       .attr('type', itype)
+                                                       .attr('value', dev.name())
+                                                       .prop('checked', !!check[dev.name()]))
+                                               .append(badge))
+                                       .appendTo(ul);
+                       }
+
+
+                       $('<li />')
+                               .append($('<label />')
+                                       .attr('for', 'custom' + id)
+                                       .addClass(itype + ' inline')
+                                       .append($('<input />')
+                                               .attr('name', itype + id)
+                                               .attr('type', itype)
+                                               .attr('value', ''))
+                                       .append($('<span />')
+                                               .addClass('badge')
+                                               .append($('<input />')
+                                                       .attr('id', 'custom' + id)
+                                                       .attr('type', 'text')
+                                                       .attr('placeholder', L.tr('Custom device â€¦'))
+                                                       .on('focus', { self: this, sid: sid }, this.handleFocus)
+                                                       .on('blur', { self: this, sid: sid }, this.handleBlur)
+                                                       .on('keydown', { self: this, sid: sid }, this.handleKeydown))))
+                               .appendTo(ul);
+
+                       if (!this.options.multiple)
+                       {
+                               $('<li />')
+                                       .append($('<label />')
+                                               .addClass(itype + ' inline text-muted')
+                                               .append($('<input />')
+                                                       .attr('name', itype + id)
+                                                       .attr('type', itype)
+                                                       .attr('value', '')
+                                                       .prop('checked', $.isEmptyObject(check)))
+                                               .append(L.tr('unspecified')))
+                                       .appendTo(ul);
+                       }
+               },
+
+               widget: function(sid)
+               {
+                       var id = this.id(sid);
+                       var ul = $('<ul />')
+                               .attr('id', id)
+                               .addClass('list-unstyled');
+
+                       this._redraw(sid, ul);
+
+                       return ul;
+               },
+
+               save: function(sid)
+               {
+                       if (this.instance[sid].disabled)
+                               return;
+
+                       var ifnames = this.formvalue(sid);
+                       //if (!ifnames)
+                       //      return;
+
+                       var iface = L.network.getInterface(sid);
+                       if (!iface)
+                               return;
+
+                       iface.setDevices($.isArray(ifnames) ? ifnames : [ ifnames ]);
+               }
+       });
+
+
+       cbi_class.AbstractSection = L.ui.AbstractWidget.extend({
+               id: function()
+               {
+                       var s = [ arguments[0], this.ownerMap.uci_package, this.uci_type ];
+
+                       for (var i = 1; i < arguments.length && typeof(arguments[i]) == 'string'; i++)
+                               s.push(arguments[i].replace(/\./g, '_'));
+
+                       return s.join('_');
+               },
+
+               option: function(widget, name, options)
+               {
+                       if (this.tabs.length == 0)
+                               this.tab({ id: '__default__', selected: true });
+
+                       return this.taboption('__default__', widget, name, options);
+               },
+
+               tab: function(options)
+               {
+                       if (options.selected)
+                               this.tabs.selected = this.tabs.length;
+
+                       this.tabs.push({
+                               id:          options.id,
+                               caption:     options.caption,
+                               description: options.description,
+                               fields:      [ ],
+                               li:          { }
+                       });
+               },
+
+               taboption: function(tabid, widget, name, options)
+               {
+                       var tab;
+                       for (var i = 0; i < this.tabs.length; i++)
+                       {
+                               if (this.tabs[i].id == tabid)
+                               {
+                                       tab = this.tabs[i];
+                                       break;
+                               }
+                       }
+
+                       if (!tab)
+                               throw 'Cannot append to unknown tab ' + tabid;
+
+                       var w = widget ? new widget(name, options) : null;
+
+                       if (!(w instanceof L.cbi.AbstractValue))
+                               throw 'Widget must be an instance of AbstractValue';
+
+                       w.ownerSection = this;
+                       w.ownerMap     = this.ownerMap;
+
+                       this.fields[name] = w;
+                       tab.fields.push(w);
+
+                       return w;
+               },
+
+               tabtoggle: function(sid)
+               {
+                       for (var i = 0; i < this.tabs.length; i++)
+                       {
+                               var tab = this.tabs[i];
+                               var elem = $('#' + this.id('nodetab', sid, tab.id));
+                               var empty = true;
+
+                               for (var j = 0; j < tab.fields.length; j++)
+                               {
+                                       if (tab.fields[j].active(sid))
+                                       {
+                                               empty = false;
+                                               break;
+                                       }
+                               }
+
+                               if (empty && elem.is(':visible'))
+                                       elem.fadeOut();
+                               else if (!empty)
+                                       elem.fadeIn();
+                       }
+               },
+
+               validate: function(parent_sid)
+               {
+                       var s = this.getUCISections(parent_sid);
+                       var n = 0;
+
+                       for (var i = 0; i < s.length; i++)
+                       {
+                               var $item = $('#' + this.id('sectionitem', s[i]['.name']));
+
+                               $item.find('.luci2-field-validate').trigger('validate');
+                               n += $item.find('.luci2-field.luci2-form-error').not('.luci2-field-disabled').length;
+                       }
+
+                       return (n == 0);
+               },
+
+               load: function(parent_sid)
+               {
+                       var deferreds = [ ];
+
+                       var s = this.getUCISections(parent_sid);
+                       for (var i = 0; i < s.length; i++)
+                       {
+                               for (var f in this.fields)
+                               {
+                                       if (typeof(this.fields[f].load) != 'function')
+                                               continue;
+
+                                       var rv = this.fields[f].load(s[i]['.name']);
+                                       if (L.isDeferred(rv))
+                                               deferreds.push(rv);
+                               }
+
+                               for (var j = 0; j < this.subsections.length; j++)
+                               {
+                                       var rv = this.subsections[j].load(s[i]['.name']);
+                                       deferreds.push.apply(deferreds, rv);
+                               }
+                       }
+
+                       return deferreds;
+               },
+
+               save: function(parent_sid)
+               {
+                       var deferreds = [ ];
+                       var s = this.getUCISections(parent_sid);
+
+                       for (i = 0; i < s.length; i++)
+                       {
+                               if (!this.options.readonly)
+                               {
+                                       for (var f in this.fields)
+                                       {
+                                               if (typeof(this.fields[f].save) != 'function')
+                                                       continue;
+
+                                               var rv = this.fields[f].save(s[i]['.name']);
+                                               if (L.isDeferred(rv))
+                                                       deferreds.push(rv);
+                                       }
+                               }
+
+                               for (var j = 0; j < this.subsections.length; j++)
+                               {
+                                       var rv = this.subsections[j].save(s[i]['.name']);
+                                       deferreds.push.apply(deferreds, rv);
+                               }
+                       }
+
+                       return deferreds;
+               },
+
+               teaser: function(sid)
+               {
+                       var tf = this.teaser_fields;
+
+                       if (!tf)
+                       {
+                               tf = this.teaser_fields = [ ];
+
+                               if ($.isArray(this.options.teasers))
+                               {
+                                       for (var i = 0; i < this.options.teasers.length; i++)
+                                       {
+                                               var f = this.options.teasers[i];
+                                               if (f instanceof L.cbi.AbstractValue)
+                                                       tf.push(f);
+                                               else if (typeof(f) == 'string' && this.fields[f] instanceof L.cbi.AbstractValue)
+                                                       tf.push(this.fields[f]);
+                                       }
+                               }
+                               else
+                               {
+                                       for (var i = 0; tf.length <= 5 && i < this.tabs.length; i++)
+                                               for (var j = 0; tf.length <= 5 && j < this.tabs[i].fields.length; j++)
+                                                       tf.push(this.tabs[i].fields[j]);
+                               }
+                       }
+
+                       var t = '';
+
+                       for (var i = 0; i < tf.length; i++)
+                       {
+                               if (tf[i].instance[sid] && tf[i].instance[sid].disabled)
+                                       continue;
+
+                               var n = tf[i].options.caption || tf[i].name;
+                               var v = tf[i].textvalue(sid);
+
+                               if (typeof(v) == 'undefined')
+                                       continue;
+
+                               t = t + '%s%s: <strong>%s</strong>'.format(t ? ' | ' : '', n, v);
+                       }
+
+                       return t;
+               },
+
+               findAdditionalUCIPackages: function()
+               {
+                       var packages = [ ];
+
+                       for (var i = 0; i < this.tabs.length; i++)
+                               for (var j = 0; j < this.tabs[i].fields.length; j++)
+                                       if (this.tabs[i].fields[j].options.uci_package)
+                                               packages.push(this.tabs[i].fields[j].options.uci_package);
+
+                       return packages;
+               },
+
+               findParentSectionIDs: function($elem)
+               {
+                       var rv = [ ];
+                       var $parents = $elem.parents('.luci2-section-item');
+
+                       for (var i = 0; i < $parents.length; i++)
+                               rv.push($parents[i].getAttribute('data-luci2-sid'));
+
+                       return rv;
+               }
+       });
+
+       cbi_class.TypedSection = cbi_class.AbstractSection.extend({
+               init: function(uci_type, options)
+               {
+                       this.uci_type = uci_type;
+                       this.options  = options;
+                       this.tabs     = [ ];
+                       this.fields   = { };
+                       this.subsections  = [ ];
+                       this.active_panel = { };
+                       this.active_tab   = { };
+
+                       this.instance = { };
+               },
+
+               filter: function(section, parent_sid)
+               {
+                       return true;
+               },
+
+               sort: function(section1, section2)
+               {
+                       return 0;
+               },
+
+               subsection: function(widget, uci_type, options)
+               {
+                       var w = widget ? new widget(uci_type, options) : null;
+
+                       if (!(w instanceof L.cbi.AbstractSection))
+                               throw 'Widget must be an instance of AbstractSection';
+
+                       w.ownerSection = this;
+                       w.ownerMap     = this.ownerMap;
+                       w.index        = this.subsections.length;
+
+                       this.subsections.push(w);
+                       return w;
+               },
+
+               getUCISections: function(parent_sid)
+               {
+                       var s1 = L.uci.sections(this.ownerMap.uci_package);
+                       var s2 = [ ];
+
+                       for (var i = 0; i < s1.length; i++)
+                               if (s1[i]['.type'] == this.uci_type)
+                                       if (this.filter(s1[i], parent_sid))
+                                               s2.push(s1[i]);
+
+                       s2.sort(this.sort);
+
+                       return s2;
+               },
+
+               add: function(name, parent_sid)
+               {
+                       return this.ownerMap.add(this.ownerMap.uci_package, this.uci_type, name);
+               },
+
+               remove: function(sid, parent_sid)
+               {
+                       return this.ownerMap.remove(this.ownerMap.uci_package, sid);
+               },
+
+               handleAdd: function(ev)
+               {
+                       var addb = $(this);
+                       var name = undefined;
+                       var self = ev.data.self;
+                       var sid  = self.findParentSectionIDs(addb)[0];
+
+                       if (addb.prev().prop('nodeName') == 'INPUT')
+                               name = addb.prev().val();
+
+                       if (addb.prop('disabled') || name === '')
+                               return;
+
+                       L.ui.saveScrollTop();
+
+                       self.setPanelIndex(sid, -1);
+                       self.ownerMap.save();
+
+                       ev.data.sid  = self.add(name, sid);
+                       ev.data.type = self.uci_type;
+                       ev.data.name = name;
+
+                       self.trigger('add', ev);
+
+                       self.ownerMap.redraw();
+
+                       L.ui.restoreScrollTop();
+               },
+
+               handleRemove: function(ev)
+               {
+                       var self = ev.data.self;
+                       var sids = self.findParentSectionIDs($(this));
+
+                       if (sids.length)
+                       {
+                               L.ui.saveScrollTop();
+
+                               ev.sid = sids[0];
+                               ev.parent_sid = sids[1];
+
+                               self.trigger('remove', ev);
+
+                               self.ownerMap.save();
+                               self.remove(ev.sid, ev.parent_sid);
+                               self.ownerMap.redraw();
+
+                               L.ui.restoreScrollTop();
+                       }
+
+                       ev.stopPropagation();
+               },
+
+               handleSID: function(ev)
+               {
+                       var self = ev.data.self;
+                       var text = $(this);
+                       var addb = text.next();
+                       var errt = addb.next();
+                       var name = text.val();
+
+                       if (!/^[a-zA-Z0-9_]*$/.test(name))
+                       {
+                               errt.text(L.tr('Invalid section name')).show();
+                               text.addClass('error');
+                               addb.prop('disabled', true);
+                               return false;
+                       }
+
+                       if (L.uci.get(self.ownerMap.uci_package, name))
+                       {
+                               errt.text(L.tr('Name already used')).show();
+                               text.addClass('error');
+                               addb.prop('disabled', true);
+                               return false;
+                       }
+
+                       errt.text('').hide();
+                       text.removeClass('error');
+                       addb.prop('disabled', false);
+                       return true;
+               },
+
+               handleTab: function(ev)
+               {
+                       var self = ev.data.self;
+                       var $tab = $(this);
+                       var sid  = self.findParentSectionIDs($tab)[0];
+
+                       self.active_tab[sid] = $tab.parent().index();
+               },
+
+               handleTabValidate: function(ev)
+               {
+                       var $pane = $(ev.delegateTarget);
+                       var $badge = $pane.parent()
+                               .children('.nav-tabs')
+                               .children('li')
+                               .eq($pane.index() - 1) // item #1 is the <ul>
+                               .find('.badge:first');
+
+                       var err_count = $pane.find('.luci2-field.luci2-form-error').not('.luci2-field-disabled').length;
+                       if (err_count > 0)
+                               $badge
+                                       .text(err_count)
+                                       .attr('title', L.trp('1 Error', '%d Errors', err_count).format(err_count))
+                                       .show();
+                       else
+                               $badge.hide();
+               },
+
+               handlePanelValidate: function(ev)
+               {
+                       var $elem = $(this);
+                       var $badge = $elem
+                               .prevAll('.luci2-section-header:first')
+                               .children('.luci2-section-teaser')
+                               .find('.badge:first');
+
+                       var err_count = $elem.find('.luci2-field.luci2-form-error').not('.luci2-field-disabled').length;
+                       if (err_count > 0)
+                               $badge
+                                       .text(err_count)
+                                       .attr('title', L.trp('1 Error', '%d Errors', err_count).format(err_count))
+                                       .show();
+                       else
+                               $badge.hide();
+               },
+
+               handlePanelCollapse: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       var $items = $(ev.delegateTarget).children('.luci2-section-item');
+
+                       var $this_panel  = $(ev.target);
+                       var $this_teaser = $this_panel.prevAll('.luci2-section-header:first').children('.luci2-section-teaser');
+
+                       var $prev_panel  = $items.children('.luci2-section-panel.in');
+                       var $prev_teaser = $prev_panel.prevAll('.luci2-section-header:first').children('.luci2-section-teaser');
+
+                       var sids = self.findParentSectionIDs($prev_panel);
+
+                       self.setPanelIndex(sids[1], $this_panel.parent().index());
+
+                       $prev_panel
+                               .removeClass('in')
+                               .addClass('collapse');
+
+                       $prev_teaser
+                               .show()
+                               .children('span:last')
+                               .empty()
+                               .append(self.teaser(sids[0]));
+
+                       $this_teaser
+                               .hide();
+
+                       ev.stopPropagation();
+               },
+
+               handleSort: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       var $item = $(this).parents('.luci2-section-item:first');
+                       var $next = ev.data.up ? $item.prev() : $item.next();
+
+                       if ($item.length && $next.length)
+                       {
+                               var cur_sid = $item.attr('data-luci2-sid');
+                               var new_sid = $next.attr('data-luci2-sid');
+
+                               L.uci.swap(self.ownerMap.uci_package, cur_sid, new_sid);
+
+                               self.ownerMap.save();
+                               self.ownerMap.redraw();
+                       }
+
+                       ev.stopPropagation();
+               },
+
+               getPanelIndex: function(parent_sid)
+               {
+                       return (this.active_panel[parent_sid || '__top__'] || 0);
+               },
+
+               setPanelIndex: function(parent_sid, new_index)
+               {
+                       if (typeof(new_index) == 'number')
+                               this.active_panel[parent_sid || '__top__'] = new_index;
+               },
+
+               renderAdd: function()
+               {
+                       if (!this.options.addremove)
+                               return null;
+
+                       var text = L.tr('Add section');
+                       var ttip = L.tr('Create new section...');
+
+                       if ($.isArray(this.options.add_caption))
+                               text = this.options.add_caption[0], ttip = this.options.add_caption[1];
+                       else if (typeof(this.options.add_caption) == 'string')
+                               text = this.options.add_caption, ttip = '';
+
+                       var add = $('<div />');
+
+                       if (this.options.anonymous === false)
+                       {
+                               $('<input />')
+                                       .addClass('cbi-input-text')
+                                       .attr('type', 'text')
+                                       .attr('placeholder', ttip)
+                                       .blur({ self: this }, this.handleSID)
+                                       .keyup({ self: this }, this.handleSID)
+                                       .appendTo(add);
+
+                               $('<img />')
+                                       .attr('src', L.globals.resource + '/icons/cbi/add.gif')
+                                       .attr('title', text)
+                                       .addClass('cbi-button')
+                                       .click({ self: this }, this.handleAdd)
+                                       .appendTo(add);
+
+                               $('<div />')
+                                       .addClass('cbi-value-error')
+                                       .hide()
+                                       .appendTo(add);
+                       }
+                       else
+                       {
+                               L.ui.button(text, 'success', ttip)
+                                       .click({ self: this }, this.handleAdd)
+                                       .appendTo(add);
+                       }
+
+                       return add;
+               },
+
+               renderRemove: function(index)
+               {
+                       if (!this.options.addremove)
+                               return null;
+
+                       var text = L.tr('Remove');
+                       var ttip = L.tr('Remove this section');
+
+                       if ($.isArray(this.options.remove_caption))
+                               text = this.options.remove_caption[0], ttip = this.options.remove_caption[1];
+                       else if (typeof(this.options.remove_caption) == 'string')
+                               text = this.options.remove_caption, ttip = '';
+
+                       return L.ui.button(text, 'danger', ttip)
+                               .click({ self: this, index: index }, this.handleRemove);
+               },
+
+               renderSort: function(index)
+               {
+                       if (!this.options.sortable)
+                               return null;
+
+                       var b1 = L.ui.button('↑', 'info', L.tr('Move up'))
+                               .click({ self: this, index: index, up: true }, this.handleSort);
+
+                       var b2 = L.ui.button('↓', 'info', L.tr('Move down'))
+                               .click({ self: this, index: index, up: false }, this.handleSort);
+
+                       return b1.add(b2);
+               },
+
+               renderCaption: function()
+               {
+                       return $('<h3 />')
+                               .addClass('panel-title')
+                               .append(this.label('caption') || this.uci_type);
+               },
+
+               renderDescription: function()
+               {
+                       var text = this.label('description');
+
+                       if (text)
+                               return $('<div />')
+                                       .addClass('luci2-section-description')
+                                       .text(text);
+
+                       return null;
+               },
+
+               renderTeaser: function(sid, index)
+               {
+                       if (this.options.collabsible || this.ownerMap.options.collabsible)
+                       {
+                               return $('<div />')
+                                       .attr('id', this.id('teaser', sid))
+                                       .addClass('luci2-section-teaser well well-sm')
+                                       .append($('<span />')
+                                               .addClass('badge'))
+                                       .append($('<span />'));
+                       }
+
+                       return null;
+               },
+
+               renderHead: function(condensed)
+               {
+                       if (condensed)
+                               return null;
+
+                       return $('<div />')
+                               .addClass('panel-heading')
+                               .append(this.renderCaption())
+                               .append(this.renderDescription());
+               },
+
+               renderTabDescription: function(sid, index, tab_index)
+               {
+                       var tab = this.tabs[tab_index];
+
+                       if (typeof(tab.description) == 'string')
+                       {
+                               return $('<div />')
+                                       .addClass('cbi-tab-descr')
+                                       .text(tab.description);
+                       }
+
+                       return null;
+               },
+
+               renderTabHead: function(sid, index, tab_index)
+               {
+                       var tab = this.tabs[tab_index];
+                       var cur = this.active_tab[sid] || 0;
+
+                       var tabh = $('<li />')
+                               .append($('<a />')
+                                       .attr('id', this.id('nodetab', sid, tab.id))
+                                       .attr('href', '#' + this.id('node', sid, tab.id))
+                                       .attr('data-toggle', 'tab')
+                                       .text((tab.caption ? tab.caption.format(tab.id) : tab.id) + ' ')
+                                       .append($('<span />')
+                                               .addClass('badge'))
+                                       .on('shown.bs.tab', { self: this, sid: sid }, this.handleTab));
+
+                       if (cur == tab_index)
+                               tabh.addClass('active');
+
+                       if (!tab.fields.length)
+                               tabh.hide();
+
+                       return tabh;
+               },
+
+               renderTabBody: function(sid, index, tab_index)
+               {
+                       var tab = this.tabs[tab_index];
+                       var cur = this.active_tab[sid] || 0;
+
+                       var tabb = $('<div />')
+                               .addClass('tab-pane')
+                               .attr('id', this.id('node', sid, tab.id))
+                               .append(this.renderTabDescription(sid, index, tab_index))
+                               .on('validate', this.handleTabValidate);
+
+                       if (cur == tab_index)
+                               tabb.addClass('active');
+
+                       for (var i = 0; i < tab.fields.length; i++)
+                               tabb.append(tab.fields[i].render(sid));
+
+                       return tabb;
+               },
+
+               renderPanelHead: function(sid, index, parent_sid)
+               {
+                       var head = $('<div />')
+                               .addClass('luci2-section-header')
+                               .append(this.renderTeaser(sid, index))
+                               .append($('<div />')
+                                       .addClass('btn-group')
+                                       .append(this.renderSort(index))
+                                       .append(this.renderRemove(index)));
+
+                       if (this.options.collabsible)
+                       {
+                               head.attr('data-toggle', 'collapse')
+                                       .attr('data-parent', this.id('sectiongroup', parent_sid))
+                                       .attr('data-target', '#' + this.id('panel', sid));
+                       }
+
+                       return head;
+               },
+
+               renderPanelBody: function(sid, index, parent_sid)
+               {
+                       var body = $('<div />')
+                               .attr('id', this.id('panel', sid))
+                               .addClass('luci2-section-panel')
+                               .on('validate', this.handlePanelValidate);
+
+                       if (this.options.collabsible || this.ownerMap.options.collabsible)
+                       {
+                               body.addClass('panel-collapse collapse');
+
+                               if (index == this.getPanelIndex(parent_sid))
+                                       body.addClass('in');
+                       }
+
+                       var tab_heads = $('<ul />')
+                               .addClass('nav nav-tabs');
+
+                       var tab_bodies = $('<div />')
+                               .addClass('form-horizontal tab-content')
+                               .append(tab_heads);
+
+                       for (var j = 0; j < this.tabs.length; j++)
+                       {
+                               tab_heads.append(this.renderTabHead(sid, index, j));
+                               tab_bodies.append(this.renderTabBody(sid, index, j));
+                       }
+
+                       body.append(tab_bodies);
+
+                       if (this.tabs.length <= 1)
+                               tab_heads.hide();
+
+                       for (var i = 0; i < this.subsections.length; i++)
+                               body.append(this.subsections[i].render(false, sid));
+
+                       return body;
+               },
+
+               renderBody: function(condensed, parent_sid)
+               {
+                       var s = this.getUCISections(parent_sid);
+                       var n = this.getPanelIndex(parent_sid);
+
+                       if (n < 0)
+                               this.setPanelIndex(parent_sid, n + s.length);
+                       else if (n >= s.length)
+                               this.setPanelIndex(parent_sid, s.length - 1);
+
+                       var body = $('<ul />')
+                               .addClass('luci2-section-group list-group');
+
+                       if (this.options.collabsible)
+                       {
+                               body.attr('id', this.id('sectiongroup', parent_sid))
+                                       .on('show.bs.collapse', { self: this }, this.handlePanelCollapse);
+                       }
+
+                       if (s.length == 0)
+                       {
+                               body.append($('<li />')
+                                       .addClass('list-group-item text-muted')
+                                       .text(this.label('placeholder') || L.tr('There are no entries defined yet.')))
+                       }
+
+                       for (var i = 0; i < s.length; i++)
+                       {
+                               var sid = s[i]['.name'];
+                               var inst = this.instance[sid] = { tabs: [ ] };
+
+                               body.append($('<li />')
+                                       .addClass('luci2-section-item list-group-item')
+                                       .attr('id', this.id('sectionitem', sid))
+                                       .attr('data-luci2-sid', sid)
+                                       .append(this.renderPanelHead(sid, i, parent_sid))
+                                       .append(this.renderPanelBody(sid, i, parent_sid)));
+                       }
+
+                       return body;
+               },
+
+               render: function(condensed, parent_sid)
+               {
+                       this.instance = { };
+
+                       var panel = $('<div />')
+                               .addClass('panel panel-default')
+                               .append(this.renderHead(condensed))
+                               .append(this.renderBody(condensed, parent_sid));
+
+                       if (this.options.addremove)
+                               panel.append($('<div />')
+                                       .addClass('panel-footer')
+                                       .append(this.renderAdd()));
+
+                       return panel;
+               },
+
+               finish: function(parent_sid)
+               {
+                       var s = this.getUCISections(parent_sid);
+
+                       for (var i = 0; i < s.length; i++)
+                       {
+                               var sid = s[i]['.name'];
+
+                               if (i != this.getPanelIndex(parent_sid))
+                                       $('#' + this.id('teaser', sid)).children('span:last')
+                                               .append(this.teaser(sid));
+                               else
+                                       $('#' + this.id('teaser', sid))
+                                               .hide();
+
+                               for (var j = 0; j < this.subsections.length; j++)
+                                       this.subsections[j].finish(sid);
+                       }
+               }
+       });
+
+       cbi_class.TableSection = cbi_class.TypedSection.extend({
+               renderTableHead: function()
+               {
+                       var thead = $('<thead />')
+                               .append($('<tr />')
+                                       .addClass('cbi-section-table-titles'));
+
+                       for (var j = 0; j < this.tabs[0].fields.length; j++)
+                               thead.children().append($('<th />')
+                                       .addClass('cbi-section-table-cell')
+                                       .css('width', this.tabs[0].fields[j].options.width || '')
+                                       .append(this.tabs[0].fields[j].label('caption')));
+
+                       if (this.options.addremove !== false || this.options.sortable)
+                               thead.children().append($('<th />')
+                                       .addClass('cbi-section-table-cell')
+                                       .text(' '));
+
+                       return thead;
+               },
+
+               renderTableRow: function(sid, index)
+               {
+                       var row = $('<tr />')
+                               .addClass('luci2-section-item')
+                               .attr('id', this.id('sectionitem', sid))
+                               .attr('data-luci2-sid', sid);
+
+                       for (var j = 0; j < this.tabs[0].fields.length; j++)
+                       {
+                               row.append($('<td />')
+                                       .css('width', this.tabs[0].fields[j].options.width || '')
+                                       .append(this.tabs[0].fields[j].render(sid, true)));
+                       }
+
+                       if (this.options.addremove !== false || this.options.sortable)
+                       {
+                               row.append($('<td />')
+                                       .css('width', '1%')
+                                       .addClass('text-right')
+                                       .append($('<div />')
+                                               .addClass('btn-group')
+                                               .append(this.renderSort(index))
+                                               .append(this.renderRemove(index))));
+                       }
+
+                       return row;
+               },
+
+               renderTableBody: function(parent_sid)
+               {
+                       var s = this.getUCISections(parent_sid);
+
+                       var tbody = $('<tbody />');
+
+                       if (s.length == 0)
+                       {
+                               var cols = this.tabs[0].fields.length;
+
+                               if (this.options.addremove !== false || this.options.sortable)
+                                       cols++;
+
+                               tbody.append($('<tr />')
+                                       .append($('<td />')
+                                               .addClass('text-muted')
+                                               .attr('colspan', cols)
+                                               .text(this.label('placeholder') || L.tr('There are no entries defined yet.'))));
+                       }
+
+                       for (var i = 0; i < s.length; i++)
+                       {
+                               var sid = s[i]['.name'];
+                               var inst = this.instance[sid] = { tabs: [ ] };
+
+                               tbody.append(this.renderTableRow(sid, i));
+                       }
+
+                       return tbody;
+               },
+
+               renderBody: function(condensed, parent_sid)
+               {
+                       return $('<table />')
+                               .addClass('table table-condensed table-hover')
+                               .append(this.renderTableHead())
+                               .append(this.renderTableBody(parent_sid));
+               }
+       });
+
+       cbi_class.NamedSection = cbi_class.TypedSection.extend({
+               getUCISections: function(cb)
+               {
+                       var sa = [ ];
+                       var sl = L.uci.sections(this.ownerMap.uci_package);
+
+                       for (var i = 0; i < sl.length; i++)
+                               if (sl[i]['.name'] == this.uci_type)
+                               {
+                                       sa.push(sl[i]);
+                                       break;
+                               }
+
+                       if (typeof(cb) == 'function' && sa.length > 0)
+                               cb.call(this, sa[0]);
+
+                       return sa;
+               }
+       });
+
+       cbi_class.SingleSection = cbi_class.NamedSection.extend({
+               render: function()
+               {
+                       this.instance = { };
+                       this.instance[this.uci_type] = { tabs: [ ] };
+
+                       return $('<div />')
+                               .addClass('luci2-section-item')
+                               .attr('id', this.id('sectionitem', this.uci_type))
+                               .attr('data-luci2-sid', this.uci_type)
+                               .append(this.renderPanelBody(this.uci_type, 0));
+               }
+       });
+
+       cbi_class.DummySection = cbi_class.TypedSection.extend({
+               getUCISections: function(cb)
+               {
+                       if (typeof(cb) == 'function')
+                               cb.apply(this, [ { '.name': this.uci_type } ]);
+
+                       return [ { '.name': this.uci_type } ];
+               }
+       });
+
+       cbi_class.Map = L.ui.AbstractWidget.extend({
+               init: function(uci_package, options)
+               {
+                       var self = this;
+
+                       this.uci_package = uci_package;
+                       this.sections = [ ];
+                       this.options = L.defaults(options, {
+                               save:    function() { },
+                               prepare: function() { }
+                       });
+               },
+
+               loadCallback: function()
+               {
+                       var deferreds = [ L.deferrable(this.options.prepare.call(this)) ];
+
+                       for (var i = 0; i < this.sections.length; i++)
+                       {
+                               var rv = this.sections[i].load();
+                               deferreds.push.apply(deferreds, rv);
+                       }
+
+                       return $.when.apply($, deferreds);
+               },
+
+               load: function()
+               {
+                       var self = this;
+                       var packages = [ this.uci_package ];
+
+                       for (var i = 0; i < this.sections.length; i++)
+                               packages.push.apply(packages, this.sections[i].findAdditionalUCIPackages());
+
+                       for (var i = 0; i < packages.length; i++)
+                               if (!L.uci.writable(packages[i]))
+                               {
+                                       this.options.readonly = true;
+                                       break;
+                               }
+
+                       return L.uci.load(packages).then(function() {
+                               return self.loadCallback();
+                       });
+               },
+
+               handleTab: function(ev)
+               {
+                       ev.data.self.active_tab = $(ev.target).parent().index();
+               },
+
+               handleApply: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       self.trigger('apply', ev);
+               },
+
+               handleSave: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       self.send().then(function() {
+                               self.trigger('save', ev);
+                       });
+               },
+
+               handleReset: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       self.trigger('reset', ev);
+                       self.reset();
+               },
+
+               renderTabHead: function(tab_index)
+               {
+                       var section = this.sections[tab_index];
+                       var cur = this.active_tab || 0;
+
+                       var tabh = $('<li />')
+                               .append($('<a />')
+                                       .attr('id', section.id('sectiontab'))
+                                       .attr('href', '#' + section.id('section'))
+                                       .attr('data-toggle', 'tab')
+                                       .text(section.label('caption') + ' ')
+                                       .append($('<span />')
+                                               .addClass('badge'))
+                                       .on('shown.bs.tab', { self: this }, this.handleTab));
+
+                       if (cur == tab_index)
+                               tabh.addClass('active');
+
+                       return tabh;
+               },
+
+               renderTabBody: function(tab_index)
+               {
+                       var section = this.sections[tab_index];
+                       var desc = section.label('description');
+                       var cur = this.active_tab || 0;
+
+                       var tabb = $('<div />')
+                               .addClass('tab-pane')
+                               .attr('id', section.id('section'));
+
+                       if (cur == tab_index)
+                               tabb.addClass('active');
+
+                       if (desc)
+                               tabb.append($('<p />')
+                                       .text(desc));
+
+                       var s = section.render(this.options.tabbed);
+
+                       if (this.options.readonly || section.options.readonly)
+                               s.find('input, select, button, img.cbi-button').attr('disabled', true);
+
+                       tabb.append(s);
+
+                       return tabb;
+               },
+
+               renderBody: function()
+               {
+                       var tabs = $('<ul />')
+                               .addClass('nav nav-tabs');
+
+                       var body = $('<div />')
+                               .append(tabs);
+
+                       for (var i = 0; i < this.sections.length; i++)
+                       {
+                               tabs.append(this.renderTabHead(i));
+                               body.append(this.renderTabBody(i));
+                       }
+
+                       if (this.options.tabbed)
+                               body.addClass('tab-content');
+                       else
+                               tabs.hide();
+
+                       return body;
+               },
+
+               renderFooter: function()
+               {
+                       var evdata = {
+                               self: this
+                       };
+
+                       return $('<div />')
+                               .addClass('panel panel-default panel-body text-right')
+                               .append($('<div />')
+                                       .addClass('btn-group')
+                                       .append(L.ui.button(L.tr('Save & Apply'), 'primary')
+                                               .click(evdata, this.handleApply))
+                                       .append(L.ui.button(L.tr('Save'), 'default')
+                                               .click(evdata, this.handleSave))
+                                       .append(L.ui.button(L.tr('Reset'), 'default')
+                                               .click(evdata, this.handleReset)));
+               },
+
+               render: function()
+               {
+                       var map = $('<form />');
+
+                       if (typeof(this.options.caption) == 'string')
+                               map.append($('<h2 />')
+                                       .text(this.options.caption));
+
+                       if (typeof(this.options.description) == 'string')
+                               map.append($('<p />')
+                                       .text(this.options.description));
+
+                       map.append(this.renderBody());
+
+                       if (this.options.pageaction !== false)
+                               map.append(this.renderFooter());
+
+                       return map;
+               },
+
+               finish: function()
+               {
+                       for (var i = 0; i < this.sections.length; i++)
+                               this.sections[i].finish();
+
+                       this.validate();
+               },
+
+               redraw: function()
+               {
+                       this.target.hide().empty().append(this.render());
+                       this.finish();
+                       this.target.show();
+               },
+
+               section: function(widget, uci_type, options)
+               {
+                       var w = widget ? new widget(uci_type, options) : null;
+
+                       if (!(w instanceof L.cbi.AbstractSection))
+                               throw 'Widget must be an instance of AbstractSection';
+
+                       w.ownerMap = this;
+                       w.index = this.sections.length;
+
+                       this.sections.push(w);
+                       return w;
+               },
+
+               add: function(conf, type, name)
+               {
+                       return L.uci.add(conf, type, name);
+               },
+
+               remove: function(conf, sid)
+               {
+                       return L.uci.remove(conf, sid);
+               },
+
+               get: function(conf, sid, opt)
+               {
+                       return L.uci.get(conf, sid, opt);
+               },
+
+               set: function(conf, sid, opt, val)
+               {
+                       return L.uci.set(conf, sid, opt, val);
+               },
+
+               validate: function()
+               {
+                       var rv = true;
+
+                       for (var i = 0; i < this.sections.length; i++)
+                       {
+                               if (!this.sections[i].validate())
+                                       rv = false;
+                       }
+
+                       return rv;
+               },
+
+               save: function()
+               {
+                       var self = this;
+
+                       if (self.options.readonly)
+                               return L.deferrable();
+
+                       var deferreds = [ ];
+
+                       for (var i = 0; i < self.sections.length; i++)
+                       {
+                               var rv = self.sections[i].save();
+                               deferreds.push.apply(deferreds, rv);
+                       }
+
+                       return $.when.apply($, deferreds).then(function() {
+                               return L.deferrable(self.options.save.call(self));
+                       });
+               },
+
+               send: function()
+               {
+                       if (!this.validate())
+                               return L.deferrable();
+
+                       var self = this;
+
+                       L.ui.saveScrollTop();
+                       L.ui.loading(true);
+
+                       return this.save().then(function() {
+                               return L.uci.save();
+                       }).then(function() {
+                               return L.ui.updateChanges();
+                       }).then(function() {
+                               return self.load();
+                       }).then(function() {
+                               self.redraw();
+                               self = null;
+
+                               L.ui.loading(false);
+                               L.ui.restoreScrollTop();
+                       });
+               },
+
+               revert: function()
+               {
+                       var packages = [ this.uci_package ];
+
+                       for (var i = 0; i < this.sections.length; i++)
+                               packages.push.apply(packages, this.sections[i].findAdditionalUCIPackages());
+
+                       L.uci.unload(packages);
+               },
+
+               reset: function()
+               {
+                       var self = this;
+
+                       self.revert();
+
+                       return self.insertInto(self.target);
+               },
+
+               insertInto: function(id)
+               {
+                       var self = this;
+                           self.target = $(id);
+
+                       L.ui.loading(true);
+                       self.target.hide();
+
+                       return self.load().then(function() {
+                               self.target.empty().append(self.render());
+                               self.finish();
+                               self.target.show();
+                               self = null;
+                               L.ui.loading(false);
+                       });
+               }
+       });
+
+       cbi_class.Modal = cbi_class.Map.extend({
+               handleApply: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       self.trigger('apply', ev);
+               },
+
+               handleSave: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       self.send().then(function() {
+                               self.trigger('save', ev);
+                               self.close();
+                       });
+               },
+
+               handleReset: function(ev)
+               {
+                       var self = ev.data.self;
+
+                       self.trigger('close', ev);
+                       self.revert();
+                       self.close();
+               },
+
+               renderFooter: function()
+               {
+                       var evdata = {
+                               self: this
+                       };
+
+                       return $('<div />')
+                               .addClass('btn-group')
+                               .append(L.ui.button(L.tr('Save & Apply'), 'primary')
+                                       .click(evdata, this.handleApply))
+                               .append(L.ui.button(L.tr('Save'), 'default')
+                                       .click(evdata, this.handleSave))
+                               .append(L.ui.button(L.tr('Cancel'), 'default')
+                                       .click(evdata, this.handleReset));
+               },
+
+               render: function()
+               {
+                       var modal = L.ui.dialog(this.label('caption'), null, { wide: true });
+                       var map = $('<form />');
+
+                       var desc = this.label('description');
+                       if (desc)
+                               map.append($('<p />').text(desc));
+
+                       map.append(this.renderBody());
+
+                       modal.find('.modal-body').append(map);
+                       modal.find('.modal-footer').append(this.renderFooter());
+
+                       return modal;
+               },
+
+               redraw: function()
+               {
+                       this.render();
+                       this.finish();
+               },
+
+               show: function()
+               {
+                       var self = this;
+
+                       L.ui.loading(true);
+
+                       return self.load().then(function() {
+                               self.render();
+                               self.finish();
+
+                               L.ui.loading(false);
+                       });
+               },
+
+               close: function()
+               {
+                       L.ui.dialog(false);
+               }
+       });
+
+       return Class.extend(cbi_class);
+})();