]> git.proxmox.com Git - proxmox-widget-toolkit.git/blobdiff - src/Utils.js
add generic OpenIDInputPanel
[proxmox-widget-toolkit.git] / src / Utils.js
index 2163794fe9b7a881e267ef4acc8f0968dffe83e7..167679ddefa763aa9aea44dcacd4d15a14a2dc36 100644 (file)
@@ -5,13 +5,6 @@ if (!Ext.isDefined(Proxmox.Setup.auth_cookie_name)) {
     throw "Proxmox library not initialized";
 }
 
-// avoid errors related to Accessible Rich Internet Applications
-// (access for people with disabilities)
-// TODO reenable after all components are upgraded
-Ext.enableAria = false;
-Ext.enableAriaButtons = false;
-Ext.enableAriaPanels = false;
-
 // avoid errors when running without development tools
 if (!Ext.isDefined(Ext.global.console)) {
     let console = {
@@ -39,6 +32,10 @@ Ext.Ajax.on('beforerequest', function(conn, options) {
        }
        options.headers.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
     }
+    let storedAuth = Proxmox.Utils.getStoredAuth();
+    if (storedAuth.token) {
+       options.headers.Authorization = storedAuth.token;
+    }
 });
 
 Ext.define('Proxmox.Utils', { // a singleton
@@ -51,6 +48,7 @@ utilities: {
     noneText: gettext('none'),
     NoneText: gettext('None'),
     errorText: gettext('Error'),
+    warningsText: gettext('Warnings'),
     unknownText: gettext('Unknown'),
     defaultText: gettext('Default'),
     daysText: gettext('days'),
@@ -67,26 +65,28 @@ utilities: {
     language_map: {
        ar: 'Arabic',
        ca: 'Catalan',
+       zh_CN: 'Chinese (Simplified)',
+       zh_TW: 'Chinese (Traditional)',
        da: 'Danish',
-       de: 'German',
+       nl: 'Dutch',
        en: 'English',
-       es: 'Spanish',
        eu: 'Euskera (Basque)',
-       fa: 'Persian (Farsi)',
        fr: 'French',
+       de: 'German',
        he: 'Hebrew',
        it: 'Italian',
        ja: 'Japanese',
+       kr: 'Korean',
        nb: 'Norwegian (Bokmal)',
        nn: 'Norwegian (Nynorsk)',
+       fa: 'Persian (Farsi)',
        pl: 'Polish',
        pt_BR: 'Portuguese (Brazil)',
        ru: 'Russian',
        sl: 'Slovenian',
+       es: 'Spanish',
        sv: 'Swedish',
        tr: 'Turkish',
-       zh_CN: 'Chinese (Simplified)',
-       zh_TW: 'Chinese (Traditional)',
     },
 
     render_language: function(value) {
@@ -157,8 +157,8 @@ utilities: {
     format_duration_human: function(ut) {
        let seconds = 0, minutes = 0, hours = 0, days = 0;
 
-       if (ut <= 0) {
-           return '0s';
+       if (ut <= 0.1) {
+           return '<0.1s';
        }
 
        let remaining = ut;
@@ -238,22 +238,58 @@ utilities: {
        return min < width ? width : min;
     },
 
+    // returns username + realm
+    parse_userid: function(userid) {
+       if (!Ext.isString(userid)) {
+           return [undefined, undefined];
+       }
+
+       let match = userid.match(/^(.+)@([^@]+)$/);
+       if (match !== null) {
+           return [match[1], match[2]];
+       }
+
+       return [undefined, undefined];
+    },
+
+    render_username: function(userid) {
+       let username = Proxmox.Utils.parse_userid(userid)[0] || "";
+       return Ext.htmlEncode(username);
+    },
+
+    render_realm: function(userid) {
+       let username = Proxmox.Utils.parse_userid(userid)[1] || "";
+       return Ext.htmlEncode(username);
+    },
+
+    getStoredAuth: function() {
+       let storedAuth = JSON.parse(window.localStorage.getItem('ProxmoxUser'));
+       return storedAuth || {};
+    },
+
     setAuthData: function(data) {
-       Proxmox.CSRFPreventionToken = data.CSRFPreventionToken;
        Proxmox.UserName = data.username;
        Proxmox.LoggedOut = data.LoggedOut;
        // creates a session cookie (expire = null)
        // that way the cookie gets deleted after the browser window is closed
-       Ext.util.Cookies.set(Proxmox.Setup.auth_cookie_name, data.ticket, null, '/', null, true);
+       if (data.ticket) {
+           Proxmox.CSRFPreventionToken = data.CSRFPreventionToken;
+           Ext.util.Cookies.set(Proxmox.Setup.auth_cookie_name, data.ticket, null, '/', null, true);
+       }
+
+       if (data.token) {
+           window.localStorage.setItem('ProxmoxUser', JSON.stringify(data));
+       }
     },
 
     authOK: function() {
        if (Proxmox.LoggedOut) {
            return undefined;
        }
+       let storedAuth = Proxmox.Utils.getStoredAuth();
        let cookie = Ext.util.Cookies.get(Proxmox.Setup.auth_cookie_name);
-       if (Proxmox.UserName !== '' && cookie && !cookie.startsWith("PVE:tfa!")) {
-           return cookie;
+       if ((Proxmox.UserName !== '' && cookie && !cookie.startsWith("PVE:tfa!")) || storedAuth.token) {
+           return cookie || storedAuth.token;
        } else {
            return false;
        }
@@ -264,6 +300,17 @@ utilities: {
            return;
        }
        Ext.util.Cookies.clear(Proxmox.Setup.auth_cookie_name);
+       window.localStorage.removeItem("ProxmoxUser");
+    },
+
+    // The End-User gets redirected back here after login on the OpenID auth. portal, and in the
+    // redirection URL the state and auth.code are passed as URL GET params, this helper parses those
+    getOpenIDRedirectionAuthorization: function() {
+       const auth = Ext.Object.fromQueryString(window.location.search);
+       if (auth.state !== undefined && auth.code !== undefined) {
+           return auth;
+       }
+       return undefined;
     },
 
     // comp.setLoading() is buggy in ExtJS 4.0.7, so we
@@ -304,7 +351,7 @@ utilities: {
        return msg.join('<br>');
     },
 
-    monStoreErrors: function(component, store, clearMaskBeforeLoad) {
+    monStoreErrors: function(component, store, clearMaskBeforeLoad, errorCallback) {
        if (clearMaskBeforeLoad) {
            component.mon(store, 'beforeload', function(s, operation, eOpts) {
                Proxmox.Utils.setErrorMask(component, false);
@@ -329,7 +376,9 @@ utilities: {
 
            let error = request._operation.getError();
            let msg = Proxmox.Utils.getResponseErrorMessage(error);
-           Proxmox.Utils.setErrorMask(component, msg);
+           if (!errorCallback || !errorCallback(error, msg)) {
+               Proxmox.Utils.setErrorMask(component, msg);
+           }
        });
     },
 
@@ -431,33 +480,47 @@ utilities: {
        Ext.Ajax.request(newopts);
     },
 
+    // can be useful for catching displaying errors from the API, e.g.:
+    // Proxmox.Async.api2({
+    //     ...
+    // }).catch(Proxmox.Utils.alertResponseFailure);
+    alertResponseFailure: (response) => {
+       Ext.Msg.alert(
+           gettext('Error'),
+           response.htmlStatus || response.result.message,
+       );
+    },
+
     checked_command: function(orig_cmd) {
-       Proxmox.Utils.API2Request({
-           url: '/nodes/localhost/subscription',
-           method: 'GET',
-           failure: function(response, opts) {
-               Ext.Msg.alert(gettext('Error'), response.htmlStatus);
-           },
-           success: function(response, opts) {
-               let data = response.result.data;
-               if (data.status !== 'Active') {
-                   Ext.Msg.show({
-                       title: gettext('No valid subscription'),
-                       icon: Ext.Msg.WARNING,
-                       message: Proxmox.Utils.getNoSubKeyHtml(data.url),
-                       buttons: Ext.Msg.OK,
-                       callback: function(btn) {
-                           if (btn !== 'ok') {
-                               return;
-                           }
-                           orig_cmd();
-                       },
-                   });
-               } else {
-                   orig_cmd();
-               }
+       Proxmox.Utils.API2Request(
+           {
+               url: '/nodes/localhost/subscription',
+               method: 'GET',
+               failure: function(response, opts) {
+                   Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+               },
+               success: function(response, opts) {
+                   let res = response.result;
+                   if (res === null || res === undefined || !res || res
+                       .data.status.toLowerCase() !== 'active') {
+                       Ext.Msg.show({
+                           title: gettext('No valid subscription'),
+                           icon: Ext.Msg.WARNING,
+                           message: Proxmox.Utils.getNoSubKeyHtml(res.data.url),
+                           buttons: Ext.Msg.OK,
+                           callback: function(btn) {
+                               if (btn !== 'ok') {
+                                   return;
+                               }
+                               orig_cmd();
+                           },
+                       });
+                   } else {
+                       orig_cmd();
+                   }
+               },
            },
-       });
+       );
     },
 
     assemble_field_data: function(values, data) {
@@ -481,7 +544,7 @@ utilities: {
        });
     },
 
-    updateColumnWidth: function(container) {
+    updateColumnWidth: function(container, thresholdWidth) {
        let mode = Ext.state.Manager.get('summarycolumns') || 'auto';
        let factor;
        if (mode !== 'auto') {
@@ -490,7 +553,8 @@ utilities: {
                factor = 1;
            }
        } else {
-           factor = container.getSize().width < 1600 ? 1 : 2;
+           thresholdWidth = (thresholdWidth || 1400) + 1;
+           factor = Math.ceil(container.getSize().width / thresholdWidth);
        }
 
        if (container.oldFactor === factor) {
@@ -511,6 +575,9 @@ utilities: {
        container.updateLayout();
     },
 
+    // NOTE: depreacated, use updateColumnWidth
+    updateColumns: container => Proxmox.Utils.updateColumnWidth(container),
+
     dialog_title: function(subject, create, isAdd) {
        if (create) {
            if (isAdd) {
@@ -539,94 +606,17 @@ utilities: {
            Proxmox.Utils.unknownText;
     },
 
+    // NOTE: only add general, product agnostic, ones here! Else use override helper in product repos
     task_desc_table: {
-       acmenewcert: ['SRV', gettext('Order Certificate')],
-       acmeregister: ['ACME Account', gettext('Register')],
-       acmedeactivate: ['ACME Account', gettext('Deactivate')],
-       acmeupdate: ['ACME Account', gettext('Update')],
-       acmerefresh: ['ACME Account', gettext('Refresh')],
-       acmerenew: ['SRV', gettext('Renew Certificate')],
-       acmerevoke: ['SRV', gettext('Revoke Certificate')],
-       'auth-realm-sync': [gettext('Realm'), gettext('Sync')],
-       'auth-realm-sync-test': [gettext('Realm'), gettext('Sync Preview')],
-       'move_volume': ['CT', gettext('Move Volume')],
-       clustercreate: ['', gettext('Create Cluster')],
-       clusterjoin: ['', gettext('Join Cluster')],
+       aptupdate: ['', gettext('Update package database')],
        diskinit: ['Disk', gettext('Initialize Disk with GPT')],
-       vncproxy: ['VM/CT', gettext('Console')],
-       spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'],
-       vncshell: ['', gettext('Shell')],
        spiceshell: ['', gettext('Shell') + ' (Spice)'],
-       qmsnapshot: ['VM', gettext('Snapshot')],
-       qmrollback: ['VM', gettext('Rollback')],
-       qmdelsnapshot: ['VM', gettext('Delete Snapshot')],
-       qmcreate: ['VM', gettext('Create')],
-       qmrestore: ['VM', gettext('Restore')],
-       qmdestroy: ['VM', gettext('Destroy')],
-       qmigrate: ['VM', gettext('Migrate')],
-       qmclone: ['VM', gettext('Clone')],
-       qmmove: ['VM', gettext('Move disk')],
-       qmtemplate: ['VM', gettext('Convert to template')],
-       qmstart: ['VM', gettext('Start')],
-       qmstop: ['VM', gettext('Stop')],
-       qmreset: ['VM', gettext('Reset')],
-       qmshutdown: ['VM', gettext('Shutdown')],
-       qmreboot: ['VM', gettext('Reboot')],
-       qmsuspend: ['VM', gettext('Hibernate')],
-       qmpause: ['VM', gettext('Pause')],
-       qmresume: ['VM', gettext('Resume')],
-       qmconfig: ['VM', gettext('Configure')],
-       vzsnapshot: ['CT', gettext('Snapshot')],
-       vzrollback: ['CT', gettext('Rollback')],
-       vzdelsnapshot: ['CT', gettext('Delete Snapshot')],
-       vzcreate: ['CT', gettext('Create')],
-       vzrestore: ['CT', gettext('Restore')],
-       vzdestroy: ['CT', gettext('Destroy')],
-       vzmigrate: ['CT', gettext('Migrate')],
-       vzclone: ['CT', gettext('Clone')],
-       vztemplate: ['CT', gettext('Convert to template')],
-       vzstart: ['CT', gettext('Start')],
-       vzstop: ['CT', gettext('Stop')],
-       vzmount: ['CT', gettext('Mount')],
-       vzumount: ['CT', gettext('Unmount')],
-       vzshutdown: ['CT', gettext('Shutdown')],
-       vzreboot: ['CT', gettext('Reboot')],
-       vzsuspend: ['CT', gettext('Suspend')],
-       vzresume: ['CT', gettext('Resume')],
-       push_file: ['CT', gettext('Push file')],
-       pull_file: ['CT', gettext('Pull file')],
-       hamigrate: ['HA', gettext('Migrate')],
-       hastart: ['HA', gettext('Start')],
-       hastop: ['HA', gettext('Stop')],
-       hashutdown: ['HA', gettext('Shutdown')],
+       srvreload: ['SRV', gettext('Reload')],
+       srvrestart: ['SRV', gettext('Restart')],
        srvstart: ['SRV', gettext('Start')],
        srvstop: ['SRV', gettext('Stop')],
-       srvrestart: ['SRV', gettext('Restart')],
-       srvreload: ['SRV', gettext('Reload')],
-       cephcreatemgr: ['Ceph Manager', gettext('Create')],
-       cephdestroymgr: ['Ceph Manager', gettext('Destroy')],
-       cephcreatemon: ['Ceph Monitor', gettext('Create')],
-       cephdestroymon: ['Ceph Monitor', gettext('Destroy')],
-       cephcreateosd: ['Ceph OSD', gettext('Create')],
-       cephdestroyosd: ['Ceph OSD', gettext('Destroy')],
-       cephcreatepool: ['Ceph Pool', gettext('Create')],
-       cephdestroypool: ['Ceph Pool', gettext('Destroy')],
-       cephfscreate: ['CephFS', gettext('Create')],
-       cephcreatemds: ['Ceph Metadata Server', gettext('Create')],
-       cephdestroymds: ['Ceph Metadata Server', gettext('Destroy')],
-       imgcopy: ['', gettext('Copy data')],
-       imgdel: ['', gettext('Erase data')],
-       unknownimgdel: ['', gettext('Destroy image from unknown guest')],
-       download: ['', gettext('Download')],
-       vzdump: (type, id) => id ? `VM/CT ${id} - ${gettext('Backup')}` : gettext('Backup Job'),
-       aptupdate: ['', gettext('Update package database')],
-       startall: ['', gettext('Start all VMs and Containers')],
-       stopall: ['', gettext('Stop all VMs and Containers')],
-       migrateall: ['', gettext('Migrate all VMs and Containers')],
-       dircreate: [gettext('Directory Storage'), gettext('Create')],
-       lvmcreate: [gettext('LVM Storage'), gettext('Create')],
-       lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')],
-       zfscreate: [gettext('ZFS Storage'), gettext('Create')],
+       termproxy: ['', gettext('Console') + ' (xterm.js)'],
+       vncshell: ['', gettext('Shell')],
     },
 
     // to add or change existing for product specific ones
@@ -656,14 +646,22 @@ utilities: {
        return text;
     },
 
-    format_size: function(size) {
-       let units = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'];
-       let num = 0;
-       while (size >= 1024 && num++ <= units.length) {
-           size = size / 1024;
+    format_size: function(size, useSI) {
+       let units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
+       let order = 0;
+       const baseValue = useSI ? 1000 : 1024;
+       while (size >= baseValue && order < units.length) {
+           size = size / baseValue;
+           order++;
        }
 
-       return size.toFixed(num > 0?2:0) + " " + units[num] + "B";
+       let unit = units[order], commaDigits = 2;
+       if (order === 0) {
+           commaDigits = 0;
+       } else if (!useSI) {
+           unit += 'i';
+       }
+       return `${size.toFixed(commaDigits)} ${unit}B`;
     },
 
     render_upid: function(value, metaData, record) {
@@ -688,6 +686,65 @@ utilities: {
        return Proxmox.Utils.format_duration_long(uptime);
     },
 
+    systemd_unescape: function(string_value) {
+       const charcode_0 = '0'.charCodeAt(0);
+       const charcode_9 = '9'.charCodeAt(0);
+       const charcode_A = 'A'.charCodeAt(0);
+       const charcode_F = 'F'.charCodeAt(0);
+       const charcode_a = 'a'.charCodeAt(0);
+       const charcode_f = 'f'.charCodeAt(0);
+       const charcode_x = 'x'.charCodeAt(0);
+       const charcode_minus = '-'.charCodeAt(0);
+       const charcode_slash = '/'.charCodeAt(0);
+       const charcode_backslash = '\\'.charCodeAt(0);
+
+       let parse_hex_digit = function(d) {
+           if (d >= charcode_0 && d <= charcode_9) {
+               return d - charcode_0;
+           }
+           if (d >= charcode_A && d <= charcode_F) {
+               return d - charcode_A + 10;
+           }
+           if (d >= charcode_a && d <= charcode_f) {
+               return d - charcode_a + 10;
+           }
+           throw "got invalid hex digit";
+       };
+
+       let value = new TextEncoder().encode(string_value);
+       let result = new Uint8Array(value.length);
+
+       let i = 0;
+       let result_len = 0;
+
+       while (i < value.length) {
+           let c0 = value[i];
+           if (c0 === charcode_minus) {
+               result.set([charcode_slash], result_len);
+               result_len += 1;
+               i += 1;
+               continue;
+           }
+           if ((i + 4) < value.length) {
+               let c1 = value[i+1];
+               if (c0 === charcode_backslash && c1 === charcode_x) {
+                   let h1 = parse_hex_digit(value[i+2]);
+                   let h0 = parse_hex_digit(value[i+3]);
+                   let ord = h1*16+h0;
+                   result.set([ord], result_len);
+                   result_len += 1;
+                   i += 4;
+                   continue;
+               }
+           }
+           result.set([c0], result_len);
+           result_len += 1;
+           i += 1;
+       }
+
+       return new TextDecoder().decode(result.slice(0, result.len));
+    },
+
     parse_task_upid: function(upid) {
        let task = {};
 
@@ -703,7 +760,7 @@ utilities: {
        }
        task.starttime = parseInt(res[6], 16);
        task.type = res[7];
-       task.id = res[8];
+       task.id = Proxmox.Utils.systemd_unescape(res[8]);
        task.user = res[9];
 
        task.desc = Proxmox.Utils.format_task_description(task.type, task.id);
@@ -728,6 +785,17 @@ utilities: {
        return 'error';
     },
 
+    format_task_status: function(status) {
+       let parsed = Proxmox.Utils.parse_task_status(status);
+       switch (parsed) {
+           case 'unknown': return Proxmox.Utils.unknownText;
+           case 'error': return Proxmox.Utils.errorText + ': ' + status;
+           case 'warning': return status.replace('WARNINGS', Proxmox.Utils.warningsText);
+           case 'ok': // fall-through
+           default: return status;
+       }
+    },
+
     render_duration: function(value) {
        if (value === undefined) {
            return '-';
@@ -740,6 +808,31 @@ utilities: {
        return Ext.Date.format(servertime, 'Y-m-d H:i:s');
     },
 
+    render_zfs_health: function(value) {
+       if (typeof value === 'undefined') {
+           return "";
+       }
+       var iconCls = 'question-circle';
+       switch (value) {
+           case 'AVAIL':
+           case 'ONLINE':
+               iconCls = 'check-circle good';
+               break;
+           case 'REMOVED':
+           case 'DEGRADED':
+               iconCls = 'exclamation-circle warning';
+               break;
+           case 'UNAVAIL':
+           case 'FAULTED':
+           case 'OFFLINE':
+               iconCls = 'times-circle critical';
+               break;
+           default: //unknown
+       }
+
+       return '<i class="fa fa-' + iconCls + '"></i> ' + value;
+    },
+
     get_help_info: function(section) {
        let helpMap;
        if (typeof proxmoxOnlineHelpInfo !== 'undefined') {
@@ -751,7 +844,17 @@ utilities: {
            throw "no global OnlineHelpInfo map declared";
        }
 
-       return helpMap[section];
+       if (helpMap[section]) {
+           return helpMap[section];
+       }
+       // try to normalize - and _ separators, to support asciidoc and sphinx
+       // references at the same time.
+       let section_minus_normalized = section.replace(/_/g, '-');
+       if (helpMap[section_minus_normalized]) {
+           return helpMap[section_minus_normalized];
+       }
+       let section_underscore_normalized = section.replace(/-/g, '_');
+       return helpMap[section_underscore_normalized];
     },
 
     get_help_link: function(section) {
@@ -778,6 +881,300 @@ utilities: {
        }
     },
 
+    render_optional_url: function(value) {
+       if (value && value.match(/^https?:\/\//) !== null) {
+           return '<a target="_blank" href="' + value + '">' + value + '</a>';
+       }
+       return value;
+    },
+
+    render_san: function(value) {
+       var names = [];
+       if (Ext.isArray(value)) {
+           value.forEach(function(val) {
+               if (!Ext.isNumber(val)) {
+                   names.push(val);
+               }
+           });
+           return names.join('<br>');
+       }
+       return value;
+    },
+
+    render_usage: val => (val * 100).toFixed(2) + '%',
+
+    render_cpu_usage: function(val, max) {
+       return Ext.String.format(
+           `${gettext('{0}% of {1}')} ${gettext('CPU(s)')}`,
+           (val*100).toFixed(2),
+           max,
+       );
+    },
+
+    render_size_usage: function(val, max, useSI) {
+       if (max === 0) {
+           return gettext('N/A');
+       }
+       let fmt = v => Proxmox.Utils.format_size(v, useSI);
+       let ratio = (val * 100 / max).toFixed(2);
+       return ratio + '% (' + Ext.String.format(gettext('{0} of {1}'), fmt(val), fmt(max)) + ')';
+    },
+
+    render_cpu: function(value, metaData, record, rowIndex, colIndex, store) {
+       if (!(record.data.uptime && Ext.isNumeric(value))) {
+           return '';
+       }
+
+       let maxcpu = record.data.maxcpu || 1;
+       if (!Ext.isNumeric(maxcpu) || maxcpu < 1) {
+           return '';
+       }
+       let cpuText = maxcpu > 1 ? 'CPUs' : 'CPU';
+       let ratio = (value * 100).toFixed(1);
+       return `${ratio}% of ${maxcpu.toString()} ${cpuText}`;
+    },
+
+    render_size: function(value, metaData, record, rowIndex, colIndex, store) {
+       if (!Ext.isNumeric(value)) {
+           return '';
+       }
+       return Proxmox.Utils.format_size(value);
+    },
+
+    render_cpu_model: function(cpu) {
+       let socketText = cpu.sockets > 1 ? gettext('Sockets') : gettext('Socket');
+       return `${cpu.cpus} x ${cpu.model} (${cpu.sockets.toString()} ${socketText})`;
+    },
+
+    /* this is different for nodes */
+    render_node_cpu_usage: function(value, record) {
+       return Proxmox.Utils.render_cpu_usage(value, record.cpus);
+    },
+
+    render_node_size_usage: function(record) {
+       return Proxmox.Utils.render_size_usage(record.used, record.total);
+    },
+
+    loadTextFromFile: function(file, callback, maxBytes) {
+       let maxSize = maxBytes || 8192;
+       if (file.size > maxSize) {
+           Ext.Msg.alert(gettext('Error'), gettext("Invalid file size: ") + file.size);
+           return;
+       }
+       let reader = new FileReader();
+       reader.onload = evt => callback(evt.target.result);
+       reader.readAsText(file);
+    },
+
+    parsePropertyString: function(value, defaultKey) {
+       var res = {},
+           error;
+
+       if (typeof value !== 'string' || value === '') {
+           return res;
+       }
+
+       Ext.Array.each(value.split(','), function(p) {
+           var kv = p.split('=', 2);
+           if (Ext.isDefined(kv[1])) {
+               res[kv[0]] = kv[1];
+           } else if (Ext.isDefined(defaultKey)) {
+               if (Ext.isDefined(res[defaultKey])) {
+                   error = 'defaultKey may be only defined once in propertyString';
+                   return false; // break
+               }
+               res[defaultKey] = kv[0];
+           } else {
+               error = 'invalid propertyString, not a key=value pair and no defaultKey defined';
+               return false; // break
+           }
+           return true;
+       });
+
+       if (error !== undefined) {
+           console.error(error);
+           return undefined;
+       }
+
+       return res;
+    },
+
+    printPropertyString: function(data, defaultKey) {
+       var stringparts = [],
+           gotDefaultKeyVal = false,
+           defaultKeyVal;
+
+       Ext.Object.each(data, function(key, value) {
+           if (defaultKey !== undefined && key === defaultKey) {
+               gotDefaultKeyVal = true;
+               defaultKeyVal = value;
+           } else if (Ext.isArray(value)) {
+               stringparts.push(key + '=' + value.join(';'));
+           } else if (value !== '') {
+               stringparts.push(key + '=' + value);
+           }
+       });
+
+       stringparts = stringparts.sort();
+       if (gotDefaultKeyVal) {
+           stringparts.unshift(defaultKeyVal);
+       }
+
+       return stringparts.join(',');
+    },
+
+    acmedomain_count: 5,
+
+    parseACMEPluginData: function(data) {
+       let res = {};
+       let extradata = [];
+       data.split('\n').forEach((line) => {
+           // capture everything after the first = as value
+           let [key, value] = line.split('=');
+           if (value !== undefined) {
+               res[key] = value;
+           } else {
+               extradata.push(line);
+           }
+       });
+       return [res, extradata];
+    },
+
+    delete_if_default: function(values, fieldname, default_val, create) {
+       if (values[fieldname] === '' || values[fieldname] === default_val) {
+           if (!create) {
+               if (values.delete) {
+                   if (Ext.isArray(values.delete)) {
+                       values.delete.push(fieldname);
+                   } else {
+                       values.delete += ',' + fieldname;
+                   }
+               } else {
+                   values.delete = fieldname;
+               }
+           }
+
+           delete values[fieldname];
+       }
+    },
+
+    printACME: function(value) {
+       if (Ext.isArray(value.domains)) {
+           value.domains = value.domains.join(';');
+       }
+       return Proxmox.Utils.printPropertyString(value);
+    },
+
+    parseACME: function(value) {
+       if (!value) {
+           return {};
+       }
+
+       var res = {};
+       var error;
+
+       Ext.Array.each(value.split(','), function(p) {
+           var kv = p.split('=', 2);
+           if (Ext.isDefined(kv[1])) {
+               res[kv[0]] = kv[1];
+           } else {
+               error = 'Failed to parse key-value pair: '+p;
+               return false;
+           }
+           return true;
+       });
+
+       if (error !== undefined) {
+           console.error(error);
+           return undefined;
+       }
+
+       if (res.domains !== undefined) {
+           res.domains = res.domains.split(/;/);
+       }
+
+       return res;
+    },
+
+    add_domain_to_acme: function(acme, domain) {
+       if (acme.domains === undefined) {
+           acme.domains = [domain];
+       } else {
+           acme.domains.push(domain);
+           acme.domains = acme.domains.filter((value, index, self) => self.indexOf(value) === index);
+       }
+       return acme;
+    },
+
+    remove_domain_from_acme: function(acme, domain) {
+       if (acme.domains !== undefined) {
+           acme.domains = acme.domains.filter(
+               (value, index, self) => self.indexOf(value) === index && value !== domain,
+           );
+       }
+       return acme;
+    },
+
+    get_health_icon: function(state, circle) {
+       if (circle === undefined) {
+           circle = false;
+       }
+
+       if (state === undefined) {
+           state = 'uknown';
+       }
+
+       var icon = 'faded fa-question';
+       switch (state) {
+           case 'good':
+               icon = 'good fa-check';
+               break;
+           case 'upgrade':
+               icon = 'warning fa-upload';
+               break;
+           case 'old':
+               icon = 'warning fa-refresh';
+               break;
+           case 'warning':
+               icon = 'warning fa-exclamation';
+               break;
+           case 'critical':
+               icon = 'critical fa-times';
+               break;
+           default: break;
+       }
+
+       if (circle) {
+           icon += '-circle';
+       }
+
+       return icon;
+    },
+
+    authSchema: {
+       pam: {
+           name: 'Linux PAM',
+           add: false,
+           edit: false,
+           pwchange: true,
+       },
+       openid: {
+           name: gettext('OpenID Connect Server'),
+           ipanel: 'pmxAuthOpenIDPanel',
+           add: true,
+           edit: true,
+           tfa: false,
+           pwchange: false,
+           iconCls: 'pmx-itype-icon-openid-logo',
+       },
+    },
+
+    // to add or change existing for product specific ones
+    overrideAuthSchema: function(extra) {
+       for (const [key, value] of Object.entries(extra)) {
+           Proxmox.Utils.authSchema[key] = value;
+       }
+    },
 },
 
     singleton: true,
@@ -817,13 +1214,33 @@ utilities: {
        me.IP64_match = new RegExp("^(?:" + IPV6_REGEXP + "|" + IPV4_REGEXP + ")$");
        me.IP64_cidr_match = new RegExp("^(?:" + IPV6_REGEXP + "/" + IPV6_CIDR_MASK + ")|(?:" + IPV4_REGEXP + "/" + IPV4_CIDR_MASK + ")$");
 
-       let DnsName_REGEXP = "(?:(([a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)\\.)*([A-Za-z0-9]([A-Za-z0-9\\-]*[A-Za-z0-9])?))";
+       let DnsName_REGEXP = "(?:(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9\\-]*[a-zA-Z0-9])?)\\.)*(?:[A-Za-z0-9](?:[A-Za-z0-9\\-]*[A-Za-z0-9])?))";
        me.DnsName_match = new RegExp("^" + DnsName_REGEXP + "$");
 
-       me.HostPort_match = new RegExp("^(" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")(:\\d+)?$");
-       me.HostPortBrackets_match = new RegExp("^\\[(?:" + IPV6_REGEXP + "|" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")\\](:\\d+)?$");
-       me.IP6_dotnotation_match = new RegExp("^" + IPV6_REGEXP + "(\\.\\d+)?$");
-       me.Vlan_match = /^vlan(\\d+)/;
-       me.VlanInterface_match = /(\\w+)\\.(\\d+)/;
+       me.HostPort_match = new RegExp("^(" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")(?::(\\d+))?$");
+       me.HostPortBrackets_match = new RegExp("^\\[(" + IPV6_REGEXP + "|" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")\\](?::(\\d+))?$");
+       me.IP6_dotnotation_match = new RegExp("^(" + IPV6_REGEXP + ")(?:\\.(\\d+))?$");
+       me.Vlan_match = /^vlan(\d+)/;
+       me.VlanInterface_match = /(\w+)\.(\d+)/;
+    },
+});
+
+Ext.define('Proxmox.Async', {
+    singleton: true,
+
+    // Returns a Promise resolving to the result of an `API2Request` or rejecting to the error
+    // repsonse on failure
+    api2: function(reqOpts) {
+       return new Promise((resolve, reject) => {
+           delete reqOpts.callback; // not allowed in this api
+           reqOpts.success = response => resolve(response);
+           reqOpts.failure = response => reject(response);
+           Proxmox.Utils.API2Request(reqOpts);
+       });
+    },
+
+    // Delay for a number of milliseconds.
+    sleep: function(millis) {
+       return new Promise((resolve, _reject) => setTimeout(resolve, millis));
     },
 });