luci-base: Improve change application message
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
index c27dd7ebfc3fd4d58779b41d2a6649f5d17b17fc..1a9504b5d33cbb091bed65da77fe6ca99f3d78e1 100644 (file)
@@ -2,6 +2,7 @@
 'require rpc';
 'require uci';
 'require validation';
+'require fs';
 
 var modalDiv = null,
     tooltipDiv = null,
@@ -70,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));
        }
 });
@@ -1182,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))
@@ -1465,26 +1516,6 @@ var UIFileUpload = UIElement.extend({
                }, options);
        },
 
-       callFileStat: rpc.declare({
-               'object': 'file',
-               'method': 'stat',
-               'params': [ 'path' ],
-               'expect': { '': {} }
-       }),
-
-       callFileList: rpc.declare({
-               'object': 'file',
-               'method': 'list',
-               'params': [ 'path' ],
-               'expect': { 'entries': [] }
-       }),
-
-       callFileRemove: rpc.declare({
-               'object': 'file',
-               'method': 'remove',
-               'params': [ 'path' ]
-       }),
-
        bind: function(browserEl) {
                this.node = browserEl;
 
@@ -1497,7 +1528,7 @@ var UIFileUpload = UIElement.extend({
        },
 
        render: function() {
-               return Promise.resolve(this.value != null ? this.callFileStat(this.value) : null).then(L.bind(function(stat) {
+               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')
@@ -1508,7 +1539,7 @@ var UIFileUpload = UIElement.extend({
                        else if (this.value != null)
                                label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
                        else
-                               label = _('Select file…');
+                               label = [ _('Select file…') ];
 
                        return this.bind(E('div', { 'id': this.options.id }, [
                                E('button', {
@@ -1642,15 +1673,11 @@ var UIFileUpload = UIElement.extend({
                                hidden.value = '';
                        }
 
-                       return this.callFileRemove(path).then(L.bind(function(parent, ev, rc) {
-                               if (rc == 0)
-                                       return this.handleSelect(parent, null, ev);
-                               else if (rc == 6)
-                                       alert(_('Delete permission denied'));
-                               else
-                                       alert(_('Delete request failed: %d %s').format(rc, rpc.getStatusText(rc)));
-
-                       }, this, parent, ev));
+                       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));
+                       });
                }
        },
 
@@ -1689,13 +1716,13 @@ var UIFileUpload = UIElement.extend({
                                                ev.preventDefault();
                                                ev.target.previousElementSibling.click();
                                        }
-                               }, _('Browse…')),
+                               }, [ _('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'))
+                               }, [ _('Upload file') ])
                        ])
                ]);
        },
@@ -1746,11 +1773,11 @@ var UIFileUpload = UIElement.extend({
                                        selected ? E('button', {
                                                'class': 'btn',
                                                'click': L.ui.createHandlerFn(this, 'handleReset')
-                                       }, _('Deselect')) : '',
+                                       }, [ _('Deselect') ]) : '',
                                        this.options.enable_remove ? E('button', {
                                                'class': 'btn cbi-button-negative',
                                                'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i])
-                                       }, _('Delete')) : ''
+                                       }, [ _('Delete') ]) : ''
                                ])
                        ]));
                }
@@ -1812,7 +1839,7 @@ var UIFileUpload = UIElement.extend({
 
                if (fileStat == null) {
                        L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
-                       this.callFileList(path).then(L.bind(this.renderListing, this, browser, path));
+                       L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
                }
                else {
                        var button = this.node.firstElementChild,
@@ -1844,7 +1871,7 @@ var UIFileUpload = UIElement.extend({
 
                ev.preventDefault();
 
-               return this.callFileList(path).then(L.bind(function(button, browser, path, list) {
+               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);
                        });
@@ -1967,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 = [];
@@ -2002,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);
 
@@ -2034,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'),
@@ -2053,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]);
@@ -2195,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() {
@@ -2266,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) {
@@ -2356,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') ])
                                                        ])
                                                ]);
 
@@ -2423,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() {
@@ -2448,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)
@@ -2561,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;
@@ -2585,6 +2787,7 @@ return L.Class.extend({
        Dropdown: UIDropdown,
        DynamicList: UIDynamicList,
        Combobox: UICombobox,
+       ComboButton: UIComboButton,
        Hiddenfield: UIHiddenfield,
        FileUpload: UIFileUpload
 });