luci-base: Improve change application message
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
index ad1547fee2648f7b0c7cbdd511edc0d424182471..1a9504b5d33cbb091bed65da77fe6ca99f3d78e1 100644 (file)
@@ -1,6 +1,8 @@
 'use strict';
+'require rpc';
 'require uci';
 'require validation';
+'require fs';
 
 var modalDiv = null,
     tooltipDiv = null,
@@ -69,6 +71,11 @@ var UIElement = L.Class.extend({
        },
 
        setChangeEvents: function(targetNode /*, ... */) {
+               var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
+
+               for (var i = 1; i < arguments.length; i++)
+                       targetNode.addEventListener(arguments[i], tag_changed);
+
                this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
        }
 });
@@ -1181,6 +1188,51 @@ var UICombobox = UIDropdown.extend({
        }
 });
 
+var UIComboButton = UIDropdown.extend({
+       __init__: function(value, choices, options) {
+               this.super('__init__', [ value, choices, Object.assign({
+                       sort: true
+               }, options, {
+                       multiple: false,
+                       create: false,
+                       optional: false
+               }) ]);
+       },
+
+       render: function(/* ... */) {
+               var node = UIDropdown.prototype.render.apply(this, arguments),
+                   val = this.getValue();
+
+               if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
+                       node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
+
+               return node;
+       },
+
+       handleClick: function(ev) {
+               var sb = ev.currentTarget,
+                   t = ev.target;
+
+               if (sb.hasAttribute('open') || L.dom.matches(t, '.cbi-dropdown > span.open'))
+                       return UIDropdown.prototype.handleClick.apply(this, arguments);
+
+               if (this.options.click)
+                       return this.options.click.call(sb, ev, this.getValue());
+       },
+
+       toggleItem: function(sb /*, ... */) {
+               var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
+                   val = this.getValue();
+
+               if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
+                       sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
+               else
+                       sb.setAttribute('class', 'cbi-dropdown');
+
+               return rv;
+       }
+});
+
 var UIDynamicList = UIElement.extend({
        __init__: function(values, choices, options) {
                if (!Array.isArray(values))
@@ -1453,6 +1505,393 @@ var UIHiddenfield = UIElement.extend({
        }
 });
 
+var UIFileUpload = UIElement.extend({
+       __init__: function(value, options) {
+               this.value = value;
+               this.options = Object.assign({
+                       show_hidden: false,
+                       enable_upload: true,
+                       enable_remove: true,
+                       root_directory: '/etc/luci-uploads'
+               }, options);
+       },
+
+       bind: function(browserEl) {
+               this.node = browserEl;
+
+               this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
+               this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
+
+               L.dom.bindClassInstance(browserEl, this);
+
+               return browserEl;
+       },
+
+       render: function() {
+               return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
+                       var label;
+
+                       if (L.isObject(stat) && stat.type != 'directory')
+                               this.stat = stat;
+
+                       if (this.stat != null)
+                               label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
+                       else if (this.value != null)
+                               label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
+                       else
+                               label = [ _('Select file…') ];
+
+                       return this.bind(E('div', { 'id': this.options.id }, [
+                               E('button', {
+                                       'class': 'btn',
+                                       'click': L.ui.createHandlerFn(this, 'handleFileBrowser')
+                               }, label),
+                               E('div', {
+                                       'class': 'cbi-filebrowser'
+                               }),
+                               E('input', {
+                                       'type': 'hidden',
+                                       'name': this.options.name,
+                                       'value': this.value
+                               })
+                       ]));
+               }, this));
+       },
+
+       truncatePath: function(path) {
+               if (path.length > 50)
+                       path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
+
+               return path;
+       },
+
+       iconForType: function(type) {
+               switch (type) {
+               case 'symlink':
+                       return E('img', {
+                               'src': L.resource('cbi/link.gif'),
+                               'title': _('Symbolic link'),
+                               'class': 'middle'
+                       });
+
+               case 'directory':
+                       return E('img', {
+                               'src': L.resource('cbi/folder.gif'),
+                               'title': _('Directory'),
+                               'class': 'middle'
+                       });
+
+               default:
+                       return E('img', {
+                               'src': L.resource('cbi/file.gif'),
+                               'title': _('File'),
+                               'class': 'middle'
+                       });
+               }
+       },
+
+       canonicalizePath: function(path) {
+               return path.replace(/\/{2,}/, '/')
+                       .replace(/\/\.(\/|$)/g, '/')
+                       .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
+                       .replace(/\/$/, '');
+       },
+
+       splitPath: function(path) {
+               var croot = this.canonicalizePath(this.options.root_directory || '/'),
+                   cpath = this.canonicalizePath(path || '/');
+
+               if (cpath.length <= croot.length)
+                       return [ croot ];
+
+               if (cpath.charAt(croot.length) != '/')
+                       return [ croot ];
+
+               var parts = cpath.substring(croot.length + 1).split(/\//);
+
+               parts.unshift(croot);
+
+               return parts;
+       },
+
+       handleUpload: function(path, list, ev) {
+               var form = ev.target.parentNode,
+                   fileinput = form.querySelector('input[type="file"]'),
+                   nameinput = form.querySelector('input[type="text"]'),
+                   filename = (nameinput.value != null ? nameinput.value : '').trim();
+
+               ev.preventDefault();
+
+               if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
+                       return;
+
+               var existing = list.filter(function(e) { return e.name == filename })[0];
+
+               if (existing != null && existing.type == 'directory')
+                       return alert(_('A directory with the same name already exists.'));
+               else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
+                       return;
+
+               var data = new FormData();
+
+               data.append('sessionid', L.env.sessionid);
+               data.append('filename', path + '/' + filename);
+               data.append('filedata', fileinput.files[0]);
+
+               return L.Request.post('/cgi-bin/cgi-upload', data, {
+                       progress: L.bind(function(btn, ev) {
+                               btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
+                       }, this, ev.target)
+               }).then(L.bind(function(path, ev, res) {
+                       var reply = res.json();
+
+                       if (L.isObject(reply) && reply.failure)
+                               alert(_('Upload request failed: %s').format(reply.message));
+
+                       return this.handleSelect(path, null, ev);
+               }, this, path, ev));
+       },
+
+       handleDelete: function(path, fileStat, ev) {
+               var parent = path.replace(/\/[^\/]+$/, '') || '/',
+                   name = path.replace(/^.+\//, ''),
+                   msg;
+
+               ev.preventDefault();
+
+               if (fileStat.type == 'directory')
+                       msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
+               else
+                       msg = _('Do you really want to delete "%s" ?').format(name);
+
+               if (confirm(msg)) {
+                       var button = this.node.firstElementChild,
+                           hidden = this.node.lastElementChild;
+
+                       if (path == hidden.value) {
+                               L.dom.content(button, _('Select file…'));
+                               hidden.value = '';
+                       }
+
+                       return fs.remove(path).then(L.bind(function(parent, ev) {
+                               return this.handleSelect(parent, null, ev);
+                       }, this, parent, ev)).catch(function(err) {
+                               alert(_('Delete request failed: %s').format(err.message));
+                       });
+               }
+       },
+
+       renderUpload: function(path, list) {
+               if (!this.options.enable_upload)
+                       return E([]);
+
+               return E([
+                       E('a', {
+                               'href': '#',
+                               'class': 'btn cbi-button-positive',
+                               'click': function(ev) {
+                                       var uploadForm = ev.target.nextElementSibling,
+                                           fileInput = uploadForm.querySelector('input[type="file"]');
+
+                                       ev.target.style.display = 'none';
+                                       uploadForm.style.display = '';
+                                       fileInput.click();
+                               }
+                       }, _('Upload file…')),
+                       E('div', { 'class': 'upload', 'style': 'display:none' }, [
+                               E('input', {
+                                       'type': 'file',
+                                       'style': 'display:none',
+                                       'change': function(ev) {
+                                               var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
+                                                   uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
+
+                                               nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
+                                               uploadbtn.disabled = false;
+                                       }
+                               }),
+                               E('button', {
+                                       'class': 'btn',
+                                       'click': function(ev) {
+                                               ev.preventDefault();
+                                               ev.target.previousElementSibling.click();
+                                       }
+                               }, [ _('Browse…') ]),
+                               E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
+                               E('button', {
+                                       'class': 'btn cbi-button-save',
+                                       'click': L.ui.createHandlerFn(this, 'handleUpload', path, list),
+                                       'disabled': true
+                               }, [ _('Upload file') ])
+                       ])
+               ]);
+       },
+
+       renderListing: function(container, path, list) {
+               var breadcrumb = E('p'),
+                   rows = E('ul');
+
+               list.sort(function(a, b) {
+                       var isDirA = (a.type == 'directory'),
+                           isDirB = (b.type == 'directory');
+
+                       if (isDirA != isDirB)
+                               return isDirA < isDirB;
+
+                       return a.name > b.name;
+               });
+
+               for (var i = 0; i < list.length; i++) {
+                       if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
+                               continue;
+
+                       var entrypath = this.canonicalizePath(path + '/' + list[i].name),
+                           selected = (entrypath == this.node.lastElementChild.value),
+                           mtime = new Date(list[i].mtime * 1000);
+
+                       rows.appendChild(E('li', [
+                               E('div', { 'class': 'name' }, [
+                                       this.iconForType(list[i].type),
+                                       ' ',
+                                       E('a', {
+                                               'href': '#',
+                                               'style': selected ? 'font-weight:bold' : null,
+                                               'click': L.ui.createHandlerFn(this, 'handleSelect',
+                                                       entrypath, list[i].type != 'directory' ? list[i] : null)
+                                       }, '%h'.format(list[i].name))
+                               ]),
+                               E('div', { 'class': 'mtime hide-xs' }, [
+                                       ' %04d-%02d-%02d %02d:%02d:%02d '.format(
+                                               mtime.getFullYear(),
+                                               mtime.getMonth() + 1,
+                                               mtime.getDate(),
+                                               mtime.getHours(),
+                                               mtime.getMinutes(),
+                                               mtime.getSeconds())
+                               ]),
+                               E('div', [
+                                       selected ? E('button', {
+                                               'class': 'btn',
+                                               'click': L.ui.createHandlerFn(this, 'handleReset')
+                                       }, [ _('Deselect') ]) : '',
+                                       this.options.enable_remove ? E('button', {
+                                               'class': 'btn cbi-button-negative',
+                                               'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i])
+                                       }, [ _('Delete') ]) : ''
+                               ])
+                       ]));
+               }
+
+               if (!rows.firstElementChild)
+                       rows.appendChild(E('em', _('No entries in this directory')));
+
+               var dirs = this.splitPath(path),
+                   cur = '';
+
+               for (var i = 0; i < dirs.length; i++) {
+                       cur = cur ? cur + '/' + dirs[i] : dirs[i];
+                       L.dom.append(breadcrumb, [
+                               i ? ' » ' : '',
+                               E('a', {
+                                       'href': '#',
+                                       'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null)
+                               }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
+                       ]);
+               }
+
+               L.dom.content(container, [
+                       breadcrumb,
+                       rows,
+                       E('div', { 'class': 'right' }, [
+                               this.renderUpload(path, list),
+                               E('a', {
+                                       'href': '#',
+                                       'class': 'btn',
+                                       'click': L.ui.createHandlerFn(this, 'handleCancel')
+                               }, _('Cancel'))
+                       ]),
+               ]);
+       },
+
+       handleCancel: function(ev) {
+               var button = this.node.firstElementChild,
+                   browser = button.nextElementSibling;
+
+               browser.classList.remove('open');
+               button.style.display = '';
+
+               this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
+       },
+
+       handleReset: function(ev) {
+               var button = this.node.firstElementChild,
+                   hidden = this.node.lastElementChild;
+
+               hidden.value = '';
+               L.dom.content(button, _('Select file…'));
+
+               this.handleCancel(ev);
+       },
+
+       handleSelect: function(path, fileStat, ev) {
+               var browser = L.dom.parent(ev.target, '.cbi-filebrowser'),
+                   ul = browser.querySelector('ul');
+
+               if (fileStat == null) {
+                       L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
+                       L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
+               }
+               else {
+                       var button = this.node.firstElementChild,
+                           hidden = this.node.lastElementChild;
+
+                       path = this.canonicalizePath(path);
+
+                       L.dom.content(button, [
+                               this.iconForType(fileStat.type),
+                               ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
+                       ]);
+
+                       browser.classList.remove('open');
+                       button.style.display = '';
+                       hidden.value = path;
+
+                       this.stat = Object.assign({ path: path }, fileStat);
+                       this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
+               }
+       },
+
+       handleFileBrowser: function(ev) {
+               var button = ev.target,
+                   browser = button.nextElementSibling,
+                   path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : this.options.root_directory;
+
+               if (this.options.root_directory.indexOf(path) != 0)
+                       path = this.options.root_directory;
+
+               ev.preventDefault();
+
+               return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
+                       document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
+                               L.dom.findClassInstance(browserEl).handleCancel(ev);
+                       });
+
+                       button.style.display = 'none';
+                       browser.classList.add('open');
+
+                       return this.renderListing(browser, path, list);
+               }, this, button, browser, path));
+       },
+
+       getValue: function() {
+               return this.node.lastElementChild.value;
+       },
+
+       setValue: function(value) {
+               this.node.lastElementChild.value = value;
+       }
+});
+
 
 return L.Class.extend({
        __init__: function() {
@@ -1555,6 +1994,43 @@ return L.Class.extend({
                tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
        },
 
+       addNotification: function(title, children /*, ... */) {
+               var mc = document.querySelector('#maincontent') || document.body;
+               var msg = E('div', {
+                       'class': 'alert-message fade-in',
+                       'style': 'display:flex',
+                       'transitionend': function(ev) {
+                               var node = ev.currentTarget;
+                               if (node.parentNode && node.classList.contains('fade-out'))
+                                       node.parentNode.removeChild(node);
+                       }
+               }, [
+                       E('div', { 'style': 'flex:10' }),
+                       E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
+                               E('button', {
+                                       'class': 'btn',
+                                       'style': 'margin-left:auto; margin-top:auto',
+                                       'click': function(ev) {
+                                               L.dom.parent(ev.target, '.alert-message').classList.add('fade-out');
+                                       },
+
+                               }, [ _('Dismiss') ])
+                       ])
+               ]);
+
+               if (title != null)
+                       L.dom.append(msg.firstElementChild, E('h4', {}, title));
+
+               L.dom.append(msg.firstElementChild, children);
+
+               for (var i = 2; i < arguments.length; i++)
+                       msg.classList.add(arguments[i]);
+
+               mc.insertBefore(msg, mc.firstElementChild);
+
+               return msg;
+       },
+
        /* Widget helper */
        itemlist: function(node, items, separators) {
                var children = [];
@@ -1590,6 +2066,9 @@ return L.Class.extend({
                        document.querySelectorAll('[data-tab]').forEach(function(tab) {
                                var parent = tab.parentNode;
 
+                               if (L.dom.matches(tab, 'li') && L.dom.matches(parent, 'ul.cbi-tabmenu'))
+                                       return;
+
                                if (!parent.hasAttribute('data-tab-group'))
                                        parent.setAttribute('data-tab-group', groups.length);
 
@@ -1622,6 +2101,9 @@ return L.Class.extend({
                            groupId = +group.getAttribute('data-tab-group'),
                            selected = null;
 
+                       if (group.getAttribute('data-initialized') === 'true')
+                               return;
+
                        for (var i = 0, pane; pane = panes[i]; i++) {
                                var name = pane.getAttribute('data-tab'),
                                    title = pane.getAttribute('data-tab-title'),
@@ -1641,6 +2123,7 @@ return L.Class.extend({
                        }
 
                        group.parentNode.insertBefore(menu, group);
+                       group.setAttribute('data-initialized', true);
 
                        if (selected === null) {
                                selected = this.getActiveTabId(panes[0]);
@@ -1660,6 +2143,8 @@ return L.Class.extend({
 
                                this.setActiveTabId(panes[selected], selected);
                        }
+
+                       this.updateTabs(group);
                },
 
                isEmptyPane: function(pane) {
@@ -1781,6 +2266,147 @@ return L.Class.extend({
                }
        }),
 
+       /* File uploading */
+       uploadFile: function(path, progressStatusNode) {
+               return new Promise(function(resolveFn, rejectFn) {
+                       L.ui.showModal(_('Uploading file…'), [
+                               E('p', _('Please select the file to upload.')),
+                               E('div', { 'style': 'display:flex' }, [
+                                       E('div', { 'class': 'left', 'style': 'flex:1' }, [
+                                               E('input', {
+                                                       type: 'file',
+                                                       style: 'display:none',
+                                                       change: function(ev) {
+                                                               var modal = L.dom.parent(ev.target, '.modal'),
+                                                                   body = modal.querySelector('p'),
+                                                                   upload = modal.querySelector('.cbi-button-action.important'),
+                                                                   file = ev.currentTarget.files[0];
+
+                                                               if (file == null)
+                                                                       return;
+
+                                                               L.dom.content(body, [
+                                                                       E('ul', {}, [
+                                                                               E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
+                                                                               E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
+                                                                       ])
+                                                               ]);
+
+                                                               upload.disabled = false;
+                                                               upload.focus();
+                                                       }
+                                               }),
+                                               E('button', {
+                                                       'class': 'btn',
+                                                       'click': function(ev) {
+                                                               ev.target.previousElementSibling.click();
+                                                       }
+                                               }, [ _('Browse…') ])
+                                       ]),
+                                       E('div', { 'class': 'right', 'style': 'flex:1' }, [
+                                               E('button', {
+                                                       'class': 'btn',
+                                                       'click': function() {
+                                                               L.ui.hideModal();
+                                                               rejectFn(new Error('Upload has been cancelled'));
+                                                       }
+                                               }, [ _('Cancel') ]),
+                                               ' ',
+                                               E('button', {
+                                                       'class': 'btn cbi-button-action important',
+                                                       'disabled': true,
+                                                       'click': function(ev) {
+                                                               var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
+
+                                                               if (!input.files[0])
+                                                                       return;
+
+                                                               var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
+
+                                                               L.ui.showModal(_('Uploading file…'), [ progress ]);
+
+                                                               var data = new FormData();
+
+                                                               data.append('sessionid', rpc.getSessionID());
+                                                               data.append('filename', path);
+                                                               data.append('filedata', input.files[0]);
+
+                                                               var filename = input.files[0].name;
+
+                                                               L.Request.post('/cgi-bin/cgi-upload', data, {
+                                                                       timeout: 0,
+                                                                       progress: function(pev) {
+                                                                               var percent = (pev.loaded / pev.total) * 100;
+
+                                                                               if (progressStatusNode)
+                                                                                       progressStatusNode.data = '%.2f%%'.format(percent);
+
+                                                                               progress.setAttribute('title', '%.2f%%'.format(percent));
+                                                                               progress.firstElementChild.style.width = '%.2f%%'.format(percent);
+                                                                       }
+                                                               }).then(function(res) {
+                                                                       var reply = res.json();
+
+                                                                       L.ui.hideModal();
+
+                                                                       if (L.isObject(reply) && reply.failure) {
+                                                                               L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
+                                                                               rejectFn(new Error(reply.failure));
+                                                                       }
+                                                                       else {
+                                                                               reply.name = filename;
+                                                                               resolveFn(reply);
+                                                                       }
+                                                               }, function(err) {
+                                                                       L.ui.hideModal();
+                                                                       rejectFn(err);
+                                                               });
+                                                       }
+                                               }, [ _('Upload') ])
+                                       ])
+                               ])
+                       ]);
+               });
+       },
+
+       /* Reconnect handling */
+       pingDevice: function(proto, ipaddr) {
+               var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
+
+               return new Promise(function(resolveFn, rejectFn) {
+                       var img = new Image();
+
+                       img.onload = resolveFn;
+                       img.onerror = rejectFn;
+
+                       window.setTimeout(rejectFn, 1000);
+
+                       img.src = target;
+               });
+       },
+
+       awaitReconnect: function(/* ... */) {
+               var ipaddrs = arguments.length ? arguments : [ window.location.host ];
+
+               window.setTimeout(L.bind(function() {
+                       L.Poll.add(L.bind(function() {
+                               var tasks = [], reachable = false;
+
+                               for (var i = 0; i < 2; i++)
+                                       for (var j = 0; j < ipaddrs.length; j++)
+                                               tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
+                                                       .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
+
+                               return Promise.all(tasks).then(function() {
+                                       if (reachable) {
+                                               L.Poll.stop();
+                                               window.location = reachable;
+                                       }
+                               });
+                       }, this));
+               }, this), 5000);
+       },
+
        /* UCI Changes */
        changes: L.Class.singleton({
                init: function() {
@@ -1852,24 +2478,18 @@ return L.Class.extend({
                                                        E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
                                        E('br'), list,
                                        E('div', { 'class': 'right' }, [
-                                               E('input', {
-                                                       'type': 'button',
+                                               E('button', {
                                                        'class': 'btn',
-                                                       'click': L.ui.hideModal,
-                                                       'value': _('Dismiss')
-                                               }), ' ',
-                                               E('input', {
-                                                       'type': 'button',
+                                                       'click': L.ui.hideModal
+                                               }, [ _('Dismiss') ]), ' ',
+                                               E('button', {
                                                        'class': 'cbi-button cbi-button-positive important',
-                                                       'click': L.bind(this.apply, this, true),
-                                                       'value': _('Save & Apply')
-                                               }), ' ',
-                                               E('input', {
-                                                       'type': 'button',
+                                                       'click': L.bind(this.apply, this, true)
+                                               }, [ _('Save & Apply') ]), ' ',
+                                               E('button', {
                                                        'class': 'cbi-button cbi-button-reset',
-                                                       'click': L.bind(this.revert, this),
-                                                       'value': _('Revert')
-                                               })])])
+                                                       'click': L.bind(this.revert, this)
+                                               }, [ _('Revert') ])])])
                        ]);
 
                        for (var config in this.changes) {
@@ -1942,27 +2562,21 @@ return L.Class.extend({
                                var call = function(r, data, duration) {
                                        if (r.status === 204) {
                                                L.ui.changes.displayStatus('warning', [
-                                                       E('h4', _('Configuration has been rolled back!')),
+                                                       E('h4', _('Configuration changes have been rolled back!')),
                                                        E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
                                                        E('div', { 'class': 'right' }, [
-                                                               E('input', {
-                                                                       'type': 'button',
+                                                               E('button', {
                                                                        'class': 'btn',
-                                                                       'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
-                                                                       'value': _('Dismiss')
-                                                               }), ' ',
-                                                               E('input', {
-                                                                       'type': 'button',
+                                                                       'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false)
+                                                               }, [ _('Dismiss') ]), ' ',
+                                                               E('button', {
                                                                        'class': 'btn cbi-button-action important',
-                                                                       'click': L.bind(L.ui.changes.revert, L.ui.changes),
-                                                                       'value': _('Revert changes')
-                                                               }), ' ',
-                                                               E('input', {
-                                                                       'type': 'button',
+                                                                       'click': L.bind(L.ui.changes.revert, L.ui.changes)
+                                                               }, [ _('Revert changes') ]), ' ',
+                                                               E('button', {
                                                                        'class': 'btn cbi-button-negative important',
-                                                                       'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
-                                                                       'value': _('Apply unchecked')
-                                                               })
+                                                                       'click': L.bind(L.ui.changes.apply, L.ui.changes, false)
+                                                               }, [ _('Apply unchecked') ])
                                                        ])
                                                ]);
 
@@ -2009,7 +2623,7 @@ return L.Class.extend({
 
                                        L.ui.changes.setIndicator(0);
                                        L.ui.changes.displayStatus('notice',
-                                               E('p', _('Configuration has been applied.')));
+                                               E('p', _('Configuration changes applied.')));
 
                                        window.clearTimeout(tt);
                                        window.setTimeout(function() {
@@ -2034,7 +2648,7 @@ return L.Class.extend({
                                var now = Date.now();
 
                                L.ui.changes.displayStatus('notice spinning',
-                                       E('p', _('Waiting for configuration to get applied… %ds')
+                                       E('p', _('Applying configuration changes… %ds')
                                                .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
 
                                if (now >= deadline)
@@ -2147,8 +2761,10 @@ return L.Class.extend({
                if (typeof(fn) != 'function')
                        return null;
 
+               var arg_offset = arguments.length - 2;
+
                return Function.prototype.bind.apply(function() {
-                       var t = arguments[arguments.length - 1].target;
+                       var t = arguments[arg_offset].target;
 
                        t.classList.add('spinning');
                        t.disabled = true;
@@ -2156,7 +2772,7 @@ return L.Class.extend({
                        if (t.blur)
                                t.blur();
 
-                       Promise.resolve(fn.apply(ctx, arguments)).then(function() {
+                       Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
                                t.classList.remove('spinning');
                                t.disabled = false;
                        });
@@ -2171,5 +2787,7 @@ return L.Class.extend({
        Dropdown: UIDropdown,
        DynamicList: UIDynamicList,
        Combobox: UICombobox,
-       Hiddenfield: UIHiddenfield
+       ComboButton: UIComboButton,
+       Hiddenfield: UIHiddenfield,
+       FileUpload: UIFileUpload
 });