commit 151153922c036328809876d914231962fe6bbc70
Author: Tsanie <tsorgy@gmail.com>
Date:   Wed Jun 26 14:49:30 2024 +0800

    initial commit

diff --git a/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js b/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
new file mode 100644
index 0000000..84abcb0
--- /dev/null
+++ b/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
@@ -0,0 +1,23690 @@
+// v4.2.3-t1714038312
+Ext.ns('Proxmox');
+Ext.ns('Proxmox.Setup');
+
+if (!Ext.isDefined(Proxmox.Setup.auth_cookie_name)) {
+    throw "Proxmox library not initialized";
+}
+
+// avoid errors when running without development tools
+if (!Ext.isDefined(Ext.global.console)) {
+    let console = {
+	dir: function() {
+	    // do nothing
+	},
+	log: function() {
+	    // do nothing
+	},
+	warn: function() {
+	    // do nothing
+	},
+    };
+    Ext.global.console = console;
+}
+
+Ext.Ajax.defaultHeaders = {
+    'Accept': 'application/json',
+};
+
+Ext.Ajax.on('beforerequest', function(conn, options) {
+    if (Proxmox.CSRFPreventionToken) {
+	if (!options.headers) {
+	    options.headers = {};
+	}
+	options.headers.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
+    }
+    let storedAuth = Proxmox.Utils.getStoredAuth();
+    if (storedAuth.token) {
+	options.headers.Authorization = storedAuth.token;
+    }
+});
+
+Ext.define('Proxmox.Utils', { // a singleton
+utilities: {
+
+    yesText: gettext('Yes'),
+    noText: gettext('No'),
+    enabledText: gettext('Enabled'),
+    disabledText: gettext('Disabled'),
+    noneText: gettext('none'),
+    NoneText: gettext('None'),
+    errorText: gettext('Error'),
+    warningsText: gettext('Warnings'),
+    unknownText: gettext('Unknown'),
+    defaultText: gettext('Default'),
+    daysText: gettext('days'),
+    dayText: gettext('day'),
+    runningText: gettext('running'),
+    stoppedText: gettext('stopped'),
+    neverText: gettext('never'),
+    totalText: gettext('Total'),
+    usedText: gettext('Used'),
+    directoryText: gettext('Directory'),
+    stateText: gettext('State'),
+    groupText: gettext('Group'),
+
+    language_map: { //language map is sorted alphabetically by iso 639-1
+	ar: `العربية - ${gettext("Arabic")}`,
+	ca: `Català - ${gettext("Catalan")}`,
+	da: `Dansk - ${gettext("Danish")}`,
+	de: `Deutsch - ${gettext("German")}`,
+	en: `English - ${gettext("English")}`,
+	es: `Español - ${gettext("Spanish")}`,
+	eu: `Euskera (Basque) - ${gettext("Euskera (Basque)")}`,
+	fa: `فارسی - ${gettext("Persian (Farsi)")}`,
+	fr: `Français - ${gettext("French")}`,
+	hr: `Hrvatski - ${gettext("Croatian")}`,
+	he: `עברית - ${gettext("Hebrew")}`,
+	it: `Italiano - ${gettext("Italian")}`,
+	ja: `日本語 - ${gettext("Japanese")}`,
+	ka: `ქართული - ${gettext("Georgian")}`,
+	ko: `한국어 - ${gettext("Korean")}`,
+	nb: `Bokmål - ${gettext("Norwegian (Bokmal)")}`,
+	nl: `Nederlands - ${gettext("Dutch")}`,
+	nn: `Nynorsk - ${gettext("Norwegian (Nynorsk)")}`,
+	pl: `Polski - ${gettext("Polish")}`,
+	pt_BR: `Português Brasileiro - ${gettext("Portuguese (Brazil)")}`,
+	ru: `Русский - ${gettext("Russian")}`,
+	sl: `Slovenščina - ${gettext("Slovenian")}`,
+	sv: `Svenska - ${gettext("Swedish")}`,
+	tr: `Türkçe - ${gettext("Turkish")}`,
+	ukr: `Українська - ${gettext("Ukrainian")}`,
+	zh_CN: `中文(简体)- ${gettext("Chinese (Simplified)")}`,
+	zh_TW: `中文(繁體)- ${gettext("Chinese (Traditional)")}`,
+    },
+
+    render_language: function(value) {
+	if (!value || value === '__default__') {
+	    return Proxmox.Utils.defaultText + ' (English)';
+	}
+	if (value === 'kr') {
+	    value = 'ko'; // fix-up wrongly used Korean code. FIXME: remove with trixie releases
+	}
+	let text = Proxmox.Utils.language_map[value];
+	if (text) {
+	    return text + ' (' + value + ')';
+	}
+	return value;
+    },
+
+    renderEnabledIcon: enabled => `<i class="fa fa-${enabled ? 'check' : 'minus'}"></i>`,
+
+    language_array: function() {
+	let data = [['__default__', Proxmox.Utils.render_language('')]];
+	Ext.Object.each(Proxmox.Utils.language_map, function(key, value) {
+	    data.push([key, Proxmox.Utils.render_language(value)]);
+	});
+
+	return data;
+    },
+
+    theme_map: {
+	crisp: 'Light theme',
+	"proxmox-dark": 'Proxmox Dark',
+    },
+
+    render_theme: function(value) {
+	if (!value || value === '__default__') {
+	    return Proxmox.Utils.defaultText + ' (auto)';
+	}
+	let text = Proxmox.Utils.theme_map[value];
+	if (text) {
+	    return text;
+	}
+	return value;
+    },
+
+    theme_array: function() {
+	let data = [['__default__', Proxmox.Utils.render_theme('')]];
+	Ext.Object.each(Proxmox.Utils.theme_map, function(key, value) {
+	    data.push([key, Proxmox.Utils.render_theme(value)]);
+	});
+
+	return data;
+    },
+
+    bond_mode_gettext_map: {
+	'802.3ad': 'LACP (802.3ad)',
+	'lacp-balance-slb': 'LACP (balance-slb)',
+	'lacp-balance-tcp': 'LACP (balance-tcp)',
+    },
+
+    render_bond_mode: value => Proxmox.Utils.bond_mode_gettext_map[value] || value || '',
+
+    bond_mode_array: function(modes) {
+	return modes.map(mode => [mode, Proxmox.Utils.render_bond_mode(mode)]);
+    },
+
+    getNoSubKeyHtml: function(url) {
+	let html_url = Ext.String.format('<a target="_blank" href="{0}">www.proxmox.com</a>', url || 'https://www.proxmox.com');
+	return Ext.String.format(
+	    gettext('You do not have a valid subscription for this server. Please visit {0} to get a list of available options.'),
+	    html_url,
+	);
+    },
+
+    format_boolean_with_default: function(value) {
+	if (Ext.isDefined(value) && value !== '__default__') {
+	    return value ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
+	}
+	return Proxmox.Utils.defaultText;
+    },
+
+    format_boolean: function(value) {
+	return value ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
+    },
+
+    format_neg_boolean: function(value) {
+	return !value ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
+    },
+
+    format_enabled_toggle: function(value) {
+	return value ? Proxmox.Utils.enabledText : Proxmox.Utils.disabledText;
+    },
+
+    format_expire: function(date) {
+	if (!date) {
+	    return Proxmox.Utils.neverText;
+	}
+	return Ext.Date.format(date, "Y-m-d");
+    },
+
+    // somewhat like a human would tell durations, omit zero values and do not
+    // give seconds precision if we talk days already
+    format_duration_human: function(ut) {
+	let seconds = 0, minutes = 0, hours = 0, days = 0, years = 0;
+
+	if (ut <= 0.1) {
+	    return '<0.1s';
+	}
+
+	let remaining = ut;
+	seconds = Number((remaining % 60).toFixed(1));
+	remaining = Math.trunc(remaining / 60);
+	if (remaining > 0) {
+	    minutes = remaining % 60;
+	    remaining = Math.trunc(remaining / 60);
+	    if (remaining > 0) {
+		hours = remaining % 24;
+		remaining = Math.trunc(remaining / 24);
+		if (remaining > 0) {
+		    days = remaining % 365;
+		    remaining = Math.trunc(remaining / 365); // yea, just lets ignore leap years...
+		    if (remaining > 0) {
+			years = remaining;
+		    }
+		}
+	    }
+	}
+
+	let res = [];
+	let add = (t, unit) => {
+	    if (t > 0) res.push(t + unit);
+	    return t > 0;
+	};
+
+	let addMinutes = !add(years, 'y');
+	let addSeconds = !add(days, 'd');
+	add(hours, 'h');
+	if (addMinutes) {
+	    add(minutes, 'm');
+	    if (addSeconds) {
+		add(seconds, 's');
+	    }
+	}
+	return res.join(' ');
+    },
+
+    format_duration_long: function(ut) {
+	let days = Math.floor(ut / 86400);
+	ut -= days*86400;
+	let hours = Math.floor(ut / 3600);
+	ut -= hours*3600;
+	let mins = Math.floor(ut / 60);
+	ut -= mins*60;
+
+	let hours_str = '00' + hours.toString();
+	hours_str = hours_str.substr(hours_str.length - 2);
+	let mins_str = "00" + mins.toString();
+	mins_str = mins_str.substr(mins_str.length - 2);
+	let ut_str = "00" + ut.toString();
+	ut_str = ut_str.substr(ut_str.length - 2);
+
+	if (days) {
+	    let ds = days > 1 ? Proxmox.Utils.daysText : Proxmox.Utils.dayText;
+	    return days.toString() + ' ' + ds + ' ' +
+		hours_str + ':' + mins_str + ':' + ut_str;
+	} else {
+	    return hours_str + ':' + mins_str + ':' + ut_str;
+	}
+    },
+
+    format_subscription_level: function(level) {
+	if (level === 'c') {
+	    return 'Community';
+	} else if (level === 'b') {
+	    return 'Basic';
+	} else if (level === 's') {
+	    return 'Standard';
+	} else if (level === 'p') {
+	    return 'Premium';
+	} else {
+	    return Proxmox.Utils.noneText;
+	}
+    },
+
+    compute_min_label_width: function(text, width) {
+	if (width === undefined) { width = 100; }
+
+	let tm = new Ext.util.TextMetrics();
+	let min = tm.getWidth(text + ':');
+
+	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.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
+	if (data.ticket) {
+	    Proxmox.CSRFPreventionToken = data.CSRFPreventionToken;
+	    Ext.util.Cookies.set(Proxmox.Setup.auth_cookie_name, data.ticket, null, '/', null, true, "strict");
+	}
+
+	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!")) || storedAuth.token) {
+	    return cookie || storedAuth.token;
+	} else {
+	    return false;
+	}
+    },
+
+    authClear: function() {
+	if (Proxmox.LoggedOut) {
+	    return;
+	}
+	// ExtJS clear is basically the same, but browser may complain if any cookie isn't "secure"
+	Ext.util.Cookies.set(Proxmox.Setup.auth_cookie_name, "", new Date(0), null, null, true, "strict");
+	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
+    // use el.mask() instead
+    setErrorMask: function(comp, msg) {
+	let el = comp.el;
+	if (!el) {
+	    return;
+	}
+	if (!msg) {
+	    el.unmask();
+	} else if (msg === true) {
+	    el.mask(gettext("Loading..."));
+	} else {
+	    el.mask(msg);
+	}
+    },
+
+    getResponseErrorMessage: (err) => {
+	if (!err.statusText) {
+	    return gettext('Connection error');
+	}
+	let msg = [`${err.statusText} (${err.status})`];
+	if (err.response && err.response.responseText) {
+	    let txt = err.response.responseText;
+	    try {
+		let res = JSON.parse(txt);
+		if (res.errors && typeof res.errors === 'object') {
+		    for (let [key, value] of Object.entries(res.errors)) {
+			msg.push(Ext.String.htmlEncode(`${key}: ${value}`));
+		    }
+		}
+	    } catch (e) {
+		// fallback to string
+		msg.push(Ext.String.htmlEncode(txt));
+	    }
+	}
+	return msg.join('<br>');
+    },
+
+    monStoreErrors: function(component, store, clearMaskBeforeLoad, errorCallback) {
+	if (clearMaskBeforeLoad) {
+	    component.mon(store, 'beforeload', function(s, operation, eOpts) {
+		Proxmox.Utils.setErrorMask(component, false);
+	    });
+	} else {
+	    component.mon(store, 'beforeload', function(s, operation, eOpts) {
+		if (!component.loadCount) {
+		    component.loadCount = 0; // make sure it is nucomponent.ic
+		    Proxmox.Utils.setErrorMask(component, true);
+		}
+	    });
+	}
+
+	// only works with 'proxmox' proxy
+	component.mon(store.proxy, 'afterload', function(proxy, request, success) {
+	    component.loadCount++;
+
+	    if (success) {
+		Proxmox.Utils.setErrorMask(component, false);
+		return;
+	    }
+
+	    let error = request._operation.getError();
+	    let msg = Proxmox.Utils.getResponseErrorMessage(error);
+	    if (!errorCallback || !errorCallback(error, msg)) {
+		Proxmox.Utils.setErrorMask(component, msg);
+	    }
+	});
+    },
+
+    extractRequestError: function(result, verbose) {
+	let msg = gettext('Successful');
+
+	if (!result.success) {
+	    msg = gettext("Unknown error");
+	    if (result.message) {
+		msg = Ext.htmlEncode(result.message);
+		if (result.status) {
+		    msg += ` (${result.status})`;
+		}
+	    }
+	    if (verbose && Ext.isObject(result.errors)) {
+		msg += "<br>";
+		Ext.Object.each(result.errors, (prop, desc) => {
+		    msg += `<br><b>${Ext.htmlEncode(prop)}</b>: ${Ext.htmlEncode(desc)}`;
+		});
+	    }
+	}
+
+	return msg;
+    },
+
+    // Ext.Ajax.request
+    API2Request: function(reqOpts) {
+	let newopts = Ext.apply({
+	    waitMsg: gettext('Please wait...'),
+	}, reqOpts);
+
+	// default to enable if user isn't handling the failure already explicitly
+	let autoErrorAlert = reqOpts.autoErrorAlert ??
+	    (typeof reqOpts.failure !== 'function' && typeof reqOpts.callback !== 'function');
+
+	if (!newopts.url.match(/^\/api2/)) {
+	    newopts.url = '/api2/extjs' + newopts.url;
+	}
+	delete newopts.callback;
+	let unmask = (target) => {
+	    if (target.waitMsgTargetCount === undefined || --target.waitMsgTargetCount <= 0) {
+		target.setLoading(false);
+		delete target.waitMsgTargetCount;
+	    }
+	};
+
+	let createWrapper = function(successFn, callbackFn, failureFn) {
+	    Ext.apply(newopts, {
+		success: function(response, options) {
+		    if (options.waitMsgTarget) {
+			if (Proxmox.Utils.toolkit === 'touch') {
+			    options.waitMsgTarget.setMasked(false);
+			} else {
+			    unmask(options.waitMsgTarget);
+			}
+		    }
+		    let result = Ext.decode(response.responseText);
+		    response.result = result;
+		    if (!result.success) {
+			response.htmlStatus = Proxmox.Utils.extractRequestError(result, true);
+			Ext.callback(callbackFn, options.scope, [options, false, response]);
+			Ext.callback(failureFn, options.scope, [response, options]);
+			if (autoErrorAlert) {
+			    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			}
+			return;
+		    }
+		    Ext.callback(callbackFn, options.scope, [options, true, response]);
+		    Ext.callback(successFn, options.scope, [response, options]);
+		},
+		failure: function(response, options) {
+		    if (options.waitMsgTarget) {
+			if (Proxmox.Utils.toolkit === 'touch') {
+			    options.waitMsgTarget.setMasked(false);
+			} else {
+			    unmask(options.waitMsgTarget);
+			}
+		    }
+		    response.result = {};
+		    try {
+			response.result = Ext.decode(response.responseText);
+		    } catch (e) {
+			// ignore
+		    }
+		    let msg = gettext('Connection error') + ' - server offline?';
+		    if (response.aborted) {
+			msg = gettext('Connection error') + ' - aborted.';
+		    } else if (response.timedout) {
+			msg = gettext('Connection error') + ' - Timeout.';
+		    } else if (response.status && response.statusText) {
+			msg = gettext('Connection error') + ' ' + response.status + ': ' + response.statusText;
+		    }
+		    response.htmlStatus = msg;
+		    Ext.callback(callbackFn, options.scope, [options, false, response]);
+		    Ext.callback(failureFn, options.scope, [response, options]);
+		},
+	    });
+	};
+
+	createWrapper(reqOpts.success, reqOpts.callback, reqOpts.failure);
+
+	let target = newopts.waitMsgTarget;
+	if (target) {
+	    if (Proxmox.Utils.toolkit === 'touch') {
+		target.setMasked({ xtype: 'loadmask', message: newopts.waitMsg });
+	    } else if (target.rendered) {
+		target.waitMsgTargetCount = (target.waitMsgTargetCount ?? 0) + 1;
+		target.setLoading(newopts.waitMsg);
+	    } else {
+		target.waitMsgTargetCount = (target.waitMsgTargetCount ?? 0) + 1;
+		target.on('afterlayout', function() {
+		    if ((target.waitMsgTargetCount ?? 0) > 0) {
+			target.setLoading(newopts.waitMsg);
+		    }
+		}, target, { single: true });
+	    }
+	}
+	Ext.Ajax.request(newopts);
+    },
+
+    // can be useful for catching displaying errors from the API, e.g.:
+    // Proxmox.Async.api2({
+    //     ...
+    // }).catch(Proxmox.Utils.alertResponseFailure);
+    alertResponseFailure: res => Ext.Msg.alert(gettext('Error'), res.htmlStatus || res.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 res = response.result;
+		    if (res === null || res === undefined || !res || res
+			.data.status.toLowerCase() !== 'active') {
+			void({ //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) {
+	if (!Ext.isObject(data)) {
+	    return;
+	}
+	Ext.Object.each(data, function(name, val) {
+	    if (Object.prototype.hasOwnProperty.call(values, name)) {
+		let bucket = values[name];
+		if (!Ext.isArray(bucket)) {
+		    bucket = values[name] = [bucket];
+		}
+		if (Ext.isArray(val)) {
+		    values[name] = bucket.concat(val);
+		} else {
+		    bucket.push(val);
+		}
+	    } else {
+		values[name] = val;
+	    }
+	});
+    },
+
+    updateColumnWidth: function(container, thresholdWidth) {
+	let mode = Ext.state.Manager.get('summarycolumns') || 'auto';
+	let factor;
+	if (mode !== 'auto') {
+	    factor = parseInt(mode, 10);
+	    if (Number.isNaN(factor)) {
+		factor = 1;
+	    }
+	} else {
+	    thresholdWidth = (thresholdWidth || 1400) + 1;
+	    factor = Math.ceil(container.getSize().width / thresholdWidth);
+	}
+
+	if (container.oldFactor === factor) {
+	    return;
+	}
+
+	let items = container.query('>'); // direct children
+	factor = Math.min(factor, items.length);
+	container.oldFactor = factor;
+
+	items.forEach((item) => {
+	    item.columnWidth = 1 / factor;
+	});
+
+	// we have to update the layout twice, since the first layout change
+	// can trigger the scrollbar which reduces the amount of space left
+	container.updateLayout();
+	container.updateLayout();
+    },
+
+    // NOTE: depreacated, use updateColumnWidth
+    updateColumns: container => Proxmox.Utils.updateColumnWidth(container),
+
+    dialog_title: function(subject, create, isAdd) {
+	if (create) {
+	    if (isAdd) {
+		return gettext('Add') + ': ' + subject;
+	    } else {
+		return gettext('Create') + ': ' + subject;
+	    }
+	} else {
+	    return gettext('Edit') + ': ' + subject;
+	}
+    },
+
+    network_iface_types: {
+	eth: gettext("Network Device"),
+	bridge: 'Linux Bridge',
+	bond: 'Linux Bond',
+	vlan: 'Linux VLAN',
+	OVSBridge: 'OVS Bridge',
+	OVSBond: 'OVS Bond',
+	OVSPort: 'OVS Port',
+	OVSIntPort: 'OVS IntPort',
+    },
+
+    render_network_iface_type: function(value) {
+	return Proxmox.Utils.network_iface_types[value] ||
+	    Proxmox.Utils.unknownText;
+    },
+
+    // Only add product-agnostic fields here!
+    notificationFieldName: {
+	'type': gettext('Notification type'),
+	'hostname': gettext('Hostname'),
+    },
+
+    formatNotificationFieldName: (value) =>
+	Proxmox.Utils.notificationFieldName[value] || value,
+
+    // to add or change existing for product specific ones
+    overrideNotificationFieldName: function(extra) {
+	for (const [key, value] of Object.entries(extra)) {
+	    Proxmox.Utils.notificationFieldName[key] = value;
+	}
+    },
+
+    // Only add product-agnostic fields here!
+    notificationFieldValue: {
+	'system-mail': gettext('Forwarded mails to the local root user'),
+    },
+
+    formatNotificationFieldValue: (value) =>
+	Proxmox.Utils.notificationFieldValue[value] || value,
+
+    // to add or change existing for product specific ones
+    overrideNotificationFieldValue: function(extra) {
+	for (const [key, value] of Object.entries(extra)) {
+	    Proxmox.Utils.notificationFieldValue[key] = value;
+	}
+    },
+
+    // NOTE: only add general, product agnostic, ones here! Else use override helper in product repos
+    task_desc_table: {
+	aptupdate: ['', gettext('Update package database')],
+	diskinit: ['Disk', gettext('Initialize Disk with GPT')],
+	spiceshell: ['', gettext('Shell') + ' (Spice)'],
+	srvreload: ['SRV', gettext('Reload')],
+	srvrestart: ['SRV', gettext('Restart')],
+	srvstart: ['SRV', gettext('Start')],
+	srvstop: ['SRV', gettext('Stop')],
+	termproxy: ['', gettext('Console') + ' (xterm.js)'],
+	vncshell: ['', gettext('Shell')],
+    },
+
+    // to add or change existing for product specific ones
+    override_task_descriptions: function(extra) {
+	for (const [key, value] of Object.entries(extra)) {
+	    Proxmox.Utils.task_desc_table[key] = value;
+	}
+    },
+
+    format_task_description: function(type, id) {
+	let farray = Proxmox.Utils.task_desc_table[type];
+	let text;
+	if (!farray) {
+	    text = type;
+	    if (id) {
+		type += ' ' + id;
+	    }
+	    return text;
+	} else if (Ext.isFunction(farray)) {
+	    return farray(type, id);
+	}
+	let prefix = farray[0];
+	text = farray[1];
+	if (prefix && id !== undefined) {
+	    return prefix + ' ' + id + ' - ' + text;
+	}
+	return text;
+    },
+
+    format_size: function(size, useSI) {
+	let unitsSI = [gettext('B'), gettext('KB'), gettext('MB'), gettext('GB'),
+	    gettext('TB'), gettext('PB'), gettext('EB'), gettext('ZB'), gettext('YB')];
+	let unitsIEC = [gettext('B'), gettext('KiB'), gettext('MiB'), gettext('GiB'),
+	    gettext('TiB'), gettext('PiB'), gettext('EiB'), gettext('ZiB'), gettext('YiB')];
+	let order = 0;
+	let commaDigits = 2;
+	const baseValue = useSI ? 1000 : 1024;
+	while (size >= baseValue && order < unitsSI.length) {
+	    size = size / baseValue;
+	    order++;
+	}
+
+	let unit = useSI ? unitsSI[order] : unitsIEC[order];
+	if (order === 0) {
+	    commaDigits = 0;
+	}
+	return `${size.toFixed(commaDigits)} ${unit}`;
+    },
+
+    SizeUnits: {
+	'B': 1,
+
+	'KiB': 1024,
+	'MiB': 1024*1024,
+	'GiB': 1024*1024*1024,
+	'TiB': 1024*1024*1024*1024,
+	'PiB': 1024*1024*1024*1024*1024,
+
+	'KB': 1000,
+	'MB': 1000*1000,
+	'GB': 1000*1000*1000,
+	'TB': 1000*1000*1000*1000,
+	'PB': 1000*1000*1000*1000*1000,
+    },
+
+    parse_size_unit: function(val) {
+	//let m = val.match(/([.\d])+\s?([KMGTP]?)(i?)B?\s*$/i);
+	let m = val.match(/(\d+(?:\.\d+)?)\s?([KMGTP]?)(i?)B?\s*$/i);
+	let size = parseFloat(m[1]);
+	let scale = m[2].toUpperCase();
+	let binary = m[3].toLowerCase();
+
+	let unit = `${scale}${binary}B`;
+	let factor = Proxmox.Utils.SizeUnits[unit];
+
+	return { size, factor, unit, binary }; // for convenience return all we got
+    },
+
+    size_unit_to_bytes: function(val) {
+	let { size, factor } = Proxmox.Utils.parse_size_unit(val);
+	return size * factor;
+    },
+
+    autoscale_size_unit: function(val) {
+	let { size, factor, binary } = Proxmox.Utils.parse_size_unit(val);
+	return Proxmox.Utils.format_size(size * factor, binary !== "i");
+    },
+
+    size_unit_ratios: function(a, b) {
+	a = typeof a !== "undefined" ? a : 0;
+	b = typeof b !== "undefined" ? b : Infinity;
+	let aBytes = typeof a === "number" ? a : Proxmox.Utils.size_unit_to_bytes(a);
+	let bBytes = typeof b === "number" ? b : Proxmox.Utils.size_unit_to_bytes(b);
+	return aBytes / (bBytes || Infinity); // avoid division by zero
+    },
+
+    render_upid: function(value, metaData, record) {
+	let task = record.data;
+	let type = task.type || task.worker_type;
+	let id = task.id || task.worker_id;
+
+	return Proxmox.Utils.format_task_description(type, id);
+    },
+
+    render_uptime: function(value) {
+	let uptime = value;
+
+	if (uptime === undefined) {
+	    return '';
+	}
+
+	if (uptime <= 0) {
+	    return '-';
+	}
+
+	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 = {};
+
+	let res = upid.match(/^UPID:([^\s:]+):([0-9A-Fa-f]{8}):([0-9A-Fa-f]{8,9}):(([0-9A-Fa-f]{8,16}):)?([0-9A-Fa-f]{8}):([^:\s]+):([^:\s]*):([^:\s]+):$/);
+	if (!res) {
+	    throw "unable to parse upid '" + upid + "'";
+	}
+	task.node = res[1];
+	task.pid = parseInt(res[2], 16);
+	task.pstart = parseInt(res[3], 16);
+	if (res[5] !== undefined) {
+	    task.task_id = parseInt(res[5], 16);
+	}
+	task.starttime = parseInt(res[6], 16);
+	task.type = res[7];
+	task.id = Proxmox.Utils.systemd_unescape(res[8]);
+	task.user = res[9];
+
+	task.desc = Proxmox.Utils.format_task_description(task.type, task.id);
+
+	return task;
+    },
+
+    parse_task_status: function(status) {
+	if (status === 'OK') {
+	    return 'ok';
+	}
+
+	if (status === 'unknown') {
+	    return 'unknown';
+	}
+
+	let match = status.match(/^WARNINGS: (.*)$/);
+	if (match) {
+	    return 'warning';
+	}
+
+	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 '-';
+	}
+	return Proxmox.Utils.format_duration_human(value);
+    },
+
+    render_timestamp: function(value, metaData, record, rowIndex, colIndex, store) {
+	let servertime = new Date(value * 1000);
+	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') {
+	    helpMap = proxmoxOnlineHelpInfo; // eslint-disable-line no-undef
+	} else if (typeof pveOnlineHelpInfo !== 'undefined') {
+	    // be backward compatible with older pve-doc-generators
+	    helpMap = pveOnlineHelpInfo; // eslint-disable-line no-undef
+	} else {
+	    throw "no global OnlineHelpInfo map declared";
+	}
+
+	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) {
+	let info = Proxmox.Utils.get_help_info(section);
+	if (!info) {
+	    return undefined;
+	}
+	return window.location.origin + info.link;
+    },
+
+    openXtermJsViewer: function(vmtype, vmid, nodename, vmname, cmd) {
+	let url = Ext.Object.toQueryString({
+	    console: vmtype, // kvm, lxc, upgrade or shell
+	    xtermjs: 1,
+	    vmid: vmid,
+	    vmname: vmname,
+	    node: nodename,
+	    cmd: cmd,
+
+	});
+	let nw = window.open("?" + url, '_blank', 'toolbar=no,location=no,status=no,menubar=no,resizable=yes,width=800,height=420');
+	if (nw) {
+	    nw.focus();
+	}
+    },
+
+    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;
+    },
+
+    formatNodeRepoStatus: function(status, product) {
+	let fmt = (txt, cls) => `<i class="fa fa-fw fa-lg fa-${cls}"></i>${txt}`;
+
+	let getUpdates = Ext.String.format(gettext('{0} updates'), product);
+	let noRepo = Ext.String.format(gettext('No {0} repository enabled!'), product);
+
+	if (status === 'ok') {
+	    return fmt(getUpdates, 'check-circle good') + ' ' +
+		fmt(gettext('Production-ready Enterprise repository enabled'), 'check-circle good');
+	} else if (status === 'no-sub') {
+	    return fmt(gettext('Production-ready Enterprise repository enabled'), 'check-circle good') + ' ' +
+		    fmt(gettext('Enterprise repository needs valid subscription'), 'exclamation-circle warning');
+	} else if (status === 'non-production') {
+	    return fmt(getUpdates, 'check-circle good') + ' ' +
+		   fmt(gettext('Non production-ready repository enabled!'), 'exclamation-circle warning');
+	} else if (status === 'no-repo') {
+	    return fmt(noRepo, 'exclamation-circle critical');
+	}
+
+	return Proxmox.Utils.unknownText;
+    },
+
+    render_u2f_error: function(error) {
+	var ErrorNames = {
+	    '1': gettext('Other Error'),
+	    '2': gettext('Bad Request'),
+	    '3': gettext('Configuration Unsupported'),
+	    '4': gettext('Device Ineligible'),
+	    '5': gettext('Timeout'),
+	};
+	return "U2F Error: " + ErrorNames[error] || Proxmox.Utils.unknownText;
+    },
+
+    // Convert an ArrayBuffer to a base64url encoded string.
+    // A `null` value will be preserved for convenience.
+    bytes_to_base64url: function(bytes) {
+	if (bytes === null) {
+	    return null;
+	}
+
+	return btoa(Array
+	    .from(new Uint8Array(bytes))
+	    .map(val => String.fromCharCode(val))
+	    .join(''),
+	)
+	.replace(/\+/g, '-')
+	.replace(/\//g, '_')
+	.replace(/[=]/g, '');
+    },
+
+    // Convert an a base64url string to an ArrayBuffer.
+    // A `null` value will be preserved for convenience.
+    base64url_to_bytes: function(b64u) {
+	if (b64u === null) {
+	    return null;
+	}
+
+	return new Uint8Array(
+	    atob(b64u
+		.replace(/-/g, '+')
+		.replace(/_/g, '/'),
+	    )
+	    .split('')
+	    .map(val => val.charCodeAt(0)),
+	);
+    },
+
+    stringToRGB: function(string) {
+	let hash = 0;
+	if (!string) {
+	    return hash;
+	}
+	string += 'prox'; // give short strings more variance
+	for (let i = 0; i < string.length; i++) {
+	    hash = string.charCodeAt(i) + ((hash << 5) - hash);
+	    hash = hash & hash; // to int
+	}
+
+	let alpha = 0.7; // make the color a bit brighter
+	let bg = 255; // assume white background
+
+	return [
+	    (hash & 255) * alpha + bg * (1 - alpha),
+	    ((hash >> 8) & 255) * alpha + bg * (1 - alpha),
+	    ((hash >> 16) & 255) * alpha + bg * (1 - alpha),
+	];
+    },
+
+    rgbToCss: function(rgb) {
+	return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
+    },
+
+    rgbToHex: function(rgb) {
+	let r = Math.round(rgb[0]).toString(16);
+	let g = Math.round(rgb[1]).toString(16);
+	let b = Math.round(rgb[2]).toString(16);
+	return `${r}${g}${b}`;
+    },
+
+    hexToRGB: function(hex) {
+	if (!hex) {
+	    return undefined;
+	}
+	if (hex.length === 7) {
+	    hex = hex.slice(1);
+	}
+	let r = parseInt(hex.slice(0, 2), 16);
+	let g = parseInt(hex.slice(2, 4), 16);
+	let b = parseInt(hex.slice(4, 6), 16);
+	return [r, g, b];
+    },
+
+    // optimized & simplified SAPC function
+    // https://github.com/Myndex/SAPC-APCA
+    getTextContrastClass: function(rgb) {
+	    const blkThrs = 0.022;
+	    const blkClmp = 1.414;
+
+	    // linearize & gamma correction
+	    let r = (rgb[0] / 255) ** 2.4;
+	    let g = (rgb[1] / 255) ** 2.4;
+	    let b = (rgb[2] / 255) ** 2.4;
+
+	    // relative luminance sRGB
+	    let bg = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
+
+	    // black clamp
+	    bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp;
+
+	    // SAPC with white text
+	    let contrastLight = bg ** 0.65 - 1;
+	    // SAPC with black text
+	    let contrastDark = bg ** 0.56 - 0.046134502;
+
+	    if (Math.abs(contrastLight) >= Math.abs(contrastDark)) {
+		return 'light';
+	    } else {
+		return 'dark';
+	    }
+    },
+
+    getTagElement: function(string, color_overrides) {
+	let rgb = color_overrides?.[string] || Proxmox.Utils.stringToRGB(string);
+	let style = `background-color: ${Proxmox.Utils.rgbToCss(rgb)};`;
+	let cls;
+	if (rgb.length > 3) {
+	    style += `color: ${Proxmox.Utils.rgbToCss([rgb[3], rgb[4], rgb[5]])}`;
+	    cls = "proxmox-tag-dark";
+	} else {
+	    let txtCls = Proxmox.Utils.getTextContrastClass(rgb);
+	    cls = `proxmox-tag-${txtCls}`;
+	}
+	return `<span class="${cls}" style="${style}">${string}</span>`;
+    },
+
+    // Setting filename here when downloading from a remote url sometimes fails in chromium browsers
+    // because of a bug when using attribute download in conjunction with a self signed certificate.
+    // For more info see https://bugs.chromium.org/p/chromium/issues/detail?id=993362
+    downloadAsFile: function(source, fileName) {
+	let hiddenElement = document.createElement('a');
+	hiddenElement.href = source;
+	hiddenElement.target = '_blank';
+	if (fileName) {
+	    hiddenElement.download = fileName;
+	}
+	hiddenElement.click();
+    },
+},
+
+    singleton: true,
+    constructor: function() {
+	let me = this;
+	Ext.apply(me, me.utilities);
+
+	let IPV4_OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])";
+	let IPV4_REGEXP = "(?:(?:" + IPV4_OCTET + "\\.){3}" + IPV4_OCTET + ")";
+	let IPV6_H16 = "(?:[0-9a-fA-F]{1,4})";
+	let IPV6_LS32 = "(?:(?:" + IPV6_H16 + ":" + IPV6_H16 + ")|" + IPV4_REGEXP + ")";
+	let IPV4_CIDR_MASK = "([0-9]{1,2})";
+	let IPV6_CIDR_MASK = "([0-9]{1,3})";
+
+
+	me.IP4_match = new RegExp("^(?:" + IPV4_REGEXP + ")$");
+	me.IP4_cidr_match = new RegExp("^(?:" + IPV4_REGEXP + ")/" + IPV4_CIDR_MASK + "$");
+
+	/* eslint-disable no-useless-concat,no-multi-spaces */
+	let IPV6_REGEXP = "(?:" +
+	    "(?:(?:"                                                  + "(?:" + IPV6_H16 + ":){6})" + IPV6_LS32 + ")|" +
+	    "(?:(?:"                                         +   "::" + "(?:" + IPV6_H16 + ":){5})" + IPV6_LS32 + ")|" +
+	    "(?:(?:(?:"                           + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){4})" + IPV6_LS32 + ")|" +
+	    "(?:(?:(?:(?:" + IPV6_H16 + ":){0,1}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){3})" + IPV6_LS32 + ")|" +
+	    "(?:(?:(?:(?:" + IPV6_H16 + ":){0,2}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){2})" + IPV6_LS32 + ")|" +
+	    "(?:(?:(?:(?:" + IPV6_H16 + ":){0,3}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){1})" + IPV6_LS32 + ")|" +
+	    "(?:(?:(?:(?:" + IPV6_H16 + ":){0,4}" + IPV6_H16 + ")?::" +                         ")" + IPV6_LS32 + ")|" +
+	    "(?:(?:(?:(?:" + IPV6_H16 + ":){0,5}" + IPV6_H16 + ")?::" +                         ")" + IPV6_H16  + ")|" +
+	    "(?:(?:(?:(?:" + IPV6_H16 + ":){0,7}" + IPV6_H16 + ")?::" +                         ")"             + ")"  +
+	    ")";
+	/* eslint-enable no-useless-concat,no-multi-spaces */
+
+	me.IP6_match = new RegExp("^(?:" + IPV6_REGEXP + ")$");
+	me.IP6_cidr_match = new RegExp("^(?:" + IPV6_REGEXP + ")/" + IPV6_CIDR_MASK + "$");
+	me.IP6_bracket_match = new RegExp("^\\[(" + IPV6_REGEXP + ")\\]");
+
+	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])?))";
+	me.DnsName_match = new RegExp("^" + DnsName_REGEXP + "$");
+	me.DnsName_or_Wildcard_match = new RegExp("^(?:\\*\\.)?" + DnsName_REGEXP + "$");
+
+	me.CpuSet_match = /^[0-9]+(?:-[0-9]+)?(?:,[0-9]+(?:-[0-9]+)?)*$/;
+
+	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
+    // response 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));
+    },
+});
+
+Ext.override(Ext.data.Store, {
+    // If the store's proxy is changed while it is waiting for an AJAX
+    // response, `onProxyLoad` will still be called for the outdated response.
+    // To avoid displaying inconsistent information, only process responses
+    // belonging to the current proxy. However, do not apply this workaround
+    // to the mobile UI, as Sencha Touch has an incompatible internal API.
+    onProxyLoad: function(operation) {
+	let me = this;
+	if (Proxmox.Utils.toolkit === 'touch' || operation.getProxy() === me.getProxy()) {
+	    me.callParent(arguments);
+	} else {
+	    console.log(`ignored outdated response: ${operation.getRequest().getUrl()}`);
+	}
+    },
+});
+Ext.define('Proxmox.Schema', { // a singleton
+    singleton: true,
+
+    authDomains: {
+	pam: {
+	    name: 'Linux PAM',
+	    add: false,
+	    edit: false,
+	    pwchange: true,
+	    sync: false,
+	},
+	openid: {
+	    name: gettext('OpenID Connect Server'),
+	    ipanel: 'pmxAuthOpenIDPanel',
+	    add: true,
+	    edit: true,
+	    tfa: false,
+	    pwchange: false,
+	    sync: false,
+	    iconCls: 'pmx-itype-icon-openid-logo',
+	},
+	ldap: {
+	    name: gettext('LDAP Server'),
+	    ipanel: 'pmxAuthLDAPPanel',
+	    syncipanel: 'pmxAuthLDAPSyncPanel',
+	    add: true,
+	    edit: true,
+	    tfa: true,
+	    pwchange: false,
+	    sync: true,
+	},
+	ad: {
+	    name: gettext('Active Directory Server'),
+	    ipanel: 'pmxAuthADPanel',
+	    syncipanel: 'pmxAuthADSyncPanel',
+	    add: true,
+	    edit: true,
+	    tfa: true,
+	    pwchange: false,
+	    sync: true,
+	},
+    },
+    // to add or change existing for product specific ones
+    overrideAuthDomains: function(extra) {
+	for (const [key, value] of Object.entries(extra)) {
+	    Proxmox.Schema.authDomains[key] = value;
+	}
+    },
+
+    notificationEndpointTypes: {
+	sendmail: {
+	    name: 'Sendmail',
+	    ipanel: 'pmxSendmailEditPanel',
+	    iconCls: 'fa-envelope-o',
+	    defaultMailAuthor: 'Proxmox VE',
+	},
+	smtp: {
+	    name: 'SMTP',
+	    ipanel: 'pmxSmtpEditPanel',
+	    iconCls: 'fa-envelope-o',
+	    defaultMailAuthor: 'Proxmox VE',
+	},
+	gotify: {
+	    name: 'Gotify',
+	    ipanel: 'pmxGotifyEditPanel',
+	    iconCls: 'fa-bell-o',
+	},
+    },
+
+    // to add or change existing for product specific ones
+    overrideEndpointTypes: function(extra) {
+	for (const [key, value] of Object.entries(extra)) {
+	    Proxmox.Schema.notificationEndpointTypes[key] = value;
+	}
+    },
+
+    pxarFileTypes: {
+	b: { icon: 'cube', label: gettext('Block Device') },
+	c: { icon: 'tty', label: gettext('Character Device') },
+	d: { icon: 'folder-o', label: gettext('Directory') },
+	f: { icon: 'file-text-o', label: gettext('File') },
+	h: { icon: 'file-o', label: gettext('Hardlink') },
+	l: { icon: 'link', label: gettext('Softlink') },
+	p: { icon: 'exchange', label: gettext('Pipe/Fifo') },
+	s: { icon: 'plug', label: gettext('Socket') },
+	v: { icon: 'cube', label: gettext('Virtual') },
+    },
+});
+// ExtJS related things
+
+ // do not send '_dc' parameter
+Ext.Ajax.disableCaching = false;
+
+// custom Vtypes
+Ext.apply(Ext.form.field.VTypes, {
+    IPAddress: function(v) {
+	return Proxmox.Utils.IP4_match.test(v);
+    },
+    IPAddressText: gettext('Example') + ': 192.168.1.1',
+    IPAddressMask: /[\d.]/i,
+
+    IPCIDRAddress: function(v) {
+	let result = Proxmox.Utils.IP4_cidr_match.exec(v);
+	// limits according to JSON Schema see
+	// pve-common/src/PVE/JSONSchema.pm
+	return result !== null && result[1] >= 8 && result[1] <= 32;
+    },
+    IPCIDRAddressText: gettext('Example') + ': 192.168.1.1/24<br>' + gettext('Valid CIDR Range') + ': 8-32',
+    IPCIDRAddressMask: /[\d./]/i,
+
+    IP6Address: function(v) {
+	return Proxmox.Utils.IP6_match.test(v);
+    },
+    IP6AddressText: gettext('Example') + ': 2001:DB8::42',
+    IP6AddressMask: /[A-Fa-f0-9:]/,
+
+    IP6CIDRAddress: function(v) {
+	let result = Proxmox.Utils.IP6_cidr_match.exec(v);
+	// limits according to JSON Schema see
+	// pve-common/src/PVE/JSONSchema.pm
+	return result !== null && result[1] >= 8 && result[1] <= 128;
+    },
+    IP6CIDRAddressText: gettext('Example') + ': 2001:DB8::42/64<br>' + gettext('Valid CIDR Range') + ': 8-128',
+    IP6CIDRAddressMask: /[A-Fa-f0-9:/]/,
+
+    IP6PrefixLength: function(v) {
+	return v >= 0 && v <= 128;
+    },
+    IP6PrefixLengthText: gettext('Example') + ': X, where 0 <= X <= 128',
+    IP6PrefixLengthMask: /[0-9]/,
+
+    IP64Address: function(v) {
+	return Proxmox.Utils.IP64_match.test(v);
+    },
+    IP64AddressText: gettext('Example') + ': 192.168.1.1 2001:DB8::42',
+    IP64AddressMask: /[A-Fa-f0-9.:]/,
+
+    IP64CIDRAddress: function(v) {
+	let result = Proxmox.Utils.IP64_cidr_match.exec(v);
+	if (result === null) {
+	    return false;
+	}
+	if (result[1] !== undefined) {
+	    return result[1] >= 8 && result[1] <= 128;
+	} else if (result[2] !== undefined) {
+	    return result[2] >= 8 && result[2] <= 32;
+	} else {
+	    return false;
+	}
+    },
+    IP64CIDRAddressText: gettext('Example') + ': 192.168.1.1/24 2001:DB8::42/64',
+    IP64CIDRAddressMask: /[A-Fa-f0-9.:/]/,
+
+    MacAddress: function(v) {
+	return (/^([a-fA-F0-9]{2}:){5}[a-fA-F0-9]{2}$/).test(v);
+    },
+    MacAddressMask: /[a-fA-F0-9:]/,
+    MacAddressText: gettext('Example') + ': 01:23:45:67:89:ab',
+
+    MacPrefix: function(v) {
+	return (/^[a-f0-9][02468ace](?::[a-f0-9]{2}){0,2}:?$/i).test(v);
+    },
+    MacPrefixMask: /[a-fA-F0-9:]/,
+    MacPrefixText: gettext('Example') + ': 02:8f - ' + gettext('only unicast addresses are allowed'),
+
+    BridgeName: function(v) {
+	return (/^[a-zA-Z][a-zA-Z0-9_]{0,9}$/).test(v);
+    },
+    VlanName: function(v) {
+       if (Proxmox.Utils.VlanInterface_match.test(v)) {
+	 return true;
+       } else if (Proxmox.Utils.Vlan_match.test(v)) {
+	 return true;
+       }
+       return true;
+    },
+    BridgeNameText: gettext('Format') + ': alphanumeric string starting with a character',
+
+    BondName: function(v) {
+	return (/^bond\d{1,4}$/).test(v);
+    },
+    BondNameText: gettext('Format') + ': bond<b>N</b>, where 0 <= <b>N</b> <= 9999',
+
+    InterfaceName: function(v) {
+	return (/^[a-z][a-z0-9_]{1,20}$/).test(v);
+    },
+    InterfaceNameText: gettext("Allowed characters") + ": 'a-z', '0-9', '_'<br />" +
+		       gettext("Minimum characters") + ": 2<br />" +
+		       gettext("Maximum characters") + ": 21<br />" +
+		       gettext("Must start with") + ": 'a-z'",
+
+    StorageId: function(v) {
+	return (/^[a-z][a-z0-9\-_.]*[a-z0-9]$/i).test(v);
+    },
+    StorageIdText: gettext("Allowed characters") + ":  'A-Z', 'a-z', '0-9', '-', '_', '.'<br />" +
+		   gettext("Minimum characters") + ": 2<br />" +
+		   gettext("Must start with") + ": 'A-Z', 'a-z'<br />" +
+		   gettext("Must end with") + ": 'A-Z', 'a-z', '0-9'<br />",
+
+    ConfigId: function(v) {
+	return (/^[a-z][a-z0-9_-]+$/i).test(v);
+    },
+    ConfigIdText: gettext("Allowed characters") + ": 'A-Z', 'a-z', '0-9', '_'<br />" +
+		  gettext("Minimum characters") + ": 2<br />" +
+		  gettext("Must start with") + ": " + gettext("letter"),
+
+    HttpProxy: function(v) {
+	return (/^http:\/\/.*$/).test(v);
+    },
+    HttpProxyText: gettext('Example') + ": http://username:password&#64;host:port/",
+
+    CpuSet: function(v) {
+	return Proxmox.Utils.CpuSet_match.test(v);
+    },
+    CpuSetText: gettext('This is not a valid CpuSet'),
+
+    DnsName: function(v) {
+	return Proxmox.Utils.DnsName_match.test(v);
+    },
+    DnsNameText: gettext('This is not a valid hostname'),
+
+    DnsNameOrWildcard: function(v) {
+	return Proxmox.Utils.DnsName_or_Wildcard_match.test(v);
+    },
+    DnsNameOrWildcardText: gettext('This is not a valid hostname'),
+
+    // email regex used by pve-common
+    proxmoxMail: function(v) {
+	return (/^[\w+-~]+(\.[\w+-~]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/).test(v);
+    },
+    proxmoxMailText: gettext('Example') + ": user@example.com",
+
+    DnsOrIp: function(v) {
+	if (!Proxmox.Utils.DnsName_match.test(v) &&
+	    !Proxmox.Utils.IP64_match.test(v)) {
+	    return false;
+	}
+
+	return true;
+    },
+    DnsOrIpText: gettext('Not a valid DNS name or IP address.'),
+
+    HostPort: function(v) {
+	return Proxmox.Utils.HostPort_match.test(v) ||
+		Proxmox.Utils.HostPortBrackets_match.test(v) ||
+		Proxmox.Utils.IP6_dotnotation_match.test(v);
+    },
+    HostPortText: gettext('Host/IP address or optional port is invalid'),
+
+    HostList: function(v) {
+	let list = v.split(/[ ,;]+/);
+	let i;
+	for (i = 0; i < list.length; i++) {
+	    if (list[i] === '') {
+		continue;
+	    }
+
+	    if (!Proxmox.Utils.HostPort_match.test(list[i]) &&
+		!Proxmox.Utils.HostPortBrackets_match.test(list[i]) &&
+		!Proxmox.Utils.IP6_dotnotation_match.test(list[i])) {
+		return false;
+	    }
+	}
+
+	return true;
+    },
+    HostListText: gettext('Not a valid list of hosts'),
+
+    password: function(val, field) {
+	if (field.initialPassField) {
+	    let pwd = field.up('form').down(`[name=${field.initialPassField}]`);
+	    return val === pwd.getValue();
+	}
+	return true;
+    },
+
+    passwordText: gettext('Passwords do not match'),
+
+    email: function(value) {
+	let emailre = /^[\w+~-]+(\.[\w+~-]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/;
+	return emailre.test(value);
+    },
+});
+
+// we always want the number in x.y format and never in, e.g., x,y
+Ext.define('PVE.form.field.Number', {
+    override: 'Ext.form.field.Number',
+    submitLocaleSeparator: false,
+});
+
+// avois spamming the console and if we ever use this avoid a CORS block error too
+Ext.define('PVE.draw.Container', {
+    override: 'Ext.draw.Container',
+    defaultDownloadServerUrl: document.location.origin, // avoid that pointing to http://svg.sencha.io
+    applyDownloadServerUrl: function(url) { // avoid noisy warning, we don't really use that anyway
+	url = url || this.defaultDownloadServerUrl;
+	return url;
+    },
+});
+
+// ExtJs 5-6 has an issue with caching
+// see https://www.sencha.com/forum/showthread.php?308989
+Ext.define('Proxmox.UnderlayPool', {
+    override: 'Ext.dom.UnderlayPool',
+
+    checkOut: function() {
+	let cache = this.cache,
+	    len = cache.length,
+	    el;
+
+	// do cleanup because some of the objects might have been destroyed
+	while (len--) {
+	    if (cache[len].destroyed) {
+		cache.splice(len, 1);
+	    }
+	}
+	// end do cleanup
+
+	el = cache.shift();
+
+	if (!el) {
+	    el = Ext.Element.create(this.elementConfig);
+	    el.setVisibilityMode(2);
+	    //<debug>
+	    // tell the spec runner to ignore this element when checking if the dom is clean
+	    el.dom.setAttribute('data-sticky', true);
+	    //</debug>
+	}
+
+	return el;
+    },
+});
+
+// if the order of the values are not the same in originalValue and value
+// extjs will not overwrite value, but marks the field dirty and thus
+// the reset button will be enabled (but clicking it changes nothing)
+// so if the arrays are not the same after resetting, we
+// clear and set it
+Ext.define('Proxmox.form.ComboBox', {
+    override: 'Ext.form.field.ComboBox',
+
+    reset: function() {
+	// copied from combobox
+	let me = this;
+	me.callParent();
+
+	// clear and set when not the same
+	let value = me.getValue();
+	if (Ext.isArray(me.originalValue) && Ext.isArray(value) &&
+	    !Ext.Array.equals(value, me.originalValue)) {
+	    me.clearValue();
+	    me.setValue(me.originalValue);
+	}
+    },
+
+    // we also want to open the trigger on editable comboboxes by default
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+
+	if (me.editable) {
+	    // The trigger.picker causes first a focus event on the field then
+	    // toggles the selection picker. Thus skip expanding in this case,
+	    // else our focus listener expands and the picker.trigger then
+	    // collapses it directly afterwards.
+	    Ext.override(me.triggers.picker, {
+		onMouseDown: function(e) {
+		    // copied "should we focus" check from Ext.form.trigger.Trigger
+		    if (e.pointerType !== 'touch' && !this.field.owns(Ext.Element.getActiveElement())) {
+			me.skip_expand_on_focus = true;
+		    }
+		    this.callParent(arguments);
+		},
+	    });
+
+	    me.on("focus", function(combobox) {
+		if (!combobox.isExpanded && !combobox.skip_expand_on_focus) {
+		    combobox.expand();
+		}
+		combobox.skip_expand_on_focus = false;
+	    });
+	}
+    },
+});
+
+// when refreshing a grid/tree view, restoring the focus moves the view back to
+// the previously focused item. Save scroll position before refocusing.
+Ext.define(null, {
+    override: 'Ext.view.Table',
+
+    jumpToFocus: false,
+
+    saveFocusState: function() {
+	var me = this,
+	    store = me.dataSource,
+	    actionableMode = me.actionableMode,
+	    navModel = me.getNavigationModel(),
+	    focusPosition = actionableMode ? me.actionPosition : navModel.getPosition(true),
+	    activeElement = Ext.fly(Ext.Element.getActiveElement()),
+	    focusCell = focusPosition && focusPosition.view === me &&
+	    Ext.fly(focusPosition.getCell(true)),
+	    refocusRow, refocusCol, record;
+
+	// The navModel may return a position that is in a locked partner, so check that
+	// the focusPosition's cell contains the focus before going forward.
+	// The skipSaveFocusState is set by Actionables which actively control
+	// focus destination. See CellEditing#activateCell.
+	if (!me.skipSaveFocusState && focusCell && focusCell.contains(activeElement)) {
+	    // Separate this from the instance that the nav model is using.
+	    focusPosition = focusPosition.clone();
+
+	    // While we deactivate the focused element, suspend focus processing on it.
+	    activeElement.suspendFocusEvents();
+
+	    // Suspend actionable mode.
+	    // Each Actionable must silently save its state ready to resume when focus
+	    // can be restored but should only do that if the activeElement is not the cell itself,
+	    // this happens when the grid is refreshed while one of the actionables is being
+	    // deactivated (e.g. Calling  view refresh inside CellEditor 'edit' event listener).
+	    if (actionableMode && focusCell.dom !== activeElement.dom) {
+		me.suspendActionableMode();
+	    } else {
+		// Clear position, otherwise the setPosition on the other side
+		// will be rejected as a no-op if the resumption position is logically
+		// equivalent.
+		actionableMode = false;
+		navModel.setPosition();
+	    }
+
+	    // Do not leave the element in that state in case refresh fails, and restoration
+	    // closure not called.
+	    activeElement.resumeFocusEvents();
+
+	    // if the store is expanding or collapsing, we should never scroll the view.
+	    if (store.isExpandingOrCollapsing) {
+		return Ext.emptyFn;
+	    }
+
+	    // The following function will attempt to refocus back in the same mode to the same cell
+	    // as it was at before based upon the previous record (if it's still in the store),
+	    // or the row index.
+	    return function() {
+		var all;
+
+		// May have changed due to reconfigure
+		store = me.dataSource;
+
+		// If we still have data, attempt to refocus in the same mode.
+		if (store.getCount()) {
+		    all = me.all;
+
+		    // Adjust expectations of where we are able to refocus according to
+		    // what kind of destruction might have been wrought on this view's DOM
+		    // during focus save.
+		    refocusRow =
+			Math.min(Math.max(focusPosition.rowIdx, all.startIndex), all.endIndex);
+
+		    refocusCol = Math.min(
+			focusPosition.colIdx,
+			me.getVisibleColumnManager().getColumns().length - 1,
+		    );
+
+		    record = focusPosition.record;
+
+		    focusPosition = new Ext.grid.CellContext(me).setPosition(
+			record && store.contains(record) && !record.isCollapsedPlaceholder
+			? record
+			: refocusRow,
+			refocusCol,
+		    );
+
+		    // Maybe there are no cells. eg: all groups collapsed.
+		    if (focusPosition.getCell(true)) {
+			if (actionableMode) {
+			    me.resumeActionableMode(focusPosition);
+			} else {
+			    // we sometimes want to scroll back to where we are
+
+			    let x = me.getScrollX();
+			    let y = me.getScrollY();
+
+			    // Pass "preventNavigation" as true
+			    // so that that does not cause selection.
+			    navModel.setPosition(focusPosition, null, null, null, true);
+
+			    if (!navModel.getPosition()) {
+				focusPosition.column.focus();
+			    }
+
+			    if (!me.jumpToFocus) {
+				me.scrollTo(x, y);
+			    }
+			}
+		    }
+		} else { // No rows - focus associated column header
+		    focusPosition.column.focus();
+		}
+	    };
+	}
+	return Ext.emptyFn;
+    },
+});
+
+// ExtJS 6.0.1 has no setSubmitValue() (although you find it in the docs).
+// Note: this.submitValue is a boolean flag, whereas getSubmitValue() returns
+// data to be submitted.
+Ext.define('Proxmox.form.field.Text', {
+    override: 'Ext.form.field.Text',
+
+    setSubmitValue: function(v) {
+	this.submitValue = v;
+    },
+});
+
+// make mousescrolling work in firefox in the containers overflowhandler,
+// by using only the 'wheel' event not 'mousewheel'(fixed in 7.3)
+// also reverse the scrolldirection (fixed in 7.3)
+// and reduce the default increment
+Ext.define(null, {
+    override: 'Ext.layout.container.boxOverflow.Scroller',
+
+    wheelIncrement: 1,
+
+    getWheelDelta: function(e) {
+	return -e.getWheelDelta(e);
+    },
+
+    onOwnerRender: function(owner) {
+	var me = this,
+	    scrollable = {
+		isBoxOverflowScroller: true,
+		x: false,
+		y: false,
+		listeners: {
+		    scrollend: this.onScrollEnd,
+		    scope: this,
+		},
+	    };
+
+	// If no obstrusive scrollbars, allow natural scrolling on mobile touch devices
+	if (!Ext.scrollbar.width() && !Ext.platformTags.desktop) {
+	    scrollable[owner.layout.horizontal ? 'x' : 'y'] = true;
+	} else {
+	    me.wheelListener = me.layout.innerCt.on(
+		'wheel', me.onMouseWheel, me, { destroyable: true },
+	    );
+	}
+
+	owner.setScrollable(scrollable);
+    },
+});
+
+// extj 6.7 reversed mousewheel direction... (fixed in 7.3)
+// https://forum.sencha.com/forum/showthread.php?472517-Mousewheel-scroll-direction-in-numberfield-with-spinners
+// also use the 'wheel' event instead of 'mousewheel' (fixed in 7.3)
+Ext.define('Proxmox.form.field.Spinner', {
+    override: 'Ext.form.field.Spinner',
+
+    onRender: function() {
+	let me = this;
+
+	me.callParent();
+
+	// Init mouse wheel
+	if (me.mouseWheelEnabled) {
+	    // Unlisten Ext generated listener ('mousewheel' is deprecated anyway)
+	    me.mun(me.bodyEl, 'mousewheel', me.onMouseWheel, me);
+
+	    me.mon(me.bodyEl, 'wheel', me.onMouseWheel, me);
+	}
+    },
+
+    onMouseWheel: function(e) {
+	var me = this,
+	    delta;
+	if (me.hasFocus) {
+	    delta = e.getWheelDelta();
+	    if (delta > 0) {
+		me.spinDown();
+	    } else if (delta < 0) {
+		me.spinUp();
+	    }
+	    e.stopEvent();
+	    me.onSpinEnd();
+	}
+    },
+});
+
+// add '@' to the valid id
+Ext.define('Proxmox.validIdReOverride', {
+    override: 'Ext.Component',
+    validIdRe: /^[a-z_][a-z0-9\-_@]*$/i,
+});
+
+Ext.define('Proxmox.selection.CheckboxModel', {
+    override: 'Ext.selection.CheckboxModel',
+
+    // [P] use whole checkbox cell to multiselect, not only the checkbox
+    checkSelector: '.x-grid-cell-row-checker',
+
+    // TODO: remove all optimizations below to an override for parent 'Ext.selection.Model' ??
+
+    // [ P: optimized to remove all records at once as single remove is O(n^3) slow ]
+    // records can be an index, a record or an array of records
+    doDeselect: function(records, suppressEvent) {
+	var me = this,
+	    selected = me.selected,
+	    i = 0,
+	    len, record,
+	    commit;
+	if (me.locked || !me.store) {
+	    return false;
+	}
+	if (typeof records === "number") {
+	    // No matching record, jump out
+	    record = me.store.getAt(records);
+	    if (!record) {
+		return false;
+	    }
+	    records = [
+		record,
+	    ];
+	} else if (!Ext.isArray(records)) {
+	    records = [
+		records,
+	    ];
+	}
+	// [P] a beforedeselection, triggered by me.onSelectChange below, can block removal by
+	// returning false, thus the original implementation removed only here in the commit fn,
+	// which has an abysmal performance O(n^3). As blocking removal is not the norm, go do the
+	// reverse, record blocked records and remove them from the to-be-removed array before
+	// applying it. A FF86 i9-9900K on 10k records goes from >40s to ~33ms for >90% deselection
+	let committed = false;
+	commit = function() {
+	    committed = true;
+	    if (record === me.selectionStart) {
+		me.selectionStart = null;
+	    }
+	};
+	let removalBlocked = [];
+	len = records.length;
+	me.suspendChanges();
+	for (; i < len; i++) {
+	    record = records[i];
+	    if (me.isSelected(record)) {
+		committed = false;
+		me.onSelectChange(record, false, suppressEvent, commit);
+		if (!committed) {
+		    removalBlocked.push(record);
+		}
+		if (me.destroyed) {
+		    return false;
+		}
+	    }
+	}
+	if (removalBlocked.length > 0) {
+	    records.remove(removalBlocked);
+	}
+	selected.remove(records); // [P] FAST(er)
+	me.lastSelected = selected.last();
+	me.resumeChanges();
+	// fire selchange if there was a change and there is no suppressEvent flag
+	me.maybeFireSelectionChange(records.length > 0 && !suppressEvent);
+	return records.length;
+    },
+
+
+    doMultiSelect: function(records, keepExisting, suppressEvent) {
+	var me = this,
+	    selected = me.selected,
+	    change = false,
+	    result, i, len, record, commit;
+
+	if (me.locked) {
+	    return;
+	}
+
+	records = !Ext.isArray(records) ? [records] : records;
+	len = records.length;
+	if (!keepExisting && selected.getCount() > 0) {
+	    result = me.deselectDuringSelect(records, suppressEvent);
+	    if (me.destroyed) {
+		return;
+	    }
+	    if (result[0]) {
+		// We had a failure during selection, so jump out
+		// Fire selection change if we did deselect anything
+		me.maybeFireSelectionChange(result[1] > 0 && !suppressEvent);
+		return;
+	    } else {
+		// Means something has been deselected, so we've had a change
+		change = result[1] > 0;
+	    }
+	}
+
+	let gotBlocked, blockedRecords = [];
+	commit = function() {
+	    if (!selected.getCount()) {
+		me.selectionStart = record;
+	    }
+	    gotBlocked = false;
+	    change = true;
+	};
+
+	for (i = 0; i < len; i++) {
+	    record = records[i];
+	    if (me.isSelected(record)) {
+		continue;
+	    }
+
+	    gotBlocked = true;
+	    me.onSelectChange(record, true, suppressEvent, commit);
+	    if (me.destroyed) {
+		return;
+	    }
+	    if (gotBlocked) {
+		blockedRecords.push(record);
+	    }
+	}
+	if (blockedRecords.length > 0) {
+	    records.remove(blockedRecords);
+	}
+	selected.add(records);
+	me.lastSelected = record;
+
+	// fire selchange if there was a change and there is no suppressEvent flag
+	me.maybeFireSelectionChange(change && !suppressEvent);
+    },
+    deselectDuringSelect: function(toSelect, suppressEvent) {
+	var me = this,
+	    selected = me.selected.getRange(),
+	    changed = 0,
+	    failed = false;
+	// Prevent selection change events from firing, will happen during select
+	me.suspendChanges();
+	me.deselectingDuringSelect = true;
+	let toDeselect = selected.filter(item => !Ext.Array.contains(toSelect, item));
+	if (toDeselect.length > 0) {
+	    changed = me.doDeselect(toDeselect, suppressEvent);
+	    if (!changed) {
+		failed = true;
+	    }
+	    if (me.destroyed) {
+		failed = true;
+		changed = 0;
+	    }
+	}
+	me.deselectingDuringSelect = false;
+	me.resumeChanges();
+	return [
+	    failed,
+	    changed,
+	];
+    },
+});
+
+// stop nulling of properties
+Ext.define('Proxmox.Component', {
+    override: 'Ext.Component',
+    clearPropertiesOnDestroy: false,
+});
+
+// Fix drag&drop for vms and desktops that detect 'pen' pointerType
+// NOTE: this part has been rewritten in ExtJS 7.4, so re-check once we can upgrade
+Ext.define('Proxmox.view.DragZone', {
+    override: 'Ext.view.DragZone',
+
+    onItemMouseDown: function(view, record, item, index, e) {
+	// Ignore touchstart.
+	// For touch events, we use longpress.
+	if (e.pointerType !== 'touch') {
+	    this.onTriggerGesture(view, record, item, index, e);
+	}
+    },
+});
+
+// Fix text selection on drag when using DragZone,
+// see https://forum.sencha.com/forum/showthread.php?335100
+Ext.define('Proxmox.dd.DragDropManager', {
+    override: 'Ext.dd.DragDropManager',
+
+    stopEvent: function(e) {
+	if (this.stopPropagation) {
+	    e.stopPropagation();
+	}
+
+	if (this.preventDefault) {
+	    e.preventDefault();
+	}
+    },
+});
+
+// make it possible to set the SameSite attribute on cookies
+Ext.define('Proxmox.Cookies', {
+    override: 'Ext.util.Cookies',
+
+    set: function(name, value, expires, path, domain, secure, samesite) {
+	let attrs = [];
+
+	if (expires) {
+	    attrs.push("expires=" + expires.toUTCString());
+	}
+
+	if (path === undefined) { // mimic original function's behaviour
+	    attrs.push("path=/");
+	} else if (path) {
+	    attrs.push("path=" + path);
+	}
+
+	if (domain) {
+	    attrs.push("domain=" + domain);
+	}
+
+	if (secure === true) {
+	    attrs.push("secure");
+	}
+
+	if (samesite && ["lax", "none", "strict"].includes(samesite.toLowerCase())) {
+	    attrs.push("samesite=" + samesite);
+	}
+
+	document.cookie = name + "=" + escape(value) + "; " + attrs.join("; ");
+    },
+});
+
+// force alert boxes to be rendered with an Error Icon
+// since Ext.Msg is an object and not a prototype, we need to override it
+// after the framework has been initiated
+Ext.onReady(function() {
+    Ext.override(Ext.Msg, {
+	alert: function(title, message, fn, scope) { // eslint-disable-line consistent-return
+	    if (Ext.isString(title)) {
+		let config = {
+		    title: title,
+		    message: message,
+		    icon: this.ERROR,
+		    buttons: this.OK,
+		    fn: fn,
+		    scope: scope,
+		    minWidth: this.minWidth,
+		};
+	    return this.show(config);
+	    }
+	},
+    });
+});
+
+// add allowfullscreen to render template to allow the noVNC/xterm.js embedded UIs to go fullscreen
+//
+// The rest is the same as in the separate ux package (extjs/build/packages/ux/classic/ux-debug.js),
+// which we do not load as it's rather big and most of the widgets there are not useful for our UIs
+Ext.define('Ext.ux.IFrame', {
+    extend: 'Ext.Component',
+
+    alias: 'widget.uxiframe',
+
+    loadMask: 'Loading...',
+
+    src: 'about:blank',
+
+    renderTpl: [
+	// eslint-disable-next-line max-len
+	'<iframe src="{src}" id="{id}-iframeEl" data-ref="iframeEl" name="{frameName}" width="100%" height="100%" frameborder="0" allowfullscreen="true"></iframe>',
+    ],
+
+    childEls: ['iframeEl'],
+
+    initComponent: function() {
+	this.callParent();
+
+	this.frameName = this.frameName || this.id + '-frame';
+    },
+
+    initEvents: function() {
+	let me = this;
+
+	me.callParent();
+	me.iframeEl.on('load', me.onLoad, me);
+    },
+
+    initRenderData: function() {
+	return Ext.apply(this.callParent(), {
+	    src: this.src,
+	    frameName: this.frameName,
+	});
+    },
+
+    getBody: function() {
+	let doc = this.getDoc();
+
+	return doc.body || doc.documentElement;
+    },
+
+    getDoc: function() {
+	try {
+	    return this.getWin().document;
+	} catch (ex) {
+	    return null;
+	}
+    },
+
+    getWin: function() {
+	let me = this,
+	    name = me.frameName,
+	    win = Ext.isIE ? me.iframeEl.dom.contentWindow : window.frames[name];
+
+	return win;
+    },
+
+    getFrame: function() {
+	let me = this;
+
+	return me.iframeEl.dom;
+    },
+
+    onLoad: function() {
+	let me = this,
+	    doc = me.getDoc();
+
+	if (doc) {
+	    this.el.unmask();
+	    this.fireEvent('load', this);
+	} else if (me.src) {
+	    this.el.unmask();
+	    this.fireEvent('error', this);
+	}
+    },
+
+    load: function(src) {
+	let me = this,
+	    text = me.loadMask,
+	    frame = me.getFrame();
+
+	if (me.fireEvent('beforeload', me, src) !== false) {
+	    if (text && me.el) {
+		me.el.mask(text);
+	    }
+
+	    frame.src = me.src = src || me.src;
+	}
+    },
+});
+Ext.define('PMX.image.Logo', {
+    extend: 'Ext.Img',
+    xtype: 'proxmoxlogo',
+
+    height: 30,
+    width: 172,
+    src: '/images/proxmox_logo.png',
+    alt: 'Proxmox',
+    autoEl: {
+	tag: 'a',
+	href: 'https://www.proxmox.com',
+	target: '_blank',
+    },
+
+    initComponent: function() {
+	let me = this;
+	let prefix = me.prefix !== undefined ? me.prefix : '/pve2';
+	me.src = prefix + me.src;
+	me.callParent();
+    },
+});
+// NOTE: just relays parsing to markedjs parser
+Ext.define('Proxmox.Markdown', {
+    alternateClassName: 'Px.Markdown', // just trying out something, do NOT copy this line
+    singleton: true,
+
+    // transforms HTML to a DOM tree and recursively descends and HTML-encodes every branch with a
+    // "bad" node.type and drops "bad" attributes from the remaining nodes.
+    // "bad" means anything which can do XSS or break the layout of the outer page
+    sanitizeHTML: function(input) {
+	if (!input) {
+	    return input;
+	}
+	let _isHTTPLike = value => value.match(/^\s*https?:/i); // URL's protocol ends with :
+	let _sanitize;
+	_sanitize = (node) => {
+	    if (node.nodeType === 3) return;
+	    if (node.nodeType !== 1 ||
+		/^(script|style|form|select|option|optgroup|map|area|canvas|textarea|applet|font|iframe|audio|video|object|embed|svg)$/i.test(node.tagName)
+	    ) {
+		// could do node.remove() instead, but it's nicer UX if we keep the (encoded!) html
+		node.outerHTML = Ext.String.htmlEncode(node.outerHTML);
+		return;
+	    }
+	    for (let i=node.attributes.length; i--;) {
+		const name = node.attributes[i].name;
+		const value = node.attributes[i].value;
+		const canonicalTagName = node.tagName.toLowerCase();
+		// TODO: we may want to also disallow class and id attrs
+		if (
+		    !/^(class|id|name|href|src|alt|align|valign|disabled|checked|start|type|target)$/i.test(name)
+		) {
+		    node.attributes.removeNamedItem(name);
+		} else if ((name === 'href' || name === 'src') && !_isHTTPLike(value)) {
+		    let safeURL = false;
+		    try {
+			let url = new URL(value, window.location.origin);
+			safeURL = _isHTTPLike(url.protocol);
+			if (canonicalTagName === 'img' && url.protocol.toLowerCase() === 'data:') {
+			    safeURL = true;
+			} else if (canonicalTagName === 'a') {
+			    // allow most link protocols so admins can use short-cuts to, e.g., RDP
+			    safeURL = url.protocol.toLowerCase() !== 'javascript:'; // eslint-disable-line no-script-url
+			}
+			if (safeURL) {
+			    node.attributes[i].value = url.href;
+			} else {
+			    node.attributes.removeNamedItem(name);
+			}
+		    } catch (e) {
+			node.attributes.removeNamedItem(name);
+		    }
+		} else if (name === 'target' && canonicalTagName !== 'a') {
+		    node.attributes.removeNamedItem(name);
+		}
+	    }
+	    for (let i=node.childNodes.length; i--;) _sanitize(node.childNodes[i]);
+	};
+
+	const doc = new DOMParser().parseFromString(`<!DOCTYPE html><html><body>${input}`, 'text/html');
+	doc.normalize();
+
+	_sanitize(doc.body);
+
+	return doc.body.innerHTML;
+    },
+
+    parse: function(markdown) {
+	/*global marked*/
+	let unsafeHTML = marked.parse(markdown);
+
+	return `<div class="pmx-md">${this.sanitizeHTML(unsafeHTML)}</div>`;
+    },
+
+});
+/*
+ * The Proxmox CBind mixin is intended to supplement the 'bind' mechanism
+ * of ExtJS. In contrast to the 'bind', 'cbind' only acts during the creation
+ * of the component, not during its lifetime. It's only applied once before
+ * the 'initComponent' method is executed, and thus you have only access
+ * to the basic initial configuration of it.
+ *
+ * You can use it to get a 'declarative' approach to component declaration,
+ * even when you need to set some properties of sub-components dynamically
+ * (e.g., the 'nodename'). It overwrites the given properties of the 'cbind'
+ * object in the component with their computed values of the computed
+ * cbind configuration object of the 'cbindData' function (or object).
+ *
+ * The cbind syntax is inspired by ExtJS' bind syntax ('{property}'), where
+ * it is possible to negate values ('{!negated}'), access sub-properties of
+ * objects ('{object.property}') and even use a getter function,
+ * akin to viewModel formulas ('(get) => get("prop")') to execute more
+ * complicated dependencies (e.g., urls).
+ *
+ * The 'cbind' will be recursively applied to all properties (objects/arrays)
+ * that contain an 'xtype' or 'cbind' property, but stops for a subtree if the
+ * object in question does not have either (if you have one or more levels that
+ * have no cbind/xtype property, you can insert empty cbind objects there to
+ * reach deeper nested objects).
+ *
+ * This reduces the code in the 'initComponent' and instead we can statically
+ * declare items, buttons, tbars, etc. while the dynamic parts are contained
+ * in the 'cbind'.
+ *
+ * It is used like in the following example:
+ *
+ * Ext.define('Some.Component', {
+ *     extend: 'Some.other.Component',
+ *
+ *     // first it has to be enabled
+ *     mixins: ['Proxmox.Mixin.CBind'],
+ *
+ *     // then a base config has to be defined. this can be a function,
+ *     // which has access to the initial config and can store persistent
+ *     // properties, as well as return temporary ones (which only exist during
+ *     // the cbind process)
+ *     // this function will be called before 'initComponent'
+ *     cbindData: function(initialconfig) {
+ *         // 'this' here is the same as in 'initComponent'
+ *         let me = this;
+ *         me.persistentProperty = false;
+ *         return {
+ *             temporaryProperty: true,
+ *         };
+ *     },
+ *
+ *     // if there is no need for persistent properties, it can also simply be an object
+ *     cbindData: {
+ *         temporaryProperty: true,
+ *         // properties itself can also be functions that will be evaluated before
+ *         // replacing the values
+ *         dynamicProperty: (cfg) => !cfg.temporaryProperty,
+ *         numericProp: 0,
+ *         objectProp: {
+ *             foo: 'bar',
+ *             bar: 'baz',
+ *         }
+ *     },
+ *
+ *     // you can 'cbind' the component itself, here the 'target' property
+ *     // will be replaced with the content of 'temporaryProperty' (true)
+ *     // before the components initComponent
+ *     cbind: {
+ *          target: '{temporaryProperty}',
+ *     },
+ *
+ *     items: [
+ *         {
+ *             xtype: 'checkbox',
+ *             cbind: {
+ *                 value: '{!persistentProperty}',
+ *                 object: '{objectProp.foo}'
+ *                 dynamic: (get) => get('numericProp') + 1,
+ *             },
+ *         },
+ *         {
+ *             // empty cbind so that subitems are reached
+ *             cbind: {},
+ *             items: [
+ *                 {
+ *                     xtype: 'textfield',
+ *                     cbind: {
+ *                         value: '{objectProp.bar}',
+ *                     },
+ *                 },
+ *             ],
+ *         },
+ *     ],
+ * });
+ */
+
+Ext.define('Proxmox.Mixin.CBind', {
+    extend: 'Ext.Mixin',
+
+    mixinConfig: {
+        before: {
+            initComponent: 'cloneTemplates',
+        },
+    },
+
+    cloneTemplates: function() {
+	let me = this;
+
+	if (typeof me.cbindData === "function") {
+	    me.cbindData = me.cbindData(me.initialConfig);
+	}
+	me.cbindData = me.cbindData || {};
+
+	let getConfigValue = function(cname) {
+	    if (cname in me.initialConfig) {
+		return me.initialConfig[cname];
+	    }
+	    if (cname in me.cbindData) {
+		let res = me.cbindData[cname];
+		if (typeof res === "function") {
+		    return res(me.initialConfig);
+		} else {
+		    return res;
+		}
+	    }
+	    if (cname in me) {
+		return me[cname];
+	    }
+	    throw "unable to get cbind data for '" + cname + "'";
+	};
+
+	let applyCBind = function(obj) {
+	    let cbind = obj.cbind, cdata;
+	    if (!cbind) return;
+
+	    for (const prop in cbind) { // eslint-disable-line guard-for-in
+		let match, found;
+		cdata = cbind[prop];
+
+		found = false;
+		if (typeof cdata === 'function') {
+		    obj[prop] = cdata(getConfigValue, prop);
+		    found = true;
+		} else if ((match = /^\{(!)?([a-z_][a-z0-9_]*)\}$/i.exec(cdata))) {
+		    let cvalue = getConfigValue(match[2]);
+		    if (match[1]) cvalue = !cvalue;
+		    obj[prop] = cvalue;
+		    found = true;
+		} else if ((match = /^\{(!)?([a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)+)\}$/i.exec(cdata))) {
+		    let keys = match[2].split('.');
+		    let cvalue = getConfigValue(keys.shift());
+		    keys.forEach(function(k) {
+			if (k in cvalue) {
+			    cvalue = cvalue[k];
+			} else {
+			    throw "unable to get cbind data for '" + match[2] + "'";
+			}
+		    });
+		    if (match[1]) cvalue = !cvalue;
+		    obj[prop] = cvalue;
+		    found = true;
+		} else {
+		    obj[prop] = cdata.replace(/{([a-z_][a-z0-9_]*)\}/ig, (_match, cname) => {
+			let cvalue = getConfigValue(cname);
+			found = true;
+			return cvalue;
+		    });
+		}
+		if (!found) {
+		    throw "unable to parse cbind template '" + cdata + "'";
+		}
+	    }
+	};
+
+	if (me.cbind) {
+	    applyCBind(me);
+	}
+
+	let cloneTemplateObject;
+	let cloneTemplateArray = function(org) {
+	    let copy, i, found, el, elcopy, arrayLength;
+
+	    arrayLength = org.length;
+	    found = false;
+	    for (i = 0; i < arrayLength; i++) {
+		el = org[i];
+		if (el.constructor === Object && (el.xtype || el.cbind)) {
+		    found = true;
+		    break;
+		}
+	    }
+
+	    if (!found) return org; // no need to copy
+
+	    copy = [];
+	    for (i = 0; i < arrayLength; i++) {
+		el = org[i];
+		if (el.constructor === Object && (el.xtype || el.cbind)) {
+		    elcopy = cloneTemplateObject(el);
+		    if (elcopy.cbind) {
+			applyCBind(elcopy);
+		    }
+		    copy.push(elcopy);
+		} else if (el.constructor === Array) {
+		    elcopy = cloneTemplateArray(el);
+		    copy.push(elcopy);
+		} else {
+		    copy.push(el);
+		}
+	    }
+	    return copy;
+	};
+
+	cloneTemplateObject = function(org) {
+	    let res = {}, prop, el, copy;
+	    for (prop in org) { // eslint-disable-line guard-for-in
+		el = org[prop];
+		if (el === undefined || el === null) {
+		    res[prop] = el;
+		    continue;
+		}
+		if (el.constructor === Object && (el.xtype || el.cbind)) {
+		    copy = cloneTemplateObject(el);
+		    if (copy.cbind) {
+			applyCBind(copy);
+		    }
+		    res[prop] = copy;
+		} else if (el.constructor === Array) {
+		    copy = cloneTemplateArray(el);
+		    res[prop] = copy;
+		} else {
+		    res[prop] = el;
+		}
+	    }
+	    return res;
+	};
+
+	let condCloneProperties = function() {
+	    let prop, el, tmp;
+
+	    for (prop in me) { // eslint-disable-line guard-for-in
+		el = me[prop];
+		if (el === undefined || el === null) continue;
+		if (typeof el === 'object' && el.constructor === Object) {
+		    if ((el.xtype || el.cbind) && prop !== 'config') {
+			me[prop] = cloneTemplateObject(el);
+		    }
+		} else if (el.constructor === Array) {
+		    tmp = cloneTemplateArray(el);
+		    me[prop] = tmp;
+		}
+	    }
+	};
+
+	condCloneProperties();
+    },
+});
+/* A reader to store a single JSON Object (hash) into a storage.
+ * Also accepts an array containing a single hash.
+ *
+ * So it can read:
+ *
+ * example1: {data1: "xyz", data2: "abc"}
+ * returns [{key: "data1", value: "xyz"}, {key: "data2", value: "abc"}]
+ *
+ * example2: [ {data1: "xyz", data2: "abc"} ]
+ * returns [{key: "data1", value: "xyz"}, {key: "data2", value: "abc"}]
+ *
+ * If you set 'readArray', the reader expects the object as array:
+ *
+ * example3: [ { key: "data1", value: "xyz", p2: "cde" },  { key: "data2", value: "abc", p2: "efg" }]
+ * returns [{key: "data1", value: "xyz", p2: "cde}, {key: "data2", value: "abc", p2: "efg"}]
+ *
+ * Note: The records can contain additional properties (like 'p2' above) when you use 'readArray'
+ *
+ * Additional feature: specify allowed properties with default values with 'rows' object
+ *
+ * let rows = {
+ *   memory: {
+ *     required: true,
+ *     defaultValue: 512
+ *   }
+ * }
+ *
+ */
+
+Ext.define('Proxmox.data.reader.JsonObject', {
+    extend: 'Ext.data.reader.Json',
+    alias: 'reader.jsonobject',
+
+    readArray: false,
+    responseType: undefined,
+
+    rows: undefined,
+
+    constructor: function(config) {
+        let me = this;
+
+        Ext.apply(me, config || {});
+
+	me.callParent([config]);
+    },
+
+    getResponseData: function(response) {
+	let me = this;
+
+	let data = [];
+        try {
+	    let result = Ext.decode(response.responseText);
+	    // get our data items inside the server response
+	    let root = result[me.getRootProperty()];
+
+	    if (me.readArray) {
+		// it can be more convenient for the backend to return null instead of an empty array
+		if (root === null) {
+		    root = [];
+		}
+		let rec_hash = {};
+		Ext.Array.each(root, function(rec) {
+		    if (Ext.isDefined(rec.key)) {
+			rec_hash[rec.key] = rec;
+		    }
+		});
+
+		if (me.rows) {
+		    Ext.Object.each(me.rows, function(key, rowdef) {
+			let rec = rec_hash[key];
+			if (Ext.isDefined(rec)) {
+			    if (!Ext.isDefined(rec.value)) {
+				rec.value = rowdef.defaultValue;
+			    }
+			    data.push(rec);
+			} else if (Ext.isDefined(rowdef.defaultValue)) {
+			    data.push({ key: key, value: rowdef.defaultValue });
+			} else if (rowdef.required) {
+			    data.push({ key: key, value: undefined });
+			}
+		    });
+		} else {
+		    Ext.Array.each(root, function(rec) {
+			if (Ext.isDefined(rec.key)) {
+			    data.push(rec);
+			}
+		    });
+		}
+	    } else {
+		// it can be more convenient for the backend to return null instead of an empty object
+		if (root === null) {
+		    root = {};
+		} else if (Ext.isArray(root)) {
+		    if (root.length === 1) {
+			root = root[0];
+		    } else {
+			root = {};
+		    }
+		}
+
+		if (me.rows) {
+		    Ext.Object.each(me.rows, function(key, rowdef) {
+			if (Ext.isDefined(root[key])) {
+			    data.push({ key: key, value: root[key] });
+			} else if (Ext.isDefined(rowdef.defaultValue)) {
+			    data.push({ key: key, value: rowdef.defaultValue });
+			} else if (rowdef.required) {
+			    data.push({ key: key, value: undefined });
+			}
+		    });
+		} else {
+		    Ext.Object.each(root, function(key, value) {
+			data.push({ key: key, value: value });
+		    });
+		}
+	    }
+	} catch (ex) {
+	    Ext.Error.raise({
+		response: response,
+		json: response.responseText,
+		parseError: ex,
+		msg: 'Unable to parse the JSON returned by the server: ' + ex.toString(),
+	    });
+	}
+
+	return data;
+    },
+});
+Ext.define('Proxmox.RestProxy', {
+    extend: 'Ext.data.RestProxy',
+    alias: 'proxy.proxmox',
+
+    pageParam: null,
+    startParam: null,
+    limitParam: null,
+    groupParam: null,
+    sortParam: null,
+    filterParam: null,
+    noCache: false,
+
+    afterRequest: function(request, success) {
+	this.fireEvent('afterload', this, request, success);
+    },
+
+    constructor: function(config) {
+	Ext.applyIf(config, {
+	    reader: {
+		responseType: undefined,
+		type: 'json',
+		rootProperty: config.root || 'data',
+	    },
+	});
+
+	this.callParent([config]);
+    },
+}, function() {
+    Ext.define('KeyValue', {
+	extend: "Ext.data.Model",
+	fields: ['key', 'value'],
+	idProperty: 'key',
+    });
+
+    Ext.define('KeyValuePendingDelete', {
+	extend: "Ext.data.Model",
+	fields: ['key', 'value', 'pending', 'delete'],
+	idProperty: 'key',
+    });
+
+    Ext.define('proxmox-tasks', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    { name: 'starttime', type: 'date', dateFormat: 'timestamp' },
+	    { name: 'endtime', type: 'date', dateFormat: 'timestamp' },
+	    { name: 'pid', type: 'int' },
+	    {
+		name: 'duration',
+		sortType: 'asInt',
+		calculate: function(data) {
+		    let endtime = data.endtime;
+		    let starttime = data.starttime;
+		    if (endtime !== undefined) {
+			return (endtime - starttime)/1000;
+		    }
+		    return 0;
+		},
+	    },
+	    'node', 'upid', 'user', 'tokenid', 'status', 'type', 'id',
+	],
+	idProperty: 'upid',
+    });
+
+    Ext.define('proxmox-cluster-log', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    { name: 'uid', type: 'int' },
+	    { name: 'time', type: 'date', dateFormat: 'timestamp' },
+	    { name: 'pri', type: 'int' },
+	    { name: 'pid', type: 'int' },
+	    'node', 'user', 'tag', 'msg',
+	    {
+		name: 'id',
+		convert: function(value, record) {
+		    let info = record.data;
+
+		    if (value) {
+			return value;
+		    }
+		    // compute unique ID
+		    return info.uid + ':' + info.node;
+		},
+	    },
+	],
+	idProperty: 'id',
+    });
+});
+/*
+ * Extends the Ext.data.Store type with  startUpdate() and stopUpdate() methods
+ * to refresh the store data in the background.
+ * Components using this store directly will flicker due to the redisplay of
+ * the element ater 'config.interval' ms.
+ *
+ * Note that you have to set 'autoStart' or call startUpdate() once yourself
+ * for the background load to begin.
+ */
+Ext.define('Proxmox.data.UpdateStore', {
+    extend: 'Ext.data.Store',
+    alias: 'store.update',
+
+    config: {
+	interval: 3000,
+
+	isStopped: true,
+
+	autoStart: false,
+    },
+
+    destroy: function() {
+	let me = this;
+	me.stopUpdate();
+	me.callParent();
+    },
+
+    constructor: function(config) {
+	let me = this;
+
+	config = config || {};
+	if (config.interval === undefined) {
+	    delete config.interval;
+	}
+
+
+	let load_task = new Ext.util.DelayedTask();
+
+	let run_load_task = function() {
+	    if (me.getIsStopped()) {
+		return;
+	    }
+
+	    if (Proxmox.Utils.authOK()) {
+		let start = new Date();
+		me.load(function() {
+		    let runtime = new Date() - start;
+		    let interval = me.getInterval() + runtime*2;
+		    load_task.delay(interval, run_load_task);
+		});
+	    } else {
+		load_task.delay(200, run_load_task);
+	    }
+	};
+
+	Ext.apply(config, {
+	    startUpdate: function() {
+		me.setIsStopped(false);
+		// run_load_task(); this makes problems with chrome
+		load_task.delay(1, run_load_task);
+	    },
+	    stopUpdate: function() {
+		me.setIsStopped(true);
+		load_task.cancel();
+	    },
+	});
+
+	me.callParent([config]);
+
+	me.load_task = load_task;
+
+	if (me.getAutoStart()) {
+	    me.startUpdate();
+	}
+    },
+});
+/*
+ * The DiffStore is a in-memory store acting as proxy between a real store
+ * instance and a component.
+ * Its purpose is to redisplay the component *only* if the data has been changed
+ * inside the real store, to avoid the annoying visual flickering of using
+ * the real store directly.
+ *
+ * Implementation:
+ * The DiffStore monitors via mon() the 'load' events sent by the real store.
+ * On each 'load' event, the DiffStore compares its own content with the target
+ * store (call to cond_add_item()) and then fires a 'refresh' event.
+ * The 'refresh' event will automatically trigger a view refresh on the component
+ * who binds to this store.
+ */
+
+/* Config properties:
+ * rstore: the realstore which will autorefresh its content from the API
+ * Only works if rstore has a model and use 'idProperty'
+ * sortAfterUpdate: sort the diffstore before rendering the view
+ */
+Ext.define('Proxmox.data.DiffStore', {
+    extend: 'Ext.data.Store',
+    alias: 'store.diff',
+
+    sortAfterUpdate: false,
+
+    // if true, destroy rstore on destruction. Defaults to true if a rstore
+    // config is passed instead of an existing rstore instance
+    autoDestroyRstore: false,
+
+    doDestroy: function() {
+	let me = this;
+	if (me.autoDestroyRstore) {
+	    if (Ext.isFunction(me.rstore.destroy)) {
+		me.rstore.destroy();
+	    }
+	    delete me.rstore;
+	}
+	me.callParent();
+    },
+
+    constructor: function(config) {
+	let me = this;
+
+	config = config || {};
+
+	if (!config.rstore) {
+	    throw "no rstore specified";
+	}
+
+	if (!config.rstore.model) {
+	    throw "no rstore model specified";
+	}
+
+	let rstore;
+	if (config.rstore.isInstance) {
+	    rstore = config.rstore;
+	} else if (config.rstore.type) {
+	    Ext.applyIf(config.rstore, {
+		autoDestroyRstore: true,
+	    });
+	    rstore = Ext.create(`store.${config.rstore.type}`, config.rstore);
+	} else {
+	    throw 'rstore is not an instance, and cannot autocreate without "type"';
+	}
+
+	Ext.apply(config, {
+	    model: rstore.model,
+	    proxy: { type: 'memory' },
+	});
+
+	me.callParent([config]);
+
+	me.rstore = rstore;
+
+	let first_load = true;
+
+	let cond_add_item = function(data, id) {
+	    let olditem = me.getById(id);
+	    if (olditem) {
+		olditem.beginEdit();
+		Ext.Array.each(me.model.prototype.fields, function(field) {
+		    if (olditem.data[field.name] !== data[field.name]) {
+			olditem.set(field.name, data[field.name]);
+		    }
+		});
+		olditem.endEdit(true);
+		olditem.commit();
+	    } else {
+		let newrec = Ext.create(me.model, data);
+		let pos = me.appendAtStart && !first_load ? 0 : me.data.length;
+		me.insert(pos, newrec);
+	    }
+	};
+
+	let loadFn = function(s, records, success) {
+	    if (!success) {
+		return;
+	    }
+
+	    me.suspendEvents();
+
+	    // getSource returns null if data is not filtered
+	    // if it is filtered it returns all records
+	    let allItems = me.getData().getSource() || me.getData();
+
+	    // remove vanished items
+	    allItems.each(function(olditem) {
+		let item = me.rstore.getById(olditem.getId());
+		if (!item) {
+		    me.remove(olditem);
+		}
+	    });
+
+	    me.rstore.each(function(item) {
+		cond_add_item(item.data, item.getId());
+	    });
+
+	    me.filter();
+
+	    if (me.sortAfterUpdate) {
+		me.sort();
+	    }
+
+	    first_load = false;
+
+	    me.resumeEvents();
+	    me.fireEvent('refresh', me);
+	    me.fireEvent('datachanged', me);
+	};
+
+	if (me.rstore.isLoaded()) {
+	    // if store is already loaded,
+	    // insert items instantly
+	    loadFn(me.rstore, [], true);
+	}
+
+	me.mon(me.rstore, 'load', loadFn);
+    },
+});
+/* This store encapsulates data items which are organized as an Array of key-values Objects
+ * ie data[0] contains something like {key: "keyboard", value: "da"}
+*
+* Designed to work with the KeyValue model and the JsonObject data reader
+*/
+Ext.define('Proxmox.data.ObjectStore', {
+    extend: 'Proxmox.data.UpdateStore',
+
+    getRecord: function() {
+	let me = this;
+	let record = Ext.create('Ext.data.Model');
+	me.getData().each(function(item) {
+	    record.set(item.data.key, item.data.value);
+	});
+	record.commit(true);
+	return record;
+    },
+
+    constructor: function(config) {
+	let me = this;
+
+        config = config || {};
+
+        Ext.applyIf(config, {
+	    model: 'KeyValue',
+            proxy: {
+                type: 'proxmox',
+		url: config.url,
+		extraParams: config.extraParams,
+                reader: {
+		    type: 'jsonobject',
+		    rows: config.rows,
+		    readArray: config.readArray,
+		    rootProperty: config.root || 'data',
+		},
+            },
+        });
+
+        me.callParent([config]);
+    },
+});
+/* Extends the Proxmox.data.UpdateStore type
+ *
+ *
+ */
+Ext.define('Proxmox.data.RRDStore', {
+    extend: 'Proxmox.data.UpdateStore',
+    alias: 'store.proxmoxRRDStore',
+
+    setRRDUrl: function(timeframe, cf) {
+	let me = this;
+	if (!timeframe) {
+	    timeframe = me.timeframe;
+	}
+
+	if (!cf) {
+	    cf = me.cf;
+	}
+
+	me.proxy.url = me.rrdurl + "?timeframe=" + timeframe + "&cf=" + cf;
+    },
+
+    proxy: {
+	type: 'proxmox',
+    },
+
+    timeframe: 'hour',
+
+    cf: 'AVERAGE',
+
+    constructor: function(config) {
+	let me = this;
+
+	config = config || {};
+
+	// set default interval to 30seconds
+	if (!config.interval) {
+	    config.interval = 30000;
+	}
+
+	// rrdurl is required
+	if (!config.rrdurl) {
+	    throw "no rrdurl specified";
+	}
+
+	let stateid = 'proxmoxRRDTypeSelection';
+	let sp = Ext.state.Manager.getProvider();
+	let stateinit = sp.get(stateid);
+
+        if (stateinit) {
+	    if (stateinit.timeframe !== me.timeframe || stateinit.cf !== me.rrdcffn) {
+		me.timeframe = stateinit.timeframe;
+		me.rrdcffn = stateinit.cf;
+	    }
+	}
+
+	me.callParent([config]);
+
+	me.setRRDUrl();
+	me.mon(sp, 'statechange', function(prov, key, state) {
+	    if (key === stateid) {
+		if (state && state.id) {
+		    if (state.timeframe !== me.timeframe || state.cf !== me.cf) {
+		        me.timeframe = state.timeframe;
+		        me.cf = state.cf;
+			me.setRRDUrl();
+			me.reload();
+		    }
+		}
+	    }
+	});
+    },
+});
+Ext.define('Timezone', {
+    extend: 'Ext.data.Model',
+    fields: ['zone'],
+});
+
+Ext.define('Proxmox.data.TimezoneStore', {
+    extend: 'Ext.data.Store',
+    model: 'Timezone',
+    data: [
+	    ['Africa/Abidjan'],
+	    ['Africa/Accra'],
+	    ['Africa/Addis_Ababa'],
+	    ['Africa/Algiers'],
+	    ['Africa/Asmara'],
+	    ['Africa/Bamako'],
+	    ['Africa/Bangui'],
+	    ['Africa/Banjul'],
+	    ['Africa/Bissau'],
+	    ['Africa/Blantyre'],
+	    ['Africa/Brazzaville'],
+	    ['Africa/Bujumbura'],
+	    ['Africa/Cairo'],
+	    ['Africa/Casablanca'],
+	    ['Africa/Ceuta'],
+	    ['Africa/Conakry'],
+	    ['Africa/Dakar'],
+	    ['Africa/Dar_es_Salaam'],
+	    ['Africa/Djibouti'],
+	    ['Africa/Douala'],
+	    ['Africa/El_Aaiun'],
+	    ['Africa/Freetown'],
+	    ['Africa/Gaborone'],
+	    ['Africa/Harare'],
+	    ['Africa/Johannesburg'],
+	    ['Africa/Kampala'],
+	    ['Africa/Khartoum'],
+	    ['Africa/Kigali'],
+	    ['Africa/Kinshasa'],
+	    ['Africa/Lagos'],
+	    ['Africa/Libreville'],
+	    ['Africa/Lome'],
+	    ['Africa/Luanda'],
+	    ['Africa/Lubumbashi'],
+	    ['Africa/Lusaka'],
+	    ['Africa/Malabo'],
+	    ['Africa/Maputo'],
+	    ['Africa/Maseru'],
+	    ['Africa/Mbabane'],
+	    ['Africa/Mogadishu'],
+	    ['Africa/Monrovia'],
+	    ['Africa/Nairobi'],
+	    ['Africa/Ndjamena'],
+	    ['Africa/Niamey'],
+	    ['Africa/Nouakchott'],
+	    ['Africa/Ouagadougou'],
+	    ['Africa/Porto-Novo'],
+	    ['Africa/Sao_Tome'],
+	    ['Africa/Tripoli'],
+	    ['Africa/Tunis'],
+	    ['Africa/Windhoek'],
+	    ['America/Adak'],
+	    ['America/Anchorage'],
+	    ['America/Anguilla'],
+	    ['America/Antigua'],
+	    ['America/Araguaina'],
+	    ['America/Argentina/Buenos_Aires'],
+	    ['America/Argentina/Catamarca'],
+	    ['America/Argentina/Cordoba'],
+	    ['America/Argentina/Jujuy'],
+	    ['America/Argentina/La_Rioja'],
+	    ['America/Argentina/Mendoza'],
+	    ['America/Argentina/Rio_Gallegos'],
+	    ['America/Argentina/Salta'],
+	    ['America/Argentina/San_Juan'],
+	    ['America/Argentina/San_Luis'],
+	    ['America/Argentina/Tucuman'],
+	    ['America/Argentina/Ushuaia'],
+	    ['America/Aruba'],
+	    ['America/Asuncion'],
+	    ['America/Atikokan'],
+	    ['America/Bahia'],
+	    ['America/Bahia_Banderas'],
+	    ['America/Barbados'],
+	    ['America/Belem'],
+	    ['America/Belize'],
+	    ['America/Blanc-Sablon'],
+	    ['America/Boa_Vista'],
+	    ['America/Bogota'],
+	    ['America/Boise'],
+	    ['America/Cambridge_Bay'],
+	    ['America/Campo_Grande'],
+	    ['America/Cancun'],
+	    ['America/Caracas'],
+	    ['America/Cayenne'],
+	    ['America/Cayman'],
+	    ['America/Chicago'],
+	    ['America/Chihuahua'],
+	    ['America/Costa_Rica'],
+	    ['America/Cuiaba'],
+	    ['America/Curacao'],
+	    ['America/Danmarkshavn'],
+	    ['America/Dawson'],
+	    ['America/Dawson_Creek'],
+	    ['America/Denver'],
+	    ['America/Detroit'],
+	    ['America/Dominica'],
+	    ['America/Edmonton'],
+	    ['America/Eirunepe'],
+	    ['America/El_Salvador'],
+	    ['America/Fortaleza'],
+	    ['America/Glace_Bay'],
+	    ['America/Godthab'],
+	    ['America/Goose_Bay'],
+	    ['America/Grand_Turk'],
+	    ['America/Grenada'],
+	    ['America/Guadeloupe'],
+	    ['America/Guatemala'],
+	    ['America/Guayaquil'],
+	    ['America/Guyana'],
+	    ['America/Halifax'],
+	    ['America/Havana'],
+	    ['America/Hermosillo'],
+	    ['America/Indiana/Indianapolis'],
+	    ['America/Indiana/Knox'],
+	    ['America/Indiana/Marengo'],
+	    ['America/Indiana/Petersburg'],
+	    ['America/Indiana/Tell_City'],
+	    ['America/Indiana/Vevay'],
+	    ['America/Indiana/Vincennes'],
+	    ['America/Indiana/Winamac'],
+	    ['America/Inuvik'],
+	    ['America/Iqaluit'],
+	    ['America/Jamaica'],
+	    ['America/Juneau'],
+	    ['America/Kentucky/Louisville'],
+	    ['America/Kentucky/Monticello'],
+	    ['America/La_Paz'],
+	    ['America/Lima'],
+	    ['America/Los_Angeles'],
+	    ['America/Maceio'],
+	    ['America/Managua'],
+	    ['America/Manaus'],
+	    ['America/Marigot'],
+	    ['America/Martinique'],
+	    ['America/Matamoros'],
+	    ['America/Mazatlan'],
+	    ['America/Menominee'],
+	    ['America/Merida'],
+	    ['America/Mexico_City'],
+	    ['America/Miquelon'],
+	    ['America/Moncton'],
+	    ['America/Monterrey'],
+	    ['America/Montevideo'],
+	    ['America/Montreal'],
+	    ['America/Montserrat'],
+	    ['America/Nassau'],
+	    ['America/New_York'],
+	    ['America/Nipigon'],
+	    ['America/Nome'],
+	    ['America/Noronha'],
+	    ['America/North_Dakota/Center'],
+	    ['America/North_Dakota/New_Salem'],
+	    ['America/Ojinaga'],
+	    ['America/Panama'],
+	    ['America/Pangnirtung'],
+	    ['America/Paramaribo'],
+	    ['America/Phoenix'],
+	    ['America/Port-au-Prince'],
+	    ['America/Port_of_Spain'],
+	    ['America/Porto_Velho'],
+	    ['America/Puerto_Rico'],
+	    ['America/Rainy_River'],
+	    ['America/Rankin_Inlet'],
+	    ['America/Recife'],
+	    ['America/Regina'],
+	    ['America/Resolute'],
+	    ['America/Rio_Branco'],
+	    ['America/Santa_Isabel'],
+	    ['America/Santarem'],
+	    ['America/Santiago'],
+	    ['America/Santo_Domingo'],
+	    ['America/Sao_Paulo'],
+	    ['America/Scoresbysund'],
+	    ['America/Shiprock'],
+	    ['America/St_Barthelemy'],
+	    ['America/St_Johns'],
+	    ['America/St_Kitts'],
+	    ['America/St_Lucia'],
+	    ['America/St_Thomas'],
+	    ['America/St_Vincent'],
+	    ['America/Swift_Current'],
+	    ['America/Tegucigalpa'],
+	    ['America/Thule'],
+	    ['America/Thunder_Bay'],
+	    ['America/Tijuana'],
+	    ['America/Toronto'],
+	    ['America/Tortola'],
+	    ['America/Vancouver'],
+	    ['America/Whitehorse'],
+	    ['America/Winnipeg'],
+	    ['America/Yakutat'],
+	    ['America/Yellowknife'],
+	    ['Antarctica/Casey'],
+	    ['Antarctica/Davis'],
+	    ['Antarctica/DumontDUrville'],
+	    ['Antarctica/Macquarie'],
+	    ['Antarctica/Mawson'],
+	    ['Antarctica/McMurdo'],
+	    ['Antarctica/Palmer'],
+	    ['Antarctica/Rothera'],
+	    ['Antarctica/South_Pole'],
+	    ['Antarctica/Syowa'],
+	    ['Antarctica/Vostok'],
+	    ['Arctic/Longyearbyen'],
+	    ['Asia/Aden'],
+	    ['Asia/Almaty'],
+	    ['Asia/Amman'],
+	    ['Asia/Anadyr'],
+	    ['Asia/Aqtau'],
+	    ['Asia/Aqtobe'],
+	    ['Asia/Ashgabat'],
+	    ['Asia/Baghdad'],
+	    ['Asia/Bahrain'],
+	    ['Asia/Baku'],
+	    ['Asia/Bangkok'],
+	    ['Asia/Beirut'],
+	    ['Asia/Bishkek'],
+	    ['Asia/Brunei'],
+	    ['Asia/Choibalsan'],
+	    ['Asia/Chongqing'],
+	    ['Asia/Colombo'],
+	    ['Asia/Damascus'],
+	    ['Asia/Dhaka'],
+	    ['Asia/Dili'],
+	    ['Asia/Dubai'],
+	    ['Asia/Dushanbe'],
+	    ['Asia/Gaza'],
+	    ['Asia/Harbin'],
+	    ['Asia/Ho_Chi_Minh'],
+	    ['Asia/Hong_Kong'],
+	    ['Asia/Hovd'],
+	    ['Asia/Irkutsk'],
+	    ['Asia/Jakarta'],
+	    ['Asia/Jayapura'],
+	    ['Asia/Jerusalem'],
+	    ['Asia/Kabul'],
+	    ['Asia/Kamchatka'],
+	    ['Asia/Karachi'],
+	    ['Asia/Kashgar'],
+	    ['Asia/Kathmandu'],
+	    ['Asia/Kolkata'],
+	    ['Asia/Krasnoyarsk'],
+	    ['Asia/Kuala_Lumpur'],
+	    ['Asia/Kuching'],
+	    ['Asia/Kuwait'],
+	    ['Asia/Macau'],
+	    ['Asia/Magadan'],
+	    ['Asia/Makassar'],
+	    ['Asia/Manila'],
+	    ['Asia/Muscat'],
+	    ['Asia/Nicosia'],
+	    ['Asia/Novokuznetsk'],
+	    ['Asia/Novosibirsk'],
+	    ['Asia/Omsk'],
+	    ['Asia/Oral'],
+	    ['Asia/Phnom_Penh'],
+	    ['Asia/Pontianak'],
+	    ['Asia/Pyongyang'],
+	    ['Asia/Qatar'],
+	    ['Asia/Qyzylorda'],
+	    ['Asia/Rangoon'],
+	    ['Asia/Riyadh'],
+	    ['Asia/Sakhalin'],
+	    ['Asia/Samarkand'],
+	    ['Asia/Seoul'],
+	    ['Asia/Shanghai'],
+	    ['Asia/Singapore'],
+	    ['Asia/Taipei'],
+	    ['Asia/Tashkent'],
+	    ['Asia/Tbilisi'],
+	    ['Asia/Tehran'],
+	    ['Asia/Thimphu'],
+	    ['Asia/Tokyo'],
+	    ['Asia/Ulaanbaatar'],
+	    ['Asia/Urumqi'],
+	    ['Asia/Vientiane'],
+	    ['Asia/Vladivostok'],
+	    ['Asia/Yakutsk'],
+	    ['Asia/Yekaterinburg'],
+	    ['Asia/Yerevan'],
+	    ['Atlantic/Azores'],
+	    ['Atlantic/Bermuda'],
+	    ['Atlantic/Canary'],
+	    ['Atlantic/Cape_Verde'],
+	    ['Atlantic/Faroe'],
+	    ['Atlantic/Madeira'],
+	    ['Atlantic/Reykjavik'],
+	    ['Atlantic/South_Georgia'],
+	    ['Atlantic/St_Helena'],
+	    ['Atlantic/Stanley'],
+	    ['Australia/Adelaide'],
+	    ['Australia/Brisbane'],
+	    ['Australia/Broken_Hill'],
+	    ['Australia/Currie'],
+	    ['Australia/Darwin'],
+	    ['Australia/Eucla'],
+	    ['Australia/Hobart'],
+	    ['Australia/Lindeman'],
+	    ['Australia/Lord_Howe'],
+	    ['Australia/Melbourne'],
+	    ['Australia/Perth'],
+	    ['Australia/Sydney'],
+	    ['Europe/Amsterdam'],
+	    ['Europe/Andorra'],
+	    ['Europe/Athens'],
+	    ['Europe/Belgrade'],
+	    ['Europe/Berlin'],
+	    ['Europe/Bratislava'],
+	    ['Europe/Brussels'],
+	    ['Europe/Bucharest'],
+	    ['Europe/Budapest'],
+	    ['Europe/Chisinau'],
+	    ['Europe/Copenhagen'],
+	    ['Europe/Dublin'],
+	    ['Europe/Gibraltar'],
+	    ['Europe/Guernsey'],
+	    ['Europe/Helsinki'],
+	    ['Europe/Isle_of_Man'],
+	    ['Europe/Istanbul'],
+	    ['Europe/Jersey'],
+	    ['Europe/Kaliningrad'],
+	    ['Europe/Kiev'],
+	    ['Europe/Lisbon'],
+	    ['Europe/Ljubljana'],
+	    ['Europe/London'],
+	    ['Europe/Luxembourg'],
+	    ['Europe/Madrid'],
+	    ['Europe/Malta'],
+	    ['Europe/Mariehamn'],
+	    ['Europe/Minsk'],
+	    ['Europe/Monaco'],
+	    ['Europe/Moscow'],
+	    ['Europe/Oslo'],
+	    ['Europe/Paris'],
+	    ['Europe/Podgorica'],
+	    ['Europe/Prague'],
+	    ['Europe/Riga'],
+	    ['Europe/Rome'],
+	    ['Europe/Samara'],
+	    ['Europe/San_Marino'],
+	    ['Europe/Sarajevo'],
+	    ['Europe/Simferopol'],
+	    ['Europe/Skopje'],
+	    ['Europe/Sofia'],
+	    ['Europe/Stockholm'],
+	    ['Europe/Tallinn'],
+	    ['Europe/Tirane'],
+	    ['Europe/Uzhgorod'],
+	    ['Europe/Vaduz'],
+	    ['Europe/Vatican'],
+	    ['Europe/Vienna'],
+	    ['Europe/Vilnius'],
+	    ['Europe/Volgograd'],
+	    ['Europe/Warsaw'],
+	    ['Europe/Zagreb'],
+	    ['Europe/Zaporozhye'],
+	    ['Europe/Zurich'],
+	    ['Indian/Antananarivo'],
+	    ['Indian/Chagos'],
+	    ['Indian/Christmas'],
+	    ['Indian/Cocos'],
+	    ['Indian/Comoro'],
+	    ['Indian/Kerguelen'],
+	    ['Indian/Mahe'],
+	    ['Indian/Maldives'],
+	    ['Indian/Mauritius'],
+	    ['Indian/Mayotte'],
+	    ['Indian/Reunion'],
+	    ['Pacific/Apia'],
+	    ['Pacific/Auckland'],
+	    ['Pacific/Chatham'],
+	    ['Pacific/Chuuk'],
+	    ['Pacific/Easter'],
+	    ['Pacific/Efate'],
+	    ['Pacific/Enderbury'],
+	    ['Pacific/Fakaofo'],
+	    ['Pacific/Fiji'],
+	    ['Pacific/Funafuti'],
+	    ['Pacific/Galapagos'],
+	    ['Pacific/Gambier'],
+	    ['Pacific/Guadalcanal'],
+	    ['Pacific/Guam'],
+	    ['Pacific/Honolulu'],
+	    ['Pacific/Johnston'],
+	    ['Pacific/Kiritimati'],
+	    ['Pacific/Kosrae'],
+	    ['Pacific/Kwajalein'],
+	    ['Pacific/Majuro'],
+	    ['Pacific/Marquesas'],
+	    ['Pacific/Midway'],
+	    ['Pacific/Nauru'],
+	    ['Pacific/Niue'],
+	    ['Pacific/Norfolk'],
+	    ['Pacific/Noumea'],
+	    ['Pacific/Pago_Pago'],
+	    ['Pacific/Palau'],
+	    ['Pacific/Pitcairn'],
+	    ['Pacific/Pohnpei'],
+	    ['Pacific/Port_Moresby'],
+	    ['Pacific/Rarotonga'],
+	    ['Pacific/Saipan'],
+	    ['Pacific/Tahiti'],
+	    ['Pacific/Tarawa'],
+	    ['Pacific/Tongatapu'],
+	    ['Pacific/Wake'],
+	    ['Pacific/Wallis'],
+	    ['UTC'],
+	],
+});
+Ext.define('proxmox-notification-endpoints', {
+    extend: 'Ext.data.Model',
+    fields: ['name', 'type', 'comment', 'disable', 'origin'],
+    proxy: {
+        type: 'proxmox',
+    },
+    idProperty: 'name',
+});
+
+Ext.define('proxmox-notification-matchers', {
+    extend: 'Ext.data.Model',
+    fields: ['name', 'comment', 'disable', 'origin'],
+    proxy: {
+        type: 'proxmox',
+    },
+    idProperty: 'name',
+});
+Ext.define('pmx-domains', {
+    extend: "Ext.data.Model",
+    fields: [
+	'realm', 'type', 'comment', 'default',
+	{
+	    name: 'tfa',
+	    allowNull: true,
+	},
+	{
+	    name: 'descr',
+	    convert: function(value, { data={} }) {
+		if (value) return Ext.String.htmlEncode(value);
+
+		let text = data.comment || data.realm;
+
+		if (data.tfa) {
+		    text += ` (+ ${data.tfa})`;
+		}
+
+		return Ext.String.htmlEncode(text);
+	    },
+	},
+    ],
+    idProperty: 'realm',
+    proxy: {
+	type: 'proxmox',
+	url: "/api2/json/access/domains",
+    },
+});
+Ext.define('proxmox-certificate', {
+    extend: 'Ext.data.Model',
+
+    fields: ['filename', 'fingerprint', 'issuer', 'notafter', 'notbefore', 'subject', 'san', 'public-key-bits', 'public-key-type'],
+    idProperty: 'filename',
+});
+Ext.define('proxmox-acme-accounts', {
+    extend: 'Ext.data.Model',
+    fields: ['name'],
+    proxy: {
+	type: 'proxmox',
+    },
+    idProperty: 'name',
+});
+
+Ext.define('proxmox-acme-challenges', {
+    extend: 'Ext.data.Model',
+    fields: ['id', 'type', 'schema'],
+    proxy: {
+	type: 'proxmox',
+    },
+    idProperty: 'id',
+});
+
+
+Ext.define('proxmox-acme-plugins', {
+    extend: 'Ext.data.Model',
+    fields: ['type', 'plugin', 'api'],
+    proxy: {
+	type: 'proxmox',
+    },
+    idProperty: 'plugin',
+});
+Ext.define('Proxmox.form.SizeField', {
+    extend: 'Ext.form.FieldContainer',
+    alias: 'widget.pmxSizeField',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    unit: 'MiB',
+	    unitPostfix: '',
+	},
+	formulas: {
+	    unitlabel: (get) => get('unit') + get('unitPostfix'),
+	},
+    },
+
+    emptyText: '',
+
+    layout: 'hbox',
+    defaults: {
+	hideLabel: true,
+    },
+
+    // display unit (TODO: make (optionally) selectable)
+    unit: 'MiB',
+    unitPostfix: '',
+
+    // use this if the backend saves values in another unit than bytes, e.g.,
+    // for KiB set it to 'KiB'
+    backendUnit: undefined,
+
+    // submit a canonical size unit, e.g., 20.5 MiB
+    submitAutoScaledSizeUnit: false,
+
+    // allow setting 0 and using it as a submit value
+    allowZero: false,
+
+    emptyValue: null,
+
+    items: [
+	{
+	    xtype: 'numberfield',
+	    cbind: {
+		name: '{name}',
+		emptyText: '{emptyText}',
+		allowZero: '{allowZero}',
+		emptyValue: '{emptyValue}',
+	    },
+	    minValue: 0,
+	    step: 1,
+	    submitLocaleSeparator: false,
+	    fieldStyle: 'text-align: right',
+	    flex: 1,
+	    enableKeyEvents: true,
+	    setValue: function(v) {
+		if (!this._transformed) {
+		    let fieldContainer = this.up('fieldcontainer');
+		    let vm = fieldContainer.getViewModel();
+		    let unit = vm.get('unit');
+
+		    if (typeof v === "string") {
+			v = Proxmox.Utils.size_unit_to_bytes(v);
+		    }
+		    v /= Proxmox.Utils.SizeUnits[unit];
+		    v *= fieldContainer.backendFactor;
+
+		    this._transformed = true;
+		}
+
+		if (Number(v) === 0 && !this.allowZero) {
+		    v = undefined;
+		}
+
+		return Ext.form.field.Text.prototype.setValue.call(this, v);
+	    },
+	    getSubmitValue: function() {
+		let v = this.processRawValue(this.getRawValue());
+		v = v.replace(this.decimalSeparator, '.');
+
+		if (v === undefined || v === '') {
+		    return this.emptyValue;
+		}
+
+		if (Number(v) === 0) {
+		    return this.allowZero ? 0 : null;
+		}
+
+		let fieldContainer = this.up('fieldcontainer');
+		let vm = fieldContainer.getViewModel();
+		let unit = vm.get('unit');
+
+		v = parseFloat(v) * Proxmox.Utils.SizeUnits[unit];
+
+		if (fieldContainer.submitAutoScaledSizeUnit) {
+		    return Proxmox.Utils.format_size(v, !unit.endsWith('iB'));
+		} else {
+		    return String(Math.floor(v / fieldContainer.backendFactor));
+		}
+	    },
+	    listeners: {
+		// our setValue gets only called if we have a value, avoid
+		// transformation of the first user-entered value
+		keydown: function() { this._transformed = true; },
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'unit',
+	    submitValue: false,
+	    padding: '0 0 0 10',
+	    bind: {
+		value: '{unitlabel}',
+	    },
+	    listeners: {
+		change: (f, v) => {
+		    f.originalValue = v;
+		},
+	    },
+	    width: 40,
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	me.unit = me.unit || 'MiB';
+	if (!(me.unit in Proxmox.Utils.SizeUnits)) {
+	    throw "unknown unit: " + me.unit;
+	}
+
+	me.backendFactor = 1;
+	if (me.backendUnit !== undefined) {
+	    if (!(me.unit in Proxmox.Utils.SizeUnits)) {
+		throw "unknown backend unit: " + me.backendUnit;
+	    }
+	    me.backendFactor = Proxmox.Utils.SizeUnits[me.backendUnit];
+	}
+
+	me.callParent(arguments);
+
+	me.getViewModel().set('unit', me.unit);
+	me.getViewModel().set('unitPostfix', me.unitPostfix);
+    },
+});
+
+Ext.define('Proxmox.form.BandwidthField', {
+    extend: 'Proxmox.form.SizeField',
+    alias: 'widget.pmxBandwidthField',
+
+    unitPostfix: '/s',
+});
+Ext.define('Proxmox.form.field.DisplayEdit', {
+    extend: 'Ext.form.FieldContainer',
+    alias: 'widget.pmxDisplayEditField',
+
+    viewModel: {
+	parent: null,
+	data: {
+	    editable: false,
+	    value: undefined,
+	},
+    },
+
+    displayType: 'displayfield',
+
+    editConfig: {},
+    editable: false,
+    setEditable: function(editable) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	me.editable = editable;
+	vm.set('editable', editable);
+    },
+    getEditable: function() {
+	let me = this;
+	let vm = me.getViewModel();
+	return vm.get('editable');
+    },
+
+    setValue: function(value) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	me.value = value;
+	vm.set('value', value);
+    },
+    getValue: function() {
+	let me = this;
+	let vm = me.getViewModel();
+	// FIXME: add return, but check all use-sites for regressions then
+	vm.get('value');
+    },
+
+    layout: 'fit',
+    defaults: {
+	hideLabel: true,
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let displayConfig = {
+	    xtype: me.displayType,
+	    bind: {},
+	};
+	Ext.applyIf(displayConfig, me.initialConfig);
+	delete displayConfig.editConfig;
+	delete displayConfig.editable;
+
+	let editConfig = Ext.apply({}, me.editConfig);
+	Ext.applyIf(editConfig, {
+	    xtype: 'textfield',
+	    bind: {},
+	});
+	Ext.applyIf(editConfig, displayConfig);
+
+	if (me.initialConfig && me.initialConfig.displayConfig) {
+	    Ext.applyIf(displayConfig, me.initialConfig.displayConfig);
+	    delete displayConfig.displayConfig;
+	}
+
+	Ext.applyIf(displayConfig, {
+	    renderer: v => Ext.htmlEncode(v),
+	});
+
+	Ext.applyIf(displayConfig.bind, {
+	    hidden: '{editable}',
+	    disabled: '{editable}',
+	    value: '{value}',
+	});
+	Ext.applyIf(editConfig.bind, {
+	    hidden: '{!editable}',
+	    disabled: '{!editable}',
+	    value: '{value}',
+	});
+
+	// avoid glitch, start off correct even before viewmodel fixes it
+	editConfig.disabled = editConfig.hidden = !me.editable;
+	displayConfig.disabled = displayConfig.hidden = !!me.editable;
+
+	editConfig.name = displayConfig.name = me.name;
+
+	Ext.apply(me, {
+	    items: [
+		editConfig,
+		displayConfig,
+	    ],
+	});
+
+	me.callParent();
+
+	me.getViewModel().set('editable', me.editable);
+    },
+
+});
+// treats 0 as "never expires"
+Ext.define('Proxmox.form.field.ExpireDate', {
+    extend: 'Ext.form.field.Date',
+    alias: ['widget.pmxExpireDate'],
+
+    name: 'expire',
+    fieldLabel: gettext('Expire'),
+    emptyText: 'never',
+    format: 'Y-m-d',
+    submitFormat: 'U',
+
+    getSubmitValue: function() {
+	let me = this;
+
+	let value = me.callParent();
+	if (!value) value = 0;
+
+	return value;
+    },
+
+    setValue: function(value) {
+	let me = this;
+
+	if (Ext.isDefined(value)) {
+	    if (!value) {
+		value = null;
+	    } else if (!Ext.isDate(value)) {
+		value = new Date(value * 1000);
+	    }
+	}
+	me.callParent([value]);
+    },
+
+});
+Ext.define('Proxmox.form.field.Integer', {
+    extend: 'Ext.form.field.Number',
+    alias: 'widget.proxmoxintegerfield',
+
+    config: {
+	deleteEmpty: false,
+    },
+
+    allowDecimals: false,
+    allowExponential: false,
+    step: 1,
+
+   getSubmitData: function() {
+	let me = this;
+	let data = null;
+	if (!me.disabled && me.submitValue && !me.isFileUpload()) {
+	    let val = me.getSubmitValue();
+	    if (val !== undefined && val !== null && val !== '') {
+		data = {};
+		data[me.getName()] = val;
+	    } else if (me.getDeleteEmpty()) {
+		data = {};
+		data.delete = me.getName();
+	    }
+	}
+	return data;
+    },
+
+});
+Ext.define('Proxmox.form.field.Textfield', {
+    extend: 'Ext.form.field.Text',
+    alias: ['widget.proxmoxtextfield'],
+
+    config: {
+	skipEmptyText: true,
+
+	deleteEmpty: false,
+
+	trimValue: false,
+    },
+
+    getSubmitData: function() {
+        let me = this,
+            data = null,
+            val;
+        if (!me.disabled && me.submitValue && !me.isFileUpload()) {
+            val = me.getSubmitValue();
+            if (val !== null) {
+                data = {};
+                data[me.getName()] = val;
+            } else if (me.getDeleteEmpty()) {
+		data = {};
+                data.delete = me.getName();
+	    }
+        }
+        return data;
+    },
+
+    getSubmitValue: function() {
+	let me = this;
+
+        let value = this.processRawValue(this.getRawValue());
+	if (me.getTrimValue() && typeof value === 'string') {
+	    value = value.trim();
+	}
+	if (value !== '') {
+	    return value;
+	}
+
+	return me.getSkipEmptyText() ? null: value;
+    },
+
+    setAllowBlank: function(allowBlank) {
+	this.allowBlank = allowBlank;
+	this.validate();
+    },
+});
+Ext.define('Proxmox.form.field.VlanField', {
+    extend: 'Ext.form.field.Number',
+    alias: ['widget.proxmoxvlanfield'],
+
+    deleteEmpty: false,
+
+    emptyText: gettext('no VLAN'),
+
+    fieldLabel: gettext('VLAN Tag'),
+
+    allowBlank: true,
+
+    getSubmitData: function() {
+	var me = this,
+	    data = null,
+	    val;
+	if (!me.disabled && me.submitValue) {
+	    val = me.getSubmitValue();
+	    if (val) {
+		data = {};
+		data[me.getName()] = val;
+	    } else if (me.deleteEmpty) {
+		data = {};
+		data.delete = me.getName();
+	    }
+	}
+	return data;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.apply(me, {
+	    minValue: 1,
+	    maxValue: 4094,
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.DateTimeField', {
+    extend: 'Ext.form.FieldContainer',
+    // FIXME: remove once all use sites upgraded (with versioned depends on new WTK!)
+    alias: ['widget.promxoxDateTimeField'],
+    xtype: 'proxmoxDateTimeField',
+
+    layout: 'hbox',
+
+    viewModel: {
+	data: {
+	    datetime: null,
+	    minDatetime: null,
+	    maxDatetime: null,
+	},
+
+	formulas: {
+	    date: {
+		get: function(get) {
+		    return get('datetime');
+		},
+		set: function(date) {
+		    if (!date) {
+			this.set('datetime', null);
+			return;
+		    }
+		    let datetime = new Date(this.get('datetime'));
+		    datetime.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
+		    this.set('datetime', datetime);
+		},
+	    },
+
+	    time: {
+		get: function(get) {
+		    return get('datetime');
+		},
+		set: function(time) {
+		    if (!time) {
+			this.set('datetime', null);
+			return;
+		    }
+		    let datetime = new Date(this.get('datetime'));
+		    datetime.setHours(time.getHours());
+		    datetime.setMinutes(time.getMinutes());
+		    datetime.setSeconds(time.getSeconds());
+		    datetime.setMilliseconds(time.getMilliseconds());
+		    this.set('datetime', datetime);
+		},
+	    },
+
+	    minDate: {
+		get: function(get) {
+		    let datetime = get('minDatetime');
+		    return datetime ? new Date(datetime) : null;
+		},
+	    },
+
+	    maxDate: {
+		get: function(get) {
+		    let datetime = get('maxDatetime');
+		    return datetime ? new Date(datetime) : null;
+		},
+	    },
+
+	    minTime: {
+		get: function(get) {
+		    let current = get('datetime');
+		    let min = get('minDatetime');
+		    if (min && current && !this.isSameDay(current, min)) {
+			return new Date(min).setHours('00', '00', '00', '000');
+		    }
+		    return min;
+		},
+	    },
+
+	    maxTime: {
+		get: function(get) {
+		    let current = get('datetime');
+		    let max = get('maxDatetime');
+		    if (max && current && !this.isSameDay(current, max)) {
+			return new Date(max).setHours('23', '59', '59', '999');
+		    }
+		    return max;
+		},
+	    },
+	},
+
+	// Helper function to check if dates are the same day of the year
+	isSameDay: function(date1, date2) {
+	    return date1.getDate() === date2.getDate() &&
+		date1.getMonth() === date2.getMonth() &&
+		date1.getFullYear() === date2.getFullYear();
+	},
+    },
+
+    config: {
+	value: null,
+	submitFormat: 'U',
+	disabled: false,
+    },
+
+    setValue: function(value) {
+	this.getViewModel().set('datetime', value);
+    },
+
+    getValue: function() {
+	return this.getViewModel().get('datetime');
+    },
+
+    getSubmitValue: function() {
+	let me = this;
+	let value = me.getValue();
+	return value ? Ext.Date.format(value, me.submitFormat) : null;
+    },
+
+    setMinValue: function(value) {
+	this.getViewModel().set('minDatetime', value);
+    },
+
+    getMinValue: function() {
+	return this.getViewModel().get('minDatetime');
+    },
+
+    setMaxValue: function(value) {
+	this.getViewModel().set('maxDatetime', value);
+    },
+
+    getMaxValue: function() {
+	return this.getViewModel().get('maxDatetime');
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+
+	let vm = me.getViewModel();
+	vm.set('datetime', me.config.value);
+	// Propagate state change to binding
+	vm.bind('{datetime}', function(value) {
+	    me.publishState('value', value);
+	    me.fireEvent('change', value);
+	});
+    },
+
+    items: [
+	{
+	    xtype: 'datefield',
+	    editable: false,
+	    flex: 1,
+	    format: 'Y-m-d',
+	    bind: {
+		value: '{date}',
+		minValue: '{minDate}',
+		maxValue: '{maxDate}',
+	    },
+	},
+	{
+	    xtype: 'timefield',
+	    format: 'H:i',
+	    width: 80,
+	    value: '00:00',
+	    increment: 60,
+	    bind: {
+		value: '{time}',
+		minValue: '{minTime}',
+		maxValue: '{maxTime}',
+	    },
+	},
+    ],
+});
+Ext.define('Proxmox.form.Checkbox', {
+    extend: 'Ext.form.field.Checkbox',
+    alias: ['widget.proxmoxcheckbox'],
+
+    config: {
+	defaultValue: undefined,
+	deleteDefaultValue: false,
+	deleteEmpty: false,
+	clearOnDisable: false,
+    },
+
+    inputValue: '1',
+
+    getSubmitData: function() {
+        let me = this,
+            data = null,
+            val;
+        if (!me.disabled && me.submitValue) {
+            val = me.getSubmitValue();
+            if (val !== null) {
+                data = {};
+		if (val === me.getDefaultValue() && me.getDeleteDefaultValue()) {
+		    data.delete = me.getName();
+		} else {
+                    data[me.getName()] = val;
+		}
+            } else if (me.getDeleteEmpty()) {
+               data = {};
+               data.delete = me.getName();
+	    }
+        }
+        return data;
+    },
+
+    setDisabled: function(disabled) {
+	let me = this;
+
+	// only clear on actual transition
+	let toClearValue = me.clearOnDisable && !me.disabled && disabled;
+
+	me.callParent(arguments);
+
+	if (toClearValue) {
+	    me.setValue(false); // TODO: could support other "reset value" or use originalValue?
+	}
+    },
+
+    // also accept integer 1 as true
+    setRawValue: function(value) {
+	let me = this;
+
+	if (value === 1) {
+            me.callParent([true]);
+	} else {
+            me.callParent([value]);
+	}
+    },
+
+});
+/* Key-Value ComboBox
+ *
+ * config properties:
+ * comboItems: an array of Key - Value pairs
+ * deleteEmpty: if set to true (default), an empty value received from the
+ * comboBox will reset the property to its default value
+ */
+Ext.define('Proxmox.form.KVComboBox', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.proxmoxKVComboBox',
+
+    config: {
+	deleteEmpty: true,
+    },
+
+    comboItems: undefined,
+    displayField: 'value',
+    valueField: 'key',
+    queryMode: 'local',
+
+    // override framework function to implement deleteEmpty behaviour
+    getSubmitData: function() {
+        let me = this,
+            data = null,
+            val;
+        if (!me.disabled && me.submitValue) {
+            val = me.getSubmitValue();
+            if (val !== null && val !== '' && val !== '__default__') {
+                data = {};
+                data[me.getName()] = val;
+            } else if (me.getDeleteEmpty()) {
+                data = {};
+                data.delete = me.getName();
+            }
+        }
+        return data;
+    },
+
+    validator: function(val) {
+	let me = this;
+
+	if (me.editable || val === null || val === '') {
+	    return true;
+	}
+
+	if (me.store.getCount() > 0) {
+	    let values = me.multiSelect ? val.split(me.delimiter) : [val];
+	    let items = me.store.getData().collect('value', 'data');
+	    if (Ext.Array.every(values, function(value) {
+		return Ext.Array.contains(items, value);
+	    })) {
+		return true;
+	    }
+	}
+
+	// returns a boolean or string
+	return "value '" + val + "' not allowed!";
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.store = Ext.create('Ext.data.ArrayStore', {
+	    model: 'KeyValue',
+	    data: me.comboItems,
+	});
+
+	if (me.initialConfig.editable === undefined) {
+	    me.editable = false;
+	}
+
+	me.callParent();
+    },
+
+    setComboItems: function(items) {
+	let me = this;
+
+	me.getStore().setData(items);
+    },
+
+});
+Ext.define('Proxmox.form.LanguageSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    xtype: 'proxmoxLanguageSelector',
+
+    comboItems: Proxmox.Utils.language_array(),
+
+    matchFieldWidth: false,
+    listConfig: {
+	width: 300,
+    },
+});
+/*
+ * ComboGrid component: a ComboBox where the dropdown menu (the
+ * "Picker") is a Grid with Rows and Columns expects a listConfig
+ * object with a columns property roughly based on the GridPicker from
+ * https://www.sencha.com/forum/showthread.php?299909
+ *
+*/
+
+Ext.define('Proxmox.form.ComboGrid', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: ['widget.proxmoxComboGrid'],
+
+    // this value is used as default value after load()
+    preferredValue: undefined,
+
+    // hack: allow to select empty value
+    // seems extjs does not allow that when 'editable == false'
+    onKeyUp: function(e, t) {
+        let me = this;
+        let key = e.getKey();
+
+        if (!me.editable && me.allowBlank && !me.multiSelect &&
+	    (key === e.BACKSPACE || key === e.DELETE)) {
+	    me.setValue('');
+	}
+
+        me.callParent(arguments);
+    },
+
+    config: {
+	skipEmptyText: false,
+	notFoundIsValid: false,
+	deleteEmpty: false,
+	errorHeight: 100,
+	// NOTE: the trigger will always be shown if allowBlank is true, setting showClearTrigger
+	// to false cannot change that
+	showClearTrigger: false,
+    },
+
+    // needed to trigger onKeyUp etc.
+    enableKeyEvents: true,
+
+    editable: false,
+
+    triggers: {
+	clear: {
+	    cls: 'pmx-clear-trigger',
+	    weight: -1,
+	    hidden: true,
+	    handler: function() {
+		let me = this;
+		me.setValue('');
+	    },
+	},
+    },
+
+    setValue: function(value) {
+	let me = this;
+	let empty = Ext.isArray(value) ? !value.length : !value;
+	me.triggers.clear.setVisible(!empty && (me.allowBlank || me.showClearTrigger));
+	return me.callParent([value]);
+    },
+
+    // override ExtJS method
+    // if the field has multiSelect enabled, the store is not loaded, and
+    // the displayfield == valuefield, it saves the rawvalue as an array
+    // but the getRawValue method is only defined in the textfield class
+    // (which has not to deal with arrays) an returns the string in the
+    // field (not an array)
+    //
+    // so if we have multiselect enabled, return the rawValue (which
+    // should be an array) and else we do callParent so
+    // it should not impact any other use of the class
+    getRawValue: function() {
+	let me = this;
+	if (me.multiSelect) {
+	    return me.rawValue;
+	} else {
+	    return me.callParent();
+	}
+    },
+
+    getSubmitData: function() {
+	let me = this;
+
+	let data = null;
+	if (!me.disabled && me.submitValue) {
+	    let val = me.getSubmitValue();
+	    if (val !== null) {
+		data = {};
+		data[me.getName()] = val;
+	    } else if (me.getDeleteEmpty()) {
+		data = {};
+		data.delete = me.getName();
+	    }
+	}
+	return data;
+   },
+
+    getSubmitValue: function() {
+	let me = this;
+
+	let value = me.callParent();
+	if (value !== '') {
+	    return value;
+	}
+
+	return me.getSkipEmptyText() ? null: value;
+    },
+
+    setAllowBlank: function(allowBlank) {
+	this.allowBlank = allowBlank;
+	this.validate();
+    },
+
+// override ExtJS protected method
+    onBindStore: function(store, initial) {
+	let me = this,
+	    picker = me.picker,
+	    extraKeySpec,
+	    valueCollectionConfig;
+
+	// We're being bound, not unbound...
+	if (store) {
+	    // If store was created from a 2 dimensional array with generated field names 'field1' and 'field2'
+	    if (store.autoCreated) {
+		me.queryMode = 'local';
+		me.valueField = me.displayField = 'field1';
+		if (!store.expanded) {
+		    me.displayField = 'field2';
+		}
+
+		// displayTpl config will need regenerating with the autogenerated displayField name 'field1'
+		me.setDisplayTpl(null);
+	    }
+	    if (!Ext.isDefined(me.valueField)) {
+		me.valueField = me.displayField;
+	    }
+
+	    // Add a byValue index to the store so that we can efficiently look up records by the value field
+	    // when setValue passes string value(s).
+	    // The two indices (Ext.util.CollectionKeys) are configured unique: false, so that if duplicate keys
+	    // are found, they are all returned by the get call.
+	    // This is so that findByText and findByValue are able to return the *FIRST* matching value. By default,
+	    // if unique is true, CollectionKey keeps the *last* matching value.
+	    extraKeySpec = {
+		byValue: {
+		    rootProperty: 'data',
+		    unique: false,
+		},
+	    };
+	    extraKeySpec.byValue.property = me.valueField;
+	    store.setExtraKeys(extraKeySpec);
+
+	    if (me.displayField === me.valueField) {
+		store.byText = store.byValue;
+	    } else {
+		extraKeySpec.byText = {
+		    rootProperty: 'data',
+		    unique: false,
+		};
+		extraKeySpec.byText.property = me.displayField;
+		store.setExtraKeys(extraKeySpec);
+	    }
+
+	    // We hold a collection of the values which have been selected, keyed by this field's valueField.
+	    // This collection also functions as the selected items collection for the BoundList's selection model
+	    valueCollectionConfig = {
+		rootProperty: 'data',
+		extraKeys: {
+		    byInternalId: {
+			property: 'internalId',
+		    },
+		    byValue: {
+			property: me.valueField,
+			rootProperty: 'data',
+		    },
+		},
+		// Whenever this collection is changed by anyone, whether by this field adding to it,
+		// or the BoundList operating, we must refresh our value.
+		listeners: {
+		    beginupdate: me.onValueCollectionBeginUpdate,
+		    endupdate: me.onValueCollectionEndUpdate,
+		    scope: me,
+		},
+	    };
+
+	    // This becomes our collection of selected records for the Field.
+	    me.valueCollection = new Ext.util.Collection(valueCollectionConfig);
+
+	    // We use the selected Collection as our value collection and the basis
+	    // for rendering the tag list.
+
+	    //proxmox override: since the picker is represented by a grid panel,
+	    // we changed here the selection to RowModel
+	    me.pickerSelectionModel = new Ext.selection.RowModel({
+		mode: me.multiSelect ? 'SIMPLE' : 'SINGLE',
+		// There are situations when a row is selected on mousedown but then the mouse is
+		// dragged to another row and released.  In these situations, the event target for
+		// the click event won't be the row where the mouse was released but the boundview.
+		// The view will then determine that it should fire a container click, and the
+		// DataViewModel will then deselect all prior selections. Setting
+		// `deselectOnContainerClick` here will prevent the model from deselecting.
+		deselectOnContainerClick: false,
+		enableInitialSelection: false,
+		pruneRemoved: false,
+		selected: me.valueCollection,
+		store: store,
+		listeners: {
+		    scope: me,
+		    lastselectedchanged: me.updateBindSelection,
+		},
+	    });
+
+	    if (!initial) {
+		me.resetToDefault();
+	    }
+
+	    if (picker) {
+		picker.setSelectionModel(me.pickerSelectionModel);
+		if (picker.getStore() !== store) {
+		    picker.bindStore(store);
+		}
+	    }
+	}
+    },
+
+    // copied from ComboBox
+    createPicker: function() {
+        let me = this;
+        let picker;
+
+        let pickerCfg = Ext.apply({
+                // proxmox overrides: display a grid for selection
+                xtype: 'gridpanel',
+                id: me.pickerId,
+                pickerField: me,
+                floating: true,
+                hidden: true,
+                store: me.store,
+                displayField: me.displayField,
+                preserveScrollOnRefresh: true,
+                pageSize: me.pageSize,
+                tpl: me.tpl,
+                selModel: me.pickerSelectionModel,
+                focusOnToFront: false,
+            }, me.listConfig, me.defaultListConfig);
+
+        picker = me.picker || Ext.widget(pickerCfg);
+
+        if (picker.getStore() !== me.store) {
+            picker.bindStore(me.store);
+        }
+
+        if (me.pageSize) {
+            picker.pagingToolbar.on('beforechange', me.onPageChange, me);
+        }
+
+        // proxmox overrides: pass missing method in gridPanel to its view
+        picker.refresh = function() {
+            picker.getSelectionModel().select(me.valueCollection.getRange());
+            picker.getView().refresh();
+        };
+        picker.getNodeByRecord = function() {
+            picker.getView().getNodeByRecord(arguments);
+        };
+
+        // We limit the height of the picker to fit in the space above
+        // or below this field unless the picker has its own ideas about that.
+        if (!picker.initialConfig.maxHeight) {
+            picker.on({
+                beforeshow: me.onBeforePickerShow,
+                scope: me,
+            });
+        }
+        picker.getSelectionModel().on({
+            beforeselect: me.onBeforeSelect,
+            beforedeselect: me.onBeforeDeselect,
+            focuschange: me.onFocusChange,
+            selectionChange: function(sm, selectedRecords) {
+                if (selectedRecords.length) {
+                    this.setValue(selectedRecords);
+                    this.fireEvent('select', me, selectedRecords);
+                }
+            },
+            scope: me,
+        });
+
+	// hack for extjs6
+	// when the clicked item is the same as the previously selected,
+	// it does not select the item
+	// instead we hide the picker
+	if (!me.multiSelect) {
+	    picker.on('itemclick', function(sm, record) {
+		if (picker.getSelection()[0] === record) {
+		    me.collapse();
+		}
+	    });
+	}
+
+	// when our store is not yet loaded, we increase
+	// the height of the gridpanel, so that we can see
+	// the loading mask
+	//
+	// we save the minheight to reset it after the load
+	picker.on('show', function() {
+	    me.store.fireEvent('refresh');
+	    if (me.enableLoadMask) {
+		me.savedMinHeight = me.savedMinHeight ?? picker.getMinHeight();
+		picker.setMinHeight(me.errorHeight);
+	    }
+	    if (me.loadError) {
+		Proxmox.Utils.setErrorMask(picker.getView(), me.loadError);
+		delete me.loadError;
+		picker.updateLayout();
+	    }
+	});
+
+        picker.getNavigationModel().navigateOnSpace = false;
+
+        return picker;
+    },
+
+    clearLocalFilter: function() {
+        let me = this;
+
+	if (me.queryFilter) {
+	    me.changingFilters = true; // FIXME: unused?
+	    me.store.removeFilter(me.queryFilter, true);
+	    me.queryFilter = null;
+	    me.changingFilters = false;
+	}
+    },
+
+    isValueInStore: function(value) {
+	let me = this;
+	let store = me.store;
+	let found = false;
+
+	if (!store) {
+	    return found;
+	}
+
+	// Make sure the current filter is removed before checking the store
+	// to prevent false negative results when iterating over a filtered store.
+	// All store.find*() method's operate on the filtered store.
+	if (me.queryFilter && me.queryMode === 'local' && me.clearFilterOnBlur) {
+	    me.clearLocalFilter();
+	}
+
+	if (Ext.isArray(value)) {
+	    Ext.Array.each(value, function(v) {
+		if (store.findRecord(me.valueField, v, 0, false, true, true)) {
+		    found = true;
+		    return false; // break
+		}
+		return true;
+	    });
+	} else {
+	    found = !!store.findRecord(me.valueField, value, 0, false, true, true);
+	}
+
+	return found;
+    },
+
+    validator: function(value) {
+	let me = this;
+
+	if (!value) {
+	    return true; // handled later by allowEmpty in the getErrors call chain
+	}
+
+	// we normally get here the displayField as value, but if a valueField
+	// is configured we need to get the "actual" value, to ensure it is in
+	// the store. Below check is copied from ExtJS 6.0.2 ComboBox source
+	//
+	// we also have to get the 'real' value if the we have a mulitSelect
+	// Field but got a non array value
+	if ((me.valueField && me.valueField !== me.displayField) ||
+	    (me.multiSelect && !Ext.isArray(value))) {
+	    value = me.getValue();
+	}
+
+	if (!(me.notFoundIsValid || me.isValueInStore(value))) {
+	    return gettext('Invalid Value');
+	}
+
+	return true;
+    },
+
+    // validate after enabling a field, otherwise blank fields with !allowBlank
+    // are sometimes not marked as invalid
+    setDisabled: function(value) {
+	this.callParent([value]);
+	this.validate();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	Ext.apply(me, {
+	    queryMode: 'local',
+	    matchFieldWidth: false,
+	});
+
+	Ext.applyIf(me, { value: [] }); // hack: avoid ExtJS validate() bug
+
+	Ext.applyIf(me.listConfig, { width: 400 });
+
+	me.callParent();
+
+	// Create the picker at an early stage, so it is available to store the previous selection
+	if (!me.picker) {
+	    me.getPicker();
+	}
+
+	me.mon(me.store, 'beforeload', function() {
+	    if (!me.isDisabled()) {
+		me.enableLoadMask = true;
+	    }
+	});
+
+	// hack: autoSelect does not work
+	me.mon(me.store, 'load', function(store, r, success, o) {
+	    if (success) {
+		me.clearInvalid();
+		delete me.loadError;
+
+		if (me.enableLoadMask) {
+		    delete me.enableLoadMask;
+
+		    // if the picker exists, we reset its minHeight to the previous saved one or 0
+		    if (me.picker) {
+			me.picker.setMinHeight(me.savedMinHeight || 0);
+			Proxmox.Utils.setErrorMask(me.picker.getView());
+			delete me.savedMinHeight;
+			// we have to update the layout, otherwise the height gets not recalculated
+			me.picker.updateLayout();
+		    }
+		}
+
+		let def = me.getValue() || me.preferredValue;
+		if (def) {
+		    me.setValue(def, true); // sync with grid
+		}
+		let found = false;
+		if (def) {
+		    found = me.isValueInStore(def);
+		}
+
+		if (!found) {
+		    if (!(Ext.isArray(def) ? def.length : def)) {
+			let rec = me.store.first();
+			if (me.autoSelect && rec && rec.data) {
+			    def = rec.data[me.valueField];
+			    me.setValue(def, true);
+			} else if (!me.allowBlank) {
+			    me.setValue(def);
+			    if (!me.isDisabled()) {
+				me.markInvalid(me.blankText);
+			    }
+			}
+		    } else if (!me.notFoundIsValid && !me.isDisabled()) {
+			me.markInvalid(gettext('Invalid Value'));
+		    }
+		}
+	    } else {
+		let msg = Proxmox.Utils.getResponseErrorMessage(o.getError());
+		if (me.picker) {
+		    me.savedMinHeight = me.savedMinHeight ?? me.picker.getMinHeight();
+		    me.picker.setMinHeight(me.errorHeight);
+		    Proxmox.Utils.setErrorMask(me.picker.getView(), msg);
+		    me.picker.updateLayout();
+		}
+		me.loadError = msg;
+	    }
+	});
+    },
+});
+Ext.define('Proxmox.form.RRDTypeSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: ['widget.proxmoxRRDTypeSelector'],
+
+    displayField: 'text',
+    valueField: 'id',
+    editable: false,
+    queryMode: 'local',
+    value: 'hour',
+    stateEvents: ['select'],
+    stateful: true,
+    stateId: 'proxmoxRRDTypeSelection',
+    store: {
+	type: 'array',
+	fields: ['id', 'timeframe', 'cf', 'text'],
+	data: [
+	    ['hour', 'hour', 'AVERAGE',
+	      gettext('Hour') + ' (' + gettext('average') +')'],
+	    ['hourmax', 'hour', 'MAX',
+	      gettext('Hour') + ' (' + gettext('maximum') + ')'],
+	    ['day', 'day', 'AVERAGE',
+	      gettext('Day') + ' (' + gettext('average') + ')'],
+	    ['daymax', 'day', 'MAX',
+	      gettext('Day') + ' (' + gettext('maximum') + ')'],
+	    ['week', 'week', 'AVERAGE',
+	      gettext('Week') + ' (' + gettext('average') + ')'],
+	    ['weekmax', 'week', 'MAX',
+	      gettext('Week') + ' (' + gettext('maximum') + ')'],
+	    ['month', 'month', 'AVERAGE',
+	      gettext('Month') + ' (' + gettext('average') + ')'],
+	    ['monthmax', 'month', 'MAX',
+	      gettext('Month') + ' (' + gettext('maximum') + ')'],
+	    ['year', 'year', 'AVERAGE',
+	      gettext('Year') + ' (' + gettext('average') + ')'],
+	    ['yearmax', 'year', 'MAX',
+	      gettext('Year') + ' (' + gettext('maximum') + ')'],
+	],
+    },
+    // save current selection in the state Provider so RRDView can read it
+    getState: function() {
+	let ind = this.getStore().findExact('id', this.getValue());
+	let rec = this.getStore().getAt(ind);
+	if (!rec) {
+	    return undefined;
+	}
+	return {
+	    id: rec.data.id,
+	    timeframe: rec.data.timeframe,
+	    cf: rec.data.cf,
+	};
+    },
+    // set selection based on last saved state
+    applyState: function(state) {
+	if (state && state.id) {
+	    this.setValue(state.id);
+	}
+    },
+});
+Ext.define('Proxmox.form.BondModeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.bondModeSelector'],
+
+    openvswitch: false,
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.openvswitch) {
+	    me.comboItems = Proxmox.Utils.bond_mode_array([
+	       'active-backup',
+	       'balance-slb',
+	       'lacp-balance-slb',
+	       'lacp-balance-tcp',
+	    ]);
+	} else {
+	    me.comboItems = Proxmox.Utils.bond_mode_array([
+		'balance-rr',
+		'active-backup',
+		'balance-xor',
+		'broadcast',
+		'802.3ad',
+		'balance-tlb',
+		'balance-alb',
+	    ]);
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('Proxmox.form.BondPolicySelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.bondPolicySelector'],
+    comboItems: [
+	    ['layer2', 'layer2'],
+	    ['layer2+3', 'layer2+3'],
+	    ['layer3+4', 'layer3+4'],
+    ],
+});
+
+Ext.define('Proxmox.form.NetworkSelectorController', {
+    extend: 'Ext.app.ViewController',
+    alias: 'controller.proxmoxNetworkSelectorController',
+
+    init: function(view) {
+	let me = this;
+
+	if (!view.nodename) {
+	    throw "missing custom view config: nodename";
+	}
+	view.getStore().getProxy().setUrl('/api2/json/nodes/'+ view.nodename + '/network');
+    },
+});
+
+Ext.define('Proxmox.data.NetworkSelector', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{ name: 'active' },
+	{ name: 'cidr' },
+	{ name: 'cidr6' },
+	{ name: 'address' },
+	{ name: 'address6' },
+	{ name: 'comments' },
+	{ name: 'iface' },
+	{ name: 'slaves' },
+	{ name: 'type' },
+    ],
+});
+
+Ext.define('Proxmox.form.NetworkSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.proxmoxNetworkSelector',
+
+    controller: 'proxmoxNetworkSelectorController',
+
+    nodename: 'localhost',
+    setNodename: function(nodename) {
+	this.nodename = nodename;
+	let networkSelectorStore = this.getStore();
+	networkSelectorStore.removeAll();
+	// because of manual local copy of data for ip4/6
+	this.getPicker().refresh();
+	if (networkSelectorStore && typeof networkSelectorStore.getProxy === 'function') {
+	    networkSelectorStore.getProxy().setUrl('/api2/json/nodes/'+ nodename + '/network');
+	    networkSelectorStore.load();
+	}
+    },
+    valueField: 'cidr',
+    displayField: 'cidr',
+    store: {
+	autoLoad: true,
+	model: 'Proxmox.data.NetworkSelector',
+	proxy: {
+	    type: 'proxmox',
+	},
+	sorters: [
+	    {
+		property: 'iface',
+		direction: 'ASC',
+	    },
+	],
+	filters: [
+	    function(item) {
+		return item.data.cidr;
+	    },
+	],
+	listeners: {
+	    load: function(store, records, successful) {
+		if (successful) {
+		    records.forEach(function(record) {
+			if (record.data.cidr6) {
+			    let dest = record.data.cidr ? record.copy(null) : record;
+			    dest.data.cidr = record.data.cidr6;
+			    dest.data.address = record.data.address6;
+			    delete record.data.cidr6;
+			    dest.data.comments = record.data.comments6;
+			    delete record.data.comments6;
+			    store.add(dest);
+			}
+		    });
+		}
+	    },
+	},
+    },
+    listConfig: {
+	width: 600,
+	columns: [
+	    {
+
+		header: gettext('CIDR'),
+		dataIndex: 'cidr',
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+
+		header: gettext('IP'),
+		dataIndex: 'address',
+		hidden: true,
+	    },
+	    {
+		header: gettext('Interface'),
+		width: 90,
+		dataIndex: 'iface',
+	    },
+	    {
+		header: gettext('Active'),
+		renderer: Proxmox.Utils.format_boolean,
+		width: 60,
+		dataIndex: 'active',
+	    },
+	    {
+		header: gettext('Type'),
+		width: 80,
+		hidden: true,
+		dataIndex: 'type',
+	    },
+	    {
+		header: gettext('Comment'),
+		flex: 2,
+		dataIndex: 'comments',
+		renderer: Ext.String.htmlEncode,
+	    },
+	],
+    },
+});
+Ext.define('Proxmox.form.RealmComboBox', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pmxRealmComboBox',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let store = view.getStore();
+	    if (view.storeFilter) {
+		store.setFilters(view.storeFilter);
+	    }
+	    store.on('load', this.onLoad, view);
+	    store.load();
+	},
+
+	onLoad: function(store, records, success) {
+	    if (!success) {
+		return;
+	    }
+	    let me = this;
+	    let val = me.getValue();
+	    if (!val || !me.store.findRecord('realm', val, 0, false, true, true)) {
+		let def = 'pam';
+		Ext.each(records, function(rec) {
+		    if (rec.data && rec.data.default) {
+			def = rec.data.realm;
+		    }
+		});
+		me.setValue(def);
+	    }
+	},
+    },
+
+    // define custom filters for the underlying store
+    storeFilter: undefined,
+
+    fieldLabel: gettext('Realm'),
+    name: 'realm',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: false,
+    forceSelection: true,
+    autoSelect: false,
+    triggerAction: 'all',
+    valueField: 'realm',
+    displayField: 'descr',
+    getState: function() {
+	return { value: this.getValue() };
+    },
+    applyState: function(state) {
+	if (state && state.value) {
+	    this.setValue(state.value);
+	}
+    },
+    stateEvents: ['select'],
+    stateful: true, // last chosen auth realm is saved between page reloads
+    id: 'pveloginrealm', // We need stable ids when using stateful, not autogenerated
+    stateID: 'pveloginrealm',
+
+    store: {
+	model: 'pmx-domains',
+	autoLoad: false,
+    },
+});
+Ext.define('Proxmox.form.field.PruneKeep', {
+    extend: 'Proxmox.form.field.Integer',
+    xtype: 'pmxPruneKeepField',
+
+    allowBlank: true,
+    minValue: 1,
+
+    listeners: {
+	dirtychange: (field, dirty) => field.triggers.clear.setVisible(dirty),
+    },
+    triggers: {
+	clear: {
+	    cls: 'pmx-clear-trigger',
+	    weight: -1,
+	    hidden: true,
+	    handler: function() {
+		this.triggers.clear.setVisible(false);
+		this.setValue(this.originalValue);
+	    },
+	},
+    },
+
+});
+Ext.define('pmx-roles', {
+    extend: 'Ext.data.Model',
+    fields: ['roleid', 'privs'],
+    proxy: {
+	type: 'proxmox',
+	url: "/api2/json/access/roles",
+    },
+    idProperty: 'roleid',
+});
+
+Ext.define('Proxmox.form.RoleSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pmxRoleSelector',
+
+    allowBlank: false,
+    autoSelect: false,
+    valueField: 'roleid',
+    displayField: 'roleid',
+
+    listConfig: {
+	width: 560,
+	resizable: true,
+	columns: [
+	    {
+		header: gettext('Role'),
+		sortable: true,
+		dataIndex: 'roleid',
+		flex: 2,
+	    },
+	    {
+		header: gettext('Privileges'),
+		dataIndex: 'privs',
+		cellWrap: true,
+		// join manually here, as ExtJS joins without whitespace which breaks cellWrap
+		renderer: v => Ext.isArray(v) ? v.join(', ') : v.replaceAll(',', ', '),
+		flex: 5,
+	    },
+	],
+    },
+
+    store: {
+	autoLoad: true,
+	model: 'pmx-roles',
+	sorters: 'roleid',
+    },
+});
+Ext.define('Proxmox.form.DiskSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    xtype: 'pmxDiskSelector',
+
+    // can be
+    // undefined: all
+    // unused: only unused
+    // journal_disk: all disks with gpt
+    diskType: undefined,
+
+    // use include-partitions=1 as a parameter
+    includePartitions: false,
+
+    // the property the backend wants for the type ('type' by default)
+    typeProperty: 'type',
+
+    valueField: 'devpath',
+    displayField: 'devpath',
+    emptyText: gettext('No Disks unused'),
+    listConfig: {
+	width: 600,
+	columns: [
+	    {
+		header: gettext('Device'),
+		flex: 3,
+		sortable: true,
+		dataIndex: 'devpath',
+	    },
+	    {
+		header: gettext('Size'),
+		flex: 2,
+		sortable: false,
+		renderer: Proxmox.Utils.format_size,
+		dataIndex: 'size',
+	    },
+	    {
+		header: gettext('Serial'),
+		flex: 5,
+		sortable: true,
+		dataIndex: 'serial',
+	    },
+	],
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	let extraParams = {};
+
+	if (me.diskType) {
+	    extraParams[me.typeProperty] = me.diskType;
+	}
+
+	if (me.includePartitions) {
+	    extraParams['include-partitions'] = 1;
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    filterOnLoad: true,
+	    model: 'pmx-disk-list',
+	    proxy: {
+                type: 'proxmox',
+                url: `/api2/json/nodes/${nodename}/disks/list`,
+		extraParams,
+	    },
+	    sorters: [
+		{
+		    property: 'devpath',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+});
+Ext.define('Proxmox.form.MultiDiskSelector', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pmxMultiDiskSelector',
+
+    mixins: {
+	field: 'Ext.form.field.Field',
+    },
+
+    selModel: 'checkboxmodel',
+
+    store: {
+	data: [],
+	proxy: {
+	    type: 'proxmox',
+	},
+    },
+
+    // which field of the disklist is used for getValue
+    valueField: 'devpath',
+
+    // which parameter is used for the type
+    typeParameter: 'type',
+
+    // the type of disks to show
+    diskType: 'unused',
+
+    // add include-partitions=1 as a request parameter
+    includePartitions: false,
+
+    disks: [],
+
+    allowBlank: false,
+
+    getValue: function() {
+	let me = this;
+	return me.disks;
+    },
+
+    setValue: function(value) {
+	let me = this;
+
+	value ??= [];
+
+	if (!Ext.isArray(value)) {
+	    value = value.split(/;, /);
+	}
+
+	let store = me.getStore();
+	let selection = [];
+
+	let keyField = me.valueField;
+
+	value.forEach(item => {
+	    let rec = store.findRecord(keyField, item, 0, false, true, true);
+	    if (rec) {
+		selection.push(rec);
+	    }
+	});
+
+	me.setSelection(selection);
+
+	return me.mixins.field.setValue.call(me, value);
+    },
+
+    getErrors: function(value) {
+	let me = this;
+	if (me.allowBlank === false &&
+	    me.getSelectionModel().getCount() === 0) {
+	    me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
+	    return [gettext('No Disk selected')];
+	}
+
+	me.removeBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
+	return [];
+    },
+
+    update_disklist: function() {
+	var me = this;
+	var disks = me.getSelection();
+
+	var val = [];
+	disks.sort(function(a, b) {
+	    var aorder = a.get('order') || 0;
+	    var border = b.get('order') || 0;
+	    return aorder - border;
+	});
+
+	disks.forEach(function(disk) {
+	    val.push(disk.get(me.valueField));
+	});
+
+	me.validate();
+	me.disks = val;
+    },
+
+    columns: [
+	{
+	    text: gettext('Device'),
+	    dataIndex: 'devpath',
+	    flex: 2,
+	},
+	{
+	    text: gettext('Model'),
+	    dataIndex: 'model',
+	    flex: 2,
+	},
+	{
+	    text: gettext('Serial'),
+	    dataIndex: 'serial',
+	    flex: 2,
+	},
+	{
+	    text: gettext('Size'),
+	    dataIndex: 'size',
+	    renderer: Proxmox.Utils.format_size,
+	    flex: 1,
+	},
+	{
+	    header: gettext('Order'),
+	    xtype: 'widgetcolumn',
+	    dataIndex: 'order',
+	    sortable: true,
+	    flex: 1,
+	    widget: {
+		xtype: 'proxmoxintegerfield',
+		minValue: 1,
+		isFormField: false,
+		listeners: {
+		    change: function(numberfield, value, old_value) {
+			let grid = this.up('pmxMultiDiskSelector');
+			var record = numberfield.getWidgetRecord();
+			record.set('order', value);
+			grid.update_disklist(record);
+		    },
+		},
+	    },
+	},
+    ],
+
+    listeners: {
+	selectionchange: function() {
+	    this.update_disklist();
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let extraParams = {};
+
+	if (!me.url) {
+	    if (!me.nodename) {
+		throw "no url or nodename given";
+	    }
+
+	    me.url = `/api2/json/nodes/${me.nodename}/disks/list`;
+
+	    extraParams[me.typeParameter] = me.diskType;
+
+	    if (me.includePartitions) {
+		extraParams['include-partitions'] = 1;
+	    }
+	}
+
+	me.disks = [];
+
+	me.callParent();
+	let store = me.getStore();
+	store.setProxy({
+	    type: 'proxmox',
+	    url: me.url,
+	    extraParams,
+	});
+	store.load();
+	store.sort({ property: me.valueField });
+    },
+
+});
+Ext.define('Proxmox.form.TaskTypeSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pmxTaskTypeSelector',
+
+    anyMatch: true,
+
+    initComponent: function() {
+	let me = this;
+	me.store = Object.keys(Proxmox.Utils.task_desc_table).sort();
+	me.callParent();
+    },
+    listeners: {
+	change: function(field, newValue, oldValue) {
+	    if (newValue !== this.originalValue) {
+		this.triggers.clear.setVisible(true);
+	    }
+	},
+    },
+    triggers: {
+	clear: {
+	    cls: 'pmx-clear-trigger',
+	    weight: -1,
+	    hidden: true,
+	    handler: function() {
+		this.triggers.clear.setVisible(false);
+		this.setValue(this.originalValue);
+	    },
+	},
+    },
+});
+Ext.define('Proxmox.form.ACMEApiSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pmxACMEApiSelector',
+
+    fieldLabel: gettext('DNS API'),
+    displayField: 'name',
+    valueField: 'id',
+
+    store: {
+	model: 'proxmox-acme-challenges',
+	autoLoad: true,
+    },
+
+    triggerAction: 'all',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: true,
+    forceSelection: true,
+    anyMatch: true,
+    selectOnFocus: true,
+
+    getSchema: function() {
+	let me = this;
+	let val = me.getValue();
+	if (val) {
+	    let record = me.getStore().findRecord('id', val, 0, false, true, true);
+	    if (record) {
+		return record.data.schema;
+	    }
+	}
+	return {};
+    },
+
+    initComponent: function() {
+        let me = this;
+
+        if (!me.url) {
+            throw "no url given";
+        }
+
+        me.callParent();
+        me.getStore().getProxy().setUrl(me.url);
+    },
+});
+
+Ext.define('Proxmox.form.ACMEAccountSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pmxACMEAccountSelector',
+
+    displayField: 'name',
+    valueField: 'name',
+
+    store: {
+	model: 'proxmox-acme-accounts',
+	autoLoad: true,
+    },
+
+    triggerAction: 'all',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: false,
+    forceSelection: true,
+
+    isEmpty: function() {
+	return this.getStore().getData().length === 0;
+    },
+
+    initComponent: function() {
+        let me = this;
+
+        if (!me.url) {
+            throw "no url given";
+        }
+
+        me.callParent();
+        me.getStore().getProxy().setUrl(me.url);
+    },
+});
+
+Ext.define('Proxmox.form.ACMEPluginSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pmxACMEPluginSelector',
+
+    fieldLabel: gettext('Plugin'),
+    displayField: 'plugin',
+    valueField: 'plugin',
+
+    store: {
+	model: 'proxmox-acme-plugins',
+	autoLoad: true,
+	filters: item => item.data.type === 'dns',
+    },
+
+    triggerAction: 'all',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: false,
+
+    initComponent: function() {
+        let me = this;
+
+        if (!me.url) {
+            throw "no url given";
+        }
+
+        me.callParent();
+        me.getStore().getProxy().setUrl(me.url);
+    },
+});
+Ext.define('Proxmox.form.UserSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pmxUserSelector',
+
+    allowBlank: false,
+    autoSelect: false,
+    valueField: 'userid',
+    displayField: 'userid',
+
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+
+    store: {
+	model: 'pmx-users',
+	autoLoad: true,
+	params: {
+	    enabled: 1,
+	},
+	sorters: 'userid',
+    },
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('User'),
+		sortable: true,
+		dataIndex: 'userid',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Name'),
+		sortable: true,
+		renderer: (first, mD, rec) => Ext.String.htmlEncode(
+		    `${first || ''} ${rec.data.lastname || ''}`,
+		),
+		dataIndex: 'firstname',
+		flex: 1,
+	    },
+	    {
+		header: gettext('Comment'),
+		sortable: false,
+		dataIndex: 'comment',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	],
+    },
+});
+Ext.define('Proxmox.form.ThemeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    xtype: 'proxmoxThemeSelector',
+
+    comboItems: Proxmox.Utils.theme_array(),
+});
+/* Button features:
+ * - observe selection changes to enable/disable the button using enableFn()
+ * - pop up confirmation dialog using confirmMsg()
+ */
+Ext.define('Proxmox.button.Button', {
+    extend: 'Ext.button.Button',
+    alias: 'widget.proxmoxButton',
+
+    // the selection model to observe
+    selModel: undefined,
+
+    // if 'false' handler will not be called (button disabled)
+    enableFn: function(record) {
+	// return undefined by default
+    },
+
+    // function(record) or text
+    confirmMsg: false,
+
+    // take special care in confirm box (select no as default).
+    dangerous: false,
+
+    // is used to get the parent container for its selection model
+    parentXType: 'grid',
+
+    initComponent: function() {
+        let me = this;
+
+	if (me.handler) {
+	    // Note: me.realHandler may be a string (see named scopes)
+	    let realHandler = me.handler;
+
+	    me.handler = function(button, event) {
+		let rec, msg;
+		if (me.selModel) {
+		    rec = me.selModel.getSelection()[0];
+		    if (!rec || me.enableFn(rec) === false) {
+			return;
+		    }
+		}
+
+		if (me.confirmMsg) {
+		    msg = me.confirmMsg;
+		    if (Ext.isFunction(me.confirmMsg)) {
+			msg = me.confirmMsg(rec);
+		    }
+		    Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1;
+		    Ext.Msg.show({
+			title: gettext('Confirm'),
+			icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
+			message: msg,
+			buttons: Ext.Msg.YESNO,
+			defaultFocus: me.dangerous ? 'no' : 'yes',
+			callback: function(btn) {
+			    if (btn !== 'yes') {
+				return;
+			    }
+			    Ext.callback(realHandler, me.scope, [button, event, rec], 0, me);
+			},
+		    });
+		} else {
+		    Ext.callback(realHandler, me.scope, [button, event, rec], 0, me);
+		}
+	    };
+	}
+
+	me.callParent();
+
+	let grid;
+	if (!me.selModel && me.selModel !== null && me.selModel !== false) {
+	    let parent = me.up(me.parentXType);
+	    if (parent && parent.selModel) {
+		me.selModel = parent.selModel;
+	    }
+	}
+
+	if (me.waitMsgTarget === true) {
+	    grid = me.up('grid');
+	    if (grid) {
+		me.waitMsgTarget = grid;
+	    } else {
+		throw "unable to find waitMsgTarget";
+	    }
+	}
+
+	if (me.selModel) {
+	    me.mon(me.selModel, "selectionchange", function() {
+		let rec = me.selModel.getSelection()[0];
+		if (!rec || me.enableFn(rec) === false) {
+		    me.setDisabled(true);
+		} else {
+		    me.setDisabled(false);
+		}
+	    });
+	}
+    },
+});
+
+
+Ext.define('Proxmox.button.StdRemoveButton', {
+    extend: 'Proxmox.button.Button',
+    alias: 'widget.proxmoxStdRemoveButton',
+
+    text: gettext('Remove'),
+
+    disabled: true,
+
+    // time to wait for removal task to finish
+    delay: undefined,
+
+    config: {
+	baseurl: undefined,
+	customConfirmationMessage: undefined,
+    },
+
+    getUrl: function(rec) {
+	let me = this;
+
+	if (me.selModel) {
+	    return me.baseurl + '/' + rec.getId();
+	} else {
+	    return me.baseurl;
+	}
+    },
+
+    // also works with names scopes
+    callback: function(options, success, response) {
+	// do nothing by default
+    },
+
+    getRecordName: (rec) => rec.getId(),
+
+    confirmMsg: function(rec) {
+	let me = this;
+
+	let name = me.getRecordName(rec);
+
+	let text;
+	if (me.customConfirmationMessage) {
+	    text = me.customConfirmationMessage;
+	} else {
+	    text = gettext('Are you sure you want to remove entry {0}');
+	}
+	return Ext.String.format(text, `'${name}'`);
+    },
+
+    handler: function(btn, event, rec) {
+	let me = this;
+
+	let url = me.getUrl(rec);
+
+	if (typeof me.delay !== 'undefined' && me.delay >= 0) {
+	    url += "?delay=" + me.delay;
+	}
+
+	Proxmox.Utils.API2Request({
+	    url: url,
+	    method: 'DELETE',
+	    waitMsgTarget: me.waitMsgTarget,
+	    callback: function(options, success, response) {
+		Ext.callback(me.callback, me.scope, [options, success, response], 0, me);
+	    },
+	    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	});
+    },
+    initComponent: function() {
+	let me = this;
+
+	// enable by default if no seleModel is there and disabled not set
+	if (me.initialConfig.disabled === undefined &&
+	    (me.selModel === null || me.selModel === false)) {
+	    me.disabled = false;
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.button.AltText', {
+    extend: 'Proxmox.button.Button',
+    xtype: 'proxmoxAltTextButton',
+
+    defaultText: "",
+    altText: "",
+
+    listeners: {
+	// HACK: calculate the max button width on first render to avoid toolbar glitches
+	render: function(button) {
+	    let me = this;
+
+	    button.setText(me.altText);
+	    let altWidth = button.getSize().width;
+
+	    button.setText(me.defaultText);
+	    let defaultWidth = button.getSize().width;
+
+	    button.setWidth(defaultWidth > altWidth ? defaultWidth : altWidth);
+	},
+    },
+});
+/* help button pointing to an online documentation
+   for components contained in a modal window
+*/
+Ext.define('Proxmox.button.Help', {
+    extend: 'Ext.button.Button',
+    xtype: 'proxmoxHelpButton',
+
+    text: gettext('Help'),
+
+    // make help button less flashy by styling it like toolbar buttons
+    iconCls: ' x-btn-icon-el-default-toolbar-small fa fa-question-circle',
+    cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+
+    hidden: true,
+
+    listenToGlobalEvent: true,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	listen: {
+	    global: {
+		proxmoxShowHelp: 'onProxmoxShowHelp',
+		proxmoxHideHelp: 'onProxmoxHideHelp',
+	    },
+	},
+	onProxmoxShowHelp: function(helpLink) {
+	    let view = this.getView();
+	    if (view.listenToGlobalEvent === true) {
+		view.setOnlineHelp(helpLink);
+		view.show();
+	    }
+	},
+	onProxmoxHideHelp: function() {
+	    let view = this.getView();
+	    if (view.listenToGlobalEvent === true) {
+		view.hide();
+	    }
+	},
+    },
+
+    // this sets the link and the tooltip text
+    setOnlineHelp: function(blockid) {
+	let me = this;
+
+	let info = Proxmox.Utils.get_help_info(blockid);
+	if (info) {
+	    me.onlineHelp = blockid;
+	    let title = info.title;
+	    if (info.subtitle) {
+		title += ' - ' + info.subtitle;
+	    }
+	    me.setTooltip(title);
+	}
+    },
+
+    // helper to set the onlineHelp via a config object
+    setHelpConfig: function(config) {
+	let me = this;
+	me.setOnlineHelp(config.onlineHelp);
+    },
+
+    handler: function() {
+	let me = this;
+	let docsURI;
+
+	if (me.onlineHelp) {
+	    docsURI = Proxmox.Utils.get_help_link(me.onlineHelp);
+	}
+
+	if (docsURI) {
+	    window.open(docsURI);
+	} else {
+	    Ext.Msg.alert(gettext('Help'), gettext('No Help available'));
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	if (me.onlineHelp) {
+	    me.setOnlineHelp(me.onlineHelp); // set tooltip
+	}
+    },
+});
+/** Renders a list of key values objects
+
+Mandatory Config Parameters:
+
+rows: an object container where each property is a key-value object we want to render
+
+  rows: {
+     keyboard: {
+         header: gettext('Keyboard Layout'),
+         editor: 'Your.KeyboardEdit',
+         required: true
+     },
+     // ...
+  },
+
+Convenience Helper:
+
+As alternative you can use the common add-row helper like `add_text_row`, but you need to
+call it in an overridden initComponent before `me.callParent(arguments)` gets executed.
+
+For a declarative approach you can use the `gridRows` configuration to pass an array of
+objects with each having at least a `xtype` to match `add_XTYPE_row` and a field-name
+property, for example:
+
+ gridRows: [
+    {
+      xtype: 'text',
+      name: 'http-proxy',
+      text: gettext('HTTP proxy'),
+      defaultValue: Proxmox.Utils.noneText,
+      vtype: 'HttpProxy',
+      deleteEmpty: true,
+    },
+  ],
+
+Optional Configs:
+
+disabled:: setting this parameter to true will disable selection and focus on
+  the proxmoxObjectGrid as well as greying out input elements. Useful for a
+  readonly tabular display
+
+*/
+Ext.define('Proxmox.grid.ObjectGrid', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.proxmoxObjectGrid'],
+
+    // can be used as declarative replacement over manually calling the add_XYZ_row helpers,
+    // see top-level doc-comment above for details/example
+    gridRows: [],
+
+    disabled: false,
+    hideHeaders: true,
+
+    monStoreErrors: false,
+
+    add_combobox_row: function(name, text, opts) {
+	let me = this;
+
+	opts = opts || {};
+	me.rows = me.rows || {};
+
+	me.rows[name] = {
+	    required: true,
+	    defaultValue: opts.defaultValue,
+	    header: text,
+	    renderer: opts.renderer,
+	    editor: {
+		xtype: 'proxmoxWindowEdit',
+		subject: text,
+		onlineHelp: opts.onlineHelp,
+		fieldDefaults: {
+		    labelWidth: opts.labelWidth || 100,
+		},
+		items: {
+		    xtype: 'proxmoxKVComboBox',
+		    name: name,
+		    comboItems: opts.comboItems,
+		    value: opts.defaultValue,
+		    deleteEmpty: !!opts.deleteEmpty,
+		    emptyText: opts.defaultValue,
+		    labelWidth: Proxmox.Utils.compute_min_label_width(
+			text, opts.labelWidth),
+		    fieldLabel: text,
+		},
+	    },
+	};
+    },
+
+    add_text_row: function(name, text, opts) {
+	let me = this;
+
+	opts = opts || {};
+	me.rows = me.rows || {};
+
+	me.rows[name] = {
+	    required: true,
+	    defaultValue: opts.defaultValue,
+	    header: text,
+	    renderer: opts.renderer,
+	    editor: {
+		xtype: 'proxmoxWindowEdit',
+		subject: text,
+		onlineHelp: opts.onlineHelp,
+		fieldDefaults: {
+		    labelWidth: opts.labelWidth || 100,
+		},
+		items: {
+		    xtype: 'proxmoxtextfield',
+		    name: name,
+		    deleteEmpty: !!opts.deleteEmpty,
+		    emptyText: opts.defaultValue,
+		    labelWidth: Proxmox.Utils.compute_min_label_width(text, opts.labelWidth),
+		    vtype: opts.vtype,
+		    fieldLabel: text,
+		},
+	    },
+	};
+    },
+
+    add_boolean_row: function(name, text, opts) {
+	let me = this;
+
+	opts = opts || {};
+	me.rows = me.rows || {};
+
+	me.rows[name] = {
+	    required: true,
+	    defaultValue: opts.defaultValue || 0,
+	    header: text,
+	    renderer: opts.renderer || Proxmox.Utils.format_boolean,
+	    editor: {
+		xtype: 'proxmoxWindowEdit',
+		subject: text,
+		onlineHelp: opts.onlineHelp,
+		fieldDefaults: {
+		    labelWidth: opts.labelWidth || 100,
+		},
+		items: {
+		    xtype: 'proxmoxcheckbox',
+		    name: name,
+		    uncheckedValue: 0,
+		    defaultValue: opts.defaultValue || 0,
+		    checked: !!opts.defaultValue,
+		    deleteDefaultValue: !!opts.deleteDefaultValue,
+		    labelWidth: Proxmox.Utils.compute_min_label_width(text, opts.labelWidth),
+		    fieldLabel: text,
+		},
+	    },
+	};
+    },
+
+    add_integer_row: function(name, text, opts) {
+	let me = this;
+
+	opts = opts || {};
+	me.rows = me.rows || {};
+
+	me.rows[name] = {
+	    required: true,
+	    defaultValue: opts.defaultValue,
+	    header: text,
+	    renderer: opts.renderer,
+	    editor: {
+		xtype: 'proxmoxWindowEdit',
+		subject: text,
+		onlineHelp: opts.onlineHelp,
+		fieldDefaults: {
+		    labelWidth: opts.labelWidth || 100,
+		},
+		items: {
+		    xtype: 'proxmoxintegerfield',
+		    name: name,
+		    minValue: opts.minValue,
+		    maxValue: opts.maxValue,
+		    emptyText: gettext('Default'),
+		    deleteEmpty: !!opts.deleteEmpty,
+		    value: opts.defaultValue,
+		    labelWidth: Proxmox.Utils.compute_min_label_width(text, opts.labelWidth),
+		    fieldLabel: text,
+		},
+	    },
+	};
+    },
+
+    editorConfig: {}, // default config passed to editor
+
+    run_editor: function() {
+	let me = this;
+
+	let sm = me.getSelectionModel();
+	let rec = sm.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+
+	let rows = me.rows;
+	let rowdef = rows[rec.data.key];
+	if (!rowdef.editor) {
+	    return;
+	}
+
+	let win;
+	let config;
+	if (Ext.isString(rowdef.editor)) {
+	    config = Ext.apply({
+		confid: rec.data.key,
+	    }, me.editorConfig);
+	    win = Ext.create(rowdef.editor, config);
+	} else {
+	    config = Ext.apply({
+		confid: rec.data.key,
+	    }, me.editorConfig);
+	    Ext.apply(config, rowdef.editor);
+	    win = Ext.createWidget(rowdef.editor.xtype, config);
+	    win.load();
+	}
+
+	win.show();
+	win.on('destroy', me.reload, me);
+    },
+
+    reload: function() {
+	let me = this;
+	me.rstore.load();
+    },
+
+    getObjectValue: function(key, defaultValue) {
+	let me = this;
+	let rec = me.store.getById(key);
+	if (rec) {
+	    return rec.data.value;
+	}
+	return defaultValue;
+    },
+
+    renderKey: function(key, metaData, record, rowIndex, colIndex, store) {
+	let me = this;
+	let rows = me.rows;
+	let rowdef = rows && rows[key] ? rows[key] : {};
+	return rowdef.header || key;
+    },
+
+    renderValue: function(value, metaData, record, rowIndex, colIndex, store) {
+	let me = this;
+	let rows = me.rows;
+	let key = record.data.key;
+	let rowdef = rows && rows[key] ? rows[key] : {};
+
+	let renderer = rowdef.renderer;
+	if (renderer) {
+	    return renderer.call(me, value, metaData, record, rowIndex, colIndex, store);
+	}
+
+	return value;
+    },
+
+    listeners: {
+	itemkeydown: function(view, record, item, index, e) {
+	    if (e.getKey() === e.ENTER) {
+		this.pressedIndex = index;
+	    }
+	},
+	itemkeyup: function(view, record, item, index, e) {
+	    if (e.getKey() === e.ENTER && index === this.pressedIndex) {
+		this.run_editor();
+	    }
+
+	    this.pressedIndex = undefined;
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	for (const rowdef of me.gridRows || []) {
+	    let addFn = me[`add_${rowdef.xtype}_row`];
+	    if (typeof addFn !== 'function') {
+		throw `unknown object-grid row xtype '${rowdef.xtype}'`;
+	    } else if (typeof rowdef.name !== 'string') {
+		throw `object-grid row need a valid name string-property!`;
+	    } else {
+		addFn.call(me, rowdef.name, rowdef.text || rowdef.name, rowdef);
+	    }
+	}
+
+	let rows = me.rows;
+
+	if (!me.rstore) {
+	    if (!me.url) {
+		throw "no url specified";
+	    }
+
+	    me.rstore = Ext.create('Proxmox.data.ObjectStore', {
+		url: me.url,
+		interval: me.interval,
+		extraParams: me.extraParams,
+		rows: me.rows,
+	    });
+	}
+
+	let rstore = me.rstore;
+	let store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: rstore,
+	    sorters: [],
+	    filters: [],
+	});
+
+	if (rows) {
+	    for (const [key, rowdef] of Object.entries(rows)) {
+		if (Ext.isDefined(rowdef.defaultValue)) {
+		    store.add({ key: key, value: rowdef.defaultValue });
+		} else if (rowdef.required) {
+		    store.add({ key: key, value: undefined });
+		}
+	    }
+	}
+
+	if (me.sorterFn) {
+	    store.sorters.add(Ext.create('Ext.util.Sorter', {
+		sorterFn: me.sorterFn,
+	    }));
+	}
+
+	store.filters.add(Ext.create('Ext.util.Filter', {
+	    filterFn: function(item) {
+		if (rows) {
+		    let rowdef = rows[item.data.key];
+		    if (!rowdef || rowdef.visible === false) {
+			return false;
+		    }
+		}
+		return true;
+	    },
+	}));
+
+	Proxmox.Utils.monStoreErrors(me, rstore);
+
+	Ext.applyIf(me, {
+	    store: store,
+	    stateful: false,
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    width: me.cwidth1 || 200,
+		    dataIndex: 'key',
+		    renderer: me.renderKey,
+		},
+		{
+		    flex: 1,
+		    header: gettext('Value'),
+		    dataIndex: 'value',
+		    renderer: me.renderValue,
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	if (me.monStoreErrors) {
+	    Proxmox.Utils.monStoreErrors(me, me.store);
+	}
+   },
+});
+Ext.define('Proxmox.grid.PendingObjectGrid', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.proxmoxPendingObjectGrid'],
+
+    getObjectValue: function(key, defaultValue, pending) {
+	let me = this;
+	let rec = me.store.getById(key);
+	if (rec) {
+	    let value = rec.data.value;
+	    if (pending) {
+		if (Ext.isDefined(rec.data.pending) && rec.data.pending !== '') {
+		    value = rec.data.pending;
+		} else if (rec.data.delete === 1) {
+		    value = defaultValue;
+		}
+	    }
+
+            if (Ext.isDefined(value) && value !== '') {
+		return value;
+            } else {
+		return defaultValue;
+            }
+	}
+	return defaultValue;
+    },
+
+    hasPendingChanges: function(key) {
+	let me = this;
+	let rows = me.rows;
+	let rowdef = rows && rows[key] ? rows[key] : {};
+	let keys = rowdef.multiKey || [key];
+	let pending = false;
+
+	Ext.Array.each(keys, function(k) {
+	    let rec = me.store.getById(k);
+	    if (rec && rec.data && (
+		    (Ext.isDefined(rec.data.pending) && rec.data.pending !== '') ||
+		    rec.data.delete === 1
+	    )) {
+		pending = true;
+		return false; // break
+	    }
+	    return true;
+	});
+
+	return pending;
+    },
+
+    renderValue: function(value, metaData, record, rowIndex, colIndex, store) {
+	let me = this;
+	let rows = me.rows;
+	let key = record.data.key;
+	let rowdef = rows && rows[key] ? rows[key] : {};
+	let renderer = rowdef.renderer;
+	let current = '';
+	let pending = '';
+
+	if (renderer) {
+	    current = renderer(value, metaData, record, rowIndex, colIndex, store, false);
+	    if (me.hasPendingChanges(key)) {
+		pending = renderer(record.data.pending, metaData, record, rowIndex, colIndex, store, true);
+	    }
+	    if (pending === current) {
+		pending = undefined;
+	    }
+	} else {
+	    current = value || '';
+	    pending = record.data.pending;
+	}
+
+	if (record.data.delete) {
+	    let delete_all = true;
+	    if (rowdef.multiKey) {
+		Ext.Array.each(rowdef.multiKey, function(k) {
+		    let rec = me.store.getById(k);
+		    if (rec && rec.data && rec.data.delete !== 1) {
+			delete_all = false;
+			return false; // break
+		    }
+		    return true;
+		});
+	    }
+	    if (delete_all) {
+		pending = '<div style="text-decoration: line-through;">'+ current +'</div>';
+	    }
+	}
+
+	if (pending) {
+	    return current + '<div style="color:darkorange">' + pending + '</div>';
+	} else {
+	    return current;
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.rstore) {
+	    if (!me.url) {
+		throw "no url specified";
+	    }
+
+	    me.rstore = Ext.create('Proxmox.data.ObjectStore', {
+		model: 'KeyValuePendingDelete',
+		readArray: true,
+		url: me.url,
+		interval: me.interval,
+		extraParams: me.extraParams,
+		rows: me.rows,
+	    });
+	}
+
+	me.callParent();
+   },
+});
+Ext.define('Proxmox.panel.AuthView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: 'widget.pmxAuthView',
+
+    stateful: true,
+    stateId: 'grid-authrealms',
+
+    viewConfig: {
+	trackOver: false,
+    },
+
+    baseUrl: '/access/domains',
+    useTypeInUrl: false,
+
+    columns: [
+	{
+	    header: gettext('Realm'),
+	    width: 100,
+	    sortable: true,
+	    dataIndex: 'realm',
+	},
+	{
+	    header: gettext('Type'),
+	    width: 100,
+	    sortable: true,
+	    dataIndex: 'type',
+	},
+	{
+	    header: gettext('Comment'),
+	    sortable: false,
+	    dataIndex: 'comment',
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    store: {
+	model: 'pmx-domains',
+	sorters: {
+	    property: 'realm',
+	    direction: 'ASC',
+	},
+    },
+
+    openEditWindow: function(authType, realm) {
+	let me = this;
+	Ext.create('Proxmox.window.AuthEditBase', {
+	    baseUrl: me.baseUrl,
+	    useTypeInUrl: me.useTypeInUrl,
+	    authType,
+	    realm,
+	    listeners: {
+		destroy: () => me.reload(),
+	    },
+	}).show();
+    },
+
+    reload: function() {
+	let me = this;
+	me.getStore().load();
+    },
+
+    run_editor: function() {
+	let me = this;
+	let rec = me.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+
+	if (!Proxmox.Schema.authDomains[rec.data.type].edit) {
+	    return;
+	}
+
+	me.openEditWindow(rec.data.type, rec.data.realm);
+    },
+
+    open_sync_window: function() {
+	let rec = this.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+	if (!Proxmox.Schema.authDomains[rec.data.type].sync) {
+	    return;
+	}
+	Ext.create('Proxmox.window.SyncWindow', {
+	    type: rec.data.type,
+	    realm: rec.data.realm,
+	    listeners: {
+		destroy: () => this.reload(),
+	    },
+	}).show();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	let menuitems = [];
+	for (const [authType, config] of Object.entries(Proxmox.Schema.authDomains).sort()) {
+	    if (!config.add) { continue; }
+	    menuitems.push({
+		text: config.name,
+		iconCls: 'fa fa-fw ' + (config.iconCls || 'fa-address-book-o'),
+		handler: () => me.openEditWindow(authType),
+	    });
+	}
+
+	let tbar = [
+	    {
+		text: gettext('Add'),
+		menu: {
+		    items: menuitems,
+		},
+	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Edit'),
+		disabled: true,
+		enableFn: (rec) => Proxmox.Schema.authDomains[rec.data.type].edit,
+		handler: () => me.run_editor(),
+	    },
+	    {
+		xtype: 'proxmoxStdRemoveButton',
+		getUrl: (rec) => {
+		    let url = me.baseUrl;
+		    if (me.useTypeInUrl) {
+			url += `/${rec.get('type')}`;
+		    }
+		    url += `/${rec.getId()}`;
+		    return url;
+		},
+		enableFn: (rec) => Proxmox.Schema.authDomains[rec.data.type].add,
+		callback: () => me.reload(),
+	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Sync'),
+		disabled: true,
+		enableFn: (rec) => Proxmox.Schema.authDomains[rec.data.type].sync,
+		handler: () => me.open_sync_window(),
+	    },
+	];
+
+	if (me.extraButtons) {
+	    tbar.push('-');
+	    for (const button of me.extraButtons) {
+		tbar.push(button);
+	    }
+	}
+
+	Ext.apply(me, {
+	    tbar,
+	    listeners: {
+		activate: () => me.reload(),
+		itemdblclick: () => me.run_editor(),
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('pmx-disk-list', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'devpath', 'used',
+	{ name: 'size', type: 'number' },
+	{ name: 'osdid', type: 'number', defaultValue: -1 },
+	{
+	    name: 'status',
+	    convert: function(value, rec) {
+		if (value) return value;
+		if (rec.data.health) {
+		    return rec.data.health;
+		}
+
+		if (rec.data.type === 'partition') {
+		    return "";
+		}
+
+		return Proxmox.Utils.unknownText;
+	    },
+	},
+	{
+	    name: 'name',
+	    convert: function(value, rec) {
+		if (value) return value;
+		if (rec.data.devpath) return rec.data.devpath;
+		return undefined;
+	    },
+	},
+	{
+	    name: 'disk-type',
+	    convert: function(value, rec) {
+		if (value) return value;
+		if (rec.data.type) return rec.data.type;
+		return undefined;
+	    },
+	},
+	'vendor', 'model', 'serial', 'rpm', 'type', 'wearout', 'health', 'mounted',
+    ],
+    idProperty: 'devpath',
+});
+
+Ext.define('Proxmox.DiskList', {
+    extend: 'Ext.tree.Panel',
+    alias: 'widget.pmxDiskList',
+
+    supportsWipeDisk: false,
+
+    rootVisible: false,
+
+    emptyText: gettext('No Disks found'),
+
+    stateful: true,
+    stateId: 'tree-node-disks',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let extraParams = {};
+	    if (view.includePartitions) {
+		extraParams['include-partitions'] = 1;
+	    }
+
+	    let url = `${view.baseurl}/list`;
+	    me.store.setProxy({
+		type: 'proxmox',
+		extraParams: extraParams,
+		url: url,
+	    });
+	    me.store.load();
+	},
+
+	openSmartWindow: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) return;
+
+	    let rec = selection[0];
+	    Ext.create('Proxmox.window.DiskSmart', {
+		baseurl: view.baseurl,
+		dev: rec.data.name,
+	    }).show();
+	},
+
+	initGPT: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) return;
+
+	    let rec = selection[0];
+	    Proxmox.Utils.API2Request({
+		url: `${view.exturl}/initgpt`,
+		waitMsgTarget: view,
+		method: 'POST',
+		params: { disk: rec.data.name },
+		failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		success: function(response, options) {
+		    Ext.create('Proxmox.window.TaskProgress', {
+		        upid: response.result.data,
+			taskDone: function() {
+			    me.reload();
+			},
+			autoShow: true,
+		    });
+		},
+	    });
+	},
+
+	wipeDisk: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) return;
+
+	    let rec = selection[0];
+	    Proxmox.Utils.API2Request({
+		url: `${view.exturl}/wipedisk`,
+		waitMsgTarget: view,
+		method: 'PUT',
+		params: { disk: rec.data.name },
+		failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		success: function(response, options) {
+		    Ext.create('Proxmox.window.TaskProgress', {
+		        upid: response.result.data,
+			taskDone: function() {
+			    me.reload();
+			},
+			autoShow: true,
+		    });
+		},
+	    });
+	},
+
+	init: function(view) {
+	    let nodename = view.nodename || 'localhost';
+	    view.baseurl = `/api2/json/nodes/${nodename}/disks`;
+	    view.exturl = `/api2/extjs/nodes/${nodename}/disks`;
+
+	    this.store = Ext.create('Ext.data.Store', {
+		model: 'pmx-disk-list',
+	    });
+	    this.store.on('load', this.onLoad, this);
+
+	    Proxmox.Utils.monStoreErrors(view, this.store);
+	    this.reload();
+	},
+
+	onLoad: function(store, records, success, operation) {
+	    let me = this;
+	    let view = this.getView();
+
+	    if (!success) {
+		Proxmox.Utils.setErrorMask(
+		    view,
+		    Proxmox.Utils.getResponseErrorMessage(operation.getError()),
+		);
+		return;
+	    }
+
+	    let disks = {};
+
+	    for (const item of records) {
+		let data = item.data;
+		data.expanded = true;
+		data.children = data.partitions ?? [];
+		for (let p of data.children) {
+		    p['disk-type'] = 'partition';
+		    p.iconCls = 'fa fa-fw fa-hdd-o x-fa-tree';
+		    p.used = p.used === 'filesystem' ? p.filesystem : p.used;
+		    p.parent = data.devpath;
+		    p.children = [];
+		    p.leaf = true;
+		}
+		data.iconCls = 'fa fa-fw fa-hdd-o x-fa-tree';
+		data.leaf = data.children.length === 0;
+
+		if (!data.parent) {
+		    disks[data.devpath] = data;
+		}
+	    }
+	    for (const item of records) {
+		let data = item.data;
+		if (data.parent) {
+		    disks[data.parent].leaf = false;
+		    disks[data.parent].children.push(data);
+		}
+	    }
+
+	    let children = [];
+	    for (const [_, device] of Object.entries(disks)) {
+		children.push(device);
+	    }
+
+	    view.setRootNode({
+		expanded: true,
+		children: children,
+	    });
+
+	    Proxmox.Utils.setErrorMask(view, false);
+	},
+    },
+
+    renderDiskType: function(v) {
+	if (v === undefined) return Proxmox.Utils.unknownText;
+	switch (v) {
+	    case 'ssd': return 'SSD';
+	    case 'hdd': return 'Hard Disk';
+	    case 'usb': return 'USB';
+	    default: return v;
+	}
+    },
+
+    renderDiskUsage: function(v, metaData, rec) {
+	let extendedInfo = '';
+	if (rec) {
+	    let types = [];
+	    if (rec.data['osdid-list'] && rec.data['osdid-list'].length > 0) {
+		for (const id of rec.data['osdid-list'].sort()) {
+		    types.push(`OSD.${id.toString()}`);
+		}
+	    } else if (rec.data.osdid !== undefined && rec.data.osdid >= 0) {
+		types.push(`OSD.${rec.data.osdid.toString()}`);
+	    }
+	    if (rec.data.journals > 0) {
+		types.push('Journal');
+	    }
+	    if (rec.data.db > 0) {
+		types.push('DB');
+	    }
+	    if (rec.data.wal > 0) {
+		types.push('WAL');
+	    }
+	    if (types.length > 0) {
+		extendedInfo = `, Ceph (${types.join(', ')})`;
+	    }
+	}
+	const formatMap = {
+	    'bios': 'BIOS boot',
+	    'zfsreserved': 'ZFS reserved',
+	    'efi': 'EFI',
+	    'lvm': 'LVM',
+	    'zfs': 'ZFS',
+	};
+
+	v = formatMap[v] || v;
+	return v ? `${v}${extendedInfo}` : Proxmox.Utils.noText;
+    },
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    header: gettext('Device'),
+	    width: 150,
+	    sortable: true,
+	    dataIndex: 'devpath',
+	},
+	{
+	    header: gettext('Type'),
+	    width: 80,
+	    sortable: true,
+	    dataIndex: 'disk-type',
+	    renderer: function(v) {
+		let me = this;
+		return me.renderDiskType(v);
+	    },
+	},
+	{
+	    header: gettext('Usage'),
+	    width: 150,
+	    sortable: false,
+	    renderer: function(v, metaData, rec) {
+		let me = this;
+		return me.renderDiskUsage(v, metaData, rec);
+	    },
+	    dataIndex: 'used',
+	},
+	{
+	    header: gettext('Size'),
+	    width: 100,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'size',
+	},
+	{
+	    header: 'GPT',
+	    width: 60,
+	    align: 'right',
+	    renderer: Proxmox.Utils.format_boolean,
+	    dataIndex: 'gpt',
+	},
+	{
+	    header: gettext('Vendor'),
+	    width: 100,
+	    sortable: true,
+	    hidden: true,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'vendor',
+	},
+	{
+	    header: gettext('Model'),
+	    width: 200,
+	    sortable: true,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'model',
+	},
+	{
+	    header: gettext('Serial'),
+	    width: 200,
+	    sortable: true,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'serial',
+	},
+	{
+	    header: 'S.M.A.R.T.',
+	    width: 100,
+	    sortable: true,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'status',
+	},
+	{
+	    header: gettext('Mounted'),
+	    width: 60,
+	    align: 'right',
+	    renderer: Proxmox.Utils.format_boolean,
+	    dataIndex: 'mounted',
+	},
+	{
+	    header: gettext('Wearout'),
+	    width: 90,
+	    sortable: true,
+	    align: 'right',
+	    dataIndex: 'wearout',
+	    renderer: function(value) {
+		if (Ext.isNumeric(value)) {
+		    return (100 - value).toString() + '%';
+		}
+		return gettext('N/A');
+	    },
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'openSmartWindow',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let tbar = [
+	    {
+		text: gettext('Reload'),
+		handler: 'reload',
+	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Show S.M.A.R.T. values'),
+		parentXType: 'treepanel',
+		disabled: true,
+		enableFn: function(rec) {
+		    if (!rec || rec.data.parent) {
+			return false;
+		    } else {
+			return true;
+		    }
+		},
+		handler: 'openSmartWindow',
+	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Initialize Disk with GPT'),
+		parentXType: 'treepanel',
+		disabled: true,
+		enableFn: function(rec) {
+		    if (!rec || rec.data.parent ||
+			(rec.data.used && rec.data.used !== 'unused')) {
+			return false;
+		    } else {
+			return true;
+		    }
+		},
+		handler: 'initGPT',
+	    },
+	];
+
+	if (me.supportsWipeDisk) {
+	    tbar.push('-');
+	    tbar.push({
+		xtype: 'proxmoxButton',
+		text: gettext('Wipe Disk'),
+		parentXType: 'treepanel',
+		dangerous: true,
+		confirmMsg: function(rec) {
+		    const data = rec.data;
+
+		    let mainMessage = Ext.String.format(
+			gettext('Are you sure you want to wipe {0}?'),
+			data.devpath,
+		    );
+		    mainMessage += `<br> ${gettext('All data on the device will be lost!')}`;
+
+		    const type = me.renderDiskType(data["disk-type"]);
+
+		    let usage;
+		    if (data.children.length > 0) {
+			const partitionUsage = data.children.map(
+			    partition => me.renderDiskUsage(partition.used),
+			).join(', ');
+			usage = `${gettext('Partitions')} (${partitionUsage})`;
+		    } else {
+			usage = me.renderDiskUsage(data.used, undefined, rec);
+		    }
+
+		    const size = Proxmox.Utils.format_size(data.size);
+		    const serial = Ext.String.htmlEncode(data.serial);
+
+		    let additionalInfo = `${gettext('Type')}: ${type}<br>`;
+		    additionalInfo += `${gettext('Usage')}: ${usage}<br>`;
+		    additionalInfo += `${gettext('Size')}: ${size}<br>`;
+		    additionalInfo += `${gettext('Serial')}: ${serial}`;
+
+		    return `${mainMessage}<br><br>${additionalInfo}`;
+		},
+		disabled: true,
+		handler: 'wipeDisk',
+	    });
+	}
+
+	me.tbar = tbar;
+
+	me.callParent();
+    },
+});
+// not realy a panel descendant, but its the best (existing) place for this
+Ext.define('Proxmox.EOLNotice', {
+    extend: 'Ext.Component',
+    alias: 'widget.proxmoxEOLNotice',
+
+    userCls: 'eol-notice',
+    padding: '0 5',
+
+    config: {
+	product: '',
+	version: '',
+	eolDate: '',
+	href: '',
+    },
+
+    autoEl: {
+	tag: 'div',
+	'data-qtip': gettext("You won't get any security fixes after the End-Of-Life date. Please consider upgrading."),
+    },
+
+    getIconCls: function() {
+	let me = this;
+
+	const now = new Date();
+	const eolDate = new Date(me.eolDate);
+	const warningCutoff = new Date(eolDate.getTime() - (21 * 24 * 60 * 60 * 1000)); // 3 weeks
+
+	return now > warningCutoff ? 'critical fa-exclamation-triangle' : 'info-blue fa-info-circle';
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let iconCls = me.getIconCls();
+	let href = me.href.startsWith('http') ? me.href : `https://${me.href}`;
+	let message = Ext.String.format(
+	    gettext('Support for {0} {1} ends on {2}'), me.product, me.version, me.eolDate);
+
+	me.html = `<i class="fa ${iconCls}"></i>
+	    <a href="${href}" target="_blank">${message} <i class="fa fa-external-link"></i></a>
+	`;
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.panel.InputPanel', {
+    extend: 'Ext.panel.Panel',
+    alias: ['widget.inputpanel'],
+    listeners: {
+	activate: function() {
+	    // notify owning container that it should display a help button
+	    if (this.onlineHelp) {
+		Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp);
+	    }
+	},
+	deactivate: function() {
+	    if (this.onlineHelp) {
+		Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp);
+	    }
+	},
+    },
+    border: false,
+
+    // override this with an URL to a relevant chapter of the pve manual
+    // setting this will display a help button in our parent panel
+    onlineHelp: undefined,
+
+    // will be set if the inputpanel has advanced items
+    hasAdvanced: false,
+
+    // if the panel has advanced items, this will determine if they are shown by default
+    showAdvanced: false,
+
+    // overwrite this to modify submit data
+    onGetValues: function(values) {
+	return values;
+    },
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+
+	if (Ext.isFunction(me.onGetValues)) {
+	    dirtyOnly = false;
+	}
+
+	let values = {};
+
+	Ext.Array.each(me.query('[isFormField]'), function(field) {
+	    if (!dirtyOnly || field.isDirty()) {
+		Proxmox.Utils.assemble_field_data(values, field.getSubmitData());
+	    }
+	});
+
+	return me.onGetValues(values);
+    },
+
+    setAdvancedVisible: function(visible) {
+	let me = this;
+	let advItems = me.getComponent('advancedContainer');
+	if (advItems) {
+	    advItems.setVisible(visible);
+	}
+    },
+
+    onSetValues: function(values) {
+	return values;
+    },
+
+    setValues: function(values) {
+	let me = this;
+
+	let form = me.up('form');
+
+	values = me.onSetValues(values);
+
+	Ext.iterate(values, function(fieldId, val) {
+	    let fields = me.query('[isFormField][name=' + fieldId + ']');
+	    for (const field of fields) {
+		if (field) {
+		    field.setValue(val);
+		    if (form.trackResetOnLoad) {
+			field.resetOriginalValue();
+		    }
+		}
+	    }
+	});
+    },
+
+    /**
+     *  inputpanel, vbox
+     * +---------------------------------------------------------------------+
+     * |                             columnT                                 |
+     * +---------------------------------------------------------------------+
+     * |                          container, hbox                            |
+     * |  +---------------+---------------+---------------+---------------+  |
+     * |  |    column1    |    column2    |    column3    |    column4    |  |
+     * |  | panel, anchor | panel, anchor | panel, anchor | panel, anchor |  |
+     * |  +---------------+---------------+---------------+---------------+  |
+     * +---------------------------------------------------------------------+
+     * |                             columnB                                 |
+     * +---------------------------------------------------------------------+
+     */
+    initComponent: function() {
+	let me = this;
+
+	let items;
+
+	if (me.items) {
+	    items = [
+		{
+		    layout: 'anchor',
+		    items: me.items,
+		},
+	    ];
+	    me.items = undefined;
+	} else if (me.column4) {
+	    items = [];
+	    if (me.columnT) {
+		items.push({
+		    padding: '0 0 0 0',
+		    layout: 'anchor',
+		    items: me.columnT,
+		});
+	    }
+	    items.push(
+		{
+		    layout: 'hbox',
+		    defaults: {
+			border: false,
+			layout: 'anchor',
+			flex: 1,
+		    },
+		    items: [
+			{
+			    padding: '0 10 0 0',
+			    items: me.column1,
+			},
+			{
+			    padding: '0 10 0 0',
+			    items: me.column2,
+			},
+			{
+			    padding: '0 10 0 0',
+			    items: me.column3,
+			},
+			{
+			    padding: '0 0 0 10',
+			    items: me.column4,
+			},
+		    ],
+		},
+	    );
+	    if (me.columnB) {
+		items.push({
+		    padding: '10 0 0 0',
+		    layout: 'anchor',
+		    items: me.columnB,
+		});
+	    }
+	} else if (me.column1) {
+	    items = [];
+	    if (me.columnT) {
+		items.push({
+		    padding: '0 0 10 0',
+		    layout: 'anchor',
+		    items: me.columnT,
+		});
+	    }
+	    items.push(
+		{
+		    layout: 'hbox',
+		    defaults: {
+			border: false,
+			layout: 'anchor',
+			flex: 1,
+		    },
+		    items: [
+			{
+			    padding: '0 10 0 0',
+			    items: me.column1,
+			},
+			{
+			    padding: '0 0 0 10',
+			    items: me.column2 || [], // allow empty column
+			},
+		    ],
+		},
+	    );
+	    if (me.columnB) {
+		items.push({
+		    padding: '10 0 0 0',
+		    layout: 'anchor',
+		    items: me.columnB,
+		});
+	    }
+	} else {
+	    throw "unsupported config";
+	}
+
+	let advItems;
+	if (me.advancedItems) {
+	    advItems = [
+		{
+		    layout: 'anchor',
+		    items: me.advancedItems,
+		},
+	    ];
+	    me.advancedItems = undefined;
+	} else if (me.advancedColumn1 || me.advancedColumn2 || me.advancedColumnB) {
+	    advItems = [
+		{
+		    layout: {
+			type: 'hbox',
+			align: 'begin',
+		    },
+		    defaults: {
+			border: false,
+			layout: 'anchor',
+			flex: 1,
+		    },
+		    items: [
+			{
+			    padding: '0 10 0 0',
+			    items: me.advancedColumn1 || [], // allow empty column
+			},
+			{
+			    padding: '0 0 0 10',
+			    items: me.advancedColumn2 || [], // allow empty column
+			},
+		    ],
+		},
+	    ];
+
+	    me.advancedColumn1 = undefined;
+	    me.advancedColumn2 = undefined;
+
+	    if (me.advancedColumnB) {
+		advItems.push({
+		    padding: '10 0 0 0',
+		    layout: 'anchor',
+		    items: me.advancedColumnB,
+		});
+		me.advancedColumnB = undefined;
+	    }
+	}
+
+	if (advItems) {
+	    me.hasAdvanced = true;
+	    advItems.unshift({
+		xtype: 'box',
+		hidden: false,
+		border: true,
+		autoEl: {
+		    tag: 'hr',
+		},
+	    });
+	    items.push({
+		xtype: 'container',
+		itemId: 'advancedContainer',
+		hidden: !me.showAdvanced,
+		defaults: {
+		    border: false,
+		},
+		items: advItems,
+	    });
+	}
+
+	Ext.apply(me, {
+	    layout: {
+		type: 'vbox',
+		align: 'stretch',
+	    },
+	    defaultType: 'container',
+	    items: items,
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.widget.Info', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pmxInfoWidget',
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    value: 0,
+    maximum: 1,
+    printBar: true,
+    items: [
+	{
+	    xtype: 'component',
+	    itemId: 'label',
+	    data: {
+		title: '',
+		usage: '',
+		iconCls: undefined,
+	    },
+	    tpl: [
+		'<div class="left-aligned">',
+		'<tpl if="iconCls">',
+		'<i class="{iconCls}"></i> ',
+		'</tpl>',
+		'{title}</div>&nbsp;<div class="right-aligned">{usage}</div>',
+	    ],
+	},
+	{
+	    height: 2,
+	    border: 0,
+	},
+	{
+	    xtype: 'progressbar',
+	    itemId: 'progress',
+	    height: 5,
+	    value: 0,
+	    animate: true,
+	},
+    ],
+
+    warningThreshold: 0.75,
+    criticalThreshold: 0.9,
+
+    setPrintBar: function(enable) {
+	var me = this;
+	me.printBar = enable;
+	me.getComponent('progress').setVisible(enable);
+    },
+
+    setIconCls: function(iconCls) {
+	var me = this;
+	me.getComponent('label').data.iconCls = iconCls;
+    },
+
+    setData: function(data) {
+	this.updateValue(data.text, data.usage);
+    },
+
+    updateValue: function(text, usage) {
+	let me = this;
+
+	if (me.lastText === text && me.lastUsage === usage) {
+	    return;
+	}
+	me.lastText = text;
+	me.lastUsage = usage;
+
+	var label = me.getComponent('label');
+	label.update(Ext.apply(label.data, { title: me.title, usage: text }));
+
+	if (usage !== undefined && me.printBar && Ext.isNumeric(usage) && usage >= 0) {
+	    let progressBar = me.getComponent('progress');
+	    progressBar.updateProgress(usage, '');
+	    if (usage > me.criticalThreshold) {
+		progressBar.removeCls('warning');
+		progressBar.addCls('critical');
+	    } else if (usage > me.warningThreshold) {
+		progressBar.removeCls('critical');
+		progressBar.addCls('warning');
+	    } else {
+		progressBar.removeCls('warning');
+		progressBar.removeCls('critical');
+	    }
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.title) {
+	    throw "no title defined";
+	}
+
+	me.callParent();
+
+	me.getComponent('progress').setVisible(me.printBar);
+
+	me.updateValue(me.text, me.value);
+	me.setIconCls(me.iconCls);
+    },
+
+});
+/*
+ * Display log entries in a panel with scrollbar
+ * The log entries are automatically refreshed via a background task,
+ * with newest entries coming at the bottom
+ */
+Ext.define('Proxmox.panel.LogView', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'proxmoxLogView',
+
+    pageSize: 510,
+    viewBuffer: 50,
+    lineHeight: 16,
+
+    scrollToEnd: true,
+
+    // callback for load failure, used for ceph
+    failCallback: undefined,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	updateParams: function() {
+	    let me = this;
+	    let viewModel = me.getViewModel();
+
+	    if (viewModel.get('hide_timespan') || viewModel.get('livemode')) {
+		return;
+	    }
+
+	    let since = viewModel.get('since');
+	    let until = viewModel.get('until');
+
+	    if (since > until) {
+		Ext.Msg.alert('Error', 'Since date must be less equal than Until date.');
+		return;
+	    }
+
+	    let submitFormat = viewModel.get('submitFormat');
+
+	    viewModel.set('params.since', Ext.Date.format(since, submitFormat));
+	    if (submitFormat === 'Y-m-d') {
+		viewModel.set('params.until', Ext.Date.format(until, submitFormat) + ' 23:59:59');
+	    } else {
+		viewModel.set('params.until', Ext.Date.format(until, submitFormat));
+	    }
+
+	    me.getView().loadTask.delay(200);
+	},
+
+	scrollPosBottom: function() {
+	    let view = this.getView();
+	    let pos = view.getScrollY();
+	    let maxPos = view.getScrollable().getMaxPosition().y;
+	    return maxPos - pos;
+	},
+
+	updateView: function(lines, first, total) {
+	    let me = this;
+	    let view = me.getView();
+	    let viewModel = me.getViewModel();
+	    let content = me.lookup('content');
+	    let data = viewModel.get('data');
+
+	    if (first === data.first && total === data.total && lines.length === data.lines) {
+		// before there is any real output, we get 'no output' as a single line, so always
+		// update if we only have one to be sure to catch the first real line of output
+		if (total !== 1) {
+		    return; // same content, skip setting and scrolling
+		}
+	    }
+	    viewModel.set('data', {
+		first: first,
+		total: total,
+		lines: lines.length,
+	    });
+
+	    let scrollPos = me.scrollPosBottom();
+	    let scrollToBottom = view.scrollToEnd && scrollPos <= 5;
+
+	    if (!scrollToBottom) {
+		// so that we have the 'correct' height for the text
+		lines.length = total;
+	    }
+
+	    content.update(lines.join('<br>'));
+
+	    if (scrollToBottom) {
+		let scroller = view.getScrollable();
+		scroller.suspendEvent('scroll');
+		view.scrollTo(0, Infinity);
+		me.updateStart(true);
+		scroller.resumeEvent('scroll');
+	    }
+	},
+
+	doLoad: function() {
+	    let me = this;
+	    if (me.running) {
+		me.requested = true;
+		return;
+	    }
+	    me.running = true;
+	    let view = me.getView();
+	    let viewModel = me.getViewModel();
+	    Proxmox.Utils.API2Request({
+		url: me.getView().url,
+		params: viewModel.get('params'),
+		method: 'GET',
+		success: function(response) {
+		    if (me.isDestroyed) {
+			return;
+		    }
+		    Proxmox.Utils.setErrorMask(me, false);
+		    let total = response.result.total;
+		    let lines = [];
+		    let first = Infinity;
+
+		    Ext.Array.each(response.result.data, function(line) {
+			if (first > line.n) {
+			    first = line.n;
+			}
+			lines[line.n - 1] = Ext.htmlEncode(line.t);
+		    });
+
+		    me.updateView(lines, first - 1, total);
+		    me.running = false;
+		    if (me.requested) {
+			me.requested = false;
+			view.loadTask.delay(200);
+		    }
+		},
+		failure: function(response) {
+		    if (view.failCallback) {
+			view.failCallback(response);
+		    } else {
+			let msg = response.htmlStatus;
+			Proxmox.Utils.setErrorMask(me, msg);
+		    }
+		    me.running = false;
+		    if (me.requested) {
+			me.requested = false;
+			view.loadTask.delay(200);
+		    }
+		},
+	    });
+	},
+
+	updateStart: function(scrolledToBottom, targetLine) {
+	    let me = this;
+	    let view = me.getView(), viewModel = me.getViewModel();
+
+	    let limit = viewModel.get('params.limit');
+	    let total = viewModel.get('data.total');
+
+	    // heuristic: scroll up? -> load more in front; scroll down? -> load more at end
+	    let startRatio = view.lastTargetLine && view.lastTargetLine > targetLine ? 2/3 : 1/3;
+	    view.lastTargetLine = targetLine;
+
+	    let newStart = scrolledToBottom
+		? Math.trunc(total - limit, 10)
+		: Math.trunc(targetLine - (startRatio * limit) + 10);
+
+	    viewModel.set('params.start', Math.max(newStart, 0));
+
+	    view.loadTask.delay(200);
+	},
+
+	onScroll: function(x, y) {
+	    let me = this;
+	    let view = me.getView(), viewModel = me.getViewModel();
+
+	    let line = view.getScrollY() / view.lineHeight;
+	    let viewLines = view.getHeight() / view.lineHeight;
+
+	    let viewStart = Math.max(Math.trunc(line - 1 - view.viewBuffer), 0);
+	    let viewEnd = Math.trunc(line + viewLines + 1 + view.viewBuffer);
+
+	    let { start, limit } = viewModel.get('params');
+
+	    let margin = start < 20 ? 0 : 20;
+
+	    if (viewStart < start + margin || viewEnd > start + limit - margin) {
+		me.updateStart(false, line);
+	    }
+	},
+
+	onLiveMode: function() {
+	    let me = this;
+	    let viewModel = me.getViewModel();
+	    viewModel.set('livemode', true);
+	    viewModel.set('params', { start: 0, limit: 510 });
+
+	    let view = me.getView();
+	    delete view.content;
+	    view.scrollToEnd = true;
+	    me.updateView([], true, false);
+	},
+
+	onTimespan: function() {
+	    let me = this;
+	    me.getViewModel().set('livemode', false);
+	    me.updateView([], false);
+	    // Directly apply currently selected values without update
+	    // button click.
+	    me.updateParams();
+	},
+
+	init: function(view) {
+	    let me = this;
+
+	    if (!view.url) {
+		throw "no url specified";
+	    }
+
+	    let viewModel = this.getViewModel();
+	    let since = new Date();
+	    since.setDate(since.getDate() - 3);
+	    viewModel.set('until', new Date());
+	    viewModel.set('since', since);
+	    viewModel.set('params.limit', view.pageSize);
+	    viewModel.set('hide_timespan', !view.log_select_timespan);
+	    viewModel.set('submitFormat', view.submitFormat);
+	    me.lookup('content').setStyle('line-height', `${view.lineHeight}px`);
+
+	    view.loadTask = new Ext.util.DelayedTask(me.doLoad, me);
+
+	    me.updateParams();
+	    view.task = Ext.TaskManager.start({
+		run: () => {
+		    if (!view.isVisible() || !view.scrollToEnd) {
+			return;
+		    }
+		    if (me.scrollPosBottom() <= 5) {
+			view.loadTask.delay(200);
+		    }
+		},
+		interval: 1000,
+	    });
+	},
+    },
+
+    onDestroy: function() {
+	let me = this;
+	me.loadTask.cancel();
+	Ext.TaskManager.stop(me.task);
+    },
+
+    // for user to initiate a load from outside
+    requestUpdate: function() {
+	let me = this;
+	me.loadTask.delay(200);
+    },
+
+    viewModel: {
+	data: {
+	    until: null,
+	    since: null,
+	    submitFormat: 'Y-m-d',
+	    livemode: true,
+	    hide_timespan: false,
+	    data: {
+		start: 0,
+		total: 0,
+		textlen: 0,
+	    },
+	    params: {
+		start: 0,
+		limit: 510,
+	    },
+	},
+    },
+
+    layout: 'auto',
+    bodyPadding: 5,
+    scrollable: {
+	x: 'auto',
+	y: 'auto',
+	listeners: {
+	    // we have to have this here, since we cannot listen to events of the scroller in
+	    // the viewcontroller (extjs bug?), nor does the panel have a 'scroll' event'
+	    scroll: {
+		fn: function(scroller, x, y) {
+		    let controller = this.component.getController();
+		    if (controller) { // on destroy, controller can be gone
+			controller.onScroll(x, y);
+		    }
+		},
+		buffer: 200,
+	    },
+	},
+    },
+
+    tbar: {
+	bind: {
+	    hidden: '{hide_timespan}',
+	},
+	items: [
+	    '->',
+	    {
+		xtype: 'segmentedbutton',
+		items: [
+		    {
+			text: gettext('Live Mode'),
+			bind: {
+			    pressed: '{livemode}',
+			},
+			handler: 'onLiveMode',
+		    },
+		    {
+			text: gettext('Select Timespan'),
+			bind: {
+			    pressed: '{!livemode}',
+			},
+			handler: 'onTimespan',
+		    },
+		],
+	    },
+	    {
+		xtype: 'box',
+		autoEl: { cn: gettext('Since') + ':' },
+		bind: {
+		    disabled: '{livemode}',
+		},
+	    },
+	    {
+		xtype: 'proxmoxDateTimeField',
+		name: 'since_date',
+		reference: 'since',
+		format: 'Y-m-d',
+		bind: {
+		    disabled: '{livemode}',
+		    value: '{since}',
+		    maxValue: '{until}',
+		    submitFormat: '{submitFormat}',
+		},
+	    },
+	    {
+		xtype: 'box',
+		autoEl: { cn: gettext('Until') + ':' },
+		bind: {
+		    disabled: '{livemode}',
+		},
+	    },
+	    {
+		xtype: 'proxmoxDateTimeField',
+		name: 'until_date',
+		reference: 'until',
+		format: 'Y-m-d',
+		bind: {
+		    disabled: '{livemode}',
+		    value: '{until}',
+		    minValue: '{since}',
+		    submitFormat: '{submitFormat}',
+		},
+	    },
+	    {
+		xtype: 'button',
+		text: 'Update',
+		handler: 'updateParams',
+		bind: {
+		    disabled: '{livemode}',
+		},
+	    },
+	],
+    },
+
+    items: [
+	{
+	    xtype: 'box',
+	    reference: 'content',
+	    style: {
+		font: 'normal 11px tahoma, arial, verdana, sans-serif',
+		'white-space': 'pre',
+	    },
+	},
+    ],
+});
+Ext.define('Proxmox.widget.NodeInfoRepoStatus', {
+    extend: 'Proxmox.widget.Info',
+    alias: 'widget.pmxNodeInfoRepoStatus',
+
+    title: gettext('Repository Status'),
+
+    colspan: 2,
+
+    printBar: false,
+
+    product: undefined,
+    repoLink: undefined,
+
+    viewModel: {
+	data: {
+	    subscriptionActive: '',
+	    noSubscriptionRepo: '',
+	    enterpriseRepo: '',
+	    testRepo: '',
+	},
+
+	formulas: {
+	    repoStatus: function(get) {
+		if (get('subscriptionActive') === '' || get('enterpriseRepo') === '') {
+		    return '';
+		}
+
+		if (get('noSubscriptionRepo') || get('testRepo')) {
+		    return 'non-production';
+		} else if (get('subscriptionActive') && get('enterpriseRepo')) {
+		    return 'ok';
+		} else if (!get('subscriptionActive') && get('enterpriseRepo')) {
+		    return 'no-sub';
+		} else if (!get('enterpriseRepo') || !get('noSubscriptionRepo') || !get('testRepo')) {
+		    return 'no-repo';
+		}
+		return 'unknown';
+	    },
+
+	    repoStatusMessage: function(get) {
+		let me = this;
+		let view = me.getView();
+
+		const status = get('repoStatus');
+
+		let repoLink = ` <a data-qtip="${gettext("Open Repositories Panel")}"
+		    href="${view.repoLink}">
+		    <i class="fa black fa-chevron-right txt-shadow-hover"></i>
+		    </a>`;
+
+		return Proxmox.Utils.formatNodeRepoStatus(status, view.product) + repoLink;
+	    },
+	},
+    },
+
+    setValue: function(value) { // for binding below
+	this.updateValue(value);
+    },
+
+    bind: {
+	value: '{repoStatusMessage}',
+    },
+
+    setRepositoryInfo: function(standardRepos) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	for (const standardRepo of standardRepos) {
+	    const handle = standardRepo.handle;
+	    const status = standardRepo.status || 0;
+
+	    if (handle === "enterprise") {
+		vm.set('enterpriseRepo', status);
+	    } else if (handle === "no-subscription") {
+		vm.set('noSubscriptionRepo', status);
+	    } else if (handle === "test") {
+		vm.set('testRepo', status);
+	    }
+	}
+    },
+
+    setSubscriptionStatus: function(status) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	vm.set('subscriptionActive', status);
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.product === undefined) {
+	    throw "no product name provided";
+	}
+
+	if (me.repoLink === undefined) {
+	    throw "no repo link href provided";
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.panel.NotificationConfigViewModel', {
+    extend: 'Ext.app.ViewModel',
+
+    alias: 'viewmodel.pmxNotificationConfigPanel',
+
+    formulas: {
+	builtinSelected: function(get) {
+	    let origin = get('selection')?.get('origin');
+	    return origin === 'modified-builtin' || origin === 'builtin';
+	},
+	removeButtonText: get => get('builtinSelected') ? gettext('Reset') : gettext('Remove'),
+	removeButtonConfirmMessage: function(get) {
+	    if (get('builtinSelected')) {
+		return gettext('Do you want to reset {0} to its default settings?');
+	    } else {
+		// Use default message provided by the button
+		return undefined;
+	    }
+	},
+    },
+
+});
+
+Ext.define('Proxmox.panel.NotificationConfigView', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pmxNotificationConfigView',
+    mixins: ['Proxmox.Mixin.CBind'],
+    onlineHelp: 'chapter_notifications',
+    layout: {
+	type: 'border',
+    },
+
+    items: [
+	{
+	    region: 'center',
+	    border: false,
+	    xtype: 'pmxNotificationEndpointView',
+	    cbind: {
+		baseUrl: '{baseUrl}',
+	    },
+	},
+	{
+	    region: 'south',
+	    height: '50%',
+	    border: false,
+	    collapsible: true,
+	    animCollapse: false,
+	    xtype: 'pmxNotificationMatcherView',
+	    cbind: {
+		baseUrl: '{baseUrl}',
+	    },
+	},
+    ],
+});
+
+Ext.define('Proxmox.panel.NotificationEndpointView', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pmxNotificationEndpointView',
+
+    title: gettext('Notification Targets'),
+
+    viewModel: {
+	type: 'pmxNotificationConfigPanel',
+    },
+
+    bind: {
+	selection: '{selection}',
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	openEditWindow: function(endpointType, endpoint) {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.EndpointEditBase', {
+		baseUrl: me.getView().baseUrl,
+		type: endpointType,
+
+		name: endpoint,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	openEditForSelectedItem: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    if (selection.length < 1) {
+		return;
+	    }
+
+	    me.openEditWindow(selection[0].data.type, selection[0].data.name);
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.getStore().rstore.load();
+	    this.getView().setSelection(null);
+	},
+
+	testEndpoint: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    if (selection.length < 1) {
+		return;
+	    }
+
+	    let target = selection[0].data.name;
+
+	    Ext.Msg.confirm(
+		gettext("Notification Target Test"),
+		Ext.String.format(gettext("Do you want to send a test notification to '{0}'?"), target),
+		function(decision) {
+		    if (decision !== "yes") {
+			return;
+		    }
+
+		    Proxmox.Utils.API2Request({
+			method: 'POST',
+			url: `${view.baseUrl}/targets/${target}/test`,
+
+			success: function(response, opt) {
+			    Ext.Msg.show({
+				title: gettext('Notification Target Test'),
+				message: Ext.String.format(
+				    gettext("Sent test notification to '{0}'."),
+				    target,
+				),
+				buttons: Ext.Msg.OK,
+				icon: Ext.Msg.INFO,
+			    });
+			},
+			autoErrorAlert: true,
+		    });
+	    });
+	},
+    },
+
+    listeners: {
+	itemdblclick: 'openEditForSelectedItem',
+	activate: 'reload',
+    },
+
+    emptyText: gettext('No notification targets configured'),
+
+    columns: [
+	{
+	    dataIndex: 'disable',
+	    text: gettext('Enable'),
+	    renderer: (disable) => Proxmox.Utils.renderEnabledIcon(!disable),
+	    align: 'center',
+	},
+	{
+	    dataIndex: 'name',
+	    text: gettext('Target Name'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 2,
+	},
+	{
+	    dataIndex: 'type',
+	    text: gettext('Type'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+	{
+	    dataIndex: 'comment',
+	    text: gettext('Comment'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 3,
+	},
+	{
+	    dataIndex: 'origin',
+	    text: gettext('Origin'),
+	    renderer: (origin) => {
+		switch (origin) {
+		    case 'user-created': return gettext('Custom');
+		    case 'modified-builtin': return gettext('Built-In (modified)');
+		    case 'builtin': return gettext('Built-In');
+		}
+
+		// Should not happen...
+		return 'unknown';
+	    },
+	},
+    ],
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	rstore: {
+	    type: 'update',
+	    storeid: 'proxmox-notification-endpoints',
+	    model: 'proxmox-notification-endpoints',
+	    autoStart: true,
+	},
+	sorters: 'name',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.baseUrl) {
+	    throw "baseUrl is not set!";
+	}
+
+	let menuItems = [];
+	for (const [endpointType, config] of Object.entries(
+	    Proxmox.Schema.notificationEndpointTypes).sort()) {
+	    menuItems.push({
+		text: config.name,
+		iconCls: 'fa fa-fw ' + (config.iconCls || 'fa-bell-o'),
+		handler: () => me.controller.openEditWindow(endpointType),
+	    });
+	}
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: menuItems,
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Modify'),
+		    handler: 'openEditForSelectedItem',
+		    disabled: true,
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    callback: 'reload',
+		    bind: {
+			text: '{removeButtonText}',
+			customConfirmationMessage: '{removeButtonConfirmMessage}',
+		    },
+		    getUrl: function(rec) {
+			return `${me.baseUrl}/endpoints/${rec.data.type}/${rec.getId()}`;
+		    },
+		    enableFn: (rec) => {
+			let origin = rec.get('origin');
+			return origin === 'user-created' || origin === 'modified-builtin';
+		    },
+		},
+		'-',
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Test'),
+		    handler: 'testEndpoint',
+		    disabled: true,
+		},
+	    ],
+	});
+
+	me.callParent();
+	me.store.rstore.proxy.setUrl(`/api2/json/${me.baseUrl}/targets`);
+    },
+});
+
+Ext.define('Proxmox.panel.NotificationMatcherView', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pmxNotificationMatcherView',
+
+    title: gettext('Notification Matchers'),
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	openEditWindow: function(matcher) {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.NotificationMatcherEdit', {
+		baseUrl: me.getView().baseUrl,
+		name: matcher,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	openEditForSelectedItem: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    if (selection.length < 1) {
+		return;
+	    }
+
+	    me.openEditWindow(selection[0].data.name);
+	},
+
+	reload: function() {
+	    this.getView().getStore().rstore.load();
+	    this.getView().setSelection(null);
+	},
+    },
+
+    viewModel: {
+	type: 'pmxNotificationConfigPanel',
+    },
+
+    bind: {
+	selection: '{selection}',
+    },
+
+    listeners: {
+	itemdblclick: 'openEditForSelectedItem',
+	activate: 'reload',
+    },
+
+    emptyText: gettext('No notification matchers configured'),
+
+    columns: [
+	{
+	    dataIndex: 'disable',
+	    text: gettext('Enable'),
+	    renderer: (disable) => Proxmox.Utils.renderEnabledIcon(!disable),
+	    align: 'center',
+	},
+	{
+	    dataIndex: 'name',
+	    text: gettext('Matcher Name'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+	{
+	    dataIndex: 'comment',
+	    text: gettext('Comment'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 2,
+	},
+	{
+	    dataIndex: 'origin',
+	    text: gettext('Origin'),
+	    renderer: (origin) => {
+		switch (origin) {
+		    case 'user-created': return gettext('Custom');
+		    case 'modified-builtin': return gettext('Built-In (modified)');
+		    case 'builtin': return gettext('Built-In');
+		}
+
+		// Should not happen...
+		return 'unknown';
+	    },
+	},
+    ],
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	rstore: {
+	    type: 'update',
+	    storeid: 'proxmox-notification-matchers',
+	    model: 'proxmox-notification-matchers',
+	    autoStart: true,
+	},
+	sorters: 'name',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.baseUrl) {
+	    throw "baseUrl is not set!";
+	}
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Add'),
+		    handler: () => me.getController().openEditWindow(),
+		    selModel: false,
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Modify'),
+		    handler: 'openEditForSelectedItem',
+		    disabled: true,
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    callback: 'reload',
+		    bind: {
+			text: '{removeButtonText}',
+			customConfirmationMessage: '{removeButtonConfirmMessage}',
+		    },
+		    baseurl: `${me.baseUrl}/matchers`,
+		    enableFn: (rec) => {
+			let origin = rec.get('origin');
+			return origin === 'user-created' || origin === 'modified-builtin';
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+	me.store.rstore.proxy.setUrl(`/api2/json/${me.baseUrl}/matchers`);
+    },
+});
+/*
+ * Display log entries in a panel with scrollbar
+ * The log entries are automatically refreshed via a background task,
+ * with newest entries coming at the bottom
+ */
+Ext.define('Proxmox.panel.JournalView', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'proxmoxJournalView',
+
+    numEntries: 500,
+    lineHeight: 16,
+
+    scrollToEnd: true,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	updateParams: function() {
+	    let me = this;
+	    let viewModel = me.getViewModel();
+	    let since = viewModel.get('since');
+	    let until = viewModel.get('until');
+
+	    since.setHours(0, 0, 0, 0);
+	    until.setHours(0, 0, 0, 0);
+	    until.setDate(until.getDate()+1);
+
+	    me.getView().loadTask.delay(200, undefined, undefined, [
+		false,
+		false,
+		Ext.Date.format(since, "U"),
+		Ext.Date.format(until, "U"),
+	    ]);
+	},
+
+	scrollPosBottom: function() {
+	    let view = this.getView();
+	    let pos = view.getScrollY();
+	    let maxPos = view.getScrollable().getMaxPosition().y;
+	    return maxPos - pos;
+	},
+
+	scrollPosTop: function() {
+	    let view = this.getView();
+	    return view.getScrollY();
+	},
+
+	updateScroll: function(livemode, num, scrollPos, scrollPosTop) {
+	    let me = this;
+	    let view = me.getView();
+
+	    if (!livemode) {
+		setTimeout(function() { view.scrollTo(0, 0); }, 10);
+	    } else if (view.scrollToEnd && scrollPos <= 5) {
+		setTimeout(function() { view.scrollTo(0, Infinity); }, 10);
+	    } else if (!view.scrollToEnd && scrollPosTop < 20 * view.lineHeight) {
+		setTimeout(function() { view.scrollTo(0, (num * view.lineHeight) + scrollPosTop); }, 10);
+	    }
+	},
+
+	updateView: function(lines, livemode, top) {
+	    let me = this;
+	    let view = me.getView();
+	    let viewmodel = me.getViewModel();
+	    if (!viewmodel || viewmodel.get('livemode') !== livemode) {
+		return; // we switched mode, do not update the content
+	    }
+	    let contentEl = me.lookup('content');
+
+	    // save old scrollpositions
+	    let scrollPos = me.scrollPosBottom();
+	    let scrollPosTop = me.scrollPosTop();
+
+	    let newend = lines.shift();
+	    let newstart = lines.pop();
+
+	    let num = lines.length;
+	    let text = lines.map(Ext.htmlEncode).join('<br>');
+
+	    let contentChanged = true;
+
+	    if (!livemode) {
+		if (num) {
+		    view.content = text;
+		} else {
+		    view.content = 'nothing logged or no timespan selected';
+		}
+	    } else {
+		// update content
+		if (top && num) {
+		    view.content = view.content ? text + '<br>' + view.content : text;
+		} else if (!top && num) {
+		    view.content = view.content ? view.content + '<br>' + text : text;
+		} else {
+		    contentChanged = false;
+		}
+
+		// update cursors
+		if (!top || !view.startcursor) {
+		    view.startcursor = newstart;
+		}
+
+		if (top || !view.endcursor) {
+		    view.endcursor = newend;
+		}
+	    }
+
+	    if (contentChanged) {
+		contentEl.update(view.content);
+	    }
+
+	    me.updateScroll(livemode, num, scrollPos, scrollPosTop);
+	},
+
+	doLoad: function(livemode, top, since, until) {
+	    let me = this;
+	    if (me.running) {
+		me.requested = true;
+		return;
+	    }
+	    me.running = true;
+	    let view = me.getView();
+	    let params = {
+		lastentries: view.numEntries || 500,
+	    };
+	    if (livemode) {
+		if (!top && view.startcursor) {
+		    params = {
+			startcursor: view.startcursor,
+		    };
+		} else if (view.endcursor) {
+		    params.endcursor = view.endcursor;
+		}
+	    } else {
+		params = {
+		    since: since,
+		    until: until,
+		};
+	    }
+	    Proxmox.Utils.API2Request({
+		url: view.url,
+		params: params,
+		waitMsgTarget: !livemode ? view : undefined,
+		method: 'GET',
+		success: function(response) {
+		    if (me.isDestroyed) {
+			return;
+		    }
+		    Proxmox.Utils.setErrorMask(me, false);
+		    let lines = response.result.data;
+		    me.updateView(lines, livemode, top);
+		    me.running = false;
+		    if (me.requested) {
+			me.requested = false;
+			view.loadTask.delay(200);
+		    }
+		},
+		failure: function(response) {
+		    let msg = response.htmlStatus;
+		    Proxmox.Utils.setErrorMask(me, msg);
+		    me.running = false;
+		    if (me.requested) {
+			me.requested = false;
+			view.loadTask.delay(200);
+		    }
+		},
+	    });
+	},
+
+	onScroll: function(x, y) {
+	    let me = this;
+	    let view = me.getView();
+	    let viewmodel = me.getViewModel();
+	    let livemode = viewmodel.get('livemode');
+	    if (!livemode) {
+		return;
+	    }
+
+	    if (me.scrollPosTop() < 20*view.lineHeight) {
+		view.scrollToEnd = false;
+		view.loadTask.delay(200, undefined, undefined, [true, true]);
+	    } else if (me.scrollPosBottom() <= 5) {
+		view.scrollToEnd = true;
+	    }
+	},
+
+	init: function(view) {
+	    let me = this;
+
+	    if (!view.url) {
+		throw "no url specified";
+	    }
+
+	    let viewmodel = me.getViewModel();
+	    let viewModel = this.getViewModel();
+	    let since = new Date();
+	    since.setDate(since.getDate() - 3);
+	    viewModel.set('until', new Date());
+	    viewModel.set('since', since);
+	    me.lookup('content').setStyle('line-height', view.lineHeight + 'px');
+
+	    view.loadTask = new Ext.util.DelayedTask(me.doLoad, me, [true, false]);
+
+	    view.task = Ext.TaskManager.start({
+		run: function() {
+		    if (!view.isVisible() || !view.scrollToEnd || !viewmodel.get('livemode')) {
+			return;
+		    }
+
+		    if (me.scrollPosBottom() <= 5) {
+			view.loadTask.delay(200, undefined, undefined, [true, false]);
+		    }
+		},
+		interval: 1000,
+	    });
+	},
+
+	onLiveMode: function() {
+	    let me = this;
+	    let view = me.getView();
+	    delete view.startcursor;
+	    delete view.endcursor;
+	    delete view.content;
+	    me.getViewModel().set('livemode', true);
+	    view.scrollToEnd = true;
+	    me.updateView([], true, false);
+	},
+
+	onTimespan: function() {
+	    let me = this;
+	    me.getViewModel().set('livemode', false);
+	    me.updateView([], false);
+	},
+    },
+
+    onDestroy: function() {
+	let me = this;
+	me.loadTask.cancel();
+	Ext.TaskManager.stop(me.task);
+	delete me.content;
+    },
+
+    // for user to initiate a load from outside
+    requestUpdate: function() {
+	let me = this;
+	me.loadTask.delay(200);
+    },
+
+    viewModel: {
+	data: {
+	    livemode: true,
+	    until: null,
+	    since: null,
+	},
+    },
+
+    layout: 'auto',
+    bodyPadding: 5,
+    scrollable: {
+	x: 'auto',
+	y: 'auto',
+	listeners: {
+	    // we have to have this here, since we cannot listen to events
+	    // of the scroller in the viewcontroller (extjs bug?), nor does
+	    // the panel have a 'scroll' event'
+	    scroll: {
+		fn: function(scroller, x, y) {
+		    let controller = this.component.getController();
+		    if (controller) { // on destroy, controller can be gone
+			controller.onScroll(x, y);
+		    }
+		},
+		buffer: 200,
+	    },
+	},
+    },
+
+    tbar: {
+
+	items: [
+	    '->',
+	    {
+		xtype: 'segmentedbutton',
+		items: [
+		    {
+			text: gettext('Live Mode'),
+			bind: {
+			    pressed: '{livemode}',
+			},
+			handler: 'onLiveMode',
+		    },
+		    {
+			text: gettext('Select Timespan'),
+			bind: {
+			    pressed: '{!livemode}',
+			},
+			handler: 'onTimespan',
+		    },
+		],
+	    },
+	    {
+		xtype: 'box',
+		bind: { disabled: '{livemode}' },
+		autoEl: { cn: gettext('Since') + ':' },
+	    },
+	    {
+		xtype: 'datefield',
+		name: 'since_date',
+		reference: 'since',
+		format: 'Y-m-d',
+		bind: {
+		    disabled: '{livemode}',
+		    value: '{since}',
+		    maxValue: '{until}',
+		},
+	    },
+	    {
+		xtype: 'box',
+		bind: { disabled: '{livemode}' },
+		autoEl: { cn: gettext('Until') + ':' },
+	    },
+	    {
+		xtype: 'datefield',
+		name: 'until_date',
+		reference: 'until',
+		format: 'Y-m-d',
+		bind: {
+		    disabled: '{livemode}',
+		    value: '{until}',
+		    minValue: '{since}',
+		},
+	    },
+	    {
+		xtype: 'button',
+		text: 'Update',
+		reference: 'updateBtn',
+		handler: 'updateParams',
+		bind: {
+		    disabled: '{livemode}',
+		},
+	    },
+	],
+    },
+
+    items: [
+	{
+	    xtype: 'box',
+	    reference: 'content',
+	    style: {
+		font: 'normal 11px tahoma, arial, verdana, sans-serif',
+		'white-space': 'pre',
+	    },
+	},
+    ],
+});
+Ext.define('pmx-permissions', {
+    extend: 'Ext.data.TreeModel',
+    fields: [
+	'text', 'type',
+	{
+	    type: 'boolean', name: 'propagate',
+	},
+    ],
+});
+
+Ext.define('Proxmox.panel.PermissionViewPanel', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'proxmoxPermissionViewPanel',
+
+    scrollable: true,
+    layout: 'fit',
+    rootVisible: false,
+    animate: false,
+    sortableColumns: false,
+
+    auth_id_name: "userid",
+    auth_id: undefined,
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    header: gettext('Path') + '/' + gettext('Permission'),
+	    dataIndex: 'text',
+	    flex: 6,
+	},
+	{
+	    header: gettext('Propagate'),
+	    dataIndex: 'propagate',
+	    flex: 1,
+	    renderer: function(value) {
+		if (Ext.isDefined(value)) {
+		    return Proxmox.Utils.format_boolean(value);
+		}
+		return '';
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	Proxmox.Utils.API2Request({
+	    url: '/access/permissions?' + encodeURIComponent(me.auth_id_name) + '=' + encodeURIComponent(me.auth_id),
+	    method: 'GET',
+	    failure: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, false);
+		let result = Ext.decode(response.responseText);
+		let data = result.data || {};
+
+		let root = {
+		    name: '__root',
+		    expanded: true,
+		    children: [],
+		};
+		let idhash = {
+		    '/': {
+			children: [],
+			text: '/',
+			type: 'path',
+		    },
+		};
+		Ext.Object.each(data, function(path, perms) {
+		    let path_item = {
+			text: path,
+			type: 'path',
+			children: [],
+		    };
+		    Ext.Object.each(perms, function(perm, propagate) {
+			let perm_item = {
+			    text: perm,
+			    type: 'perm',
+			    propagate: propagate === 1 || propagate === true,
+			    iconCls: 'fa fa-fw fa-unlock',
+			    leaf: true,
+			};
+			path_item.children.push(perm_item);
+			path_item.expandable = true;
+		    });
+		    idhash[path] = path_item;
+		});
+
+		Ext.Object.each(idhash, function(path, item) {
+		    let parent_item = idhash['/'];
+		    if (path === '/') {
+			parent_item = root;
+			item.expanded = true;
+		    } else {
+			let split_path = path.split('/');
+			while (split_path.pop()) {
+			    let parent_path = split_path.join('/');
+			    if (idhash[parent_path]) {
+				parent_item = idhash[parent_path];
+				break;
+			    }
+			}
+		    }
+		    parent_item.children.push(item);
+		});
+
+		me.setRootNode(root);
+	    },
+	});
+
+	me.callParent();
+
+	me.store.sorters.add(new Ext.util.Sorter({
+	    sorterFn: function(rec1, rec2) {
+		let v1 = rec1.data.text,
+		    v2 = rec2.data.text;
+		if (rec1.data.type !== rec2.data.type) {
+		    v2 = rec1.data.type;
+		    v1 = rec2.data.type;
+		}
+		if (v1 > v2) {
+		    return 1;
+		} else if (v1 < v2) {
+		    return -1;
+		}
+		return 0;
+	    },
+	}));
+    },
+});
+
+Ext.define('Proxmox.PermissionView', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.userShowPermissionWindow',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    scrollable: true,
+    width: 800,
+    height: 600,
+    layout: 'fit',
+    cbind: {
+	title: (get) => Ext.String.htmlEncode(get('auth_id')) +
+	    ` - ${gettext('Granted Permissions')}`,
+    },
+    items: [{
+	xtype: 'proxmoxPermissionViewPanel',
+	cbind: {
+	    auth_id: '{auth_id}',
+	    auth_id_name: '{auth_id_name}',
+	},
+    }],
+});
+Ext.define('Proxmox.panel.PruneInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxPruneInputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    // set on use for now
+    //onlineHelp: 'maintenance_pruning',
+
+    keepLastEmptyText: '',
+
+    cbindData: function() {
+	let me = this;
+	me.isCreate = !!me.isCreate;
+	return {};
+    },
+
+    column1: [
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-last',
+	    fieldLabel: gettext('Keep Last'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+		emptyText: '{keepLastEmptyText}',
+	    },
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-daily',
+	    fieldLabel: gettext('Keep Daily'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-monthly',
+	    fieldLabel: gettext('Keep Monthly'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+    column2: [
+	{
+	    xtype: 'pmxPruneKeepField',
+	    fieldLabel: gettext('Keep Hourly'),
+	    name: 'keep-hourly',
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-weekly',
+	    fieldLabel: gettext('Keep Weekly'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-yearly',
+	    fieldLabel: gettext('Keep Yearly'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+});
+// override the download server url globally, for privacy reasons
+Ext.draw.Container.prototype.defaultDownloadServerUrl = "-";
+
+Ext.define('Proxmox.chart.axis.segmenter.NumericBase2', {
+    extend: 'Ext.chart.axis.segmenter.Numeric',
+    alias: 'segmenter.numericBase2',
+
+    // derived from the original numeric segmenter but using 2 instead of 10 as base
+    preferredStep: function(min, estStepSize) {
+	// Getting an order of magnitude of the estStepSize with a common logarithm.
+	let order = Math.floor(Math.log2(estStepSize));
+	let scale = Math.pow(2, order);
+
+	estStepSize /= scale;
+
+	// FIXME: below is not useful when using base 2 instead of base 10, we could
+	// just directly set estStepSize to 2
+	if (estStepSize <= 1) {
+	    estStepSize = 1;
+	} else if (estStepSize < 2) {
+	    estStepSize = 2;
+	}
+	return {
+	    unit: {
+		// When passed estStepSize is less than 1, its order of magnitude
+		// is equal to -number_of_leading_zeros in the estStepSize.
+		fixes: -order, // Number of fractional digits.
+		scale: scale,
+	    },
+	    step: estStepSize,
+	};
+    },
+
+    /**
+     * Wraps the provided estimated step size of a range without altering it into a step size object.
+     *
+     * @param {*} min The start point of range.
+     * @param {*} estStepSize The estimated step size.
+     * @return {Object} Return the step size by an object of step x unit.
+     * @return {Number} return.step The step count of units.
+     * @return {Object} return.unit The unit.
+     */
+    // derived from the original numeric segmenter but using 2 instead of 10 as base
+    exactStep: function(min, estStepSize) {
+	let order = Math.floor(Math.log2(estStepSize));
+	let scale = Math.pow(2, order);
+
+	return {
+	    unit: {
+		// add one decimal point if estStepSize is not a multiple of scale
+		fixes: -order + (estStepSize % scale === 0 ? 0 : 1),
+		scale: 1,
+	    },
+	    step: estStepSize,
+	};
+    },
+});
+
+Ext.define('Proxmox.widget.RRDChart', {
+    extend: 'Ext.chart.CartesianChart',
+    alias: 'widget.proxmoxRRDChart',
+
+    unit: undefined, // bytes, bytespersecond, percent
+
+    powerOfTwo: false,
+
+    // set to empty string to suppress warning in debug mode
+    downloadServerUrl: '-',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    this.powerOfTwo = view.powerOfTwo;
+	},
+
+	convertToUnits: function(value) {
+	    let units = ['', 'k', 'M', 'G', 'T', 'P'];
+	    let si = 0;
+	    let format = '0.##';
+	    if (value < 0.1) format += '#';
+	    const baseValue = this.powerOfTwo ? 1024 : 1000;
+	    while (value >= baseValue && si < units.length -1) {
+		value = value / baseValue;
+		si++;
+	    }
+
+	    // javascript floating point weirdness
+	    value = Ext.Number.correctFloat(value);
+
+	    // limit decimal points
+	    value = Ext.util.Format.number(value, format);
+
+	    let unit = units[si];
+	    if (unit && this.powerOfTwo) unit += 'i';
+
+	    return `${value.toString()} ${unit}`;
+	},
+
+	leftAxisRenderer: function(axis, label, layoutContext) {
+	    let me = this;
+	    return me.convertToUnits(label);
+	},
+
+	onSeriesTooltipRender: function(tooltip, record, item) {
+	    let view = this.getView();
+
+	    let suffix = '';
+	    if (view.unit === 'percent') {
+		suffix = '%';
+	    } else if (view.unit === 'bytes') {
+		suffix = 'B';
+	    } else if (view.unit === 'bytespersecond') {
+		suffix = 'B/s';
+	    }
+
+	    let prefix = item.field;
+	    if (view.fieldTitles && view.fieldTitles[view.fields.indexOf(item.field)]) {
+		prefix = view.fieldTitles[view.fields.indexOf(item.field)];
+	    }
+	    let v = this.convertToUnits(record.get(item.field));
+	    let t = new Date(record.get('time'));
+	    tooltip.setHtml(`${prefix}: ${v}${suffix}<br>${t}`);
+	},
+
+	onAfterAnimation: function(chart, eopts) {
+	    if (!chart.header || !chart.header.tools) {
+		return;
+	    }
+	    // if the undo button is disabled, disable our tool
+	    let ourUndoZoomButton = chart.header.tools[0];
+	    let undoButton = chart.interactions[0].getUndoButton();
+	    ourUndoZoomButton.setDisabled(undoButton.isDisabled());
+	},
+    },
+
+    width: 770,
+    height: 300,
+    animation: false,
+    interactions: [
+	{
+	    type: 'crosszoom',
+	},
+    ],
+    legend: {
+	type: 'dom',
+	padding: 0,
+    },
+    listeners: {
+	redraw: {
+	    fn: 'onAfterAnimation',
+	    options: {
+		buffer: 500,
+	    },
+	},
+    },
+
+    touchAction: {
+	panX: true,
+	panY: true,
+    },
+
+    constructor: function(config) {
+	let me = this;
+
+	let segmenter = config.powerOfTwo ? 'numericBase2' : 'numeric';
+	config.axes = [
+	    {
+		type: 'numeric',
+		position: 'left',
+		grid: true,
+		renderer: 'leftAxisRenderer',
+		minimum: 0,
+		segmenter,
+	    },
+	    {
+		type: 'time',
+		position: 'bottom',
+		grid: true,
+		fields: ['time'],
+	    },
+	];
+	me.callParent([config]);
+    },
+
+    checkThemeColors: function() {
+	let me = this;
+	let rootStyle = getComputedStyle(document.documentElement);
+
+	// get colors
+	let background = rootStyle.getPropertyValue("--pwt-panel-background").trim() || "#ffffff";
+	let text = rootStyle.getPropertyValue("--pwt-text-color").trim() || "#000000";
+	let primary = rootStyle.getPropertyValue("--pwt-chart-primary").trim() || "#000000";
+	let gridStroke = rootStyle.getPropertyValue("--pwt-chart-grid-stroke").trim() || "#dddddd";
+
+	// set the colors
+	me.setBackground(background);
+	me.axes.forEach((axis) => {
+		axis.setLabel({ color: text });
+		axis.setTitle({ color: text });
+		axis.setStyle({ strokeStyle: primary });
+		axis.setGrid({ stroke: gridStroke });
+	});
+	me.redraw();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.store) {
+	    throw "cannot work without store";
+	}
+
+	if (!me.fields) {
+	    throw "cannot work without fields";
+	}
+
+	me.callParent();
+
+	// add correct label for left axis
+	let axisTitle = "";
+	if (me.unit === 'percent') {
+	    axisTitle = "%";
+	} else if (me.unit === 'bytes') {
+	    axisTitle = "Bytes";
+	} else if (me.unit === 'bytespersecond') {
+	    axisTitle = "Bytes/s";
+	} else if (me.fieldTitles && me.fieldTitles.length === 1) {
+	    axisTitle = me.fieldTitles[0];
+	} else if (me.fields.length === 1) {
+	    axisTitle = me.fields[0];
+	}
+
+	me.axes[0].setTitle(axisTitle);
+
+	me.updateHeader();
+
+	if (me.header && me.legend) {
+	    me.header.padding = '4 9 4';
+	    me.header.add(me.legend);
+	    me.legend = undefined;
+	}
+
+	if (!me.noTool) {
+	    me.addTool({
+		type: 'minus',
+		disabled: true,
+		tooltip: gettext('Undo Zoom'),
+		handler: function() {
+		    let undoButton = me.interactions[0].getUndoButton();
+		    if (undoButton.handler) {
+			undoButton.handler();
+		    }
+		},
+	    });
+	}
+
+	// add a series for each field we get
+	me.fields.forEach(function(item, index) {
+	    let title = item;
+	    if (me.fieldTitles && me.fieldTitles[index]) {
+		title = me.fieldTitles[index];
+	    }
+	    me.addSeries(Ext.apply(
+		{
+		    type: 'line',
+		    xField: 'time',
+		    yField: item,
+		    title: title,
+		    fill: true,
+		    style: {
+			lineWidth: 1.5,
+			opacity: 0.60,
+		    },
+		    marker: {
+			opacity: 0,
+			scaling: 0.01,
+		    },
+		    highlightCfg: {
+			opacity: 1,
+			scaling: 1.5,
+		    },
+		    tooltip: {
+			trackMouse: true,
+			renderer: 'onSeriesTooltipRender',
+		    },
+		},
+		me.seriesConfig,
+	    ));
+	});
+
+	// enable animation after the store is loaded
+	me.store.onAfter('load', function() {
+	    me.setAnimation({
+		duration: 200,
+		easing: 'easeIn',
+	    });
+	}, this, { single: true });
+
+
+	me.checkThemeColors();
+
+	// switch colors on media query changes
+	me.mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
+	me.themeListener = (e) => { me.checkThemeColors(); };
+	me.mediaQueryList.addEventListener("change", me.themeListener);
+    },
+
+    doDestroy: function() {
+	let me = this;
+
+	me.mediaQueryList.removeEventListener("change", me.themeListener);
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.panel.GaugeWidget', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.proxmoxGauge',
+
+    defaults: {
+	style: {
+	    'text-align': 'center',
+	},
+    },
+    items: [
+	{
+	    xtype: 'box',
+	    itemId: 'title',
+	    data: {
+		title: '',
+	    },
+	    tpl: '<h3>{title}</h3>',
+	},
+	{
+	    xtype: 'polar',
+	    height: 120,
+	    border: false,
+	    // set to '-' to suppress warning in debug mode
+	    downloadServerUrl: '-',
+	    itemId: 'chart',
+	    series: [{
+		type: 'gauge',
+		value: 0,
+		colors: ['#f5f5f5'],
+		sectors: [0],
+		donut: 90,
+		needleLength: 100,
+		totalAngle: Math.PI,
+	    }],
+	    sprites: [{
+		id: 'valueSprite',
+		type: 'text',
+		text: '',
+		textAlign: 'center',
+		textBaseline: 'bottom',
+		x: 125,
+		y: 110,
+		fontSize: 30,
+	    }],
+	},
+	{
+	    xtype: 'box',
+	    itemId: 'text',
+	},
+    ],
+
+    header: false,
+    border: false,
+
+    warningThreshold: 0.6,
+    criticalThreshold: 0.9,
+    warningColor: '#fc0',
+    criticalColor: '#FF6C59',
+    defaultColor: '#c2ddf2',
+    backgroundColor: '#f5f5f5',
+
+    initialValue: 0,
+
+    checkThemeColors: function() {
+	let me = this;
+	let rootStyle = getComputedStyle(document.documentElement);
+
+	// get colors
+	let panelBg = rootStyle.getPropertyValue("--pwt-panel-background").trim() || "#ffffff";
+	let textColor = rootStyle.getPropertyValue("--pwt-text-color").trim() || "#000000";
+	me.defaultColor = rootStyle.getPropertyValue("--pwt-gauge-default").trim() || '#c2ddf2';
+	me.criticalColor = rootStyle.getPropertyValue("--pwt-gauge-crit").trim() || '#ff6c59';
+	me.warningColor = rootStyle.getPropertyValue("--pwt-gauge-warn").trim() || '#fc0';
+	me.backgroundColor = rootStyle.getPropertyValue("--pwt-gauge-back").trim() || '#f5f5f5';
+
+	// set gauge colors
+	let value = me.chart.series[0].getValue() / 100;
+
+	let color = me.defaultColor;
+
+	if (value >= me.criticalThreshold) {
+	    color = me.criticalColor;
+	} else if (value >= me.warningThreshold) {
+	    color = me.warningColor;
+	}
+
+	me.chart.series[0].setColors([color, me.backgroundColor]);
+
+	// set text and background colors
+	me.chart.setBackground(panelBg);
+	me.valueSprite.setAttributes({ fillStyle: textColor }, true);
+	me.chart.redraw();
+    },
+
+    updateValue: function(value, text) {
+	let me = this;
+	let color = me.defaultColor;
+	let attr = {};
+
+	if (value >= me.criticalThreshold) {
+	    color = me.criticalColor;
+	} else if (value >= me.warningThreshold) {
+	    color = me.warningColor;
+	}
+
+	me.chart.series[0].setColors([color, me.backgroundColor]);
+	me.chart.series[0].setValue(value*100);
+
+	me.valueSprite.setText(' '+(value*100).toFixed(0) + '%');
+	attr.x = me.chart.getWidth()/2;
+	attr.y = me.chart.getHeight()-20;
+	if (me.spriteFontSize) {
+	    attr.fontSize = me.spriteFontSize;
+	}
+	me.valueSprite.setAttributes(attr, true);
+
+	if (text !== undefined) {
+	    me.text.setHtml(text);
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	if (me.title) {
+	    me.getComponent('title').update({ title: me.title });
+	}
+	me.text = me.getComponent('text');
+	me.chart = me.getComponent('chart');
+	me.valueSprite = me.chart.getSurface('chart').get('valueSprite');
+
+	me.checkThemeColors();
+
+	// switch colors on media query changes
+	me.mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
+	me.themeListener = (e) => { me.checkThemeColors(); };
+	me.mediaQueryList.addEventListener("change", me.themeListener);
+    },
+
+    doDestroy: function() {
+	let me = this;
+
+	me.mediaQueryList.removeEventListener("change", me.themeListener);
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.panel.GotifyEditPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxGotifyEditPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+    onlineHelp: 'notification_targets_gotify',
+
+    type: 'gotify',
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'name',
+	    cbind: {
+		value: '{name}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Endpoint Name'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'enable',
+	    fieldLabel: gettext('Enable'),
+	    allowBlank: false,
+	    checked: true,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Server URL'),
+	    name: 'server',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('API Token'),
+	    name: 'token',
+	    cbind: {
+		emptyText: get => !get('isCreate') ? gettext('Unchanged') : '',
+		allowBlank: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    onSetValues: (values) => {
+	values.enable = !values.disable;
+
+	delete values.disable;
+	return values;
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (values.enable) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'disable' });
+	    }
+	} else {
+	    values.disable = 1;
+	}
+
+	delete values.enable;
+
+	return values;
+    },
+});
+Ext.define('Proxmox.panel.Certificates', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pmxCertificates',
+
+    // array of { name, id (=filename), url, deletable, reloadUi }
+    uploadButtons: undefined,
+
+    // The /info path for the current node.
+    infoUrl: undefined,
+
+    columns: [
+	{
+	    header: gettext('File'),
+	    width: 150,
+	    dataIndex: 'filename',
+	},
+	{
+	    header: gettext('Issuer'),
+	    flex: 1,
+	    dataIndex: 'issuer',
+	},
+	{
+	    header: gettext('Subject'),
+	    flex: 1,
+	    dataIndex: 'subject',
+	},
+	{
+	    header: gettext('Public Key Alogrithm'),
+	    flex: 1,
+	    dataIndex: 'public-key-type',
+	    hidden: true,
+	},
+	{
+	    header: gettext('Public Key Size'),
+	    flex: 1,
+	    dataIndex: 'public-key-bits',
+	    hidden: true,
+	},
+	{
+	    header: gettext('Valid Since'),
+	    width: 150,
+	    dataIndex: 'notbefore',
+	    renderer: Proxmox.Utils.render_timestamp,
+	},
+	{
+	    header: gettext('Expires'),
+	    width: 150,
+	    dataIndex: 'notafter',
+	    renderer: Proxmox.Utils.render_timestamp,
+	},
+	{
+	    header: gettext('Subject Alternative Names'),
+	    flex: 1,
+	    dataIndex: 'san',
+	    renderer: Proxmox.Utils.render_san,
+	},
+	{
+	    header: gettext('Fingerprint'),
+	    dataIndex: 'fingerprint',
+	    hidden: true,
+	},
+	{
+	    header: gettext('PEM'),
+	    dataIndex: 'pem',
+	    hidden: true,
+	},
+    ],
+
+    reload: function() {
+	let me = this;
+	me.rstore.load();
+    },
+
+    delete_certificate: function() {
+	let me = this;
+
+	let rec = me.selModel.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+
+	let cert = me.certById[rec.id];
+	let url = cert.url;
+	Proxmox.Utils.API2Request({
+	    url: `/api2/extjs/${url}?restart=1`,
+	    method: 'DELETE',
+	    success: function(response, opt) {
+		if (cert.reloadUi) {
+		    Ext.getBody().mask(
+			gettext('API server will be restarted to use new certificates, please reload web-interface!'),
+			['pve-static-mask'],
+		    );
+		    // try to reload after 10 seconds automatically
+		    Ext.defer(() => window.location.reload(true), 10000);
+		}
+	    },
+	    failure: function(response, opt) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	});
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	view_certificate: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+	    let win = Ext.create('Proxmox.window.CertificateViewer', {
+		cert: selection[0].data.filename,
+		url: `/api2/extjs/${view.infoUrl}`,
+	    });
+	    win.show();
+	},
+    },
+
+    listeners: {
+	itemdblclick: 'view_certificate',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    // only used for the store name
+	    me.nodename = "_all";
+	}
+
+	if (!me.uploadButtons) {
+	    throw "no upload buttons defined";
+	}
+
+	if (!me.infoUrl) {
+	    throw "no certificate store url given";
+	}
+
+	me.rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    storeid: 'certs-' + me.nodename,
+	    model: 'proxmox-certificate',
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/extjs/${me.infoUrl}`,
+	    },
+	});
+
+	me.store = {
+	    type: 'diff',
+	    rstore: me.rstore,
+	};
+
+	let tbar = [];
+
+	me.deletableCertIds = {};
+	me.certById = {};
+	if (me.uploadButtons.length === 1) {
+	    let cert = me.uploadButtons[0];
+
+	    if (!cert.url) {
+		throw "missing certificate url";
+	    }
+
+	    me.certById[cert.id] = cert;
+
+	    if (cert.deletable) {
+		me.deletableCertIds[cert.id] = true;
+	    }
+
+	    tbar.push(
+		{
+		    xtype: 'button',
+		    text: gettext('Upload Custom Certificate'),
+		    handler: function() {
+			let grid = this.up('grid');
+			let win = Ext.create('Proxmox.window.CertificateUpload', {
+			    url: `/api2/extjs/${cert.url}`,
+			    reloadUi: cert.reloadUi,
+			});
+			win.show();
+			win.on('destroy', grid.reload, grid);
+		    },
+		},
+	    );
+	} else {
+	    let items = [];
+
+	    me.selModel = Ext.create('Ext.selection.RowModel', {});
+
+	    for (const cert of me.uploadButtons) {
+		if (!cert.id) {
+		    throw "missing id in certificate entry";
+		}
+
+		if (!cert.url) {
+		    throw "missing url in certificate entry";
+		}
+
+		if (!cert.name) {
+		    throw "missing name in certificate entry";
+		}
+
+		me.certById[cert.id] = cert;
+
+		if (cert.deletable) {
+		    me.deletableCertIds[cert.id] = true;
+		}
+
+		items.push({
+		    text: Ext.String.format('Upload {0} Certificate', cert.name),
+		    handler: function() {
+			let grid = this.up('grid');
+			let win = Ext.create('Proxmox.window.CertificateUpload', {
+			    url: `/api2/extjs/${cert.url}`,
+			    reloadUi: cert.reloadUi,
+			});
+			win.show();
+			win.on('destroy', grid.reload, grid);
+		    },
+		});
+	    }
+
+	    tbar.push(
+		{
+		    text: gettext('Upload Custom Certificate'),
+		    menu: {
+			xtype: 'menu',
+			items,
+		    },
+		},
+	    );
+	}
+
+	tbar.push(
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Delete Custom Certificate'),
+		confirmMsg: rec => {
+		    let cert = me.certById[rec.id];
+		    if (cert.name) {
+			return Ext.String.format(
+			    gettext('Are you sure you want to remove the certificate used for {0}'),
+			    cert.name,
+			);
+		    }
+		    return gettext('Are you sure you want to remove the certificate');
+		},
+		callback: () => me.reload(),
+		selModel: me.selModel,
+		disabled: true,
+		enableFn: rec => !!me.deletableCertIds[rec.id],
+		handler: function() { me.delete_certificate(); },
+	    },
+	    '-',
+	    {
+		xtype: 'proxmoxButton',
+		itemId: 'viewbtn',
+		disabled: true,
+		text: gettext('View Certificate'),
+		handler: 'view_certificate',
+	    },
+	);
+	Ext.apply(me, { tbar });
+
+	me.callParent();
+
+	me.rstore.startUpdate();
+	me.on('destroy', me.rstore.stopUpdate, me.rstore);
+    },
+});
+Ext.define('Proxmox.panel.ACMEAccounts', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pmxACMEAccounts',
+
+    title: gettext('Accounts'),
+
+    acmeUrl: undefined,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addAccount: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let defaultExists = view.getStore().findExact('name', 'default') !== -1;
+	    Ext.create('Proxmox.window.ACMEAccountCreate', {
+		defaultExists,
+		acmeUrl: view.acmeUrl,
+		taskDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	viewAccount: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+	    Ext.create('Proxmox.window.ACMEAccountView', {
+	        url: `${view.acmeUrl}/account/${selection[0].data.name}`,
+	    }).show();
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.getStore().rstore.load();
+	},
+
+	showTaskAndReload: function(options, success, response) {
+	    let me = this;
+	    if (!success) return;
+
+	    let upid = response.result.data;
+	    Ext.create('Proxmox.window.TaskProgress', {
+		upid,
+		taskDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+    },
+
+    minHeight: 150,
+    emptyText: gettext('No Accounts configured'),
+
+    columns: [
+	{
+	    dataIndex: 'name',
+	    text: gettext('Name'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'viewAccount',
+    },
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	rstore: {
+	    type: 'update',
+	    storeid: 'proxmox-acme-accounts',
+	    model: 'proxmox-acme-accounts',
+	    autoStart: true,
+	},
+	sorters: 'name',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.acmeUrl) {
+	    throw "no acmeUrl given";
+	}
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Add'),
+		    selModel: false,
+		    handler: 'addAccount',
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('View'),
+		    handler: 'viewAccount',
+		    disabled: true,
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    baseurl: `${me.acmeUrl}/account`,
+		    callback: 'showTaskAndReload',
+		},
+	    ],
+	});
+
+	me.callParent();
+	me.store.rstore.proxy.setUrl(`/api2/json/${me.acmeUrl}/account`);
+    },
+});
+Ext.define('Proxmox.panel.ACMEPluginView', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pmxACMEPluginView',
+
+    title: gettext('Challenge Plugins'),
+    acmeUrl: undefined,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addPlugin: function() {
+	    let me = this;
+	    let view = me.getView();
+	    Ext.create('Proxmox.window.ACMEPluginEdit', {
+		acmeUrl: view.acmeUrl,
+		url: `${view.acmeUrl}/plugins`,
+		isCreate: true,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	editPlugin: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+	    let plugin = selection[0].data.plugin;
+	    Ext.create('Proxmox.window.ACMEPluginEdit', {
+		acmeUrl: view.acmeUrl,
+		url: `${view.acmeUrl}/plugins/${plugin}`,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.getStore().rstore.load();
+	},
+    },
+
+    minHeight: 150,
+    emptyText: gettext('No Plugins configured'),
+
+    columns: [
+	{
+	    dataIndex: 'plugin',
+	    text: gettext('Plugin'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+	{
+	    dataIndex: 'api',
+	    text: 'API',
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'editPlugin',
+    },
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	rstore: {
+	    type: 'update',
+	    storeid: 'proxmox-acme-plugins',
+	    model: 'proxmox-acme-plugins',
+	    autoStart: true,
+	    filters: item => !!item.data.api,
+	},
+	sorters: 'plugin',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.acmeUrl) {
+	    throw "no acmeUrl given";
+	}
+	me.url = `${me.acmeUrl}/plugins`;
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Add'),
+		    handler: 'addPlugin',
+		    selModel: false,
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Edit'),
+		    handler: 'editPlugin',
+		    disabled: true,
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    callback: 'reload',
+		    baseurl: `${me.acmeUrl}/plugins`,
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	me.store.rstore.proxy.setUrl(`/api2/json/${me.acmeUrl}/plugins`);
+    },
+});
+Ext.define('proxmox-acme-domains', {
+    extend: 'Ext.data.Model',
+    fields: ['domain', 'type', 'alias', 'plugin', 'configkey'],
+    idProperty: 'domain',
+});
+
+Ext.define('Proxmox.panel.ACMEDomains', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pmxACMEDomains',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    margin: '10 0 0 0',
+    title: 'ACME',
+
+    emptyText: gettext('No Domains configured'),
+
+    // URL to the config containing 'acme' and 'acmedomainX' properties
+    url: undefined,
+
+    // array of { name, url, usageLabel }
+    domainUsages: undefined,
+    // if no domainUsages parameter is supllied, the orderUrl is required instead:
+    orderUrl: undefined,
+    // Force the use of 'acmedomainX' properties.
+    separateDomainEntries: undefined,
+
+    acmeUrl: undefined,
+
+    cbindData: function(config) {
+	let me = this;
+	return {
+	    acmeUrl: me.acmeUrl,
+	    accountUrl: `/api2/json/${me.acmeUrl}/account`,
+	};
+    },
+
+    viewModel: {
+	data: {
+	    domaincount: 0,
+	    account: undefined, // the account we display
+	    configaccount: undefined, // the account set in the config
+	    accountEditable: false,
+	    accountsAvailable: false,
+	    hasUsage: false,
+	},
+
+	formulas: {
+	    canOrder: (get) => !!get('account') && get('domaincount') > 0,
+	    editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'),
+	    accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'),
+	    accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'),
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let accountSelector = this.lookup('accountselector');
+	    accountSelector.store.on('load', this.onAccountsLoad, this);
+	},
+
+	onAccountsLoad: function(store, records, success) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let configaccount = vm.get('configaccount');
+	    vm.set('accountsAvailable', records.length > 0);
+	    if (me.autoChangeAccount && records.length > 0) {
+		me.changeAccount(records[0].data.name, () => {
+		    vm.set('accountEditable', false);
+		    me.reload();
+		});
+		me.autoChangeAccount = false;
+	    } else if (configaccount) {
+		if (store.findExact('name', configaccount) !== -1) {
+		    vm.set('account', configaccount);
+		} else {
+		    vm.set('account', null);
+		}
+	    }
+	},
+
+	addDomain: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    Ext.create('Proxmox.window.ACMEDomainEdit', {
+		url: view.url,
+		acmeUrl: view.acmeUrl,
+		nodeconfig: view.nodeconfig,
+		domainUsages: view.domainUsages,
+		separateDomainEntries: view.separateDomainEntries,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	editDomain: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+
+	    Ext.create('Proxmox.window.ACMEDomainEdit', {
+		url: view.url,
+		acmeUrl: view.acmeUrl,
+		nodeconfig: view.nodeconfig,
+		domainUsages: view.domainUsages,
+		separateDomainEntries: view.separateDomainEntries,
+		domain: selection[0].data,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	removeDomain: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+
+	    let rec = selection[0].data;
+	    let params = {};
+	    if (rec.configkey !== 'acme') {
+		params.delete = rec.configkey;
+	    } else {
+		let acme = Proxmox.Utils.parseACME(view.nodeconfig.acme);
+		Proxmox.Utils.remove_domain_from_acme(acme, rec.domain);
+		params.acme = Proxmox.Utils.printACME(acme);
+	    }
+
+	    Proxmox.Utils.API2Request({
+		method: 'PUT',
+		url: view.url,
+		params,
+		success: function(response, opt) {
+		    me.reload();
+		},
+		failure: function(response, opt) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	},
+
+	toggleEditAccount: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let editable = vm.get('accountEditable');
+	    if (editable) {
+		me.changeAccount(vm.get('account'), function() {
+		    vm.set('accountEditable', false);
+		    me.reload();
+		});
+	    } else {
+		vm.set('accountEditable', true);
+	    }
+	},
+
+	changeAccount: function(account, callback) {
+	    let me = this;
+	    let view = me.getView();
+	    let params = {};
+
+	    let acme = Proxmox.Utils.parseACME(view.nodeconfig.acme);
+	    acme.account = account;
+	    params.acme = Proxmox.Utils.printACME(acme);
+
+	    Proxmox.Utils.API2Request({
+		method: 'PUT',
+		waitMsgTarget: view,
+		url: view.url,
+		params,
+		success: function(response, opt) {
+		    if (Ext.isFunction(callback)) {
+			callback();
+		    }
+		},
+		failure: function(response, opt) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	},
+
+	order: function(cert) {
+	    let me = this;
+	    let view = me.getView();
+
+	    Proxmox.Utils.API2Request({
+		method: 'POST',
+		params: {
+		    force: 1,
+		},
+		url: cert ? cert.url : view.orderUrl,
+		success: function(response, opt) {
+		    Ext.create('Proxmox.window.TaskViewer', {
+		        upid: response.result.data,
+		        taskDone: function(success) {
+			    me.orderFinished(success, cert);
+		        },
+		    }).show();
+		},
+		failure: function(response, opt) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	},
+
+	orderFinished: function(success, cert) {
+	    if (!success || !cert.reloadUi) return;
+
+	    Ext.getBody().mask(
+		gettext('API server will be restarted to use new certificates, please reload web-interface!'),
+		['pve-static-mask'],
+	    );
+	    // try to reload after 10 seconds automatically
+	    Ext.defer(() => window.location.reload(true), 10000);
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.rstore.load();
+	},
+
+	addAccount: function() {
+	    let me = this;
+	    let view = me.getView();
+	    Ext.create('Proxmox.window.ACMEAccountCreate', {
+		autoShow: true,
+		acmeUrl: view.acmeUrl,
+		taskDone: function() {
+		    me.reload();
+		    let accountSelector = me.lookup('accountselector');
+		    me.autoChangeAccount = true;
+		    accountSelector.store.load();
+		},
+	    });
+	},
+    },
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Add'),
+	    handler: 'addDomain',
+	    selModel: false,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    disabled: true,
+	    handler: 'editDomain',
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    handler: 'removeDomain',
+	},
+	'-',
+	'order-menu', // placeholder, filled in initComponent
+	'-',
+	{
+	    xtype: 'displayfield',
+	    value: gettext('Using Account') + ':',
+	    bind: {
+		hidden: '{!accountsAvailable}',
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    reference: 'accounttext',
+	    renderer: (val) => val || Proxmox.Utils.NoneText,
+	    bind: {
+		value: '{account}',
+		hidden: '{accountTextHidden}',
+	    },
+	},
+	{
+	    xtype: 'pmxACMEAccountSelector',
+	    hidden: true,
+	    reference: 'accountselector',
+	    cbind: {
+		url: '{accountUrl}',
+	    },
+	    bind: {
+		value: '{account}',
+		hidden: '{accountValueHidden}',
+	    },
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa black fa-pencil',
+	    baseCls: 'x-plain',
+	    userCls: 'pointer',
+	    bind: {
+		iconCls: '{editBtnIcon}',
+		hidden: '{!accountsAvailable}',
+	    },
+	    handler: 'toggleEditAccount',
+	},
+	{
+	    xtype: 'displayfield',
+	    value: gettext('No Account available.'),
+	    bind: {
+		hidden: '{accountsAvailable}',
+	    },
+	},
+	{
+	    xtype: 'button',
+	    hidden: true,
+	    reference: 'accountlink',
+	    text: gettext('Add ACME Account'),
+	    bind: {
+		hidden: '{accountsAvailable}',
+	    },
+	    handler: 'addAccount',
+	},
+    ],
+
+    updateStore: function(store, records, success) {
+	let me = this;
+	let data = [];
+	let rec;
+	if (success && records.length > 0) {
+	    rec = records[0];
+	} else {
+	    rec = {
+		data: {},
+	    };
+	}
+
+	me.nodeconfig = rec.data; // save nodeconfig for updates
+
+	let account = 'default';
+
+	if (rec.data.acme) {
+	    let obj = Proxmox.Utils.parseACME(rec.data.acme);
+	    (obj.domains || []).forEach(domain => {
+		if (domain === '') return;
+		let record = {
+		    domain,
+		    type: 'standalone',
+		    configkey: 'acme',
+		};
+		data.push(record);
+	    });
+
+	    if (obj.account) {
+		account = obj.account;
+	    }
+	}
+
+	let vm = me.getViewModel();
+	let oldaccount = vm.get('account');
+
+	// account changed, and we do not edit currently, load again to verify
+	if (oldaccount !== account && !vm.get('accountEditable')) {
+	    vm.set('configaccount', account);
+	    me.lookup('accountselector').store.load();
+	}
+
+	for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) {
+	    let acmedomain = rec.data[`acmedomain${i}`];
+	    if (!acmedomain) continue;
+
+	    let record = Proxmox.Utils.parsePropertyString(acmedomain, 'domain');
+	    record.type = record.plugin ? 'dns' : 'standalone';
+	    record.configkey = `acmedomain${i}`;
+	    data.push(record);
+	}
+
+	vm.set('domaincount', data.length);
+	me.store.loadData(data, false);
+    },
+
+    listeners: {
+	itemdblclick: 'editDomain',
+    },
+
+    columns: [
+	{
+	    dataIndex: 'domain',
+	    flex: 5,
+	    text: gettext('Domain'),
+	},
+	{
+	    dataIndex: 'usage',
+	    flex: 1,
+	    text: gettext('Usage'),
+	    bind: {
+		hidden: '{!hasUsage}',
+	    },
+	},
+	{
+	    dataIndex: 'type',
+	    flex: 1,
+	    text: gettext('Type'),
+	},
+	{
+	    dataIndex: 'plugin',
+	    flex: 1,
+	    text: gettext('Plugin'),
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.acmeUrl) {
+	    throw "no acmeUrl given";
+	}
+
+	if (!me.url) {
+	    throw "no url given";
+	}
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	if (!me.domainUsages && !me.orderUrl) {
+	    throw "neither domainUsages nor orderUrl given";
+	}
+
+	me.rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 10 * 1000,
+	    autoStart: true,
+	    storeid: `proxmox-node-domains-${me.nodename}`,
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/${me.url}`,
+	    },
+	});
+
+	me.store = Ext.create('Ext.data.Store', {
+	    model: 'proxmox-acme-domains',
+	    sorters: 'domain',
+	});
+
+	if (me.domainUsages) {
+	    let items = [];
+
+	    for (const cert of me.domainUsages) {
+		if (!cert.name) {
+		    throw "missing certificate url";
+		}
+
+		if (!cert.url) {
+		    throw "missing certificate url";
+		}
+
+		items.push({
+		    text: Ext.String.format('Order {0} Certificate Now', cert.name),
+		    handler: function() {
+			return me.getController().order(cert);
+		    },
+		});
+	    }
+	    me.tbar.splice(
+		me.tbar.indexOf("order-menu"),
+		1,
+		{
+		    text: gettext('Order Certificates Now'),
+		    menu: {
+			xtype: 'menu',
+			items,
+		    },
+		},
+	    );
+	} else {
+	    me.tbar.splice(
+		me.tbar.indexOf("order-menu"),
+		1,
+		{
+		    xtype: 'button',
+		    reference: 'order',
+		    text: gettext('Order Certificates Now'),
+		    bind: {
+			disabled: '{!canOrder}',
+		    },
+		    handler: function() {
+			return me.getController().order();
+		    },
+		},
+	    );
+	}
+
+	me.callParent();
+	me.getViewModel().set('hasUsage', !!me.domainUsages);
+	me.mon(me.rstore, 'load', 'updateStore', me);
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+	me.on('destroy', me.rstore.stopUpdate, me.rstore);
+    },
+});
+Ext.define('Proxmox.panel.EmailRecipientPanel', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pmxEmailRecipientPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+    border: false,
+
+    mailValidator: function() {
+	let mailto_user = this.down(`[name=mailto-user]`);
+	let mailto = this.down(`[name=mailto]`);
+
+	if (!mailto_user.getValue()?.length && !mailto.getValue()) {
+	    return gettext('Either mailto or mailto-user must be set');
+	}
+
+	return true;
+    },
+
+    items: [
+	{
+	    layout: 'anchor',
+	    border: false,
+	    cbind: {
+		isCreate: '{isCreate}',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxUserSelector',
+		    name: 'mailto-user',
+		    multiSelect: true,
+		    allowBlank: true,
+		    editable: false,
+		    skipEmptyText: true,
+		    fieldLabel: gettext('Recipient(s)'),
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    validator: function() {
+			return this.up('pmxEmailRecipientPanel').mailValidator();
+		    },
+		    autoEl: {
+			tag: 'div',
+			'data-qtip': gettext('The notification will be sent to the user\'s configured mail address'),
+		    },
+		    listConfig: {
+			width: 600,
+			columns: [
+			    {
+				header: gettext('User'),
+				sortable: true,
+				dataIndex: 'userid',
+				renderer: Ext.String.htmlEncode,
+				flex: 1,
+			    },
+			    {
+				header: gettext('E-Mail'),
+				sortable: true,
+				dataIndex: 'email',
+				renderer: Ext.String.htmlEncode,
+				flex: 1,
+			    },
+			    {
+				header: gettext('Comment'),
+				sortable: false,
+				dataIndex: 'comment',
+				renderer: Ext.String.htmlEncode,
+				flex: 1,
+			    },
+			],
+		    },
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    fieldLabel: gettext('Additional Recipient(s)'),
+		    name: 'mailto',
+		    allowBlank: true,
+		    emptyText: 'user@example.com, ...',
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    autoEl: {
+			tag: 'div',
+			'data-qtip': gettext('Multiple recipients must be separated by spaces, commas or semicolons'),
+		    },
+		    validator: function() {
+			return this.up('pmxEmailRecipientPanel').mailValidator();
+		    },
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('Proxmox.panel.SendmailEditPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxSendmailEditPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    type: 'sendmail',
+    onlineHelp: 'notification_targets_sendmail',
+
+    mailValidator: function() {
+	let mailto_user = this.down(`[name=mailto-user]`);
+	let mailto = this.down(`[name=mailto]`);
+
+	if (!mailto_user.getValue()?.length && !mailto.getValue()) {
+	    return gettext('Either mailto or mailto-user must be set');
+	}
+
+	return true;
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'name',
+	    cbind: {
+		value: '{name}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Endpoint Name'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'enable',
+	    fieldLabel: gettext('Enable'),
+	    allowBlank: false,
+	    checked: true,
+	},
+	{
+	    // provides 'mailto' and 'mailto-user' fields
+	    xtype: 'pmxEmailRecipientPanel',
+	    cbind: {
+		isCreate: '{isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    advancedItems: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Author'),
+	    name: 'author',
+	    allowBlank: true,
+	    cbind: {
+		emptyText: '{defaultMailAuthor}',
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('From Address'),
+	    name: 'from-address',
+	    allowBlank: true,
+	    emptyText: gettext('Defaults to datacenter configuration, or root@$hostname'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    onSetValues: (values) => {
+	values.enable = !values.disable;
+
+	delete values.disable;
+	return values;
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (values.enable) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'disable' });
+	    }
+	} else {
+	    values.disable = 1;
+	}
+
+	delete values.enable;
+
+	if (values.mailto) {
+	    values.mailto = values.mailto.split(/[\s,;]+/);
+	}
+	return values;
+    },
+});
+Ext.define('Proxmox.panel.SmtpEditPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxSmtpEditPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+    onlineHelp: 'notification_targets_smtp',
+
+    type: 'smtp',
+
+    viewModel: {
+	xtype: 'viewmodel',
+	cbind: {
+	    isCreate: "{isCreate}",
+	},
+	data: {
+	    mode: 'tls',
+	    authentication: true,
+	},
+	formulas: {
+	    portEmptyText: function(get) {
+		let port;
+
+		switch (get('mode')) {
+		    case 'insecure':
+			port = 25;
+			break;
+		    case 'starttls':
+			port = 587;
+			break;
+		    case 'tls':
+			port = 465;
+			break;
+		}
+		return `${Proxmox.Utils.defaultText} (${port})`;
+	    },
+	    passwordEmptyText: function(get) {
+		let isCreate = this.isCreate;
+		return get('authentication') && !isCreate ? gettext('Unchanged') : '';
+	    },
+	},
+    },
+
+    columnT: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'name',
+	    cbind: {
+		value: '{name}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Endpoint Name'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'enable',
+	    fieldLabel: gettext('Enable'),
+	    allowBlank: false,
+	    checked: true,
+	},
+    ],
+
+    column1: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Server'),
+	    name: 'server',
+	    allowBlank: false,
+	    emptyText: gettext('mail.example.com'),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'mode',
+	    fieldLabel: gettext('Encryption'),
+	    editable: false,
+	    comboItems: [
+		['insecure', Proxmox.Utils.noneText + ' (' + gettext('insecure') + ')'],
+		['starttls', 'STARTTLS'],
+		['tls', 'TLS'],
+	    ],
+	    bind: "{mode}",
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'port',
+	    fieldLabel: gettext('Port'),
+	    minValue: 1,
+	    maxValue: 65535,
+	    bind: {
+		emptyText: "{portEmptyText}",
+	    },
+	    submitEmptyText: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+    column2: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Authenticate'),
+	    name: 'authentication',
+	    bind: {
+		value: '{authentication}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Username'),
+	    name: 'username',
+	    allowBlank: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	    bind: {
+		disabled: '{!authentication}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Password'),
+	    name: 'password',
+	    allowBlank: true,
+	    bind: {
+		disabled: '{!authentication}',
+		emptyText: '{passwordEmptyText}',
+	    },
+	},
+    ],
+    columnB: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('From Address'),
+	    name: 'from-address',
+	    allowBlank: false,
+	    emptyText: gettext('user@example.com'),
+	},
+	{
+	    // provides 'mailto' and 'mailto-user' fields
+	    xtype: 'pmxEmailRecipientPanel',
+	    cbind: {
+		isCreate: '{isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    advancedColumnB: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Author'),
+	    name: 'author',
+	    allowBlank: true,
+	    cbind: {
+		emptyText: '{defaultMailAuthor}',
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (values.mailto) {
+	    values.mailto = values.mailto.split(/[\s,;]+/);
+	}
+
+	if (!values.authentication && !me.isCreate) {
+	    Proxmox.Utils.assemble_field_data(values, { 'delete': 'username' });
+	    Proxmox.Utils.assemble_field_data(values, { 'delete': 'password' });
+	}
+
+	if (values.enable) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'disable' });
+	    }
+	} else {
+	    values.disable = 1;
+	}
+
+	delete values.enable;
+
+	delete values.authentication;
+
+	return values;
+    },
+
+    onSetValues: function(values) {
+	values.authentication = !!values.username;
+	values.enable = !values.disable;
+	delete values.disable;
+
+	return values;
+    },
+});
+Ext.define('Proxmox.panel.StatusView', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pmxStatusView',
+
+    layout: {
+	type: 'column',
+    },
+
+    title: gettext('Status'),
+
+    getRecordValue: function(key, store) {
+	let me = this;
+
+	if (!key) {
+	    throw "no key given";
+	}
+
+	if (store === undefined) {
+	    store = me.getStore();
+	}
+
+	let rec = store.getById(key);
+	if (rec) {
+	    return rec.data.value;
+	}
+	return '';
+    },
+
+    fieldRenderer: function(val, max) {
+	if (max === undefined) {
+	    return val;
+	}
+
+	if (!Ext.isNumeric(max) || max === 1) {
+	    return Proxmox.Utils.render_usage(val);
+	}
+	return Proxmox.Utils.render_size_usage(val, max);
+    },
+
+    fieldCalculator: function(used, max) {
+	if (!Ext.isNumeric(max) && Ext.isNumeric(used)) {
+	    return used;
+	} else if (!Ext.isNumeric(used)) {
+	    /* we come here if the field is from a node
+	     * where the records are not mem and maxmem
+	     * but mem.used and mem.total
+	     */
+	    if (used.used !== undefined &&
+		used.total !== undefined) {
+		return used.total > 0 ? used.used/used.total : 0;
+	    }
+	}
+
+	return used/max;
+    },
+
+    updateField: function(field) {
+	let me = this;
+	let renderer = me.fieldRenderer;
+	if (Ext.isFunction(field.renderer)) {
+	    renderer = field.renderer;
+	}
+	if (field.multiField === true) {
+	    field.updateValue(renderer.call(field, me.getStore().getRecord()));
+	} else if (field.textField !== undefined) {
+	    field.updateValue(renderer.call(field, me.getRecordValue(field.textField)));
+	} else if (field.valueField !== undefined) {
+	    let used = me.getRecordValue(field.valueField);
+	    let max = field.maxField !== undefined ? me.getRecordValue(field.maxField) : 1;
+
+	    let calculate = me.fieldCalculator;
+	    if (Ext.isFunction(field.calculate)) {
+		calculate = field.calculate;
+	    }
+	    field.updateValue(renderer.call(field, used, max), calculate(used, max));
+	}
+    },
+
+    getStore: function() {
+	let me = this;
+
+	if (!me.rstore) {
+	    throw "there is no rstore";
+	}
+
+	return me.rstore;
+    },
+
+    updateTitle: function() {
+	let me = this;
+	me.setTitle(me.getRecordValue('name'));
+    },
+
+    updateValues: function(store, records, success) {
+	let me = this;
+
+	if (!success) {
+	    return; // do not update if store load was not successful
+	}
+	me.query('pmxInfoWidget').forEach(me.updateField, me);
+	me.query('pveInfoWidget').forEach(me.updateField, me);
+
+	me.updateTitle(store);
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.rstore) {
+	    throw "no rstore given";
+	}
+	if (!me.title) {
+	    throw "no title given";
+	}
+
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+
+	me.callParent();
+
+	me.mon(me.rstore, 'load', me.updateValues, me);
+    },
+
+});
+Ext.define('pmx-tfa-users', {
+    extend: 'Ext.data.Model',
+    fields: ['userid'],
+    idProperty: 'userid',
+    proxy: {
+	type: 'proxmox',
+	url: '/api2/json/access/tfa',
+    },
+});
+
+Ext.define('pmx-tfa-entry', {
+    extend: 'Ext.data.Model',
+    fields: ['fullid', 'userid', 'type', 'description', 'created', 'enable'],
+    idProperty: 'fullid',
+});
+
+
+Ext.define('Proxmox.panel.TfaView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pmxTfaView',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext('Second Factors'),
+    reference: 'tfaview',
+
+    issuerName: 'Proxmox',
+    yubicoEnabled: false,
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	return {
+	    yubicoEnabled: me.yubicoEnabled,
+	};
+    },
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	model: 'pmx-tfa-entry',
+	rstore: {
+	    type: 'store',
+	    proxy: 'memory',
+	    storeid: 'pmx-tfa-entry',
+	    model: 'pmx-tfa-entry',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let me = this;
+	    view.tfaStore = Ext.create('Proxmox.data.UpdateStore', {
+		autoStart: true,
+		interval: 5 * 1000,
+		storeid: 'pmx-tfa-users',
+		model: 'pmx-tfa-users',
+	    });
+	    view.tfaStore.on('load', this.onLoad, this);
+	    view.on('destroy', view.tfaStore.stopUpdate);
+	    Proxmox.Utils.monStoreErrors(view, view.tfaStore);
+	},
+
+	reload: function() { this.getView().tfaStore.load(); },
+
+	onLoad: function(store, data, success) {
+	    if (!success) return;
+
+	    let now = new Date().getTime() / 1000;
+	    let records = [];
+	    Ext.Array.each(data, user => {
+		let tfa_locked = (user.data['tfa-locked-until'] || 0) > now;
+		let totp_locked = user.data['totp-locked'];
+		Ext.Array.each(user.data.entries, entry => {
+		    records.push({
+			fullid: `${user.id}/${entry.id}`,
+			userid: user.id,
+			type: entry.type,
+			description: entry.description,
+			created: entry.created,
+			enable: entry.enable,
+			locked: tfa_locked || (entry.type === 'totp' && totp_locked),
+		    });
+		});
+	    });
+
+	    let rstore = this.getView().store.rstore;
+	    rstore.loadData(records);
+	    rstore.fireEvent('load', rstore, records, true);
+	},
+
+	addTotp: function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddTotp', {
+		isCreate: true,
+		issuerName: me.getView().issuerName,
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+	    }).show();
+	},
+
+	addWebauthn: function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddWebauthn', {
+		isCreate: true,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	addRecovery: async function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddTfaRecovery', {
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	addYubico: function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddYubico', {
+		isCreate: true,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	editItem: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length !== 1 || selection[0].id.endsWith("/recovery")) {
+		return;
+	    }
+
+	    Ext.create('Proxmox.window.TfaEdit', {
+		'tfa-id': selection[0].data.fullid,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	renderUser: fullid => fullid.split('/')[0],
+
+	renderEnabled: function(enabled, metaData, record) {
+	    if (record.data.locked) {
+		return gettext("Locked");
+	    } else if (enabled === undefined) {
+		return Proxmox.Utils.yesText;
+	    } else {
+		return Proxmox.Utils.format_boolean(enabled);
+	    }
+	},
+
+	onRemoveButton: function(btn, event, record) {
+	    let me = this;
+
+	    Ext.create('Proxmox.tfa.confirmRemove', {
+		...record.data,
+		callback: password => me.removeItem(password, record),
+		autoShow: true,
+	    });
+	},
+
+	removeItem: async function(password, record) {
+	    let me = this;
+
+	    if (password !== null) {
+		password = '?password=' + encodeURIComponent(password);
+	    } else {
+		password = '';
+	    }
+
+	    try {
+		me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
+		await Proxmox.Async.api2({
+		    url: `/api2/extjs/access/tfa/${record.id}${password}`,
+		    method: 'DELETE',
+		});
+		me.reload();
+	    } catch (response) {
+		Ext.Msg.alert(gettext('Error'), response.result.message);
+	    } finally {
+		me.getView().unmask();
+            }
+	},
+    },
+
+    viewConfig: {
+	trackOver: false,
+    },
+
+    listeners: {
+	itemdblclick: 'editItem',
+    },
+
+    columns: [
+	{
+	    header: gettext('User'),
+	    width: 200,
+	    sortable: true,
+	    dataIndex: 'fullid',
+	    renderer: 'renderUser',
+	},
+	{
+	    header: gettext('Enabled'),
+	    width: 80,
+	    sortable: true,
+	    dataIndex: 'enable',
+	    renderer: 'renderEnabled',
+	},
+	{
+	    header: gettext('TFA Type'),
+	    width: 80,
+	    sortable: true,
+	    dataIndex: 'type',
+	},
+	{
+	    header: gettext('Created'),
+	    width: 150,
+	    sortable: true,
+	    dataIndex: 'created',
+	    renderer: t => !t ? 'N/A' : Proxmox.Utils.render_timestamp(t),
+	},
+	{
+	    header: gettext('Description'),
+	    width: 300,
+	    sortable: true,
+	    dataIndex: 'description',
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    cbind: {},
+	    menu: {
+		xtype: 'menu',
+		items: [
+		    {
+			text: gettext('TOTP'),
+			itemId: 'totp',
+			iconCls: 'fa fa-fw fa-clock-o',
+			handler: 'addTotp',
+		    },
+		    {
+			text: gettext('WebAuthn'),
+			itemId: 'webauthn',
+			iconCls: 'fa fa-fw fa-shield',
+			handler: 'addWebauthn',
+		    },
+		    {
+			text: gettext('Recovery Keys'),
+			itemId: 'recovery',
+			iconCls: 'fa fa-fw fa-file-text-o',
+			handler: 'addRecovery',
+		    },
+		    {
+			text: gettext('Yubico OTP'),
+			itemId: 'yubico',
+			iconCls: 'fa fa-fw fa-yahoo', // close enough
+			handler: 'addYubico',
+			cbind: {
+			    hidden: '{!yubicoEnabled}',
+			},
+		    },
+		],
+	    },
+	},
+	'-',
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    handler: 'editItem',
+	    enableFn: rec => !rec.id.endsWith("/recovery"),
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    disabled: true,
+	    text: gettext('Remove'),
+	    getRecordName: rec => rec.data.description,
+	    handler: 'onRemoveButton',
+	},
+    ],
+});
+Ext.define('Proxmox.panel.NotesView', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pmxNotesView',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext("Notes"),
+    bodyPadding: 10,
+    scrollable: true,
+    animCollapse: false,
+    collapseFirst: false,
+
+    maxLength: 64 * 1024,
+    enableTBar: false,
+    onlineHelp: 'markdown_basics',
+
+    tbar: {
+	itemId: 'tbar',
+	hidden: true,
+	items: [
+	    {
+		text: gettext('Edit'),
+		iconCls: 'fa fa-pencil-square-o',
+		handler: function() {
+		    let view = this.up('panel');
+		    view.run_editor();
+		},
+	    },
+	],
+    },
+
+    cbindData: function(initalConfig) {
+	let me = this;
+	let type = '';
+
+	if (me.node) {
+	    me.url = `/api2/extjs/nodes/${me.node}/config`;
+	} else if (me.pveSelNode?.data?.id === 'root') {
+	    me.url = '/api2/extjs/cluster/options';
+	    type = me.pveSelNode?.data?.type;
+	} else {
+	    const nodename = me.pveSelNode?.data?.node;
+	    type = me.pveSelNode?.data?.type;
+
+	    if (!nodename) {
+		throw "no node name specified";
+	    }
+
+	    if (!Ext.Array.contains(['node', 'qemu', 'lxc'], type)) {
+		throw 'invalid type specified';
+	    }
+
+	    const vmid = me.pveSelNode?.data?.vmid;
+
+	    if (!vmid && type !== 'node') {
+		throw "no VM ID specified";
+	    }
+
+	    me.url = `/api2/extjs/nodes/${nodename}/`;
+
+	    // add the type specific path if qemu/lxc and set the backend's maxLen
+	    if (type === 'qemu' || type === 'lxc') {
+		me.url += `${type}/${vmid}/`;
+		me.maxLength = 8 * 1024;
+	    }
+
+	    me.url += 'config';
+	}
+
+	me.pveType = type;
+
+	me.load();
+	return {};
+    },
+
+    run_editor: function() {
+	let me = this;
+	Ext.create('Proxmox.window.NotesEdit', {
+	    url: me.url,
+	    onlineHelp: me.onlineHelp,
+	    listeners: {
+		destroy: () => me.load(),
+	    },
+	    autoShow: true,
+	}).setMaxLength(me.maxLength);
+    },
+
+    setNotes: function(value = '') {
+	let me = this;
+
+	let mdHtml = Proxmox.Markdown.parse(value);
+	me.update(mdHtml);
+
+	if (me.collapsible && me.collapseMode === 'auto') {
+	    me.setCollapsed(!value);
+	}
+    },
+
+    load: function() {
+	let me = this;
+
+	Proxmox.Utils.API2Request({
+	    url: me.url,
+	    waitMsgTarget: me,
+	    failure: (response, opts) => {
+		me.update(gettext('Error') + " " + response.htmlStatus);
+		me.setCollapsed(false);
+	    },
+	    success: ({ result }) => me.setNotes(result.data.description),
+	});
+    },
+
+    listeners: {
+	render: function(c) {
+	    let me = this;
+	    let sp = Ext.state.Manager.getProvider();
+	    // to cover live changes to the browser setting
+	    me.mon(sp, 'statechange', function(provider, key, value) {
+		if (value === null || key !== 'edit-notes-on-double-click') {
+		    return;
+		}
+		if (value) {
+		    me.getEl().on('dblclick', me.run_editor, me);
+		} else {
+		    // there's only the me.run_editor listener, and removing just that did not work
+		    me.getEl().clearListeners();
+		}
+	    });
+	    // to cover initial setting value
+	    if (sp.get('edit-notes-on-double-click', false)) {
+		me.getEl().on('dblclick', me.run_editor, me);
+	    }
+	},
+	afterlayout: function() {
+	    let me = this;
+	    if (me.collapsible && !me.getCollapsed() && me.collapseMode === 'always') {
+		me.setCollapsed(true);
+		me.collapseMode = ''; // only once, on initial load!
+	    }
+	},
+    },
+
+    tools: [
+	{
+	    glyph: 'xf044@FontAwesome', // fa-pencil-square-o
+	    tooltip: gettext('Edit notes'),
+	    callback: view => view.run_editor(),
+	    style: {
+		paddingRight: '5px',
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+
+	// '' is for datacenter
+	if (me.enableTBar === true || me.pveType === 'node' || me.pveType === '') {
+	    me.down('#tbar').setVisible(true);
+	} else if (me.pveSelNode?.data?.template !== 1) {
+	    me.setCollapsible(true);
+	    me.collapseDirection = 'right';
+
+	    let sp = Ext.state.Manager.getProvider();
+	    me.collapseMode = sp.get('guest-notes-collapse', 'never');
+
+	    if (me.collapseMode === 'auto') {
+		me.setCollapsed(true);
+	    }
+	}
+    },
+});
+Ext.define('Proxmox.window.Edit', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.proxmoxWindowEdit',
+
+    // autoLoad trigger a load() after component creation
+    autoLoad: false,
+    // set extra options like params for the load request
+    autoLoadOptions: undefined,
+
+    // to submit extra params on load and submit, useful, e.g., if not all ID
+    // parameters are included in the URL
+    extraRequestParams: {},
+
+    resizable: false,
+
+    // use this to automatically generate a title like `Create: <subject>`
+    subject: undefined,
+
+    // set isCreate to true if you want a Create button (instead OK and RESET)
+    isCreate: false,
+
+    // set to true if you want an Add button (instead of Create)
+    isAdd: false,
+
+    // set to true if you want a Remove button (instead of Create)
+    isRemove: false,
+
+    // set to false, if you don't want the reset button present
+    showReset: true,
+
+    // custom submitText
+    submitText: undefined,
+
+    // custom options for the submit api call
+    submitOptions: {},
+
+    backgroundDelay: 0,
+
+    // string or function, called as (url, values) - useful if the ID of the
+    // new object is part of the URL, or that URL differs from GET/PUT URL
+    submitUrl: Ext.identityFn,
+
+    // string or function, called as (url, initialConfig) - mostly for
+    // consistency with submitUrl existing. If both are set `url` gets optional
+    loadUrl: Ext.identityFn,
+
+    // needed for finding the reference to submitbutton
+    // because we do not have a controller
+    referenceHolder: true,
+    defaultButton: 'submitbutton',
+
+    // finds the first form field
+    defaultFocus: 'field:focusable[disabled=false][hidden=false]',
+
+    showProgress: false,
+
+    showTaskViewer: false,
+
+    // gets called if we have a progress bar or taskview and it detected that
+    // the task finished. function(success)
+    taskDone: Ext.emptyFn,
+
+    // gets called when the api call is finished, right at the beginning
+    // function(success, response, options)
+    apiCallDone: Ext.emptyFn,
+
+    // assign a reference from docs, to add a help button docked to the
+    // bottom of the window. If undefined we magically fall back to the
+    // onlineHelp of our first item, if set.
+    onlineHelp: undefined,
+
+    constructor: function(conf) {
+	let me = this;
+	// make copies in order to prevent subclasses from accidentally writing
+	// to objects that are shared with other edit window subclasses
+	me.extraRequestParams = Object.assign({}, me.extraRequestParams);
+	me.submitOptions = Object.assign({}, me.submitOptions);
+	me.callParent(arguments);
+    },
+
+    isValid: function() {
+	let me = this;
+
+	let form = me.formPanel.getForm();
+	return form.isValid();
+    },
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+
+	let values = {};
+	Ext.apply(values, me.extraRequestParams);
+
+	let form = me.formPanel.getForm();
+
+	form.getFields().each(function(field) {
+	    if (!field.up('inputpanel') && (!dirtyOnly || field.isDirty())) {
+		Proxmox.Utils.assemble_field_data(values, field.getSubmitData());
+	    }
+	});
+
+	Ext.Array.each(me.query('inputpanel'), function(panel) {
+	    Proxmox.Utils.assemble_field_data(values, panel.getValues(dirtyOnly));
+	});
+
+        return values;
+    },
+
+    setValues: function(values) {
+	let me = this;
+
+	let form = me.formPanel.getForm();
+	let formfields = form.getFields();
+
+	Ext.iterate(values, function(id, val) {
+	    let fields = formfields.filterBy((f) =>
+	        (f.id === id || f.name === id || f.dataIndex === id) && !f.up('inputpanel'),
+	    );
+	    fields.each((field) => {
+		field.setValue(val);
+		if (form.trackResetOnLoad) {
+		    field.resetOriginalValue();
+		}
+	    });
+	});
+
+	Ext.Array.each(me.query('inputpanel'), function(panel) {
+	    panel.setValues(values);
+	});
+    },
+
+    setSubmitText: function(text) {
+	this.lookup('submitbutton').setText(text);
+    },
+
+    submit: function() {
+	let me = this;
+
+	let form = me.formPanel.getForm();
+
+	let values = me.getValues();
+	Ext.Object.each(values, function(name, val) {
+	    if (Object.prototype.hasOwnProperty.call(values, name)) {
+		if (Ext.isArray(val) && !val.length) {
+		    values[name] = '';
+		}
+	    }
+	});
+
+	if (me.digest) {
+	    values.digest = me.digest;
+	}
+
+	if (me.backgroundDelay) {
+	    values.background_delay = me.backgroundDelay;
+	}
+
+	let url = Ext.isFunction(me.submitUrl)
+	    ? me.submitUrl(me.url, values)
+	    : me.submitUrl || me.url;
+	if (me.method === 'DELETE') {
+	    url = url + "?" + Ext.Object.toQueryString(values);
+	    values = undefined;
+	}
+
+	let requestOptions = Ext.apply({
+	    url: url,
+	    waitMsgTarget: me,
+	    method: me.method || (me.backgroundDelay ? 'POST' : 'PUT'),
+	    params: values,
+	    failure: function(response, options) {
+		me.apiCallDone(false, response, options);
+
+		if (response.result && response.result.errors) {
+		    form.markInvalid(response.result.errors);
+		}
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response, options) {
+		let hasProgressBar =
+		    (me.backgroundDelay || me.showProgress || me.showTaskViewer) &&
+		    response.result.data;
+
+		me.apiCallDone(true, response, options);
+
+		if (hasProgressBar) {
+		    // only hide to allow delaying our close event until task is done
+		    me.hide();
+
+		    let upid = response.result.data;
+		    let viewerClass = me.showTaskViewer ? 'Viewer' : 'Progress';
+		    Ext.create('Proxmox.window.Task' + viewerClass, {
+			autoShow: true,
+			upid: upid,
+			taskDone: me.taskDone,
+			listeners: {
+			    destroy: function() {
+				me.close();
+			    },
+			},
+		    });
+		} else {
+		    me.close();
+		}
+	    },
+	}, me.submitOptions ?? {});
+	Proxmox.Utils.API2Request(requestOptions);
+    },
+
+    load: function(options) {
+	let me = this;
+
+	let form = me.formPanel.getForm();
+
+	options = options || {};
+
+	let newopts = Ext.apply({
+	    waitMsgTarget: me,
+	}, options);
+
+	if (Object.keys(me.extraRequestParams).length > 0) {
+	    let params = newopts.params || {};
+	    Ext.applyIf(params, me.extraRequestParams);
+	    newopts.params = params;
+	}
+
+	let url = Ext.isFunction(me.loadUrl)
+	    ? me.loadUrl(me.url, me.initialConfig)
+	    : me.loadUrl || me.url;
+
+	let createWrapper = function(successFn) {
+	    Ext.apply(newopts, {
+		url: url,
+		method: 'GET',
+		success: function(response, opts) {
+		    form.clearInvalid();
+		    me.digest = response.result?.digest || response.result?.data?.digest;
+		    if (successFn) {
+			successFn(response, opts);
+		    } else {
+			me.setValues(response.result.data);
+		    }
+		    // hack: fix ExtJS bug
+		    Ext.Array.each(me.query('radiofield'), f => f.resetOriginalValue());
+		},
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus, function() {
+			me.close();
+		    });
+		},
+	    });
+	};
+
+	createWrapper(options.success);
+
+	Proxmox.Utils.API2Request(newopts);
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.url && (
+		!me.submitUrl || !me.loadUrl || me.submitUrl === Ext.identityFn ||
+		me.loadUrl === Ext.identityFn
+	    )
+	) {
+	    throw "neither 'url' nor both, submitUrl and loadUrl specified";
+	}
+	if (me.create) {
+	    throw "deprecated parameter, use isCreate";
+	}
+
+	let items = Ext.isArray(me.items) ? me.items : [me.items];
+
+	me.items = undefined;
+
+	me.formPanel = Ext.create('Ext.form.Panel', {
+	    url: me.url, // FIXME: not in 'form' class, safe to remove??
+	    method: me.method || 'PUT',
+	    trackResetOnLoad: true,
+	    bodyPadding: me.bodyPadding !== undefined ? me.bodyPadding : 10,
+	    border: false,
+	    defaults: Ext.apply({}, me.defaults, {
+		border: false,
+	    }),
+	    fieldDefaults: Ext.apply({}, me.fieldDefaults, {
+		labelWidth: 100,
+		anchor: '100%',
+            }),
+	    items: items,
+	});
+
+	let inputPanel = me.formPanel.down('inputpanel');
+
+	let form = me.formPanel.getForm();
+
+	let submitText;
+	if (me.isCreate) {
+	    if (me.submitText) {
+		submitText = me.submitText;
+	    } else if (me.isAdd) {
+		submitText = gettext('Add');
+	    } else if (me.isRemove) {
+		submitText = gettext('Remove');
+	    } else {
+		submitText = gettext('Create');
+	    }
+	} else {
+	    submitText = me.submitText || gettext('OK');
+	}
+
+	let submitBtn = Ext.create('Ext.Button', {
+	    reference: 'submitbutton',
+	    text: submitText,
+	    disabled: !me.isCreate,
+	    handler: function() {
+		me.submit();
+	    },
+	});
+
+	let resetTool = Ext.create('Ext.panel.Tool', {
+	    glyph: 'xf0e2@FontAwesome', // fa-undo
+	    tooltip: gettext('Reset form data'),
+	    callback: () => form.reset(),
+	    style: {
+		paddingRight: '2px', // just slightly more room to breathe
+	    },
+	    disabled: true,
+	});
+
+	let set_button_status = function() {
+	    let valid = form.isValid();
+	    let dirty = form.isDirty();
+	    submitBtn.setDisabled(!valid || !(dirty || me.isCreate));
+	    resetTool.setDisabled(!dirty);
+	};
+
+	form.on('dirtychange', set_button_status);
+	form.on('validitychange', set_button_status);
+
+	let colwidth = 300;
+	if (me.fieldDefaults && me.fieldDefaults.labelWidth) {
+	    colwidth += me.fieldDefaults.labelWidth - 100;
+	}
+
+	let twoColumn = inputPanel && (inputPanel.column1 || inputPanel.column2);
+
+	if (me.subject && !me.title) {
+	    me.title = Proxmox.Utils.dialog_title(me.subject, me.isCreate, me.isAdd);
+	}
+
+	me.buttons = [submitBtn];
+
+	if (!me.isCreate && me.showReset) {
+	    me.tools = [resetTool];
+	}
+
+	if (inputPanel && inputPanel.hasAdvanced) {
+	    let sp = Ext.state.Manager.getProvider();
+	    let advchecked = sp.get('proxmox-advanced-cb');
+	    inputPanel.setAdvancedVisible(advchecked);
+	    me.buttons.unshift({
+		xtype: 'proxmoxcheckbox',
+		itemId: 'advancedcb',
+		boxLabelAlign: 'before',
+		boxLabel: gettext('Advanced'),
+		stateId: 'proxmox-advanced-cb',
+		value: advchecked,
+		listeners: {
+		    change: function(cb, val) {
+			inputPanel.setAdvancedVisible(val);
+			sp.set('proxmox-advanced-cb', val);
+		    },
+		},
+	    });
+	}
+
+	let onlineHelp = me.onlineHelp;
+	if (!onlineHelp && inputPanel && inputPanel.onlineHelp) {
+	    onlineHelp = inputPanel.onlineHelp;
+	}
+
+	if (onlineHelp) {
+	    let helpButton = Ext.create('Proxmox.button.Help');
+	    me.buttons.unshift(helpButton, '->');
+	    Ext.GlobalEvents.fireEvent('proxmoxShowHelp', onlineHelp);
+	}
+
+	Ext.applyIf(me, {
+	    modal: true,
+	    width: twoColumn ? colwidth*2 : colwidth,
+	    border: false,
+	    items: [me.formPanel],
+	});
+
+	me.callParent();
+
+
+	if (inputPanel?.hasAdvanced) {
+	    let advancedItems = inputPanel.down('#advancedContainer').query('field');
+	    advancedItems.forEach(function(field) {
+		me.mon(field, 'validitychange', (f, valid) => {
+		    if (!valid) {
+			f.up('inputpanel').setAdvancedVisible(true);
+		    }
+		});
+	    });
+	}
+
+	// always mark invalid fields
+	me.on('afterlayout', function() {
+	    // on touch devices, the isValid function
+	    // triggers a layout, which triggers an isValid
+	    // and so on
+	    // to prevent this we disable the layouting here
+	    // and enable it afterwards
+	    me.suspendLayout = true;
+	    me.isValid();
+	    me.suspendLayout = false;
+	});
+
+	if (me.autoLoad) {
+	    me.load(me.autoLoadOptions);
+	}
+    },
+});
+Ext.define('Proxmox.window.PasswordEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'proxmoxWindowPasswordEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    subject: gettext('Password'),
+
+    url: '/api2/extjs/access/password',
+
+    width: 380,
+    fieldDefaults: {
+	labelWidth: 150,
+    },
+
+    // allow products to opt-in as their API gains support for this.
+    confirmCurrentPassword: false,
+
+    items: [
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Your Current Password'),
+	    reference: 'confirmation-password',
+	    name: 'confirmation-password',
+	    allowBlank: false,
+	    vtype: 'password',
+	    cbind: {
+		hidden: '{!confirmCurrentPassword}',
+		disabled: '{!confirmCurrentPassword}',
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('New Password'),
+	    minLength: 5,
+	    allowBlank: false,
+	    name: 'password',
+	    listeners: {
+		change: (field) => field.next().validate(),
+		blur: (field) => field.next().validate(),
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Confirm New Password'),
+	    name: 'verifypassword',
+	    allowBlank: false,
+	    vtype: 'password',
+	    initialPassField: 'password',
+	    submitValue: false,
+	},
+	{
+	    xtype: 'hiddenfield',
+	    name: 'userid',
+	    cbind: {
+		value: '{userid}',
+	    },
+	},
+    ],
+});
+// Pop-up a message window where the user has to manually enter the resource ID to enable the
+// destroy confirmation button to ensure that they got the correct resource selected for.
+Ext.define('Proxmox.window.SafeDestroy', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.proxmoxSafeDestroy',
+
+    title: gettext('Confirm'),
+    modal: true,
+    buttonAlign: 'center',
+    bodyPadding: 10,
+    width: 450,
+    layout: { type: 'hbox' },
+    defaultFocus: 'confirmField',
+    showProgress: false,
+
+    additionalItems: [],
+
+    // gets called if we have a progress bar or taskview and it detected that
+    // the task finished. function(success)
+    taskDone: Ext.emptyFn,
+
+    // gets called when the api call is finished, right at the beginning
+    // function(success, response, options)
+    apiCallDone: Ext.emptyFn,
+
+    config: {
+	item: {
+	    id: undefined,
+	},
+	url: undefined,
+	note: undefined,
+	taskName: undefined,
+	params: {},
+    },
+
+    getParams: function() {
+	let me = this;
+
+	if (Ext.Object.isEmpty(me.params)) {
+	    return '';
+	}
+	return '?' + Ext.Object.toQueryString(me.params);
+    },
+
+    controller: {
+
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'field[name=confirm]': {
+		change: function(f, value) {
+		    const view = this.getView();
+		    const removeButton = this.lookupReference('removeButton');
+		    if (value === view.getItem().id.toString()) {
+			removeButton.enable();
+		    } else {
+			removeButton.disable();
+		    }
+		},
+		specialkey: function(field, event) {
+		    const removeButton = this.lookupReference('removeButton');
+		    if (!removeButton.isDisabled() && event.getKey() === event.ENTER) {
+			removeButton.fireEvent('click', removeButton, event);
+		    }
+		},
+	    },
+           'button[reference=removeButton]': {
+		click: function() {
+		    const view = this.getView();
+		    Proxmox.Utils.API2Request({
+			url: view.getUrl() + view.getParams(),
+			method: 'DELETE',
+			waitMsgTarget: view,
+			failure: function(response, opts) {
+			    view.apiCallDone(false, response, opts);
+			    view.close();
+			    Ext.Msg.alert('Error', response.htmlStatus);
+			},
+			success: function(response, options) {
+			    const hasProgressBar = !!(view.showProgress &&
+				response.result.data);
+
+			    view.apiCallDone(true, response, options);
+
+			    if (hasProgressBar) {
+				// stay around so we can trigger our close events
+				// when background action is completed
+				view.hide();
+
+				const upid = response.result.data;
+				const win = Ext.create('Proxmox.window.TaskProgress', {
+				    upid: upid,
+				    taskDone: view.taskDone,
+				    listeners: {
+					destroy: function() {
+					    view.close();
+					},
+				    },
+				});
+				win.show();
+			    } else {
+				view.close();
+			    }
+			},
+		    });
+		},
+            },
+	},
+    },
+
+    buttons: [
+	{
+	    reference: 'removeButton',
+	    text: gettext('Remove'),
+	    disabled: true,
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	me.items = [
+	    {
+		xtype: 'component',
+		cls: [
+		    Ext.baseCSSPrefix + 'message-box-icon',
+		    Ext.baseCSSPrefix + 'message-box-warning',
+		    Ext.baseCSSPrefix + 'dlg-icon',
+		],
+	    },
+	    {
+		xtype: 'container',
+		flex: 1,
+		layout: {
+		    type: 'vbox',
+		    align: 'stretch',
+		},
+		items: [
+		    {
+			xtype: 'component',
+			reference: 'messageCmp',
+		    },
+		    {
+			itemId: 'confirmField',
+			reference: 'confirmField',
+			xtype: 'textfield',
+			name: 'confirm',
+			labelWidth: 300,
+			hideTrigger: true,
+			allowBlank: false,
+		    },
+		]
+		.concat(me.additionalItems)
+		.concat([
+		    {
+			xtype: 'container',
+			reference: 'noteContainer',
+			flex: 1,
+			hidden: true,
+			layout: {
+			    type: 'vbox',
+			},
+			items: [
+			    {
+				xtype: 'component',
+				reference: 'noteCmp',
+				userCls: 'pmx-hint',
+			    },
+			],
+		    },
+		]),
+	    },
+	];
+
+	me.callParent();
+
+	const itemId = me.getItem().id;
+	if (!Ext.isDefined(itemId)) {
+	    throw "no ID specified";
+	}
+
+	if (Ext.isDefined(me.getNote())) {
+	    me.lookupReference('noteCmp').setHtml(`<span title="${me.getNote()}">${me.getNote()}</span>`);
+	    const noteContainer = me.lookupReference('noteContainer');
+	    noteContainer.setHidden(false);
+	    noteContainer.setDisabled(false);
+	}
+
+	let taskName = me.getTaskName();
+	if (Ext.isDefined(taskName)) {
+	    me.lookupReference('messageCmp').setHtml(
+		Proxmox.Utils.format_task_description(taskName, itemId),
+	    );
+	} else {
+	    throw "no task name specified";
+	}
+
+	me.lookupReference('confirmField')
+	    .setFieldLabel(`${gettext('Please enter the ID to confirm')} (${itemId})`);
+    },
+});
+Ext.define('Proxmox.window.PackageVersions', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.proxmoxPackageVersions',
+
+    title: gettext('Package versions'),
+    width: 600,
+    height: 650,
+    layout: 'fit',
+    modal: true,
+
+    url: `/nodes/localhost/apt/versions`,
+
+    viewModel: {
+	parent: null,
+	data: {
+	    packageList: '',
+	},
+    },
+    buttons: [
+	{
+	    xtype: 'button',
+	    text: gettext('Copy'),
+	    iconCls: 'fa fa-clipboard',
+	    handler: function(button) {
+		window.getSelection().selectAllChildren(
+		    document.getElementById('pkgversions'),
+		);
+		document.execCommand("copy");
+	    },
+	},
+	{
+	    text: gettext('Ok'),
+	    handler: function() {
+		this.up('window').close();
+	    },
+	},
+    ],
+    items: [
+	{
+	    xtype: 'component',
+	    autoScroll: true,
+	    id: 'pkgversions',
+	    padding: 5,
+	    bind: {
+		html: '{packageList}',
+	    },
+	    style: {
+		'white-space': 'pre',
+		'font-family': 'monospace',
+	    },
+	},
+    ],
+    listeners: {
+	afterrender: function() {
+	    this.loadPackageVersions(); // wait for after render so that we can show a load mask
+	},
+    },
+
+    loadPackageVersions: async function() {
+	let me = this;
+
+	let { result } = await Proxmox.Async.api2({
+	    waitMsgTarget: me.down('component[id="pkgversions"]'),
+	    method: 'GET',
+	    url: me.url,
+	}).catch(Proxmox.Utils.alertResponseFailure); // FIXME: mask window instead?
+
+	let text = '';
+	for (const pkg of result.data) {
+	    let version = "not correctly installed";
+	    if (pkg.OldVersion && pkg.OldVersion !== 'unknown') {
+		version = pkg.OldVersion;
+	    } else if (pkg.CurrentState === 'ConfigFiles') {
+		version = 'residual config';
+	    }
+	    const name = pkg.Package;
+	    if (pkg.ExtraInfo) {
+		text += `${name}: ${version} (${pkg.ExtraInfo})\n`;
+	    } else {
+		text += `${name}: ${version}\n`;
+	    }
+	}
+	me.getViewModel().set('packageList', Ext.htmlEncode(text));
+    },
+});
+Ext.define('Proxmox.window.TaskProgress', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.proxmoxTaskProgress',
+
+    taskDone: Ext.emptyFn,
+
+    width: 300,
+    layout: 'auto',
+    modal: true,
+    bodyPadding: 5,
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.upid) {
+	    throw "no task specified";
+	}
+
+	let task = Proxmox.Utils.parse_task_upid(me.upid);
+
+	let statstore = Ext.create('Proxmox.data.ObjectStore', {
+	    url: `/api2/json/nodes/${task.node}/tasks/${encodeURIComponent(me.upid)}/status`,
+	    interval: 1000,
+	    rows: {
+		status: { defaultValue: 'unknown' },
+		exitstatus: { defaultValue: 'unknown' },
+	    },
+	});
+
+	me.on('destroy', statstore.stopUpdate);
+
+	let getObjectValue = function(key, defaultValue) {
+	    let rec = statstore.getById(key);
+	    if (rec) {
+		return rec.data.value;
+	    }
+	    return defaultValue;
+	};
+
+	let pbar = Ext.create('Ext.ProgressBar');
+
+	me.mon(statstore, 'load', function() {
+	    let status = getObjectValue('status');
+	    if (status === 'stopped') {
+		let exitstatus = getObjectValue('exitstatus');
+		if (exitstatus === 'OK') {
+		    pbar.reset();
+		    pbar.updateText("Done!");
+		    Ext.Function.defer(me.close, 1000, me);
+		} else {
+		    me.close();
+		    Ext.Msg.alert('Task failed', exitstatus);
+		}
+		me.taskDone(exitstatus === 'OK');
+	    }
+	});
+
+	let descr = Proxmox.Utils.format_task_description(task.type, task.id);
+
+	Ext.apply(me, {
+	    title: gettext('Task') + ': ' + descr,
+	    items: pbar,
+	    buttons: [
+		{
+		    text: gettext('Details'),
+		    handler: function() {
+			Ext.create('Proxmox.window.TaskViewer', {
+			    autoShow: true,
+			    taskDone: me.taskDone,
+			    upid: me.upid,
+			});
+			me.close();
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	statstore.startUpdate();
+
+	pbar.wait({ text: gettext('running...') });
+    },
+});
+
+Ext.define('Proxmox.window.TaskViewer', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.proxmoxTaskViewer',
+
+    extraTitle: '', // string to prepend after the generic task title
+
+    taskDone: Ext.emptyFn,
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.upid) {
+	    throw "no task specified";
+	}
+
+	let task = Proxmox.Utils.parse_task_upid(me.upid);
+
+	let statgrid;
+
+	let rows = {
+	    status: {
+		header: gettext('Status'),
+		defaultValue: 'unknown',
+		renderer: function(value) {
+		    if (value !== 'stopped') {
+			return value;
+		    }
+		    let es = statgrid.getObjectValue('exitstatus');
+		    if (es) {
+			return value + ': ' + es;
+		    }
+		    return 'unknown';
+		},
+	    },
+	    exitstatus: {
+		visible: false,
+	    },
+	    type: {
+		header: gettext('Task type'),
+		required: true,
+	    },
+	    user: {
+		header: gettext('User name'),
+		renderer: function(value) {
+		    let user = value;
+		    let tokenid = statgrid.getObjectValue('tokenid');
+		    if (tokenid) {
+			user += `!${tokenid} (API Token)`;
+		    }
+		    return Ext.String.htmlEncode(user);
+		},
+		required: true,
+	    },
+	    tokenid: {
+		header: gettext('API Token'),
+		renderer: Ext.String.htmlEncode,
+		visible: false,
+	    },
+	    node: {
+		header: gettext('Node'),
+		required: true,
+	    },
+	    pid: {
+		header: gettext('Process ID'),
+		required: true,
+	    },
+	    task_id: {
+		header: gettext('Task ID'),
+	    },
+	    starttime: {
+		header: gettext('Start Time'),
+		required: true,
+		renderer: Proxmox.Utils.render_timestamp,
+	    },
+	    upid: {
+		header: gettext('Unique task ID'),
+		renderer: Ext.String.htmlEncode,
+	    },
+	};
+
+	if (me.endtime) {
+	    if (typeof me.endtime === 'object') {
+		// convert to epoch
+		me.endtime = parseInt(me.endtime.getTime()/1000, 10);
+	    }
+	    rows.endtime = {
+		header: gettext('End Time'),
+		required: true,
+		renderer: function() {
+		    return Proxmox.Utils.render_timestamp(me.endtime);
+		},
+	    };
+	}
+
+	rows.duration = {
+	    header: gettext('Duration'),
+	    required: true,
+	    renderer: function() {
+		let starttime = statgrid.getObjectValue('starttime');
+		let endtime = me.endtime || Date.now()/1000;
+		let duration = endtime - starttime;
+		return Proxmox.Utils.format_duration_human(duration);
+	    },
+	};
+
+	let statstore = Ext.create('Proxmox.data.ObjectStore', {
+            url: `/api2/json/nodes/${task.node}/tasks/${encodeURIComponent(me.upid)}/status`,
+	    interval: 1000,
+	    rows: rows,
+	});
+
+	me.on('destroy', statstore.stopUpdate);
+
+	let stop_task = function() {
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${task.node}/tasks/${encodeURIComponent(me.upid)}`,
+		waitMsgTarget: me,
+		method: 'DELETE',
+		failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    });
+	};
+
+	let stop_btn1 = new Ext.Button({
+	    text: gettext('Stop'),
+	    disabled: true,
+	    handler: stop_task,
+	});
+
+	let stop_btn2 = new Ext.Button({
+	    text: gettext('Stop'),
+	    disabled: true,
+	    handler: stop_task,
+	});
+
+	statgrid = Ext.create('Proxmox.grid.ObjectGrid', {
+	    title: gettext('Status'),
+	    layout: 'fit',
+	    tbar: [stop_btn1],
+	    rstore: statstore,
+	    rows: rows,
+	    border: false,
+	});
+
+	let downloadBtn = new Ext.Button({
+	    text: gettext('Download'),
+	    iconCls: 'fa fa-download',
+	    handler: () => Proxmox.Utils.downloadAsFile(
+	        `/api2/json/nodes/${task.node}/tasks/${encodeURIComponent(me.upid)}/log?download=1`),
+	});
+
+
+	let logView = Ext.create('Proxmox.panel.LogView', {
+	    title: gettext('Output'),
+	    tbar: [stop_btn2, '->', downloadBtn],
+	    border: false,
+	    url: `/api2/extjs/nodes/${task.node}/tasks/${encodeURIComponent(me.upid)}/log`,
+	});
+
+	me.mon(statstore, 'load', function() {
+	    let status = statgrid.getObjectValue('status');
+
+	    if (status === 'stopped') {
+		logView.scrollToEnd = false;
+		logView.requestUpdate();
+		statstore.stopUpdate();
+		me.taskDone(statgrid.getObjectValue('exitstatus') === 'OK');
+	    }
+
+	    stop_btn1.setDisabled(status !== 'running');
+	    stop_btn2.setDisabled(status !== 'running');
+	    downloadBtn.setDisabled(status === 'running');
+	});
+
+	statstore.startUpdate();
+
+	Ext.apply(me, {
+	    title: "Task viewer: " + task.desc + me.extraTitle,
+	    width: 800,
+	    height: 500,
+	    layout: 'fit',
+	    modal: true,
+	    items: [{
+		xtype: 'tabpanel',
+		region: 'center',
+		items: [logView, statgrid],
+	    }],
+        });
+
+	me.callParent();
+
+	logView.fireEvent('show', logView);
+    },
+});
+
+Ext.define('Proxmox.window.LanguageEditWindow', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pmxLanguageEditWindow',
+
+    viewModel: {
+	parent: null,
+	data: {
+	    language: '__default__',
+	},
+    },
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	init: function(view) {
+	    let language = Ext.util.Cookies.get(view.cookieName) || '__default__';
+	    if (language === 'kr') {
+		// fix-up wrongly used Korean code before FIXME: remove with trixie releases
+		language = 'ko';
+		let expire = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
+		Ext.util.Cookies.set(view.cookieName, language, expire);
+	    }
+	    this.getViewModel().set('language', language);
+	},
+	applyLanguage: function(button) {
+	    let view = this.getView();
+	    let vm = this.getViewModel();
+
+	    let expire = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
+	    Ext.util.Cookies.set(view.cookieName, vm.get('language'), expire);
+	    view.mask(gettext('Please wait...'), 'x-mask-loading');
+	    window.location.reload();
+	},
+    },
+
+    cookieName: 'PVELangCookie',
+
+    title: gettext('Language'),
+    modal: true,
+    bodyPadding: 10,
+    resizable: false,
+    items: [
+	{
+	    xtype: 'proxmoxLanguageSelector',
+	    fieldLabel: gettext('Language'),
+	    labelWidth: 75,
+	    bind: {
+		value: '{language}',
+	    },
+	},
+    ],
+    buttons: [
+	{
+	    text: gettext('Apply'),
+	    handler: 'applyLanguage',
+	},
+    ],
+});
+Ext.define('Proxmox.window.DiskSmart', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pmxSmartWindow',
+
+    modal: true,
+
+    layout: {
+	type: 'fit',
+    },
+    width: 800,
+    height: 500,
+    minWidth: 400,
+    minHeight: 300,
+    bodyPadding: 5,
+
+    items: [
+	{
+	    xtype: 'gridpanel',
+	    layout: {
+		type: 'fit',
+	    },
+	    emptyText: gettext('No S.M.A.R.T. Values'),
+	    scrollable: true,
+	    flex: 1,
+	    itemId: 'smartGrid',
+	    reserveScrollbar: true,
+	    columns: [
+		{
+		    text: 'ID',
+		    dataIndex: 'id',
+		    width: 50,
+		},
+		{
+		    text: gettext('Attribute'),
+		    dataIndex: 'name',
+		    flex: 1,
+		    renderer: Ext.String.htmlEncode,
+		},
+		{
+		    text: gettext('Value'),
+		    dataIndex: 'real-value',
+		    renderer: Ext.String.htmlEncode,
+		},
+		{
+		    text: gettext('Normalized'),
+		    dataIndex: 'real-normalized',
+		    width: 60,
+		},
+		{
+		    text: gettext('Threshold'),
+		    dataIndex: 'threshold',
+		    width: 60,
+		},
+		{
+		    text: gettext('Worst'),
+		    dataIndex: 'worst',
+		    width: 60,
+		},
+		{
+		    text: gettext('Flags'),
+		    dataIndex: 'flags',
+		},
+		{
+		    text: gettext('Failing'),
+		    dataIndex: 'fail',
+		    renderer: Ext.String.htmlEncode,
+		},
+	    ],
+	},
+	{
+	    xtype: 'component',
+	    itemId: 'smartPlainText',
+	    hidden: true,
+	    autoScroll: true,
+	    padding: 5,
+	    style: {
+		'white-space': 'pre',
+		'font-family': 'monospace',
+	    },
+	},
+    ],
+
+    buttons: [
+	{
+	    text: gettext('Reload'),
+	    name: 'reload',
+	    handler: function() {
+		var me = this;
+		me.up('window').store.reload();
+	    },
+	},
+	{
+	    text: gettext('Close'),
+	    name: 'close',
+	    handler: function() {
+		var me = this;
+		me.up('window').close();
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.baseurl) {
+	    throw "no baseurl specified";
+	}
+	if (!me.dev) {
+	    throw "no device specified";
+	}
+
+	me.title = `${gettext('S.M.A.R.T. Values')} (${me.dev})`;
+
+	me.store = Ext.create('Ext.data.Store', {
+	    model: 'pmx-disk-smart',
+	    proxy: {
+                type: 'proxmox',
+		url: `${me.baseurl}/smart?disk=${me.dev}`,
+	    },
+	});
+
+	me.callParent();
+
+	let grid = me.down('#smartGrid'), plainText = me.down('#smartPlainText');
+
+	Proxmox.Utils.monStoreErrors(grid, me.store);
+	me.mon(me.store, 'load', function(_store, records, success) {
+	    if (!success || records.length <= 0) {
+		return; // FIXME: clear displayed info?
+	    }
+	    let isPlainText = records[0].data.type === 'text';
+	    if (isPlainText) {
+		plainText.setHtml(Ext.String.htmlEncode(records[0].data.text));
+	    } else {
+		grid.setStore(records[0].attributes());
+	    }
+	    grid.setVisible(!isPlainText);
+	    plainText.setVisible(isPlainText);
+	});
+
+	me.store.load();
+    },
+}, function() {
+    Ext.define('pmx-disk-smart', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    { name: 'health' },
+	    { name: 'type' },
+	    { name: 'text' },
+	],
+	hasMany: { model: 'pmx-smart-attribute', name: 'attributes' },
+    });
+    Ext.define('pmx-smart-attribute', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    { name: 'id', type: 'number' }, 'name', 'value', 'worst', 'threshold', 'flags', 'fail',
+	    'raw', 'normalized',
+	    {
+		name: 'real-value',
+		// FIXME remove with next major release (PBS 3.0)
+		calculate: data => data.raw ?? data.value,
+	    },
+	    {
+		name: 'real-normalized',
+		// FIXME remove with next major release (PBS 3.0)
+		calculate: data => data.normalized ?? data.value,
+	    },
+	],
+	idProperty: 'name',
+    });
+});
+Ext.define('Proxmox.window.ZFSDetail', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pmxZFSDetail',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	me.url = `/nodes/${me.nodename}/disks/zfs/${encodeURIComponent(me.zpool)}`;
+	return {
+	    zpoolUri: `/api2/json/${me.url}`,
+	    title: `${gettext('Status')}: ${me.zpool}`,
+	};
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    me.lookup('status').reload();
+
+	    Proxmox.Utils.API2Request({
+		url: `/api2/extjs/${view.url}`,
+		waitMsgTarget: view,
+		method: 'GET',
+		failure: function(response, opts) {
+		    Proxmox.Utils.setErrorMask(view, response.htmlStatus);
+		},
+		success: function(response, opts) {
+		    let devices = me.lookup('devices');
+		    devices.getSelectionModel().deselectAll();
+		    devices.setRootNode(response.result.data);
+		    devices.expandAll();
+		},
+	    });
+	},
+
+	init: function(view) {
+	    let me = this;
+	    Proxmox.Utils.monStoreErrors(me, me.lookup('status').getStore().rstore);
+	    me.reload();
+	},
+    },
+
+    modal: true,
+    width: 800,
+    height: 600,
+    resizable: true,
+    cbind: {
+	title: '{title}',
+    },
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+    defaults: {
+	layout: 'fit',
+	border: false,
+    },
+
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: 'reload',
+	},
+    ],
+
+    items: [
+	{
+	    xtype: 'proxmoxObjectGrid',
+	    reference: 'status',
+	    flex: 0,
+	    cbind: {
+		url: '{zpoolUri}',
+		nodename: '{nodename}',
+	    },
+	    rows: {
+		state: {
+		    header: gettext('Health'),
+		    renderer: Proxmox.Utils.render_zfs_health,
+		},
+		scan: {
+		    header: gettext('Scan'),
+		},
+		status: {
+		    header: gettext('Status'),
+		},
+		action: {
+		    header: gettext('Action'),
+		},
+		errors: {
+		    header: gettext('Errors'),
+		},
+	    },
+	},
+	{
+	    xtype: 'treepanel',
+	    reference: 'devices',
+	    title: gettext('Devices'),
+	    stateful: true,
+	    stateId: 'grid-node-zfsstatus',
+	    // the root is the pool itself and the information is shown by the grid
+	    rootVisible: false,
+	    fields: ['name', 'status',
+		{
+		    type: 'string',
+		    name: 'iconCls',
+		    calculate: function(data) {
+			var txt = 'fa x-fa-tree fa-';
+			if (data.leaf) {
+			    return txt + 'hdd-o';
+			}
+			return undefined;
+		    },
+		},
+	    ],
+	    sorters: 'name',
+	    flex: 1,
+	    cbind: {
+		zpool: '{zpoolUri}',
+		nodename: '{nodename}',
+	    },
+	    columns: [
+		{
+		    xtype: 'treecolumn',
+		    text: gettext('Name'),
+		    dataIndex: 'name',
+		    flex: 1,
+		},
+		{
+		    text: gettext('Health'),
+		    renderer: Proxmox.Utils.render_zfs_health,
+		    dataIndex: 'state',
+		},
+		{
+		    text: 'READ',
+		    dataIndex: 'read',
+		},
+		{
+		    text: 'WRITE',
+		    dataIndex: 'write',
+		},
+		{
+		    text: 'CKSUM',
+		    dataIndex: 'cksum',
+		},
+		{
+		    text: gettext('Message'),
+		    dataIndex: 'msg',
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('Proxmox.window.CertificateViewer', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pmxCertViewer',
+
+    title: gettext('Certificate'),
+
+    fieldDefaults: {
+	labelWidth: 120,
+    },
+    width: 800,
+    resizable: true,
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Name'),
+	    name: 'filename',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Fingerprint'),
+	    name: 'fingerprint',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Issuer'),
+	    name: 'issuer',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Subject'),
+	    name: 'subject',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Public Key Type'),
+	    name: 'public-key-type',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Public Key Size'),
+	    name: 'public-key-bits',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Valid Since'),
+	    renderer: Proxmox.Utils.render_timestamp,
+	    name: 'notbefore',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Expires'),
+	    renderer: Proxmox.Utils.render_timestamp,
+	    name: 'notafter',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Subject Alternative Names'),
+	    name: 'san',
+	    renderer: Proxmox.Utils.render_san,
+	},
+	{
+	    xtype: 'textarea',
+	    editable: false,
+	    grow: true,
+	    growMax: 200,
+	    fieldLabel: gettext('Certificate'),
+	    name: 'pem',
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.cert) {
+	    throw "no cert given";
+	}
+
+	if (!me.url) {
+	    throw "no url given";
+	}
+
+	me.callParent();
+
+	// hide OK/Reset button, because we just want to show data
+	me.down('toolbar[dock=bottom]').setVisible(false);
+
+	me.load({
+	    success: function(response) {
+		if (Ext.isArray(response.result.data)) {
+		    Ext.Array.each(response.result.data, function(item) {
+			if (item.filename === me.cert) {
+			    me.setValues(item);
+			    return false;
+			}
+			return true;
+		    });
+		}
+	    },
+	});
+    },
+});
+
+Ext.define('Proxmox.window.CertificateUpload', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pmxCertUpload',
+
+    title: gettext('Upload Custom Certificate'),
+    resizable: false,
+    isCreate: true,
+    submitText: gettext('Upload'),
+    method: 'POST',
+    width: 600,
+
+    // whether the UI needs a reload after this
+    reloadUi: undefined,
+
+    apiCallDone: function(success, response, options) {
+	let me = this;
+
+	if (!success || !me.reloadUi) {
+	    return;
+	}
+
+	Ext.getBody().mask(
+	    gettext('API server will be restarted to use new certificates, please reload web-interface!'),
+	    ['pve-static-mask'],
+	);
+	// try to reload after 10 seconds automatically
+	Ext.defer(() => window.location.reload(true), 10000);
+    },
+
+    items: [
+	{
+	    fieldLabel: gettext('Private Key (Optional)'),
+	    labelAlign: 'top',
+	    emptyText: gettext('No change'),
+	    name: 'key',
+	    xtype: 'textarea',
+	},
+	{
+	    xtype: 'filebutton',
+	    text: gettext('From File'),
+	    listeners: {
+		change: function(btn, e, value) {
+		    let form = this.up('form');
+		    e = e.event;
+		    Ext.Array.each(e.target.files, function(file) {
+			Proxmox.Utils.loadTextFromFile(
+			    file,
+			    function(res) {
+				form.down('field[name=key]').setValue(res);
+			    },
+			    16384,
+			);
+		    });
+		    btn.reset();
+		},
+	    },
+	},
+	{
+	    xtype: 'box',
+	    autoEl: 'hr',
+	},
+	{
+	    fieldLabel: gettext('Certificate Chain'),
+	    labelAlign: 'top',
+	    allowBlank: false,
+	    name: 'certificates',
+	    xtype: 'textarea',
+	},
+	{
+	    xtype: 'filebutton',
+	    text: gettext('From File'),
+	    listeners: {
+		change: function(btn, e, value) {
+		    let form = this.up('form');
+		    e = e.event;
+		    Ext.Array.each(e.target.files, function(file) {
+			Proxmox.Utils.loadTextFromFile(
+			    file,
+			    function(res) {
+				form.down('field[name=certificates]').setValue(res);
+			    },
+			    16384,
+			);
+		    });
+		    btn.reset();
+		},
+	    },
+	},
+	{
+	    xtype: 'hidden',
+	    name: 'restart',
+	    value: '1',
+	},
+	{
+	    xtype: 'hidden',
+	    name: 'force',
+	    value: '1',
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.url) {
+	    throw "neither url given";
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.window.ACMEAccountCreate', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+    xtype: 'pmxACMEAccountCreate',
+
+    acmeUrl: undefined,
+
+    width: 450,
+    title: gettext('Register Account'),
+    isCreate: true,
+    method: 'POST',
+    submitText: gettext('Register'),
+    showTaskViewer: true,
+    defaultExists: false,
+
+    items: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Account Name'),
+	    name: 'name',
+	    cbind: {
+		emptyText: (get) => get('defaultExists') ? '' : 'default',
+		allowBlank: (get) => !get('defaultExists'),
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'contact',
+	    vtype: 'email',
+	    allowBlank: false,
+	    fieldLabel: gettext('E-Mail'),
+	},
+	{
+	    xtype: 'proxmoxComboGrid',
+	    name: 'directory',
+	    reference: 'directory',
+	    allowBlank: false,
+	    valueField: 'url',
+	    displayField: 'name',
+	    fieldLabel: gettext('ACME Directory'),
+	    store: {
+		autoLoad: true,
+		fields: ['name', 'url'],
+		idProperty: ['name'],
+		proxy: { type: 'proxmox' },
+		sorters: {
+		    property: 'name',
+		    direction: 'ASC',
+		},
+	    },
+	    listConfig: {
+		columns: [
+		    {
+			header: gettext('Name'),
+			dataIndex: 'name',
+			flex: 1,
+		    },
+		    {
+			header: gettext('URL'),
+			dataIndex: 'url',
+			flex: 1,
+		    },
+		],
+	    },
+	    listeners: {
+		change: function(combogrid, value) {
+		    let me = this;
+
+		    if (!value) {
+			return;
+		    }
+
+		    let acmeUrl = me.up('window').acmeUrl;
+
+		    let disp = me.up('window').down('#tos_url_display');
+		    let field = me.up('window').down('#tos_url');
+		    let checkbox = me.up('window').down('#tos_checkbox');
+
+		    disp.setValue(gettext('Loading'));
+		    field.setValue(undefined);
+		    checkbox.setValue(undefined);
+		    checkbox.setHidden(true);
+
+		    Proxmox.Utils.API2Request({
+			url: `${acmeUrl}/tos`,
+			method: 'GET',
+			params: {
+			    directory: value,
+			},
+			success: function(response, opt) {
+			    field.setValue(response.result.data);
+			    disp.setValue(response.result.data);
+			    checkbox.setHidden(false);
+			},
+			failure: function(response, opt) {
+			    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			},
+		    });
+		},
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    itemId: 'tos_url_display',
+	    renderer: Proxmox.Utils.render_optional_url,
+	    name: 'tos_url_display',
+	},
+	{
+	    xtype: 'hidden',
+	    itemId: 'tos_url',
+	    name: 'tos_url',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    itemId: 'tos_checkbox',
+	    boxLabel: gettext('Accept TOS'),
+	    submitValue: false,
+	    validateValue: function(value) {
+		if (value && this.checked) {
+		    return true;
+		}
+		return false;
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.acmeUrl) {
+	    throw "no acmeUrl given";
+	}
+
+	me.url = `${me.acmeUrl}/account`;
+
+	me.callParent();
+
+	me.lookup('directory')
+	    .store
+	    .proxy
+	    .setUrl(`/api2/json/${me.acmeUrl}/directories`);
+    },
+});
+
+Ext.define('Proxmox.window.ACMEAccountView', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pmxACMEAccountView',
+
+    width: 600,
+    fieldDefaults: {
+	labelWidth: 140,
+    },
+
+    title: gettext('Account'),
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('E-Mail'),
+	    name: 'email',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Created'),
+	    name: 'createdAt',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Status'),
+	    name: 'status',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Directory'),
+	    renderer: Proxmox.Utils.render_optional_url,
+	    name: 'directory',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Terms of Services'),
+	    renderer: Proxmox.Utils.render_optional_url,
+	    name: 'tos',
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	// hide OK/Reset button, because we just want to show data
+	me.down('toolbar[dock=bottom]').setVisible(false);
+
+	me.load({
+	    success: function(response) {
+		var data = response.result.data;
+		data.email = data.account.contact[0];
+		data.createdAt = data.account.createdAt;
+		data.status = data.account.status;
+		me.setValues(data);
+	    },
+	});
+    },
+});
+Ext.define('Proxmox.window.ACMEPluginEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pmxACMEPluginEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    //onlineHelp: 'sysadmin_certs_acme_plugins',
+
+    isAdd: true,
+    isCreate: false,
+
+    width: 550,
+
+    acmeUrl: undefined,
+
+    subject: 'ACME DNS Plugin',
+
+    cbindData: function(config) {
+	let me = this;
+	return {
+	    challengeSchemaUrl: `/api2/json/${me.acmeUrl}/challenge-schema`,
+	};
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    // we dynamically create fields from the given schema
+	    // things we have to do here:
+	    // * save which fields we created to remove them again
+	    // * split the data from the generic 'data' field into the boxes
+	    // * on deletion collect those values again
+	    // * save the original values of the data field
+	    createdFields: {},
+	    createdInitially: false,
+	    originalValues: {},
+	    createSchemaFields: function(schema) {
+		let me = this;
+		// we know where to add because we define it right below
+		let container = me.down('container');
+		let datafield = me.down('field[name=data]');
+		let hintfield = me.down('field[name=hint]');
+		if (!me.createdInitially) {
+		    [me.originalValues] = Proxmox.Utils.parseACMEPluginData(datafield.getValue());
+		}
+
+		// collect values from custom fields and add it to 'data'',
+		// then remove the custom fields
+		let data = [];
+		for (const [name, field] of Object.entries(me.createdFields)) {
+		    let value = field.getValue();
+		    if (value !== undefined && value !== null && value !== '') {
+			data.push(`${name}=${value}`);
+		    }
+		    container.remove(field);
+		}
+		let datavalue = datafield.getValue();
+		if (datavalue !== undefined && datavalue !== null && datavalue !== '') {
+		    data.push(datavalue);
+		}
+		datafield.setValue(data.join('\n'));
+
+		me.createdFields = {};
+
+		if (typeof schema.fields !== 'object') {
+		    schema.fields = {};
+		}
+		// create custom fields according to schema
+		let gotSchemaField = false;
+		for (const [name, definition] of Object
+		    .entries(schema.fields)
+		    .sort((a, b) => a[0].localeCompare(b[0]))
+		) {
+		    let xtype;
+		    switch (definition.type) {
+			case 'string':
+			    xtype = 'proxmoxtextfield';
+			    break;
+			case 'integer':
+			    xtype = 'proxmoxintegerfield';
+			    break;
+			case 'number':
+			    xtype = 'numberfield';
+			    break;
+			default:
+			    console.warn(`unknown type '${definition.type}'`);
+			    xtype = 'proxmoxtextfield';
+			    break;
+		    }
+
+		    let label = name;
+		    if (typeof definition.name === "string") {
+			label = definition.name;
+		    }
+
+		    let field = Ext.create({
+			xtype,
+			name: `custom_${name}`,
+			fieldLabel: label,
+			width: '100%',
+			labelWidth: 150,
+			labelSeparator: '=',
+			emptyText: definition.default || '',
+			autoEl: definition.description ? {
+			    tag: 'div',
+			    'data-qtip': definition.description,
+			} : undefined,
+		    });
+
+		    me.createdFields[name] = field;
+		    container.add(field);
+		    gotSchemaField = true;
+		}
+		datafield.setHidden(gotSchemaField); // prefer schema-fields
+
+		if (schema.description) {
+		    hintfield.setValue(schema.description);
+		    hintfield.setHidden(false);
+		} else {
+		    hintfield.setValue('');
+		    hintfield.setHidden(true);
+		}
+
+		// parse data from field and set it to the custom ones
+		let extradata = [];
+		[data, extradata] = Proxmox.Utils.parseACMEPluginData(datafield.getValue());
+		for (const [key, value] of Object.entries(data)) {
+		    if (me.createdFields[key]) {
+			me.createdFields[key].setValue(value);
+			me.createdFields[key].originalValue = me.originalValues[key];
+			me.createdFields[key].checkDirty();
+		    } else {
+			extradata.push(`${key}=${value}`);
+		    }
+		}
+		datafield.setValue(extradata.join('\n'));
+		if (!me.createdInitially) {
+		    datafield.resetOriginalValue();
+		    me.createdInitially = true; // save that we initially set that
+		}
+	    },
+
+	    onGetValues: function(values) {
+		let me = this;
+		let win = me.up('pmxACMEPluginEdit');
+		if (win.isCreate) {
+		    values.id = values.plugin;
+		    values.type = 'dns'; // the only one for now
+		}
+		delete values.plugin;
+
+		Proxmox.Utils.delete_if_default(values, 'validation-delay', '30', win.isCreate);
+
+		let data = '';
+		for (const [name, field] of Object.entries(me.createdFields)) {
+		    let value = field.getValue();
+		    if (value !== null && value !== undefined && value !== '') {
+			data += `${name}=${value}\n`;
+		    }
+		    delete values[`custom_${name}`];
+		}
+		values.data = Ext.util.Base64.encode(data + values.data);
+		return values;
+	    },
+
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    cbind: {
+			editable: (get) => get('isCreate'),
+			submitValue: (get) => get('isCreate'),
+		    },
+		    editConfig: {
+			flex: 1,
+			xtype: 'proxmoxtextfield',
+			allowBlank: false,
+		    },
+		    name: 'plugin',
+		    labelWidth: 150,
+		    fieldLabel: gettext('Plugin ID'),
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'validation-delay',
+		    labelWidth: 150,
+		    fieldLabel: gettext('Validation Delay'),
+		    emptyText: 30,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    minValue: 0,
+		    maxValue: 48*60*60,
+		},
+		{
+		    xtype: 'pmxACMEApiSelector',
+		    name: 'api',
+		    labelWidth: 150,
+		    cbind: {
+			url: '{challengeSchemaUrl}',
+		    },
+		    listeners: {
+			change: function(selector) {
+			    let schema = selector.getSchema();
+			    selector.up('inputpanel').createSchemaFields(schema);
+			},
+		    },
+		},
+		{
+		    xtype: 'textarea',
+		    fieldLabel: gettext('API Data'),
+		    labelWidth: 150,
+		    name: 'data',
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Hint'),
+		    labelWidth: 150,
+		    name: 'hint',
+		    hidden: true,
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.acmeUrl) {
+	    throw "no acmeUrl given";
+	}
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, opts) {
+		    me.setValues(response.result.data);
+		},
+	    });
+	} else {
+	    me.method = 'POST';
+	}
+    },
+});
+Ext.define('Proxmox.window.ACMEDomainEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pmxACMEDomainEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    subject: gettext('Domain'),
+    isCreate: false,
+    width: 450,
+    //onlineHelp: 'sysadmin_certificate_management',
+
+    acmeUrl: undefined,
+
+    // config url
+    url: undefined,
+
+    // For PMG the we have multiple certificates, so we have a "usage" attribute & column.
+    domainUsages: undefined,
+
+    // Force the use of 'acmedomainX' properties.
+    separateDomainEntries: undefined,
+
+    cbindData: function(config) {
+	let me = this;
+	return {
+	    pluginsUrl: `/api2/json/${me.acmeUrl}/plugins`,
+	    hasUsage: !!me.domainUsages,
+	};
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    onGetValues: function(values) {
+		let me = this;
+		let win = me.up('pmxACMEDomainEdit');
+		let nodeconfig = win.nodeconfig;
+		let olddomain = win.domain || {};
+
+		let params = {
+		    digest: nodeconfig.digest,
+		};
+
+		let configkey = olddomain.configkey;
+		let acmeObj = Proxmox.Utils.parseACME(nodeconfig.acme);
+
+		let find_free_slot = () => {
+		    for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) {
+			if (nodeconfig[`acmedomain${i}`] === undefined) {
+			    return `acmedomain${i}`;
+			}
+		    }
+		    throw "too many domains configured";
+		};
+
+		// If we have a 'usage' property (pmg), we only use the `acmedomainX` config keys.
+		if (win.separateDomainEntries || win.domainUsages) {
+		    if (!configkey || configkey === 'acme') {
+			configkey = find_free_slot();
+		    }
+		    delete values.type;
+		    params[configkey] = Proxmox.Utils.printPropertyString(values, 'domain');
+		    return params;
+		}
+
+		// Otherwise we put the standalone entries into the `domains` list of the `acme`
+		// property string.
+
+		// Then insert the domain depending on its type:
+		if (values.type === 'dns') {
+		    if (!olddomain.configkey || olddomain.configkey === 'acme') {
+			configkey = find_free_slot();
+			if (olddomain.domain) {
+			    // we have to remove the domain from the acme domainlist
+			    Proxmox.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
+			    params.acme = Proxmox.Utils.printACME(acmeObj);
+			}
+		    }
+
+		    delete values.type;
+		    params[configkey] = Proxmox.Utils.printPropertyString(values, 'domain');
+		} else {
+		    if (olddomain.configkey && olddomain.configkey !== 'acme') {
+			// delete the old dns entry, unless we need to declare its usage:
+			params.delete = [olddomain.configkey];
+		    }
+
+		    // add new, remove old and make entries unique
+		    Proxmox.Utils.add_domain_to_acme(acmeObj, values.domain);
+		    if (olddomain.domain !== values.domain) {
+			Proxmox.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
+		    }
+		    params.acme = Proxmox.Utils.printACME(acmeObj);
+		}
+
+		return params;
+	    },
+	    items: [
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'type',
+		    fieldLabel: gettext('Challenge Type'),
+		    allowBlank: false,
+		    value: 'standalone',
+		    comboItems: [
+			['standalone', 'HTTP'],
+			['dns', 'DNS'],
+		    ],
+		    validator: function(value) {
+			let me = this;
+			let win = me.up('pmxACMEDomainEdit');
+			let oldconfigkey = win.domain ? win.domain.configkey : undefined;
+			let val = me.getValue();
+			if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) {
+			    // we have to check if there is a 'acmedomain' slot left
+			    let found = false;
+			    for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) {
+				if (!win.nodeconfig[`acmedomain${i}`]) {
+				    found = true;
+				}
+			    }
+			    if (!found) {
+				return gettext('Only 5 Domains with type DNS can be configured');
+			    }
+			}
+
+			return true;
+		    },
+		    listeners: {
+			change: function(cb, value) {
+			    let me = this;
+			    let view = me.up('pmxACMEDomainEdit');
+			    let pluginField = view.down('field[name=plugin]');
+			    pluginField.setDisabled(value !== 'dns');
+			    pluginField.setHidden(value !== 'dns');
+			},
+		    },
+		},
+		{
+		    xtype: 'hidden',
+		    name: 'alias',
+		},
+		{
+		    xtype: 'pmxACMEPluginSelector',
+		    name: 'plugin',
+		    disabled: true,
+		    hidden: true,
+		    allowBlank: false,
+		    cbind: {
+			url: '{pluginsUrl}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'domain',
+		    allowBlank: false,
+		    vtype: 'DnsNameOrWildcard',
+		    value: '',
+		    fieldLabel: gettext('Domain'),
+		},
+		{
+		    xtype: 'combobox',
+		    name: 'usage',
+		    multiSelect: true,
+		    editable: false,
+		    fieldLabel: gettext('Usage'),
+		    cbind: {
+			hidden: '{!hasUsage}',
+			allowBlank: '{!hasUsage}',
+		    },
+		    fields: ['usage', 'name'],
+		    displayField: 'name',
+		    valueField: 'usage',
+		    store: {
+			data: [
+			    { usage: 'api', name: 'API' },
+			    { usage: 'smtp', name: 'SMTP' },
+			],
+		    },
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.url) {
+	    throw 'no url given';
+	}
+
+	if (!me.acmeUrl) {
+	    throw 'no acmeUrl given';
+	}
+
+	if (!me.nodeconfig) {
+	    throw 'no nodeconfig given';
+	}
+
+	me.isCreate = !me.domain;
+	if (me.isCreate) {
+	    me.domain = `${Proxmox.NodeName}.`; // TODO: FQDN of node
+	}
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    let values = { ...me.domain };
+	    if (Ext.isDefined(values.usage)) {
+		values.usage = values.usage.split(';');
+	    }
+	    me.setValues(values);
+	} else {
+	    me.setValues({ domain: me.domain });
+	}
+    },
+});
+Ext.define('Proxmox.window.EndpointEditBase', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+
+    fieldDefaults: {
+	labelWidth: 120,
+    },
+
+    width: 700,
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = !me.name;
+
+	if (!me.baseUrl) {
+	    throw "baseUrl not set";
+	}
+
+	if (me.type === 'group') {
+	    me.url = `/api2/extjs${me.baseUrl}/groups`;
+	} else {
+	    me.url = `/api2/extjs${me.baseUrl}/endpoints/${me.type}`;
+	}
+
+	if (me.isCreate) {
+	    me.method = 'POST';
+	} else {
+	    me.url += `/${me.name}`;
+	    me.method = 'PUT';
+	}
+
+	let endpointConfig = Proxmox.Schema.notificationEndpointTypes[me.type];
+	if (!endpointConfig) {
+	    throw 'unknown endpoint type';
+	}
+
+	me.subject = endpointConfig.name;
+
+	Ext.apply(me, {
+	    items: [{
+		name: me.name,
+		xtype: endpointConfig.ipanel,
+		isCreate: me.isCreate,
+		baseUrl: me.baseUrl,
+		type: me.type,
+		defaultMailAuthor: endpointConfig.defaultMailAuthor,
+	    }],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load();
+	}
+    },
+});
+Ext.define('Proxmox.panel.NotificationMatcherGeneralPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxNotificationMatcherGeneralPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'name',
+	    cbind: {
+		value: '{name}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Matcher Name'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'enable',
+	    fieldLabel: gettext('Enable'),
+	    allowBlank: false,
+	    checked: true,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+
+    onSetValues: function(values) {
+	values.enable = !values.disable;
+
+	delete values.disable;
+	return values;
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (values.enable) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'disable' });
+	    }
+	} else {
+	    values.disable = 1;
+	}
+	delete values.enable;
+
+	return values;
+    },
+});
+
+Ext.define('Proxmox.panel.NotificationMatcherTargetPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxNotificationMatcherTargetPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    items: [
+	{
+	    xtype: 'pmxNotificationTargetSelector',
+	    name: 'target',
+	    allowBlank: false,
+	},
+    ],
+});
+
+Ext.define('Proxmox.window.NotificationMatcherEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+    onlineHelp: 'notification_matchers',
+
+    fieldDefaults: {
+	labelWidth: 120,
+    },
+
+    width: 700,
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = !me.name;
+
+	if (!me.baseUrl) {
+	    throw "baseUrl not set";
+	}
+
+	me.url = `/api2/extjs${me.baseUrl}/matchers`;
+
+	if (me.isCreate) {
+	    me.method = 'POST';
+	} else {
+	    me.url += `/${me.name}`;
+	    me.method = 'PUT';
+	}
+
+	me.subject = gettext('Notification Matcher');
+
+	Ext.apply(me, {
+	    bodyPadding: 0,
+	    items: [
+		{
+		    xtype: 'tabpanel',
+		    region: 'center',
+		    layout: 'fit',
+		    bodyPadding: 10,
+		    items: [
+			{
+			    name: me.name,
+			    title: gettext('General'),
+			    xtype: 'pmxNotificationMatcherGeneralPanel',
+			    isCreate: me.isCreate,
+			    baseUrl: me.baseUrl,
+			},
+			{
+			    name: me.name,
+			    title: gettext('Match Rules'),
+			    xtype: 'pmxNotificationMatchRulesEditPanel',
+			    isCreate: me.isCreate,
+			    baseUrl: me.baseUrl,
+			},
+			{
+			    name: me.name,
+			    title: gettext('Targets to notify'),
+			    xtype: 'pmxNotificationMatcherTargetPanel',
+			    isCreate: me.isCreate,
+			    baseUrl: me.baseUrl,
+			},
+		    ],
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load();
+	}
+    },
+});
+
+Ext.define('Proxmox.form.NotificationTargetSelector', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pmxNotificationTargetSelector',
+
+    mixins: {
+	field: 'Ext.form.field.Field',
+    },
+
+    padding: '0 0 10 0',
+
+    allowBlank: true,
+    selectAll: false,
+    isFormField: true,
+
+    store: {
+	autoLoad: true,
+	model: 'proxmox-notification-endpoints',
+	sorters: 'name',
+    },
+
+    columns: [
+	{
+	    header: gettext('Target Name'),
+	    dataIndex: 'name',
+	    flex: 1,
+	},
+	{
+	    header: gettext('Type'),
+	    dataIndex: 'type',
+	    flex: 1,
+	},
+	{
+	    header: gettext('Comment'),
+	    dataIndex: 'comment',
+	    flex: 3,
+	},
+    ],
+
+    selModel: {
+	selType: 'checkboxmodel',
+	mode: 'SIMPLE',
+    },
+
+    checkChangeEvents: [
+	'selectionchange',
+	'change',
+    ],
+
+    listeners: {
+	selectionchange: function() {
+	    // to trigger validity and error checks
+	    this.checkChange();
+	},
+    },
+
+    getSubmitData: function() {
+	let me = this;
+	let res = {};
+	res[me.name] = me.getValue();
+	return res;
+    },
+
+    getValue: function() {
+	let me = this;
+	if (me.savedValue !== undefined) {
+	    return me.savedValue;
+	}
+	let sm = me.getSelectionModel();
+	return (sm.getSelection() ?? []).map(item => item.data.name);
+    },
+
+    setValueSelection: function(value) {
+	let me = this;
+
+	let store = me.getStore();
+
+	let notFound = [];
+	let selection = value.map(item => {
+	    let found = store.findRecord('name', item, 0, false, true, true);
+	    if (!found) {
+		notFound.push(item);
+	    }
+	    return found;
+	}).filter(r => r);
+
+	for (const name of notFound) {
+	    let rec = store.add({
+		name,
+		type: '-',
+		comment: gettext('Included target does not exist!'),
+	    });
+	    selection.push(rec[0]);
+	}
+
+	let sm = me.getSelectionModel();
+	if (selection.length) {
+	    sm.select(selection);
+	} else {
+	    sm.deselectAll();
+	}
+	// to correctly trigger invalid class
+	me.getErrors();
+    },
+
+    setValue: function(value) {
+	let me = this;
+
+	let store = me.getStore();
+	if (!store.isLoaded()) {
+	    me.savedValue = value;
+	    store.on('load', function() {
+		me.setValueSelection(value);
+		delete me.savedValue;
+	    }, { single: true });
+	} else {
+	    me.setValueSelection(value);
+	}
+	return me.mixins.field.setValue.call(me, value);
+    },
+
+    getErrors: function(value) {
+	let me = this;
+	if (!me.isDisabled() && me.allowBlank === false &&
+	    me.getSelectionModel().getCount() === 0) {
+	    me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
+	    return [gettext('No target selected')];
+	}
+
+	me.removeBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
+	return [];
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	me.initField();
+    },
+
+});
+
+Ext.define('Proxmox.panel.NotificationRulesEditPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxNotificationMatchRulesEditPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	// we want to also set the empty value, but 'bind' does not do that so
+	// we have to set it then (and only then) to get the correct value in
+	// the tree
+	control: {
+	    'field': {
+		change: function(cmp) {
+		    let me = this;
+		    let vm = me.getViewModel();
+		    if (cmp.field) {
+			let record = vm.get('selectedRecord');
+			if (!record) {
+			    return;
+			}
+			let data = Ext.apply({}, record.get('data'));
+			let value = cmp.getValue();
+			// only update if the value is empty (or empty array)
+			if (!value || !value.length) {
+			    data[cmp.field] = value;
+			    record.set({ data });
+			}
+		    }
+		},
+	    },
+	},
+    },
+
+    viewModel: {
+	data: {
+	    selectedRecord: null,
+	    matchFieldType: 'exact',
+	    matchFieldField: '',
+	    matchFieldValue: '',
+	    rootMode: 'all',
+	},
+
+	formulas: {
+	    nodeType: {
+		get: function(get) {
+		    let record = get('selectedRecord');
+		    return record?.get('type');
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+
+		    let data;
+
+		    switch (value) {
+			case 'match-severity':
+			    data = {
+				value: ['info', 'notice', 'warning', 'error', 'unknown'],
+			    };
+			    break;
+			case 'match-field':
+			    data = {
+				type: 'exact',
+				field: '',
+				value: '',
+			    };
+			    break;
+			case 'match-calendar':
+			    data = {
+				value: '',
+			    };
+			    break;
+		    }
+
+		    let node = {
+			type: value,
+			data,
+		    };
+		    record.set(node);
+		},
+	    },
+	    showMatchingMode: function(get) {
+		let record = get('selectedRecord');
+		if (!record) {
+		    return false;
+		}
+		return record.isRoot();
+	    },
+	    showMatcherType: function(get) {
+		let record = get('selectedRecord');
+		if (!record) {
+		    return false;
+		}
+		return !record.isRoot();
+	    },
+	    typeIsMatchField: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		get: function(record) {
+		    return record?.get('type') === 'match-field';
+		},
+	    },
+	    typeIsMatchSeverity: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		get: function(record) {
+		    return record?.get('type') === 'match-severity';
+		},
+	    },
+	    typeIsMatchCalendar: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		get: function(record) {
+		    return record?.get('type') === 'match-calendar';
+		},
+	    },
+	    matchFieldType: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+		    let currentData = record.get('data');
+		    record.set({
+			data: {
+			    ...currentData,
+			    type: value,
+			},
+		    });
+		},
+		get: function(record) {
+		    return record?.get('data')?.type;
+		},
+	    },
+	    matchFieldField: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+		    let currentData = record.get('data');
+
+		    record.set({
+			data: {
+			    ...currentData,
+			    field: value,
+			},
+		    });
+		},
+		get: function(record) {
+		    return record?.get('data')?.field;
+		},
+	    },
+	    matchFieldValue: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+		    let currentData = record.get('data');
+		    record.set({
+			data: {
+			    ...currentData,
+			    value: value,
+			},
+		    });
+		},
+		get: function(record) {
+		    return record?.get('data')?.value;
+		},
+	    },
+	    matchSeverityValue: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+		    let currentData = record.get('data');
+		    record.set({
+			data: {
+			    ...currentData,
+			    value: value,
+			},
+		    });
+		},
+		get: function(record) {
+		    return record?.get('data')?.value;
+		},
+	    },
+	    matchCalendarValue: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+		    let currentData = record.get('data');
+		    record.set({
+			data: {
+			    ...currentData,
+			    value: value,
+			},
+		    });
+		},
+		get: function(record) {
+		    return record?.get('data')?.value;
+		},
+	    },
+	    rootMode: {
+		bind: {
+		    bindTo: '{selectedRecord}',
+		    deep: true,
+		},
+		set: function(value) {
+		    let me = this;
+		    let record = me.get('selectedRecord');
+		    let currentData = record.get('data');
+		    let invert = false;
+		    if (value.startsWith('not')) {
+			value = value.substring(3);
+			invert = true;
+		    }
+		    record.set({
+			data: {
+			    ...currentData,
+			    value,
+			    invert,
+			},
+		    });
+		},
+		get: function(record) {
+		    let prefix = record?.get('data').invert ? 'not' : '';
+		    return prefix + record?.get('data')?.value;
+		},
+	    },
+	},
+    },
+
+    column1: [
+	{
+	    xtype: 'pmxNotificationMatchRuleTree',
+	    cbind: {
+		isCreate: '{isCreate}',
+	    },
+	},
+    ],
+    column2: [
+	{
+	    xtype: 'pmxNotificationMatchRuleSettings',
+	},
+
+    ],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	let deleteArrayIfEmtpy = (field) => {
+	    if (Ext.isArray(values[field])) {
+		if (values[field].length === 0) {
+		    delete values[field];
+		    if (!me.isCreate) {
+			Proxmox.Utils.assemble_field_data(values, { 'delete': field });
+		    }
+		}
+	    }
+	};
+	deleteArrayIfEmtpy('match-field');
+	deleteArrayIfEmtpy('match-severity');
+	deleteArrayIfEmtpy('match-calendar');
+
+	return values;
+    },
+});
+
+Ext.define('Proxmox.panel.NotificationMatchRuleTree', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pmxNotificationMatchRuleTree',
+    mixins: ['Proxmox.Mixin.CBind'],
+    border: false,
+
+    getNodeTextAndIcon: function(type, data) {
+	let text;
+	let iconCls;
+
+	switch (type) {
+	    case 'match-severity': {
+		let v = data.value;
+		if (Ext.isArray(data.value)) {
+		    v = data.value.join(', ');
+		}
+		text = Ext.String.format(gettext("Match severity: {0}"), v);
+		iconCls = 'fa fa-exclamation';
+		if (!v) {
+		    iconCls += ' internal-error';
+		}
+	    } break;
+	    case 'match-field': {
+		let field = data.field;
+		let value = data.value;
+		text = Ext.String.format(gettext("Match field: {0}={1}"), field, value);
+		iconCls = 'fa fa-square-o';
+		if (!field || !value) {
+		    iconCls += ' internal-error';
+		}
+	    } break;
+	    case 'match-calendar': {
+		let v = data.value;
+		text = Ext.String.format(gettext("Match calendar: {0}"), v);
+		iconCls = 'fa fa-calendar-o';
+		if (!v || !v.length) {
+		    iconCls += ' internal-error';
+		}
+	    } break;
+	    case 'mode':
+		if (data.value === 'all') {
+		    text = gettext("All");
+		} else if (data.value === 'any') {
+		    text = gettext("Any");
+		}
+		if (data.invert) {
+		    text = `!${text}`;
+		}
+		iconCls = 'fa fa-filter';
+
+		break;
+	}
+
+	return [text, iconCls];
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let treeStore = Ext.create('Ext.data.TreeStore', {
+	    root: {
+		expanded: true,
+		expandable: false,
+		text: '',
+		type: 'mode',
+		data: {
+		    value: 'all',
+		    invert: false,
+		},
+		children: [],
+		iconCls: 'fa fa-filter',
+	    },
+	});
+
+	let realMatchFields = Ext.create({
+	    xtype: 'hiddenfield',
+	    setValue: function(value) {
+		this.value = value;
+		this.checkChange();
+	    },
+	    getValue: function() {
+		return this.value;
+	    },
+	    getErrors: function() {
+		for (const matcher of this.value ?? []) {
+		    let matches = matcher.match(/^([^:]+):([^=]+)=(.+)$/);
+		    if (!matches) {
+			return [""]; // fake error for validation
+		    }
+		}
+		return [];
+	    },
+	    getSubmitValue: function() {
+		let value = this.value;
+		if (!value) {
+		    value = [];
+		}
+		return value;
+	    },
+	    name: 'match-field',
+	});
+
+	let realMatchSeverity = Ext.create({
+	    xtype: 'hiddenfield',
+	    setValue: function(value) {
+		this.value = value;
+		this.checkChange();
+	    },
+	    getValue: function() {
+		return this.value;
+	    },
+	    getErrors: function() {
+		for (const severities of this.value ?? []) {
+		    if (!severities) {
+			return [""]; // fake error for validation
+		    }
+		}
+		return [];
+	    },
+	    getSubmitValue: function() {
+		let value = this.value;
+		if (!value) {
+		    value = [];
+		}
+		return value;
+	    },
+	    name: 'match-severity',
+	});
+
+	let realMode = Ext.create({
+	    xtype: 'hiddenfield',
+	    name: 'mode',
+	    setValue: function(value) {
+		this.value = value;
+		this.checkChange();
+	    },
+	    getValue: function() {
+		return this.value;
+	    },
+	    getSubmitValue: function() {
+		let value = this.value;
+		return value;
+	    },
+	});
+
+	let realMatchCalendar = Ext.create({
+	    xtype: 'hiddenfield',
+	    name: 'match-calendar',
+
+	    setValue: function(value) {
+		this.value = value;
+		this.checkChange();
+	    },
+	    getValue: function() {
+		return this.value;
+	    },
+	    getErrors: function() {
+		for (const timespan of this.value ?? []) {
+		    if (!timespan) {
+			return [""]; // fake error for validation
+		    }
+		}
+		return [];
+	    },
+	    getSubmitValue: function() {
+		let value = this.value;
+		return value;
+	    },
+	});
+
+	let realInvertMatch = Ext.create({
+	    xtype: 'proxmoxcheckbox',
+	    name: 'invert-match',
+	    hidden: true,
+	    deleteEmpty: !me.isCreate,
+	});
+
+	let storeChanged = function(store) {
+	    store.suspendEvent('datachanged');
+
+	    let matchFieldStmts = [];
+	    let matchSeverityStmts = [];
+	    let matchCalendarStmts = [];
+	    let modeStmt = 'all';
+	    let invertMatchStmt = false;
+
+	    store.each(function(model) {
+		let type = model.get('type');
+		let data = model.get('data');
+
+		switch (type) {
+		    case 'match-field':
+			matchFieldStmts.push(`${data.type}:${data.field ?? ''}=${data.value ?? ''}`);
+			break;
+		    case 'match-severity':
+			if (Ext.isArray(data.value)) {
+			    matchSeverityStmts.push(data.value.join(','));
+			} else {
+			    matchSeverityStmts.push(data.value);
+			}
+			break;
+		    case 'match-calendar':
+			matchCalendarStmts.push(data.value);
+			break;
+		    case 'mode':
+			modeStmt = data.value;
+			invertMatchStmt = data.invert;
+			break;
+		}
+
+		let [text, iconCls] = me.getNodeTextAndIcon(type, data);
+		model.set({
+		    text,
+		    iconCls,
+		});
+	    });
+
+	    realMatchFields.suspendEvent('change');
+	    realMatchFields.setValue(matchFieldStmts);
+	    realMatchFields.resumeEvent('change');
+
+	    realMatchCalendar.suspendEvent('change');
+	    realMatchCalendar.setValue(matchCalendarStmts);
+	    realMatchCalendar.resumeEvent('change');
+
+	    realMode.suspendEvent('change');
+	    realMode.setValue(modeStmt);
+	    realMode.resumeEvent('change');
+
+	    realInvertMatch.suspendEvent('change');
+	    realInvertMatch.setValue(invertMatchStmt);
+	    realInvertMatch.resumeEvent('change');
+
+	    realMatchSeverity.suspendEvent('change');
+	    realMatchSeverity.setValue(matchSeverityStmts);
+	    realMatchSeverity.resumeEvent('change');
+
+	    store.resumeEvent('datachanged');
+	};
+
+	realMatchFields.addListener('change', function(field, value) {
+	    let parseMatchField = function(filter) {
+		let [, type, matchedField, matchedValue] =
+		    filter.match(/^(?:(regex|exact):)?([A-Za-z0-9_][A-Za-z0-9._-]*)=(.+)$/);
+		if (type === undefined) {
+		    type = "exact";
+		}
+		return {
+		    type: 'match-field',
+		    data: {
+			type,
+			field: matchedField,
+			value: matchedValue,
+		    },
+		    leaf: true,
+		};
+	    };
+
+	    for (let node of treeStore.queryBy(
+		record => record.get('type') === 'match-field',
+	    ).getRange()) {
+		node.remove(true);
+	    }
+
+	    if (!value) {
+		return;
+	    }
+	    let records = value.map(parseMatchField);
+
+	    let rootNode = treeStore.getRootNode();
+
+	    for (let record of records) {
+		rootNode.appendChild(record);
+	    }
+	});
+
+	realMatchSeverity.addListener('change', function(field, value) {
+	    let parseSeverity = function(severities) {
+		return {
+		    type: 'match-severity',
+		    data: {
+			value: severities.split(','),
+		    },
+		    leaf: true,
+		};
+	    };
+
+	    for (let node of treeStore.queryBy(
+		record => record.get('type') === 'match-severity').getRange()) {
+		node.remove(true);
+	    }
+
+	    let records = value.map(parseSeverity);
+	    let rootNode = treeStore.getRootNode();
+
+	    for (let record of records) {
+		rootNode.appendChild(record);
+	    }
+	});
+
+	realMatchCalendar.addListener('change', function(field, value) {
+	    let parseCalendar = function(timespan) {
+		return {
+		    type: 'match-calendar',
+		    data: {
+			value: timespan,
+		    },
+		    leaf: true,
+		};
+	    };
+
+	    for (let node of treeStore.queryBy(
+		record => record.get('type') === 'match-calendar').getRange()) {
+		node.remove(true);
+	    }
+
+	    let records = value.map(parseCalendar);
+	    let rootNode = treeStore.getRootNode();
+
+	    for (let record of records) {
+		rootNode.appendChild(record);
+	    }
+	});
+
+	realMode.addListener('change', function(field, value) {
+	    let data = treeStore.getRootNode().get('data');
+	    treeStore.getRootNode().set('data', {
+		...data,
+		value,
+	    });
+	});
+
+	realInvertMatch.addListener('change', function(field, value) {
+	    let data = treeStore.getRootNode().get('data');
+	    treeStore.getRootNode().set('data', {
+		...data,
+		invert: value,
+	    });
+	});
+
+	treeStore.addListener('datachanged', storeChanged);
+
+	let treePanel = Ext.create({
+	    xtype: 'treepanel',
+	    store: treeStore,
+	    minHeight: 300,
+	    maxHeight: 300,
+	    scrollable: true,
+
+	    bind: {
+		selection: '{selectedRecord}',
+	    },
+	});
+
+	let addNode = function() {
+	    let node = {
+		type: 'match-field',
+		data: {
+		    type: 'exact',
+		    field: '',
+		    value: '',
+		},
+		leaf: true,
+	    };
+	    treeStore.getRootNode().appendChild(node);
+	    treePanel.setSelection(treeStore.getRootNode().lastChild);
+	};
+
+	let deleteNode = function() {
+	    let selection = treePanel.getSelection();
+	    for (let selected of selection) {
+		if (!selected.isRoot()) {
+		    selected.remove(true);
+		}
+	    }
+	};
+
+	Ext.apply(me, {
+	    items: [
+		realMatchFields,
+		realMode,
+		realMatchSeverity,
+		realInvertMatch,
+		realMatchCalendar,
+		treePanel,
+		{
+		    xtype: 'button',
+		    margin: '5 5 5 0',
+		    text: gettext('Add'),
+		    iconCls: 'fa fa-plus-circle',
+		    handler: addNode,
+		},
+		{
+		    xtype: 'button',
+		    margin: '5 5 5 0',
+		    text: gettext('Remove'),
+		    iconCls: 'fa fa-minus-circle',
+		    handler: deleteNode,
+		},
+	    ],
+	});
+	me.callParent();
+    },
+});
+
+Ext.define('Proxmox.panel.NotificationMatchRuleSettings', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pmxNotificationMatchRuleSettings',
+    border: false,
+
+    items: [
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'mode',
+	    fieldLabel: gettext('Match if'),
+	    allowBlank: false,
+	    isFormField: false,
+
+	    matchFieldWidth: false,
+
+	    comboItems: [
+		['all', gettext('All rules match')],
+		['any', gettext('Any rule matches')],
+		['notall', gettext('At least one rule does not match')],
+		['notany', gettext('No rule matches')],
+	    ],
+	    bind: {
+		hidden: '{!showMatchingMode}',
+		disabled: '{!showMatchingMode}',
+		value: '{rootMode}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    fieldLabel: gettext('Node type'),
+	    isFormField: false,
+	    allowBlank: false,
+
+	    bind: {
+		value: '{nodeType}',
+		hidden: '{!showMatcherType}',
+		disabled: '{!showMatcherType}',
+	    },
+
+	    comboItems: [
+		['match-field', gettext('Match Field')],
+		['match-severity', gettext('Match Severity')],
+		['match-calendar', gettext('Match Calendar')],
+	    ],
+	},
+	{
+	    fieldLabel: gettext('Match Type'),
+	    xtype: 'proxmoxKVComboBox',
+	    reference: 'type',
+	    isFormField: false,
+	    allowBlank: false,
+	    submitValue: false,
+	    field: 'type',
+
+	    bind: {
+		hidden: '{!typeIsMatchField}',
+		disabled: '{!typeIsMatchField}',
+		value: '{matchFieldType}',
+	    },
+
+	    comboItems: [
+		['exact', gettext('Exact')],
+		['regex', gettext('Regex')],
+	    ],
+	},
+	{
+	    fieldLabel: gettext('Field'),
+	    xtype: 'proxmoxKVComboBox',
+	    isFormField: false,
+	    submitValue: false,
+	    allowBlank: false,
+	    editable: true,
+	    displayField: 'key',
+	    field: 'field',
+	    bind: {
+		hidden: '{!typeIsMatchField}',
+		disabled: '{!typeIsMatchField}',
+		value: '{matchFieldField}',
+	    },
+	    // TODO: Once we have a 'notification registry', we should
+	    // retrive those via an API call.
+	    comboItems: [
+		['type', ''],
+		['hostname', ''],
+	    ],
+	},
+	{
+	    fieldLabel: gettext('Value'),
+	    xtype: 'textfield',
+	    isFormField: false,
+	    submitValue: false,
+	    allowBlank: false,
+	    field: 'value',
+	    bind: {
+		hidden: '{!typeIsMatchField}',
+		disabled: '{!typeIsMatchField}',
+		value: '{matchFieldValue}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    fieldLabel: gettext('Severities to match'),
+	    isFormField: false,
+	    allowBlank: true,
+	    multiSelect: true,
+	    field: 'value',
+
+	    bind: {
+		value: '{matchSeverityValue}',
+		hidden: '{!typeIsMatchSeverity}',
+		disabled: '{!typeIsMatchSeverity}',
+	    },
+
+	    comboItems: [
+		['info', gettext('Info')],
+		['notice', gettext('Notice')],
+		['warning', gettext('Warning')],
+		['error', gettext('Error')],
+		['unknown', gettext('Unknown')],
+	    ],
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    fieldLabel: gettext('Timespan to match'),
+	    isFormField: false,
+	    allowBlank: false,
+	    editable: true,
+	    displayField: 'key',
+	    field: 'value',
+
+	    bind: {
+		value: '{matchCalendarValue}',
+		hidden: '{!typeIsMatchCalendar}',
+		disabled: '{!typeIsMatchCalender}',
+	    },
+
+	    comboItems: [
+		['mon 8-12', ''],
+		['tue..fri,sun 0:00-23:59', ''],
+	    ],
+	},
+    ],
+});
+Ext.define('proxmox-file-tree', {
+    extend: 'Ext.data.Model',
+
+    fields: [
+	'filepath', 'text', 'type', 'size',
+	{
+	    name: 'sizedisplay',
+	    calculate: data => {
+		if (data.size === undefined) {
+		    return '';
+		} else if (false && data.type === 'd') { // eslint-disable-line no-constant-condition
+		    // FIXME: enable again once we fixed trouble with confusing size vs item #
+		    let fs = data.size === 1 ? gettext('{0} Item') : gettext('{0} Items');
+		    return Ext.String.format(fs, data.size);
+		}
+
+		return Proxmox.Utils.format_size(data.size);
+	    },
+	},
+	{
+	    name: 'mtime',
+	    type: 'date',
+	    dateFormat: 'timestamp',
+	},
+	{
+	    name: 'iconCls',
+	    calculate: function(data) {
+		let icon = Proxmox.Schema.pxarFileTypes[data.type]?.icon ?? 'file-o';
+		if (data.expanded && data.type === 'd') {
+		    icon = 'folder-open-o';
+		}
+		return `fa fa-${icon}`;
+	    },
+	},
+    ],
+    idProperty: 'filepath',
+});
+
+Ext.define("Proxmox.window.FileBrowser", {
+    extend: "Ext.window.Window",
+
+    width: 800,
+    height: 600,
+
+    modal: true,
+
+    config: {
+	// the base-URL to get the list of files. required.
+	listURL: '',
+
+	// the base download URL, e.g., something like '/api2/...'
+	downloadURL: '',
+
+	// extra parameters set as proxy paramns and for an actual download request
+	extraParams: {},
+
+	// the file types for which the download button should be enabled
+	downloadableFileTypes: {
+	    'h': true, // hardlinks
+	    'f': true, // "normal" files
+	    'd': true, // directories
+	},
+
+	// prefix to prepend to downloaded file names
+	downloadPrefix: '',
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	buildUrl: function(baseurl, params) {
+	    let url = new URL(baseurl, window.location.origin);
+	    for (const [key, value] of Object.entries(params)) {
+		url.searchParams.append(key, value);
+	    }
+
+	    return url.href;
+	},
+
+	downloadTar: function() {
+	    this.downloadFile(true);
+	},
+
+	downloadZip: function() {
+	    this.downloadFile(false);
+	},
+
+	downloadFile: function(tar) {
+	    let me = this;
+	    let view = me.getView();
+	    let tree = me.lookup('tree');
+	    let selection = tree.getSelection();
+	    if (!selection || selection.length < 1) return;
+
+	    let data = selection[0].data;
+
+	    let params = { ...view.extraParams };
+	    params.filepath = data.filepath;
+
+	    let filename = view.downloadPrefix + data.text;
+	    if (data.type === 'd') {
+		if (tar) {
+		    params.tar = 1;
+		    filename += ".tar.zst";
+		} else {
+		    filename += ".zip";
+		}
+	    }
+
+	    Proxmox.Utils.downloadAsFile(me.buildUrl(view.downloadURL, params), filename);
+	},
+
+	fileChanged: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let tree = me.lookup('tree');
+	    let selection = tree.getSelection();
+	    if (!selection || selection.length < 1) return;
+
+	    let data = selection[0].data;
+	    let st = Ext.String.format(gettext('Selected "{0}"'), atob(data.filepath));
+	    view.lookup('selectText').setText(st);
+
+	    let canDownload = view.downloadURL && view.downloadableFileTypes[data.type];
+	    let enableMenu = data.type === 'd';
+
+	    let downloadBtn = view.lookup('downloadBtn');
+	    downloadBtn.setDisabled(!canDownload || enableMenu);
+	    downloadBtn.setHidden(canDownload && enableMenu);
+	    let typeLabel = Proxmox.Schema.pxarFileTypes[data.type]?.label ?? Proxmox.Utils.unknownText;
+	    let ttip = Ext.String.format(
+	        gettext('File of type {0} cannot be downloaded directly, download a parent directory instead.'),
+	        typeLabel,
+	    );
+	    if (!canDownload) { // ensure tooltip gets shown
+		downloadBtn.setStyle({ pointerEvents: 'all' });
+	    }
+	    downloadBtn.setTooltip(canDownload ? null : ttip);
+
+	    let menuBtn = view.lookup('menuBtn');
+	    menuBtn.setDisabled(!canDownload || !enableMenu);
+	    menuBtn.setHidden(!canDownload || !enableMenu);
+	},
+
+	errorHandler: function(error, msg) {
+	    let me = this;
+	    if (error?.status === 503) {
+		return false;
+	    }
+	    me.lookup('downloadBtn').setDisabled(true);
+	    me.lookup('menuBtn').setDisabled(true);
+	    if (me.initialLoadDone) {
+		Ext.Msg.alert(gettext('Error'), msg);
+		return true;
+	    }
+	    return false;
+	},
+
+	init: function(view) {
+	    let me = this;
+	    let tree = me.lookup('tree');
+
+	    if (!view.listURL) {
+		throw "no list URL given";
+	    }
+
+	    let store = tree.getStore();
+	    let proxy = store.getProxy();
+
+	    let errorCallback = (error, msg) => me.errorHandler(error, msg);
+	    proxy.setUrl(view.listURL);
+	    proxy.setTimeout(60*1000);
+	    proxy.setExtraParams(view.extraParams);
+
+	    tree.mon(store, 'beforeload', () => {
+		Proxmox.Utils.setErrorMask(tree, true);
+	    });
+	    tree.mon(store, 'load', (treestore, rec, success, operation, node) => {
+		if (success) {
+		    Proxmox.Utils.setErrorMask(tree, false);
+		    return;
+		}
+		if (!node.loadCount) {
+		    node.loadCount = 0; // ensure its numeric
+		}
+		// trigger a reload if we got a 503 answer from the proxy
+		if (operation?.error?.status === 503 && node.loadCount < 10) {
+		    node.collapse();
+		    node.expand();
+		    node.loadCount++;
+		    return;
+		}
+
+		let error = operation.getError();
+		let msg = Proxmox.Utils.getResponseErrorMessage(error);
+		if (!errorCallback(error, msg)) {
+		    Proxmox.Utils.setErrorMask(tree, msg);
+		} else {
+		    Proxmox.Utils.setErrorMask(tree, false);
+		}
+	    });
+	    store.load((rec, op, success) => {
+		let root = store.getRoot();
+		root.expand(); // always expand invisible root node
+		if (view.archive === 'all') {
+		    root.expandChildren(false);
+		} else if (view.archive) {
+		    let child = root.findChild('text', view.archive);
+		    if (child) {
+			child.expand();
+			setTimeout(function() {
+			    tree.setSelection(child);
+			    tree.getView().focusRow(child);
+			}, 10);
+		    }
+		} else if (root.childNodes.length === 1) {
+		    root.firstChild.expand();
+		}
+		me.initialLoadDone = success;
+	    });
+	},
+
+	control: {
+	    'treepanel': {
+		selectionchange: 'fileChanged',
+	    },
+	},
+    },
+
+    layout: 'fit',
+    items: [
+	{
+	    xtype: 'treepanel',
+	    scrollable: true,
+	    rootVisible: false,
+	    reference: 'tree',
+	    store: {
+		autoLoad: false,
+		model: 'proxmox-file-tree',
+		defaultRootId: '/',
+		nodeParam: 'filepath',
+		sorters: 'text',
+		proxy: {
+		    appendId: false,
+		    type: 'proxmox',
+		},
+	    },
+
+	    viewConfig: {
+		loadMask: false,
+	    },
+
+	    columns: [
+		{
+		    text: gettext('Name'),
+		    xtype: 'treecolumn',
+		    flex: 1,
+		    dataIndex: 'text',
+		    renderer: Ext.String.htmlEncode,
+		},
+		{
+		    text: gettext('Size'),
+		    dataIndex: 'sizedisplay',
+		    align: 'end',
+		    sorter: {
+			sorterFn: function(a, b) {
+			    if (a.data.type === 'd' && b.data.type !== 'd') {
+				return -1;
+			    } else if (a.data.type !== 'd' && b.data.type === 'd') {
+				return 1;
+			    }
+
+			    let asize = a.data.size || 0;
+			    let bsize = b.data.size || 0;
+
+			    return asize - bsize;
+			},
+		    },
+		},
+		{
+		    text: gettext('Modified'),
+		    dataIndex: 'mtime',
+		    minWidth: 200,
+		},
+		{
+		    text: gettext('Type'),
+		    dataIndex: 'type',
+		    renderer: (v) => Proxmox.Schema.pxarFileTypes[v]?.label ?? Proxmox.Utils.unknownText,
+		},
+	    ],
+	},
+    ],
+
+    fbar: [
+	{
+	    text: '',
+	    xtype: 'label',
+	    reference: 'selectText',
+	},
+	{
+	    text: gettext('Download'),
+	    xtype: 'button',
+	    handler: 'downloadZip',
+	    reference: 'downloadBtn',
+	    disabled: true,
+	    hidden: true,
+	},
+	{
+	    text: gettext('Download as'),
+	    xtype: 'button',
+	    reference: 'menuBtn',
+	    menu: {
+		items: [
+		    {
+			iconCls: 'fa fa-fw fa-file-zip-o',
+			text: gettext('.zip'),
+			handler: 'downloadZip',
+			reference: 'downloadZip',
+		    },
+		    {
+			iconCls: 'fa fa-fw fa-archive',
+			text: gettext('.tar.zst'),
+			handler: 'downloadTar',
+			reference: 'downloadTar',
+		    },
+		],
+	    },
+	},
+    ],
+});
+Ext.define('Proxmox.window.AuthEditBase', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+
+    fieldDefaults: {
+	labelWidth: 120,
+    },
+
+    baseurl: '/access/domains',
+    useTypeInUrl: false,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.realm;
+
+	me.url = `/api2/extjs${me.baseUrl}`;
+	if (me.useTypeInUrl) {
+	    me.url += `/${me.authType}`;
+	}
+
+	if (me.isCreate) {
+	    me.method = 'POST';
+	} else {
+	    me.url += `/${me.realm}`;
+	    me.method = 'PUT';
+	}
+
+	let authConfig = Proxmox.Schema.authDomains[me.authType];
+	if (!authConfig) {
+	    throw 'unknown auth type';
+	} else if (!authConfig.add && me.isCreate) {
+	    throw 'trying to add non addable realm';
+	}
+
+	me.subject = authConfig.name;
+
+	let items;
+	let bodyPadding;
+	if (authConfig.syncipanel) {
+	    bodyPadding = 0;
+	    items = {
+		xtype: 'tabpanel',
+		region: 'center',
+		layout: 'fit',
+		bodyPadding: 10,
+		items: [
+		    {
+			title: gettext('General'),
+			realm: me.realm,
+			xtype: authConfig.ipanel,
+			isCreate: me.isCreate,
+			useTypeInUrl: me.useTypeInUrl,
+			type: me.authType,
+		    },
+		    {
+			title: gettext('Sync Options'),
+			realm: me.realm,
+			xtype: authConfig.syncipanel,
+			isCreate: me.isCreate,
+			type: me.authType,
+		    },
+		],
+	    };
+	} else {
+	    items = [{
+		realm: me.realm,
+		xtype: authConfig.ipanel,
+		isCreate: me.isCreate,
+		useTypeInUrl: me.useTypeInUrl,
+		type: me.authType,
+	    }];
+	}
+
+	Ext.apply(me, {
+	    items,
+	    bodyPadding,
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var data = response.result.data || {};
+		    // just to be sure (should not happen)
+		    // only check this when the type is not in the api path
+		    if (!me.useTypeInUrl && data.type !== me.authType) {
+			me.close();
+			throw "got wrong auth type";
+		    }
+		    me.setValues(data);
+		},
+	    });
+	}
+    },
+});
+Ext.define('Proxmox.panel.OpenIDInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxAuthOpenIDPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    type: 'openid',
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (me.isCreate && !me.useTypeInUrl) {
+	    values.type = me.type;
+	}
+
+	return values;
+    },
+
+    columnT: [
+	{
+	    xtype: 'textfield',
+	    name: 'issuer-url',
+	    fieldLabel: gettext('Issuer URL'),
+	    allowBlank: false,
+	},
+    ],
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'realm',
+	    cbind: {
+		value: '{realm}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Realm'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Client ID'),
+	    name: 'client-id',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Client Key'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	    name: 'client-key',
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Autocreate Users'),
+	    name: 'autocreate',
+	    value: 0,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'username-claim',
+	    fieldLabel: gettext('Username Claim'),
+	    editConfig: {
+		xtype: 'proxmoxKVComboBox',
+		editable: true,
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText],
+		    ['subject', 'subject'],
+		    ['username', 'username'],
+		    ['email', 'email'],
+		],
+	    },
+	    cbind: {
+		value: get => get('isCreate') ? '__default__' : Proxmox.Utils.defaultText,
+		deleteEmpty: '{!isCreate}',
+		editable: '{isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'scopes',
+	    fieldLabel: gettext('Scopes'),
+	    emptyText: `${Proxmox.Utils.defaultText} (email profile)`,
+	    submitEmpty: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'prompt',
+	    fieldLabel: gettext('Prompt'),
+	    editable: true,
+	    emptyText: gettext('Auth-Provider Default'),
+	    comboItems: [
+		['__default__', gettext('Auth-Provider Default')],
+		['none', 'none'],
+		['login', 'login'],
+		['consent', 'consent'],
+		['select_account', 'select_account'],
+	    ],
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    advancedColumnB: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'acr-values',
+	    fieldLabel: gettext('ACR Values'),
+	    submitEmpty: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+});
+
+
+Ext.define('Proxmox.panel.LDAPInputPanelViewModel', {
+    extend: 'Ext.app.ViewModel',
+
+    alias: 'viewmodel.pmxAuthLDAPPanel',
+
+    data: {
+	mode: 'ldap',
+	anonymous_search: 1,
+    },
+
+    formulas: {
+	tls_enabled: function(get) {
+	    return get('mode') !== 'ldap';
+	},
+    },
+
+});
+
+
+Ext.define('Proxmox.panel.LDAPInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxAuthLDAPPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	type: 'pmxAuthLDAPPanel',
+    },
+
+    type: 'ldap',
+
+    onlineHelp: 'user-realms-ldap',
+
+    onGetValues: function(values) {
+	if (this.isCreate && !this.useTypeInUrl) {
+	    values.type = this.type;
+	}
+
+	if (values.anonymous_search && !this.isCreate) {
+	    if (!values.delete) {
+		values.delete = [];
+	    }
+
+	    if (!Array.isArray(values.delete)) {
+		let tmp = values.delete;
+		values.delete = [];
+		values.delete.push(tmp);
+	    }
+
+	    values.delete.push("bind-dn");
+	    values.delete.push("password");
+	}
+
+	delete values.anonymous_search;
+
+	return values;
+    },
+
+    onSetValues: function(values) {
+	let me = this;
+	values.anonymous_search = values["bind-dn"] ? 0 : 1;
+	me.getViewModel().set('anonymous_search', values.anonymous_search);
+
+	return values;
+    },
+
+    cbindData: function(config) {
+	return {
+	    isLdap: this.type === 'ldap',
+	    isAd: this.type === 'ad',
+	};
+    },
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'realm',
+	    cbind: {
+		value: '{realm}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Realm'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Base Domain Name'),
+	    name: 'base-dn',
+	    emptyText: 'cn=Users,dc=company,dc=net',
+	    cbind: {
+		hidden: '{!isLdap}',
+		allowBlank: '{!isLdap}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('User Attribute Name'),
+	    name: 'user-attr',
+	    emptyText: 'uid / sAMAccountName',
+	    cbind: {
+		hidden: '{!isLdap}',
+		allowBlank: '{!isLdap}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Anonymous Search'),
+	    name: 'anonymous_search',
+	    bind: {
+		value: '{anonymous_search}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Bind Domain Name'),
+	    name: 'bind-dn',
+	    allowBlank: false,
+	    cbind: {
+		emptyText: get => get('isAd') ? 'user@company.net' : 'cn=user,dc=company,dc=net',
+		autoEl: get => get('isAd') ? {
+		    tag: 'div',
+		    'data-qtip':
+			gettext('LDAP DN syntax can be used as well, e.g. cn=user,dc=company,dc=net'),
+		} : {},
+	    },
+	    bind: {
+		disabled: "{anonymous_search}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Bind Password'),
+	    name: 'password',
+	    cbind: {
+		emptyText: get => !get('isCreate') ? gettext('Unchanged') : '',
+		allowBlank: '{!isCreate}',
+	    },
+	    bind: {
+		disabled: "{anonymous_search}",
+	    },
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'server1',
+	    fieldLabel: gettext('Server'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'server2',
+	    fieldLabel: gettext('Fallback Server'),
+	    submitEmpty: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'port',
+	    fieldLabel: gettext('Port'),
+	    minValue: 1,
+	    maxValue: 65535,
+	    emptyText: gettext('Default'),
+	    submitEmptyText: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'mode',
+	    fieldLabel: gettext('Mode'),
+	    editable: false,
+	    comboItems: [
+		['ldap', 'LDAP'],
+		['ldap+starttls', 'STARTTLS'],
+		['ldaps', 'LDAPS'],
+	    ],
+	    bind: "{mode}",
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+		value: get => get('isCreate') ? 'ldap' : 'LDAP',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Verify Certificate'),
+	    name: 'verify',
+	    value: 0,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+
+	    bind: {
+		disabled: '{!tls_enabled}',
+	    },
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Verify TLS certificate of the server'),
+	    },
+
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+});
+
+
+Ext.define('Proxmox.panel.LDAPSyncInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxAuthLDAPSyncPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    editableAttributes: ['firstname', 'lastname', 'email'],
+    editableDefaults: ['scope', 'enable-new'],
+    default_opts: {},
+    sync_attributes: {},
+
+    type: 'ldap',
+
+    // (de)construct the sync-attributes from the list above,
+    // not touching all others
+    onGetValues: function(values) {
+	let me = this;
+
+	me.editableDefaults.forEach((attr) => {
+	    if (values[attr]) {
+		me.default_opts[attr] = values[attr];
+		delete values[attr];
+	    } else {
+		delete me.default_opts[attr];
+	    }
+	});
+	let vanished_opts = [];
+	['acl', 'entry', 'properties'].forEach((prop) => {
+	    if (values[`remove-vanished-${prop}`]) {
+		vanished_opts.push(prop);
+	    }
+	    delete values[`remove-vanished-${prop}`];
+	});
+	me.default_opts['remove-vanished'] = vanished_opts.join(';');
+
+	values['sync-defaults-options'] = Proxmox.Utils.printPropertyString(me.default_opts);
+	me.editableAttributes.forEach((attr) => {
+	    if (values[attr]) {
+		me.sync_attributes[attr] = values[attr];
+		delete values[attr];
+	    } else {
+		delete me.sync_attributes[attr];
+	    }
+	});
+	values['sync-attributes'] = Proxmox.Utils.printPropertyString(me.sync_attributes);
+
+	Proxmox.Utils.delete_if_default(values, 'sync-defaults-options');
+	Proxmox.Utils.delete_if_default(values, 'sync-attributes');
+
+	// Force values.delete to be an array
+	if (typeof values.delete === 'string') {
+	   values.delete = values.delete.split(',');
+	}
+
+	if (me.isCreate) {
+	    delete values.delete; // on create we cannot delete values
+	}
+
+	return values;
+    },
+
+    setValues: function(values) {
+	let me = this;
+
+	if (values['sync-attributes']) {
+	    me.sync_attributes = Proxmox.Utils.parsePropertyString(values['sync-attributes']);
+	    delete values['sync-attributes'];
+	    me.editableAttributes.forEach((attr) => {
+		if (me.sync_attributes[attr]) {
+		    values[attr] = me.sync_attributes[attr];
+		}
+	    });
+	}
+	if (values['sync-defaults-options']) {
+	    me.default_opts = Proxmox.Utils.parsePropertyString(values['sync-defaults-options']);
+	    delete values.default_opts;
+	    me.editableDefaults.forEach((attr) => {
+		if (me.default_opts[attr]) {
+		    values[attr] = me.default_opts[attr];
+		}
+	    });
+
+	    if (me.default_opts['remove-vanished']) {
+		let opts = me.default_opts['remove-vanished'].split(';');
+		for (const opt of opts) {
+		    values[`remove-vanished-${opt}`] = 1;
+		}
+	    }
+	}
+	return me.callParent([values]);
+    },
+
+    column1: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'firstname',
+	    fieldLabel: gettext('First Name attribute'),
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': Ext.String.format(gettext('Often called {0}'), '`givenName`'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'lastname',
+	    fieldLabel: gettext('Last Name attribute'),
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': Ext.String.format(gettext('Often called {0}'), '`sn`'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'email',
+	    fieldLabel: gettext('E-Mail attribute'),
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': get => get('isAd')
+		    ? Ext.String.format(gettext('Often called {0} or {1}'), '`userPrincipalName`', '`mail`')
+		    : Ext.String.format(gettext('Often called {0}'), '`mail`'),
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    value: gettext('Default Sync Options'),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    value: '__default__',
+	    deleteEmpty: false,
+	    comboItems: [
+		[
+		    '__default__',
+		    Ext.String.format(
+			gettext("{0} ({1})"),
+			Proxmox.Utils.yesText,
+			Proxmox.Utils.defaultText,
+		    ),
+		],
+		['true', Proxmox.Utils.yesText],
+		['false', Proxmox.Utils.noText],
+	    ],
+	    name: 'enable-new',
+	    fieldLabel: gettext('Enable new users'),
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'user-classes',
+	    fieldLabel: gettext('User classes'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	    emptyText: 'inetorgperson, posixaccount, person, user',
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Default user classes: inetorgperson, posixaccount, person, user'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'filter',
+	    fieldLabel: gettext('User Filter'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'fieldset',
+	    title: gettext('Remove Vanished Options'),
+	    items: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('ACL'),
+		    name: 'remove-vanished-acl',
+		    boxLabel: gettext('Remove ACLs of vanished users'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Entry'),
+		    name: 'remove-vanished-entry',
+		    boxLabel: gettext('Remove vanished user'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Properties'),
+		    name: 'remove-vanished-properties',
+		    boxLabel: gettext('Remove vanished properties from synced users.'),
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('Proxmox.panel.ADInputPanel', {
+    extend: 'Proxmox.panel.LDAPInputPanel',
+    xtype: 'pmxAuthADPanel',
+
+    type: 'ad',
+    onlineHelp: 'user-realms-ad',
+});
+
+Ext.define('Proxmox.panel.ADSyncInputPanel', {
+    extend: 'Proxmox.panel.LDAPSyncInputPanel',
+    xtype: 'pmxAuthADSyncPanel',
+
+    type: 'ad',
+});
+/*global u2f*/
+Ext.define('Proxmox.window.TfaLoginWindow', {
+    extend: 'Ext.window.Window',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext("Second login factor required"),
+
+    modal: true,
+    resizable: false,
+    width: 512,
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    defaultButton: 'tfaButton',
+
+    viewModel: {
+	data: {
+	    confirmText: gettext('Confirm Second Factor'),
+	    canConfirm: false,
+	    availableChallenge: {},
+	},
+    },
+
+    cancelled: true,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    if (!view.userid) {
+		throw "no userid given";
+	    }
+	    if (!view.ticket) {
+		throw "no ticket given";
+	    }
+	    const challenge = view.challenge;
+	    if (!challenge) {
+		throw "no challenge given";
+	    }
+
+	    let lastTabId = me.getLastTabUsed();
+	    let initialTab = -1, i = 0;
+	    let count2nd = 0;
+	    let hasRecovery = false;
+	    for (const k of ['webauthn', 'totp', 'recovery', 'u2f', 'yubico']) {
+		const available = !!challenge[k];
+		vm.set(`availableChallenge.${k}`, available);
+
+		if (available) {
+		    count2nd++;
+		    if (k === 'recovery') {
+			hasRecovery = true;
+		    }
+		    if (i === lastTabId) {
+			initialTab = i;
+		    } else if (initialTab < 0) {
+			initialTab = i;
+		    }
+		}
+		i++;
+	    }
+	    if (!count2nd || (count2nd === 1 && hasRecovery && !challenge.recovery.length)) {
+		// no 2nd factors available (and if recovery keys are configured they're empty)
+		me.lookup('cannotLogin').setVisible(true);
+		me.lookup('recoveryKey').setVisible(false);
+		view.down('tabpanel').setActiveTab(2); // recovery
+		return;
+	    }
+	    view.down('tabpanel').setActiveTab(initialTab);
+
+	    if (challenge.recovery) {
+		if (!view.challenge.recovery.length) {
+		    me.lookup('recoveryEmpty').setVisible(true);
+		    me.lookup('recoveryKey').setVisible(false);
+		} else {
+		    let idList = view
+			    .challenge
+			    .recovery
+			    .map((id) => Ext.String.format(gettext('ID {0}'), id))
+			    .join(', ');
+		    me.lookup('availableRecovery').update(Ext.String.htmlEncode(
+			Ext.String.format(gettext('Available recovery keys: {0}'), idList),
+		    ));
+		    me.lookup('availableRecovery').setVisible(true);
+		    if (view.challenge.recovery.length <= 3) {
+			me.lookup('recoveryLow').setVisible(true);
+		    }
+		}
+	    }
+
+	    if (challenge.webauthn && initialTab === 0) {
+		let _promise = me.loginWebauthn();
+	    } else if (challenge.u2f && initialTab === 3) {
+		let _promise = me.loginU2F();
+	    }
+	},
+	control: {
+	    'tabpanel': {
+		tabchange: function(tabPanel, newCard, oldCard) {
+		    // for now every TFA method has at max one field, so keep it simple..
+		    let oldField = oldCard.down('field');
+		    if (oldField) {
+			oldField.setDisabled(true);
+		    }
+		    let newField = newCard.down('field');
+		    if (newField) {
+			newField.setDisabled(false);
+			newField.focus();
+			newField.validate();
+		    }
+
+		    let confirmText = newCard.confirmText || gettext('Confirm Second Factor');
+		    this.getViewModel().set('confirmText', confirmText);
+
+		    this.saveLastTabUsed(tabPanel, newCard);
+		},
+	    },
+	    'field': {
+		validitychange: function(field, valid) {
+		    // triggers only for enabled fields and we disable the one from the
+		    // non-visible tab, so we can just directly use the valid param
+		    this.getViewModel().set('canConfirm', valid);
+		},
+		afterrender: field => field.focus(), // ensure focus after initial render
+	    },
+	},
+
+	saveLastTabUsed: function(tabPanel, card) {
+	    let id = tabPanel.items.indexOf(card);
+	    window.localStorage.setItem('Proxmox.TFALogin.lastTab', JSON.stringify({ id }));
+	},
+
+	getLastTabUsed: function() {
+	    let data = window.localStorage.getItem('Proxmox.TFALogin.lastTab');
+	    if (typeof data === 'string') {
+		let last = JSON.parse(data);
+		return last.id;
+	    }
+	    return null;
+	},
+
+	onClose: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    if (!view.cancelled) {
+		return;
+	    }
+
+	    view.onReject();
+	},
+
+	cancel: function() {
+	    this.getView().close();
+	},
+
+	loginTotp: function() {
+	    let me = this;
+
+	    let code = me.lookup('totp').getValue();
+	    let _promise = me.finishChallenge(`totp:${code}`);
+	},
+
+	loginYubico: function() {
+	    let me = this;
+
+	    let code = me.lookup('yubico').getValue();
+	    let _promise = me.finishChallenge(`yubico:${code}`);
+	},
+
+	loginWebauthn: async function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    me.lookup('webAuthnWaiting').setVisible(true);
+	    me.lookup('webAuthnError').setVisible(false);
+
+	    let challenge = view.challenge.webauthn;
+
+	    if (typeof challenge.string !== 'string') {
+		// Byte array fixup, keep challenge string:
+		challenge.string = challenge.publicKey.challenge;
+		challenge.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge.string);
+		for (const cred of challenge.publicKey.allowCredentials) {
+		    cred.id = Proxmox.Utils.base64url_to_bytes(cred.id);
+		}
+	    }
+
+	    let controller = new AbortController();
+	    challenge.signal = controller.signal;
+
+	    let hwrsp;
+	    try {
+		//Promise.race( ...
+		hwrsp = await navigator.credentials.get(challenge);
+	    } catch (error) {
+		// we do NOT want to fail login because of canceling the challenge actively,
+		// in some browser that's the only way to switch over to another method as the
+		// disallow user input during the time the challenge is active
+		// checking for error.code === DOMException.ABORT_ERR only works in firefox -.-
+		this.getViewModel().set('canConfirm', true);
+		// FIXME: better handling, show some message, ...?
+		me.lookup('webAuthnError').setData({
+		    error: Ext.htmlEncode(error.toString()),
+		});
+		me.lookup('webAuthnError').setVisible(true);
+		return;
+	    } finally {
+		let waitingMessage = me.lookup('webAuthnWaiting');
+		if (waitingMessage) {
+		    waitingMessage.setVisible(false);
+		}
+	    }
+
+	    let response = {
+		id: hwrsp.id,
+		type: hwrsp.type,
+		challenge: challenge.string,
+		rawId: Proxmox.Utils.bytes_to_base64url(hwrsp.rawId),
+		response: {
+		    authenticatorData: Proxmox.Utils.bytes_to_base64url(
+			hwrsp.response.authenticatorData,
+		    ),
+		    clientDataJSON: Proxmox.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON),
+		    signature: Proxmox.Utils.bytes_to_base64url(hwrsp.response.signature),
+		},
+	    };
+
+	    await me.finishChallenge("webauthn:" + JSON.stringify(response));
+	},
+
+	loginU2F: async function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    me.lookup('u2fWaiting').setVisible(true);
+	    me.lookup('u2fError').setVisible(false);
+
+	    let hwrsp;
+	    try {
+		hwrsp = await new Promise((resolve, reject) => {
+		    try {
+			let data = view.challenge.u2f;
+			let chlg = data.challenge;
+			u2f.sign(chlg.appId, chlg.challenge, data.keys, resolve);
+		    } catch (error) {
+			reject(error);
+		    }
+		});
+		if (hwrsp.errorCode) {
+		    throw Proxmox.Utils.render_u2f_error(hwrsp.errorCode);
+		}
+		delete hwrsp.errorCode;
+	    } catch (error) {
+		this.getViewModel().set('canConfirm', true);
+		me.lookup('u2fError').setData({
+		    error: Ext.htmlEncode(error.toString()),
+		});
+		me.lookup('u2fError').setVisible(true);
+		return;
+	    } finally {
+		let waitingMessage = me.lookup('u2fWaiting');
+		if (waitingMessage) {
+		    waitingMessage.setVisible(false);
+		}
+	    }
+
+	    await me.finishChallenge("u2f:" + JSON.stringify(hwrsp));
+	},
+
+	loginRecovery: function() {
+	    let me = this;
+
+	    let key = me.lookup('recoveryKey').getValue();
+	    let _promise = me.finishChallenge(`recovery:${key}`);
+	},
+
+	loginTFA: function() {
+	    let me = this;
+	    // avoid triggering more than once during challenge
+	    me.getViewModel().set('canConfirm', false);
+	    let view = me.getView();
+	    let tfaPanel = view.down('tabpanel').getActiveTab();
+	    me[tfaPanel.handler]();
+	},
+
+	finishChallenge: function(password) {
+	    let me = this;
+	    let view = me.getView();
+	    view.cancelled = false;
+
+	    let params = {
+		username: view.userid,
+		'tfa-challenge': view.ticket,
+		password,
+	    };
+
+	    let resolve = view.onResolve;
+	    let reject = view.onReject;
+	    view.close();
+
+	    return Proxmox.Async.api2({
+		url: '/api2/extjs/access/ticket',
+		method: 'POST',
+		params,
+	    })
+	    .then(resolve)
+	    .catch(reject);
+	},
+    },
+
+    listeners: {
+	close: 'onClose',
+    },
+
+    items: [{
+	xtype: 'tabpanel',
+	region: 'center',
+	layout: 'fit',
+	bodyPadding: 10,
+	items: [
+	    {
+		xtype: 'panel',
+		title: 'WebAuthn',
+		iconCls: 'fa fa-fw fa-shield',
+		confirmText: gettext('Start WebAuthn challenge'),
+		handler: 'loginWebauthn',
+		bind: {
+		    disabled: '{!availableChallenge.webauthn}',
+		},
+		items: [
+		    {
+			xtype: 'box',
+			html: gettext('Please insert your authentication device and press its button'),
+		    },
+		    {
+			xtype: 'box',
+			html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
+			reference: 'webAuthnWaiting',
+			hidden: true,
+		    },
+		    {
+			xtype: 'box',
+			data: {
+			    error: '',
+			},
+			tpl: '<i class="fa fa-warning warning"></i> {error}',
+			reference: 'webAuthnError',
+			hidden: true,
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: gettext('TOTP App'),
+		iconCls: 'fa fa-fw fa-clock-o',
+		handler: 'loginTotp',
+		bind: {
+		    disabled: '{!availableChallenge.totp}',
+		},
+		items: [
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Please enter your TOTP verification code'),
+			labelWidth: 300,
+			name: 'totp',
+			disabled: true,
+			reference: 'totp',
+			allowBlank: false,
+			regex: /^[0-9]{2,16}$/,
+			regexText: gettext('TOTP codes usually consist of six decimal digits'),
+			inputAttrTpl: 'autocomplete=one-time-code',
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: gettext('Recovery Key'),
+		iconCls: 'fa fa-fw fa-file-text-o',
+		handler: 'loginRecovery',
+		bind: {
+		    disabled: '{!availableChallenge.recovery}',
+		},
+		items: [
+		    {
+			xtype: 'box',
+			reference: 'cannotLogin',
+			hidden: true,
+			html: '<i class="fa fa-exclamation-triangle warning"></i>'
+			    + Ext.String.format(
+				gettext('No second factor left! Please contact an administrator!'),
+				4,
+			    ),
+		    },
+		    {
+			xtype: 'box',
+			reference: 'recoveryEmpty',
+			hidden: true,
+			html: '<i class="fa fa-exclamation-triangle warning"></i>'
+			    + Ext.String.format(
+				gettext('No more recovery keys left! Please generate a new set!'),
+				4,
+			    ),
+		    },
+		    {
+			xtype: 'box',
+			reference: 'recoveryLow',
+			hidden: true,
+			html: '<i class="fa fa-exclamation-triangle warning"></i>'
+			    + Ext.String.format(
+				gettext('Less than {0} recovery keys available. Please generate a new set after login!'),
+				4,
+			    ),
+		    },
+		    {
+			xtype: 'box',
+			reference: 'availableRecovery',
+			hidden: true,
+		    },
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Please enter one of your single-use recovery keys'),
+			labelWidth: 300,
+			name: 'recoveryKey',
+			disabled: true,
+			reference: 'recoveryKey',
+			allowBlank: false,
+			regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/,
+			regexText: gettext('Does not look like a valid recovery key'),
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: 'U2F',
+		iconCls: 'fa fa-fw fa-shield',
+		confirmText: gettext('Start U2F challenge'),
+		handler: 'loginU2F',
+		bind: {
+		    disabled: '{!availableChallenge.u2f}',
+		},
+		tabConfig: {
+		    bind: {
+			hidden: '{!availableChallenge.u2f}',
+		    },
+		},
+		items: [
+		    {
+			xtype: 'box',
+			html: gettext('Please insert your authentication device and press its button'),
+		    },
+		    {
+			xtype: 'box',
+			html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
+			reference: 'u2fWaiting',
+			hidden: true,
+		    },
+		    {
+			xtype: 'box',
+			data: {
+			    error: '',
+			},
+			tpl: '<i class="fa fa-warning warning"></i> {error}',
+			reference: 'u2fError',
+			hidden: true,
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: gettext('Yubico OTP'),
+		iconCls: 'fa fa-fw fa-yahoo',
+		handler: 'loginYubico',
+		bind: {
+		    disabled: '{!availableChallenge.yubico}',
+		},
+		tabConfig: {
+		    bind: {
+			hidden: '{!availableChallenge.yubico}',
+		    },
+		},
+		items: [
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Please enter your Yubico OTP code'),
+			labelWidth: 300,
+			name: 'yubico',
+			disabled: true,
+			reference: 'yubico',
+			allowBlank: false,
+			regex: /^[a-z0-9]{30,60}$/, // *should* be 44 but not sure if that's "fixed"
+			regexText: gettext('TOTP codes consist of six decimal digits'),
+		    },
+		],
+	    },
+	],
+    }],
+
+    buttons: [
+	{
+	    handler: 'loginTFA',
+	    reference: 'tfaButton',
+	    disabled: true,
+	    bind: {
+		text: '{confirmText}',
+		disabled: '{!canConfirm}',
+	    },
+	},
+    ],
+});
+Ext.define('Proxmox.window.AddTfaRecovery', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAddTfaRecovery',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+    isCreate: true,
+    isAdd: true,
+    subject: gettext('TFA recovery keys'),
+    width: 512,
+    method: 'POST',
+
+    fixedUser: false,
+
+    url: '/api2/extjs/access/tfa',
+    submitUrl: function(url, values) {
+	let userid = values.userid;
+	delete values.userid;
+	return `${url}/${userid}`;
+    },
+
+    apiCallDone: function(success, response) {
+	if (!success) {
+	    return;
+	}
+
+	let values = response
+	    .result
+	    .data
+	    .recovery
+	    .map((v, i) => `${i}: ${v}`)
+	    .join("\n");
+	Ext.create('Proxmox.window.TfaRecoveryShow', {
+	    autoShow: true,
+	    userid: this.getViewModel().get('userid'),
+	    values,
+	});
+    },
+
+    viewModel: {
+	data: {
+	    has_entry: false,
+	    userid: null,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	hasEntry: async function(userid) {
+	    let me = this;
+	    let view = me.getView();
+
+	    try {
+		await Proxmox.Async.api2({
+		    url: `${view.url}/${userid}/recovery`,
+		    method: 'GET',
+		});
+		return true;
+	    } catch (_response) {
+		return false;
+	    }
+	},
+
+	init: function(view) {
+	    this.onUseridChange(null, Proxmox.UserName);
+	},
+
+	onUseridChange: async function(field, userid) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    me.userid = userid;
+	    vm.set('userid', userid);
+
+	    let has_entry = await me.hasEntry(userid);
+	    vm.set('has_entry', has_entry);
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'userid',
+	    cbind: {
+		editable: (get) => !get('fixedUser'),
+		value: () => Proxmox.UserName,
+	    },
+	    fieldLabel: gettext('User'),
+	    editConfig: {
+		xtype: 'pmxUserSelector',
+		allowBlank: false,
+		validator: function(_value) {
+		    return !this.up('window').getViewModel().get('has_entry');
+		},
+	    },
+	    renderer: Ext.String.htmlEncode,
+	    listeners: {
+		change: 'onUseridChange',
+	    },
+	},
+	{
+	    xtype: 'hiddenfield',
+	    name: 'type',
+	    value: 'recovery',
+	},
+	{
+	    xtype: 'displayfield',
+	    bind: {
+		hidden: '{!has_entry}',
+	    },
+	    hidden: true,
+	    userCls: 'pmx-hint',
+	    value: gettext('User already has recovery keys.'),
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'password',
+	    reference: 'password',
+	    fieldLabel: gettext('Verify Password'),
+	    inputType: 'password',
+	    minLength: 5,
+	    allowBlank: false,
+	    validateBlank: true,
+	    cbind: {
+		hidden: () => Proxmox.UserName === 'root@pam',
+		disabled: () => Proxmox.UserName === 'root@pam',
+		emptyText: () =>
+		    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+	    },
+	},
+    ],
+});
+
+Ext.define('Proxmox.window.TfaRecoveryShow', {
+    extend: 'Ext.window.Window',
+    alias: ['widget.pmxTfaRecoveryShow'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 600,
+    modal: true,
+    resizable: false,
+    title: gettext('Recovery Keys'),
+    onEsc: Ext.emptyFn,
+
+    items: [
+	{
+	    xtype: 'form',
+	    layout: 'anchor',
+	    bodyPadding: 10,
+	    border: false,
+	    fieldDefaults: {
+		anchor: '100%',
+            },
+	    items: [
+		{
+		    xtype: 'textarea',
+		    editable: false,
+		    inputId: 'token-secret-value',
+		    cbind: {
+			value: '{values}',
+		    },
+		    fieldStyle: {
+			'fontFamily': 'monospace',
+		    },
+		    height: '160px',
+		},
+		{
+		    xtype: 'displayfield',
+		    border: false,
+		    padding: '5 0 0 0',
+		    userCls: 'pmx-hint',
+		    value: gettext('Please record recovery keys - they will only be displayed now'),
+		},
+	    ],
+	},
+    ],
+    buttons: [
+	{
+	    handler: function(b) {
+		document.getElementById('token-secret-value').select();
+		document.execCommand("copy");
+	    },
+	    iconCls: 'fa fa-clipboard',
+	    text: gettext('Copy Recovery Keys'),
+	},
+	{
+	    handler: function(b) {
+		let win = this.up('window');
+		win.paperkeys(win.values, win.userid);
+	    },
+	    iconCls: 'fa fa-print',
+	    text: gettext('Print Recovery Keys'),
+	},
+    ],
+    paperkeys: function(keyString, userid) {
+	let me = this;
+
+	let printFrame = document.createElement("iframe");
+	Object.assign(printFrame.style, {
+	    position: "fixed",
+	    right: "0",
+	    bottom: "0",
+	    width: "0",
+	    height: "0",
+	    border: "0",
+	});
+	const host = document.location.host;
+	const title = document.title;
+	const html = `<html><head><script>
+	    window.addEventListener('DOMContentLoaded', (ev) => window.print());
+	</script><style>@media print and (max-height: 150mm) {
+	  h4, p { margin: 0; font-size: 1em; }
+	}</style></head><body style="padding: 5px;">
+	<h4>Recovery Keys for '${userid}' - ${title} (${host})</h4>
+<p style="font-size:1.5em;line-height:1.5em;font-family:monospace;
+   white-space:pre-wrap;overflow-wrap:break-word;">
+${keyString}
+</p>
+	</body></html>`;
+
+	printFrame.src = "data:text/html;base64," + btoa(html);
+	document.body.appendChild(printFrame);
+	me.on('destroy', () => document.body.removeChild(printFrame));
+    },
+});
+/*global QRCode*/
+Ext.define('Proxmox.window.AddTotp', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAddTotp',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext('Add a TOTP login factor'),
+    width: 512,
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    isAdd: true,
+    userid: undefined,
+    tfa_id: undefined,
+    issuerName: `Proxmox - ${document?.location?.hostname || 'unknown'}`,
+    fixedUser: false,
+
+    updateQrCode: function() {
+	let me = this;
+	let values = me.lookup('totp_form').getValues();
+	let algorithm = values.algorithm;
+	if (!algorithm) {
+	    algorithm = 'SHA1';
+	}
+
+	let otpuri =
+	    'otpauth://totp/' +
+	    encodeURIComponent(values.issuer) +
+	    ':' +
+	    encodeURIComponent(values.userid) +
+	    '?secret=' + values.secret +
+	    '&period=' + values.step +
+	    '&digits=' + values.digits +
+	    '&algorithm=' + algorithm +
+	    '&issuer=' + encodeURIComponent(values.issuer);
+
+	me.getController().getViewModel().set('otpuri', otpuri);
+	me.qrcode.makeCode(otpuri);
+	me.lookup('challenge').setVisible(true);
+	me.down('#qrbox').setVisible(true);
+    },
+
+    viewModel: {
+	data: {
+	    valid: false,
+	    secret: '',
+	    otpuri: '',
+	    userid: null,
+	},
+
+	formulas: {
+	    secretEmpty: function(get) {
+		return get('secret').length === 0;
+	    },
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'field[qrupdate=true]': {
+		change: function() {
+		    this.getView().updateQrCode();
+		},
+	    },
+	    'field': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    let viewModel = me.getViewModel();
+		    let form = me.lookup('totp_form');
+		    let challenge = me.lookup('challenge');
+		    let password = me.lookup('password');
+		    viewModel.set('valid', form.isValid() && challenge.isValid() && password.isValid());
+		},
+	    },
+	    '#': {
+		show: function() {
+		    let me = this;
+		    let view = me.getView();
+
+		    view.qrdiv = document.createElement('div');
+		    view.qrcode = new QRCode(view.qrdiv, {
+			width: 256,
+			height: 256,
+			correctLevel: QRCode.CorrectLevel.M,
+		    });
+		    view.down('#qrbox').getEl().appendChild(view.qrdiv);
+
+		    view.getController().randomizeSecret();
+		},
+	    },
+	},
+
+	randomizeSecret: function() {
+	    let me = this;
+	    let rnd = new Uint8Array(32);
+	    window.crypto.getRandomValues(rnd);
+	    let data = '';
+	    rnd.forEach(function(b) {
+		// secret must be base32, so just use the first 5 bits
+		b = b & 0x1f;
+		if (b < 26) {
+		    // A..Z
+		    data += String.fromCharCode(b + 0x41);
+		} else {
+		    // 2..7
+		    data += String.fromCharCode(b-26 + 0x32);
+		}
+	    });
+	    me.getViewModel().set('secret', data);
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    layout: 'anchor',
+	    border: false,
+	    reference: 'totp_form',
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'userid',
+		    cbind: {
+			editable: (get) => get('isAdd') && !get('fixedUser'),
+			value: () => Proxmox.UserName,
+		    },
+		    fieldLabel: gettext('User'),
+		    editConfig: {
+			xtype: 'pmxUserSelector',
+			allowBlank: false,
+		    },
+		    renderer: Ext.String.htmlEncode,
+		    listeners: {
+			change: function(field, newValue, oldValue) {
+			    let vm = this.up('window').getViewModel();
+			    vm.set('userid', newValue);
+			},
+		    },
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Description'),
+		    emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+		    allowBlank: false,
+		    name: 'description',
+		    maxLength: 256,
+		},
+		{
+		    layout: 'hbox',
+		    border: false,
+		    padding: '0 0 5 0',
+		    items: [
+			{
+			    xtype: 'textfield',
+			    fieldLabel: gettext('Secret'),
+			    emptyText: gettext('Unchanged'),
+			    name: 'secret',
+			    reference: 'tfa_secret',
+			    regex: /^[A-Z2-7=]+$/,
+			    regexText: 'Must be base32 [A-Z2-7=]',
+			    maskRe: /[A-Z2-7=]/,
+			    qrupdate: true,
+			    bind: {
+				value: "{secret}",
+			    },
+			    flex: 4,
+			    padding: '0 5 0 0',
+			},
+			{
+			    xtype: 'button',
+			    text: gettext('Randomize'),
+			    reference: 'randomize_button',
+			    handler: 'randomizeSecret',
+			    flex: 1,
+			},
+		    ],
+		},
+		{
+		    xtype: 'numberfield',
+		    fieldLabel: gettext('Time period'),
+		    name: 'step',
+		    // Google Authenticator ignores this and generates bogus data
+		    hidden: true,
+		    value: 30,
+		    minValue: 10,
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'numberfield',
+		    fieldLabel: gettext('Digits'),
+		    name: 'digits',
+		    value: 6,
+		    // Google Authenticator ignores this and generates bogus data
+		    hidden: true,
+		    minValue: 6,
+		    maxValue: 8,
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Issuer Name'),
+		    name: 'issuer',
+		    cbind: {
+			value: '{issuerName}',
+		    },
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'box',
+		    itemId: 'qrbox',
+		    visible: false, // will be enabled when generating a qr code
+		    bind: {
+			visible: '{!secretEmpty}',
+		    },
+		    style: {
+			margin: '16px auto',
+			padding: '16px',
+			width: '288px',
+			height: '288px',
+			'background-color': 'white',
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Verify Code'),
+		    allowBlank: false,
+		    reference: 'challenge',
+		    name: 'challenge',
+		    bind: {
+			disabled: '{!showTOTPVerifiction}',
+			visible: '{showTOTPVerifiction}',
+		    },
+		    emptyText: gettext('Scan QR code in a TOTP app and enter an auth. code here'),
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'password',
+		    reference: 'password',
+		    fieldLabel: gettext('Verify Password'),
+		    inputType: 'password',
+		    minLength: 5,
+		    allowBlank: false,
+		    validateBlank: true,
+		    cbind: {
+			hidden: () => Proxmox.UserName === 'root@pam',
+			disabled: () => Proxmox.UserName === 'root@pam',
+			emptyText: () =>
+			    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+		    },
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+	me.url = '/api2/extjs/access/tfa/';
+	me.method = 'POST';
+	me.callParent();
+    },
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+	let viewmodel = me.getController().getViewModel();
+
+	let values = me.callParent(arguments);
+
+	let uid = encodeURIComponent(values.userid);
+	me.url = `/api2/extjs/access/tfa/${uid}`;
+	delete values.userid;
+
+	let data = {
+	    description: values.description,
+	    type: "totp",
+	    totp: viewmodel.get('otpuri'),
+	    value: values.challenge,
+	};
+
+	if (values.password) {
+	    data.password = values.password;
+	}
+
+	return data;
+    },
+});
+Ext.define('Proxmox.window.AddWebauthn', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pmxAddWebauthn',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext('Add a Webauthn login token'),
+    width: 512,
+
+    user: undefined,
+    fixedUser: false,
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	Ext.GlobalEvents.fireEvent('proxmoxShowHelp', me.onlineHelp);
+    },
+
+    viewModel: {
+	data: {
+	    valid: false,
+	    userid: null,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'field': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    let viewmodel = me.getViewModel();
+		    let form = me.lookup('webauthn_form');
+		    viewmodel.set('valid', form.isValid());
+		},
+	    },
+	    '#': {
+		show: function() {
+		    let me = this;
+		    let view = me.getView();
+
+		    if (Proxmox.UserName === 'root@pam') {
+			view.lookup('password').setVisible(false);
+			view.lookup('password').setDisabled(true);
+		    }
+		},
+	    },
+	},
+
+	registerWebauthn: async function() {
+	    let me = this;
+	    let values = me.lookup('webauthn_form').getValues();
+	    values.type = "webauthn";
+
+	    let userid = values.user;
+	    delete values.user;
+
+	    me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
+
+	    try {
+		let register_response = await Proxmox.Async.api2({
+		    url: `/api2/extjs/access/tfa/${userid}`,
+		    method: 'POST',
+		    params: values,
+		});
+
+		let data = register_response.result.data;
+		if (!data.challenge) {
+		    throw "server did not respond with a challenge";
+		}
+
+		let creds = JSON.parse(data.challenge);
+
+		// Fix this up before passing it to the browser, but keep a copy of the original
+		// string to pass in the response:
+		let challenge_str = creds.publicKey.challenge;
+		creds.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge_str);
+		creds.publicKey.user.id =
+		    Proxmox.Utils.base64url_to_bytes(creds.publicKey.user.id);
+
+		// convert existing authenticators structure
+		creds.publicKey.excludeCredentials =
+		    (creds.publicKey.excludeCredentials || [])
+		    .map((credential) => ({
+			id: Proxmox.Utils.base64url_to_bytes(credential.id),
+			type: credential.type,
+		    }));
+
+		let msg = Ext.Msg.show({
+		    title: `Webauthn: ${gettext('Setup')}`,
+		    message: gettext('Please press the button on your Webauthn Device'),
+		    buttons: [],
+		});
+
+		let token_response;
+		try {
+		    token_response = await navigator.credentials.create(creds);
+		} catch (error) {
+		    let errmsg = error.message;
+		    if (error.name === 'InvalidStateError') {
+			errmsg = gettext('Is this token already registered?');
+		    }
+		    throw gettext('An error occurred during token registration.') +
+			`<br>${error.name}: ${errmsg}`;
+		}
+
+		// We cannot pass ArrayBuffers to the API, so extract & convert the data.
+		let response = {
+		    id: token_response.id,
+		    type: token_response.type,
+		    rawId: Proxmox.Utils.bytes_to_base64url(token_response.rawId),
+		    response: {
+			attestationObject: Proxmox.Utils.bytes_to_base64url(
+			    token_response.response.attestationObject,
+			),
+			clientDataJSON: Proxmox.Utils.bytes_to_base64url(
+			    token_response.response.clientDataJSON,
+			),
+		    },
+		};
+
+		msg.close();
+
+		let params = {
+		    type: "webauthn",
+		    challenge: challenge_str,
+		    value: JSON.stringify(response),
+		};
+
+		if (values.password) {
+		    params.password = values.password;
+		}
+
+		await Proxmox.Async.api2({
+		    url: `/api2/extjs/access/tfa/${userid}`,
+		    method: 'POST',
+		    params,
+		});
+	    } catch (response) {
+		let error = response;
+		console.error(error); // for debugging if it's not displayable...
+		if (typeof error === "object") {
+		    // in case it came from an api request:
+		    error = error.result?.message;
+		}
+
+		Ext.Msg.alert(gettext('Error'), error);
+	    }
+
+	    me.getView().close();
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'webauthn_form',
+	    layout: 'anchor',
+	    border: false,
+	    bodyPadding: 10,
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'user',
+		    cbind: {
+			editable: (get) => !get('fixedUser'),
+			value: () => Proxmox.UserName,
+		    },
+		    fieldLabel: gettext('User'),
+		    editConfig: {
+			xtype: 'pmxUserSelector',
+			allowBlank: false,
+		    },
+		    renderer: Ext.String.htmlEncode,
+		    listeners: {
+			change: function(field, newValue, oldValue) {
+			    let vm = this.up('window').getViewModel();
+			    vm.set('userid', newValue);
+			},
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Description'),
+		    allowBlank: false,
+		    name: 'description',
+		    maxLength: 256,
+		    emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'password',
+		    reference: 'password',
+		    fieldLabel: gettext('Verify Password'),
+		    inputType: 'password',
+		    minLength: 5,
+		    allowBlank: false,
+		    validateBlank: true,
+		    cbind: {
+			hidden: () => Proxmox.UserName === 'root@pam',
+			disabled: () => Proxmox.UserName === 'root@pam',
+			emptyText: () =>
+			    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+		    },
+		},
+	    ],
+	},
+    ],
+
+    buttons: [
+	{
+	    xtype: 'proxmoxHelpButton',
+	},
+	'->',
+	{
+	    xtype: 'button',
+	    text: gettext('Register Webauthn Device'),
+	    handler: 'registerWebauthn',
+	    bind: {
+		disabled: '{!valid}',
+	    },
+	},
+    ],
+});
+Ext.define('Proxmox.window.AddYubico', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAddYubico',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext('Add a Yubico OTP key'),
+    width: 512,
+
+    isAdd: true,
+    userid: undefined,
+    fixedUser: false,
+
+    initComponent: function() {
+	let me = this;
+	me.url = '/api2/extjs/access/tfa/';
+	me.method = 'POST';
+	me.callParent();
+    },
+
+    viewModel: {
+	data: {
+	    valid: false,
+	    userid: null,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'field': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    let viewmodel = me.getViewModel();
+		    let form = me.lookup('yubico_form');
+		    viewmodel.set('valid', form.isValid());
+		},
+	    },
+	    '#': {
+		show: function() {
+		    let me = this;
+		    let view = me.getView();
+
+		    if (Proxmox.UserName === 'root@pam') {
+			view.lookup('password').setVisible(false);
+			view.lookup('password').setDisabled(true);
+		    }
+		},
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'yubico_form',
+	    layout: 'anchor',
+	    border: false,
+	    bodyPadding: 10,
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'userid',
+		    cbind: {
+			editable: (get) => !get('fixedUser'),
+			value: () => Proxmox.UserName,
+		    },
+		    fieldLabel: gettext('User'),
+		    editConfig: {
+			xtype: 'pmxUserSelector',
+			allowBlank: false,
+		    },
+		    renderer: Ext.String.htmlEncode,
+		    listeners: {
+			change: function(field, newValue, oldValue) {
+			    let vm = this.up('window').getViewModel();
+			    vm.set('userid', newValue);
+			},
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Description'),
+		    allowBlank: false,
+		    name: 'description',
+		    maxLength: 256,
+		    emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Yubico OTP Key'),
+		    emptyText: gettext('A currently valid Yubico OTP value'),
+		    name: 'otp_value',
+		    maxLength: 44,
+		    enforceMaxLength: true,
+		    regex: /^[a-zA-Z0-9]{44}$/,
+		    regexText: '44 characters',
+		    maskRe: /^[a-zA-Z0-9]$/,
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'password',
+		    reference: 'password',
+		    fieldLabel: gettext('Verify Password'),
+		    inputType: 'password',
+		    minLength: 5,
+		    allowBlank: false,
+		    validateBlank: true,
+		    cbind: {
+			hidden: () => Proxmox.UserName === 'root@pam',
+			disabled: () => Proxmox.UserName === 'root@pam',
+			emptyText: () =>
+			    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+		    },
+		},
+		{
+		    xtype: 'box',
+		    html: `<span class='pmx-hint'>${gettext('Tip:')}</span> `
+			+ gettext('YubiKeys also support WebAuthn, which is often a better alternative.'),
+		},
+	    ],
+	},
+    ],
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+
+	let values = me.callParent(arguments);
+
+	let uid = encodeURIComponent(values.userid);
+	me.url = `/api2/extjs/access/tfa/${uid}`;
+	delete values.userid;
+
+	let data = {
+	    description: values.description,
+	    type: "yubico",
+	    value: values.otp_value,
+	};
+
+	if (values.password) {
+	    data.password = values.password;
+	}
+
+	return data;
+    },
+});
+Ext.define('Proxmox.window.TfaEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxTfaEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext("Modify a TFA entry's description"),
+    width: 512,
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    cbindData: function(initialConfig) {
+	let me = this;
+
+	let tfa_id = initialConfig['tfa-id'];
+	me.tfa_id = tfa_id;
+	me.defaultFocus = 'textfield[name=description]';
+	me.url = `/api2/extjs/access/tfa/${tfa_id}`;
+	me.method = 'PUT';
+	me.autoLoad = true;
+	return {};
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+
+	if (Proxmox.UserName === 'root@pam') {
+	    me.lookup('password').setVisible(false);
+	    me.lookup('password').setDisabled(true);
+	}
+
+	let userid = me.tfa_id.split('/')[0];
+	me.lookup('userid').setValue(userid);
+    },
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    reference: 'userid',
+	    editable: false,
+	    fieldLabel: gettext('User'),
+	    editConfig: {
+		xtype: 'pmxUserSelector',
+		allowBlank: false,
+	    },
+	    cbind: {
+		value: () => Proxmox.UserName,
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'description',
+	    allowBlank: false,
+	    fieldLabel: gettext('Description'),
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Enabled'),
+	    name: 'enable',
+	    uncheckedValue: 0,
+	    defaultValue: 1,
+	    checked: true,
+	},
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Password'),
+	    minLength: 5,
+	    reference: 'password',
+	    name: 'password',
+	    allowBlank: false,
+	    validateBlank: true,
+	    emptyText: gettext('verify current password'),
+	},
+    ],
+
+    getValues: function() {
+	var me = this;
+
+	var values = me.callParent(arguments);
+
+	delete values.userid;
+
+	return values;
+    },
+});
+
+Ext.define('Proxmox.tfa.confirmRemove', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext("Confirm TFA Removal"),
+
+    modal: true,
+    resizable: false,
+    width: 600,
+    isCreate: true, // logic
+    isRemove: true,
+
+    url: '/access/tfa',
+
+    initComponent: function() {
+	let me = this;
+
+	if (typeof me.type !== "string") {
+	    throw "missing type";
+	}
+
+	if (!me.callback) {
+	    throw "missing callback";
+	}
+
+	me.callParent();
+
+	if (Proxmox.UserName === 'root@pam') {
+	    me.lookup('password').setVisible(false);
+	    me.lookup('password').setDisabled(true);
+	}
+    },
+
+    submit: function() {
+	let me = this;
+	if (Proxmox.UserName === 'root@pam') {
+	    me.callback(null);
+	} else {
+	    me.callback(me.lookup('password').getValue());
+	}
+	me.close();
+    },
+
+    items: [
+	{
+	    xtype: 'box',
+	    padding: '0 0 10 0',
+	    html: Ext.String.format(
+	        gettext('Are you sure you want to remove this {0} entry?'),
+	        'TFA',
+	    ),
+	},
+	{
+	    xtype: 'container',
+	    layout: {
+		type: 'hbox',
+		align: 'begin',
+	    },
+	    defaults: {
+		border: false,
+		layout: 'anchor',
+		flex: 1,
+		padding: 5,
+	    },
+	    items: [
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+		    },
+		    padding: '0 10 0 0',
+		    items: [
+			{
+			    xtype: 'displayfield',
+			    fieldLabel: gettext('User'),
+			    cbind: {
+				value: '{userid}',
+			    },
+			},
+			{
+			    xtype: 'displayfield',
+			    fieldLabel: gettext('Type'),
+			    cbind: {
+				value: '{type}',
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+		    },
+		    padding: '0 0 0 10',
+		    items: [
+			{
+			    xtype: 'displayfield',
+			    fieldLabel: gettext('Created'),
+			    renderer: v => Proxmox.Utils.render_timestamp(v),
+			    cbind: {
+				value: '{created}',
+			    },
+			},
+			{
+			    xtype: 'textfield',
+			    fieldLabel: gettext('Description'),
+			    cbind: {
+				value: '{description}',
+			    },
+			    emptyText: Proxmox.Utils.NoneText,
+			    submitValue: false,
+			    editable: false,
+			},
+		    ],
+		},
+	    ],
+	},
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Password'),
+	    minLength: 5,
+	    reference: 'password',
+	    name: 'password',
+	    allowBlank: false,
+	    validateBlank: true,
+	    padding: '10 0 0 0',
+	    cbind: {
+		emptyText: () =>
+		    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+	    },
+	},
+    ],
+});
+Ext.define('Proxmox.window.NotesEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    title: gettext('Notes'),
+    onlineHelp: 'markdown_basics',
+
+    width: 800,
+    height: 600,
+
+    resizable: true,
+    layout: 'fit',
+
+    autoLoad: true,
+    defaultButton: undefined,
+
+    setMaxLength: function(maxLength) {
+	let me = this;
+
+	let area = me.down('textarea[name="description"]');
+	area.maxLength = maxLength;
+	area.validate();
+
+	return me;
+    },
+
+    items: {
+	xtype: 'textarea',
+	name: 'description',
+	height: '100%',
+	value: '',
+	hideLabel: true,
+	emptyText: gettext('You can use Markdown for rich text formatting.'),
+	fieldStyle: {
+	    'white-space': 'pre-wrap',
+	    'font-family': 'monospace',
+	},
+    },
+});
+Ext.define('Proxmox.window.ThemeEditWindow', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pmxThemeEditWindow',
+
+    viewModel: {
+	parent: null,
+	data: {},
+    },
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	init: function(view) {
+	    let theme = '__default__';
+
+	    let savedTheme = Ext.util.Cookies.get(view.cookieName);
+	    if (savedTheme && savedTheme in Proxmox.Utils.theme_map) {
+		theme = savedTheme;
+	    }
+	    this.getViewModel().set('theme', theme);
+	},
+	applyTheme: function(button) {
+	    let view = this.getView();
+	    let vm = this.getViewModel();
+
+	    let expire = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
+	    Ext.util.Cookies.set(view.cookieName, vm.get('theme'), expire);
+	    view.mask(gettext('Please wait...'), 'x-mask-loading');
+	    window.location.reload();
+	},
+    },
+
+    cookieName: 'PVEThemeCookie',
+
+    title: gettext('Color Theme'),
+    modal: true,
+    bodyPadding: 10,
+    resizable: false,
+    items: [
+	{
+	    xtype: 'proxmoxThemeSelector',
+	    fieldLabel: gettext('Color Theme'),
+	    bind: {
+		value: '{theme}',
+	    },
+	},
+    ],
+    buttons: [
+	{
+	    text: gettext('Apply'),
+	    handler: 'applyTheme',
+	},
+    ],
+});
+Ext.define('Proxmox.window.SyncWindow', {
+    extend: 'Ext.window.Window',
+
+    title: gettext('Realm Sync'),
+
+    width: 600,
+    bodyPadding: 10,
+    modal: true,
+    resizable: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'form': {
+		validitychange: function(field, valid) {
+		    this.lookup('preview_btn').setDisabled(!valid);
+		    this.lookup('sync_btn').setDisabled(!valid);
+		},
+	    },
+	    'button': {
+		click: function(btn) {
+		    this.sync_realm(btn.reference === 'preview_btn');
+		},
+	    },
+	},
+
+	sync_realm: function(is_preview) {
+	    let view = this.getView();
+	    let ipanel = this.lookup('ipanel');
+	    let params = ipanel.getValues();
+
+	    let vanished_opts = [];
+	    ['acl', 'entry', 'properties'].forEach((prop) => {
+		if (params[`remove-vanished-${prop}`]) {
+		    vanished_opts.push(prop);
+		}
+		delete params[`remove-vanished-${prop}`];
+	    });
+	    if (vanished_opts.length > 0) {
+		params['remove-vanished'] = vanished_opts.join(';');
+	    }
+
+	    params['dry-run'] = is_preview ? 1 : 0;
+	    Proxmox.Utils.API2Request({
+		url: `/access/domains/${view.realm}/sync`,
+		waitMsgTarget: view,
+		method: 'POST',
+		params,
+		failure: (response) => {
+		    view.show();
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: (response) => {
+		    view.hide();
+		    Ext.create('Proxmox.window.TaskViewer', {
+			upid: response.result.data,
+			listeners: {
+			    destroy: () => {
+				if (is_preview) {
+				    view.show();
+				} else {
+				    view.close();
+				}
+			    },
+			},
+		    }).show();
+		},
+	    });
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'form',
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+	    },
+	    items: [{
+		xtype: 'inputpanel',
+		reference: 'ipanel',
+		column1: [
+		    {
+			xtype: 'proxmoxKVComboBox',
+			value: 'true',
+			deleteEmpty: false,
+			allowBlank: false,
+			comboItems: [
+			    ['true', Proxmox.Utils.yesText],
+			    ['false', Proxmox.Utils.noText],
+			],
+			name: 'enable-new',
+			fieldLabel: gettext('Enable new'),
+		    },
+		],
+
+		column2: [
+		],
+
+		columnB: [
+		    {
+			xtype: 'fieldset',
+			title: gettext('Remove Vanished Options'),
+			items: [
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('ACL'),
+				name: 'remove-vanished-acl',
+				boxLabel: gettext('Remove ACLs of vanished users and groups.'),
+			    },
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Entry'),
+				name: 'remove-vanished-entry',
+				boxLabel: gettext('Remove vanished user and group entries.'),
+			    },
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Properties'),
+				name: 'remove-vanished-properties',
+				boxLabel: gettext('Remove vanished properties from synced users.'),
+			    },
+			],
+		    },
+		    {
+			xtype: 'displayfield',
+			reference: 'defaulthint',
+			value: gettext('Default sync options can be set by editing the realm.'),
+			userCls: 'pmx-hint',
+			hidden: true,
+		    },
+		],
+	    }],
+	},
+    ],
+
+    buttons: [
+	'->',
+	{
+	    text: gettext('Preview'),
+	    reference: 'preview_btn',
+	},
+	{
+	    text: gettext('Sync'),
+	    reference: 'sync_btn',
+	},
+    ],
+
+    initComponent: function() {
+	if (!this.realm) {
+	    throw "no realm defined";
+	}
+
+	if (!this.type) {
+	    throw "no realm type defined";
+	}
+
+	this.callParent();
+
+	Proxmox.Utils.API2Request({
+	    url: `/config/access/${this.type}/${this.realm}`,
+	    waitMsgTarget: this,
+	    method: 'GET',
+	    failure: (response) => {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		this.close();
+	    },
+	    success: (response) => {
+		let default_options = response.result.data['sync-defaults-options'];
+		if (default_options) {
+		    let options = Proxmox.Utils.parsePropertyString(default_options);
+		    if (options['remove-vanished']) {
+			let opts = options['remove-vanished'].split(';');
+			for (const opt of opts) {
+			    options[`remove-vanished-${opt}`] = 1;
+			}
+		    }
+		    let ipanel = this.lookup('ipanel');
+		    ipanel.setValues(options);
+		} else {
+		    this.lookup('defaulthint').setVisible(true);
+		}
+
+		// check validity for button state
+		this.lookup('form').isValid();
+	    },
+	});
+    },
+});
+Ext.define('apt-pkglist', {
+    extend: 'Ext.data.Model',
+    fields: [
+        'Package', 'Title', 'Description', 'Section', 'Arch', 'Priority', 'Version', 'OldVersion',
+        'Origin',
+    ],
+    idProperty: 'Package',
+});
+
+Ext.define('Proxmox.node.APT', {
+    extend: 'Ext.grid.GridPanel',
+
+    xtype: 'proxmoxNodeAPT',
+
+    upgradeBtn: undefined,
+
+    columns: [
+	{
+	    header: gettext('Package'),
+	    width: 200,
+	    sortable: true,
+	    dataIndex: 'Package',
+	},
+	{
+	    text: gettext('Version'),
+	    columns: [
+		{
+		    header: gettext('current'),
+		    width: 100,
+		    sortable: false,
+		    dataIndex: 'OldVersion',
+		},
+		{
+		    header: gettext('new'),
+		    width: 100,
+		    sortable: false,
+		    dataIndex: 'Version',
+		},
+	    ],
+	},
+	{
+	    header: gettext('Description'),
+	    sortable: false,
+	    dataIndex: 'Title',
+	    flex: 1,
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    model: 'apt-pkglist',
+	    groupField: 'Origin',
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${me.nodename}/apt/update`,
+	    },
+	    sorters: [
+		{
+		    property: 'Package',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+	Proxmox.Utils.monStoreErrors(me, store, true);
+
+	let groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
+            groupHeaderTpl: '{[ "Origin: " + values.name ]} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})',
+	    enableGroupingMenu: false,
+	});
+
+	let rowBodyFeature = Ext.create('Ext.grid.feature.RowBody', {
+            getAdditionalData: function(data, rowIndex, record, orig) {
+		let headerCt = this.view.headerCt;
+		let colspan = headerCt.getColumnCount();
+		return {
+		    rowBody: `<div style="padding: 1em">${Ext.htmlEncode(data.Description)}</div>`,
+		    rowBodyCls: me.full_description ? '' : Ext.baseCSSPrefix + 'grid-row-body-hidden',
+		    rowBodyColspan: colspan,
+		};
+	    },
+	});
+
+	let apt_command = function(cmd) {
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${me.nodename}/apt/${cmd}`,
+		method: 'POST',
+		success: ({ result }) => Ext.create('Proxmox.window.TaskViewer', {
+		    autoShow: true,
+		    upid: result.data,
+		    listeners: {
+			close: () => store.load(),
+		    },
+		}),
+	    });
+	};
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let update_btn = new Ext.Button({
+	    text: gettext('Refresh'),
+	    handler: () => Proxmox.Utils.checked_command(function() { apt_command('update'); }),
+	});
+
+	let show_changelog = function(rec) {
+	    if (!rec?.data?.Package) {
+		console.debug('cannot show changelog, missing Package', rec);
+		return;
+	    }
+
+	    let view = Ext.createWidget('component', {
+		autoScroll: true,
+		style: {
+		    'white-space': 'pre',
+		    'font-family': 'monospace',
+		    padding: '5px',
+		},
+	    });
+
+	    let win = Ext.create('Ext.window.Window', {
+		title: gettext('Changelog') + ": " + rec.data.Package,
+		width: 800,
+		height: 600,
+		layout: 'fit',
+		modal: true,
+		items: [view],
+	    });
+
+	    Proxmox.Utils.API2Request({
+		waitMsgTarget: me,
+		url: "/nodes/" + me.nodename + "/apt/changelog",
+		params: {
+		    name: rec.data.Package,
+		    version: rec.data.Version,
+		},
+		method: 'GET',
+		failure: function(response, opts) {
+		    win.close();
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: function(response, opts) {
+		    win.show();
+		    view.update(Ext.htmlEncode(response.result.data));
+		},
+	    });
+	};
+
+	let changelog_btn = new Proxmox.button.Button({
+	    text: gettext('Changelog'),
+	    selModel: sm,
+	    disabled: true,
+	    enableFn: rec => !!rec?.data?.Package,
+	    handler: (b, e, rec) => show_changelog(rec),
+	});
+
+	let verbose_desc_checkbox = new Ext.form.field.Checkbox({
+	    boxLabel: gettext('Show details'),
+	    value: false,
+	    listeners: {
+		change: (f, val) => {
+		    me.full_description = val;
+		    me.getView().refresh();
+		},
+	    },
+	});
+
+	if (me.upgradeBtn) {
+	    me.tbar = [update_btn, me.upgradeBtn, changelog_btn, '->', verbose_desc_checkbox];
+	} else {
+	    me.tbar = [update_btn, changelog_btn, '->', verbose_desc_checkbox];
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: true,
+	    stateId: 'grid-update',
+	    selModel: sm,
+            viewConfig: {
+		stripeRows: false,
+		emptyText: `<div style="display:flex;justify-content:center;"><p>${gettext('No updates available.')}</p></div>`,
+	    },
+	    features: [groupingFeature, rowBodyFeature],
+	    listeners: {
+		activate: () => store.load(),
+		itemdblclick: (v, rec) => show_changelog(rec),
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('apt-repolist', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'Path',
+	'Index',
+	'Origin',
+	'FileType',
+	'Enabled',
+	'Comment',
+	'Types',
+	'URIs',
+	'Suites',
+	'Components',
+	'Options',
+    ],
+});
+
+Ext.define('Proxmox.window.APTRepositoryAdd', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAPTRepositoryAdd',
+
+    isCreate: true,
+    isAdd: true,
+
+    subject: gettext('Repository'),
+    width: 600,
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.repoInfo || me.repoInfo.length === 0) {
+	    throw "repository information not initialized";
+	}
+
+	let description = Ext.create('Ext.form.field.Display', {
+	    fieldLabel: gettext('Description'),
+	    name: 'description',
+	});
+
+	let status = Ext.create('Ext.form.field.Display', {
+	    fieldLabel: gettext('Status'),
+	    name: 'status',
+	    renderer: function(value) {
+		let statusText = gettext('Not yet configured');
+		if (value !== '') {
+		    statusText = Ext.String.format(
+			'{0}: {1}',
+			gettext('Configured'),
+			value ? gettext('enabled') : gettext('disabled'),
+		    );
+		}
+
+		return statusText;
+	    },
+	});
+
+	let repoSelector = Ext.create('Proxmox.form.KVComboBox', {
+	    fieldLabel: gettext('Repository'),
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'handle',
+	    allowBlank: false,
+	    comboItems: me.repoInfo.map(info => [info.handle, info.name]),
+	    validator: function(renderedValue) {
+		let handle = this.value;
+		// we cannot use this.callParent in instantiations
+		let valid = Proxmox.form.KVComboBox.prototype.validator.call(this, renderedValue);
+
+		if (!valid || !handle) {
+		    return false;
+		}
+
+		const info = me.repoInfo.find(elem => elem.handle === handle);
+		if (!info) {
+		    return false;
+		}
+
+		if (info.status) {
+		    return Ext.String.format(gettext('{0} is already configured'), renderedValue);
+		}
+		return valid;
+	    },
+	    listeners: {
+		change: function(f, value) {
+		    const info = me.repoInfo.find(elem => elem.handle === value);
+		    description.setValue(info.description);
+		    status.setValue(info.status);
+		},
+	    },
+	});
+
+	repoSelector.setValue(me.repoInfo[0].handle);
+
+	Ext.apply(me, {
+	    items: [
+		repoSelector,
+		description,
+		status,
+	    ],
+	    repoSelector: repoSelector,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('Proxmox.node.APTRepositoriesErrors', {
+    extend: 'Ext.grid.GridPanel',
+
+    xtype: 'proxmoxNodeAPTRepositoriesErrors',
+
+    store: {},
+
+    scrollable: true,
+
+    viewConfig: {
+	stripeRows: false,
+	getRowClass: (record) => {
+	    switch (record.data.status) {
+		case 'warning': return 'proxmox-warning-row';
+		case 'critical': return 'proxmox-invalid-row';
+		default: return '';
+	    }
+	},
+    },
+
+    hideHeaders: true,
+
+    columns: [
+	{
+	    dataIndex: 'status',
+	    renderer: (value) => `<i class="fa fa-fw ${Proxmox.Utils.get_health_icon(value, true)}"></i>`,
+	    width: 50,
+	},
+	{
+	    dataIndex: 'message',
+	    flex: 1,
+	},
+    ],
+});
+
+Ext.define('Proxmox.node.APTRepositoriesGrid', {
+    extend: 'Ext.grid.GridPanel',
+    xtype: 'proxmoxNodeAPTRepositoriesGrid',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext('APT Repositories'),
+
+    cls: 'proxmox-apt-repos', // to allow applying styling to general components with local effect
+
+    border: false,
+
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: function() {
+		let me = this;
+		me.up('proxmoxNodeAPTRepositories').reload();
+	    },
+	},
+	{
+	    text: gettext('Add'),
+	    name: 'addRepo',
+	    disabled: true,
+	    repoInfo: undefined,
+	    cbind: {
+		onlineHelp: '{onlineHelp}',
+	    },
+	    handler: function(button, event, record) {
+		Proxmox.Utils.checked_command(() => {
+		    let me = this;
+		    let panel = me.up('proxmoxNodeAPTRepositories');
+
+		    let extraParams = {};
+		    if (panel.digest !== undefined) {
+		       extraParams.digest = panel.digest;
+		    }
+
+		    Ext.create('Proxmox.window.APTRepositoryAdd', {
+			repoInfo: me.repoInfo,
+			url: `/api2/extjs/nodes/${panel.nodename}/apt/repositories`,
+			method: 'PUT',
+			extraRequestParams: extraParams,
+			onlineHelp: me.onlineHelp,
+			listeners: {
+			    destroy: function() {
+				panel.reload();
+			    },
+			},
+		    }).show();
+		});
+	    },
+	},
+	'-',
+	{
+	    xtype: 'proxmoxAltTextButton',
+	    defaultText: gettext('Enable'),
+	    altText: gettext('Disable'),
+	    name: 'repoEnable',
+	    disabled: true,
+	    bind: {
+		text: '{enableButtonText}',
+	    },
+	    handler: function(button, event, record) {
+		let me = this;
+		let panel = me.up('proxmoxNodeAPTRepositories');
+
+		let params = {
+		    path: record.data.Path,
+		    index: record.data.Index,
+		    enabled: record.data.Enabled ? 0 : 1, // invert
+		};
+
+		if (panel.digest !== undefined) {
+		   params.digest = panel.digest;
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${panel.nodename}/apt/repositories`,
+		    method: 'POST',
+		    params: params,
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			panel.reload();
+		    },
+		    success: function(response, opts) {
+			panel.reload();
+		    },
+		});
+	    },
+	},
+    ],
+
+    sortableColumns: false,
+    viewConfig: {
+	stripeRows: false,
+	getRowClass: (record, index) => record.get('Enabled') ? '' : 'proxmox-disabled-row',
+    },
+
+    columns: [
+	{
+	    header: gettext('Enabled'),
+	    dataIndex: 'Enabled',
+	    align: 'center',
+	    renderer: Proxmox.Utils.renderEnabledIcon,
+	    width: 90,
+	},
+	{
+	    header: gettext('Types'),
+	    dataIndex: 'Types',
+	    renderer: function(types, cell, record) {
+		return types.join(' ');
+	    },
+	    width: 100,
+	},
+	{
+	    header: gettext('URIs'),
+	    dataIndex: 'URIs',
+	    renderer: function(uris, cell, record) {
+		return uris.join(' ');
+	    },
+	    width: 350,
+	},
+	{
+	    header: gettext('Suites'),
+	    dataIndex: 'Suites',
+	    renderer: function(suites, metaData, record) {
+		let err = '';
+		if (record.data.warnings && record.data.warnings.length > 0) {
+		    let txt = [gettext('Warning')];
+		    record.data.warnings.forEach((warning) => {
+			if (warning.property === 'Suites') {
+			    txt.push(warning.message);
+			}
+		    });
+		    metaData.tdAttr = `data-qtip="${Ext.htmlEncode(txt.join('<br>'))}"`;
+		    if (record.data.Enabled) {
+			metaData.tdCls = 'proxmox-invalid-row';
+			err = '<i class="fa fa-fw critical fa-exclamation-circle"></i> ';
+		    } else {
+			metaData.tdCls = 'proxmox-warning-row';
+			err = '<i class="fa fa-fw warning fa-exclamation-circle"></i> ';
+		    }
+		}
+		return suites.join(' ') + err;
+	    },
+	    width: 130,
+	},
+	{
+	    header: gettext('Components'),
+	    dataIndex: 'Components',
+	    renderer: function(components, metaData, record) {
+		if (components === undefined) {
+		    return '';
+		}
+		let err = '';
+		if (components.length === 1) {
+		    // FIXME: this should be a flag set to the actual repsotiories, i.e., a tristate
+		    // like production-ready = <yes|no|other> (Option<bool>)
+		    if (components[0].match(/\w+(-no-subscription|test)\s*$/i)) {
+			metaData.tdCls = 'proxmox-warning-row';
+			err = '<i class="fa fa-fw warning fa-exclamation-circle"></i> ';
+
+			let qtip = components[0].match(/no-subscription/)
+			    ? gettext('The no-subscription repository is NOT production-ready')
+			    : gettext('The test repository may contain unstable updates')
+			    ;
+			    metaData.tdAttr = `data-qtip="${Ext.htmlEncode(qtip)}"`;
+		    }
+		}
+		return components.join(' ') + err;
+	    },
+	    width: 170,
+	},
+	{
+	    header: gettext('Options'),
+	    dataIndex: 'Options',
+	    renderer: function(options, cell, record) {
+		if (!options) {
+		    return '';
+		}
+
+		let filetype = record.data.FileType;
+		let text = '';
+
+		options.forEach(function(option) {
+		    let key = option.Key;
+		    if (filetype === 'list') {
+			let values = option.Values.join(',');
+			text += `${key}=${values} `;
+		    } else if (filetype === 'sources') {
+			let values = option.Values.join(' ');
+			text += `${key}: ${values}<br>`;
+		    } else {
+			throw "unknown file type";
+		    }
+		});
+		return text;
+	    },
+	    flex: 1,
+	},
+	{
+	    header: gettext('Origin'),
+	    dataIndex: 'Origin',
+	    width: 120,
+	    renderer: function(value, meta, rec) {
+		if (typeof value !== 'string' || value.length === 0) {
+		    value = gettext('Other');
+		}
+		let cls = 'fa fa-fw fa-question-circle-o';
+		let originType = this.up('proxmoxNodeAPTRepositories').classifyOrigin(value);
+		if (originType === 'Proxmox') {
+		    cls = 'pmx-itype-icon pmx-itype-icon-proxmox-x';
+		} else if (originType === 'Debian') {
+		    cls = 'pmx-itype-icon pmx-itype-icon-debian-swirl';
+		}
+		return `<i class='${cls}'></i> ${value}`;
+	    },
+	},
+	{
+	    header: gettext('Comment'),
+	    dataIndex: 'Comment',
+	    flex: 2,
+	    renderer: Ext.String.htmlEncode,
+	},
+    ],
+
+    features: [
+	{
+	    ftype: 'grouping',
+	    groupHeaderTpl: '{[ "File: " + values.name ]} ({rows.length} repositor{[values.rows.length > 1 ? "ies" : "y"]})',
+	    enableGroupingMenu: false,
+	},
+    ],
+
+    store: {
+	model: 'apt-repolist',
+	groupField: 'Path',
+	sorters: [
+	    {
+		property: 'Index',
+		direction: 'ASC',
+	    },
+	],
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('Proxmox.node.APTRepositories', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'proxmoxNodeAPTRepositories',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    digest: undefined,
+
+    onlineHelp: undefined,
+
+    product: 'Proxmox VE', // default
+
+    classifyOrigin: function(origin) {
+	origin ||= '';
+	if (origin.match(/^\s*Proxmox\s*$/i)) {
+	    return 'Proxmox';
+	} else if (origin.match(/^\s*Debian\s*(:?Backports)?$/i)) {
+	    return 'Debian';
+	}
+	return 'Other';
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	selectionChange: function(grid, selection) {
+	    let me = this;
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+	    let rec = selection[0];
+	    let vm = me.getViewModel();
+	    vm.set('selectionenabled', rec.get('Enabled'));
+	    vm.notify();
+	},
+
+	updateState: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    let store = vm.get('errorstore');
+	    store.removeAll();
+
+	    let status = 'good'; // start with best, the helper below will downgrade if needed
+	    let text = gettext('All OK, you have production-ready repositories configured!');
+
+	    let addGood = message => store.add({ status: 'good', message });
+	    let addWarn = (message, important) => {
+		if (status !== 'critical') {
+		    status = 'warning';
+		    text = important ? message : gettext('Warning');
+		}
+		store.add({ status: 'warning', message });
+	    };
+	    let addCritical = (message, important) => {
+		status = 'critical';
+		text = important ? message : gettext('Error');
+		store.add({ status: 'critical', message });
+	    };
+
+	    let errors = vm.get('errors');
+	    errors.forEach(error => addCritical(`${error.path} - ${error.error}`));
+
+	    let activeSubscription = vm.get('subscriptionActive');
+	    let enterprise = vm.get('enterpriseRepo');
+	    let nosubscription = vm.get('noSubscriptionRepo');
+	    let test = vm.get('testRepo');
+	    let cephRepos = {
+		enterprise: vm.get('cephEnterpriseRepo'),
+		nosubscription: vm.get('cephNoSubscriptionRepo'),
+		test: vm.get('cephTestRepo'),
+	    };
+	    let wrongSuites = vm.get('suitesWarning');
+	    let mixedSuites = vm.get('mixedSuites');
+
+	    if (!enterprise && !nosubscription && !test) {
+		addCritical(
+		    Ext.String.format(gettext('No {0} repository is enabled, you do not get any updates!'), vm.get('product')),
+		);
+	    } else if (errors.length > 0) {
+		// nothing extra, just avoid that we show "get updates"
+	    } else if (enterprise && !nosubscription && !test && activeSubscription) {
+		addGood(Ext.String.format(gettext('You get supported updates for {0}'), vm.get('product')));
+	    } else if (nosubscription || test) {
+		addGood(Ext.String.format(gettext('You get updates for {0}'), vm.get('product')));
+	    }
+
+	    if (wrongSuites) {
+		addWarn(gettext('Some suites are misconfigured'));
+	    }
+
+	    if (mixedSuites) {
+		addWarn(gettext('Detected mixed suites before upgrade'));
+	    }
+
+	    let productionReadyCheck = (repos, type, noSubAlternateName) => {
+		if (!activeSubscription && repos.enterprise) {
+		    addWarn(Ext.String.format(
+			gettext('The {0}enterprise repository is enabled, but there is no active subscription!'),
+			type,
+		    ));
+		}
+
+		if (repos.nosubscription) {
+		    addWarn(Ext.String.format(
+			gettext('The {0}no-subscription{1} repository is not recommended for production use!'),
+			type,
+			noSubAlternateName,
+		    ));
+		}
+
+		if (repos.test) {
+		    addWarn(Ext.String.format(
+			gettext('The {0}test repository may pull in unstable updates and is not recommended for production use!'),
+			type,
+		    ));
+		}
+	    };
+
+	    productionReadyCheck({ enterprise, nosubscription, test }, '', '');
+	    // TODO drop alternate 'main' name when no longer relevant
+	    productionReadyCheck(cephRepos, 'Ceph ', '/main');
+
+	    if (errors.length > 0) {
+		text = gettext('Fatal parsing error for at least one repository');
+	    }
+
+	    let iconCls = Proxmox.Utils.get_health_icon(status, true);
+
+	    vm.set('state', {
+		iconCls,
+		text,
+	    });
+	},
+    },
+
+    viewModel: {
+	data: {
+	    product: 'Proxmox VE', // default
+	    errors: [],
+	    suitesWarning: false,
+	    mixedSuites: false, // used before major upgrade
+	    subscriptionActive: '',
+	    noSubscriptionRepo: '',
+	    enterpriseRepo: '',
+	    testRepo: '',
+	    cephEnterpriseRepo: '',
+	    cephNoSubscriptionRepo: '',
+	    cephTestRepo: '',
+	    selectionenabled: false,
+	    state: {},
+	},
+	formulas: {
+	    enableButtonText: (get) => get('selectionenabled')
+		? gettext('Disable') : gettext('Enable'),
+	},
+	stores: {
+	    errorstore: {
+		fields: ['status', 'message'],
+	    },
+	},
+    },
+
+    scrollable: true,
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    items: [
+	{
+	    xtype: 'panel',
+	    border: false,
+	    layout: {
+		type: 'hbox',
+		align: 'stretch',
+	    },
+	    height: 200,
+	    title: gettext('Status'),
+	    items: [
+		{
+		    xtype: 'box',
+		    flex: 2,
+		    margin: 10,
+		    data: {
+			iconCls: Proxmox.Utils.get_health_icon(undefined, true),
+			text: '',
+		    },
+		    bind: {
+			data: '{state}',
+		    },
+		    tpl: [
+			'<center class="centered-flex-column" style="font-size:15px;line-height: 25px;">',
+			'<i class="fa fa-4x {iconCls}"></i>',
+			'{text}',
+			'</center>',
+		    ],
+		},
+		{
+		    xtype: 'proxmoxNodeAPTRepositoriesErrors',
+		    name: 'repositoriesErrors',
+		    flex: 7,
+		    margin: 10,
+		    bind: {
+			store: '{errorstore}',
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'proxmoxNodeAPTRepositoriesGrid',
+	    name: 'repositoriesGrid',
+	    flex: 1,
+	    cbind: {
+		nodename: '{nodename}',
+		onlineHelp: '{onlineHelp}',
+	    },
+	    majorUpgradeAllowed: false, // TODO get release information from an API call?
+	    listeners: {
+		selectionchange: 'selectionChange',
+	    },
+	},
+    ],
+
+    check_subscription: function() {
+	let me = this;
+	let vm = me.getViewModel();
+
+	Proxmox.Utils.API2Request({
+	    url: `/nodes/${me.nodename}/subscription`,
+	    method: 'GET',
+	    failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    success: function(response, opts) {
+		const res = response.result;
+		const subscription = !(!res || !res.data || res.data.status.toLowerCase() !== 'active');
+		vm.set('subscriptionActive', subscription);
+		me.getController().updateState();
+	    },
+	});
+    },
+
+    updateStandardRepos: function(standardRepos) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	let addButton = me.down('button[name=addRepo]');
+
+	addButton.repoInfo = [];
+	for (const standardRepo of standardRepos) {
+	    const handle = standardRepo.handle;
+	    const status = standardRepo.status;
+
+	    if (handle === "enterprise") {
+		vm.set('enterpriseRepo', status);
+	    } else if (handle === "no-subscription") {
+		vm.set('noSubscriptionRepo', status);
+	    } else if (handle === 'test') {
+		vm.set('testRepo', status);
+	    } else if (handle.match(/^ceph-[a-zA-Z]+-enterprise$/)) {
+		vm.set('cephEnterpriseRepo', status);
+	    } else if (handle.match(/^ceph-[a-zA-Z]+-no-subscription$/)) {
+		vm.set('cephNoSubscriptionRepo', status);
+	    } else if (handle.match(/^ceph-[a-zA-Z]+-test$/)) {
+		vm.set('cephTestRepo', status);
+	    }
+	    me.getController().updateState();
+
+	    addButton.repoInfo.push(standardRepo);
+	    addButton.digest = me.digest;
+	}
+
+	addButton.setDisabled(false);
+    },
+
+    reload: function() {
+	let me = this;
+	let vm = me.getViewModel();
+	let repoGrid = me.down('proxmoxNodeAPTRepositoriesGrid');
+
+	me.store.load(function(records, operation, success) {
+	    let gridData = [];
+	    let errors = [];
+	    let digest;
+	    let suitesWarning = false;
+
+	    // Usually different suites will give errors anyways, but before a major upgrade the
+	    // current and the next suite are allowed, so it makes sense to check for mixed suites.
+	    let checkMixedSuites = false;
+	    let mixedSuites = false;
+
+	    if (success && records.length > 0) {
+		let data = records[0].data;
+		let files = data.files;
+		errors = data.errors;
+		digest = data.digest;
+
+		let infos = {};
+		for (const info of data.infos) {
+		    let path = info.path;
+		    let idx = info.index;
+
+		    if (!infos[path]) {
+			infos[path] = {};
+		    }
+		    if (!infos[path][idx]) {
+			infos[path][idx] = {
+			    origin: '',
+			    warnings: [],
+			    // Used as a heuristic to detect mixed repositories pre-upgrade. The
+			    // warning is set on all repositories that do configure the next suite.
+			    gotIgnorePreUpgradeWarning: false,
+			};
+		    }
+
+		    if (info.kind === 'origin') {
+			infos[path][idx].origin = info.message;
+		    } else if (info.kind === 'warning') {
+			infos[path][idx].warnings.push(info);
+		    } else if (info.kind === 'ignore-pre-upgrade-warning') {
+			infos[path][idx].gotIgnorePreUpgradeWarning = true;
+			if (!repoGrid.majorUpgradeAllowed) {
+			    infos[path][idx].warnings.push(info);
+			} else {
+			    checkMixedSuites = true;
+			}
+		    }
+		}
+
+
+		files.forEach(function(file) {
+		    for (let n = 0; n < file.repositories.length; n++) {
+			let repo = file.repositories[n];
+			repo.Path = file.path;
+			repo.Index = n;
+			if (infos[file.path] && infos[file.path][n]) {
+			    repo.Origin = infos[file.path][n].origin || Proxmox.Utils.unknownText;
+			    repo.warnings = infos[file.path][n].warnings || [];
+
+			    if (repo.Enabled) {
+				if (repo.warnings.some(w => w.property === 'Suites')) {
+				    suitesWarning = true;
+				}
+
+				let originType = me.classifyOrigin(repo.Origin);
+				// Only Proxmox and Debian repositories checked here, because the
+				// warning can be missing for others for a different reason (e.g.
+				// using 'stable' or non-Debian code names).
+				if (checkMixedSuites && repo.Types.includes('deb') &&
+				    (originType === 'Proxmox' || originType === 'Debian') &&
+				    !infos[file.path][n].gotIgnorePreUpgradeWarning
+				) {
+				    mixedSuites = true;
+				}
+			    }
+			}
+			gridData.push(repo);
+		    }
+		});
+
+		repoGrid.store.loadData(gridData);
+
+		me.updateStandardRepos(data['standard-repos']);
+	    }
+
+	    me.digest = digest;
+
+	    vm.set('errors', errors);
+	    vm.set('suitesWarning', suitesWarning);
+	    vm.set('mixedSuites', mixedSuites);
+	    me.getController().updateState();
+	});
+
+	me.check_subscription();
+    },
+
+    listeners: {
+	activate: function() {
+	    let me = this;
+	    me.reload();
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${me.nodename}/apt/repositories`,
+	    },
+	});
+
+	Ext.apply(me, { store: store });
+
+	Proxmox.Utils.monStoreErrors(me, me.store, true);
+
+	me.callParent();
+
+	me.getViewModel().set('product', me.product);
+    },
+});
+Ext.define('Proxmox.node.NetworkEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.proxmoxNodeNetworkEdit'],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.iftype) {
+	    throw "no network device type specified";
+	}
+
+	me.isCreate = !me.iface;
+
+	let iface_vtype;
+
+	if (me.iftype === 'bridge') {
+	    iface_vtype = 'BridgeName';
+	} else if (me.iftype === 'bond') {
+	    iface_vtype = 'BondName';
+	} else if (me.iftype === 'eth' && !me.isCreate) {
+	    iface_vtype = 'InterfaceName';
+	} else if (me.iftype === 'vlan') {
+	    iface_vtype = 'VlanName';
+	} else if (me.iftype === 'OVSBridge') {
+	    iface_vtype = 'BridgeName';
+	} else if (me.iftype === 'OVSBond') {
+	    iface_vtype = 'BondName';
+	} else if (me.iftype === 'OVSIntPort') {
+	    iface_vtype = 'InterfaceName';
+	} else if (me.iftype === 'OVSPort') {
+	    iface_vtype = 'InterfaceName';
+	} else {
+	    console.log(me.iftype);
+	    throw "unknown network device type specified";
+	}
+
+	me.subject = Proxmox.Utils.render_network_iface_type(me.iftype);
+
+	let column1 = [],
+	    column2 = [],
+	    columnB = [],
+	    advancedColumn1 = [],
+	    advancedColumn2 = [];
+
+	if (!(me.iftype === 'OVSIntPort' || me.iftype === 'OVSPort' || me.iftype === 'OVSBond')) {
+	    column2.push({
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Autostart'),
+		name: 'autostart',
+		uncheckedValue: 0,
+		checked: me.isCreate ? true : undefined,
+	    });
+	}
+
+	if (me.iftype === 'bridge') {
+	    column2.push({
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('VLAN aware'),
+		name: 'bridge_vlan_aware',
+		deleteEmpty: !me.isCreate,
+	    });
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('Bridge ports'),
+		name: 'bridge_ports',
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Space-separated list of interfaces, for example: enp0s0 enp1s0'),
+		},
+	    });
+	} else if (me.iftype === 'OVSBridge') {
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('Bridge ports'),
+		name: 'ovs_ports',
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Space-separated list of interfaces, for example: enp0s0 enp1s0'),
+		},
+	    });
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('OVS options'),
+		name: 'ovs_options',
+	    });
+	} else if (me.iftype === 'OVSPort' || me.iftype === 'OVSIntPort') {
+	    column2.push({
+		xtype: me.isCreate ? 'PVE.form.BridgeSelector' : 'displayfield',
+		fieldLabel: Proxmox.Utils.render_network_iface_type('OVSBridge'),
+		allowBlank: false,
+		nodename: me.nodename,
+		bridgeType: 'OVSBridge',
+		name: 'ovs_bridge',
+	    });
+	    column2.push({
+		xtype: 'proxmoxvlanfield',
+		deleteEmpty: !me.isCreate,
+		name: 'ovs_tag',
+		value: '',
+	    });
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('OVS options'),
+		name: 'ovs_options',
+	    });
+	} else if (me.iftype === 'vlan') {
+	    if (!me.isCreate) {
+		me.disablevlanid = false;
+		me.disablevlanrawdevice = false;
+		me.vlanrawdevicevalue = '';
+		me.vlanidvalue = '';
+
+		if (Proxmox.Utils.VlanInterface_match.test(me.iface)) {
+		   me.disablevlanid = true;
+		   me.disablevlanrawdevice = true;
+		   let arr = Proxmox.Utils.VlanInterface_match.exec(me.iface);
+		   me.vlanrawdevicevalue = arr[1];
+		   me.vlanidvalue = arr[2];
+		} else if (Proxmox.Utils.Vlan_match.test(me.iface)) {
+		   me.disablevlanid = true;
+		   let arr = Proxmox.Utils.Vlan_match.exec(me.iface);
+		   me.vlanidvalue = arr[1];
+		}
+	    } else {
+		me.disablevlanid = true;
+		me.disablevlanrawdevice = true;
+           }
+
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('Vlan raw device'),
+		name: 'vlan-raw-device',
+		value: me.vlanrawdevicevalue,
+		disabled: me.disablevlanrawdevice,
+		allowBlank: false,
+	    });
+
+	    column2.push({
+		xtype: 'proxmoxvlanfield',
+		name: 'vlan-id',
+		value: me.vlanidvalue,
+		disabled: me.disablevlanid,
+	    });
+
+	    columnB.push({
+		xtype: 'label',
+		userCls: 'pmx-hint',
+		text: 'Either add the VLAN number to an existing interface name, or choose your own name and set the VLAN raw device (for the latter ifupdown1 supports vlanXY naming only)',
+	    });
+	} else if (me.iftype === 'bond') {
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('Slaves'),
+		name: 'slaves',
+	    });
+
+	    let policySelector = Ext.createWidget('bondPolicySelector', {
+		fieldLabel: gettext('Hash policy'),
+		name: 'bond_xmit_hash_policy',
+		deleteEmpty: !me.isCreate,
+		disabled: true,
+	    });
+
+	    let primaryfield = Ext.createWidget('textfield', {
+		fieldLabel: 'bond-primary',
+		name: 'bond-primary',
+		value: '',
+		disabled: true,
+	    });
+
+	    column2.push({
+		xtype: 'bondModeSelector',
+		fieldLabel: gettext('Mode'),
+		name: 'bond_mode',
+		value: me.isCreate ? 'balance-rr' : undefined,
+		listeners: {
+		    change: function(f, value) {
+			if (value === 'balance-xor' ||
+			    value === '802.3ad') {
+			    policySelector.setDisabled(false);
+			    primaryfield.setDisabled(true);
+			    primaryfield.setValue('');
+			} else if (value === 'active-backup') {
+			    primaryfield.setDisabled(false);
+			    policySelector.setDisabled(true);
+			    policySelector.setValue('');
+			} else {
+			    policySelector.setDisabled(true);
+			    policySelector.setValue('');
+			    primaryfield.setDisabled(true);
+			    primaryfield.setValue('');
+			}
+		    },
+		},
+		allowBlank: false,
+	    });
+
+	    column2.push(policySelector);
+	    column2.push(primaryfield);
+	} else if (me.iftype === 'OVSBond') {
+	    column2.push({
+		xtype: me.isCreate ? 'PVE.form.BridgeSelector' : 'displayfield',
+		fieldLabel: Proxmox.Utils.render_network_iface_type('OVSBridge'),
+		allowBlank: false,
+		nodename: me.nodename,
+		bridgeType: 'OVSBridge',
+		name: 'ovs_bridge',
+	    });
+	    column2.push({
+		xtype: 'proxmoxvlanfield',
+		deleteEmpty: !me.isCreate,
+		name: 'ovs_tag',
+		value: '',
+	    });
+	    column2.push({
+		xtype: 'textfield',
+		fieldLabel: gettext('OVS options'),
+		name: 'ovs_options',
+	    });
+	}
+
+	column2.push({
+	    xtype: 'textfield',
+	    fieldLabel: gettext('Comment'),
+	    allowBlank: true,
+	    nodename: me.nodename,
+	    name: 'comments',
+	});
+
+	let url;
+	let method;
+
+	if (me.isCreate) {
+	    url = "/api2/extjs/nodes/" + me.nodename + "/network";
+	    method = 'POST';
+	} else {
+	    url = "/api2/extjs/nodes/" + me.nodename + "/network/" + me.iface;
+	    method = 'PUT';
+	}
+
+	column1.push({
+	    xtype: 'hiddenfield',
+	    name: 'type',
+	    value: me.iftype,
+	},
+	{
+	    xtype: me.isCreate ? 'textfield' : 'displayfield',
+	    fieldLabel: gettext('Name'),
+	    name: 'iface',
+	    value: me.iface,
+	    vtype: iface_vtype,
+	    allowBlank: false,
+	    maxLength: iface_vtype === 'BridgeName' ? 10 : 15,
+	    autoEl: {
+		tag: 'div',
+		 'data-qtip': gettext('For example, vmbr0.100, vmbr0, vlan0.100, vlan0'),
+	    },
+	    listeners: {
+		change: function(f, value) {
+		    if (me.isCreate && iface_vtype === 'VlanName') {
+			let vlanidField = me.down('field[name=vlan-id]');
+			let vlanrawdeviceField = me.down('field[name=vlan-raw-device]');
+			if (Proxmox.Utils.VlanInterface_match.test(value)) {
+			    vlanidField.setDisabled(true);
+			    vlanrawdeviceField.setDisabled(true);
+			    // User defined those values in the `iface` (Name)
+			    // field. Match them (instead of leaving the
+			    // previous value) to make clear what is submitted
+			    // and how the fields `iface`, `vlan-id` and
+			    // `vlan-raw-device` are connected
+			    vlanidField.setValue(
+				value.match(Proxmox.Utils.VlanInterface_match)[2],
+			    );
+			    vlanrawdeviceField.setValue(
+				value.match(Proxmox.Utils.VlanInterface_match)[1],
+			    );
+			} else if (Proxmox.Utils.Vlan_match.test(value)) {
+			    vlanidField.setDisabled(true);
+			    vlanidField.setValue(
+				value.match(Proxmox.Utils.Vlan_match)[1],
+			    );
+			    vlanrawdeviceField.setDisabled(false);
+			} else {
+			    vlanidField.setDisabled(false);
+			    vlanrawdeviceField.setDisabled(false);
+			}
+		    }
+		},
+	    },
+	});
+
+	if (me.iftype === 'OVSBond') {
+	    column1.push(
+		{
+		    xtype: 'bondModeSelector',
+		    fieldLabel: gettext('Mode'),
+		    name: 'bond_mode',
+		    openvswitch: true,
+		    value: me.isCreate ? 'active-backup' : undefined,
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Slaves'),
+		    name: 'ovs_bonds',
+		},
+	    );
+	} else {
+	    column1.push(
+		{
+		    xtype: 'proxmoxtextfield',
+		    deleteEmpty: !me.isCreate,
+		    fieldLabel: 'IPv4/CIDR',
+		    vtype: 'IPCIDRAddress',
+		    name: 'cidr',
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    deleteEmpty: !me.isCreate,
+		    fieldLabel: gettext('Gateway') + ' (IPv4)',
+		    vtype: 'IPAddress',
+		    name: 'gateway',
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    deleteEmpty: !me.isCreate,
+		    fieldLabel: 'IPv6/CIDR',
+		    vtype: 'IP6CIDRAddress',
+		    name: 'cidr6',
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    deleteEmpty: !me.isCreate,
+		    fieldLabel: gettext('Gateway') + ' (IPv6)',
+		    vtype: 'IP6Address',
+		    name: 'gateway6',
+		},
+	    );
+	}
+	advancedColumn1.push(
+	    {
+		xtype: 'proxmoxintegerfield',
+		minValue: 1280,
+		maxValue: 65520,
+		deleteEmpty: !me.isCreate,
+		emptyText: 1500,
+		fieldLabel: 'MTU',
+		name: 'mtu',
+	    },
+	);
+
+	Ext.applyIf(me, {
+	    url: url,
+	    method: method,
+	    items: {
+                xtype: 'inputpanel',
+		column1: column1,
+		column2: column2,
+		columnB: columnB,
+		advancedColumn1: advancedColumn1,
+		advancedColumn2: advancedColumn2,
+	    },
+	});
+
+	me.callParent();
+
+	if (me.isCreate) {
+	    me.down('field[name=iface]').setValue(me.iface_default);
+	} else {
+	    me.load({
+		success: function(response, options) {
+		    let data = response.result.data;
+		    if (data.type !== me.iftype) {
+			let msg = "Got unexpected device type";
+			Ext.Msg.alert(gettext('Error'), msg, function() {
+			    me.close();
+			});
+			return;
+		    }
+		    me.setValues(data);
+		    me.isValid(); // trigger validation
+		},
+	    });
+	}
+    },
+});
+Ext.define('proxmox-networks', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'active',
+	'address',
+	'address6',
+	'autostart',
+	'bridge_ports',
+	'cidr',
+	'cidr6',
+	'comments',
+	'gateway',
+	'gateway6',
+	'iface',
+	'netmask',
+	'netmask6',
+	'slaves',
+	'type',
+	'vlan-id',
+	'vlan-raw-device',
+    ],
+    idProperty: 'iface',
+});
+
+Ext.define('Proxmox.node.NetworkView', {
+    extend: 'Ext.panel.Panel',
+
+    alias: ['widget.proxmoxNodeNetworkView'],
+
+    // defines what types of network devices we want to create
+    // order is always the same
+    types: ['bridge', 'bond', 'vlan', 'ovs'],
+
+    showApplyBtn: false,
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let baseUrl = `/nodes/${me.nodename}/network`;
+
+	let store = Ext.create('Ext.data.Store', {
+	    model: 'proxmox-networks',
+	    proxy: {
+                type: 'proxmox',
+                url: '/api2/json' + baseUrl,
+	    },
+	    sorters: [
+		{
+		    property: 'iface',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+
+	let reload = function() {
+	    let changeitem = me.down('#changes');
+	    let apply_btn = me.down('#apply');
+	    let revert_btn = me.down('#revert');
+	    Proxmox.Utils.API2Request({
+		url: baseUrl,
+		failure: function(response, opts) {
+		    store.loadData({});
+		    Proxmox.Utils.setErrorMask(me, response.htmlStatus);
+		    changeitem.update('');
+		    changeitem.setHidden(true);
+		},
+		success: function(response, opts) {
+		    let result = Ext.decode(response.responseText);
+		    store.loadData(result.data);
+		    let changes = result.changes;
+		    if (changes === undefined || changes === '') {
+			changes = gettext("No changes");
+			changeitem.setHidden(true);
+			apply_btn.setDisabled(true);
+			revert_btn.setDisabled(true);
+		    } else {
+			changeitem.update("<pre>" + Ext.htmlEncode(changes) + "</pre>");
+			changeitem.setHidden(false);
+			apply_btn.setDisabled(false);
+			revert_btn.setDisabled(false);
+		    }
+		},
+	    });
+	};
+
+	let run_editor = function() {
+	    let grid = me.down('gridpanel');
+	    let sm = grid.getSelectionModel();
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    Ext.create('Proxmox.node.NetworkEdit', {
+		autoShow: true,
+		nodename: me.nodename,
+		iface: rec.data.iface,
+		iftype: rec.data.type,
+		listeners: {
+		    destroy: () => reload(),
+		},
+	    });
+	};
+
+	let edit_btn = new Ext.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    handler: run_editor,
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let del_btn = new Proxmox.button.StdRemoveButton({
+	    selModel: sm,
+	    getUrl: ({ data }) => `${baseUrl}/${data.iface}`,
+	    callback: () => reload(),
+	});
+
+	let apply_btn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Apply Configuration'),
+	    itemId: 'apply',
+	    disabled: true,
+	    confirmMsg: 'Do you want to apply pending network changes?',
+	    hidden: !me.showApplyBtn,
+	    handler: function() {
+		Proxmox.Utils.API2Request({
+		    url: baseUrl,
+		    method: 'PUT',
+		    waitMsgTarget: me,
+		    success: function({ result }, opts) {
+			Ext.create('Proxmox.window.TaskProgress', {
+			    autoShow: true,
+			    taskDone: reload,
+			    upid: result.data,
+			});
+		    },
+		    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		});
+	    },
+	});
+
+	let set_button_status = function() {
+	    let rec = sm.getSelection()[0];
+
+	    edit_btn.setDisabled(!rec);
+	    del_btn.setDisabled(!rec);
+	};
+
+	let findNextFreeInterfaceId = function(prefix) {
+	    for (let next = 0; next <= 9999; next++) {
+		let id = `${prefix}${next.toString()}`;
+		if (!store.getById(id)) {
+		    return id;
+		}
+	    }
+	    Ext.Msg.alert('Error', `No free ID for ${prefix} found!`);
+	    return '';
+	};
+
+	let menu_items = [];
+	let addEditWindowToMenu = (iType, iDefault) => {
+	    menu_items.push({
+		text: Proxmox.Utils.render_network_iface_type(iType),
+		handler: () => Ext.create('Proxmox.node.NetworkEdit', {
+		    autoShow: true,
+		    nodename: me.nodename,
+		    iftype: iType,
+		    iface_default: findNextFreeInterfaceId(iDefault ?? iType),
+		    onlineHelp: 'sysadmin_network_configuration',
+		    listeners: {
+			destroy: () => reload(),
+		    },
+		}),
+	    });
+	};
+
+	if (me.types.indexOf('bridge') !== -1) {
+	    addEditWindowToMenu('bridge', 'vmbr');
+	}
+
+	if (me.types.indexOf('bond') !== -1) {
+	    addEditWindowToMenu('bond');
+	}
+
+	if (me.types.indexOf('vlan') !== -1) {
+	    addEditWindowToMenu('vlan');
+	}
+
+	if (me.types.indexOf('ovs') !== -1) {
+	    if (menu_items.length > 0) {
+		menu_items.push({ xtype: 'menuseparator' });
+	    }
+
+	    addEditWindowToMenu('OVSBridge', 'vmbr');
+	    addEditWindowToMenu('OVSBond', 'bond');
+
+	    menu_items.push({
+		text: Proxmox.Utils.render_network_iface_type('OVSIntPort'),
+		handler: () => Ext.create('Proxmox.node.NetworkEdit', {
+		    autoShow: true,
+		    nodename: me.nodename,
+		    iftype: 'OVSIntPort',
+		    listeners: {
+			destroy: () => reload(),
+		    },
+		}),
+	    });
+	}
+
+	let renderer_generator = function(fieldname) {
+	    return function(val, metaData, rec) {
+		let tmp = [];
+		if (rec.data[fieldname]) {
+		    tmp.push(rec.data[fieldname]);
+		}
+		if (rec.data[fieldname + '6']) {
+		    tmp.push(rec.data[fieldname + '6']);
+		}
+		return tmp.join('<br>') || '';
+	    };
+	};
+
+	Ext.apply(me, {
+	    layout: 'border',
+	    tbar: [
+		{
+		    text: gettext('Create'),
+		    menu: {
+			plain: true,
+			items: menu_items,
+		    },
+		}, '-',
+		{
+		    text: gettext('Revert'),
+		    itemId: 'revert',
+		    handler: function() {
+			Proxmox.Utils.API2Request({
+			    url: baseUrl,
+			    method: 'DELETE',
+			    waitMsgTarget: me,
+			    callback: function() {
+				reload();
+			    },
+			    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+			});
+		    },
+		},
+		edit_btn,
+		del_btn,
+		'-',
+		apply_btn,
+	    ],
+	    items: [
+		{
+		    xtype: 'gridpanel',
+		    stateful: true,
+		    stateId: 'grid-node-network',
+		    store: store,
+		    selModel: sm,
+		    region: 'center',
+		    border: false,
+		    columns: [
+			{
+			    header: gettext('Name'),
+			    sortable: true,
+			    dataIndex: 'iface',
+			},
+			{
+			    header: gettext('Type'),
+			    sortable: true,
+			    width: 120,
+			    renderer: Proxmox.Utils.render_network_iface_type,
+			    dataIndex: 'type',
+			},
+			{
+			    xtype: 'booleancolumn',
+			    header: gettext('Active'),
+			    width: 80,
+			    sortable: true,
+			    dataIndex: 'active',
+			    trueText: Proxmox.Utils.yesText,
+			    falseText: Proxmox.Utils.noText,
+			    undefinedText: Proxmox.Utils.noText,
+			},
+			{
+			    xtype: 'booleancolumn',
+			    header: gettext('Autostart'),
+			    width: 80,
+			    sortable: true,
+			    dataIndex: 'autostart',
+			    trueText: Proxmox.Utils.yesText,
+			    falseText: Proxmox.Utils.noText,
+			    undefinedText: Proxmox.Utils.noText,
+			},
+			{
+			    xtype: 'booleancolumn',
+			    header: gettext('VLAN aware'),
+			    width: 80,
+			    sortable: true,
+			    dataIndex: 'bridge_vlan_aware',
+			    trueText: Proxmox.Utils.yesText,
+			    falseText: Proxmox.Utils.noText,
+			    undefinedText: Proxmox.Utils.noText,
+			},
+			{
+			    header: gettext('Ports/Slaves'),
+			    dataIndex: 'type',
+			    renderer: (value, metaData, { data }) => {
+				if (value === 'bridge') {
+				    return data.bridge_ports;
+				} else if (value === 'bond') {
+				    return data.slaves;
+				} else if (value === 'OVSBridge') {
+				    return data.ovs_ports;
+				} else if (value === 'OVSBond') {
+				    return data.ovs_bonds;
+				}
+				return '';
+			    },
+			},
+			{
+			    header: gettext('Bond Mode'),
+			    dataIndex: 'bond_mode',
+			    renderer: Proxmox.Utils.render_bond_mode,
+			},
+			{
+			    header: gettext('Hash Policy'),
+			    hidden: true,
+			    dataIndex: 'bond_xmit_hash_policy',
+			},
+			{
+			    header: gettext('IP address'),
+			    sortable: true,
+			    width: 120,
+			    hidden: true,
+			    dataIndex: 'address',
+			    renderer: renderer_generator('address'),
+			},
+			{
+			    header: gettext('Subnet mask'),
+			    width: 120,
+			    sortable: true,
+			    hidden: true,
+			    dataIndex: 'netmask',
+			    renderer: renderer_generator('netmask'),
+			},
+			{
+			    header: gettext('CIDR'),
+			    width: 150,
+			    sortable: true,
+			    dataIndex: 'cidr',
+			    renderer: renderer_generator('cidr'),
+			},
+			{
+			    header: gettext('Gateway'),
+			    width: 150,
+			    sortable: true,
+			    dataIndex: 'gateway',
+			    renderer: renderer_generator('gateway'),
+			},
+			{
+			    header: gettext('VLAN ID'),
+			    hidden: true,
+			    sortable: true,
+			    dataIndex: 'vlan-id',
+			},
+			{
+			    header: gettext('VLAN raw device'),
+			    hidden: true,
+			    sortable: true,
+			    dataIndex: 'vlan-raw-device',
+			},
+			{
+			    header: 'MTU',
+			    hidden: true,
+			    sortable: true,
+			    dataIndex: 'mtu',
+			},
+			{
+			    header: gettext('Comment'),
+			    dataIndex: 'comments',
+			    flex: 1,
+			    renderer: Ext.String.htmlEncode,
+			},
+		    ],
+		    listeners: {
+			selectionchange: set_button_status,
+			itemdblclick: run_editor,
+		    },
+		},
+		{
+		    border: false,
+		    region: 'south',
+		    autoScroll: true,
+		    hidden: true,
+		    itemId: 'changes',
+		    tbar: [
+			gettext('Pending changes') + ' (' +
+			    gettext("Either reboot or use 'Apply Configuration' (needs ifupdown2) to activate") + ')',
+		    ],
+		    split: true,
+		    bodyPadding: 5,
+		    flex: 0.6,
+		    html: gettext("No changes"),
+		},
+	    ],
+	});
+
+	me.callParent();
+	reload();
+    },
+});
+Ext.define('Proxmox.node.DNSEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.proxmoxNodeDNSEdit'],
+
+    // Some longer existing APIs use a brittle "replace whole config" style, you can set this option
+    // if the DNSEdit component is used in an API that has more modern, granular update semantics.
+    deleteEmpty: false,
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.items = [
+	    {
+		xtype: 'textfield',
+                fieldLabel: gettext('Search domain'),
+                name: 'search',
+                allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+                fieldLabel: gettext('DNS server') + " 1",
+		vtype: 'IP64Address',
+		skipEmptyText: true,
+		deleteEmpty: me.deleteEmpty,
+                name: 'dns1',
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		fieldLabel: gettext('DNS server') + " 2",
+		vtype: 'IP64Address',
+		skipEmptyText: true,
+		deleteEmpty: me.deleteEmpty,
+                name: 'dns2',
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+                fieldLabel: gettext('DNS server') + " 3",
+		vtype: 'IP64Address',
+		skipEmptyText: true,
+		deleteEmpty: me.deleteEmpty,
+                name: 'dns3',
+	    },
+	];
+
+	Ext.applyIf(me, {
+	    subject: gettext('DNS'),
+	    url: "/api2/extjs/nodes/" + me.nodename + "/dns",
+	    fieldDefaults: {
+		labelWidth: 120,
+	    },
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+Ext.define('Proxmox.node.HostsView', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'proxmoxNodeHostsView',
+
+    reload: function() {
+	let me = this;
+	me.store.load();
+    },
+
+    tbar: [
+	{
+	    text: gettext('Save'),
+	    disabled: true,
+	    itemId: 'savebtn',
+	    handler: function() {
+		let view = this.up('panel');
+		Proxmox.Utils.API2Request({
+		    params: {
+			digest: view.digest,
+			data: view.down('#hostsfield').getValue(),
+		    },
+		    method: 'POST',
+		    url: '/nodes/' + view.nodename + '/hosts',
+		    waitMsgTarget: view,
+		    success: function(response, opts) {
+			view.reload();
+		    },
+		    failure: function(response, opts) {
+			Ext.Msg.alert('Error', response.htmlStatus);
+		    },
+		});
+	    },
+	},
+	{
+	    text: gettext('Revert'),
+	    disabled: true,
+	    itemId: 'resetbtn',
+	    handler: function() {
+		let view = this.up('panel');
+		view.down('#hostsfield').reset();
+	    },
+	},
+    ],
+
+	    layout: 'fit',
+
+    items: [
+	{
+	    xtype: 'textarea',
+	    itemId: 'hostsfield',
+	    fieldStyle: {
+		'font-family': 'monospace',
+		'white-space': 'pre',
+	    },
+	    listeners: {
+		dirtychange: function(ta, dirty) {
+		    let view = this.up('panel');
+		    view.down('#savebtn').setDisabled(!dirty);
+		    view.down('#resetbtn').setDisabled(!dirty);
+		},
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.store = Ext.create('Ext.data.Store', {
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/nodes/" + me.nodename + "/hosts",
+	    },
+	});
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.store);
+
+	me.mon(me.store, 'load', function(store, records, success) {
+	    if (!success || records.length < 1) {
+		return;
+	    }
+	    me.digest = records[0].data.digest;
+	    let data = records[0].data.data;
+	    me.down('#hostsfield').setValue(data);
+	    me.down('#hostsfield').resetOriginalValue();
+	});
+
+	me.reload();
+    },
+});
+Ext.define('Proxmox.node.DNSView', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.proxmoxNodeDNSView'],
+
+    // Some longer existing APIs use a brittle "replace whole config" style, you can set this option
+    // if the DNSView component is used in an API that has more modern, granular update semantics.
+    deleteEmpty: false,
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let run_editor = () => Ext.create('Proxmox.node.DNSEdit', {
+	    autoShow: true,
+	    nodename: me.nodename,
+	    deleteEmpty: me.deleteEmpty,
+	});
+
+	Ext.apply(me, {
+	    url: `/api2/json/nodes/${me.nodename}/dns`,
+	    cwidth1: 130,
+	    interval: 10 * 1000,
+	    run_editor: run_editor,
+	    rows: {
+		search: {
+		    header: gettext('Search domain'),
+		    required: true,
+		    renderer: Ext.htmlEncode,
+		},
+		dns1: {
+		    header: gettext('DNS server') + " 1",
+		    required: true,
+		    renderer: Ext.htmlEncode,
+		},
+		dns2: {
+		    header: gettext('DNS server') + " 2",
+		    renderer: Ext.htmlEncode,
+		},
+		dns3: {
+		    header: gettext('DNS server') + " 3",
+		    renderer: Ext.htmlEncode,
+		},
+	    },
+	    tbar: [
+		{
+		    text: gettext("Edit"),
+		    handler: run_editor,
+		},
+	    ],
+	    listeners: {
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+    },
+});
+Ext.define('Proxmox.node.Tasks', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: 'widget.proxmoxNodeTasks',
+
+    stateful: true,
+    stateId: 'pve-grid-node-tasks',
+
+    loadMask: true,
+    sortableColumns: false,
+
+    // set extra filter components, must have a 'name' property for the parameter, and must
+    // trigger a 'change' event if the value is 'undefined', it will not be sent to the api
+    extraFilter: [],
+
+
+    // fixed filters which cannot be changed after instantiation, for example:
+    // { vmid: 100 }
+    preFilter: {},
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	showTaskLog: function() {
+	    let me = this;
+	    let selection = me.getView().getSelection();
+	    if (selection.length < 1) {
+		return;
+	    }
+
+	    let rec = selection[0];
+
+	    Ext.create('Proxmox.window.TaskViewer', {
+		upid: rec.data.upid,
+		endtime: rec.data.endtime,
+	    }).show();
+	},
+
+	updateLayout: function(store, records, success, operation) {
+	    let me = this;
+	    let view = me.getView().getView(); // the table view, not the whole grid
+	    Proxmox.Utils.setErrorMask(view, false);
+	    // update the scrollbar on every store load since the total count might be different.
+	    // the buffered grid plugin does this only on (user) scrolling itself and even reduces
+	    // the scrollheight again when scrolling up
+	    me.getView().updateLayout();
+
+	    if (!success) {
+		Proxmox.Utils.setErrorMask(view, Proxmox.Utils.getResponseErrorMessage(operation.getError()));
+	    }
+	},
+
+	refresh: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    let store = me.getViewModel().get('bufferedstore');
+	    if (selection && selection.length > 0) {
+		// deselect if selection is not there anymore
+		if (!store.contains(selection[0])) {
+		    view.setSelection(undefined);
+		}
+	    }
+	},
+
+	sinceChange: function(field, newval) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    vm.set('since', newval);
+	},
+
+	untilChange: function(field, newval, oldval) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    vm.set('until', newval);
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.getStore().load();
+	},
+
+	showFilter: function(btn, pressed) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    vm.set('showFilter', pressed);
+	},
+
+	clearFilter: function() {
+	    let me = this;
+	    me.lookup('filtertoolbar').query('field').forEach((field) => {
+		field.setValue(undefined);
+	    });
+	},
+    },
+
+    listeners: {
+	itemdblclick: 'showTaskLog',
+    },
+
+    viewModel: {
+	data: {
+	    typefilter: '',
+	    statusfilter: '',
+	    showFilter: false,
+	    extraFilter: {},
+	    since: null,
+	    until: null,
+	},
+
+	formulas: {
+	    filterIcon: (get) => 'fa fa-filter' + (get('showFilter') ? ' info-blue' : ''),
+	    extraParams: function(get) {
+		let me = this;
+		let params = {};
+		if (get('typefilter')) {
+		    params.typefilter = get('typefilter');
+		}
+		if (get('statusfilter')) {
+		    params.statusfilter = get('statusfilter');
+		}
+
+		if (get('extraFilter')) {
+		    let extraFilter = get('extraFilter');
+		    for (const [name, value] of Object.entries(extraFilter)) {
+			if (value !== undefined && value !== null && value !== "") {
+			    params[name] = value;
+			}
+		    }
+		}
+
+		if (get('since')) {
+		    params.since = get('since').valueOf()/1000;
+		}
+
+		if (get('until')) {
+		    let until = new Date(get('until').getTime()); // copy object
+		    until.setDate(until.getDate() + 1); // end of the day
+		    params.until = until.valueOf()/1000;
+		}
+
+		me.getView().getStore().load();
+
+		return params;
+	    },
+	    filterCount: function(get) {
+		let count = 0;
+		if (get('typefilter')) {
+		    count++;
+		}
+		let status = get('statusfilter');
+		if ((Ext.isArray(status) && status.length > 0) ||
+		    (!Ext.isArray(status) && status)) {
+		    count++;
+		}
+		if (get('since')) {
+		    count++;
+		}
+		if (get('until')) {
+		    count++;
+		}
+
+		if (get('extraFilter')) {
+		    let preFilter = get('preFilter') || {};
+		    let extraFilter = get('extraFilter');
+		    for (const [name, value] of Object.entries(extraFilter)) {
+			if (value !== undefined && value !== null && value !== "" &&
+			    preFilter[name] === undefined
+			) {
+			    count++;
+			}
+		    }
+		}
+
+		return count;
+	    },
+	    clearFilterText: function(get) {
+		let count = get('filterCount');
+		let fieldMsg = '';
+		if (count > 1) {
+		    fieldMsg = ` (${count} ${gettext('Fields')})`;
+		} else if (count > 0) {
+		    fieldMsg = ` (1 ${gettext('Field')})`;
+		}
+		return gettext('Clear Filter') + fieldMsg;
+	    },
+	},
+
+	stores: {
+	    bufferedstore: {
+		type: 'buffered',
+		pageSize: 500,
+		autoLoad: true,
+		remoteFilter: true,
+		model: 'proxmox-tasks',
+		proxy: {
+		    type: 'proxmox',
+		    startParam: 'start',
+		    limitParam: 'limit',
+		    extraParams: '{extraParams}',
+		    url: '{url}',
+		},
+		listeners: {
+		    prefetch: 'updateLayout',
+		    refresh: 'refresh',
+		},
+	    },
+	},
+    },
+
+    bind: {
+	store: '{bufferedstore}',
+    },
+
+    dockedItems: [
+	{
+	    xtype: 'toolbar',
+	    items: [
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('View'),
+		    iconCls: 'fa fa-window-restore',
+		    disabled: true,
+		    handler: 'showTaskLog',
+		},
+		{
+		    xtype: 'button',
+		    text: gettext('Reload'),
+		    iconCls: 'fa fa-refresh',
+		    handler: 'reload',
+		},
+		'->',
+		{
+		    xtype: 'button',
+		    bind: {
+			text: '{clearFilterText}',
+			disabled: '{!filterCount}',
+		    },
+		    text: gettext('Clear Filter'),
+		    enabled: false,
+		    handler: 'clearFilter',
+		},
+		{
+		    xtype: 'button',
+		    enableToggle: true,
+		    bind: {
+			iconCls: '{filterIcon}',
+		    },
+		    text: gettext('Filter'),
+		    stateful: true,
+		    stateId: 'task-showfilter',
+		    stateEvents: ['toggle'],
+		    applyState: function(state) {
+			if (state.pressed !== undefined) {
+			    this.setPressed(state.pressed);
+			}
+		    },
+		    getState: function() {
+			return {
+			    pressed: this.pressed,
+			};
+		    },
+		    listeners: {
+			toggle: 'showFilter',
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'toolbar',
+	    dock: 'top',
+	    reference: 'filtertoolbar',
+	    layout: {
+		type: 'hbox',
+		align: 'top',
+	    },
+	    bind: {
+		hidden: '{!showFilter}',
+	    },
+	    items: [
+		{
+		    xtype: 'container',
+		    padding: 10,
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    defaults: {
+			labelWidth: 80,
+		    },
+		    // cannot bind the values directly, as it then changes also
+		    // on blur, causing wrong reloads of the store
+		    items: [
+			{
+			    xtype: 'datefield',
+			    fieldLabel: gettext('Since'),
+			    format: 'Y-m-d',
+			    bind: {
+				maxValue: '{until}',
+			    },
+			    listeners: {
+				change: 'sinceChange',
+			    },
+			},
+			{
+			    xtype: 'datefield',
+			    fieldLabel: gettext('Until'),
+			    format: 'Y-m-d',
+			    bind: {
+				minValue: '{since}',
+			    },
+			    listeners: {
+				change: 'untilChange',
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    padding: 10,
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    defaults: {
+			labelWidth: 80,
+		    },
+		    items: [
+			{
+			    xtype: 'pmxTaskTypeSelector',
+			    fieldLabel: gettext('Task Type'),
+			    emptyText: gettext('All'),
+			    bind: {
+				value: '{typefilter}',
+			    },
+			},
+			{
+			    xtype: 'combobox',
+			    fieldLabel: gettext('Task Result'),
+			    emptyText: gettext('All'),
+			    multiSelect: true,
+			    store: [
+				['ok', gettext('OK')],
+				['unknown', Proxmox.Utils.unknownText],
+				['warning', gettext('Warnings')],
+				['error', gettext('Errors')],
+			    ],
+			    bind: {
+				value: '{statusfilter}',
+			    },
+			},
+		    ],
+		},
+	    ],
+	},
+    ],
+
+    viewConfig: {
+	trackOver: false,
+	stripeRows: false, // does not work with getRowClass()
+	emptyText: gettext('No Tasks found'),
+
+	getRowClass: function(record, index) {
+	    let status = record.get('status');
+
+	    if (status) {
+		let parsed = Proxmox.Utils.parse_task_status(status);
+		if (parsed === 'error') {
+		    return "proxmox-invalid-row";
+		} else if (parsed === 'warning') {
+		    return "proxmox-warning-row";
+		}
+	    }
+	    return '';
+	},
+    },
+
+    columns: [
+	{
+	    header: gettext("Start Time"),
+	    dataIndex: 'starttime',
+	    width: 130,
+	    renderer: function(value) {
+		return Ext.Date.format(value, "M d H:i:s");
+	    },
+	},
+	{
+	    header: gettext("End Time"),
+	    dataIndex: 'endtime',
+	    width: 130,
+	    renderer: function(value, metaData, record) {
+		if (!value) {
+		    metaData.tdCls = "x-grid-row-loading";
+		    return '';
+		}
+		return Ext.Date.format(value, "M d H:i:s");
+	    },
+	},
+	{
+	    header: gettext("Duration"),
+	    hidden: true,
+	    width: 80,
+	    renderer: function(value, metaData, record) {
+		let start = record.data.starttime;
+		if (start) {
+		    let end = record.data.endtime || Date.now();
+		    let duration = end - start;
+		    if (duration > 0) {
+			duration /= 1000;
+		    }
+		    return Proxmox.Utils.format_duration_human(duration);
+		}
+		return Proxmox.Utils.unknownText;
+	    },
+	},
+	{
+	    header: gettext("User name"),
+	    dataIndex: 'user',
+	    width: 150,
+	},
+	{
+	    header: gettext("Description"),
+	    dataIndex: 'upid',
+	    flex: 1,
+	    renderer: Proxmox.Utils.render_upid,
+	},
+	{
+	    header: gettext("Status"),
+	    dataIndex: 'status',
+	    width: 200,
+	    renderer: function(value, metaData, record) {
+		if (value === undefined && !record.data.endtime) {
+		    metaData.tdCls = "x-grid-row-loading";
+		    return '';
+		}
+
+		return Proxmox.Utils.format_task_status(value);
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	const me = this;
+
+	let nodename = me.nodename || 'localhost';
+	let url = me.url || `/api2/json/nodes/${nodename}/tasks`;
+	me.getViewModel().set('url', url);
+
+	let updateExtraFilters = function(name, value) {
+	    let vm = me.getViewModel();
+	    let extraFilter = Ext.clone(vm.get('extraFilter'));
+	    extraFilter[name] = value;
+	    vm.set('extraFilter', extraFilter);
+	};
+
+	for (const [name, value] of Object.entries(me.preFilter)) {
+	    updateExtraFilters(name, value);
+	}
+
+	me.getViewModel().set('preFilter', me.preFilter);
+
+	me.callParent();
+
+	let addFields = function(items) {
+	    me.lookup('filtertoolbar').add({
+		xtype: 'container',
+		padding: 10,
+		layout: {
+		    type: 'vbox',
+		    align: 'stretch',
+		},
+		defaults: {
+		    labelWidth: 80,
+		},
+		items,
+	    });
+	};
+
+	// start with a userfilter
+	me.extraFilter = [
+	    {
+		xtype: 'textfield',
+		fieldLabel: gettext('User name'),
+		changeOptions: {
+		    buffer: 500,
+		},
+		name: 'userfilter',
+	    },
+	    ...me.extraFilter,
+	];
+	let items = [];
+	for (const filterTemplate of me.extraFilter) {
+	    let filter = Ext.clone(filterTemplate);
+
+	    filter.listeners = filter.listeners || {};
+	    filter.listeners.change = Ext.apply(filter.changeOptions || {}, {
+		fn: function(field, value) {
+		    updateExtraFilters(filter.name, value);
+		},
+	    });
+
+	    items.push(filter);
+	    if (items.length === 2) {
+		addFields(items);
+		items = [];
+	    }
+	}
+
+	addFields(items);
+    },
+});
+Ext.define('proxmox-services', {
+    extend: 'Ext.data.Model',
+    fields: ['service', 'name', 'desc', 'state', 'unit-state', 'active-state'],
+    idProperty: 'service',
+});
+
+Ext.define('Proxmox.node.ServiceView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.proxmoxNodeServiceView'],
+
+    startOnlyServices: {},
+
+    restartCommand: "restart", // TODO: default to reload once everywhere supported
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 1000,
+	    model: 'proxmox-services',
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${me.nodename}/services`,
+	    },
+	});
+
+	let store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: rstore,
+	    sortAfterUpdate: true,
+	    sorters: [
+		{
+		    property: 'name',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+
+	let view_service_log = function() {
+	    let { data: { service } } = me.getSelectionModel().getSelection()[0];
+	    Ext.create('Ext.window.Window', {
+		title: gettext('Syslog') + ': ' + service,
+		modal: true,
+		width: 800,
+		height: 400,
+		layout: 'fit',
+		items: {
+		    xtype: 'proxmoxLogView',
+		    url: `/api2/extjs/nodes/${me.nodename}/syslog?service=${service}`,
+		    log_select_timespan: 1,
+		},
+		autoShow: true,
+	    });
+	};
+
+	let service_cmd = function(cmd) {
+	    let { data: { service } } = me.getSelectionModel().getSelection()[0];
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${me.nodename}/services/${service}/${cmd}`,
+		method: 'POST',
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    me.loading = true;
+		},
+		success: function(response, opts) {
+		    rstore.startUpdate();
+		    Ext.create('Proxmox.window.TaskProgress', {
+			upid: response.result.data,
+			autoShow: true,
+		    });
+		},
+	    });
+	};
+
+	let start_btn = new Ext.Button({
+	    text: gettext('Start'),
+	    disabled: true,
+	    handler: () => service_cmd("start"),
+	});
+	let stop_btn = new Ext.Button({
+	    text: gettext('Stop'),
+	    disabled: true,
+	    handler: () => service_cmd("stop"),
+	});
+	let restart_btn = new Ext.Button({
+	    text: gettext('Restart'),
+	    disabled: true,
+	    handler: () => service_cmd(me.restartCommand || "restart"),
+	});
+	let syslog_btn = new Ext.Button({
+	    text: gettext('Syslog'),
+	    disabled: true,
+	    handler: view_service_log,
+	});
+
+	let set_button_status = function() {
+	    let sm = me.getSelectionModel();
+	    let rec = sm.getSelection()[0];
+
+	    if (!rec) {
+		start_btn.disable();
+		stop_btn.disable();
+		restart_btn.disable();
+		syslog_btn.disable();
+		return;
+	    }
+	    let service = rec.data.service;
+	    let state = rec.data.state;
+	    let unit = rec.data['unit-state'];
+
+	    syslog_btn.enable();
+
+	    if (state === 'running') {
+		if (me.startOnlyServices[service]) {
+		    stop_btn.disable();
+		    restart_btn.enable();
+		} else {
+		    stop_btn.enable();
+		    restart_btn.enable();
+		    start_btn.disable();
+		}
+	    } else if (unit !== undefined && (unit === 'masked' || unit === 'unknown' || unit === 'not-found')) {
+		start_btn.disable();
+		restart_btn.disable();
+		stop_btn.disable();
+	    } else {
+		start_btn.enable();
+		stop_btn.disable();
+		restart_btn.disable();
+	    }
+	};
+
+	me.mon(store, 'refresh', set_button_status);
+
+	Proxmox.Utils.monStoreErrors(me, rstore);
+
+	Ext.apply(me, {
+	    viewConfig: {
+		trackOver: false,
+		stripeRows: false, // does not work with getRowClass()
+		getRowClass: function(record, index) {
+		    let unitState = record.get('unit-state');
+		    if (!unitState) {
+			return '';
+		    }
+		    if (unitState === 'masked' || unitState === 'not-found') {
+			return "proxmox-disabled-row";
+		    } else if (unitState === 'unknown') {
+			if (record.get('name') === 'syslog') {
+			    return "proxmox-disabled-row"; // replaced by journal on most hosts
+			}
+			return "proxmox-warning-row";
+		    }
+		    return '';
+		},
+	    },
+	    store: store,
+	    stateful: false,
+	    tbar: [
+		start_btn,
+		stop_btn,
+		restart_btn,
+		'-',
+		syslog_btn,
+	    ],
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    flex: 1,
+		    sortable: true,
+		    dataIndex: 'name',
+		},
+		{
+		    header: gettext('Status'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'state',
+		    renderer: (value, meta, rec) => {
+			const unitState = rec.get('unit-state');
+			if (unitState === 'masked') {
+			    return gettext('disabled');
+			} else if (unitState === 'not-found') {
+			    return gettext('not installed');
+			} else {
+			    return value;
+			}
+		    },
+		},
+		{
+		    header: gettext('Active'),
+		    width: 100,
+		    sortable: true,
+		    hidden: true,
+		    dataIndex: 'active-state',
+		},
+		{
+		    header: gettext('Unit'),
+		    width: 120,
+		    sortable: true,
+		    hidden: !Ext.Array.contains(['PVEAuthCookie', 'PBSAuthCookie'], Proxmox?.Setup?.auth_cookie_name),
+		    dataIndex: 'unit-state',
+		},
+		{
+		    header: gettext('Description'),
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'desc',
+		    flex: 2,
+		},
+	    ],
+	    listeners: {
+		selectionchange: set_button_status,
+		itemdblclick: view_service_log,
+		activate: rstore.startUpdate,
+		destroy: rstore.stopUpdate,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.node.TimeEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.proxmoxNodeTimeEdit'],
+
+    subject: gettext('Time zone'),
+
+    width: 400,
+
+    autoLoad: true,
+
+    fieldDefaults: {
+	labelWidth: 70,
+    },
+
+    items: {
+	xtype: 'combo',
+	fieldLabel: gettext('Time zone'),
+	name: 'timezone',
+	queryMode: 'local',
+	store: Ext.create('Proxmox.data.TimezoneStore'),
+	displayField: 'zone',
+	editable: true,
+	anyMatch: true,
+	forceSelection: true,
+	allowBlank: false,
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	me.url = "/api2/extjs/nodes/" + me.nodename + "/time";
+
+	me.callParent();
+    },
+});
+Ext.define('Proxmox.node.TimeView', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.proxmoxNodeTimeView'],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let tzOffset = new Date().getTimezoneOffset() * 60000;
+	let renderLocaltime = function(value) {
+	    let servertime = new Date((value * 1000) + tzOffset);
+	    return Ext.Date.format(servertime, 'Y-m-d H:i:s');
+	};
+
+	let run_editor = () => Ext.create('Proxmox.node.TimeEdit', {
+	    autoShow: true,
+	    nodename: me.nodename,
+	});
+
+	Ext.apply(me, {
+	    url: `/api2/json/nodes/${me.nodename}/time`,
+	    cwidth1: 150,
+	    interval: 1000,
+	    run_editor: run_editor,
+	    rows: {
+		timezone: {
+		    header: gettext('Time zone'),
+		    required: true,
+		},
+		localtime: {
+		    header: gettext('Server time'),
+		    required: true,
+		    renderer: renderLocaltime,
+		},
+	    },
+	    tbar: [
+		{
+		    text: gettext("Edit"),
+		    handler: run_editor,
+		},
+	    ],
+	    listeners: {
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+    },
+});
+/**
+ * marked - a markdown parser
+ * Copyright (c) 2011-2022, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+
+/**
+ * DO NOT EDIT THIS FILE
+ * The code in this file is generated from files in ./src/
+ */
+
+(function (global, factory) {
+  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+  typeof define === 'function' && define.amd ? define(['exports'], factory) :
+  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.marked = {}));
+})(this, (function (exports) { 'use strict';
+
+  function _defineProperties(target, props) {
+    for (var i = 0; i < props.length; i++) {
+      var descriptor = props[i];
+      descriptor.enumerable = descriptor.enumerable || false;
+      descriptor.configurable = true;
+      if ("value" in descriptor) descriptor.writable = true;
+      Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor);
+    }
+  }
+  function _createClass(Constructor, protoProps, staticProps) {
+    if (protoProps) _defineProperties(Constructor.prototype, protoProps);
+    if (staticProps) _defineProperties(Constructor, staticProps);
+    Object.defineProperty(Constructor, "prototype", {
+      writable: false
+    });
+    return Constructor;
+  }
+  function _unsupportedIterableToArray(o, minLen) {
+    if (!o) return;
+    if (typeof o === "string") return _arrayLikeToArray(o, minLen);
+    var n = Object.prototype.toString.call(o).slice(8, -1);
+    if (n === "Object" && o.constructor) n = o.constructor.name;
+    if (n === "Map" || n === "Set") return Array.from(o);
+    if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);
+  }
+  function _arrayLikeToArray(arr, len) {
+    if (len == null || len > arr.length) len = arr.length;
+    for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];
+    return arr2;
+  }
+  function _createForOfIteratorHelperLoose(o, allowArrayLike) {
+    var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"];
+    if (it) return (it = it.call(o)).next.bind(it);
+    if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") {
+      if (it) o = it;
+      var i = 0;
+      return function () {
+        if (i >= o.length) return {
+          done: true
+        };
+        return {
+          done: false,
+          value: o[i++]
+        };
+      };
+    }
+    throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
+  }
+  function _toPrimitive(input, hint) {
+    if (typeof input !== "object" || input === null) return input;
+    var prim = input[Symbol.toPrimitive];
+    if (prim !== undefined) {
+      var res = prim.call(input, hint || "default");
+      if (typeof res !== "object") return res;
+      throw new TypeError("@@toPrimitive must return a primitive value.");
+    }
+    return (hint === "string" ? String : Number)(input);
+  }
+  function _toPropertyKey(arg) {
+    var key = _toPrimitive(arg, "string");
+    return typeof key === "symbol" ? key : String(key);
+  }
+
+  function getDefaults() {
+    return {
+      async: false,
+      baseUrl: null,
+      breaks: false,
+      extensions: null,
+      gfm: true,
+      headerIds: true,
+      headerPrefix: '',
+      highlight: null,
+      langPrefix: 'language-',
+      mangle: true,
+      pedantic: false,
+      renderer: null,
+      sanitize: false,
+      sanitizer: null,
+      silent: false,
+      smartypants: false,
+      tokenizer: null,
+      walkTokens: null,
+      xhtml: false
+    };
+  }
+  exports.defaults = getDefaults();
+  function changeDefaults(newDefaults) {
+    exports.defaults = newDefaults;
+  }
+
+  /**
+   * Helpers
+   */
+  var escapeTest = /[&<>"']/;
+  var escapeReplace = new RegExp(escapeTest.source, 'g');
+  var escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
+  var escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g');
+  var escapeReplacements = {
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
+    '"': '&quot;',
+    "'": '&#39;'
+  };
+  var getEscapeReplacement = function getEscapeReplacement(ch) {
+    return escapeReplacements[ch];
+  };
+  function escape(html, encode) {
+    if (encode) {
+      if (escapeTest.test(html)) {
+        return html.replace(escapeReplace, getEscapeReplacement);
+      }
+    } else {
+      if (escapeTestNoEncode.test(html)) {
+        return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
+      }
+    }
+    return html;
+  }
+  var unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig;
+
+  /**
+   * @param {string} html
+   */
+  function unescape(html) {
+    // explicitly match decimal, hex, and named HTML entities
+    return html.replace(unescapeTest, function (_, n) {
+      n = n.toLowerCase();
+      if (n === 'colon') return ':';
+      if (n.charAt(0) === '#') {
+        return n.charAt(1) === 'x' ? String.fromCharCode(parseInt(n.substring(2), 16)) : String.fromCharCode(+n.substring(1));
+      }
+      return '';
+    });
+  }
+  var caret = /(^|[^\[])\^/g;
+
+  /**
+   * @param {string | RegExp} regex
+   * @param {string} opt
+   */
+  function edit(regex, opt) {
+    regex = typeof regex === 'string' ? regex : regex.source;
+    opt = opt || '';
+    var obj = {
+      replace: function replace(name, val) {
+        val = val.source || val;
+        val = val.replace(caret, '$1');
+        regex = regex.replace(name, val);
+        return obj;
+      },
+      getRegex: function getRegex() {
+        return new RegExp(regex, opt);
+      }
+    };
+    return obj;
+  }
+  var nonWordAndColonTest = /[^\w:]/g;
+  var originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;
+
+  /**
+   * @param {boolean} sanitize
+   * @param {string} base
+   * @param {string} href
+   */
+  function cleanUrl(sanitize, base, href) {
+    if (sanitize) {
+      var prot;
+      try {
+        prot = decodeURIComponent(unescape(href)).replace(nonWordAndColonTest, '').toLowerCase();
+      } catch (e) {
+        return null;
+      }
+      if (prot.indexOf('javascript:') === 0 || prot.indexOf('vbscript:') === 0 || prot.indexOf('data:') === 0) {
+        return null;
+      }
+    }
+    if (base && !originIndependentUrl.test(href)) {
+      href = resolveUrl(base, href);
+    }
+    try {
+      href = encodeURI(href).replace(/%25/g, '%');
+    } catch (e) {
+      return null;
+    }
+    return href;
+  }
+  var baseUrls = {};
+  var justDomain = /^[^:]+:\/*[^/]*$/;
+  var protocol = /^([^:]+:)[\s\S]*$/;
+  var domain = /^([^:]+:\/*[^/]*)[\s\S]*$/;
+
+  /**
+   * @param {string} base
+   * @param {string} href
+   */
+  function resolveUrl(base, href) {
+    if (!baseUrls[' ' + base]) {
+      // we can ignore everything in base after the last slash of its path component,
+      // but we might need to add _that_
+      // https://tools.ietf.org/html/rfc3986#section-3
+      if (justDomain.test(base)) {
+        baseUrls[' ' + base] = base + '/';
+      } else {
+        baseUrls[' ' + base] = rtrim(base, '/', true);
+      }
+    }
+    base = baseUrls[' ' + base];
+    var relativeBase = base.indexOf(':') === -1;
+    if (href.substring(0, 2) === '//') {
+      if (relativeBase) {
+        return href;
+      }
+      return base.replace(protocol, '$1') + href;
+    } else if (href.charAt(0) === '/') {
+      if (relativeBase) {
+        return href;
+      }
+      return base.replace(domain, '$1') + href;
+    } else {
+      return base + href;
+    }
+  }
+  var noopTest = {
+    exec: function noopTest() {}
+  };
+  function merge(obj) {
+    var i = 1,
+      target,
+      key;
+    for (; i < arguments.length; i++) {
+      target = arguments[i];
+      for (key in target) {
+        if (Object.prototype.hasOwnProperty.call(target, key)) {
+          obj[key] = target[key];
+        }
+      }
+    }
+    return obj;
+  }
+  function splitCells(tableRow, count) {
+    // ensure that every cell-delimiting pipe has a space
+    // before it to distinguish it from an escaped pipe
+    var row = tableRow.replace(/\|/g, function (match, offset, str) {
+        var escaped = false,
+          curr = offset;
+        while (--curr >= 0 && str[curr] === '\\') {
+          escaped = !escaped;
+        }
+        if (escaped) {
+          // odd number of slashes means | is escaped
+          // so we leave it alone
+          return '|';
+        } else {
+          // add space before unescaped |
+          return ' |';
+        }
+      }),
+      cells = row.split(/ \|/);
+    var i = 0;
+
+    // First/last cell in a row cannot be empty if it has no leading/trailing pipe
+    if (!cells[0].trim()) {
+      cells.shift();
+    }
+    if (cells.length > 0 && !cells[cells.length - 1].trim()) {
+      cells.pop();
+    }
+    if (cells.length > count) {
+      cells.splice(count);
+    } else {
+      while (cells.length < count) {
+        cells.push('');
+      }
+    }
+    for (; i < cells.length; i++) {
+      // leading or trailing whitespace is ignored per the gfm spec
+      cells[i] = cells[i].trim().replace(/\\\|/g, '|');
+    }
+    return cells;
+  }
+
+  /**
+   * Remove trailing 'c's. Equivalent to str.replace(/c*$/, '').
+   * /c*$/ is vulnerable to REDOS.
+   *
+   * @param {string} str
+   * @param {string} c
+   * @param {boolean} invert Remove suffix of non-c chars instead. Default falsey.
+   */
+  function rtrim(str, c, invert) {
+    var l = str.length;
+    if (l === 0) {
+      return '';
+    }
+
+    // Length of suffix matching the invert condition.
+    var suffLen = 0;
+
+    // Step left until we fail to match the invert condition.
+    while (suffLen < l) {
+      var currChar = str.charAt(l - suffLen - 1);
+      if (currChar === c && !invert) {
+        suffLen++;
+      } else if (currChar !== c && invert) {
+        suffLen++;
+      } else {
+        break;
+      }
+    }
+    return str.slice(0, l - suffLen);
+  }
+  function findClosingBracket(str, b) {
+    if (str.indexOf(b[1]) === -1) {
+      return -1;
+    }
+    var l = str.length;
+    var level = 0,
+      i = 0;
+    for (; i < l; i++) {
+      if (str[i] === '\\') {
+        i++;
+      } else if (str[i] === b[0]) {
+        level++;
+      } else if (str[i] === b[1]) {
+        level--;
+        if (level < 0) {
+          return i;
+        }
+      }
+    }
+    return -1;
+  }
+  function checkSanitizeDeprecation(opt) {
+    if (opt && opt.sanitize && !opt.silent) {
+      console.warn('marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options');
+    }
+  }
+
+  // copied from https://stackoverflow.com/a/5450113/806777
+  /**
+   * @param {string} pattern
+   * @param {number} count
+   */
+  function repeatString(pattern, count) {
+    if (count < 1) {
+      return '';
+    }
+    var result = '';
+    while (count > 1) {
+      if (count & 1) {
+        result += pattern;
+      }
+      count >>= 1;
+      pattern += pattern;
+    }
+    return result + pattern;
+  }
+
+  function outputLink(cap, link, raw, lexer) {
+    var href = link.href;
+    var title = link.title ? escape(link.title) : null;
+    var text = cap[1].replace(/\\([\[\]])/g, '$1');
+    if (cap[0].charAt(0) !== '!') {
+      lexer.state.inLink = true;
+      var token = {
+        type: 'link',
+        raw: raw,
+        href: href,
+        title: title,
+        text: text,
+        tokens: lexer.inlineTokens(text)
+      };
+      lexer.state.inLink = false;
+      return token;
+    }
+    return {
+      type: 'image',
+      raw: raw,
+      href: href,
+      title: title,
+      text: escape(text)
+    };
+  }
+  function indentCodeCompensation(raw, text) {
+    var matchIndentToCode = raw.match(/^(\s+)(?:```)/);
+    if (matchIndentToCode === null) {
+      return text;
+    }
+    var indentToCode = matchIndentToCode[1];
+    return text.split('\n').map(function (node) {
+      var matchIndentInNode = node.match(/^\s+/);
+      if (matchIndentInNode === null) {
+        return node;
+      }
+      var indentInNode = matchIndentInNode[0];
+      if (indentInNode.length >= indentToCode.length) {
+        return node.slice(indentToCode.length);
+      }
+      return node;
+    }).join('\n');
+  }
+
+  /**
+   * Tokenizer
+   */
+  var Tokenizer = /*#__PURE__*/function () {
+    function Tokenizer(options) {
+      this.options = options || exports.defaults;
+    }
+    var _proto = Tokenizer.prototype;
+    _proto.space = function space(src) {
+      var cap = this.rules.block.newline.exec(src);
+      if (cap && cap[0].length > 0) {
+        return {
+          type: 'space',
+          raw: cap[0]
+        };
+      }
+    };
+    _proto.code = function code(src) {
+      var cap = this.rules.block.code.exec(src);
+      if (cap) {
+        var text = cap[0].replace(/^ {1,4}/gm, '');
+        return {
+          type: 'code',
+          raw: cap[0],
+          codeBlockStyle: 'indented',
+          text: !this.options.pedantic ? rtrim(text, '\n') : text
+        };
+      }
+    };
+    _proto.fences = function fences(src) {
+      var cap = this.rules.block.fences.exec(src);
+      if (cap) {
+        var raw = cap[0];
+        var text = indentCodeCompensation(raw, cap[3] || '');
+        return {
+          type: 'code',
+          raw: raw,
+          lang: cap[2] ? cap[2].trim().replace(this.rules.inline._escapes, '$1') : cap[2],
+          text: text
+        };
+      }
+    };
+    _proto.heading = function heading(src) {
+      var cap = this.rules.block.heading.exec(src);
+      if (cap) {
+        var text = cap[2].trim();
+
+        // remove trailing #s
+        if (/#$/.test(text)) {
+          var trimmed = rtrim(text, '#');
+          if (this.options.pedantic) {
+            text = trimmed.trim();
+          } else if (!trimmed || / $/.test(trimmed)) {
+            // CommonMark requires space before trailing #s
+            text = trimmed.trim();
+          }
+        }
+        return {
+          type: 'heading',
+          raw: cap[0],
+          depth: cap[1].length,
+          text: text,
+          tokens: this.lexer.inline(text)
+        };
+      }
+    };
+    _proto.hr = function hr(src) {
+      var cap = this.rules.block.hr.exec(src);
+      if (cap) {
+        return {
+          type: 'hr',
+          raw: cap[0]
+        };
+      }
+    };
+    _proto.blockquote = function blockquote(src) {
+      var cap = this.rules.block.blockquote.exec(src);
+      if (cap) {
+        var text = cap[0].replace(/^ *>[ \t]?/gm, '');
+        return {
+          type: 'blockquote',
+          raw: cap[0],
+          tokens: this.lexer.blockTokens(text, []),
+          text: text
+        };
+      }
+    };
+    _proto.list = function list(src) {
+      var cap = this.rules.block.list.exec(src);
+      if (cap) {
+        var raw, istask, ischecked, indent, i, blankLine, endsWithBlankLine, line, nextLine, rawLine, itemContents, endEarly;
+        var bull = cap[1].trim();
+        var isordered = bull.length > 1;
+        var list = {
+          type: 'list',
+          raw: '',
+          ordered: isordered,
+          start: isordered ? +bull.slice(0, -1) : '',
+          loose: false,
+          items: []
+        };
+        bull = isordered ? "\\d{1,9}\\" + bull.slice(-1) : "\\" + bull;
+        if (this.options.pedantic) {
+          bull = isordered ? bull : '[*+-]';
+        }
+
+        // Get next list item
+        var itemRegex = new RegExp("^( {0,3}" + bull + ")((?:[\t ][^\\n]*)?(?:\\n|$))");
+
+        // Check if current bullet point can start a new List Item
+        while (src) {
+          endEarly = false;
+          if (!(cap = itemRegex.exec(src))) {
+            break;
+          }
+          if (this.rules.block.hr.test(src)) {
+            // End list if bullet was actually HR (possibly move into itemRegex?)
+            break;
+          }
+          raw = cap[0];
+          src = src.substring(raw.length);
+          line = cap[2].split('\n', 1)[0];
+          nextLine = src.split('\n', 1)[0];
+          if (this.options.pedantic) {
+            indent = 2;
+            itemContents = line.trimLeft();
+          } else {
+            indent = cap[2].search(/[^ ]/); // Find first non-space char
+            indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent
+            itemContents = line.slice(indent);
+            indent += cap[1].length;
+          }
+          blankLine = false;
+          if (!line && /^ *$/.test(nextLine)) {
+            // Items begin with at most one blank line
+            raw += nextLine + '\n';
+            src = src.substring(nextLine.length + 1);
+            endEarly = true;
+          }
+          if (!endEarly) {
+            var nextBulletRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}(?:[*+-]|\\d{1,9}[.)])((?: [^\\n]*)?(?:\\n|$))");
+            var hrRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)");
+            var fencesBeginRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}(?:```|~~~)");
+            var headingBeginRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}#");
+
+            // Check if following lines should be included in List Item
+            while (src) {
+              rawLine = src.split('\n', 1)[0];
+              line = rawLine;
+
+              // Re-align to follow commonmark nesting rules
+              if (this.options.pedantic) {
+                line = line.replace(/^ {1,4}(?=( {4})*[^ ])/g, '  ');
+              }
+
+              // End list item if found code fences
+              if (fencesBeginRegex.test(line)) {
+                break;
+              }
+
+              // End list item if found start of new heading
+              if (headingBeginRegex.test(line)) {
+                break;
+              }
+
+              // End list item if found start of new bullet
+              if (nextBulletRegex.test(line)) {
+                break;
+              }
+
+              // Horizontal rule found
+              if (hrRegex.test(src)) {
+                break;
+              }
+              if (line.search(/[^ ]/) >= indent || !line.trim()) {
+                // Dedent if possible
+                itemContents += '\n' + line.slice(indent);
+              } else if (!blankLine) {
+                // Until blank line, item doesn't need indentation
+                itemContents += '\n' + line;
+              } else {
+                // Otherwise, improper indentation ends this item
+                break;
+              }
+              if (!blankLine && !line.trim()) {
+                // Check if current line is blank
+                blankLine = true;
+              }
+              raw += rawLine + '\n';
+              src = src.substring(rawLine.length + 1);
+            }
+          }
+          if (!list.loose) {
+            // If the previous item ended with a blank line, the list is loose
+            if (endsWithBlankLine) {
+              list.loose = true;
+            } else if (/\n *\n *$/.test(raw)) {
+              endsWithBlankLine = true;
+            }
+          }
+
+          // Check for task list items
+          if (this.options.gfm) {
+            istask = /^\[[ xX]\] /.exec(itemContents);
+            if (istask) {
+              ischecked = istask[0] !== '[ ] ';
+              itemContents = itemContents.replace(/^\[[ xX]\] +/, '');
+            }
+          }
+          list.items.push({
+            type: 'list_item',
+            raw: raw,
+            task: !!istask,
+            checked: ischecked,
+            loose: false,
+            text: itemContents
+          });
+          list.raw += raw;
+        }
+
+        // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic
+        list.items[list.items.length - 1].raw = raw.trimRight();
+        list.items[list.items.length - 1].text = itemContents.trimRight();
+        list.raw = list.raw.trimRight();
+        var l = list.items.length;
+
+        // Item child tokens handled here at end because we needed to have the final item to trim it first
+        for (i = 0; i < l; i++) {
+          this.lexer.state.top = false;
+          list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []);
+          var spacers = list.items[i].tokens.filter(function (t) {
+            return t.type === 'space';
+          });
+          var hasMultipleLineBreaks = spacers.every(function (t) {
+            var chars = t.raw.split('');
+            var lineBreaks = 0;
+            for (var _iterator = _createForOfIteratorHelperLoose(chars), _step; !(_step = _iterator()).done;) {
+              var _char = _step.value;
+              if (_char === '\n') {
+                lineBreaks += 1;
+              }
+              if (lineBreaks > 1) {
+                return true;
+              }
+            }
+            return false;
+          });
+          if (!list.loose && spacers.length && hasMultipleLineBreaks) {
+            // Having a single line break doesn't mean a list is loose. A single line break is terminating the last list item
+            list.loose = true;
+            list.items[i].loose = true;
+          }
+        }
+        return list;
+      }
+    };
+    _proto.html = function html(src) {
+      var cap = this.rules.block.html.exec(src);
+      if (cap) {
+        var token = {
+          type: 'html',
+          raw: cap[0],
+          pre: !this.options.sanitizer && (cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style'),
+          text: cap[0]
+        };
+        if (this.options.sanitize) {
+          var text = this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]);
+          token.type = 'paragraph';
+          token.text = text;
+          token.tokens = this.lexer.inline(text);
+        }
+        return token;
+      }
+    };
+    _proto.def = function def(src) {
+      var cap = this.rules.block.def.exec(src);
+      if (cap) {
+        var tag = cap[1].toLowerCase().replace(/\s+/g, ' ');
+        var href = cap[2] ? cap[2].replace(/^<(.*)>$/, '$1').replace(this.rules.inline._escapes, '$1') : '';
+        var title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline._escapes, '$1') : cap[3];
+        return {
+          type: 'def',
+          tag: tag,
+          raw: cap[0],
+          href: href,
+          title: title
+        };
+      }
+    };
+    _proto.table = function table(src) {
+      var cap = this.rules.block.table.exec(src);
+      if (cap) {
+        var item = {
+          type: 'table',
+          header: splitCells(cap[1]).map(function (c) {
+            return {
+              text: c
+            };
+          }),
+          align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */),
+          rows: cap[3] && cap[3].trim() ? cap[3].replace(/\n[ \t]*$/, '').split('\n') : []
+        };
+        if (item.header.length === item.align.length) {
+          item.raw = cap[0];
+          var l = item.align.length;
+          var i, j, k, row;
+          for (i = 0; i < l; i++) {
+            if (/^ *-+: *$/.test(item.align[i])) {
+              item.align[i] = 'right';
+            } else if (/^ *:-+: *$/.test(item.align[i])) {
+              item.align[i] = 'center';
+            } else if (/^ *:-+ *$/.test(item.align[i])) {
+              item.align[i] = 'left';
+            } else {
+              item.align[i] = null;
+            }
+          }
+          l = item.rows.length;
+          for (i = 0; i < l; i++) {
+            item.rows[i] = splitCells(item.rows[i], item.header.length).map(function (c) {
+              return {
+                text: c
+              };
+            });
+          }
+
+          // parse child tokens inside headers and cells
+
+          // header child tokens
+          l = item.header.length;
+          for (j = 0; j < l; j++) {
+            item.header[j].tokens = this.lexer.inline(item.header[j].text);
+          }
+
+          // cell child tokens
+          l = item.rows.length;
+          for (j = 0; j < l; j++) {
+            row = item.rows[j];
+            for (k = 0; k < row.length; k++) {
+              row[k].tokens = this.lexer.inline(row[k].text);
+            }
+          }
+          return item;
+        }
+      }
+    };
+    _proto.lheading = function lheading(src) {
+      var cap = this.rules.block.lheading.exec(src);
+      if (cap) {
+        return {
+          type: 'heading',
+          raw: cap[0],
+          depth: cap[2].charAt(0) === '=' ? 1 : 2,
+          text: cap[1],
+          tokens: this.lexer.inline(cap[1])
+        };
+      }
+    };
+    _proto.paragraph = function paragraph(src) {
+      var cap = this.rules.block.paragraph.exec(src);
+      if (cap) {
+        var text = cap[1].charAt(cap[1].length - 1) === '\n' ? cap[1].slice(0, -1) : cap[1];
+        return {
+          type: 'paragraph',
+          raw: cap[0],
+          text: text,
+          tokens: this.lexer.inline(text)
+        };
+      }
+    };
+    _proto.text = function text(src) {
+      var cap = this.rules.block.text.exec(src);
+      if (cap) {
+        return {
+          type: 'text',
+          raw: cap[0],
+          text: cap[0],
+          tokens: this.lexer.inline(cap[0])
+        };
+      }
+    };
+    _proto.escape = function escape$1(src) {
+      var cap = this.rules.inline.escape.exec(src);
+      if (cap) {
+        return {
+          type: 'escape',
+          raw: cap[0],
+          text: escape(cap[1])
+        };
+      }
+    };
+    _proto.tag = function tag(src) {
+      var cap = this.rules.inline.tag.exec(src);
+      if (cap) {
+        if (!this.lexer.state.inLink && /^<a /i.test(cap[0])) {
+          this.lexer.state.inLink = true;
+        } else if (this.lexer.state.inLink && /^<\/a>/i.test(cap[0])) {
+          this.lexer.state.inLink = false;
+        }
+        if (!this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])) {
+          this.lexer.state.inRawBlock = true;
+        } else if (this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])) {
+          this.lexer.state.inRawBlock = false;
+        }
+        return {
+          type: this.options.sanitize ? 'text' : 'html',
+          raw: cap[0],
+          inLink: this.lexer.state.inLink,
+          inRawBlock: this.lexer.state.inRawBlock,
+          text: this.options.sanitize ? this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]) : cap[0]
+        };
+      }
+    };
+    _proto.link = function link(src) {
+      var cap = this.rules.inline.link.exec(src);
+      if (cap) {
+        var trimmedUrl = cap[2].trim();
+        if (!this.options.pedantic && /^</.test(trimmedUrl)) {
+          // commonmark requires matching angle brackets
+          if (!/>$/.test(trimmedUrl)) {
+            return;
+          }
+
+          // ending angle bracket cannot be escaped
+          var rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\');
+          if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) {
+            return;
+          }
+        } else {
+          // find closing parenthesis
+          var lastParenIndex = findClosingBracket(cap[2], '()');
+          if (lastParenIndex > -1) {
+            var start = cap[0].indexOf('!') === 0 ? 5 : 4;
+            var linkLen = start + cap[1].length + lastParenIndex;
+            cap[2] = cap[2].substring(0, lastParenIndex);
+            cap[0] = cap[0].substring(0, linkLen).trim();
+            cap[3] = '';
+          }
+        }
+        var href = cap[2];
+        var title = '';
+        if (this.options.pedantic) {
+          // split pedantic href and title
+          var link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href);
+          if (link) {
+            href = link[1];
+            title = link[3];
+          }
+        } else {
+          title = cap[3] ? cap[3].slice(1, -1) : '';
+        }
+        href = href.trim();
+        if (/^</.test(href)) {
+          if (this.options.pedantic && !/>$/.test(trimmedUrl)) {
+            // pedantic allows starting angle bracket without ending angle bracket
+            href = href.slice(1);
+          } else {
+            href = href.slice(1, -1);
+          }
+        }
+        return outputLink(cap, {
+          href: href ? href.replace(this.rules.inline._escapes, '$1') : href,
+          title: title ? title.replace(this.rules.inline._escapes, '$1') : title
+        }, cap[0], this.lexer);
+      }
+    };
+    _proto.reflink = function reflink(src, links) {
+      var cap;
+      if ((cap = this.rules.inline.reflink.exec(src)) || (cap = this.rules.inline.nolink.exec(src))) {
+        var link = (cap[2] || cap[1]).replace(/\s+/g, ' ');
+        link = links[link.toLowerCase()];
+        if (!link) {
+          var text = cap[0].charAt(0);
+          return {
+            type: 'text',
+            raw: text,
+            text: text
+          };
+        }
+        return outputLink(cap, link, cap[0], this.lexer);
+      }
+    };
+    _proto.emStrong = function emStrong(src, maskedSrc, prevChar) {
+      if (prevChar === void 0) {
+        prevChar = '';
+      }
+      var match = this.rules.inline.emStrong.lDelim.exec(src);
+      if (!match) return;
+
+      // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well
+      if (match[3] && prevChar.match(/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE3F\uDE40\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDF02\uDF04-\uDF10\uDF12-\uDF33\uDF50-\uDF59\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883\uD885-\uD887][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2F\uDC41-\uDC46]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD32\uDD50-\uDD52\uDD55\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEC0-\uDED3\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E\uDF25-\uDF2A]|\uD838[\uDC30-\uDC6D\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDCD0-\uDCEB\uDCF0-\uDCF9\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF39\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A\uDF50-\uDFFF]|\uD888[\uDC00-\uDFAF])/)) return;
+      var nextChar = match[1] || match[2] || '';
+      if (!nextChar || nextChar && (prevChar === '' || this.rules.inline.punctuation.exec(prevChar))) {
+        var lLength = match[0].length - 1;
+        var rDelim,
+          rLength,
+          delimTotal = lLength,
+          midDelimTotal = 0;
+        var endReg = match[0][0] === '*' ? this.rules.inline.emStrong.rDelimAst : this.rules.inline.emStrong.rDelimUnd;
+        endReg.lastIndex = 0;
+
+        // Clip maskedSrc to same section of string as src (move to lexer?)
+        maskedSrc = maskedSrc.slice(-1 * src.length + lLength);
+        while ((match = endReg.exec(maskedSrc)) != null) {
+          rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6];
+          if (!rDelim) continue; // skip single * in __abc*abc__
+
+          rLength = rDelim.length;
+          if (match[3] || match[4]) {
+            // found another Left Delim
+            delimTotal += rLength;
+            continue;
+          } else if (match[5] || match[6]) {
+            // either Left or Right Delim
+            if (lLength % 3 && !((lLength + rLength) % 3)) {
+              midDelimTotal += rLength;
+              continue; // CommonMark Emphasis Rules 9-10
+            }
+          }
+
+          delimTotal -= rLength;
+          if (delimTotal > 0) continue; // Haven't found enough closing delimiters
+
+          // Remove extra characters. *a*** -> *a*
+          rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal);
+          var raw = src.slice(0, lLength + match.index + (match[0].length - rDelim.length) + rLength);
+
+          // Create `em` if smallest delimiter has odd char count. *a***
+          if (Math.min(lLength, rLength) % 2) {
+            var _text = raw.slice(1, -1);
+            return {
+              type: 'em',
+              raw: raw,
+              text: _text,
+              tokens: this.lexer.inlineTokens(_text)
+            };
+          }
+
+          // Create 'strong' if smallest delimiter has even char count. **a***
+          var text = raw.slice(2, -2);
+          return {
+            type: 'strong',
+            raw: raw,
+            text: text,
+            tokens: this.lexer.inlineTokens(text)
+          };
+        }
+      }
+    };
+    _proto.codespan = function codespan(src) {
+      var cap = this.rules.inline.code.exec(src);
+      if (cap) {
+        var text = cap[2].replace(/\n/g, ' ');
+        var hasNonSpaceChars = /[^ ]/.test(text);
+        var hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text);
+        if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) {
+          text = text.substring(1, text.length - 1);
+        }
+        text = escape(text, true);
+        return {
+          type: 'codespan',
+          raw: cap[0],
+          text: text
+        };
+      }
+    };
+    _proto.br = function br(src) {
+      var cap = this.rules.inline.br.exec(src);
+      if (cap) {
+        return {
+          type: 'br',
+          raw: cap[0]
+        };
+      }
+    };
+    _proto.del = function del(src) {
+      var cap = this.rules.inline.del.exec(src);
+      if (cap) {
+        return {
+          type: 'del',
+          raw: cap[0],
+          text: cap[2],
+          tokens: this.lexer.inlineTokens(cap[2])
+        };
+      }
+    };
+    _proto.autolink = function autolink(src, mangle) {
+      var cap = this.rules.inline.autolink.exec(src);
+      if (cap) {
+        var text, href;
+        if (cap[2] === '@') {
+          text = escape(this.options.mangle ? mangle(cap[1]) : cap[1]);
+          href = 'mailto:' + text;
+        } else {
+          text = escape(cap[1]);
+          href = text;
+        }
+        return {
+          type: 'link',
+          raw: cap[0],
+          text: text,
+          href: href,
+          tokens: [{
+            type: 'text',
+            raw: text,
+            text: text
+          }]
+        };
+      }
+    };
+    _proto.url = function url(src, mangle) {
+      var cap;
+      if (cap = this.rules.inline.url.exec(src)) {
+        var text, href;
+        if (cap[2] === '@') {
+          text = escape(this.options.mangle ? mangle(cap[0]) : cap[0]);
+          href = 'mailto:' + text;
+        } else {
+          // do extended autolink path validation
+          var prevCapZero;
+          do {
+            prevCapZero = cap[0];
+            cap[0] = this.rules.inline._backpedal.exec(cap[0])[0];
+          } while (prevCapZero !== cap[0]);
+          text = escape(cap[0]);
+          if (cap[1] === 'www.') {
+            href = 'http://' + text;
+          } else {
+            href = text;
+          }
+        }
+        return {
+          type: 'link',
+          raw: cap[0],
+          text: text,
+          href: href,
+          tokens: [{
+            type: 'text',
+            raw: text,
+            text: text
+          }]
+        };
+      }
+    };
+    _proto.inlineText = function inlineText(src, smartypants) {
+      var cap = this.rules.inline.text.exec(src);
+      if (cap) {
+        var text;
+        if (this.lexer.state.inRawBlock) {
+          text = this.options.sanitize ? this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]) : cap[0];
+        } else {
+          text = escape(this.options.smartypants ? smartypants(cap[0]) : cap[0]);
+        }
+        return {
+          type: 'text',
+          raw: cap[0],
+          text: text
+        };
+      }
+    };
+    return Tokenizer;
+  }();
+
+  /**
+   * Block-Level Grammar
+   */
+  var block = {
+    newline: /^(?: *(?:\n|$))+/,
+    code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,
+    fences: /^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/,
+    hr: /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,
+    heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,
+    blockquote: /^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,
+    list: /^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,
+    html: '^ {0,3}(?:' // optional indentation
+    + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)' // (1)
+    + '|comment[^\\n]*(\\n+|$)' // (2)
+    + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3)
+    + '|<![A-Z][\\s\\S]*?(?:>\\n*|$)' // (4)
+    + '|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)' // (5)
+    + '|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6)
+    + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag
+    + '|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) closing tag
+    + ')',
+    def: /^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,
+    table: noopTest,
+    lheading: /^((?:.|\n(?!\n))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,
+    // regex template, placeholders will be replaced according to different paragraph
+    // interruption rules of commonmark and the original markdown spec:
+    _paragraph: /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,
+    text: /^[^\n]+/
+  };
+  block._label = /(?!\s*\])(?:\\.|[^\[\]\\])+/;
+  block._title = /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/;
+  block.def = edit(block.def).replace('label', block._label).replace('title', block._title).getRegex();
+  block.bullet = /(?:[*+-]|\d{1,9}[.)])/;
+  block.listItemStart = edit(/^( *)(bull) */).replace('bull', block.bullet).getRegex();
+  block.list = edit(block.list).replace(/bull/g, block.bullet).replace('hr', '\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))').replace('def', '\\n+(?=' + block.def.source + ')').getRegex();
+  block._tag = 'address|article|aside|base|basefont|blockquote|body|caption' + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + '|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr' + '|track|ul';
+  block._comment = /<!--(?!-?>)[\s\S]*?(?:-->|$)/;
+  block.html = edit(block.html, 'i').replace('comment', block._comment).replace('tag', block._tag).replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex();
+  block.paragraph = edit(block._paragraph).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs
+  .replace('|table', '').replace('blockquote', ' {0,3}>').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt
+  .replace('html', '</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // pars can be interrupted by type (6) html blocks
+  .getRegex();
+  block.blockquote = edit(block.blockquote).replace('paragraph', block.paragraph).getRegex();
+
+  /**
+   * Normal Block Grammar
+   */
+
+  block.normal = merge({}, block);
+
+  /**
+   * GFM Block Grammar
+   */
+
+  block.gfm = merge({}, block.normal, {
+    table: '^ *([^\\n ].*\\|.*)\\n' // Header
+    + ' {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?' // Align
+    + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)' // Cells
+  });
+
+  block.gfm.table = edit(block.gfm.table).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('blockquote', ' {0,3}>').replace('code', ' {4}[^\\n]').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt
+  .replace('html', '</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // tables can be interrupted by type (6) html blocks
+  .getRegex();
+  block.gfm.paragraph = edit(block._paragraph).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs
+  .replace('table', block.gfm.table) // interrupt paragraphs with table
+  .replace('blockquote', ' {0,3}>').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt
+  .replace('html', '</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // pars can be interrupted by type (6) html blocks
+  .getRegex();
+  /**
+   * Pedantic grammar (original John Gruber's loose markdown specification)
+   */
+
+  block.pedantic = merge({}, block.normal, {
+    html: edit('^ *(?:comment *(?:\\n|\\s*$)' + '|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)' // closed tag
+    + '|<tag(?:"[^"]*"|\'[^\']*\'|\\s[^\'"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))').replace('comment', block._comment).replace(/tag/g, '(?!(?:' + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b').getRegex(),
+    def: /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,
+    heading: /^(#{1,6})(.*)(?:\n+|$)/,
+    fences: noopTest,
+    // fences not supported
+    lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,
+    paragraph: edit(block.normal._paragraph).replace('hr', block.hr).replace('heading', ' *#{1,6} *[^\n]').replace('lheading', block.lheading).replace('blockquote', ' {0,3}>').replace('|fences', '').replace('|list', '').replace('|html', '').getRegex()
+  });
+
+  /**
+   * Inline-Level Grammar
+   */
+  var inline = {
+    escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,
+    autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/,
+    url: noopTest,
+    tag: '^comment' + '|^</[a-zA-Z][\\w:-]*\\s*>' // self-closing tag
+    + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag
+    + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. <?php ?>
+    + '|^<![a-zA-Z]+\\s[\\s\\S]*?>' // declaration, e.g. <!DOCTYPE html>
+    + '|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>',
+    // CDATA section
+    link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,
+    reflink: /^!?\[(label)\]\[(ref)\]/,
+    nolink: /^!?\[(ref)\](?:\[\])?/,
+    reflinkSearch: 'reflink|nolink(?!\\()',
+    emStrong: {
+      lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,
+      //        (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left.  (5) and (6) can be either Left or Right.
+      //          () Skip orphan inside strong                                      () Consume to delim     (1) #***                (2) a***#, a***                             (3) #***a, ***a                 (4) ***#              (5) #***#                 (6) a***a
+      rDelimAst: /^(?:[^_*\\]|\\.)*?\_\_(?:[^_*\\]|\\.)*?\*(?:[^_*\\]|\\.)*?(?=\_\_)|(?:[^*\\]|\\.)+(?=[^*])|[punct_](\*+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|(?:[^punct*_\s\\]|\\.)(\*+)(?=[^punct*_\s])/,
+      rDelimUnd: /^(?:[^_*\\]|\\.)*?\*\*(?:[^_*\\]|\\.)*?\_(?:[^_*\\]|\\.)*?(?=\*\*)|(?:[^_\\]|\\.)+(?=[^_])|[punct*](\_+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/ // ^- Not allowed for _
+    },
+
+    code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,
+    br: /^( {2,}|\\)\n(?!\s*$)/,
+    del: noopTest,
+    text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,
+    punctuation: /^([\spunctuation])/
+  };
+
+  // list of punctuation marks from CommonMark spec
+  // without * and _ to handle the different emphasis markers * and _
+  inline._punctuation = '!"#$%&\'()+\\-.,/:;<=>?@\\[\\]`^{|}~';
+  inline.punctuation = edit(inline.punctuation).replace(/punctuation/g, inline._punctuation).getRegex();
+
+  // sequences em should skip over [title](link), `code`, <html>
+  inline.blockSkip = /\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g;
+  // lookbehind is not available on Safari as of version 16
+  // inline.escapedEmSt = /(?<=(?:^|[^\\)(?:\\[^])*)\\[*_]/g;
+  inline.escapedEmSt = /(?:^|[^\\])(?:\\\\)*\\[*_]/g;
+  inline._comment = edit(block._comment).replace('(?:-->|$)', '-->').getRegex();
+  inline.emStrong.lDelim = edit(inline.emStrong.lDelim).replace(/punct/g, inline._punctuation).getRegex();
+  inline.emStrong.rDelimAst = edit(inline.emStrong.rDelimAst, 'g').replace(/punct/g, inline._punctuation).getRegex();
+  inline.emStrong.rDelimUnd = edit(inline.emStrong.rDelimUnd, 'g').replace(/punct/g, inline._punctuation).getRegex();
+  inline._escapes = /\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g;
+  inline._scheme = /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/;
+  inline._email = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/;
+  inline.autolink = edit(inline.autolink).replace('scheme', inline._scheme).replace('email', inline._email).getRegex();
+  inline._attribute = /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/;
+  inline.tag = edit(inline.tag).replace('comment', inline._comment).replace('attribute', inline._attribute).getRegex();
+  inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/;
+  inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/;
+  inline._title = /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/;
+  inline.link = edit(inline.link).replace('label', inline._label).replace('href', inline._href).replace('title', inline._title).getRegex();
+  inline.reflink = edit(inline.reflink).replace('label', inline._label).replace('ref', block._label).getRegex();
+  inline.nolink = edit(inline.nolink).replace('ref', block._label).getRegex();
+  inline.reflinkSearch = edit(inline.reflinkSearch, 'g').replace('reflink', inline.reflink).replace('nolink', inline.nolink).getRegex();
+
+  /**
+   * Normal Inline Grammar
+   */
+
+  inline.normal = merge({}, inline);
+
+  /**
+   * Pedantic Inline Grammar
+   */
+
+  inline.pedantic = merge({}, inline.normal, {
+    strong: {
+      start: /^__|\*\*/,
+      middle: /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,
+      endAst: /\*\*(?!\*)/g,
+      endUnd: /__(?!_)/g
+    },
+    em: {
+      start: /^_|\*/,
+      middle: /^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,
+      endAst: /\*(?!\*)/g,
+      endUnd: /_(?!_)/g
+    },
+    link: edit(/^!?\[(label)\]\((.*?)\)/).replace('label', inline._label).getRegex(),
+    reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace('label', inline._label).getRegex()
+  });
+
+  /**
+   * GFM Inline Grammar
+   */
+
+  inline.gfm = merge({}, inline.normal, {
+    escape: edit(inline.escape).replace('])', '~|])').getRegex(),
+    _extended_email: /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,
+    url: /^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,
+    _backpedal: /(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,
+    del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,
+    text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/
+  });
+  inline.gfm.url = edit(inline.gfm.url, 'i').replace('email', inline.gfm._extended_email).getRegex();
+  /**
+   * GFM + Line Breaks Inline Grammar
+   */
+
+  inline.breaks = merge({}, inline.gfm, {
+    br: edit(inline.br).replace('{2,}', '*').getRegex(),
+    text: edit(inline.gfm.text).replace('\\b_', '\\b_| {2,}\\n').replace(/\{2,\}/g, '*').getRegex()
+  });
+
+  /**
+   * smartypants text replacement
+   * @param {string} text
+   */
+  function smartypants(text) {
+    return text
+    // em-dashes
+    .replace(/---/g, "\u2014")
+    // en-dashes
+    .replace(/--/g, "\u2013")
+    // opening singles
+    .replace(/(^|[-\u2014/(\[{"\s])'/g, "$1\u2018")
+    // closing singles & apostrophes
+    .replace(/'/g, "\u2019")
+    // opening doubles
+    .replace(/(^|[-\u2014/(\[{\u2018\s])"/g, "$1\u201C")
+    // closing doubles
+    .replace(/"/g, "\u201D")
+    // ellipses
+    .replace(/\.{3}/g, "\u2026");
+  }
+
+  /**
+   * mangle email addresses
+   * @param {string} text
+   */
+  function mangle(text) {
+    var out = '',
+      i,
+      ch;
+    var l = text.length;
+    for (i = 0; i < l; i++) {
+      ch = text.charCodeAt(i);
+      if (Math.random() > 0.5) {
+        ch = 'x' + ch.toString(16);
+      }
+      out += '&#' + ch + ';';
+    }
+    return out;
+  }
+
+  /**
+   * Block Lexer
+   */
+  var Lexer = /*#__PURE__*/function () {
+    function Lexer(options) {
+      this.tokens = [];
+      this.tokens.links = Object.create(null);
+      this.options = options || exports.defaults;
+      this.options.tokenizer = this.options.tokenizer || new Tokenizer();
+      this.tokenizer = this.options.tokenizer;
+      this.tokenizer.options = this.options;
+      this.tokenizer.lexer = this;
+      this.inlineQueue = [];
+      this.state = {
+        inLink: false,
+        inRawBlock: false,
+        top: true
+      };
+      var rules = {
+        block: block.normal,
+        inline: inline.normal
+      };
+      if (this.options.pedantic) {
+        rules.block = block.pedantic;
+        rules.inline = inline.pedantic;
+      } else if (this.options.gfm) {
+        rules.block = block.gfm;
+        if (this.options.breaks) {
+          rules.inline = inline.breaks;
+        } else {
+          rules.inline = inline.gfm;
+        }
+      }
+      this.tokenizer.rules = rules;
+    }
+
+    /**
+     * Expose Rules
+     */
+    /**
+     * Static Lex Method
+     */
+    Lexer.lex = function lex(src, options) {
+      var lexer = new Lexer(options);
+      return lexer.lex(src);
+    }
+
+    /**
+     * Static Lex Inline Method
+     */;
+    Lexer.lexInline = function lexInline(src, options) {
+      var lexer = new Lexer(options);
+      return lexer.inlineTokens(src);
+    }
+
+    /**
+     * Preprocessing
+     */;
+    var _proto = Lexer.prototype;
+    _proto.lex = function lex(src) {
+      src = src.replace(/\r\n|\r/g, '\n');
+      this.blockTokens(src, this.tokens);
+      var next;
+      while (next = this.inlineQueue.shift()) {
+        this.inlineTokens(next.src, next.tokens);
+      }
+      return this.tokens;
+    }
+
+    /**
+     * Lexing
+     */;
+    _proto.blockTokens = function blockTokens(src, tokens) {
+      var _this = this;
+      if (tokens === void 0) {
+        tokens = [];
+      }
+      if (this.options.pedantic) {
+        src = src.replace(/\t/g, '    ').replace(/^ +$/gm, '');
+      } else {
+        src = src.replace(/^( *)(\t+)/gm, function (_, leading, tabs) {
+          return leading + '    '.repeat(tabs.length);
+        });
+      }
+      var token, lastToken, cutSrc, lastParagraphClipped;
+      while (src) {
+        if (this.options.extensions && this.options.extensions.block && this.options.extensions.block.some(function (extTokenizer) {
+          if (token = extTokenizer.call({
+            lexer: _this
+          }, src, tokens)) {
+            src = src.substring(token.raw.length);
+            tokens.push(token);
+            return true;
+          }
+          return false;
+        })) {
+          continue;
+        }
+
+        // newline
+        if (token = this.tokenizer.space(src)) {
+          src = src.substring(token.raw.length);
+          if (token.raw.length === 1 && tokens.length > 0) {
+            // if there's a single \n as a spacer, it's terminating the last line,
+            // so move it there so that we don't get unecessary paragraph tags
+            tokens[tokens.length - 1].raw += '\n';
+          } else {
+            tokens.push(token);
+          }
+          continue;
+        }
+
+        // code
+        if (token = this.tokenizer.code(src)) {
+          src = src.substring(token.raw.length);
+          lastToken = tokens[tokens.length - 1];
+          // An indented code block cannot interrupt a paragraph.
+          if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) {
+            lastToken.raw += '\n' + token.raw;
+            lastToken.text += '\n' + token.text;
+            this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text;
+          } else {
+            tokens.push(token);
+          }
+          continue;
+        }
+
+        // fences
+        if (token = this.tokenizer.fences(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // heading
+        if (token = this.tokenizer.heading(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // hr
+        if (token = this.tokenizer.hr(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // blockquote
+        if (token = this.tokenizer.blockquote(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // list
+        if (token = this.tokenizer.list(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // html
+        if (token = this.tokenizer.html(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // def
+        if (token = this.tokenizer.def(src)) {
+          src = src.substring(token.raw.length);
+          lastToken = tokens[tokens.length - 1];
+          if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) {
+            lastToken.raw += '\n' + token.raw;
+            lastToken.text += '\n' + token.raw;
+            this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text;
+          } else if (!this.tokens.links[token.tag]) {
+            this.tokens.links[token.tag] = {
+              href: token.href,
+              title: token.title
+            };
+          }
+          continue;
+        }
+
+        // table (gfm)
+        if (token = this.tokenizer.table(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // lheading
+        if (token = this.tokenizer.lheading(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // top-level paragraph
+        // prevent paragraph consuming extensions by clipping 'src' to extension start
+        cutSrc = src;
+        if (this.options.extensions && this.options.extensions.startBlock) {
+          (function () {
+            var startIndex = Infinity;
+            var tempSrc = src.slice(1);
+            var tempStart = void 0;
+            _this.options.extensions.startBlock.forEach(function (getStartIndex) {
+              tempStart = getStartIndex.call({
+                lexer: this
+              }, tempSrc);
+              if (typeof tempStart === 'number' && tempStart >= 0) {
+                startIndex = Math.min(startIndex, tempStart);
+              }
+            });
+            if (startIndex < Infinity && startIndex >= 0) {
+              cutSrc = src.substring(0, startIndex + 1);
+            }
+          })();
+        }
+        if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) {
+          lastToken = tokens[tokens.length - 1];
+          if (lastParagraphClipped && lastToken.type === 'paragraph') {
+            lastToken.raw += '\n' + token.raw;
+            lastToken.text += '\n' + token.text;
+            this.inlineQueue.pop();
+            this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text;
+          } else {
+            tokens.push(token);
+          }
+          lastParagraphClipped = cutSrc.length !== src.length;
+          src = src.substring(token.raw.length);
+          continue;
+        }
+
+        // text
+        if (token = this.tokenizer.text(src)) {
+          src = src.substring(token.raw.length);
+          lastToken = tokens[tokens.length - 1];
+          if (lastToken && lastToken.type === 'text') {
+            lastToken.raw += '\n' + token.raw;
+            lastToken.text += '\n' + token.text;
+            this.inlineQueue.pop();
+            this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text;
+          } else {
+            tokens.push(token);
+          }
+          continue;
+        }
+        if (src) {
+          var errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0);
+          if (this.options.silent) {
+            console.error(errMsg);
+            break;
+          } else {
+            throw new Error(errMsg);
+          }
+        }
+      }
+      this.state.top = true;
+      return tokens;
+    };
+    _proto.inline = function inline(src, tokens) {
+      if (tokens === void 0) {
+        tokens = [];
+      }
+      this.inlineQueue.push({
+        src: src,
+        tokens: tokens
+      });
+      return tokens;
+    }
+
+    /**
+     * Lexing/Compiling
+     */;
+    _proto.inlineTokens = function inlineTokens(src, tokens) {
+      var _this2 = this;
+      if (tokens === void 0) {
+        tokens = [];
+      }
+      var token, lastToken, cutSrc;
+
+      // String with links masked to avoid interference with em and strong
+      var maskedSrc = src;
+      var match;
+      var keepPrevChar, prevChar;
+
+      // Mask out reflinks
+      if (this.tokens.links) {
+        var links = Object.keys(this.tokens.links);
+        if (links.length > 0) {
+          while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) {
+            if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) {
+              maskedSrc = maskedSrc.slice(0, match.index) + '[' + repeatString('a', match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex);
+            }
+          }
+        }
+      }
+      // Mask out other blocks
+      while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) {
+        maskedSrc = maskedSrc.slice(0, match.index) + '[' + repeatString('a', match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);
+      }
+
+      // Mask out escaped em & strong delimiters
+      while ((match = this.tokenizer.rules.inline.escapedEmSt.exec(maskedSrc)) != null) {
+        maskedSrc = maskedSrc.slice(0, match.index + match[0].length - 2) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex);
+        this.tokenizer.rules.inline.escapedEmSt.lastIndex--;
+      }
+      while (src) {
+        if (!keepPrevChar) {
+          prevChar = '';
+        }
+        keepPrevChar = false;
+
+        // extensions
+        if (this.options.extensions && this.options.extensions.inline && this.options.extensions.inline.some(function (extTokenizer) {
+          if (token = extTokenizer.call({
+            lexer: _this2
+          }, src, tokens)) {
+            src = src.substring(token.raw.length);
+            tokens.push(token);
+            return true;
+          }
+          return false;
+        })) {
+          continue;
+        }
+
+        // escape
+        if (token = this.tokenizer.escape(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // tag
+        if (token = this.tokenizer.tag(src)) {
+          src = src.substring(token.raw.length);
+          lastToken = tokens[tokens.length - 1];
+          if (lastToken && token.type === 'text' && lastToken.type === 'text') {
+            lastToken.raw += token.raw;
+            lastToken.text += token.text;
+          } else {
+            tokens.push(token);
+          }
+          continue;
+        }
+
+        // link
+        if (token = this.tokenizer.link(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // reflink, nolink
+        if (token = this.tokenizer.reflink(src, this.tokens.links)) {
+          src = src.substring(token.raw.length);
+          lastToken = tokens[tokens.length - 1];
+          if (lastToken && token.type === 'text' && lastToken.type === 'text') {
+            lastToken.raw += token.raw;
+            lastToken.text += token.text;
+          } else {
+            tokens.push(token);
+          }
+          continue;
+        }
+
+        // em & strong
+        if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // code
+        if (token = this.tokenizer.codespan(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // br
+        if (token = this.tokenizer.br(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // del (gfm)
+        if (token = this.tokenizer.del(src)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // autolink
+        if (token = this.tokenizer.autolink(src, mangle)) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // url (gfm)
+        if (!this.state.inLink && (token = this.tokenizer.url(src, mangle))) {
+          src = src.substring(token.raw.length);
+          tokens.push(token);
+          continue;
+        }
+
+        // text
+        // prevent inlineText consuming extensions by clipping 'src' to extension start
+        cutSrc = src;
+        if (this.options.extensions && this.options.extensions.startInline) {
+          (function () {
+            var startIndex = Infinity;
+            var tempSrc = src.slice(1);
+            var tempStart = void 0;
+            _this2.options.extensions.startInline.forEach(function (getStartIndex) {
+              tempStart = getStartIndex.call({
+                lexer: this
+              }, tempSrc);
+              if (typeof tempStart === 'number' && tempStart >= 0) {
+                startIndex = Math.min(startIndex, tempStart);
+              }
+            });
+            if (startIndex < Infinity && startIndex >= 0) {
+              cutSrc = src.substring(0, startIndex + 1);
+            }
+          })();
+        }
+        if (token = this.tokenizer.inlineText(cutSrc, smartypants)) {
+          src = src.substring(token.raw.length);
+          if (token.raw.slice(-1) !== '_') {
+            // Track prevChar before string of ____ started
+            prevChar = token.raw.slice(-1);
+          }
+          keepPrevChar = true;
+          lastToken = tokens[tokens.length - 1];
+          if (lastToken && lastToken.type === 'text') {
+            lastToken.raw += token.raw;
+            lastToken.text += token.text;
+          } else {
+            tokens.push(token);
+          }
+          continue;
+        }
+        if (src) {
+          var errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0);
+          if (this.options.silent) {
+            console.error(errMsg);
+            break;
+          } else {
+            throw new Error(errMsg);
+          }
+        }
+      }
+      return tokens;
+    };
+    _createClass(Lexer, null, [{
+      key: "rules",
+      get: function get() {
+        return {
+          block: block,
+          inline: inline
+        };
+      }
+    }]);
+    return Lexer;
+  }();
+
+  /**
+   * Renderer
+   */
+  var Renderer = /*#__PURE__*/function () {
+    function Renderer(options) {
+      this.options = options || exports.defaults;
+    }
+    var _proto = Renderer.prototype;
+    _proto.code = function code(_code, infostring, escaped) {
+      var lang = (infostring || '').match(/\S*/)[0];
+      if (this.options.highlight) {
+        var out = this.options.highlight(_code, lang);
+        if (out != null && out !== _code) {
+          escaped = true;
+          _code = out;
+        }
+      }
+      _code = _code.replace(/\n$/, '') + '\n';
+      if (!lang) {
+        return '<pre><code>' + (escaped ? _code : escape(_code, true)) + '</code></pre>\n';
+      }
+      return '<pre><code class="' + this.options.langPrefix + escape(lang) + '">' + (escaped ? _code : escape(_code, true)) + '</code></pre>\n';
+    }
+
+    /**
+     * @param {string} quote
+     */;
+    _proto.blockquote = function blockquote(quote) {
+      return "<blockquote>\n" + quote + "</blockquote>\n";
+    };
+    _proto.html = function html(_html) {
+      return _html;
+    }
+
+    /**
+     * @param {string} text
+     * @param {string} level
+     * @param {string} raw
+     * @param {any} slugger
+     */;
+    _proto.heading = function heading(text, level, raw, slugger) {
+      if (this.options.headerIds) {
+        var id = this.options.headerPrefix + slugger.slug(raw);
+        return "<h" + level + " id=\"" + id + "\">" + text + "</h" + level + ">\n";
+      }
+
+      // ignore IDs
+      return "<h" + level + ">" + text + "</h" + level + ">\n";
+    };
+    _proto.hr = function hr() {
+      return this.options.xhtml ? '<hr/>\n' : '<hr>\n';
+    };
+    _proto.list = function list(body, ordered, start) {
+      var type = ordered ? 'ol' : 'ul',
+        startatt = ordered && start !== 1 ? ' start="' + start + '"' : '';
+      return '<' + type + startatt + '>\n' + body + '</' + type + '>\n';
+    }
+
+    /**
+     * @param {string} text
+     */;
+    _proto.listitem = function listitem(text) {
+      return "<li>" + text + "</li>\n";
+    };
+    _proto.checkbox = function checkbox(checked) {
+      return '<input ' + (checked ? 'checked="" ' : '') + 'disabled="" type="checkbox"' + (this.options.xhtml ? ' /' : '') + '> ';
+    }
+
+    /**
+     * @param {string} text
+     */;
+    _proto.paragraph = function paragraph(text) {
+      return "<p>" + text + "</p>\n";
+    }
+
+    /**
+     * @param {string} header
+     * @param {string} body
+     */;
+    _proto.table = function table(header, body) {
+      if (body) body = "<tbody>" + body + "</tbody>";
+      return '<table>\n' + '<thead>\n' + header + '</thead>\n' + body + '</table>\n';
+    }
+
+    /**
+     * @param {string} content
+     */;
+    _proto.tablerow = function tablerow(content) {
+      return "<tr>\n" + content + "</tr>\n";
+    };
+    _proto.tablecell = function tablecell(content, flags) {
+      var type = flags.header ? 'th' : 'td';
+      var tag = flags.align ? "<" + type + " align=\"" + flags.align + "\">" : "<" + type + ">";
+      return tag + content + ("</" + type + ">\n");
+    }
+
+    /**
+     * span level renderer
+     * @param {string} text
+     */;
+    _proto.strong = function strong(text) {
+      return "<strong>" + text + "</strong>";
+    }
+
+    /**
+     * @param {string} text
+     */;
+    _proto.em = function em(text) {
+      return "<em>" + text + "</em>";
+    }
+
+    /**
+     * @param {string} text
+     */;
+    _proto.codespan = function codespan(text) {
+      return "<code>" + text + "</code>";
+    };
+    _proto.br = function br() {
+      return this.options.xhtml ? '<br/>' : '<br>';
+    }
+
+    /**
+     * @param {string} text
+     */;
+    _proto.del = function del(text) {
+      return "<del>" + text + "</del>";
+    }
+
+    /**
+     * @param {string} href
+     * @param {string} title
+     * @param {string} text
+     */;
+    _proto.link = function link(href, title, text) {
+      href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
+      if (href === null) {
+        return text;
+      }
+      var out = '<a href="' + href + '"';
+      if (title) {
+        out += ' title="' + title + '"';
+      }
+      out += '>' + text + '</a>';
+      return out;
+    }
+
+    /**
+     * @param {string} href
+     * @param {string} title
+     * @param {string} text
+     */;
+    _proto.image = function image(href, title, text) {
+      href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
+      if (href === null) {
+        return text;
+      }
+      var out = "<img src=\"" + href + "\" alt=\"" + text + "\"";
+      if (title) {
+        out += " title=\"" + title + "\"";
+      }
+      out += this.options.xhtml ? '/>' : '>';
+      return out;
+    };
+    _proto.text = function text(_text) {
+      return _text;
+    };
+    return Renderer;
+  }();
+
+  /**
+   * TextRenderer
+   * returns only the textual part of the token
+   */
+  var TextRenderer = /*#__PURE__*/function () {
+    function TextRenderer() {}
+    var _proto = TextRenderer.prototype;
+    // no need for block level renderers
+    _proto.strong = function strong(text) {
+      return text;
+    };
+    _proto.em = function em(text) {
+      return text;
+    };
+    _proto.codespan = function codespan(text) {
+      return text;
+    };
+    _proto.del = function del(text) {
+      return text;
+    };
+    _proto.html = function html(text) {
+      return text;
+    };
+    _proto.text = function text(_text) {
+      return _text;
+    };
+    _proto.link = function link(href, title, text) {
+      return '' + text;
+    };
+    _proto.image = function image(href, title, text) {
+      return '' + text;
+    };
+    _proto.br = function br() {
+      return '';
+    };
+    return TextRenderer;
+  }();
+
+  /**
+   * Slugger generates header id
+   */
+  var Slugger = /*#__PURE__*/function () {
+    function Slugger() {
+      this.seen = {};
+    }
+
+    /**
+     * @param {string} value
+     */
+    var _proto = Slugger.prototype;
+    _proto.serialize = function serialize(value) {
+      return value.toLowerCase().trim()
+      // remove html tags
+      .replace(/<[!\/a-z].*?>/ig, '')
+      // remove unwanted chars
+      .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, '').replace(/\s/g, '-');
+    }
+
+    /**
+     * Finds the next safe (unique) slug to use
+     * @param {string} originalSlug
+     * @param {boolean} isDryRun
+     */;
+    _proto.getNextSafeSlug = function getNextSafeSlug(originalSlug, isDryRun) {
+      var slug = originalSlug;
+      var occurenceAccumulator = 0;
+      if (this.seen.hasOwnProperty(slug)) {
+        occurenceAccumulator = this.seen[originalSlug];
+        do {
+          occurenceAccumulator++;
+          slug = originalSlug + '-' + occurenceAccumulator;
+        } while (this.seen.hasOwnProperty(slug));
+      }
+      if (!isDryRun) {
+        this.seen[originalSlug] = occurenceAccumulator;
+        this.seen[slug] = 0;
+      }
+      return slug;
+    }
+
+    /**
+     * Convert string to unique id
+     * @param {object} [options]
+     * @param {boolean} [options.dryrun] Generates the next unique slug without
+     * updating the internal accumulator.
+     */;
+    _proto.slug = function slug(value, options) {
+      if (options === void 0) {
+        options = {};
+      }
+      var slug = this.serialize(value);
+      return this.getNextSafeSlug(slug, options.dryrun);
+    };
+    return Slugger;
+  }();
+
+  /**
+   * Parsing & Compiling
+   */
+  var Parser = /*#__PURE__*/function () {
+    function Parser(options) {
+      this.options = options || exports.defaults;
+      this.options.renderer = this.options.renderer || new Renderer();
+      this.renderer = this.options.renderer;
+      this.renderer.options = this.options;
+      this.textRenderer = new TextRenderer();
+      this.slugger = new Slugger();
+    }
+
+    /**
+     * Static Parse Method
+     */
+    Parser.parse = function parse(tokens, options) {
+      var parser = new Parser(options);
+      return parser.parse(tokens);
+    }
+
+    /**
+     * Static Parse Inline Method
+     */;
+    Parser.parseInline = function parseInline(tokens, options) {
+      var parser = new Parser(options);
+      return parser.parseInline(tokens);
+    }
+
+    /**
+     * Parse Loop
+     */;
+    var _proto = Parser.prototype;
+    _proto.parse = function parse(tokens, top) {
+      if (top === void 0) {
+        top = true;
+      }
+      var out = '',
+        i,
+        j,
+        k,
+        l2,
+        l3,
+        row,
+        cell,
+        header,
+        body,
+        token,
+        ordered,
+        start,
+        loose,
+        itemBody,
+        item,
+        checked,
+        task,
+        checkbox,
+        ret;
+      var l = tokens.length;
+      for (i = 0; i < l; i++) {
+        token = tokens[i];
+
+        // Run any renderer extensions
+        if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) {
+          ret = this.options.extensions.renderers[token.type].call({
+            parser: this
+          }, token);
+          if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(token.type)) {
+            out += ret || '';
+            continue;
+          }
+        }
+        switch (token.type) {
+          case 'space':
+            {
+              continue;
+            }
+          case 'hr':
+            {
+              out += this.renderer.hr();
+              continue;
+            }
+          case 'heading':
+            {
+              out += this.renderer.heading(this.parseInline(token.tokens), token.depth, unescape(this.parseInline(token.tokens, this.textRenderer)), this.slugger);
+              continue;
+            }
+          case 'code':
+            {
+              out += this.renderer.code(token.text, token.lang, token.escaped);
+              continue;
+            }
+          case 'table':
+            {
+              header = '';
+
+              // header
+              cell = '';
+              l2 = token.header.length;
+              for (j = 0; j < l2; j++) {
+                cell += this.renderer.tablecell(this.parseInline(token.header[j].tokens), {
+                  header: true,
+                  align: token.align[j]
+                });
+              }
+              header += this.renderer.tablerow(cell);
+              body = '';
+              l2 = token.rows.length;
+              for (j = 0; j < l2; j++) {
+                row = token.rows[j];
+                cell = '';
+                l3 = row.length;
+                for (k = 0; k < l3; k++) {
+                  cell += this.renderer.tablecell(this.parseInline(row[k].tokens), {
+                    header: false,
+                    align: token.align[k]
+                  });
+                }
+                body += this.renderer.tablerow(cell);
+              }
+              out += this.renderer.table(header, body);
+              continue;
+            }
+          case 'blockquote':
+            {
+              body = this.parse(token.tokens);
+              out += this.renderer.blockquote(body);
+              continue;
+            }
+          case 'list':
+            {
+              ordered = token.ordered;
+              start = token.start;
+              loose = token.loose;
+              l2 = token.items.length;
+              body = '';
+              for (j = 0; j < l2; j++) {
+                item = token.items[j];
+                checked = item.checked;
+                task = item.task;
+                itemBody = '';
+                if (item.task) {
+                  checkbox = this.renderer.checkbox(checked);
+                  if (loose) {
+                    if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') {
+                      item.tokens[0].text = checkbox + ' ' + item.tokens[0].text;
+                      if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
+                        item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text;
+                      }
+                    } else {
+                      item.tokens.unshift({
+                        type: 'text',
+                        text: checkbox
+                      });
+                    }
+                  } else {
+                    itemBody += checkbox;
+                  }
+                }
+                itemBody += this.parse(item.tokens, loose);
+                body += this.renderer.listitem(itemBody, task, checked);
+              }
+              out += this.renderer.list(body, ordered, start);
+              continue;
+            }
+          case 'html':
+            {
+              // TODO parse inline content if parameter markdown=1
+              out += this.renderer.html(token.text);
+              continue;
+            }
+          case 'paragraph':
+            {
+              out += this.renderer.paragraph(this.parseInline(token.tokens));
+              continue;
+            }
+          case 'text':
+            {
+              body = token.tokens ? this.parseInline(token.tokens) : token.text;
+              while (i + 1 < l && tokens[i + 1].type === 'text') {
+                token = tokens[++i];
+                body += '\n' + (token.tokens ? this.parseInline(token.tokens) : token.text);
+              }
+              out += top ? this.renderer.paragraph(body) : body;
+              continue;
+            }
+          default:
+            {
+              var errMsg = 'Token with "' + token.type + '" type was not found.';
+              if (this.options.silent) {
+                console.error(errMsg);
+                return;
+              } else {
+                throw new Error(errMsg);
+              }
+            }
+        }
+      }
+      return out;
+    }
+
+    /**
+     * Parse Inline Tokens
+     */;
+    _proto.parseInline = function parseInline(tokens, renderer) {
+      renderer = renderer || this.renderer;
+      var out = '',
+        i,
+        token,
+        ret;
+      var l = tokens.length;
+      for (i = 0; i < l; i++) {
+        token = tokens[i];
+
+        // Run any renderer extensions
+        if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) {
+          ret = this.options.extensions.renderers[token.type].call({
+            parser: this
+          }, token);
+          if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(token.type)) {
+            out += ret || '';
+            continue;
+          }
+        }
+        switch (token.type) {
+          case 'escape':
+            {
+              out += renderer.text(token.text);
+              break;
+            }
+          case 'html':
+            {
+              out += renderer.html(token.text);
+              break;
+            }
+          case 'link':
+            {
+              out += renderer.link(token.href, token.title, this.parseInline(token.tokens, renderer));
+              break;
+            }
+          case 'image':
+            {
+              out += renderer.image(token.href, token.title, token.text);
+              break;
+            }
+          case 'strong':
+            {
+              out += renderer.strong(this.parseInline(token.tokens, renderer));
+              break;
+            }
+          case 'em':
+            {
+              out += renderer.em(this.parseInline(token.tokens, renderer));
+              break;
+            }
+          case 'codespan':
+            {
+              out += renderer.codespan(token.text);
+              break;
+            }
+          case 'br':
+            {
+              out += renderer.br();
+              break;
+            }
+          case 'del':
+            {
+              out += renderer.del(this.parseInline(token.tokens, renderer));
+              break;
+            }
+          case 'text':
+            {
+              out += renderer.text(token.text);
+              break;
+            }
+          default:
+            {
+              var errMsg = 'Token with "' + token.type + '" type was not found.';
+              if (this.options.silent) {
+                console.error(errMsg);
+                return;
+              } else {
+                throw new Error(errMsg);
+              }
+            }
+        }
+      }
+      return out;
+    };
+    return Parser;
+  }();
+
+  /**
+   * Marked
+   */
+  function marked(src, opt, callback) {
+    // throw error in case of non string input
+    if (typeof src === 'undefined' || src === null) {
+      throw new Error('marked(): input parameter is undefined or null');
+    }
+    if (typeof src !== 'string') {
+      throw new Error('marked(): input parameter is of type ' + Object.prototype.toString.call(src) + ', string expected');
+    }
+    if (typeof opt === 'function') {
+      callback = opt;
+      opt = null;
+    }
+    opt = merge({}, marked.defaults, opt || {});
+    checkSanitizeDeprecation(opt);
+    if (callback) {
+      var highlight = opt.highlight;
+      var tokens;
+      try {
+        tokens = Lexer.lex(src, opt);
+      } catch (e) {
+        return callback(e);
+      }
+      var done = function done(err) {
+        var out;
+        if (!err) {
+          try {
+            if (opt.walkTokens) {
+              marked.walkTokens(tokens, opt.walkTokens);
+            }
+            out = Parser.parse(tokens, opt);
+          } catch (e) {
+            err = e;
+          }
+        }
+        opt.highlight = highlight;
+        return err ? callback(err) : callback(null, out);
+      };
+      if (!highlight || highlight.length < 3) {
+        return done();
+      }
+      delete opt.highlight;
+      if (!tokens.length) return done();
+      var pending = 0;
+      marked.walkTokens(tokens, function (token) {
+        if (token.type === 'code') {
+          pending++;
+          setTimeout(function () {
+            highlight(token.text, token.lang, function (err, code) {
+              if (err) {
+                return done(err);
+              }
+              if (code != null && code !== token.text) {
+                token.text = code;
+                token.escaped = true;
+              }
+              pending--;
+              if (pending === 0) {
+                done();
+              }
+            });
+          }, 0);
+        }
+      });
+      if (pending === 0) {
+        done();
+      }
+      return;
+    }
+    function onError(e) {
+      e.message += '\nPlease report this to https://github.com/markedjs/marked.';
+      if (opt.silent) {
+        return '<p>An error occurred:</p><pre>' + escape(e.message + '', true) + '</pre>';
+      }
+      throw e;
+    }
+    try {
+      var _tokens = Lexer.lex(src, opt);
+      if (opt.walkTokens) {
+        if (opt.async) {
+          return Promise.all(marked.walkTokens(_tokens, opt.walkTokens)).then(function () {
+            return Parser.parse(_tokens, opt);
+          })["catch"](onError);
+        }
+        marked.walkTokens(_tokens, opt.walkTokens);
+      }
+      return Parser.parse(_tokens, opt);
+    } catch (e) {
+      onError(e);
+    }
+  }
+
+  /**
+   * Options
+   */
+
+  marked.options = marked.setOptions = function (opt) {
+    merge(marked.defaults, opt);
+    changeDefaults(marked.defaults);
+    return marked;
+  };
+  marked.getDefaults = getDefaults;
+  marked.defaults = exports.defaults;
+
+  /**
+   * Use Extension
+   */
+
+  marked.use = function () {
+    var extensions = marked.defaults.extensions || {
+      renderers: {},
+      childTokens: {}
+    };
+    for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
+      args[_key] = arguments[_key];
+    }
+    args.forEach(function (pack) {
+      // copy options to new object
+      var opts = merge({}, pack);
+
+      // set async to true if it was set to true before
+      opts.async = marked.defaults.async || opts.async;
+
+      // ==-- Parse "addon" extensions --== //
+      if (pack.extensions) {
+        pack.extensions.forEach(function (ext) {
+          if (!ext.name) {
+            throw new Error('extension name required');
+          }
+          if (ext.renderer) {
+            // Renderer extensions
+            var prevRenderer = extensions.renderers[ext.name];
+            if (prevRenderer) {
+              // Replace extension with func to run new extension but fall back if false
+              extensions.renderers[ext.name] = function () {
+                for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
+                  args[_key2] = arguments[_key2];
+                }
+                var ret = ext.renderer.apply(this, args);
+                if (ret === false) {
+                  ret = prevRenderer.apply(this, args);
+                }
+                return ret;
+              };
+            } else {
+              extensions.renderers[ext.name] = ext.renderer;
+            }
+          }
+          if (ext.tokenizer) {
+            // Tokenizer Extensions
+            if (!ext.level || ext.level !== 'block' && ext.level !== 'inline') {
+              throw new Error("extension level must be 'block' or 'inline'");
+            }
+            if (extensions[ext.level]) {
+              extensions[ext.level].unshift(ext.tokenizer);
+            } else {
+              extensions[ext.level] = [ext.tokenizer];
+            }
+            if (ext.start) {
+              // Function to check for start of token
+              if (ext.level === 'block') {
+                if (extensions.startBlock) {
+                  extensions.startBlock.push(ext.start);
+                } else {
+                  extensions.startBlock = [ext.start];
+                }
+              } else if (ext.level === 'inline') {
+                if (extensions.startInline) {
+                  extensions.startInline.push(ext.start);
+                } else {
+                  extensions.startInline = [ext.start];
+                }
+              }
+            }
+          }
+          if (ext.childTokens) {
+            // Child tokens to be visited by walkTokens
+            extensions.childTokens[ext.name] = ext.childTokens;
+          }
+        });
+        opts.extensions = extensions;
+      }
+
+      // ==-- Parse "overwrite" extensions --== //
+      if (pack.renderer) {
+        (function () {
+          var renderer = marked.defaults.renderer || new Renderer();
+          var _loop = function _loop(prop) {
+            var prevRenderer = renderer[prop];
+            // Replace renderer with func to run extension, but fall back if false
+            renderer[prop] = function () {
+              for (var _len3 = arguments.length, args = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {
+                args[_key3] = arguments[_key3];
+              }
+              var ret = pack.renderer[prop].apply(renderer, args);
+              if (ret === false) {
+                ret = prevRenderer.apply(renderer, args);
+              }
+              return ret;
+            };
+          };
+          for (var prop in pack.renderer) {
+            _loop(prop);
+          }
+          opts.renderer = renderer;
+        })();
+      }
+      if (pack.tokenizer) {
+        (function () {
+          var tokenizer = marked.defaults.tokenizer || new Tokenizer();
+          var _loop2 = function _loop2(prop) {
+            var prevTokenizer = tokenizer[prop];
+            // Replace tokenizer with func to run extension, but fall back if false
+            tokenizer[prop] = function () {
+              for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) {
+                args[_key4] = arguments[_key4];
+              }
+              var ret = pack.tokenizer[prop].apply(tokenizer, args);
+              if (ret === false) {
+                ret = prevTokenizer.apply(tokenizer, args);
+              }
+              return ret;
+            };
+          };
+          for (var prop in pack.tokenizer) {
+            _loop2(prop);
+          }
+          opts.tokenizer = tokenizer;
+        })();
+      }
+
+      // ==-- Parse WalkTokens extensions --== //
+      if (pack.walkTokens) {
+        var _walkTokens = marked.defaults.walkTokens;
+        opts.walkTokens = function (token) {
+          var values = [];
+          values.push(pack.walkTokens.call(this, token));
+          if (_walkTokens) {
+            values = values.concat(_walkTokens.call(this, token));
+          }
+          return values;
+        };
+      }
+      marked.setOptions(opts);
+    });
+  };
+
+  /**
+   * Run callback for every token
+   */
+
+  marked.walkTokens = function (tokens, callback) {
+    var values = [];
+    var _loop3 = function _loop3() {
+      var token = _step.value;
+      values = values.concat(callback.call(marked, token));
+      switch (token.type) {
+        case 'table':
+          {
+            for (var _iterator2 = _createForOfIteratorHelperLoose(token.header), _step2; !(_step2 = _iterator2()).done;) {
+              var cell = _step2.value;
+              values = values.concat(marked.walkTokens(cell.tokens, callback));
+            }
+            for (var _iterator3 = _createForOfIteratorHelperLoose(token.rows), _step3; !(_step3 = _iterator3()).done;) {
+              var row = _step3.value;
+              for (var _iterator4 = _createForOfIteratorHelperLoose(row), _step4; !(_step4 = _iterator4()).done;) {
+                var _cell = _step4.value;
+                values = values.concat(marked.walkTokens(_cell.tokens, callback));
+              }
+            }
+            break;
+          }
+        case 'list':
+          {
+            values = values.concat(marked.walkTokens(token.items, callback));
+            break;
+          }
+        default:
+          {
+            if (marked.defaults.extensions && marked.defaults.extensions.childTokens && marked.defaults.extensions.childTokens[token.type]) {
+              // Walk any extensions
+              marked.defaults.extensions.childTokens[token.type].forEach(function (childTokens) {
+                values = values.concat(marked.walkTokens(token[childTokens], callback));
+              });
+            } else if (token.tokens) {
+              values = values.concat(marked.walkTokens(token.tokens, callback));
+            }
+          }
+      }
+    };
+    for (var _iterator = _createForOfIteratorHelperLoose(tokens), _step; !(_step = _iterator()).done;) {
+      _loop3();
+    }
+    return values;
+  };
+
+  /**
+   * Parse Inline
+   * @param {string} src
+   */
+  marked.parseInline = function (src, opt) {
+    // throw error in case of non string input
+    if (typeof src === 'undefined' || src === null) {
+      throw new Error('marked.parseInline(): input parameter is undefined or null');
+    }
+    if (typeof src !== 'string') {
+      throw new Error('marked.parseInline(): input parameter is of type ' + Object.prototype.toString.call(src) + ', string expected');
+    }
+    opt = merge({}, marked.defaults, opt || {});
+    checkSanitizeDeprecation(opt);
+    try {
+      var tokens = Lexer.lexInline(src, opt);
+      if (opt.walkTokens) {
+        marked.walkTokens(tokens, opt.walkTokens);
+      }
+      return Parser.parseInline(tokens, opt);
+    } catch (e) {
+      e.message += '\nPlease report this to https://github.com/markedjs/marked.';
+      if (opt.silent) {
+        return '<p>An error occurred:</p><pre>' + escape(e.message + '', true) + '</pre>';
+      }
+      throw e;
+    }
+  };
+
+  /**
+   * Expose
+   */
+  marked.Parser = Parser;
+  marked.parser = Parser.parse;
+  marked.Renderer = Renderer;
+  marked.TextRenderer = TextRenderer;
+  marked.Lexer = Lexer;
+  marked.lexer = Lexer.lex;
+  marked.Tokenizer = Tokenizer;
+  marked.Slugger = Slugger;
+  marked.parse = marked;
+  var options = marked.options;
+  var setOptions = marked.setOptions;
+  var use = marked.use;
+  var walkTokens = marked.walkTokens;
+  var parseInline = marked.parseInline;
+  var parse = marked;
+  var parser = Parser.parse;
+  var lexer = Lexer.lex;
+
+  exports.Lexer = Lexer;
+  exports.Parser = Parser;
+  exports.Renderer = Renderer;
+  exports.Slugger = Slugger;
+  exports.TextRenderer = TextRenderer;
+  exports.Tokenizer = Tokenizer;
+  exports.getDefaults = getDefaults;
+  exports.lexer = lexer;
+  exports.marked = marked;
+  exports.options = options;
+  exports.parse = parse;
+  exports.parseInline = parseInline;
+  exports.parser = parser;
+  exports.setOptions = setOptions;
+  exports.use = use;
+  exports.walkTokens = walkTokens;
+
+}));
diff --git a/usr/share/perl5/PVE/API2/Nodes.pm b/usr/share/perl5/PVE/API2/Nodes.pm
new file mode 100644
index 0000000..67e249e
--- /dev/null
+++ b/usr/share/perl5/PVE/API2/Nodes.pm
@@ -0,0 +1,2612 @@
+package PVE::API2::Nodes::Nodeinfo;
+
+use strict;
+use warnings;
+
+use Digest::MD5;
+use Digest::SHA;
+use Filesys::Df;
+use HTTP::Status qw(:constants);
+use JSON;
+use POSIX qw(LONG_MAX);
+use Time::Local qw(timegm_nocheck);
+use Socket;
+use IO::Socket::SSL;
+
+use PVE::API2Tools;
+use PVE::APLInfo;
+use PVE::AccessControl;
+use PVE::Cluster qw(cfs_read_file);
+use PVE::DataCenterConfig;
+use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
+use PVE::Firewall;
+use PVE::HA::Config;
+use PVE::HA::Env::PVE2;
+use PVE::INotify;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::LXC;
+use PVE::NodeConfig;
+use PVE::ProcFSTools;
+use PVE::QemuConfig;
+use PVE::QemuServer;
+use PVE::RESTEnvironment qw(log_warn);
+use PVE::RESTHandler;
+use PVE::RPCEnvironment;
+use PVE::RRD;
+use PVE::Report;
+use PVE::SafeSyslog;
+use PVE::Storage;
+use PVE::Tools qw(file_get_contents);
+use PVE::pvecfg;
+
+use PVE::API2::APT;
+use PVE::API2::Capabilities;
+use PVE::API2::Ceph;
+use PVE::API2::Certificates;
+use PVE::API2::Disks;
+use PVE::API2::Firewall::Host;
+use PVE::API2::Hardware;
+use PVE::API2::LXC::Status;
+use PVE::API2::LXC;
+use PVE::API2::Network;
+use PVE::API2::NodeConfig;
+use PVE::API2::Qemu::CPU;
+use PVE::API2::Qemu;
+use PVE::API2::Replication;
+use PVE::API2::Services;
+use PVE::API2::Storage::Scan;
+use PVE::API2::Storage::Status;
+use PVE::API2::Subscription;
+use PVE::API2::Tasks;
+use PVE::API2::VZDump;
+
+my $have_sdn;
+eval {
+    require PVE::API2::Network::SDN::Zones::Status;
+    $have_sdn = 1;
+};
+
+use base qw(PVE::RESTHandler);
+
+my $verify_command_item_desc = {
+    description => "An array of objects describing endpoints, methods and arguments.",
+    type => "array",
+    items => {
+	type => "object",
+	properties => {
+	    path => {
+		description => "A relative path to an API endpoint on this node.",
+		type => "string",
+		optional => 0,
+	    },
+	    method => {
+		description => "A method related to the API endpoint (GET, POST etc.).",
+		type => "string",
+		pattern => "(GET|POST|PUT|DELETE)",
+		optional => 0,
+	    },
+	    args => {
+		description => "A set of parameter names and their values.",
+		type => "object",
+		optional => 1,
+	    },
+	},
+    }
+};
+
+PVE::JSONSchema::register_format('pve-command-batch', \&verify_command_batch);
+sub verify_command_batch {
+    my ($value, $noerr) = @_;
+    my $commands = eval { decode_json($value); };
+
+    return if $noerr && $@;
+    die "commands param did not contain valid JSON: $@" if $@;
+
+    eval { PVE::JSONSchema::validate($commands, $verify_command_item_desc) };
+
+    return $commands if !$@;
+
+    return if $noerr;
+    die "commands is not a valid array of commands: $@";
+}
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Qemu",
+    path => 'qemu',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::LXC",
+    path => 'lxc',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Ceph",
+    path => 'ceph',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::VZDump",
+    path => 'vzdump',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Services",
+    path => 'services',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Subscription",
+    path => 'subscription',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Network",
+    path => 'network',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Tasks",
+    path => 'tasks',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Storage::Scan",
+    path => 'scan',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Hardware",
+    path => 'hardware',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Capabilities",
+    path => 'capabilities',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Storage::Status",
+    path => 'storage',
+});
+
+__PACKAGE__->register_method ({
+   subclass => "PVE::API2::Disks",
+   path => 'disks',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::APT",
+    path => 'apt',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Firewall::Host",
+    path => 'firewall',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Replication",
+    path => 'replication',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Certificates",
+    path => 'certificates',
+});
+
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::NodeConfig",
+    path => 'config',
+});
+
+if ($have_sdn) {
+    __PACKAGE__->register_method ({
+	subclass => "PVE::API2::Network::SDN::Zones::Status",
+	path => 'sdn/zones',
+    });
+
+__PACKAGE__->register_method ({
+    name => 'sdnindex',
+    path => 'sdn',
+    method => 'GET',
+    permissions => { user => 'all' },
+    description => "SDN index.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "object",
+	    properties => {},
+	},
+	links => [ { rel => 'child', href => "{name}" } ],
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $result = [
+	    { name => 'zones' },
+	];
+	return $result;
+    }});
+}
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    permissions => { user => 'all' },
+    description => "Node index.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "object",
+	    properties => {},
+	},
+	links => [ { rel => 'child', href => "{name}" } ],
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $result = [
+	    { name => 'aplinfo' },
+	    { name => 'apt' },
+	    { name => 'capabilities' },
+	    { name => 'ceph' },
+	    { name => 'certificates' },
+	    { name => 'config' },
+	    { name => 'disks' },
+	    { name => 'dns' },
+	    { name => 'firewall' },
+	    { name => 'hardware' },
+	    { name => 'hosts' },
+	    { name => 'journal' },
+	    { name => 'lxc' },
+	    { name => 'migrateall' },
+	    { name => 'netstat' },
+	    { name => 'network' },
+	    { name => 'qemu' },
+	    { name => 'query-url-metadata' },
+	    { name => 'replication' },
+	    { name => 'report' },
+	    { name => 'rrd' }, # fixme: remove?
+	    { name => 'rrddata' },# fixme: remove?
+	    { name => 'scan' },
+	    { name => 'services' },
+	    { name => 'spiceshell' },
+	    { name => 'startall' },
+	    { name => 'status' },
+	    { name => 'stopall' },
+	    { name => 'storage' },
+	    { name => 'subscription' },
+	    { name => 'suspendall' },
+	    { name => 'syslog' },
+	    { name => 'tasks' },
+	    { name => 'termproxy' },
+	    { name => 'time' },
+	    { name => 'version' },
+	    { name => 'vncshell' },
+	    { name => 'vzdump' },
+	    { name => 'wakeonlan' },
+	];
+
+	push @$result, { name => 'sdn' } if $have_sdn;
+
+	return $result;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'version',
+    path => 'version',
+    method => 'GET',
+    proxyto => 'node',
+    permissions => { user => 'all' },
+    description => "API version details",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => "object",
+	properties => {
+	    version => {
+		type => 'string',
+		description => 'The current installed pve-manager package version',
+	    },
+	    release => {
+		type => 'string',
+		description => 'The current installed Proxmox VE Release',
+	    },
+	    repoid => {
+		type => 'string',
+		description => 'The short git commit hash ID from which this version was build',
+	    },
+	},
+    },
+    code => sub {
+	my ($resp, $param) = @_;
+
+	return PVE::pvecfg::version_info();
+    }});
+
+my sub get_current_kernel_info {
+    my ($sysname, $nodename, $release, $version, $machine) = POSIX::uname();
+
+    my $kernel_version_string = "$sysname $release $version"; # for legacy compat
+    my $current_kernel = {
+	sysname => $sysname,
+	release => $release,
+	version => $version,
+	machine => $machine,
+    };
+    return ($current_kernel, $kernel_version_string);
+}
+
+my $boot_mode_info_cache;
+my sub get_boot_mode_info {
+    return $boot_mode_info_cache if defined($boot_mode_info_cache);
+
+    my $is_efi_booted = -d "/sys/firmware/efi";
+
+    $boot_mode_info_cache = {
+	mode => $is_efi_booted ? 'efi' : 'legacy-bios',
+    };
+
+    my $efi_var = "/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c";
+
+    if ($is_efi_booted && -e $efi_var) {
+	my $efi_var_sec_boot_entry = eval { file_get_contents($efi_var) };
+	if ($@) {
+	    warn "Failed to read secure boot state: $@\n";
+	} else {
+	    my @secureboot = unpack("CCCCC", $efi_var_sec_boot_entry);
+	    $boot_mode_info_cache->{secureboot} = $secureboot[4] == 1 ? 1 : 0;
+	}
+    }
+    return $boot_mode_info_cache;
+}
+
+__PACKAGE__->register_method({
+    name => 'status',
+    path => 'status',
+    method => 'GET',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+    },
+    description => "Read node status",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => "object",
+	additionalProperties => 1,
+	properties => {
+	    # TODO: document remaing ones
+	    'boot-info' => {
+		description => "Meta-information about the boot mode.",
+		type => 'object',
+		properties => {
+		    mode => {
+			description => 'Through which firmware the system got booted.',
+			type => 'string',
+			enum => [qw(efi legacy-bios)],
+		    },
+		    secureboot => {
+			description => 'System is booted in secure mode, only applicable for the "efi" mode.',
+			type => 'boolean',
+			optional => 1,
+		    },
+		},
+	    },
+	    'current-kernel' => {
+		description => "The uptime of the system in seconds.",
+		type => 'object',
+		properties => {
+		    sysname => {
+			description => 'OS kernel name (e.g., "Linux")',
+			type => 'string',
+		    },
+		    release => {
+			description => 'OS kernel release (e.g., "6.8.0")',
+			type => 'string',
+		    },
+		    version => {
+			description => 'OS kernel version with build info',
+			type => 'string',
+		    },
+		    machine => {
+			description => 'Hardware (architecture) type',
+			type => 'string',
+		    },
+		},
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $res = {
+	    uptime => 0,
+	    idle => 0,
+	};
+
+	my ($uptime, $idle) = PVE::ProcFSTools::read_proc_uptime();
+	$res->{uptime} = $uptime;
+
+	my ($avg1, $avg5, $avg15) = PVE::ProcFSTools::read_loadavg();
+	$res->{loadavg} = [ $avg1, $avg5, $avg15];
+
+	my ($current_kernel_info, $kversion_string) = get_current_kernel_info();
+	$res->{kversion} = $kversion_string;
+	$res->{'current-kernel'} = $current_kernel_info;
+
+	$res->{'boot-info'} = get_boot_mode_info();
+
+	$res->{cpuinfo} = PVE::ProcFSTools::read_cpuinfo();
+
+	my $stat = PVE::ProcFSTools::read_proc_stat();
+	$res->{cpu} = $stat->{cpu};
+	$res->{wait} = $stat->{wait};
+
+	my $meminfo = PVE::ProcFSTools::read_meminfo();
+	$res->{memory} = {
+	    free => $meminfo->{memfree},
+	    total => $meminfo->{memtotal},
+	    used => $meminfo->{memused},
+	};
+
+	$res->{ksm} = {
+	    shared => $meminfo->{memshared},
+	};
+
+	$res->{swap} = {
+	    free => $meminfo->{swapfree},
+	    total => $meminfo->{swaptotal},
+	    used => $meminfo->{swapused},
+	};
+
+	$res->{pveversion} = PVE::pvecfg::package() . "/" .
+	    PVE::pvecfg::version_text();
+
+	my $net_eno1 = `ethtool eno1 | grep Speed`;
+	my $net_enp4s0 = `ethtool enp4s0 | grep Speed`;
+	my $net_enp5s0 = `ethtool enp5s0 | grep Speed`;
+	my $net_enp7s0 = `ethtool enp7s0 | grep Speed`;
+	my $net_enp8s0 = `ethtool enp8s0 | grep Speed`;
+	$res->{networksp} = [ $net_eno1, $net_enp4s0, $net_enp5s0, $net_enp7s0, $net_enp8s0 ];
+
+	$res->{thermal} = `sensors -j`; # add temps
+
+	my $dinfo = df('/', 1);     # output is bytes
+
+	$res->{rootfs} = {
+	    total => $dinfo->{blocks},
+	    avail => $dinfo->{bavail},
+	    used => $dinfo->{used},
+	    free => $dinfo->{blocks} - $dinfo->{used},
+	};
+
+	return $res;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'netstat',
+    path => 'netstat',
+    method => 'GET',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+    },
+    description => "Read tap/vm network device interface counters",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => "array",
+	items => {
+	    type => "object",
+	    properties => {},
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $res = [ ];
+
+	my $netdev = PVE::ProcFSTools::read_proc_net_dev();
+	foreach my $dev (sort keys %$netdev) {
+	    next if $dev !~ m/^(?:tap|veth)([1-9]\d*)i(\d+)$/;
+	    my ($vmid, $netid) = ($1, $2);
+
+	    push @$res, {
+		vmid => $vmid,
+		dev  => "net$netid",
+		in   => $netdev->{$dev}->{transmit},
+		out  => $netdev->{$dev}->{receive},
+	    };
+	}
+
+	return $res;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'execute',
+    path => 'execute',
+    method => 'POST',
+    description => "Execute multiple commands in order, root only.",
+    proxyto => 'node',
+    protected => 1, # avoid problems with proxy code
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    commands => {
+		description => "JSON encoded array of commands.",
+		type => "string",
+		verbose_description => "JSON encoded array of commands, where each command is an object with the following properties:\n"
+		 . PVE::RESTHandler::dump_properties($verify_command_item_desc->{items}->{properties}, 'full'),
+		format => "pve-command-batch",
+	    }
+	},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "object",
+	    properties => {},
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+	my $res = [];
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $user = $rpcenv->get_user();
+	# just parse the json again, it should already be validated
+	my $commands = eval { decode_json($param->{commands}); };
+
+	foreach my $cmd (@$commands) {
+	    eval {
+		$cmd->{args} //= {};
+
+		my $path = "nodes/$param->{node}/$cmd->{path}";
+
+		my $uri_param = {};
+		my ($handler, $info) = PVE::API2->find_handler($cmd->{method}, $path, $uri_param);
+		if (!$handler || !$info) {
+		    die "no handler for '$path'\n";
+		}
+
+		foreach my $p (keys %{$cmd->{args}}) {
+		    raise_param_exc({ $p => "duplicate parameter" }) if defined($uri_param->{$p});
+		    $uri_param->{$p} = $cmd->{args}->{$p};
+		}
+
+		# check access permissions
+		$rpcenv->check_api2_permissions($info->{permissions}, $user, $uri_param);
+
+		push @$res, {
+		    status => HTTP_OK,
+		    data => $handler->handle($info, $uri_param),
+		};
+	    };
+	    if (my $err = $@) {
+		my $resp = { status => HTTP_INTERNAL_SERVER_ERROR };
+		if (ref($err) eq "PVE::Exception") {
+		    $resp->{status} = $err->{code} if $err->{code};
+		    $resp->{errors} = $err->{errors} if $err->{errors};
+		    $resp->{message} = $err->{msg};
+		} else {
+		    $resp->{message} = $err;
+		}
+		push @$res, $resp;
+	    }
+	}
+
+	return $res;
+    }});
+
+
+__PACKAGE__->register_method({
+    name => 'node_cmd',
+    path => 'status',
+    method => 'POST',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.PowerMgmt' ]],
+    },
+    protected => 1,
+    description => "Reboot or shutdown a node.",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    command => {
+		description => "Specify the command.",
+		type => 'string',
+		enum => [qw(reboot shutdown)],
+	    },
+	},
+    },
+    returns => { type => "null" },
+    code => sub {
+	my ($param) = @_;
+
+	if ($param->{command} eq 'reboot') {
+	    system ("(sleep 2;/sbin/reboot)&");
+	} elsif ($param->{command} eq 'shutdown') {
+	    system ("(sleep 2;/sbin/poweroff)&");
+	}
+
+	return;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'wakeonlan',
+    path => 'wakeonlan',
+    method => 'POST',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.PowerMgmt' ]],
+    },
+    protected => 1,
+    description => "Try to wake a node via 'wake on LAN' network packet.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node', {
+		description => 'target node for wake on LAN packet',
+		completion => sub {
+		    my $members = PVE::Cluster::get_members();
+		    return [ grep { !$members->{$_}->{online} } keys %$members ];
+		}
+	    }),
+	},
+    },
+    returns => {
+	type => 'string',
+	format => 'mac-addr',
+	description => 'MAC address used to assemble the WoL magic packet.',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $node = $param->{node};
+	my $local_node = PVE::INotify::nodename();
+
+	die "'$node' is local node, cannot wake my self!\n"
+	    if $node eq 'localhost' || $node eq $local_node;
+
+	PVE::Cluster::check_node_exists($node);
+
+	my $config = PVE::NodeConfig::load_config($node);
+	my $wol_config = PVE::NodeConfig::get_wakeonlan_config($config);
+	my $mac_addr = $wol_config->{mac};
+	if (!defined($mac_addr)) {
+	    die "No wake on LAN MAC address defined for '$node'!\n";
+	}
+
+	my $local_config = PVE::NodeConfig::load_config($local_node);
+	my $local_wol_config = PVE::NodeConfig::get_wakeonlan_config($local_config);
+	my $broadcast_addr = $local_wol_config->{'broadcast-address'} // '255.255.255.255';
+
+	$mac_addr =~ s/://g;
+	my $packet = chr(0xff) x 6 . pack('H*', $mac_addr) x 16;
+
+	my $addr = gethostbyname($broadcast_addr);
+	my $port = getservbyname('discard', 'udp');
+	my $to = Socket::pack_sockaddr_in($port, $addr);
+
+	socket(my $sock, Socket::AF_INET, Socket::SOCK_DGRAM, Socket::IPPROTO_UDP)
+	    || die "Unable to open socket: $!\n";
+	setsockopt($sock, Socket::SOL_SOCKET, Socket::SO_BROADCAST, 1)
+	    || die "Unable to set socket option: $!\n";
+
+	if (defined(my $bind_iface = $local_wol_config->{'bind-interface'})) {
+	    my $bind_iface_raw = pack('Z*', $bind_iface); # Null terminated interface name
+	    setsockopt($sock, Socket::SOL_SOCKET, Socket::SO_BINDTODEVICE, $bind_iface_raw)
+		|| die "Unable to bind socket to interface '$bind_iface': $!\n";
+	}
+
+	send($sock, $packet, 0, $to)
+	    || die "Unable to send packet: $!\n";
+
+	close($sock);
+
+	return $wol_config->{mac};
+    }});
+
+__PACKAGE__->register_method({
+    name => 'rrd',
+    path => 'rrd',
+    method => 'GET',
+    protected => 1, # fixme: can we avoid that?
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+    },
+    description => "Read node RRD statistics (returns PNG)",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    timeframe => {
+		description => "Specify the time frame you are interested in.",
+		type => 'string',
+		enum => [ 'hour', 'day', 'week', 'month', 'year' ],
+	    },
+	    ds => {
+		description => "The list of datasources you want to display.",
+		type => 'string', format => 'pve-configid-list',
+	    },
+	    cf => {
+		description => "The RRD consolidation function",
+		type => 'string',
+		enum => [ 'AVERAGE', 'MAX' ],
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => "object",
+	properties => {
+	    filename => { type => 'string' },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	return PVE::RRD::create_rrd_graph(
+	    "pve2-node/$param->{node}", $param->{timeframe},
+	    $param->{ds}, $param->{cf});
+
+    }});
+
+__PACKAGE__->register_method({
+    name => 'rrddata',
+    path => 'rrddata',
+    method => 'GET',
+    protected => 1, # fixme: can we avoid that?
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+    },
+    description => "Read node RRD statistics",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    timeframe => {
+		description => "Specify the time frame you are interested in.",
+		type => 'string',
+		enum => [ 'hour', 'day', 'week', 'month', 'year' ],
+	    },
+	    cf => {
+		description => "The RRD consolidation function",
+		type => 'string',
+		enum => [ 'AVERAGE', 'MAX' ],
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => "array",
+	items => {
+	    type => "object",
+	    properties => {},
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	return PVE::RRD::create_rrd_data(
+	    "pve2-node/$param->{node}", $param->{timeframe}, $param->{cf});
+    }});
+
+__PACKAGE__->register_method({
+    name => 'syslog',
+    path => 'syslog',
+    method => 'GET',
+    description => "Read system log",
+    proxyto => 'node',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Syslog' ]],
+    },
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    start => {
+		type => 'integer',
+		minimum => 0,
+		optional => 1,
+	    },
+	    limit => {
+		type => 'integer',
+		minimum => 0,
+		optional => 1,
+	    },
+	    since => {
+		type=> 'string',
+		pattern => '^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$',
+		description => "Display all log since this date-time string.",
+		optional => 1,
+	    },
+	    until => {
+		type=> 'string',
+		pattern => '^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$',
+		description => "Display all log until this date-time string.",
+		optional => 1,
+	    },
+	    service => {
+		description => "Service ID",
+		type => 'string',
+		maxLength => 128,
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "object",
+	    properties => {
+		n => {
+		  description=>  "Line number",
+		  type=> 'integer',
+		},
+		t => {
+		  description=>  "Line text",
+		  type => 'string',
+		}
+	    }
+	}
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $user = $rpcenv->get_user();
+	my $node = $param->{node};
+	my $service;
+
+	if ($param->{service}) {
+	    my $service_aliases = {
+		'postfix' => 'postfix@-',
+	    };
+
+	    $service = $service_aliases->{$param->{service}} // $param->{service};
+	}
+
+	my ($count, $lines) = PVE::Tools::dump_journal($param->{start}, $param->{limit},
+						       $param->{since}, $param->{until}, $service);
+
+	$rpcenv->set_result_attrib('total', $count);
+
+	return $lines;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'journal',
+    path => 'journal',
+    method => 'GET',
+    description => "Read Journal",
+    proxyto => 'node',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Syslog' ]],
+    },
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    since => {
+		type=> 'integer',
+		minimum => 0,
+		description => "Display all log since this UNIX epoch. Conflicts with 'startcursor'.",
+		optional => 1,
+	    },
+	    until => {
+		type=> 'integer',
+		minimum => 0,
+		description => "Display all log until this UNIX epoch. Conflicts with 'endcursor'.",
+		optional => 1,
+	    },
+	    lastentries => {
+		description => "Limit to the last X lines. Conflicts with a range.",
+		type => 'integer',
+		minimum => 0,
+		optional => 1,
+	    },
+	    startcursor => {
+		description => "Start after the given Cursor. Conflicts with 'since'",
+		type => 'string',
+		optional => 1,
+	    },
+	    endcursor => {
+		description => "End before the given Cursor. Conflicts with 'until'",
+		type => 'string',
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "string",
+	}
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $user = $rpcenv->get_user();
+
+	my $cmd = ["/usr/bin/mini-journalreader", "-j"];
+	push @$cmd, '-n', $param->{lastentries} if $param->{lastentries};
+	push @$cmd, '-b', $param->{since} if $param->{since};
+	push @$cmd, '-e', $param->{until} if $param->{until};
+	push @$cmd, '-f', PVE::Tools::shellquote($param->{startcursor}) if $param->{startcursor};
+	push @$cmd, '-t', PVE::Tools::shellquote($param->{endcursor}) if $param->{endcursor};
+	push @$cmd, ' | gzip ';
+
+	open(my $fh, "-|", join(' ', @$cmd))
+	    or die "could not start mini-journalreader";
+
+	return {
+	    download => {
+		fh => $fh,
+		stream => 1,
+		'content-type' => 'application/json',
+		'content-encoding' => 'gzip',
+	    },
+	},
+    }});
+
+my $sslcert;
+
+my $shell_cmd_map = {
+    'login' => {
+	cmd => [ '/bin/login', '-f', 'root' ],
+    },
+    'upgrade' => {
+	cmd => [ '/usr/bin/pveupgrade', '--shell' ],
+    },
+    'ceph_install' => {
+	cmd => [ '/usr/bin/pveceph', 'install' ],
+	allow_args => 1,
+    },
+};
+
+sub get_shell_command  {
+    my ($user, $shellcmd, $args) = @_;
+
+    my $cmd;
+    if ($user eq 'root@pam') {
+	if (defined($shellcmd) && exists($shell_cmd_map->{$shellcmd})) {
+	    my $def = $shell_cmd_map->{$shellcmd};
+	    $cmd = [ @{$def->{cmd}} ]; # clone
+	    if (defined($args) && $def->{allow_args}) {
+		push @$cmd, split("\0", $args);
+	    }
+	} else {
+	    $cmd = [ '/bin/login', '-f', 'root' ];
+	}
+    } else {
+	# non-root must always login for now, we do not have a superuser role!
+	$cmd = [ '/bin/login' ];
+    }
+    return $cmd;
+}
+
+my $get_vnc_connection_info = sub {
+    my $node = shift;
+
+    my $remote_cmd = [];
+
+    my ($remip, $family);
+    if ($node ne 'localhost' && $node ne PVE::INotify::nodename()) {
+	($remip, $family) = PVE::Cluster::remote_node_ip($node);
+	$remote_cmd = PVE::SSHInfo::ssh_info_to_command({ ip => $remip, name => $node }, ('-t'));
+	push @$remote_cmd, '--';
+    } else {
+	$family = PVE::Tools::get_host_address_family($node);
+    }
+    my $port = PVE::Tools::next_vnc_port($family);
+
+    return ($port, $remote_cmd);
+};
+
+__PACKAGE__->register_method ({
+    name => 'vncshell',
+    path => 'vncshell',
+    method => 'POST',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]],
+    },
+    description => "Creates a VNC Shell proxy.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    cmd => {
+		type => 'string',
+		description => "Run specific command or default to login (requires 'root\@pam')",
+		enum => [keys %$shell_cmd_map],
+		optional => 1,
+		default => 'login',
+	    },
+	    'cmd-opts' => {
+		type => 'string',
+		description => "Add parameters to a command. Encoded as null terminated strings.",
+		requires => 'cmd',
+		optional => 1,
+		default => '',
+	    },
+	    websocket => {
+		optional => 1,
+		type => 'boolean',
+		description => "use websocket instead of standard vnc.",
+	    },
+	    width => {
+		optional => 1,
+		description => "sets the width of the console in pixels.",
+		type => 'integer',
+		minimum => 16,
+		maximum => 4096,
+	    },
+	    height => {
+		optional => 1,
+		description => "sets the height of the console in pixels.",
+		type => 'integer',
+		minimum => 16,
+		maximum => 2160,
+	    },
+	},
+    },
+    returns => {
+	additionalProperties => 0,
+	properties => {
+	    user => { type => 'string' },
+	    ticket => { type => 'string' },
+	    cert => { type => 'string' },
+	    port => { type => 'integer' },
+	    upid => { type => 'string' },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my ($user, undef, $realm) = PVE::AccessControl::verify_username($rpcenv->get_user());
+
+
+	if (defined($param->{cmd}) && $param->{cmd} ne 'login' && $user ne 'root@pam') {
+	    raise_perm_exc('user != root@pam');
+	}
+
+	my $node = $param->{node};
+
+	my $authpath = "/nodes/$node";
+	my $ticket = PVE::AccessControl::assemble_vnc_ticket($user, $authpath);
+
+	$sslcert = PVE::Tools::file_get_contents("/etc/pve/pve-root-ca.pem", 8192)
+	    if !$sslcert;
+
+	my ($port, $remcmd) = $get_vnc_connection_info->($node);
+
+	my $shcmd = get_shell_command($user, $param->{cmd}, $param->{'cmd-opts'});
+
+	my $timeout = 10;
+
+	my $cmd = ['/usr/bin/vncterm',
+	    '-rfbport', $port,
+	    '-timeout', $timeout,
+	    '-authpath', $authpath,
+	    '-perm', 'Sys.Console',
+	];
+
+	push @$cmd, '-width', $param->{width} if $param->{width};
+	push @$cmd, '-height', $param->{height} if $param->{height};
+
+	if ($param->{websocket}) {
+	    $ENV{PVE_VNC_TICKET} = $ticket; # pass ticket to vncterm
+	    push @$cmd, '-notls', '-listen', 'localhost';
+	}
+
+	push @$cmd, '-c', @$remcmd, @$shcmd;
+
+	my $realcmd = sub {
+	    my $upid = shift;
+
+	    syslog ('info', "starting vnc proxy $upid\n");
+
+	    my $cmdstr = join (' ', @$cmd);
+	    syslog ('info', "launch command: $cmdstr");
+
+	    eval {
+		foreach my $k (keys %ENV) {
+		    next if $k eq 'PVE_VNC_TICKET';
+		    next if $k eq 'PATH' || $k eq 'TERM' || $k eq 'USER' || $k eq 'HOME' || $k eq 'LANG' || $k eq 'LANGUAGE';
+		    delete $ENV{$k};
+		}
+		$ENV{PWD} = '/';
+
+		PVE::Tools::run_command($cmd, errmsg => "vncterm failed", keeplocale => 1);
+	    };
+	    if (my $err = $@) {
+		syslog ('err', $err);
+	    }
+
+	    return;
+	};
+
+	my $upid = $rpcenv->fork_worker('vncshell', "", $user, $realcmd);
+
+	PVE::Tools::wait_for_vnc_port($port);
+
+	return {
+	    user => $user,
+	    ticket => $ticket,
+	    port => $port,
+	    upid => $upid,
+	    cert => $sslcert,
+	};
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'termproxy',
+    path => 'termproxy',
+    method => 'POST',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]],
+    },
+    description => "Creates a VNC Shell proxy.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    cmd => {
+		type => 'string',
+		description => "Run specific command or default to login (requires 'root\@pam')",
+		enum => [keys %$shell_cmd_map],
+		optional => 1,
+		default => 'login',
+	    },
+	    'cmd-opts' => {
+		type => 'string',
+		description => "Add parameters to a command. Encoded as null terminated strings.",
+		requires => 'cmd',
+		optional => 1,
+		default => '',
+	    },
+	},
+    },
+    returns => {
+	additionalProperties => 0,
+	properties => {
+	    user => { type => 'string' },
+	    ticket => { type => 'string' },
+	    port => { type => 'integer' },
+	    upid => { type => 'string' },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my ($user, undef, $realm) = PVE::AccessControl::verify_username($rpcenv->get_user());
+
+	my $node = $param->{node};
+	my $authpath = "/nodes/$node";
+	my $ticket = PVE::AccessControl::assemble_vnc_ticket($user, $authpath);
+
+	my ($port, $remcmd) = $get_vnc_connection_info->($node);
+
+	my $shcmd = get_shell_command($user, $param->{cmd}, $param->{'cmd-opts'});
+
+	my $realcmd = sub {
+	    my $upid = shift;
+
+	    syslog ('info', "starting termproxy $upid\n");
+
+	    my $cmd = [
+		'/usr/bin/termproxy',
+		$port,
+		'--path', $authpath,
+		'--perm', 'Sys.Console',
+		'--'
+	    ];
+	    push  @$cmd, @$remcmd, @$shcmd;
+
+	    PVE::Tools::run_command($cmd);
+	};
+	my $upid = $rpcenv->fork_worker('vncshell', "", $user, $realcmd);
+
+	PVE::Tools::wait_for_vnc_port($port);
+
+	return {
+	    user => $user,
+	    ticket => $ticket,
+	    port => $port,
+	    upid => $upid,
+	};
+    }});
+
+__PACKAGE__->register_method({
+    name => 'vncwebsocket',
+    path => 'vncwebsocket',
+    method => 'GET',
+    permissions => {
+	description => "You also need to pass a valid ticket (vncticket).",
+	check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]],
+    },
+    description => "Opens a websocket for VNC traffic.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    vncticket => {
+		description => "Ticket from previous call to vncproxy.",
+		type => 'string',
+		maxLength => 512,
+	    },
+	    port => {
+		description => "Port number returned by previous vncproxy call.",
+		type => 'integer',
+		minimum => 5900,
+		maximum => 5999,
+	    },
+	},
+    },
+    returns => {
+	type => "object",
+	properties => {
+	    port => { type => 'string' },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+
+	my ($user, undef, $realm) = PVE::AccessControl::verify_username($rpcenv->get_user());
+
+	my $authpath = "/nodes/$param->{node}";
+
+	PVE::AccessControl::verify_vnc_ticket($param->{vncticket}, $user, $authpath);
+
+	my $port = $param->{port};
+
+	return { port => $port };
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'spiceshell',
+    path => 'spiceshell',
+    method => 'POST',
+    protected => 1,
+    proxyto => 'node',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]],
+    },
+    description => "Creates a SPICE shell.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    proxy => get_standard_option('spice-proxy', { optional => 1 }),
+	    cmd => {
+		type => 'string',
+		description => "Run specific command or default to login (requires 'root\@pam')",
+		enum => [keys %$shell_cmd_map],
+		optional => 1,
+		default => 'login',
+	    },
+	    'cmd-opts' => {
+		type => 'string',
+		description => "Add parameters to a command. Encoded as null terminated strings.",
+		requires => 'cmd',
+		optional => 1,
+		default => '',
+	    },
+	},
+    },
+    returns => get_standard_option('remote-viewer-config'),
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	my ($user, undef, $realm) = PVE::AccessControl::verify_username($authuser);
+
+
+	if (defined($param->{cmd}) && $param->{cmd} ne 'login' && $user ne 'root@pam') {
+	    raise_perm_exc('user != root@pam');
+	}
+
+	my $node = $param->{node};
+	my $proxy = $param->{proxy};
+
+	my $authpath = "/nodes/$node";
+	my $permissions = 'Sys.Console';
+
+	my $shcmd = get_shell_command($user, $param->{cmd}, $param->{'cmd-opts'});
+
+	my $title = "Shell on '$node'";
+
+	return PVE::API2Tools::run_spiceterm($authpath, $permissions, 0, $node, $proxy, $title, $shcmd);
+    }});
+
+__PACKAGE__->register_method({
+    name => 'dns',
+    path => 'dns',
+    method => 'GET',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+    },
+    description => "Read DNS settings.",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => "object",
+	additionalProperties => 0,
+	properties => {
+	    search => {
+		description => "Search domain for host-name lookup.",
+		type => 'string',
+		optional => 1,
+	    },
+	    dns1 => {
+		description => 'First name server IP address.',
+		type => 'string',
+		optional => 1,
+	    },
+	    dns2 => {
+		description => 'Second name server IP address.',
+		type => 'string',
+		optional => 1,
+	    },
+	    dns3 => {
+		description => 'Third name server IP address.',
+		type => 'string',
+		optional => 1,
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $res = PVE::INotify::read_file('resolvconf');
+
+	return $res;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'update_dns',
+    path => 'dns',
+    method => 'PUT',
+    description => "Write DNS settings.",
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Modify' ]],
+    },
+    proxyto => 'node',
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    search => {
+		description => "Search domain for host-name lookup.",
+		type => 'string',
+	    },
+	    dns1 => {
+		description => 'First name server IP address.',
+		type => 'string', format => 'ip',
+		optional => 1,
+	    },
+	    dns2 => {
+		description => 'Second name server IP address.',
+		type => 'string', format => 'ip',
+		optional => 1,
+	    },
+	    dns3 => {
+		description => 'Third name server IP address.',
+		type => 'string', format => 'ip',
+		optional => 1,
+	    },
+	},
+    },
+    returns => { type => "null" },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::INotify::update_file('resolvconf', $param);
+
+	return;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'time',
+    path => 'time',
+    method => 'GET',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+   },
+    description => "Read server time and time zone settings.",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => "object",
+	additionalProperties => 0,
+	properties => {
+	    timezone => {
+		description => "Time zone",
+		type => 'string',
+	    },
+	    time => {
+		description => "Seconds since 1970-01-01 00:00:00 UTC.",
+		type => 'integer',
+		minimum => 1297163644,
+		renderer => 'timestamp',
+	    },
+	    localtime => {
+		description => "Seconds since 1970-01-01 00:00:00 (local time)",
+		type => 'integer',
+		minimum => 1297163644,
+		renderer => 'timestamp_gmt',
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $ctime = time();
+	my $ltime = timegm_nocheck(localtime($ctime));
+	my $res = {
+	    timezone => PVE::INotify::read_file('timezone'),
+	    time => $ctime,
+	    localtime => $ltime,
+	};
+
+	return $res;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'set_timezone',
+    path => 'time',
+    method => 'PUT',
+    description => "Set time zone.",
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Modify' ]],
+    },
+    proxyto => 'node',
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    timezone => {
+		description => "Time zone. The file '/usr/share/zoneinfo/zone.tab' contains the list of valid names.",
+		type => 'string',
+	    },
+	},
+    },
+    returns => { type => "null" },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::INotify::write_file('timezone', $param->{timezone});
+
+	return;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'aplinfo',
+    path => 'aplinfo',
+    method => 'GET',
+    permissions => {
+	user => 'all',
+    },
+    description => "Get list of appliances.",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "object",
+	    properties => {},
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $list = PVE::APLInfo::load_data();
+
+	my $res = [];
+	for my $appliance (values %{$list->{all}}) {
+	    next if $appliance->{'package'} eq 'pve-web-news';
+	    push @$res, $appliance;
+	}
+
+	return $res;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'apl_download',
+    path => 'aplinfo',
+    method => 'POST',
+    permissions => {
+	check => ['perm', '/storage/{storage}', ['Datastore.AllocateTemplate']],
+    },
+    description => "Download appliance templates.",
+    proxyto => 'node',
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    storage => get_standard_option('pve-storage-id', {
+		description => "The storage where the template will be stored",
+		completion => \&PVE::Storage::complete_storage_enabled,
+	    }),
+	    template => {
+		type => 'string',
+		description => "The template which will downloaded",
+		maxLength => 255,
+		completion => \&complete_templet_repo,
+	    },
+	},
+    },
+    returns => { type => "string" },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $user = $rpcenv->get_user();
+
+	my $node = $param->{node};
+	my $template = $param->{template};
+
+	my $list = PVE::APLInfo::load_data();
+	my $appliance = $list->{all}->{$template};
+	raise_param_exc({ template => "no such template"}) if !$appliance;
+
+	my $cfg = PVE::Storage::config();
+	my $scfg = PVE::Storage::storage_check_enabled($cfg, $param->{storage}, $node);
+
+	die "unknown template type '$appliance->{type}'\n"
+	    if !($appliance->{type} eq 'openvz' || $appliance->{type} eq 'lxc');
+
+	die "storage '$param->{storage}' does not support templates\n"
+	    if !$scfg->{content}->{vztmpl};
+
+	my $tmpldir = PVE::Storage::get_vztmpl_dir($cfg, $param->{storage});
+
+	my $worker = sub {
+	    my $dccfg = PVE::Cluster::cfs_read_file('datacenter.cfg');
+
+	    PVE::Tools::download_file_from_url("$tmpldir/$template", $appliance->{location}, {
+		hash_required => 1,
+		sha512sum => $appliance->{sha512sum},
+		md5sum => $appliance->{md5sum},
+		http_proxy => $dccfg->{http_proxy},
+	    });
+	};
+
+	my $upid = $rpcenv->fork_worker('download', $template, $user, $worker);
+
+	return $upid;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'query_url_metadata',
+    path => 'query-url-metadata',
+    method => 'GET',
+    description => "Query metadata of an URL: file size, file name and mime type.",
+    proxyto => 'node',
+    permissions => {
+	check => ['or',
+	    ['perm', '/', [ 'Sys.Audit', 'Sys.Modify' ]],
+	    ['perm', '/nodes/{node}', [ 'Sys.AccessNetwork' ]],
+	],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    url => {
+		description => "The URL to query the metadata from.",
+		type => 'string',
+		pattern => 'https?://.*',
+	    },
+	    'verify-certificates' => {
+		description => "If false, no SSL/TLS certificates will be verified.",
+		type => 'boolean',
+		optional => 1,
+		default => 1,
+	    },
+	},
+    },
+    returns => {
+	type => "object",
+	properties => {
+	    filename => {
+		type => 'string',
+		optional => 1,
+	    },
+	    size => {
+		type => 'integer',
+		renderer => 'bytes',
+		optional => 1,
+	    },
+	    mimetype => {
+		type => 'string',
+		optional => 1,
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $url = $param->{url};
+
+	my $ua = LWP::UserAgent->new();
+	$ua->agent("Proxmox VE");
+
+	my $dccfg = PVE::Cluster::cfs_read_file('datacenter.cfg');
+	if ($dccfg->{http_proxy}) {
+	    $ua->proxy('http', $dccfg->{http_proxy});
+	}
+
+	my $verify = $param->{'verify-certificates'} // 1;
+	if (!$verify) {
+	    $ua->ssl_opts(
+		verify_hostname => 0,
+		SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+	    );
+	}
+
+	my $req = HTTP::Request->new(HEAD => $url);
+	my $res = $ua->request($req);
+
+	die "invalid server response: '" . $res->status_line() . "'\n" if ($res->code() != 200);
+
+	my $size = $res->header("Content-Length");
+	my $disposition = $res->header("Content-Disposition");
+	my $type = $res->header("Content-Type");
+
+	my $filename;
+
+	if ($disposition && ($disposition =~ m/filename="([^"]*)"/ || $disposition =~ m/filename=([^;]*)/)) {
+	    $filename = $1;
+	} elsif ($url =~ m!^[^?]+/([^?/]*)(?:\?.*)?$!) {
+	    $filename = $1;
+	}
+
+	# Content-Type: text/html; charset=utf-8
+	if ($type && $type =~ m/^([^;]+);/) {
+	    $type = $1;
+	}
+
+	my $ret = {};
+	$ret->{filename} = $filename if $filename;
+	$ret->{size} = $size + 0 if $size;
+	$ret->{mimetype} = $type if $type;
+
+	return $ret;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'report',
+    path => 'report',
+    method => 'GET',
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
+    },
+    protected => 1,
+    description => "Gather various systems information about a node",
+    proxyto => 'node',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => 'string',
+    },
+    code => sub {
+	return PVE::Report::generate();
+    }});
+
+# returns a list of VMIDs, those can be filtered by
+# * current parent node
+# * vmid whitelist
+# * guest is a template (default: skip)
+# * guest is HA manged (default: skip)
+my $get_filtered_vmlist = sub {
+    my ($nodename, $vmfilter, $templates, $ha_managed) = @_;
+
+    my $vmlist = PVE::Cluster::get_vmlist();
+
+    my $vms_allowed;
+    if (defined($vmfilter)) {
+	$vms_allowed = { map { $_ => 1 } PVE::Tools::split_list($vmfilter) };
+    }
+
+    my $res = {};
+    foreach my $vmid (keys %{$vmlist->{ids}}) {
+	next if defined($vms_allowed) && !$vms_allowed->{$vmid};
+
+	my $d = $vmlist->{ids}->{$vmid};
+	next if $nodename && $d->{node} ne $nodename;
+
+	eval {
+	    my $class;
+	    if ($d->{type} eq 'lxc') {
+		$class = 'PVE::LXC::Config';
+	    } elsif ($d->{type} eq 'qemu') {
+		$class = 'PVE::QemuConfig';
+	    } else {
+		die "unknown virtual guest type '$d->{type}'\n";
+	    }
+
+	    my $conf = $class->load_config($vmid);
+	    return if !$templates && $class->is_template($conf);
+	    return if !$ha_managed && PVE::HA::Config::vm_is_ha_managed($vmid);
+
+	    $res->{$vmid}->{conf} = $conf;
+	    $res->{$vmid}->{type} = $d->{type};
+	    $res->{$vmid}->{class} = $class;
+	};
+	warn $@ if $@;
+    }
+
+    return $res;
+};
+
+# return all VMs which should get started/stopped on power up/down
+my $get_start_stop_list = sub {
+    my ($nodename, $autostart, $vmfilter) = @_;
+
+    # do not skip HA vms on force or if a specific VMID set is wanted
+    my $include_ha_managed = defined($vmfilter) ? 1 : 0;
+
+    my $vmlist = $get_filtered_vmlist->($nodename, $vmfilter, undef, $include_ha_managed);
+
+    my $resList = {};
+    foreach my $vmid (keys %$vmlist) {
+	my $conf = $vmlist->{$vmid}->{conf};
+	next if $autostart && !$conf->{onboot};
+
+	my $startup = $conf->{startup} ? PVE::JSONSchema::pve_parse_startup_order($conf->{startup}) : {};
+	my $order = $startup->{order} = $startup->{order} // LONG_MAX;
+
+	$resList->{$order}->{$vmid} = $startup;
+	$resList->{$order}->{$vmid}->{type} = $vmlist->{$vmid}->{type};
+    }
+
+    return $resList;
+};
+
+my $remove_locks_on_startup = sub {
+    my ($nodename) = @_;
+
+    my $vmlist = &$get_filtered_vmlist($nodename, undef, undef, 1);
+
+    foreach my $vmid (keys %$vmlist) {
+	my $conf = $vmlist->{$vmid}->{conf};
+	my $class = $vmlist->{$vmid}->{class};
+
+	eval {
+	    if ($class->has_lock($conf, 'backup')) {
+		$class->remove_lock($vmid, 'backup');
+		my $msg =  "removed left over backup lock from '$vmid'!";
+		warn "$msg\n"; # prints to task log
+		syslog('warning', $msg);
+	    }
+	}; warn $@ if $@;
+    }
+};
+
+__PACKAGE__->register_method ({
+    name => 'startall',
+    path => 'startall',
+    method => 'POST',
+    protected => 1,
+    permissions => {
+	description => "The 'VM.PowerMgmt' permission is required on '/' or on '/vms/<ID>' for "
+	    ."each ID passed via the 'vms' parameter.",
+	user => 'all',
+    },
+    proxyto => 'node',
+    description => "Start all VMs and containers located on this node (by default only those with onboot=1).",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    force => {
+		optional => 1,
+		type => 'boolean',
+		default => 'off',
+		description => "Issue start command even if virtual guest have 'onboot' not set or set to off.",
+	    },
+	    vms => {
+		description => "Only consider guests from this comma separated list of VMIDs.",
+		type => 'string',  format => 'pve-vmid-list',
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => 'string',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	if (!$rpcenv->check($authuser, "/", [ 'VM.PowerMgmt' ], 1)) {
+	    my @vms = PVE::Tools::split_list($param->{vms});
+	    if (scalar(@vms) > 0) {
+		$rpcenv->check($authuser, "/vms/$_", [ 'VM.PowerMgmt' ]) for @vms;
+	    } else {
+		raise_perm_exc("/, VM.PowerMgmt");
+	    }
+	}
+
+	my $nodename = $param->{node};
+	$nodename = PVE::INotify::nodename() if $nodename eq 'localhost';
+
+	my $force = $param->{force};
+
+	my $code = sub {
+	    $rpcenv->{type} = 'priv'; # to start tasks in background
+
+	    if (!PVE::Cluster::check_cfs_quorum(1)) {
+		print "waiting for quorum ...\n";
+		do {
+		    sleep(1);
+		} while (!PVE::Cluster::check_cfs_quorum(1));
+		print "got quorum\n";
+	    }
+
+	    eval { # remove backup locks, but avoid running into a scheduled backup job
+		PVE::Tools::lock_file('/var/run/vzdump.lock', 10, $remove_locks_on_startup, $nodename);
+	    };
+	    warn $@ if $@;
+
+	    my $autostart = $force ? undef : 1;
+	    my $startList = $get_start_stop_list->($nodename, $autostart, $param->{vms});
+
+	    # Note: use numeric sorting with <=>
+	    for my $order (sort {$a <=> $b} keys %$startList) {
+		my $vmlist = $startList->{$order};
+
+		for my $vmid (sort {$a <=> $b} keys %$vmlist) {
+		    my $d = $vmlist->{$vmid};
+
+		    PVE::Cluster::check_cfs_quorum(); # abort when we loose quorum
+
+		    eval {
+			my $default_delay = 0;
+			my $upid;
+
+			if ($d->{type} eq 'lxc') {
+			    return if PVE::LXC::check_running($vmid);
+			    print STDERR "Starting CT $vmid\n";
+			    $upid = PVE::API2::LXC::Status->vm_start({node => $nodename, vmid => $vmid });
+			} elsif ($d->{type} eq 'qemu') {
+			    $default_delay = 3; # to reduce load
+			    return if PVE::QemuServer::check_running($vmid, 1);
+			    print STDERR "Starting VM $vmid\n";
+			    $upid = PVE::API2::Qemu->vm_start({node => $nodename, vmid => $vmid });
+			} else {
+			    die "unknown VM type '$d->{type}'\n";
+			}
+
+			my $task = PVE::Tools::upid_decode($upid);
+			while (PVE::ProcFSTools::check_process_running($task->{pid})) {
+			    sleep(1);
+			}
+
+			my $status = PVE::Tools::upid_read_status($upid);
+			if (!PVE::Tools::upid_status_is_error($status)) {
+			    # use default delay to reduce load
+			    my $delay = defined($d->{up}) ? int($d->{up}) : $default_delay;
+			    if ($delay > 0) {
+				print STDERR "Waiting for $delay seconds (startup delay)\n" if $d->{up};
+				for (my $i = 0; $i < $delay; $i++) {
+				    sleep(1);
+				}
+			    }
+			} else {
+			    my $rendered_type = $d->{type} eq 'lxc' ? 'CT' : 'VM';
+			    print STDERR "Starting $rendered_type $vmid failed: $status\n";
+			}
+		    };
+		    warn $@ if $@;
+		}
+	    }
+	    return;
+	};
+
+	return $rpcenv->fork_worker('startall', undef, $authuser, $code);
+    }});
+
+my $create_stop_worker = sub {
+    my ($nodename, $type, $vmid, $timeout, $force_stop) = @_;
+
+    if ($type eq 'lxc') {
+	return if !PVE::LXC::check_running($vmid);
+	print STDERR "Stopping CT $vmid (timeout = $timeout seconds)\n";
+	return PVE::API2::LXC::Status->vm_shutdown(
+	    { node => $nodename, vmid => $vmid, timeout => $timeout, forceStop => $force_stop }
+	);
+    } elsif ($type eq 'qemu') {
+	return if !PVE::QemuServer::check_running($vmid, 1);
+	print STDERR "Stopping VM $vmid (timeout = $timeout seconds)\n";
+	return PVE::API2::Qemu->vm_shutdown(
+	    { node => $nodename, vmid => $vmid, timeout => $timeout, forceStop => $force_stop }
+	);
+    } else {
+	die "unknown VM type '$type'\n";
+    }
+};
+
+__PACKAGE__->register_method ({
+    name => 'stopall',
+    path => 'stopall',
+    method => 'POST',
+    protected => 1,
+    permissions => {
+	description => "The 'VM.PowerMgmt' permission is required on '/' or on '/vms/<ID>' for "
+	    ."each ID passed via the 'vms' parameter.",
+	user => 'all',
+    },
+    proxyto => 'node',
+    description => "Stop all VMs and Containers.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    vms => {
+		description => "Only consider Guests with these IDs.",
+		type => 'string',  format => 'pve-vmid-list',
+		optional => 1,
+	    },
+	    'force-stop' => {
+		description => 'Force a hard-stop after the timeout.',
+		type => 'boolean',
+		default => 1,
+		optional => 1,
+	    },
+	    'timeout' => {
+		description => 'Timeout for each guest shutdown task. Depending on `force-stop`,'
+		    .' the shutdown gets then simply aborted or a hard-stop is forced.',
+		type => 'integer',
+		optional => 1,
+		default => 180,
+		minimum => 0,
+		maximum => 2 * 3600, # mostly arbitrary, but we do not want to high timeouts
+	    },
+	},
+    },
+    returns => {
+	type => 'string',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	if (!$rpcenv->check($authuser, "/", [ 'VM.PowerMgmt' ], 1)) {
+	    my @vms = PVE::Tools::split_list($param->{vms});
+	    if (scalar(@vms) > 0) {
+		$rpcenv->check($authuser, "/vms/$_", [ 'VM.PowerMgmt' ]) for @vms;
+	    } else {
+		raise_perm_exc("/, VM.PowerMgmt");
+	    }
+	}
+
+	my $nodename = $param->{node};
+	$nodename = PVE::INotify::nodename() if $nodename eq 'localhost';
+
+	my $code = sub {
+
+	    $rpcenv->{type} = 'priv'; # to start tasks in background
+
+	    my $stopList = $get_start_stop_list->($nodename, undef, $param->{vms});
+
+	    my $cpuinfo = PVE::ProcFSTools::read_cpuinfo();
+	    my $datacenterconfig = cfs_read_file('datacenter.cfg');
+	    # if not set by user spawn max cpu count number of workers
+	    my $maxWorkers =  $datacenterconfig->{max_workers} || $cpuinfo->{cpus};
+
+	    for my $order (sort {$b <=> $a} keys %$stopList) {
+		my $vmlist = $stopList->{$order};
+		my $workers = {};
+
+		my $finish_worker = sub {
+		    my $pid = shift;
+		    my $worker = delete $workers->{$pid} || return;
+
+		    syslog('info', "end task $worker->{upid}");
+		};
+
+		for my $vmid (sort {$b <=> $a} keys %$vmlist) {
+		    my $d = $vmlist->{$vmid};
+		    my $timeout = int($d->{down} // $param->{timeout} // 180);
+		    my $upid = eval {
+			$create_stop_worker->(
+			    $nodename, $d->{type}, $vmid, $timeout, $param->{'force-stop'} // 1)
+		    };
+		    warn $@ if $@;
+		    next if !$upid;
+
+		    my $task = PVE::Tools::upid_decode($upid, 1);
+		    next if !$task;
+
+		    my $pid = $task->{pid};
+
+		    $workers->{$pid} = { type => $d->{type}, upid => $upid, vmid => $vmid };
+		    while (scalar(keys %$workers) >= $maxWorkers) {
+			foreach my $p (keys %$workers) {
+			    if (!PVE::ProcFSTools::check_process_running($p)) {
+				$finish_worker->($p);
+			    }
+			}
+			sleep(1);
+		    }
+		}
+		while (scalar(keys %$workers)) {
+		    for my $p (keys %$workers) {
+			if (!PVE::ProcFSTools::check_process_running($p)) {
+			    $finish_worker->($p);
+			}
+		    }
+		    sleep(1);
+		}
+	    }
+
+	    syslog('info', "all VMs and CTs stopped");
+
+	    return;
+	};
+
+	return $rpcenv->fork_worker('stopall', undef, $authuser, $code);
+    }});
+
+my $create_suspend_worker = sub {
+    my ($nodename, $vmid) = @_;
+    if (!PVE::QemuServer::check_running($vmid, 1)) {
+	print "VM $vmid not running, skipping suspension\n";
+	return;
+    }
+    print STDERR "Suspending VM $vmid\n";
+    return PVE::API2::Qemu->vm_suspend(
+	{ node => $nodename, vmid => $vmid, todisk => 1 }
+    );
+};
+
+__PACKAGE__->register_method ({
+    name => 'suspendall',
+    path => 'suspendall',
+    method => 'POST',
+    protected => 1,
+    permissions => {
+	description => "The 'VM.PowerMgmt' permission is required on '/' or on '/vms/<ID>' for each"
+	    ." ID passed via the 'vms' parameter. Additionally, you need 'VM.Config.Disk' on the"
+	    ." '/vms/{vmid}' path and 'Datastore.AllocateSpace' for the configured state-storage(s)",
+	user => 'all',
+    },
+    proxyto => 'node',
+    description => "Suspend all VMs.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    vms => {
+		description => "Only consider Guests with these IDs.",
+		type => 'string',  format => 'pve-vmid-list',
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => 'string',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	# we cannot really check access to the state-storage here, that's happening per worker.
+	if (!$rpcenv->check($authuser, "/", [ 'VM.PowerMgmt', 'VM.Config.Disk' ], 1)) {
+	    my @vms = PVE::Tools::split_list($param->{vms});
+	    if (scalar(@vms) > 0) {
+		$rpcenv->check($authuser, "/vms/$_", [ 'VM.PowerMgmt' ]) for @vms;
+	    } else {
+		raise_perm_exc("/, VM.PowerMgmt && VM.Config.Disk");
+	    }
+	}
+
+	my $nodename = $param->{node};
+	$nodename = PVE::INotify::nodename() if $nodename eq 'localhost';
+
+	my $code = sub {
+
+	    $rpcenv->{type} = 'priv'; # to start tasks in background
+
+	    my $toSuspendList = $get_start_stop_list->($nodename, undef, $param->{vms});
+
+	    my $cpuinfo = PVE::ProcFSTools::read_cpuinfo();
+	    my $datacenterconfig = cfs_read_file('datacenter.cfg');
+	    # if not set by user spawn max cpu count number of workers
+	    my $maxWorkers =  $datacenterconfig->{max_workers} || $cpuinfo->{cpus};
+
+	    for my $order (sort {$b <=> $a} keys %$toSuspendList) {
+		my $vmlist = $toSuspendList->{$order};
+		my $workers = {};
+
+		my $finish_worker = sub {
+		    my $pid = shift;
+		    my $worker = delete $workers->{$pid} || return;
+
+		    syslog('info', "end task $worker->{upid}");
+		};
+
+		for my $vmid (sort {$b <=> $a} keys %$vmlist) {
+		    my $d = $vmlist->{$vmid};
+		    if ($d->{type} ne 'qemu') {
+			log_warn("skipping $vmid, only VMs can be suspended");
+			next;
+		    }
+		    my $upid = eval {
+			$create_suspend_worker->($nodename, $vmid)
+		    };
+		    warn $@ if $@;
+		    next if !$upid;
+
+		    my $task = PVE::Tools::upid_decode($upid, 1);
+		    next if !$task;
+
+		    my $pid = $task->{pid};
+		    $workers->{$pid} = { type => $d->{type}, upid => $upid, vmid => $vmid };
+
+		    while (scalar(keys %$workers) >= $maxWorkers) {
+			for my $p (keys %$workers) {
+			    if (!PVE::ProcFSTools::check_process_running($p)) {
+				$finish_worker->($p);
+			    }
+			}
+			sleep(1);
+		    }
+		}
+		while (scalar(keys %$workers)) {
+		    for my $p (keys %$workers) {
+			if (!PVE::ProcFSTools::check_process_running($p)) {
+			    $finish_worker->($p);
+			}
+		    }
+		    sleep(1);
+		}
+	    }
+
+	    syslog('info', "all VMs suspended");
+
+	    return;
+	};
+
+	return $rpcenv->fork_worker('suspendall', undef, $authuser, $code);
+    }});
+
+
+my $create_migrate_worker = sub {
+    my ($nodename, $type, $vmid, $target, $with_local_disks) = @_;
+
+    my $upid;
+    if ($type eq 'lxc') {
+	my $online = PVE::LXC::check_running($vmid) ? 1 : 0;
+	print STDERR "Migrating CT $vmid\n";
+	$upid = PVE::API2::LXC->migrate_vm(
+	   { node => $nodename, vmid => $vmid, target => $target, restart => $online });
+    } elsif ($type eq 'qemu') {
+	print STDERR "Check VM $vmid: ";
+	*STDERR->flush();
+	my $online = PVE::QemuServer::check_running($vmid, 1) ? 1 : 0;
+	my $preconditions = PVE::API2::Qemu->migrate_vm_precondition(
+	    {node => $nodename, vmid => $vmid, target => $target});
+	my $invalidConditions = '';
+	if ($online && !$with_local_disks && scalar @{$preconditions->{local_disks}}) {
+	    $invalidConditions .= "\n  Has local disks: ";
+	    $invalidConditions .= join(', ', map { $_->{volid} } @{$preconditions->{local_disks}});
+	}
+
+	if (@{$preconditions->{local_resources}}) {
+	    $invalidConditions .= "\n  Has local resources: ";
+	    $invalidConditions .= join(', ', @{$preconditions->{local_resources}});
+	}
+
+	if ($invalidConditions && $invalidConditions ne '') {
+	    print STDERR "skip VM $vmid - precondition check failed:";
+	    die "$invalidConditions\n";
+	}
+	print STDERR "precondition check passed\n";
+	print STDERR "Migrating VM $vmid\n";
+
+	my $params = {
+	    node => $nodename,
+	    vmid => $vmid,
+	    target => $target,
+	    online => $online,
+	};
+	$params->{'with-local-disks'} = $with_local_disks if defined($with_local_disks);
+
+	$upid = PVE::API2::Qemu->migrate_vm($params);
+    } else {
+	die "unknown VM type '$type'\n";
+    }
+
+    my $task = PVE::Tools::upid_decode($upid);
+
+    return $task->{pid};
+};
+
+__PACKAGE__->register_method ({
+    name => 'migrateall',
+    path => 'migrateall',
+    method => 'POST',
+    proxyto => 'node',
+    protected => 1,
+    permissions => {
+	description => "The 'VM.Migrate' permission is required on '/' or on '/vms/<ID>' for each "
+	    ."ID passed via the 'vms' parameter.",
+	user => 'all',
+    },
+    description => "Migrate all VMs and Containers.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    target => get_standard_option('pve-node', { description => "Target node." }),
+	    maxworkers => {
+		description => "Maximal number of parallel migration job. If not set, uses"
+		    ."'max_workers' from datacenter.cfg. One of both must be set!",
+		optional => 1,
+		type => 'integer',
+		minimum => 1
+	    },
+	    vms => {
+		description => "Only consider Guests with these IDs.",
+		type => 'string',  format => 'pve-vmid-list',
+		optional => 1,
+	    },
+	    "with-local-disks" => {
+		type => 'boolean',
+		description => "Enable live storage migration for local disk",
+		optional => 1,
+	    },
+	},
+    },
+    returns => {
+	type => 'string',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	if (!$rpcenv->check($authuser, "/", [ 'VM.Migrate' ], 1)) {
+	    my @vms = PVE::Tools::split_list($param->{vms});
+	    if (scalar(@vms) > 0) {
+		$rpcenv->check($authuser, "/vms/$_", [ 'VM.Migrate' ]) for @vms;
+	    } else {
+		raise_perm_exc("/, VM.Migrate");
+	    }
+	}
+
+	my $nodename = $param->{node};
+	$nodename = PVE::INotify::nodename() if $nodename eq 'localhost';
+
+	my $target = $param->{target};
+	my $with_local_disks = $param->{'with-local-disks'};
+	raise_param_exc({ target => "target is local node."}) if $target eq $nodename;
+
+	PVE::Cluster::check_cfs_quorum();
+
+	PVE::Cluster::check_node_exists($target);
+
+	my $datacenterconfig = cfs_read_file('datacenter.cfg');
+	# prefer parameter over datacenter cfg settings
+	my $maxWorkers = $param->{maxworkers} || $datacenterconfig->{max_workers} ||
+	    die "either 'maxworkers' parameter or max_workers in datacenter.cfg must be set!\n";
+
+	my $code = sub {
+	    $rpcenv->{type} = 'priv'; # to start tasks in background
+
+	    my $vmlist = &$get_filtered_vmlist($nodename, $param->{vms}, 1, 1);
+	    if (!scalar(keys %$vmlist)) {
+		warn "no virtual guests matched, nothing to do..\n";
+		return;
+	    }
+
+	    my $workers = {};
+	    my $workers_started = 0;
+	    foreach my $vmid (sort keys %$vmlist) {
+		my $d = $vmlist->{$vmid};
+		my $pid;
+		eval { $pid = &$create_migrate_worker($nodename, $d->{type}, $vmid, $target, $with_local_disks); };
+		warn $@ if $@;
+		next if !$pid;
+
+		$workers_started++;
+		$workers->{$pid} = 1;
+		while (scalar(keys %$workers) >= $maxWorkers) {
+		    foreach my $p (keys %$workers) {
+			if (!PVE::ProcFSTools::check_process_running($p)) {
+			    delete $workers->{$p};
+			}
+		    }
+		    sleep(1);
+		}
+	    }
+	    while (scalar(keys %$workers)) {
+		foreach my $p (keys %$workers) {
+		    # FIXME: what about PID re-use ?!?!
+		    if (!PVE::ProcFSTools::check_process_running($p)) {
+			delete $workers->{$p};
+		    }
+		}
+		sleep(1);
+	    }
+	    if ($workers_started <= 0) {
+		die "no migrations worker started...\n";
+	    }
+	    print STDERR "All jobs finished, used $workers_started workers in total.\n";
+	    return;
+	};
+
+	return $rpcenv->fork_worker('migrateall', undef, $authuser, $code);
+
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'get_etc_hosts',
+    path => 'hosts',
+    method => 'GET',
+    proxyto => 'node',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', [ 'Sys.Audit' ]],
+    },
+    description => "Get the content of /etc/hosts.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => 'object',
+	properties => {
+	    digest => get_standard_option('pve-config-digest'),
+	    data => {
+		type => 'string',
+		description => 'The content of /etc/hosts.'
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	return PVE::INotify::read_file('etchosts');
+
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'write_etc_hosts',
+    path => 'hosts',
+    method => 'POST',
+    proxyto => 'node',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Modify' ]],
+    },
+    description => "Write /etc/hosts.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    digest => get_standard_option('pve-config-digest'),
+	    data => {
+		type => 'string',
+		description =>  'The target content of /etc/hosts.'
+	    },
+	},
+    },
+    returns => {
+	type => 'null',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::Tools::lock_file('/var/lock/pve-etchosts.lck', undef, sub {
+	    if ($param->{digest}) {
+		my $hosts = PVE::INotify::read_file('etchosts');
+		PVE::Tools::assert_if_modified($hosts->{digest}, $param->{digest});
+	    }
+	    PVE::INotify::write_file('etchosts', $param->{data});
+	});
+	die $@ if $@;
+
+	return;
+    }});
+
+# bash completion helper
+
+sub complete_templet_repo {
+    my ($cmdname, $pname, $cvalue) = @_;
+
+    my $repo = PVE::APLInfo::load_data();
+    my $res = [];
+    foreach my $templ (keys %{$repo->{all}}) {
+	next if $templ !~ m/^$cvalue/;
+	push @$res, $templ;
+    }
+
+    return $res;
+}
+
+package PVE::API2::Nodes;
+
+use strict;
+use warnings;
+
+use PVE::SafeSyslog;
+use PVE::Cluster;
+use PVE::RESTHandler;
+use PVE::RPCEnvironment;
+use PVE::API2Tools;
+use PVE::JSONSchema qw(get_standard_option);
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Nodes::Nodeinfo",
+    path => '{node}',
+});
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    permissions => { user => 'all' },
+    description => "Cluster node index.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => "object",
+	    properties => {
+		node => get_standard_option('pve-node'),
+		status => {
+		    description => "Node status.",
+		    type => 'string',
+		    enum => ['unknown', 'online', 'offline'],
+		},
+		cpu => {
+		    description => "CPU utilization.",
+		    type => 'number',
+		    optional => 1,
+		    renderer => 'fraction_as_percentage',
+		},
+		maxcpu => {
+		    description => "Number of available CPUs.",
+		    type => 'integer',
+		    optional => 1,
+		},
+		mem => {
+		    description => "Used memory in bytes.",
+		    type => 'integer',
+		    optional => 1,
+		    renderer => 'bytes',
+		},
+		maxmem => {
+		    description => "Number of available memory in bytes.",
+		    type => 'integer',
+		    optional => 1,
+		    renderer => 'bytes',
+		},
+		level => {
+		    description => "Support level.",
+		    type => 'string',
+		    optional => 1,
+		},
+		uptime => {
+		    description => "Node uptime in seconds.",
+		    type => 'integer',
+		    optional => 1,
+		    renderer => 'duration',
+		},
+		ssl_fingerprint => {
+		    description => "The SSL fingerprint for the node certificate.",
+		    type => 'string',
+		    optional => 1,
+		},
+	    },
+	},
+	links => [ { rel => 'child', href => "{node}" } ],
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	my $clinfo = PVE::Cluster::get_clinfo();
+	my $res = [];
+
+	my $nodelist = PVE::Cluster::get_nodelist();
+	my $members = PVE::Cluster::get_members();
+	my $rrd = PVE::Cluster::rrd_dump();
+
+	foreach my $node (@$nodelist) {
+	    my $can_audit = $rpcenv->check($authuser, "/nodes/$node", [ 'Sys.Audit' ], 1);
+	    my $entry = PVE::API2Tools::extract_node_stats($node, $members, $rrd, !$can_audit);
+
+	    $entry->{ssl_fingerprint} = eval { PVE::Cluster::get_node_fingerprint($node) };
+	    warn "$@" if $@;
+
+	    push @$res, $entry;
+	}
+
+	return $res;
+    }});
+
+1;
diff --git a/usr/share/pve-manager/js/pvemanagerlib.js b/usr/share/pve-manager/js/pvemanagerlib.js
new file mode 100644
index 0000000..1d090d3
--- /dev/null
+++ b/usr/share/pve-manager/js/pvemanagerlib.js
@@ -0,0 +1,60994 @@
+const pveOnlineHelpInfo = {
+   "ceph_rados_block_devices" : {
+      "link" : "/pve-docs/chapter-pvesm.html#ceph_rados_block_devices",
+      "title" : "Ceph RADOS Block Devices (RBD)"
+   },
+   "chapter_ha_manager" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#chapter_ha_manager",
+      "title" : "High Availability"
+   },
+   "chapter_lvm" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#chapter_lvm",
+      "title" : "Logical Volume Manager (LVM)"
+   },
+   "chapter_notifications" : {
+      "link" : "/pve-docs/chapter-notifications.html#chapter_notifications",
+      "title" : "Notifications"
+   },
+   "chapter_pct" : {
+      "link" : "/pve-docs/chapter-pct.html#chapter_pct",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "chapter_pve_firewall" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#chapter_pve_firewall",
+      "title" : "Proxmox VE Firewall"
+   },
+   "chapter_pveceph" : {
+      "link" : "/pve-docs/chapter-pveceph.html#chapter_pveceph",
+      "title" : "Deploy Hyper-Converged Ceph Cluster"
+   },
+   "chapter_pvecm" : {
+      "link" : "/pve-docs/chapter-pvecm.html#chapter_pvecm",
+      "title" : "Cluster Manager"
+   },
+   "chapter_pvesdn" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#chapter_pvesdn",
+      "title" : "Software-Defined Network"
+   },
+   "chapter_pvesr" : {
+      "link" : "/pve-docs/chapter-pvesr.html#chapter_pvesr",
+      "title" : "Storage Replication"
+   },
+   "chapter_storage" : {
+      "link" : "/pve-docs/chapter-pvesm.html#chapter_storage",
+      "title" : "Proxmox VE Storage"
+   },
+   "chapter_system_administration" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#chapter_system_administration",
+      "title" : "Host System Administration"
+   },
+   "chapter_user_management" : {
+      "link" : "/pve-docs/chapter-pveum.html#chapter_user_management",
+      "title" : "User Management"
+   },
+   "chapter_virtual_machines" : {
+      "link" : "/pve-docs/chapter-qm.html#chapter_virtual_machines",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "chapter_vzdump" : {
+      "link" : "/pve-docs/chapter-vzdump.html#chapter_vzdump",
+      "title" : "Backup and Restore"
+   },
+   "chapter_zfs" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#chapter_zfs",
+      "title" : "ZFS on Linux"
+   },
+   "datacenter_configuration_file" : {
+      "link" : "/pve-docs/pve-admin-guide.html#datacenter_configuration_file",
+      "title" : "Datacenter Configuration"
+   },
+   "external_metric_server" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#external_metric_server",
+      "title" : "External Metric Server"
+   },
+   "getting_help" : {
+      "link" : "/pve-docs/pve-admin-guide.html#getting_help",
+      "title" : "Getting Help"
+   },
+   "gui_my_settings" : {
+      "link" : "/pve-docs/chapter-pve-gui.html#gui_my_settings",
+      "subtitle" : "My Settings",
+      "title" : "Graphical User Interface"
+   },
+   "ha_manager_crs" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_crs",
+      "subtitle" : "Cluster Resource Scheduling",
+      "title" : "High Availability"
+   },
+   "ha_manager_fencing" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_fencing",
+      "subtitle" : "Fencing",
+      "title" : "High Availability"
+   },
+   "ha_manager_groups" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_groups",
+      "subtitle" : "Groups",
+      "title" : "High Availability"
+   },
+   "ha_manager_resource_config" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resource_config",
+      "subtitle" : "Resources",
+      "title" : "High Availability"
+   },
+   "ha_manager_resources" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resources",
+      "subtitle" : "Resources",
+      "title" : "High Availability"
+   },
+   "ha_manager_shutdown_policy" : {
+      "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_shutdown_policy",
+      "subtitle" : "Shutdown Policy",
+      "title" : "High Availability"
+   },
+   "markdown_basics" : {
+      "link" : "/pve-docs/pve-admin-guide.html#markdown_basics",
+      "title" : "Markdown Primer"
+   },
+   "metric_server_graphite" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#metric_server_graphite",
+      "subtitle" : "Graphite server configuration",
+      "title" : "External Metric Server"
+   },
+   "metric_server_influxdb" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#metric_server_influxdb",
+      "subtitle" : "Influxdb plugin configuration",
+      "title" : "External Metric Server"
+   },
+   "notification_matchers" : {
+      "link" : "/pve-docs/chapter-notifications.html#notification_matchers",
+      "subtitle" : "Notification Matchers",
+      "title" : "Notifications"
+   },
+   "notification_targets_gotify" : {
+      "link" : "/pve-docs/chapter-notifications.html#notification_targets_gotify",
+      "subtitle" : "Gotify",
+      "title" : "Notifications"
+   },
+   "notification_targets_sendmail" : {
+      "link" : "/pve-docs/chapter-notifications.html#notification_targets_sendmail",
+      "subtitle" : "Sendmail",
+      "title" : "Notifications"
+   },
+   "notification_targets_smtp" : {
+      "link" : "/pve-docs/chapter-notifications.html#notification_targets_smtp",
+      "subtitle" : "SMTP",
+      "title" : "Notifications"
+   },
+   "pct_configuration" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_configuration",
+      "subtitle" : "Configuration",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_container_images" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_container_images",
+      "subtitle" : "Container Images",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_container_network" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_container_network",
+      "subtitle" : "Network",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_container_storage" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_container_storage",
+      "subtitle" : "Container Storage",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_cpu" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_cpu",
+      "subtitle" : "CPU",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_general" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_general",
+      "subtitle" : "General Settings",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_memory" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_memory",
+      "subtitle" : "Memory",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_migration" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_migration",
+      "subtitle" : "Migration",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_options" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_options",
+      "subtitle" : "Options",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "pct_startup_and_shutdown" : {
+      "link" : "/pve-docs/chapter-pct.html#pct_startup_and_shutdown",
+      "subtitle" : "Automatic Start and Shutdown of Containers",
+      "title" : "Proxmox Container Toolkit"
+   },
+   "proxmox_node_management" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#proxmox_node_management",
+      "title" : "Proxmox Node Management"
+   },
+   "pve_admin_guide" : {
+      "link" : "/pve-docs/pve-admin-guide.html",
+      "title" : "Proxmox VE Administration Guide"
+   },
+   "pve_ceph_install" : {
+      "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_install",
+      "subtitle" : "CLI Installation of Ceph Packages",
+      "title" : "Deploy Hyper-Converged Ceph Cluster"
+   },
+   "pve_ceph_osds" : {
+      "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_osds",
+      "subtitle" : "Ceph OSDs",
+      "title" : "Deploy Hyper-Converged Ceph Cluster"
+   },
+   "pve_ceph_pools" : {
+      "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_pools",
+      "subtitle" : "Ceph Pools",
+      "title" : "Deploy Hyper-Converged Ceph Cluster"
+   },
+   "pve_documentation_index" : {
+      "link" : "/pve-docs/index.html",
+      "title" : "Proxmox VE Documentation Index"
+   },
+   "pve_firewall_cluster_wide_setup" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_cluster_wide_setup",
+      "subtitle" : "Cluster Wide Setup",
+      "title" : "Proxmox VE Firewall"
+   },
+   "pve_firewall_host_specific_configuration" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_host_specific_configuration",
+      "subtitle" : "Host Specific Configuration",
+      "title" : "Proxmox VE Firewall"
+   },
+   "pve_firewall_ip_aliases" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_aliases",
+      "subtitle" : "IP Aliases",
+      "title" : "Proxmox VE Firewall"
+   },
+   "pve_firewall_ip_sets" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_sets",
+      "subtitle" : "IP Sets",
+      "title" : "Proxmox VE Firewall"
+   },
+   "pve_firewall_security_groups" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_security_groups",
+      "subtitle" : "Security Groups",
+      "title" : "Proxmox VE Firewall"
+   },
+   "pve_firewall_vm_container_configuration" : {
+      "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_vm_container_configuration",
+      "subtitle" : "VM/Container Configuration",
+      "title" : "Proxmox VE Firewall"
+   },
+   "pve_service_daemons" : {
+      "link" : "/pve-docs/index.html#_service_daemons",
+      "title" : "Service Daemons"
+   },
+   "pveceph_fs" : {
+      "link" : "/pve-docs/chapter-pveceph.html#pveceph_fs",
+      "subtitle" : "CephFS",
+      "title" : "Deploy Hyper-Converged Ceph Cluster"
+   },
+   "pveceph_fs_create" : {
+      "link" : "/pve-docs/chapter-pveceph.html#pveceph_fs_create",
+      "subtitle" : "Create CephFS",
+      "title" : "Deploy Hyper-Converged Ceph Cluster"
+   },
+   "pvecm_create_cluster" : {
+      "link" : "/pve-docs/chapter-pvecm.html#pvecm_create_cluster",
+      "subtitle" : "Create a Cluster",
+      "title" : "Cluster Manager"
+   },
+   "pvecm_join_node_to_cluster" : {
+      "link" : "/pve-docs/chapter-pvecm.html#pvecm_join_node_to_cluster",
+      "subtitle" : "Adding Nodes to the Cluster",
+      "title" : "Cluster Manager"
+   },
+   "pvesdn_config_controllers" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_controllers",
+      "subtitle" : "Controllers",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_config_vnet" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_vnet",
+      "subtitle" : "VNets",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_config_zone" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_zone",
+      "subtitle" : "Zones",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_controller_plugin_evpn" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_controller_plugin_evpn",
+      "subtitle" : "EVPN Controller",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_dns_plugin_powerdns" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_dns_plugin_powerdns",
+      "subtitle" : "PowerDNS Plugin",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_ipam_plugin_netbox" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_netbox",
+      "subtitle" : "NetBox IPAM Plugin",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_ipam_plugin_phpipam" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_phpipam",
+      "subtitle" : "phpIPAM Plugin",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_ipam_plugin_pveipam" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_pveipam",
+      "subtitle" : "PVE IPAM Plugin",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_zone_plugin_evpn" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_evpn",
+      "subtitle" : "EVPN Zones",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_zone_plugin_qinq" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_qinq",
+      "subtitle" : "QinQ Zones",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_zone_plugin_simple" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_simple",
+      "subtitle" : "Simple Zones",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_zone_plugin_vlan" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vlan",
+      "subtitle" : "VLAN Zones",
+      "title" : "Software-Defined Network"
+   },
+   "pvesdn_zone_plugin_vxlan" : {
+      "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vxlan",
+      "subtitle" : "VXLAN Zones",
+      "title" : "Software-Defined Network"
+   },
+   "pvesr_schedule_time_format" : {
+      "link" : "/pve-docs/chapter-pvesr.html#pvesr_schedule_time_format",
+      "subtitle" : "Schedule Format",
+      "title" : "Storage Replication"
+   },
+   "pveum_authentication_realms" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_authentication_realms",
+      "subtitle" : "Authentication Realms",
+      "title" : "User Management"
+   },
+   "pveum_configure_u2f" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_configure_u2f",
+      "subtitle" : "Server Side U2F Configuration",
+      "title" : "User Management"
+   },
+   "pveum_configure_webauthn" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_configure_webauthn",
+      "subtitle" : "Server Side Webauthn Configuration",
+      "title" : "User Management"
+   },
+   "pveum_groups" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_groups",
+      "subtitle" : "Groups",
+      "title" : "User Management"
+   },
+   "pveum_ldap_sync" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_ldap_sync",
+      "subtitle" : "Syncing LDAP-Based Realms",
+      "title" : "User Management"
+   },
+   "pveum_permission_management" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_permission_management",
+      "subtitle" : "Permission Management",
+      "title" : "User Management"
+   },
+   "pveum_pools" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_pools",
+      "subtitle" : "Pools",
+      "title" : "User Management"
+   },
+   "pveum_roles" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_roles",
+      "subtitle" : "Roles",
+      "title" : "User Management"
+   },
+   "pveum_tokens" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_tokens",
+      "subtitle" : "API Tokens",
+      "title" : "User Management"
+   },
+   "pveum_users" : {
+      "link" : "/pve-docs/chapter-pveum.html#pveum_users",
+      "subtitle" : "Users",
+      "title" : "User Management"
+   },
+   "qm_bios_and_uefi" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_bios_and_uefi",
+      "subtitle" : "BIOS and UEFI",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_bootorder" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_bootorder",
+      "subtitle" : "Device Boot Order",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_cloud_init" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_cloud_init",
+      "title" : "Cloud-Init Support"
+   },
+   "qm_copy_and_clone" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_copy_and_clone",
+      "subtitle" : "Copies and Clones",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_cpu" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_cpu",
+      "subtitle" : "CPU",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_display" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_display",
+      "subtitle" : "Display",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_general_settings" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_general_settings",
+      "subtitle" : "General Settings",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_hard_disk" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_hard_disk",
+      "subtitle" : "Hard Disk",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_machine_type" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_machine_type",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_memory" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_memory",
+      "subtitle" : "Memory",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_migration" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_migration",
+      "subtitle" : "Migration",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_network_device" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_network_device",
+      "subtitle" : "Network Device",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_options" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_options",
+      "subtitle" : "Options",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_os_settings" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_os_settings",
+      "subtitle" : "OS Settings",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_pci_passthrough_vm_config" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_pci_passthrough_vm_config",
+      "subtitle" : "VM Configuration",
+      "title" : "PCI(e) Passthrough"
+   },
+   "qm_qemu_agent" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_qemu_agent",
+      "subtitle" : "QEMU Guest Agent",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_spice_enhancements" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_spice_enhancements",
+      "subtitle" : "SPICE Enhancements",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_startup_and_shutdown" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_startup_and_shutdown",
+      "subtitle" : "Automatic Start and Shutdown of Virtual Machines",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_system_settings" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_system_settings",
+      "subtitle" : "System Settings",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_usb_passthrough" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_usb_passthrough",
+      "subtitle" : "USB Passthrough",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_virtio_rng" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_virtio_rng",
+      "subtitle" : "VirtIO RNG",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "qm_virtual_machines_settings" : {
+      "link" : "/pve-docs/chapter-qm.html#qm_virtual_machines_settings",
+      "subtitle" : "Virtual Machines Settings",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "resource_mapping" : {
+      "link" : "/pve-docs/chapter-qm.html#resource_mapping",
+      "subtitle" : "Resource Mapping",
+      "title" : "QEMU/KVM Virtual Machines"
+   },
+   "storage_btrfs" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_btrfs",
+      "title" : "BTRFS Backend"
+   },
+   "storage_cephfs" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_cephfs",
+      "title" : "Ceph Filesystem (CephFS)"
+   },
+   "storage_cifs" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_cifs",
+      "title" : "CIFS Backend"
+   },
+   "storage_directory" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_directory",
+      "title" : "Directory Backend"
+   },
+   "storage_glusterfs" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_glusterfs",
+      "title" : "GlusterFS Backend"
+   },
+   "storage_lvm" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_lvm",
+      "title" : "LVM Backend"
+   },
+   "storage_lvmthin" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_lvmthin",
+      "title" : "LVM thin Backend"
+   },
+   "storage_nfs" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_nfs",
+      "title" : "NFS Backend"
+   },
+   "storage_open_iscsi" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_open_iscsi",
+      "title" : "Open-iSCSI initiator"
+   },
+   "storage_pbs" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_pbs",
+      "title" : "Proxmox Backup Server"
+   },
+   "storage_pbs_encryption" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_pbs_encryption",
+      "subtitle" : "Encryption",
+      "title" : "Proxmox Backup Server"
+   },
+   "storage_zfspool" : {
+      "link" : "/pve-docs/chapter-pvesm.html#storage_zfspool",
+      "title" : "Local ZFS Pool Backend"
+   },
+   "sysadmin_certificate_management" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certificate_management",
+      "title" : "Certificate Management"
+   },
+   "sysadmin_certs_acme_account" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certs_acme_account",
+      "subtitle" : "ACME Account",
+      "title" : "Certificate Management"
+   },
+   "sysadmin_certs_acme_plugins" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certs_acme_plugins",
+      "subtitle" : "ACME Plugins",
+      "title" : "Certificate Management"
+   },
+   "sysadmin_network_configuration" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_network_configuration",
+      "title" : "Network Configuration"
+   },
+   "sysadmin_package_repositories" : {
+      "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_package_repositories",
+      "title" : "Package Repositories"
+   },
+   "user-realms-ad" : {
+      "link" : "/pve-docs/chapter-pveum.html#user-realms-ad",
+      "subtitle" : "Microsoft Active Directory (AD)",
+      "title" : "User Management"
+   },
+   "user-realms-ldap" : {
+      "link" : "/pve-docs/chapter-pveum.html#user-realms-ldap",
+      "subtitle" : "LDAP",
+      "title" : "User Management"
+   },
+   "user_mgmt" : {
+      "link" : "/pve-docs/chapter-pveum.html#user_mgmt",
+      "title" : "User Management"
+   },
+   "vzdump_retention" : {
+      "link" : "/pve-docs/chapter-vzdump.html#vzdump_retention",
+      "subtitle" : "Backup Retention",
+      "title" : "Backup and Restore"
+   }
+};
+// Some configuration values are complex strings - so we need parsers/generators for them.
+Ext.define('PVE.Parser', {
+ statics: {
+
+    // this class only contains static functions
+
+    printACME: function(value) {
+	if (Ext.isArray(value.domains)) {
+	    value.domains = value.domains.join(';');
+	}
+	return PVE.Parser.printPropertyString(value);
+    },
+
+    parseACME: function(value) {
+	if (!value) {
+	    return {};
+	}
+
+	let res = {};
+	try {
+	    value.split(',').forEach(property => {
+		let [k, v] = property.split('=', 2);
+		if (Ext.isDefined(v)) {
+		    res[k] = v;
+		} else {
+		    throw `Failed to parse key-value pair: ${property}`;
+		}
+	    });
+	} catch (err) {
+	    console.warn(err);
+	    return undefined;
+	}
+
+	if (res.domains !== undefined) {
+	    res.domains = res.domains.split(/;/);
+	}
+
+	return res;
+    },
+
+    parseBoolean: function(value, default_value) {
+	if (!Ext.isDefined(value)) {
+	    return default_value;
+	}
+	value = value.toLowerCase();
+	return value === '1' ||
+	       value === 'on' ||
+	       value === 'yes' ||
+	       value === 'true';
+    },
+
+    parsePropertyString: function(value, defaultKey) {
+	let res = {};
+
+	if (typeof value !== 'string' || value === '') {
+	    return res;
+	}
+
+	try {
+	    value.split(',').forEach(property => {
+		let [k, v] = property.split('=', 2);
+		if (Ext.isDefined(v)) {
+		    res[k] = v;
+		} else if (Ext.isDefined(defaultKey)) {
+		    if (Ext.isDefined(res[defaultKey])) {
+			throw 'defaultKey may be only defined once in propertyString';
+		    }
+		    res[defaultKey] = k; // k ist the value in this case
+		} else {
+		    throw `Failed to parse key-value pair: ${property}`;
+		}
+	    });
+	} catch (err) {
+	    console.warn(err);
+	    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 (value !== '') {
+		stringparts.push(key + '=' + value);
+	    }
+	});
+
+	stringparts = stringparts.sort();
+	if (gotDefaultKeyVal) {
+	    stringparts.unshift(defaultKeyVal);
+	}
+
+	return stringparts.join(',');
+    },
+
+    parseQemuNetwork: function(key, value) {
+	if (!(key && value)) {
+	    return undefined;
+	}
+
+	let res = {},
+	    errors = false;
+	Ext.Array.each(value.split(','), function(p) {
+	    if (!p || p.match(/^\s*$/)) {
+		return undefined; // continue
+	    }
+
+	    let match_res;
+
+	    if ((match_res = p.match(/^(ne2k_pci|e1000e?|e1000-82540em|e1000-82544gc|e1000-82545em|vmxnet3|rtl8139|pcnet|virtio|ne2k_isa|i82551|i82557b|i82559er)(=([0-9a-f]{2}(:[0-9a-f]{2}){5}))?$/i)) !== null) {
+		res.model = match_res[1].toLowerCase();
+		if (match_res[3]) {
+		    res.macaddr = match_res[3];
+		}
+	    } else if ((match_res = p.match(/^bridge=(\S+)$/)) !== null) {
+		res.bridge = match_res[1];
+	    } else if ((match_res = p.match(/^rate=(\d+(\.\d+)?|\.\d+)$/)) !== null) {
+		res.rate = match_res[1];
+	    } else if ((match_res = p.match(/^tag=(\d+(\.\d+)?)$/)) !== null) {
+		res.tag = match_res[1];
+	    } else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) {
+		res.firewall = match_res[1];
+	    } else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) {
+		res.disconnect = match_res[1];
+	    } else if ((match_res = p.match(/^queues=(\d+)$/)) !== null) {
+		res.queues = match_res[1];
+	    } else if ((match_res = p.match(/^trunks=(\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*)$/)) !== null) {
+		res.trunks = match_res[1];
+	    } else if ((match_res = p.match(/^mtu=(\d+)$/)) !== null) {
+		res.mtu = match_res[1];
+	    } else {
+		errors = true;
+		return false; // break
+	    }
+	    return undefined; // continue
+	});
+
+	if (errors || !res.model) {
+	    return undefined;
+	}
+
+	return res;
+    },
+
+    printQemuNetwork: function(net) {
+	var netstr = net.model;
+	if (net.macaddr) {
+	    netstr += "=" + net.macaddr;
+	}
+	if (net.bridge) {
+	    netstr += ",bridge=" + net.bridge;
+	    if (net.tag) {
+		netstr += ",tag=" + net.tag;
+	    }
+	    if (net.firewall) {
+		netstr += ",firewall=" + net.firewall;
+	    }
+	}
+	if (net.rate) {
+	    netstr += ",rate=" + net.rate;
+	}
+	if (net.queues) {
+	    netstr += ",queues=" + net.queues;
+	}
+	if (net.disconnect) {
+	    netstr += ",link_down=" + net.disconnect;
+	}
+	if (net.trunks) {
+	    netstr += ",trunks=" + net.trunks;
+	}
+	if (net.mtu) {
+	    netstr += ",mtu=" + net.mtu;
+	}
+	return netstr;
+    },
+
+    parseQemuDrive: function(key, value) {
+	if (!(key && value)) {
+	    return undefined;
+	}
+
+	const [, bus, index] = key.match(/^([a-z]+)(\d+)$/);
+	if (!bus) {
+	    return undefined;
+	}
+	let res = {
+	    'interface': bus,
+	    index,
+	};
+
+	var errors = false;
+	Ext.Array.each(value.split(','), function(p) {
+	    if (!p || p.match(/^\s*$/)) {
+		return undefined; // continue
+	    }
+	    let match = p.match(/^([a-z_]+)=(\S+)$/);
+	    if (!match) {
+		if (!p.match(/[=]/)) {
+		    res.file = p;
+		    return undefined; // continue
+		}
+		errors = true;
+		return false; // break
+	    }
+	    let [, k, v] = match;
+	    if (k === 'volume') {
+		k = 'file';
+	    }
+
+	    if (Ext.isDefined(res[k])) {
+		errors = true;
+		return false; // break
+	    }
+
+	    if (k === 'cache' && v === 'off') {
+		v = 'none';
+	    }
+
+	    res[k] = v;
+
+	    return undefined; // continue
+	});
+
+	if (errors || !res.file) {
+	    return undefined;
+	}
+
+	return res;
+    },
+
+    printQemuDrive: function(drive) {
+	var drivestr = drive.file;
+
+	Ext.Object.each(drive, function(key, value) {
+	    if (!Ext.isDefined(value) || key === 'file' ||
+		key === 'index' || key === 'interface') {
+		return; // continue
+	    }
+	    drivestr += ',' + key + '=' + value;
+	});
+
+	return drivestr;
+    },
+
+    parseIPConfig: function(key, value) {
+	if (!(key && value)) {
+	    return undefined; // continue
+	}
+
+	let res = {};
+	try {
+	    value.split(',').forEach(p => {
+		if (!p || p.match(/^\s*$/)) {
+		    return; // continue
+		}
+
+		const match = p.match(/^(ip|gw|ip6|gw6)=(\S+)$/);
+		if (!match) {
+		    throw `could not parse as IP config: ${p}`;
+		}
+		let [, k, v] = match;
+		res[k] = v;
+	    });
+	} catch (err) {
+	    console.warn(err);
+	    return undefined; // continue
+	}
+
+	return res;
+    },
+
+    printIPConfig: function(cfg) {
+	return Object.entries(cfg)
+	    .filter(([k, v]) => v && k.match(/^(ip|gw|ip6|gw6)$/))
+	    .map(([k, v]) => `${k}=${v}`)
+	    .join(',');
+    },
+
+    parseLxcNetwork: function(value) {
+	if (!value) {
+	    return undefined;
+	}
+
+	let data = {};
+	value.split(',').forEach(p => {
+	    if (!p || p.match(/^\s*$/)) {
+		return; // continue
+	    }
+	    let match_res = p.match(/^(bridge|hwaddr|mtu|name|ip|ip6|gw|gw6|tag|rate)=(\S+)$/);
+	    if (match_res) {
+		data[match_res[1]] = match_res[2];
+	    } else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) {
+		data.firewall = PVE.Parser.parseBoolean(match_res[1]);
+	    } else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) {
+		data.link_down = PVE.Parser.parseBoolean(match_res[1]);
+	    } else if (!p.match(/^type=\S+$/)) {
+		console.warn(`could not parse LXC network string ${p}`);
+	    }
+	});
+
+	return data;
+    },
+
+    printLxcNetwork: function(config) {
+	let knownKeys = {
+	    bridge: 1,
+	    firewall: 1,
+	    gw6: 1,
+	    gw: 1,
+	    hwaddr: 1,
+	    ip6: 1,
+	    ip: 1,
+	    mtu: 1,
+	    name: 1,
+	    rate: 1,
+	    tag: 1,
+	    link_down: 1,
+	};
+	return Object.entries(config)
+	    .filter(([k, v]) => v !== undefined && v !== '' && knownKeys[k])
+	    .map(([k, v]) => `${k}=${v}`)
+	    .join(',');
+    },
+
+    parseLxcMountPoint: function(value) {
+	if (!value) {
+	    return undefined;
+	}
+
+	let res = {};
+	let errors = false;
+	Ext.Array.each(value.split(','), function(p) {
+	    if (!p || p.match(/^\s*$/)) {
+		return undefined; // continue
+	    }
+	    let match = p.match(/^([a-z_]+)=(.+)$/);
+	    if (!match) {
+		if (!p.match(/[=]/)) {
+		    res.file = p;
+		    return undefined; // continue
+		}
+		errors = true;
+		return false; // break
+	    }
+	    let [, k, v] = match;
+	    if (k === 'volume') {
+		k = 'file';
+	    }
+
+	    if (Ext.isDefined(res[k])) {
+		errors = true;
+		return false; // break
+	    }
+
+	    res[k] = v;
+
+	    return undefined;
+	});
+
+	if (errors || !res.file) {
+	    return undefined;
+	}
+
+	const match = res.file.match(/^([a-z][a-z0-9\-_.]*[a-z0-9]):/i);
+	if (match) {
+	    res.storage = match[1];
+	    res.type = 'volume';
+	} else if (res.file.match(/^\/dev\//)) {
+	    res.type = 'device';
+	} else {
+	    res.type = 'bind';
+	}
+
+	return res;
+    },
+
+    printLxcMountPoint: function(mp) {
+	let drivestr = mp.file;
+	for (const [key, value] of Object.entries(mp)) {
+	    if (!Ext.isDefined(value) || key === 'file' || key === 'type' || key === 'storage') {
+		continue;
+	    }
+	    drivestr += `,${key}=${value}`;
+	}
+	return drivestr;
+    },
+
+    parseStartup: function(value) {
+	if (value === undefined) {
+	    return undefined;
+	}
+
+	let res = {};
+	try {
+	    value.split(',').forEach(p => {
+		if (!p || p.match(/^\s*$/)) {
+		    return; // continue
+		}
+
+		let match_res;
+		if ((match_res = p.match(/^(order)?=(\d+)$/)) !== null) {
+		    res.order = match_res[2];
+		} else if ((match_res = p.match(/^up=(\d+)$/)) !== null) {
+		    res.up = match_res[1];
+		} else if ((match_res = p.match(/^down=(\d+)$/)) !== null) {
+		    res.down = match_res[1];
+		} else {
+		    throw `could not parse startup config ${p}`;
+		}
+	    });
+	} catch (err) {
+	    console.warn(err);
+	    return undefined;
+	}
+
+	return res;
+    },
+
+    printStartup: function(startup) {
+	let arr = [];
+	if (startup.order !== undefined && startup.order !== '') {
+	    arr.push('order=' + startup.order);
+	}
+	if (startup.up !== undefined && startup.up !== '') {
+	    arr.push('up=' + startup.up);
+	}
+	if (startup.down !== undefined && startup.down !== '') {
+	    arr.push('down=' + startup.down);
+	}
+
+	return arr.join(',');
+    },
+
+    parseQemuSmbios1: function(value) {
+	let res = value.split(',').reduce((acc, currentValue) => {
+	    const [k, v] = currentValue.split(/[=](.+)/);
+	    acc[k] = v;
+	    return acc;
+	}, {});
+
+	if (PVE.Parser.parseBoolean(res.base64, false)) {
+	    for (const [k, v] of Object.entries(res)) {
+		if (k !== 'uuid') {
+		    res[k] = Ext.util.Base64.decode(v);
+		}
+	    }
+	}
+
+	return res;
+    },
+
+    printQemuSmbios1: function(data) {
+	let base64 = false;
+	let datastr = Object.entries(data)
+	    .map(([key, value]) => {
+		if (value === '') {
+		    return undefined;
+		}
+		if (key !== 'uuid') {
+		    base64 = true; // smbios values can be arbitrary, so encode and mark config as such
+		    value = Ext.util.Base64.encode(value);
+		}
+		return `${key}=${value}`;
+	    })
+	    .filter(v => v !== undefined)
+	    .join(',');
+
+	if (base64) {
+	    datastr += ',base64=1';
+	}
+	return datastr;
+    },
+
+    parseTfaConfig: function(value) {
+	let res = {};
+	value.split(',').forEach(p => {
+	    const [k, v] = p.split('=', 2);
+	    res[k] = v;
+	});
+
+	return res;
+    },
+
+    parseTfaType: function(value) {
+	let match;
+	if (!value || !value.length) {
+	    return undefined;
+	} else if (value === 'x!oath') {
+	    return 'totp';
+	} else if ((match = value.match(/^x!(.+)$/)) !== null) {
+	    return match[1];
+	} else {
+	    return 1;
+	}
+    },
+
+    parseQemuCpu: function(value) {
+	if (!value) {
+	    return {};
+	}
+
+	let res = {};
+	let errors = false;
+	Ext.Array.each(value.split(','), function(p) {
+	    if (!p || p.match(/^\s*$/)) {
+		return undefined; // continue
+	    }
+
+	    if (!p.match(/[=]/)) {
+		if (Ext.isDefined(res.cpu)) {
+		    errors = true;
+		    return false; // break
+		}
+		res.cputype = p;
+		return undefined; // continue
+	    }
+
+	    let match = p.match(/^([a-z_]+)=(\S+)$/);
+	    if (!match || Ext.isDefined(res[match[1]])) {
+		errors = true;
+		return false; // break
+	    }
+
+	    let [, k, v] = match;
+	    res[k] = v;
+
+	    return undefined;
+	});
+
+	if (errors || !res.cputype) {
+	    return undefined;
+	}
+
+	return res;
+    },
+
+    printQemuCpu: function(cpu) {
+	let cpustr = cpu.cputype;
+	let optstr = '';
+
+	Ext.Object.each(cpu, function(key, value) {
+	    if (!Ext.isDefined(value) || key === 'cputype') {
+		return; // continue
+	    }
+	    optstr += ',' + key + '=' + value;
+	});
+
+	if (!cpustr) {
+	    if (optstr) {
+		return 'kvm64' + optstr;
+	    } else {
+		return undefined;
+	    }
+	}
+
+	return cpustr + optstr;
+    },
+
+    parseSSHKey: function(key) {
+	//                |--- options can have quotes--|     type    key        comment
+	let keyre = /^(?:((?:[^\s"]|"(?:\\.|[^"\\])*")+)\s+)?(\S+)\s+(\S+)(?:\s+(.*))?$/;
+	let typere = /^(?:(?:sk-)?(?:ssh-(?:dss|rsa|ed25519)|ecdsa-sha2-nistp\d+)(?:@(?:[a-z0-9_-]+\.)+[a-z]{2,})?)$/;
+
+	let m = key.match(keyre);
+	if (!m || m.length < 3 || !m[2]) { // [2] is always either type or key
+	    return null;
+	}
+	if (m[1] && m[1].match(typere)) {
+	    return {
+		type: m[1],
+		key: m[2],
+		comment: m[3],
+	    };
+	}
+	if (m[2].match(typere)) {
+	    return {
+		options: m[1],
+		type: m[2],
+		key: m[3],
+		comment: m[4],
+	    };
+	}
+	return null;
+    },
+
+    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];
+    },
+
+    filterPropertyStringList: function(list, filterFn, defaultKey) {
+	return list.filter((entry) => filterFn(PVE.Parser.parsePropertyString(entry, defaultKey)));
+    },
+},
+});
+/* This state provider keeps part of the state inside the browser history.
+ *
+ * We compress (shorten) url using dictionary based compression, i.e., we use
+ * column separated list instead of url encoded hash:
+ *  #v\d*       version/format
+ *  :=          indicates string values
+ *  :\d+        lookup value in dictionary hash
+ *  #v1:=value1:5:=value2:=value3:...
+*/
+
+Ext.define('PVE.StateProvider', {
+    extend: 'Ext.state.LocalStorageProvider',
+
+    // private
+    setHV: function(name, newvalue, fireEvents) {
+	let me = this;
+
+	let changes = false;
+	let oldtext = Ext.encode(me.UIState[name]);
+	let newtext = Ext.encode(newvalue);
+	if (newtext !== oldtext) {
+	    changes = true;
+	    me.UIState[name] = newvalue;
+	    if (fireEvents) {
+		me.fireEvent("statechange", me, name, { value: newvalue });
+	    }
+	}
+	return changes;
+    },
+
+    // private
+    hslist: [
+	// order is important for notifications
+	// [ name, default ]
+	['view', 'server'],
+	['rid', 'root'],
+	['ltab', 'tasks'],
+	['nodetab', ''],
+	['storagetab', ''],
+	['sdntab', ''],
+	['pooltab', ''],
+	['kvmtab', ''],
+	['lxctab', ''],
+	['dctab', ''],
+    ],
+
+    hprefix: 'v1',
+
+    compDict: {
+        tfa: 54,
+	sdn: 53,
+	cloudinit: 52,
+	replication: 51,
+	system: 50,
+	monitor: 49,
+	'ha-fencing': 48,
+	'ha-groups': 47,
+	'ha-resources': 46,
+	'ceph-log': 45,
+	'ceph-crushmap': 44,
+	'ceph-pools': 43,
+	'ceph-osdtree': 42,
+	'ceph-disklist': 41,
+	'ceph-monlist': 40,
+	'ceph-config': 39,
+	ceph: 38,
+	'firewall-fwlog': 37,
+	'firewall-options': 36,
+	'firewall-ipset': 35,
+	'firewall-aliases': 34,
+	'firewall-sg': 33,
+	firewall: 32,
+	apt: 31,
+	members: 30,
+	snapshot: 29,
+	ha: 28,
+	support: 27,
+	pools: 26,
+	syslog: 25,
+	ubc: 24,
+	initlog: 23,
+	openvz: 22,
+	backup: 21,
+	resources: 20,
+	content: 19,
+	root: 18,
+	domains: 17,
+	roles: 16,
+	groups: 15,
+	users: 14,
+	time: 13,
+	dns: 12,
+	network: 11,
+	services: 10,
+	options: 9,
+	console: 8,
+	hardware: 7,
+	permissions: 6,
+	summary: 5,
+	tasks: 4,
+	clog: 3,
+	storage: 2,
+	folder: 1,
+	server: 0,
+    },
+
+    decodeHToken: function(token) {
+	let me = this;
+
+	let state = {};
+	if (!token) {
+	    me.hslist.forEach(([k, v]) => { state[k] = v; });
+	    return state;
+	}
+
+	let [prefix, ...items] = token.split(':');
+
+	if (prefix !== me.hprefix) {
+	    return me.decodeHToken();
+	}
+
+	Ext.Array.each(me.hslist, function(rec) {
+	    let value = items.shift();
+	    if (value) {
+		if (value[0] === '=') {
+		    value = decodeURIComponent(value.slice(1));
+		}
+		for (const [key, hash] of Object.entries(me.compDict)) {
+		    if (String(value) === String(hash)) {
+			value = key;
+			break;
+		    }
+		}
+	    }
+	    state[rec[0]] = value;
+	});
+
+	return state;
+    },
+
+    encodeHToken: function(state) {
+	let me = this;
+
+	let ctoken = me.hprefix;
+	Ext.Array.each(me.hslist, function(rec) {
+	    let value = state[rec[0]];
+	    if (!Ext.isDefined(value)) {
+		value = rec[1];
+	    }
+	    value = encodeURIComponent(value);
+	    if (!value) {
+		ctoken += ':';
+	    } else if (Ext.isDefined(me.compDict[value])) {
+		ctoken += ":" + me.compDict[value];
+	    } else {
+		ctoken += ":=" + value;
+	    }
+	});
+
+	return ctoken;
+    },
+
+    constructor: function(config) {
+	let me = this;
+
+	me.callParent([config]);
+
+	me.UIState = me.decodeHToken(); // set default
+
+	let history_change_cb = function(token) {
+	    if (!token) {
+		Ext.History.back();
+		return;
+	    }
+
+	    let newstate = me.decodeHToken(token);
+	    Ext.Array.each(me.hslist, function(rec) {
+		if (typeof newstate[rec[0]] === "undefined") {
+		    return;
+		}
+		me.setHV(rec[0], newstate[rec[0]], true);
+	    });
+	};
+
+	let start_token = Ext.History.getToken();
+	if (start_token) {
+	    history_change_cb(start_token);
+	} else {
+	    let htext = me.encodeHToken(me.UIState);
+	    Ext.History.add(htext);
+	}
+
+	Ext.History.on('change', history_change_cb);
+    },
+
+    get: function(name, defaultValue) {
+	let me = this;
+
+	let data;
+	if (typeof me.UIState[name] !== "undefined") {
+	    data = { value: me.UIState[name] };
+	} else {
+	    data = me.callParent(arguments);
+	    if (!data && name === 'GuiCap') {
+		data = {
+		    vms: {},
+		    storage: {},
+		    access: {},
+		    nodes: {},
+		    dc: {},
+		    sdn: {},
+		};
+	    }
+	}
+	return data;
+    },
+
+    clear: function(name) {
+	let me = this;
+
+	if (typeof me.UIState[name] !== "undefined") {
+	    me.UIState[name] = null;
+	}
+	me.callParent(arguments);
+    },
+
+    set: function(name, value, fireevent) {
+        let me = this;
+
+	if (typeof me.UIState[name] !== "undefined") {
+	    var newvalue = value ? value.value : null;
+	    if (me.setHV(name, newvalue, fireevent)) {
+		let htext = me.encodeHToken(me.UIState);
+		Ext.History.add(htext);
+	    }
+	} else {
+	    me.callParent(arguments);
+	}
+    },
+});
+Ext.ns('PVE');
+
+console.log("Starting Proxmox VE Manager");
+
+Ext.Ajax.defaultHeaders = {
+    'Accept': 'application/json',
+};
+
+Ext.define('PVE.Utils', {
+ utilities: {
+
+    // this singleton contains miscellaneous utilities
+
+    toolkit: undefined, // (extjs|touch), set inside Toolkit.js
+
+    bus_match: /^(ide|sata|virtio|scsi)(\d+)$/,
+
+    log_severity_hash: {
+	0: "panic",
+	1: "alert",
+	2: "critical",
+	3: "error",
+	4: "warning",
+	5: "notice",
+	6: "info",
+	7: "debug",
+    },
+
+    support_level_hash: {
+	'c': gettext('Community'),
+	'b': gettext('Basic'),
+	's': gettext('Standard'),
+	'p': gettext('Premium'),
+    },
+
+    noSubKeyHtml: 'You do not have a valid subscription for this server. Please visit '
+      +'<a target="_blank" href="https://www.proxmox.com/en/proxmox-virtual-environment/pricing">'
+      +'www.proxmox.com</a> to get a list of available options.',
+
+    getClusterSubscriptionLevel: async function() {
+	let { result } = await Proxmox.Async.api2({ url: '/cluster/status' });
+	let levelMap = Object.fromEntries(
+	  result.data.filter(v => v.type === 'node').map(v => [v.name, v.level]),
+	);
+	return levelMap;
+    },
+
+    kvm_ostypes: {
+	'Linux': [
+	    { desc: '6.x - 2.6 Kernel', val: 'l26' },
+	    { desc: '2.4 Kernel', val: 'l24' },
+	],
+	'Microsoft Windows': [
+	    { desc: '11/2022/2025', val: 'win11' },
+	    { desc: '10/2016/2019', val: 'win10' },
+	    { desc: '8.x/2012/2012r2', val: 'win8' },
+	    { desc: '7/2008r2', val: 'win7' },
+	    { desc: 'Vista/2008', val: 'w2k8' },
+	    { desc: 'XP/2003', val: 'wxp' },
+	    { desc: '2000', val: 'w2k' },
+	],
+	'Solaris Kernel': [
+	    { desc: '-', val: 'solaris' },
+	],
+	'Other': [
+	    { desc: '-', val: 'other' },
+	],
+    },
+
+    is_windows: function(ostype) {
+	for (let entry of PVE.Utils.kvm_ostypes['Microsoft Windows']) {
+	    if (entry.val === ostype) {
+		return true;
+	    }
+	}
+	return false;
+    },
+
+    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;
+    },
+
+    parse_ceph_version: function(service) {
+	if (service.ceph_version_short) {
+	    return service.ceph_version_short;
+	}
+
+	if (service.ceph_version) {
+	    var match = service.ceph_version.match(/version (\d+(\.\d+)*)/);
+	    if (match) {
+		return match[1];
+	    }
+	}
+
+	return undefined;
+    },
+
+    compare_ceph_versions: function(a, b) {
+	let avers = [];
+	let bvers = [];
+
+	if (a === b) {
+	    return 0;
+	}
+
+	if (Ext.isArray(a)) {
+	    avers = a.slice(); // copy array
+	} else {
+	    avers = a.toString().split('.');
+	}
+
+	if (Ext.isArray(b)) {
+	    bvers = b.slice(); // copy array
+	} else {
+	    bvers = b.toString().split('.');
+	}
+
+	for (;;) {
+	    let av = avers.shift();
+	    let bv = bvers.shift();
+
+	    if (av === undefined && bv === undefined) {
+		return 0;
+	    } else if (av === undefined) {
+		return -1;
+	    } else if (bv === undefined) {
+		return 1;
+	    } else {
+		let diff = parseInt(av, 10) - parseInt(bv, 10);
+		if (diff !== 0) return diff;
+		// else we need to look at the next parts
+	    }
+	}
+    },
+
+    get_ceph_icon_html: function(health, fw) {
+	var state = PVE.Utils.map_ceph_health[health];
+	var cls = PVE.Utils.get_health_icon(state);
+	if (fw) {
+	    cls += ' fa-fw';
+	}
+	return "<i class='fa " + cls + "'></i> ";
+    },
+
+    map_ceph_health: {
+	'HEALTH_OK': 'good',
+	'HEALTH_UPGRADE': 'upgrade',
+	'HEALTH_OLD': 'old',
+	'HEALTH_WARN': 'warning',
+	'HEALTH_ERR': 'critical',
+    },
+
+    render_sdn_pending: function(rec, value, key, index) {
+	if (rec.data.state === undefined || rec.data.state === null) {
+	    return value;
+	}
+
+	if (rec.data.state === 'deleted') {
+	    if (value === undefined) {
+		return ' ';
+	    } else {
+		return '<div style="text-decoration: line-through;">'+ value +'</div>';
+	    }
+	} else if (rec.data.pending[key] !== undefined && rec.data.pending[key] !== null) {
+	    if (rec.data.pending[key] === 'deleted') {
+		return ' ';
+	    } else {
+		return rec.data.pending[key];
+	    }
+	}
+	return value;
+    },
+
+    render_sdn_pending_state: function(rec, value) {
+	if (value === undefined || value === null) {
+	    return ' ';
+	}
+
+	let icon = `<i class="fa fa-fw fa-refresh warning"></i>`;
+
+	if (value === 'deleted') {
+	    return '<span>' + icon + value + '</span>';
+	}
+
+	let tip = gettext('Pending Changes') + ': <br>';
+
+	for (const [key, keyvalue] of Object.entries(rec.data.pending)) {
+	    if ((rec.data[key] !== undefined && rec.data.pending[key] !== rec.data[key]) ||
+		rec.data[key] === undefined
+	    ) {
+		tip += `${key}: ${keyvalue} <br>`;
+	    }
+	}
+	return '<span data-qtip="' + tip + '">'+ icon + value + '</span>';
+    },
+
+    render_ceph_health: function(healthObj) {
+	var state = {
+	    iconCls: PVE.Utils.get_health_icon(),
+	    text: '',
+	};
+
+	if (!healthObj || !healthObj.status) {
+	    return state;
+	}
+
+	var health = PVE.Utils.map_ceph_health[healthObj.status];
+
+	state.iconCls = PVE.Utils.get_health_icon(health, true);
+	state.text = healthObj.status;
+
+	return state;
+    },
+
+    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;
+    },
+
+    render_pbs_fingerprint: fp => fp.substring(0, 23),
+
+    render_backup_encryption: function(v, meta, record) {
+	if (!v) {
+	    return gettext('No');
+	}
+
+	let tip = '';
+	if (v.match(/^[a-fA-F0-9]{2}:/)) { // fingerprint
+	    tip = `Key fingerprint ${PVE.Utils.render_pbs_fingerprint(v)}`;
+	}
+	let icon = `<i class="fa fa-fw fa-lock good"></i>`;
+	return `<span data-qtip="${tip}">${icon} ${gettext('Encrypted')}</span>`;
+    },
+
+    render_backup_verification: function(v, meta, record) {
+	let i = (cls, txt) => `<i class="fa fa-fw fa-${cls}"></i> ${txt}`;
+	if (v === undefined || v === null) {
+	    return i('question-circle-o warning', gettext('None'));
+	}
+	let tip = "";
+	let txt = gettext('Failed');
+	let iconCls = 'times critical';
+	if (v.state === 'ok') {
+	    txt = gettext('OK');
+	    iconCls = 'check good';
+	    let now = Date.now() / 1000;
+	    let task = Proxmox.Utils.parse_task_upid(v.upid);
+	    let verify_time = Proxmox.Utils.render_timestamp(task.starttime);
+	    tip = `Last verify task started on ${verify_time}`;
+	    if (now - v.starttime > 30 * 24 * 60 * 60) {
+		tip = `Last verify task over 30 days ago: ${verify_time}`;
+		iconCls = 'check warning';
+	    }
+	}
+	return `<span data-qtip="${tip}"> ${i(iconCls, txt)} </span>`;
+    },
+
+    render_backup_status: function(value, meta, record) {
+	if (typeof value === 'undefined') {
+	    return "";
+	}
+
+	let iconCls = 'check-circle good';
+	let text = gettext('Yes');
+
+	if (!PVE.Parser.parseBoolean(value.toString())) {
+	    iconCls = 'times-circle critical';
+
+	    text = gettext('No');
+
+	    let reason = record.get('reason');
+	    if (typeof reason !== 'undefined') {
+		if (reason in PVE.Utils.backup_reasons_table) {
+		    reason = PVE.Utils.backup_reasons_table[record.get('reason')];
+		}
+		text = `${text} - ${reason}`;
+	    }
+	}
+
+	return `<i class="fa fa-${iconCls}"></i> ${text}`;
+    },
+
+    render_backup_days_of_week: function(val) {
+	var dows = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
+	var selected = [];
+	var cur = -1;
+	val.split(',').forEach(function(day) {
+	    cur++;
+	    var dow = (dows.indexOf(day)+6)%7;
+	    if (cur === dow) {
+		if (selected.length === 0 || selected[selected.length-1] === 0) {
+		    selected.push(1);
+		} else {
+		    selected[selected.length-1]++;
+		}
+	    } else {
+		while (cur < dow) {
+		    cur++;
+		    selected.push(0);
+		}
+		selected.push(1);
+	    }
+	});
+
+	cur = -1;
+	var days = [];
+	selected.forEach(function(item) {
+	    cur++;
+	    if (item > 2) {
+		days.push(Ext.Date.dayNames[cur+1] + '-' + Ext.Date.dayNames[(cur+item)%7]);
+		cur += item-1;
+	    } else if (item === 2) {
+		days.push(Ext.Date.dayNames[cur+1]);
+		days.push(Ext.Date.dayNames[(cur+2)%7]);
+		cur++;
+	    } else if (item === 1) {
+		days.push(Ext.Date.dayNames[(cur+1)%7]);
+	    }
+	});
+	return days.join(', ');
+    },
+
+    render_backup_selection: function(value, metaData, record) {
+	let allExceptText = gettext('All except {0}');
+	let allText = '-- ' + gettext('All') + ' --';
+	if (record.data.all) {
+	    if (record.data.exclude) {
+		return Ext.String.format(allExceptText, record.data.exclude);
+	    }
+	    return allText;
+	}
+	if (record.data.vmid) {
+	    return record.data.vmid;
+	}
+
+	if (record.data.pool) {
+	    return "Pool '"+ record.data.pool + "'";
+	}
+
+	return "-";
+    },
+
+    backup_reasons_table: {
+	'backup=yes': gettext('Enabled'),
+	'backup=no': gettext('Disabled'),
+	'enabled': gettext('Enabled'),
+	'disabled': gettext('Disabled'),
+	'not a volume': gettext('Not a volume'),
+	'efidisk but no OMVF BIOS': gettext('EFI Disk without OMVF BIOS'),
+    },
+
+    renderNotFound: what => Ext.String.format(gettext("No {0} found"), what),
+
+    get_kvm_osinfo: function(value) {
+	var info = { base: 'Other' }; // default
+	if (value) {
+	    Ext.each(Object.keys(PVE.Utils.kvm_ostypes), function(k) {
+		Ext.each(PVE.Utils.kvm_ostypes[k], function(e) {
+		    if (e.val === value) {
+			info = { desc: e.desc, base: k };
+		    }
+		});
+	    });
+	}
+	return info;
+    },
+
+    render_kvm_ostype: function(value) {
+	var osinfo = PVE.Utils.get_kvm_osinfo(value);
+	if (osinfo.desc && osinfo.desc !== '-') {
+	    return osinfo.base + ' ' + osinfo.desc;
+	} else {
+	    return osinfo.base;
+	}
+    },
+
+    render_hotplug_features: function(value) {
+	var fa = [];
+
+	if (!value || value === '0') {
+	    return gettext('Disabled');
+	}
+
+	if (value === '1') {
+	    value = 'disk,network,usb';
+	}
+
+	Ext.each(value.split(','), function(el) {
+	    if (el === 'disk') {
+		fa.push(gettext('Disk'));
+	    } else if (el === 'network') {
+		fa.push(gettext('Network'));
+	    } else if (el === 'usb') {
+		fa.push('USB');
+	    } else if (el === 'memory') {
+		fa.push(gettext('Memory'));
+	    } else if (el === 'cpu') {
+		fa.push(gettext('CPU'));
+	    } else {
+		fa.push(el);
+	    }
+	});
+
+	return fa.join(', ');
+    },
+
+    render_localtime: function(value) {
+	if (value === '__default__') {
+	    return Proxmox.Utils.defaultText + ' (' + gettext('Enabled for Windows') + ')';
+	}
+	return Proxmox.Utils.format_boolean(value);
+    },
+
+    render_qga_features: function(config) {
+	if (!config) {
+	    return Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')';
+	}
+	let qga = PVE.Parser.parsePropertyString(config, 'enabled');
+	if (!PVE.Parser.parseBoolean(qga.enabled)) {
+	    return Proxmox.Utils.disabledText;
+	}
+	delete qga.enabled;
+
+	let agentstring = Proxmox.Utils.enabledText;
+
+	for (const [key, value] of Object.entries(qga)) {
+	    let displayText = Proxmox.Utils.disabledText;
+	    if (key === 'type') {
+		let map = {
+		    isa: "ISA",
+		    virtio: "VirtIO",
+		};
+		displayText = map[value] || Proxmox.Utils.unknownText;
+	    } else if (key === 'freeze-fs-on-backup' && PVE.Parser.parseBoolean(value)) {
+		continue;
+	    } else if (PVE.Parser.parseBoolean(value)) {
+		displayText = Proxmox.Utils.enabledText;
+	    }
+	    agentstring += `, ${key}: ${displayText}`;
+	}
+
+	return agentstring;
+    },
+
+    render_qemu_machine: function(value) {
+	return value || Proxmox.Utils.defaultText + ' (i440fx)';
+    },
+
+    render_qemu_bios: function(value) {
+	if (!value) {
+	    return Proxmox.Utils.defaultText + ' (SeaBIOS)';
+	} else if (value === 'seabios') {
+	    return "SeaBIOS";
+	} else if (value === 'ovmf') {
+	    return "OVMF (UEFI)";
+	} else {
+	    return value;
+	}
+    },
+
+    render_dc_ha_opts: function(value) {
+	if (!value) {
+	    return Proxmox.Utils.defaultText;
+	} else {
+	    return PVE.Parser.printPropertyString(value);
+	}
+    },
+    render_as_property_string: v => !v ? Proxmox.Utils.defaultText : PVE.Parser.printPropertyString(v),
+
+    render_scsihw: function(value) {
+	if (!value || value === '__default__') {
+	    return Proxmox.Utils.defaultText + ' (LSI 53C895A)';
+	} else if (value === 'lsi') {
+	    return 'LSI 53C895A';
+	} else if (value === 'lsi53c810') {
+	    return 'LSI 53C810';
+	} else if (value === 'megasas') {
+	    return 'MegaRAID SAS 8708EM2';
+	} else if (value === 'virtio-scsi-pci') {
+	    return 'VirtIO SCSI';
+	} else if (value === 'virtio-scsi-single') {
+	    return 'VirtIO SCSI single';
+	} else if (value === 'pvscsi') {
+	    return 'VMware PVSCSI';
+	} else {
+	    return value;
+	}
+    },
+
+    render_spice_enhancements: function(values) {
+	let props = PVE.Parser.parsePropertyString(values);
+	if (Ext.Object.isEmpty(props)) {
+	    return Proxmox.Utils.noneText;
+	}
+
+	let output = [];
+	if (PVE.Parser.parseBoolean(props.foldersharing)) {
+	    output.push('Folder Sharing: ' + gettext('Enabled'));
+	}
+	if (props.videostreaming === 'all' || props.videostreaming === 'filter') {
+	    output.push('Video Streaming: ' + props.videostreaming);
+	}
+	return output.join(', ');
+    },
+
+    // fixme: auto-generate this
+    // for now, please keep in sync with PVE::Tools::kvmkeymaps
+    kvm_keymaps: {
+	'__default__': Proxmox.Utils.defaultText,
+	//ar: 'Arabic',
+	da: 'Danish',
+	de: 'German',
+	'de-ch': 'German (Swiss)',
+	'en-gb': 'English (UK)',
+	'en-us': 'English (USA)',
+	es: 'Spanish',
+	//et: 'Estonia',
+	fi: 'Finnish',
+	//fo: 'Faroe Islands',
+	fr: 'French',
+	'fr-be': 'French (Belgium)',
+	'fr-ca': 'French (Canada)',
+	'fr-ch': 'French (Swiss)',
+	//hr: 'Croatia',
+	hu: 'Hungarian',
+	is: 'Icelandic',
+	it: 'Italian',
+	ja: 'Japanese',
+	lt: 'Lithuanian',
+	//lv: 'Latvian',
+	mk: 'Macedonian',
+	nl: 'Dutch',
+	//'nl-be': 'Dutch (Belgium)',
+	no: 'Norwegian',
+	pl: 'Polish',
+	pt: 'Portuguese',
+	'pt-br': 'Portuguese (Brazil)',
+	//ru: 'Russian',
+	sl: 'Slovenian',
+	sv: 'Swedish',
+	//th: 'Thai',
+	tr: 'Turkish',
+    },
+
+    kvm_vga_drivers: {
+	'__default__': Proxmox.Utils.defaultText,
+	std: gettext('Standard VGA'),
+	vmware: gettext('VMware compatible'),
+	qxl: 'SPICE',
+	qxl2: 'SPICE dual monitor',
+	qxl3: 'SPICE three monitors',
+	qxl4: 'SPICE four monitors',
+	serial0: gettext('Serial terminal') + ' 0',
+	serial1: gettext('Serial terminal') + ' 1',
+	serial2: gettext('Serial terminal') + ' 2',
+	serial3: gettext('Serial terminal') + ' 3',
+	virtio: 'VirtIO-GPU',
+	'virtio-gl': 'VirGL GPU',
+	none: Proxmox.Utils.noneText,
+    },
+
+    render_kvm_language: function(value) {
+	if (!value || value === '__default__') {
+	    return Proxmox.Utils.defaultText;
+	}
+	let text = PVE.Utils.kvm_keymaps[value];
+	return text ? `${text} (${value})` : value;
+    },
+
+    console_map: {
+	'__default__': Proxmox.Utils.defaultText + ' (xterm.js)',
+	'vv': 'SPICE (remote-viewer)',
+	'html5': 'HTML5 (noVNC)',
+	'xtermjs': 'xterm.js',
+    },
+
+    render_console_viewer: function(value) {
+	value = value || '__default__';
+	return PVE.Utils.console_map[value] || value;
+    },
+
+    render_kvm_vga_driver: function(value) {
+	if (!value) {
+	    return Proxmox.Utils.defaultText;
+	}
+	let vga = PVE.Parser.parsePropertyString(value, 'type');
+	let text = PVE.Utils.kvm_vga_drivers[vga.type];
+	if (!vga.type) {
+	    text = Proxmox.Utils.defaultText;
+	}
+	return text ? `${text} (${value})` : value;
+    },
+
+    render_kvm_startup: function(value) {
+	var startup = PVE.Parser.parseStartup(value);
+
+	var res = 'order=';
+	if (startup.order === undefined) {
+	    res += 'any';
+	} else {
+	    res += startup.order;
+	}
+	if (startup.up !== undefined) {
+	    res += ',up=' + startup.up;
+	}
+	if (startup.down !== undefined) {
+	    res += ',down=' + startup.down;
+	}
+
+	return res;
+    },
+
+    extractFormActionError: function(action) {
+	var msg;
+	switch (action.failureType) {
+	case Ext.form.action.Action.CLIENT_INVALID:
+	    msg = gettext('Form fields may not be submitted with invalid values');
+	    break;
+	case Ext.form.action.Action.CONNECT_FAILURE:
+	    msg = gettext('Connection error');
+	    var resp = action.response;
+	    if (resp.status && resp.statusText) {
+		msg += " " + resp.status + ": " + resp.statusText;
+	    }
+	    break;
+	case Ext.form.action.Action.LOAD_FAILURE:
+	case Ext.form.action.Action.SERVER_INVALID:
+	    msg = Proxmox.Utils.extractRequestError(action.result, true);
+	    break;
+	}
+	return msg;
+    },
+
+    contentTypes: {
+	'images': gettext('Disk image'),
+	'backup': gettext('VZDump backup file'),
+	'vztmpl': gettext('Container template'),
+	'iso': gettext('ISO image'),
+	'rootdir': gettext('Container'),
+	'snippets': gettext('Snippets'),
+    },
+
+    volume_is_qemu_backup: function(volid, format) {
+	return format === 'pbs-vm' || volid.match(':backup/vzdump-qemu-');
+    },
+
+    volume_is_lxc_backup: function(volid, format) {
+	return format === 'pbs-ct' || volid.match(':backup/vzdump-(lxc|openvz)-');
+    },
+
+    authSchema: {
+	ad: {
+	    name: gettext('Active Directory Server'),
+	    ipanel: 'pveAuthADPanel',
+	    syncipanel: 'pveAuthLDAPSyncPanel',
+	    add: true,
+	    tfa: true,
+	    pwchange: true,
+	},
+	ldap: {
+	    name: gettext('LDAP Server'),
+	    ipanel: 'pveAuthLDAPPanel',
+	    syncipanel: 'pveAuthLDAPSyncPanel',
+	    add: true,
+	    tfa: true,
+	    pwchange: true,
+	},
+	openid: {
+	    name: gettext('OpenID Connect Server'),
+	    ipanel: 'pveAuthOpenIDPanel',
+	    add: true,
+	    tfa: false,
+	    pwchange: false,
+	    iconCls: 'pmx-itype-icon-openid-logo',
+	},
+	pam: {
+	    name: 'Linux PAM',
+	    ipanel: 'pveAuthBasePanel',
+	    add: false,
+	    tfa: true,
+	    pwchange: true,
+	},
+	pve: {
+	    name: 'Proxmox VE authentication server',
+	    ipanel: 'pveAuthBasePanel',
+	    add: false,
+	    tfa: true,
+	    pwchange: true,
+	},
+    },
+
+    storageSchema: {
+	dir: {
+	    name: Proxmox.Utils.directoryText,
+	    ipanel: 'DirInputPanel',
+	    faIcon: 'folder',
+	    backups: true,
+	},
+	lvm: {
+	    name: 'LVM',
+	    ipanel: 'LVMInputPanel',
+	    faIcon: 'folder',
+	    backups: false,
+	},
+	lvmthin: {
+	    name: 'LVM-Thin',
+	    ipanel: 'LvmThinInputPanel',
+	    faIcon: 'folder',
+	    backups: false,
+	},
+	btrfs: {
+	    name: 'BTRFS',
+	    ipanel: 'BTRFSInputPanel',
+	    faIcon: 'folder',
+	    backups: true,
+	},
+	nfs: {
+	    name: 'NFS',
+	    ipanel: 'NFSInputPanel',
+	    faIcon: 'building',
+	    backups: true,
+	},
+	cifs: {
+	    name: 'SMB/CIFS',
+	    ipanel: 'CIFSInputPanel',
+	    faIcon: 'building',
+	    backups: true,
+	},
+	glusterfs: {
+	    name: 'GlusterFS',
+	    ipanel: 'GlusterFsInputPanel',
+	    faIcon: 'building',
+	    backups: true,
+	},
+	iscsi: {
+	    name: 'iSCSI',
+	    ipanel: 'IScsiInputPanel',
+	    faIcon: 'building',
+	    backups: false,
+	},
+	cephfs: {
+	    name: 'CephFS',
+	    ipanel: 'CephFSInputPanel',
+	    faIcon: 'building',
+	    backups: true,
+	},
+	pvecephfs: {
+	    name: 'CephFS (PVE)',
+	    ipanel: 'CephFSInputPanel',
+	    hideAdd: true,
+	    faIcon: 'building',
+	    backups: true,
+	},
+	rbd: {
+	    name: 'RBD',
+	    ipanel: 'RBDInputPanel',
+	    faIcon: 'building',
+	    backups: false,
+	},
+	pveceph: {
+	    name: 'RBD (PVE)',
+	    ipanel: 'RBDInputPanel',
+	    hideAdd: true,
+	    faIcon: 'building',
+	    backups: false,
+	},
+	zfs: {
+	    name: 'ZFS over iSCSI',
+	    ipanel: 'ZFSInputPanel',
+	    faIcon: 'building',
+	    backups: false,
+	},
+	zfspool: {
+	    name: 'ZFS',
+	    ipanel: 'ZFSPoolInputPanel',
+	    faIcon: 'folder',
+	    backups: false,
+	},
+	pbs: {
+	    name: 'Proxmox Backup Server',
+	    ipanel: 'PBSInputPanel',
+	    faIcon: 'floppy-o',
+	    backups: true,
+	},
+	drbd: {
+	    name: 'DRBD',
+	    hideAdd: true,
+	    backups: false,
+	},
+	esxi: {
+	    name: 'ESXi',
+	    ipanel: 'ESXIInputPanel',
+	    faIcon: 'cloud-download',
+	    backups: false,
+	},
+    },
+
+    sdnvnetSchema: {
+	vnet: {
+	    name: 'vnet',
+	    faIcon: 'folder',
+	},
+    },
+
+    sdnzoneSchema: {
+	zone: {
+	     name: 'zone',
+	     hideAdd: true,
+	},
+	simple: {
+	    name: 'Simple',
+	    ipanel: 'SimpleInputPanel',
+	    faIcon: 'th',
+	},
+	vlan: {
+	    name: 'VLAN',
+	    ipanel: 'VlanInputPanel',
+	    faIcon: 'th',
+	},
+	qinq: {
+	    name: 'QinQ',
+	    ipanel: 'QinQInputPanel',
+	    faIcon: 'th',
+	},
+	vxlan: {
+	    name: 'VXLAN',
+	    ipanel: 'VxlanInputPanel',
+	    faIcon: 'th',
+	},
+	evpn: {
+	    name: 'EVPN',
+	    ipanel: 'EvpnInputPanel',
+	    faIcon: 'th',
+	},
+    },
+
+    sdncontrollerSchema: {
+	controller: {
+	     name: 'controller',
+	     hideAdd: true,
+	},
+	evpn: {
+	    name: 'evpn',
+	    ipanel: 'EvpnInputPanel',
+	    faIcon: 'crosshairs',
+	},
+	bgp: {
+	    name: 'bgp',
+	    ipanel: 'BgpInputPanel',
+	    faIcon: 'crosshairs',
+	},
+	isis: {
+	    name: 'isis',
+	    ipanel: 'IsisInputPanel',
+	    faIcon: 'crosshairs',
+	},
+    },
+
+    sdnipamSchema: {
+	ipam: {
+	     name: 'ipam',
+	     hideAdd: true,
+	},
+	pve: {
+	    name: 'PVE',
+	    ipanel: 'PVEIpamInputPanel',
+	    faIcon: 'th',
+	    hideAdd: true,
+	},
+	netbox: {
+	    name: 'Netbox',
+	    ipanel: 'NetboxInputPanel',
+	    faIcon: 'th',
+	},
+	phpipam: {
+	    name: 'PhpIpam',
+	    ipanel: 'PhpIpamInputPanel',
+	    faIcon: 'th',
+	},
+    },
+
+    sdndnsSchema: {
+	dns: {
+	     name: 'dns',
+	     hideAdd: true,
+	},
+	powerdns: {
+	    name: 'powerdns',
+	    ipanel: 'PowerdnsInputPanel',
+	    faIcon: 'th',
+	},
+    },
+
+    format_sdnvnet_type: function(value, md, record) {
+	var schema = PVE.Utils.sdnvnetSchema[value];
+	if (schema) {
+	    return schema.name;
+	}
+	return Proxmox.Utils.unknownText;
+    },
+
+    format_sdnzone_type: function(value, md, record) {
+	var schema = PVE.Utils.sdnzoneSchema[value];
+	if (schema) {
+	    return schema.name;
+	}
+	return Proxmox.Utils.unknownText;
+    },
+
+    format_sdncontroller_type: function(value, md, record) {
+	var schema = PVE.Utils.sdncontrollerSchema[value];
+	if (schema) {
+	    return schema.name;
+	}
+	return Proxmox.Utils.unknownText;
+    },
+
+    format_sdnipam_type: function(value, md, record) {
+	var schema = PVE.Utils.sdnipamSchema[value];
+	if (schema) {
+	    return schema.name;
+	}
+	return Proxmox.Utils.unknownText;
+    },
+
+    format_sdndns_type: function(value, md, record) {
+	var schema = PVE.Utils.sdndnsSchema[value];
+	if (schema) {
+	    return schema.name;
+	}
+	return Proxmox.Utils.unknownText;
+    },
+
+    format_storage_type: function(value, md, record) {
+	if (value === 'rbd') {
+	    value = !record || record.get('monhost') ? 'rbd' : 'pveceph';
+	} else if (value === 'cephfs') {
+	    value = !record || record.get('monhost') ? 'cephfs' : 'pvecephfs';
+	}
+
+	let schema = PVE.Utils.storageSchema[value];
+	return schema?.name ?? value;
+    },
+
+    format_ha: function(value) {
+	var text = Proxmox.Utils.noneText;
+
+	if (value.managed) {
+	    text = value.state || Proxmox.Utils.noneText;
+
+	    text += ', ' + Proxmox.Utils.groupText + ': ';
+	    text += value.group || Proxmox.Utils.noneText;
+	}
+
+	return text;
+    },
+
+    format_content_types: function(value) {
+	return value.split(',').sort().map(function(ct) {
+	    return PVE.Utils.contentTypes[ct] || ct;
+	}).join(', ');
+    },
+
+    render_storage_content: function(value, metaData, record) {
+	let data = record.data;
+	let result;
+	if (Ext.isNumber(data.channel) &&
+	    Ext.isNumber(data.id) &&
+	    Ext.isNumber(data.lun)) {
+	    result = "CH " +
+		Ext.String.leftPad(data.channel, 2, '0') +
+		" ID " + data.id + " LUN " + data.lun;
+	} else if (data.content === 'import') {
+	    result = data.volid.replace(/^.*?:/, '');
+	} else {
+	    result = data.volid.replace(/^.*?:(.*?\/)?/, '');
+	}
+	return Ext.String.htmlEncode(result);
+    },
+
+    render_serverity: function(value) {
+	return PVE.Utils.log_severity_hash[value] || value;
+    },
+
+    calculate_hostcpu: function(data) {
+	if (!(data.uptime && Ext.isNumeric(data.cpu))) {
+	    return -1;
+	}
+
+	if (data.type !== 'qemu' && data.type !== 'lxc') {
+	    return -1;
+	}
+
+	var index = PVE.data.ResourceStore.findExact('id', 'node/' + data.node);
+	var node = PVE.data.ResourceStore.getAt(index);
+	if (!Ext.isDefined(node) || node === null) {
+	    return -1;
+	}
+	var maxcpu = node.data.maxcpu || 1;
+
+	if (!Ext.isNumeric(maxcpu) && (maxcpu >= 1)) {
+	    return -1;
+	}
+
+	return (data.cpu/maxcpu) * data.maxcpu;
+    },
+
+    render_hostcpu: function(value, metaData, record, rowIndex, colIndex, store) {
+	if (!(record.data.uptime && Ext.isNumeric(record.data.cpu))) {
+	    return '';
+	}
+
+	if (record.data.type !== 'qemu' && record.data.type !== 'lxc') {
+	    return '';
+	}
+
+	var index = PVE.data.ResourceStore.findExact('id', 'node/' + record.data.node);
+	var node = PVE.data.ResourceStore.getAt(index);
+	if (!Ext.isDefined(node) || node === null) {
+	    return '';
+	}
+	var maxcpu = node.data.maxcpu || 1;
+
+	if (!Ext.isNumeric(maxcpu) && (maxcpu >= 1)) {
+	    return '';
+	}
+
+	var per = (record.data.cpu/maxcpu) * record.data.maxcpu * 100;
+
+	return per.toFixed(1) + '% of ' + maxcpu.toString() + (maxcpu > 1 ? 'CPUs' : 'CPU');
+    },
+
+    render_bandwidth: function(value) {
+	if (!Ext.isNumeric(value)) {
+	    return '';
+	}
+
+	return Proxmox.Utils.format_size(value) + '/s';
+    },
+
+    render_timestamp_human_readable: function(value) {
+	return Ext.Date.format(new Date(value * 1000), 'l d F Y H:i:s');
+    },
+
+    // render a timestamp or pending
+    render_next_event: function(value) {
+	if (!value) {
+	    return '-';
+	}
+	let now = new Date(), next = new Date(value * 1000);
+	if (next < now) {
+	    return gettext('pending');
+	}
+	return Proxmox.Utils.render_timestamp(value);
+    },
+
+    calculate_mem_usage: function(data) {
+	if (!Ext.isNumeric(data.mem) ||
+	    data.maxmem === 0 ||
+	    data.uptime < 1) {
+	    return -1;
+	}
+
+	return data.mem / data.maxmem;
+    },
+
+    calculate_hostmem_usage: function(data) {
+	if (data.type !== 'qemu' && data.type !== 'lxc') {
+	    return -1;
+	}
+
+        var index = PVE.data.ResourceStore.findExact('id', 'node/' + data.node);
+	var node = PVE.data.ResourceStore.getAt(index);
+
+        if (!Ext.isDefined(node) || node === null) {
+	    return -1;
+        }
+	var maxmem = node.data.maxmem || 0;
+
+	if (!Ext.isNumeric(data.mem) ||
+	    maxmem === 0 ||
+	    data.uptime < 1) {
+	    return -1;
+	}
+
+	return data.mem / maxmem;
+    },
+
+    render_mem_usage_percent: function(value, metaData, record, rowIndex, colIndex, store) {
+	if (!Ext.isNumeric(value) || value === -1) {
+	    return '';
+	}
+	if (value > 1) {
+	    // we got no percentage but bytes
+	    var mem = value;
+	    var maxmem = record.data.maxmem;
+	    if (!record.data.uptime ||
+		maxmem === 0 ||
+		!Ext.isNumeric(mem)) {
+		return '';
+	    }
+
+	    return (mem*100/maxmem).toFixed(1) + " %";
+	}
+	return (value*100).toFixed(1) + " %";
+    },
+
+    render_hostmem_usage_percent: function(value, metaData, record, rowIndex, colIndex, store) {
+	if (!Ext.isNumeric(record.data.mem) || value === -1) {
+	    return '';
+	}
+
+	if (record.data.type !== 'qemu' && record.data.type !== 'lxc') {
+	    return '';
+	}
+
+	var index = PVE.data.ResourceStore.findExact('id', 'node/' + record.data.node);
+	var node = PVE.data.ResourceStore.getAt(index);
+	var maxmem = node.data.maxmem || 0;
+
+	if (record.data.mem > 1) {
+	    // we got no percentage but bytes
+	    var mem = record.data.mem;
+	    if (!record.data.uptime ||
+		maxmem === 0 ||
+		!Ext.isNumeric(mem)) {
+		return '';
+	    }
+
+	    return ((mem*100)/maxmem).toFixed(1) + " %";
+	}
+	return (value*100).toFixed(1) + " %";
+    },
+
+    render_mem_usage: function(value, metaData, record, rowIndex, colIndex, store) {
+	var mem = value;
+	var maxmem = record.data.maxmem;
+
+	if (!record.data.uptime) {
+	    return '';
+	}
+
+	if (!(Ext.isNumeric(mem) && maxmem)) {
+	    return '';
+	}
+
+	return Proxmox.Utils.render_size(value);
+    },
+
+    calculate_disk_usage: function(data) {
+	if (!Ext.isNumeric(data.disk) ||
+	    ((data.type === 'qemu' || data.type === 'lxc') && data.uptime === 0) ||
+	    data.maxdisk === 0
+	) {
+	    return -1;
+	}
+
+	return data.disk / data.maxdisk;
+    },
+
+    render_disk_usage_percent: function(value, metaData, record, rowIndex, colIndex, store) {
+	if (!Ext.isNumeric(value) || value === -1) {
+	    return '';
+	}
+
+	return (value * 100).toFixed(1) + " %";
+    },
+
+    render_disk_usage: function(value, metaData, record, rowIndex, colIndex, store) {
+	var disk = value;
+	var maxdisk = record.data.maxdisk;
+	var type = record.data.type;
+
+	if (!Ext.isNumeric(disk) ||
+	    maxdisk === 0 ||
+	    ((type === 'qemu' || type === 'lxc') && record.data.uptime === 0)
+	) {
+	    return '';
+	}
+
+	return Proxmox.Utils.render_size(value);
+    },
+
+    get_object_icon_class: function(type, record) {
+	var status = '';
+	var objType = type;
+
+	if (type === 'type') {
+	    // for folder view
+	    objType = record.groupbyid;
+	} else if (record.template) {
+	    // templates
+	    objType = 'template';
+	    status = type;
+	} else if (type === 'storage' && record.content.indexOf('import') !== -1) {
+	    return 'fa fa-cloud-download';
+	} else {
+	    // everything else
+	    status = record.status + ' ha-' + record.hastate;
+	}
+
+	if (record.lock) {
+	    status += ' locked lock-' + record.lock;
+	}
+
+	var defaults = PVE.tree.ResourceTree.typeDefaults[objType];
+	if (defaults && defaults.iconCls) {
+	    var retVal = defaults.iconCls + ' ' + status;
+	    return retVal;
+	}
+
+	return '';
+    },
+
+    render_resource_type: function(value, metaData, record, rowIndex, colIndex, store) {
+	var cls = PVE.Utils.get_object_icon_class(value, record.data);
+
+	var fa = '<i class="fa-fw x-grid-icon-custom ' + cls + '"></i> ';
+	return fa + value;
+    },
+
+    render_support_level: function(value, metaData, record) {
+	return PVE.Utils.support_level_hash[value] || '-';
+    },
+
+    render_upid: function(value, metaData, record) {
+	var type = record.data.type;
+	var id = record.data.id;
+
+	return Proxmox.Utils.format_task_description(type, id);
+    },
+
+    render_optional_url: function(value) {
+	if (value && value.match(/^https?:\/\//)) {
+	    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_full_name: function(firstname, metaData, record) {
+	var first = firstname || '';
+	var last = record.data.lastname || '';
+	return Ext.htmlEncode(first + " " + last);
+    },
+
+    // expecting the following format:
+    // [v2:10.10.10.1:6802/2008,v1:10.10.10.1:6803/2008]
+    render_ceph_osd_addr: function(value) {
+	value = value.trim();
+	if (value.startsWith('[') && value.endsWith(']')) {
+	    value = value.slice(1, -1); // remove []
+	}
+	value = value.replaceAll(',', '\n'); // split IPs in lines
+	let retVal = '';
+	for (const i of value.matchAll(/^(v[0-9]):(.*):([0-9]*)\/([0-9]*)$/gm)) {
+	    retVal += `${i[1]}: ${i[2]}:${i[3]}<br>`;
+	}
+	return retVal.length < 1 ? value : retVal;
+    },
+
+    windowHostname: function() {
+	return window.location.hostname.replace(Proxmox.Utils.IP6_bracket_match,
+            function(m, addr, offset, original) { return addr; });
+    },
+
+    openDefaultConsoleWindow: function(consoles, consoleType, vmid, nodename, vmname, cmd) {
+	var dv = PVE.Utils.defaultViewer(consoles, consoleType);
+	PVE.Utils.openConsoleWindow(dv, consoleType, vmid, nodename, vmname, cmd);
+    },
+
+    openConsoleWindow: function(viewer, consoleType, vmid, nodename, vmname, cmd) {
+	if (vmid === undefined && (consoleType === 'kvm' || consoleType === 'lxc')) {
+	    throw "missing vmid";
+	}
+	if (!nodename) {
+	    throw "no nodename specified";
+	}
+
+	if (viewer === 'html5') {
+	    PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd);
+	} else if (viewer === 'xtermjs') {
+	    Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
+	} else if (viewer === 'vv') {
+	    let url = '/nodes/' + nodename + '/spiceshell';
+	    let params = {
+		proxy: PVE.Utils.windowHostname(),
+	    };
+	    if (consoleType === 'kvm') {
+		url = '/nodes/' + nodename + '/qemu/' + vmid.toString() + '/spiceproxy';
+	    } else if (consoleType === 'lxc') {
+		url = '/nodes/' + nodename + '/lxc/' + vmid.toString() + '/spiceproxy';
+	    } else if (consoleType === 'upgrade') {
+		params.cmd = 'upgrade';
+	    } else if (consoleType === 'cmd') {
+		params.cmd = cmd;
+	    } else if (consoleType !== 'shell') {
+		throw `unknown spice viewer type '${consoleType}'`;
+	    }
+	    PVE.Utils.openSpiceViewer(url, params);
+	} else {
+	    throw `unknown viewer type '${viewer}'`;
+	}
+    },
+
+    defaultViewer: function(consoles, type) {
+	var allowSpice, allowXtermjs;
+
+	if (consoles === true) {
+	    allowSpice = true;
+	    allowXtermjs = true;
+	} else if (typeof consoles === 'object') {
+	    allowSpice = consoles.spice;
+	    allowXtermjs = !!consoles.xtermjs;
+	}
+	let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
+	if (dv === 'vv' && !allowSpice) {
+	    dv = allowXtermjs ? 'xtermjs' : 'html5';
+	} else if (dv === 'xtermjs' && !allowXtermjs) {
+	    dv = allowSpice ? 'vv' : 'html5';
+	}
+
+	return dv;
+    },
+
+    openVNCViewer: function(vmtype, vmid, nodename, vmname, cmd) {
+	let scaling = 'off';
+	if (Proxmox.Utils.toolkit !== 'touch') {
+	    var sp = Ext.state.Manager.getProvider();
+	    scaling = sp.get('novnc-scaling', 'off');
+	}
+	var url = Ext.Object.toQueryString({
+	    console: vmtype, // kvm, lxc, upgrade or shell
+	    novnc: 1,
+	    vmid: vmid,
+	    vmname: vmname,
+	    node: nodename,
+	    resize: scaling,
+	    cmd: cmd,
+	});
+	var nw = window.open("?" + url, '_blank', "innerWidth=745,innerheight=427");
+	if (nw) {
+	    nw.focus();
+	}
+    },
+
+    openSpiceViewer: function(url, params) {
+	var downloadWithName = function(uri, name) {
+	    var link = Ext.DomHelper.append(document.body, {
+		tag: 'a',
+		href: uri,
+		css: 'display:none;visibility:hidden;height:0px;',
+	    });
+
+	    // Note: we need to tell Android, AppleWebKit and Chrome
+	    // the correct file name extension
+	    // but we do not set 'download' tag for other environments, because
+	    // It can have strange side effects (additional user prompt on firefox)
+	    if (navigator.userAgent.match(/Android|AppleWebKit|Chrome/i)) {
+		link.download = name;
+	    }
+
+	    if (link.fireEvent) {
+		link.fireEvent('onclick');
+	    } else {
+		let evt = document.createEvent("MouseEvents");
+		evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
+		link.dispatchEvent(evt);
+	    }
+	};
+
+	Proxmox.Utils.API2Request({
+	    url: url,
+	    params: params,
+	    method: 'POST',
+	    failure: function(response, opts) {
+		Ext.Msg.alert('Error', response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		let cfg = response.result.data;
+		let raw = Object.entries(cfg).reduce((acc, [k, v]) => acc + `${k}=${v}\n`, "[virt-viewer]\n");
+		let spiceDownload = 'data:application/x-virt-viewer;charset=UTF-8,' + encodeURIComponent(raw);
+		downloadWithName(spiceDownload, "pve-spice.vv");
+	    },
+	});
+    },
+
+    openTreeConsole: function(tree, record, item, index, e) {
+	e.stopEvent();
+	let nodename = record.data.node;
+	let vmid = record.data.vmid;
+	let vmname = record.data.name;
+	if (record.data.type === 'qemu' && !record.data.template) {
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${nodename}/qemu/${vmid}/status/current`,
+		failure: response => Ext.Msg.alert('Error', response.htmlStatus),
+		success: function(response, opts) {
+		    let conf = response.result.data;
+		    let consoles = {
+			spice: !!conf.spice,
+			xtermjs: !!conf.serial,
+		    };
+		    PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
+		},
+	    });
+	} else if (record.data.type === 'lxc' && !record.data.template) {
+	    PVE.Utils.openDefaultConsoleWindow(true, 'lxc', vmid, nodename, vmname);
+	}
+    },
+
+    // test automation helper
+    call_menu_handler: function(menu, text) {
+	let item = menu.query('menuitem').find(el => el.text === text);
+	if (item && item.handler) {
+	    item.handler();
+	}
+    },
+
+    createCmdMenu: function(v, record, item, index, event) {
+	event.stopEvent();
+	if (!(v instanceof Ext.tree.View)) {
+	    v.select(record);
+	}
+	let menu;
+	let type = record.data.type;
+
+	if (record.data.template) {
+	    if (type === 'qemu' || type === 'lxc') {
+		menu = Ext.create('PVE.menu.TemplateMenu', {
+		    pveSelNode: record,
+		});
+	    }
+	} else if (type === 'qemu' || type === 'lxc' || type === 'node') {
+	    menu = Ext.create('PVE.' + type + '.CmdMenu', {
+		pveSelNode: record,
+		nodename: record.data.node,
+	    });
+	} else {
+	    return undefined;
+	}
+
+	menu.showAt(event.getXY());
+	return menu;
+    },
+
+    // helper for deleting field which are set to there default values
+    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];
+	}
+    },
+
+    loadSSHKeyFromFile: function(file, callback) {
+	// ssh-keygen produces ~ 740 bytes for a 4096 bit RSA key,  current max is 16 kbit, so assume:
+	// 740 * 8 for max. 32kbit (5920 bytes), round upwards to 8192 bytes, leaves lots of comment space
+	PVE.Utils.loadFile(file, callback, 8192);
+    },
+
+    loadFile: function(file, callback, maxSize) {
+	maxSize = maxSize || 32 * 1024;
+	if (file.size > maxSize) {
+	    Ext.Msg.alert(gettext('Error'), `${gettext("Invalid file size")}: ${file.size} > ${maxSize}`);
+	    return;
+	}
+	let reader = new FileReader();
+	reader.onload = evt => callback(evt.target.result);
+	reader.readAsText(file);
+    },
+
+    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);
+    },
+
+    diskControllerMaxIDs: {
+	ide: 4,
+	sata: 6,
+	scsi: 31,
+	virtio: 16,
+	unused: 256,
+    },
+
+    // types is either undefined (all busses), an array of busses, or a single bus
+    forEachBus: function(types, func) {
+	let busses = Object.keys(PVE.Utils.diskControllerMaxIDs);
+
+	if (Ext.isArray(types)) {
+	    busses = types;
+	} else if (Ext.isDefined(types)) {
+	    busses = [types];
+	}
+
+	// check if we only have valid busses
+	for (let i = 0; i < busses.length; i++) {
+	    if (!PVE.Utils.diskControllerMaxIDs[busses[i]]) {
+		throw "invalid bus: '" + busses[i] + "'";
+	    }
+	}
+
+	for (let i = 0; i < busses.length; i++) {
+	    let count = PVE.Utils.diskControllerMaxIDs[busses[i]];
+	    for (let j = 0; j < count; j++) {
+		let cont = func(busses[i], j);
+		if (!cont && cont !== undefined) {
+		    return;
+		}
+	    }
+	}
+    },
+
+    lxc_mp_counts: {
+	mp: 256,
+	unused: 256,
+    },
+
+    forEachLxcMP: function(func, includeUnused) {
+	for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) {
+	    let cont = func('mp', i, `mp${i}`);
+	    if (!cont && cont !== undefined) {
+		return;
+	    }
+	}
+
+	if (!includeUnused) {
+	    return;
+	}
+
+	for (let i = 0; i < PVE.Utils.lxc_mp_counts.unused; i++) {
+	    let cont = func('unused', i, `unused${i}`);
+	    if (!cont && cont !== undefined) {
+		return;
+	    }
+	}
+    },
+
+    lxc_dev_count: 256,
+
+    forEachLxcDev: function(func) {
+	for (let i = 0; i < PVE.Utils.lxc_dev_count; i++) {
+	    let cont = func(i, `dev${i}`);
+	    if (!cont && cont !== undefined) {
+		return;
+	    }
+	}
+    },
+
+    hardware_counts: {
+	net: 32,
+	usb: 14,
+	usb_old: 5,
+	hostpci: 16,
+	audio: 1,
+	efidisk: 1,
+	serial: 4,
+	rng: 1,
+	tpmstate: 1,
+    },
+
+    // we can have usb6 and up only for specific machine/ostypes
+    get_max_usb_count: function(ostype, machine) {
+	if (!ostype) {
+	    return PVE.Utils.hardware_counts.usb_old;
+	}
+
+	let match = /-(\d+).(\d+)/.exec(machine ?? '');
+	if (!match || PVE.Utils.qemu_min_version([match[1], match[2]], [7, 1])) {
+	    if (ostype === 'l26') {
+		return PVE.Utils.hardware_counts.usb;
+	    }
+	    let os_match = /^win(\d+)$/.exec(ostype);
+	    if (os_match && os_match[1] > 7) {
+		return PVE.Utils.hardware_counts.usb;
+	    }
+	}
+
+	return PVE.Utils.hardware_counts.usb_old;
+    },
+
+    // parameters are expected to be arrays, e.g. [7,1], [4,0,1]
+    // returns true if toCheck is equal or greater than minVersion
+    qemu_min_version: function(toCheck, minVersion) {
+	let i;
+	for (i = 0; i < toCheck.length && i < minVersion.length; i++) {
+	    if (toCheck[i] < minVersion[i]) {
+		return false;
+	    }
+	}
+
+	if (minVersion.length > toCheck.length) {
+	    for (; i < minVersion.length; i++) {
+		if (minVersion[i] !== 0) {
+		    return false;
+		}
+	    }
+	}
+
+	return true;
+    },
+
+    cleanEmptyObjectKeys: function(obj) {
+	for (const propName of Object.keys(obj)) {
+	    if (obj[propName] === null || obj[propName] === undefined) {
+		delete obj[propName];
+	    }
+	}
+    },
+
+    acmedomain_count: 5,
+
+    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;
+    },
+
+    handleStoreErrorOrMask: function(view, store, regex, callback) {
+	view.mon(store, 'load', function(proxy, response, success, operation) {
+	    if (success) {
+		Proxmox.Utils.setErrorMask(view, false);
+		return;
+	    }
+	    let msg;
+	    if (operation.error.statusText) {
+		if (operation.error.statusText.match(regex)) {
+		    callback(view, operation.error);
+		    return;
+		} else {
+		    msg = operation.error.statusText + ' (' + operation.error.status + ')';
+		}
+	    } else {
+		msg = gettext('Connection error');
+	    }
+	    Proxmox.Utils.setErrorMask(view, msg);
+	});
+    },
+
+    showCephInstallOrMask: function(container, msg, nodename, callback) {
+	if (msg.match(/not (installed|initialized)/i)) {
+	    if (Proxmox.UserName === 'root@pam') {
+		container.el.mask();
+		if (!container.down('pveCephInstallWindow')) {
+		    var isInstalled = !!msg.match(/not initialized/i);
+		    var win = Ext.create('PVE.ceph.Install', {
+			nodename: nodename,
+		    });
+		    win.getViewModel().set('isInstalled', isInstalled);
+		    container.add(win);
+		    win.on('close', () => {
+			container.el.unmask();
+		    });
+		    win.show();
+		    callback(win);
+		}
+	    } else {
+		container.mask(Ext.String.format(gettext('{0} not installed.') +
+		    ' ' + gettext('Log in as root to install.'), 'Ceph'), ['pve-static-mask']);
+	    }
+	    return true;
+	} else {
+	    return false;
+	}
+    },
+
+    monitor_ceph_installed: function(view, rstore, nodename, maskOwnerCt) {
+	PVE.Utils.handleStoreErrorOrMask(
+	    view,
+	    rstore,
+	    /not (installed|initialized)/i,
+	    (_, error) => {
+		nodename = nodename || Proxmox.NodeName;
+		let maskTarget = maskOwnerCt ? view.ownerCt : view;
+		rstore.stopUpdate();
+		PVE.Utils.showCephInstallOrMask(maskTarget, error.statusText, nodename, win => {
+		    view.mon(win, 'cephInstallWindowClosed', () => rstore.startUpdate());
+		});
+	    },
+	);
+    },
+
+
+    propertyStringSet: function(target, source, name, value) {
+	if (source) {
+	    if (value === undefined) {
+		target[name] = source;
+	    } else {
+		target[name] = value;
+	    }
+	} else {
+	    delete target[name];
+	}
+    },
+
+    forEachCorosyncLink: function(nodeinfo, cb) {
+	let re = /(?:ring|link)(\d+)_addr/;
+	Ext.iterate(nodeinfo, (prop, val) => {
+	    let match = re.exec(prop);
+	    if (match) {
+		cb(Number(match[1]), val);
+	    }
+	});
+    },
+
+    cpu_vendor_map: {
+	'default': 'QEMU',
+	'AuthenticAMD': 'AMD',
+	'GenuineIntel': 'Intel',
+    },
+
+    cpu_vendor_order: {
+	"AMD": 1,
+	"Intel": 2,
+	"QEMU": 3,
+	"Host": 4,
+	"_default_": 5, // includes custom models
+    },
+
+    verify_ip64_address_list: function(value, with_suffix) {
+	for (let addr of value.split(/[ ,;]+/)) {
+	    if (addr === '') {
+		continue;
+	    }
+
+	    if (with_suffix) {
+		let parts = addr.split('%');
+		addr = parts[0];
+
+		if (parts.length > 2) {
+		    return false;
+		}
+
+		if (parts.length > 1 && !addr.startsWith('fe80:')) {
+		    return false;
+		}
+	    }
+
+	    if (!Proxmox.Utils.IP64_match.test(addr)) {
+		return false;
+	    }
+	}
+
+	return true;
+    },
+
+    sortByPreviousUsage: function(vmconfig, controllerList) {
+	if (!controllerList) {
+	    controllerList = ['ide', 'virtio', 'scsi', 'sata'];
+	}
+	let usedControllers = {};
+	for (const type of Object.keys(PVE.Utils.diskControllerMaxIDs)) {
+	    usedControllers[type] = 0;
+	}
+
+	for (const property of Object.keys(vmconfig)) {
+	    if (property.match(PVE.Utils.bus_match) && !vmconfig[property].match(/media=cdrom/)) {
+		const foundController = property.match(PVE.Utils.bus_match)[1];
+		usedControllers[foundController]++;
+	    }
+	}
+
+	let sortPriority = PVE.qemu.OSDefaults.getDefaults(vmconfig.ostype).busPriority;
+
+	let sortedList = Ext.clone(controllerList);
+	sortedList.sort(function(a, b) {
+	    if (usedControllers[b] === usedControllers[a]) {
+		return sortPriority[b] - sortPriority[a];
+	    }
+	    return usedControllers[b] - usedControllers[a];
+	});
+
+	return sortedList;
+    },
+
+    nextFreeDisk: function(controllers, config) {
+	for (const controller of controllers) {
+	    for (let i = 0; i < PVE.Utils.diskControllerMaxIDs[controller]; i++) {
+		let confid = controller + i.toString();
+		if (!Ext.isDefined(config[confid])) {
+		    return {
+			controller,
+			id: i,
+			confid,
+		    };
+		}
+	    }
+	}
+
+	return undefined;
+    },
+
+    nextFreeLxcMP: function(type, config) {
+	for (let i = 0; i < PVE.Utils.lxc_mp_counts[type]; i++) {
+	    let confid = `${type}${i}`;
+	    if (!Ext.isDefined(config[confid])) {
+		return {
+		    type,
+		    id: i,
+		    confid,
+		};
+	    }
+	}
+
+	return undefined;
+    },
+
+    escapeNotesTemplate: function(value) {
+	let replace = {
+	    '\\': '\\\\',
+	    '\n': '\\n',
+	};
+	return value.replace(/(\\|[\n])/g, match => replace[match]);
+    },
+
+    unEscapeNotesTemplate: function(value) {
+	let replace = {
+	    '\\\\': '\\',
+	    '\\n': '\n',
+	};
+	return value.replace(/(\\\\|\\n)/g, match => replace[match]);
+    },
+
+    notesTemplateVars: ['cluster', 'guestname', 'node', 'vmid'],
+
+    renderTags: function(tagstext, overrides) {
+	let text = '';
+	if (tagstext) {
+	    let tags = (tagstext.split(/[,; ]/) || []).filter(t => !!t);
+	    if (PVE.UIOptions.shouldSortTags()) {
+		tags = tags.sort((a, b) => {
+		    let alc = a.toLowerCase();
+		    let blc = b.toLowerCase();
+		    return alc < blc ? -1 : blc < alc ? 1 : a.localeCompare(b);
+		});
+	    }
+	    text += ' ';
+	    tags.forEach((tag) => {
+		text += Proxmox.Utils.getTagElement(tag, overrides);
+	    });
+	}
+	return text;
+    },
+
+    tagCharRegex: /^[a-z0-9+_.-]+$/i,
+
+    verificationStateOrder: {
+	'failed': 0,
+	'none': 1,
+	'ok': 2,
+	'__default__': 3,
+    },
+
+    isStandaloneNode: function() {
+	return PVE.data.ResourceStore.getNodes().length < 2;
+    },
+
+    // main use case of this helper is the login window
+    getUiLanguage: function() {
+	let languageCookie = Ext.util.Cookies.get('PVELangCookie');
+	if (languageCookie === 'kr') {
+	    // fix-up 'kr' being used for Korean by mistake FIXME: remove with PVE 9
+	    let dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
+	    languageCookie = 'ko';
+	    Ext.util.Cookies.set('PVELangCookie', languageCookie, dt);
+	}
+	return languageCookie || Proxmox.defaultLang || 'en';
+    },
+},
+
+    singleton: true,
+    constructor: function() {
+	var me = this;
+	Ext.apply(me, me.utilities);
+
+	Proxmox.Utils.override_task_descriptions({
+	    acmedeactivate: ['ACME Account', gettext('Deactivate')],
+	    acmenewcert: ['SRV', gettext('Order Certificate')],
+	    acmerefresh: ['ACME Account', gettext('Refresh')],
+	    acmeregister: ['ACME Account', gettext('Register')],
+	    acmerenew: ['SRV', gettext('Renew Certificate')],
+	    acmerevoke: ['SRV', gettext('Revoke Certificate')],
+	    acmeupdate: ['ACME Account', gettext('Update')],
+	    'auth-realm-sync': [gettext('Realm'), gettext('Sync')],
+	    'auth-realm-sync-test': [gettext('Realm'), gettext('Sync Preview')],
+	    cephcreatemds: ['Ceph Metadata Server', gettext('Create')],
+	    cephcreatemgr: ['Ceph Manager', gettext('Create')],
+	    cephcreatemon: ['Ceph Monitor', gettext('Create')],
+	    cephcreateosd: ['Ceph OSD', gettext('Create')],
+	    cephcreatepool: ['Ceph Pool', gettext('Create')],
+	    cephdestroymds: ['Ceph Metadata Server', gettext('Destroy')],
+	    cephdestroymgr: ['Ceph Manager', gettext('Destroy')],
+	    cephdestroymon: ['Ceph Monitor', gettext('Destroy')],
+	    cephdestroyosd: ['Ceph OSD', gettext('Destroy')],
+	    cephdestroypool: ['Ceph Pool', gettext('Destroy')],
+	    cephdestroyfs: ['CephFS', gettext('Destroy')],
+	    cephfscreate: ['CephFS', gettext('Create')],
+	    cephsetpool: ['Ceph Pool', gettext('Edit')],
+	    cephsetflags: ['', gettext('Change global Ceph flags')],
+	    clustercreate: ['', gettext('Create Cluster')],
+	    clusterjoin: ['', gettext('Join Cluster')],
+	    dircreate: [gettext('Directory Storage'), gettext('Create')],
+	    dirremove: [gettext('Directory'), gettext('Remove')],
+	    download: [gettext('File'), gettext('Download')],
+	    hamigrate: ['HA', gettext('Migrate')],
+	    hashutdown: ['HA', gettext('Shutdown')],
+	    hastart: ['HA', gettext('Start')],
+	    hastop: ['HA', gettext('Stop')],
+	    imgcopy: ['', gettext('Copy data')],
+	    imgdel: ['', gettext('Erase data')],
+	    lvmcreate: [gettext('LVM Storage'), gettext('Create')],
+	    lvmremove: ['Volume Group', gettext('Remove')],
+	    lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')],
+	    lvmthinremove: ['Thinpool', gettext('Remove')],
+	    migrateall: ['', gettext('Bulk migrate VMs and Containers')],
+	    'move_volume': ['CT', gettext('Move Volume')],
+	    'pbs-download': ['VM/CT', gettext('File Restore Download')],
+	    pull_file: ['CT', gettext('Pull file')],
+	    push_file: ['CT', gettext('Push file')],
+	    qmclone: ['VM', gettext('Clone')],
+	    qmconfig: ['VM', gettext('Configure')],
+	    qmcreate: ['VM', gettext('Create')],
+	    qmdelsnapshot: ['VM', gettext('Delete Snapshot')],
+	    qmdestroy: ['VM', gettext('Destroy')],
+	    qmigrate: ['VM', gettext('Migrate')],
+	    qmmove: ['VM', gettext('Move disk')],
+	    qmpause: ['VM', gettext('Pause')],
+	    qmreboot: ['VM', gettext('Reboot')],
+	    qmreset: ['VM', gettext('Reset')],
+	    qmrestore: ['VM', gettext('Restore')],
+	    qmresume: ['VM', gettext('Resume')],
+	    qmrollback: ['VM', gettext('Rollback')],
+	    qmshutdown: ['VM', gettext('Shutdown')],
+	    qmsnapshot: ['VM', gettext('Snapshot')],
+	    qmstart: ['VM', gettext('Start')],
+	    qmstop: ['VM', gettext('Stop')],
+	    qmsuspend: ['VM', gettext('Hibernate')],
+	    qmtemplate: ['VM', gettext('Convert to template')],
+	    resize: ['VM/CT', gettext('Resize')],
+	    spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'],
+	    spiceshell: ['', gettext('Shell') + ' (Spice)'],
+	    startall: ['', gettext('Bulk start VMs and Containers')],
+	    stopall: ['', gettext('Bulk shutdown VMs and Containers')],
+	    suspendall: ['', gettext('Suspend all VMs')],
+	    unknownimgdel: ['', gettext('Destroy image from unknown guest')],
+	    wipedisk: ['Device', gettext('Wipe Disk')],
+	    vncproxy: ['VM/CT', gettext('Console')],
+	    vncshell: ['', gettext('Shell')],
+	    vzclone: ['CT', gettext('Clone')],
+	    vzcreate: ['CT', gettext('Create')],
+	    vzdelsnapshot: ['CT', gettext('Delete Snapshot')],
+	    vzdestroy: ['CT', gettext('Destroy')],
+	    vzdump: (type, id) => id ? `VM/CT ${id} - ${gettext('Backup')}` : gettext('Backup Job'),
+	    vzmigrate: ['CT', gettext('Migrate')],
+	    vzmount: ['CT', gettext('Mount')],
+	    vzreboot: ['CT', gettext('Reboot')],
+	    vzrestore: ['CT', gettext('Restore')],
+	    vzresume: ['CT', gettext('Resume')],
+	    vzrollback: ['CT', gettext('Rollback')],
+	    vzshutdown: ['CT', gettext('Shutdown')],
+	    vzsnapshot: ['CT', gettext('Snapshot')],
+	    vzstart: ['CT', gettext('Start')],
+	    vzstop: ['CT', gettext('Stop')],
+	    vzsuspend: ['CT', gettext('Suspend')],
+	    vztemplate: ['CT', gettext('Convert to template')],
+	    vzumount: ['CT', gettext('Unmount')],
+	    zfscreate: [gettext('ZFS Storage'), gettext('Create')],
+	    zfsremove: ['ZFS Pool', gettext('Remove')],
+	});
+    },
+
+});
+Ext.define('PVE.UIOptions', {
+    singleton: true,
+
+    options: {
+	'allowed-tags': [],
+    },
+
+    update: function() {
+	Proxmox.Utils.API2Request({
+	    url: '/cluster/options',
+	    method: 'GET',
+	    success: function(response) {
+		for (const option of ['allowed-tags', 'console', 'tag-style']) {
+		    PVE.UIOptions.options[option] = response?.result?.data?.[option];
+		}
+
+		PVE.UIOptions.updateTagList(PVE.UIOptions.options['allowed-tags']);
+		PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']);
+		PVE.UIOptions.fireUIConfigChanged();
+	    },
+	});
+    },
+
+    tagList: [],
+
+    updateTagList: function(tags) {
+	PVE.UIOptions.tagList = [...new Set([...tags])].sort();
+    },
+
+    parseTagOverrides: function(overrides) {
+	let colors = {};
+	(overrides || "").split(';').forEach(color => {
+	    if (!color) {
+		return;
+	    }
+	    let [tag, color_hex, font_hex] = color.split(':');
+	    let r = parseInt(color_hex.slice(0, 2), 16);
+	    let g = parseInt(color_hex.slice(2, 4), 16);
+	    let b = parseInt(color_hex.slice(4, 6), 16);
+	    colors[tag] = [r, g, b];
+	    if (font_hex) {
+		colors[tag].push(parseInt(font_hex.slice(0, 2), 16));
+		colors[tag].push(parseInt(font_hex.slice(2, 4), 16));
+		colors[tag].push(parseInt(font_hex.slice(4, 6), 16));
+	    }
+	});
+	return colors;
+    },
+
+    tagOverrides: {},
+
+    updateTagOverrides: function(colors) {
+	let sp = Ext.state.Manager.getProvider();
+	let color_state = sp.get('colors', '');
+	let browser_colors = PVE.UIOptions.parseTagOverrides(color_state);
+	PVE.UIOptions.tagOverrides = Ext.apply({}, browser_colors, colors);
+    },
+
+    updateTagSettings: function(style) {
+	let overrides = style?.['color-map'];
+	PVE.UIOptions.updateTagOverrides(PVE.UIOptions.parseTagOverrides(overrides ?? ""));
+
+	let shape = style?.shape ?? 'circle';
+	if (shape === '__default__') {
+	    style = 'circle';
+	}
+
+	Ext.ComponentQuery.query('pveResourceTree')[0].setUserCls(`proxmox-tags-${shape}`);
+    },
+
+    tagTreeStyles: {
+	'__default__': `${Proxmox.Utils.defaultText} (${gettext('Circle')})`,
+	'full': gettext('Full'),
+	'circle': gettext('Circle'),
+	'dense': gettext('Dense'),
+	'none': Proxmox.Utils.NoneText,
+    },
+
+    tagOrderOptions: {
+	'__default__': `${Proxmox.Utils.defaultText} (${gettext('Alphabetical')})`,
+	'config': gettext('Configuration'),
+	'alphabetical': gettext('Alphabetical'),
+    },
+
+    shouldSortTags: function() {
+	return !(PVE.UIOptions.options['tag-style']?.ordering === 'config');
+    },
+
+    getTreeSortingValue: function(key) {
+	let localStorage = Ext.state.Manager.getProvider();
+	let browserValues = localStorage.get('pve-tree-sorting');
+	let defaults = {
+	    'sort-field': 'vmid',
+	    'group-templates': true,
+	    'group-guest-types': true,
+	};
+
+	return browserValues?.[key] ?? defaults[key];
+    },
+
+    fireUIConfigChanged: function() {
+	PVE.data.ResourceStore.refresh();
+	Ext.GlobalEvents.fireEvent('loadedUiOptions');
+    },
+});
+// ExtJS related things
+
+Proxmox.Utils.toolkit = 'extjs';
+
+// custom PVE specific VTypes
+Ext.apply(Ext.form.field.VTypes, {
+
+    QemuStartDate: function(v) {
+	return (/^(now|\d{4}-\d{1,2}-\d{1,2}(T\d{1,2}:\d{1,2}:\d{1,2})?)$/).test(v);
+    },
+    QemuStartDateText: gettext('Format') + ': "now" or "2006-06-17T16:01:21" or "2006-06-17"',
+    IP64AddressList: v => PVE.Utils.verify_ip64_address_list(v, false),
+    IP64AddressWithSuffixList: v => PVE.Utils.verify_ip64_address_list(v, true),
+    IP64AddressListText: gettext('Example') + ': 192.168.1.1,192.168.1.2',
+    IP64AddressListMask: /[A-Fa-f0-9,:.; ]/,
+    PciIdText: gettext('Example') + ': 0x8086',
+    PciId: v => /^0x[0-9a-fA-F]{4}$/.test(v),
+});
+
+Ext.define('PVE.form.field.Display', {
+    override: 'Ext.form.field.Display',
+
+    setSubmitValue: function(value) {
+	// do nothing, this is only to allow generalized  bindings for the:
+	// `me.isCreate ? 'textfield' : 'displayfield'` cases we have.
+    },
+});
+Ext.define('PVE.noVncConsole', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveNoVncConsole',
+
+    nodename: undefined,
+    vmid: undefined,
+    cmd: undefined,
+
+    consoleType: undefined, // lxc, kvm, shell, cmd
+    xtermjs: false,
+
+    layout: 'fit',
+    border: false,
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.consoleType) {
+	    throw "no console type specified";
+	}
+
+	if (!me.vmid && me.consoleType !== 'shell' && me.consoleType !== 'cmd') {
+	    throw "no VM ID specified";
+	}
+
+	// always use same iframe, to avoid running several noVnc clients
+	// at same time (to avoid performance problems)
+	var box = Ext.create('Ext.ux.IFrame', { itemid: "vncconsole" });
+
+	var type = me.xtermjs ? 'xtermjs' : 'novnc';
+	Ext.apply(me, {
+	    items: box,
+	    listeners: {
+		activate: function() {
+		    let sp = Ext.state.Manager.getProvider();
+		    if (Ext.isFunction(me.beforeLoad)) {
+			me.beforeLoad();
+		    }
+		    let queryDict = {
+			console: me.consoleType, // kvm, lxc, upgrade or shell
+			vmid: me.vmid,
+			node: me.nodename,
+			cmd: me.cmd,
+			'cmd-opts': me.cmdOpts,
+			resize: sp.get('novnc-scaling', 'scale'),
+		    };
+		    queryDict[type] = 1;
+		    PVE.Utils.cleanEmptyObjectKeys(queryDict);
+		    var url = '/?' + Ext.Object.toQueryString(queryDict);
+		    box.load(url);
+		},
+	    },
+	});
+
+	me.callParent();
+
+	me.on('afterrender', function() {
+	    me.focus();
+	});
+    },
+
+    reload: function() {
+	// reload IFrame content to forcibly reconnect VNC/xterm.js to VM
+	var box = this.down('[itemid=vncconsole]');
+	box.getWin().location.reload();
+    },
+});
+
+Ext.define('PVE.button.ConsoleButton', {
+    extend: 'Ext.button.Split',
+    alias: 'widget.pveConsoleButton',
+
+    consoleType: 'shell', // one of 'shell', 'kvm', 'lxc', 'upgrade', 'cmd'
+
+    cmd: undefined,
+
+    consoleName: undefined,
+
+    iconCls: 'fa fa-terminal',
+
+    enableSpice: true,
+    enableXtermjs: true,
+
+    nodename: undefined,
+
+    vmid: 0,
+
+    text: gettext('Console'),
+
+    setEnableSpice: function(enable) {
+	var me = this;
+
+	me.enableSpice = enable;
+	me.down('#spicemenu').setDisabled(!enable);
+    },
+
+    setEnableXtermJS: function(enable) {
+	var me = this;
+
+	me.enableXtermjs = enable;
+	me.down('#xtermjs').setDisabled(!enable);
+    },
+
+    handler: function() { // main, general, handler
+	let me = this;
+	PVE.Utils.openDefaultConsoleWindow(
+	    {
+		spice: me.enableSpice,
+		xtermjs: me.enableXtermjs,
+	    },
+	    me.consoleType,
+	    me.vmid,
+	    me.nodename,
+	    me.consoleName,
+	    me.cmd,
+	);
+    },
+
+    openConsole: function(types) { // used by split-menu buttons
+	let me = this;
+	PVE.Utils.openConsoleWindow(
+	    types,
+	    me.consoleType,
+	    me.vmid,
+	    me.nodename,
+	    me.consoleName,
+	    me.cmd,
+	);
+    },
+
+    menu: [
+	{
+	    xtype: 'menuitem',
+	    text: 'noVNC',
+	    iconCls: 'pve-itype-icon-novnc',
+	    type: 'html5',
+	    handler: function(button) {
+		let view = this.up('button');
+		view.openConsole(button.type);
+	    },
+	},
+	{
+	    xterm: 'menuitem',
+	    itemId: 'spicemenu',
+	    text: 'SPICE',
+	    type: 'vv',
+	    iconCls: 'pve-itype-icon-virt-viewer',
+	    handler: function(button) {
+		let view = this.up('button');
+		view.openConsole(button.type);
+	    },
+	},
+	{
+	    text: 'xterm.js',
+	    itemId: 'xtermjs',
+	    iconCls: 'pve-itype-icon-xtermjs',
+	    type: 'xtermjs',
+	    handler: function(button) {
+		let view = this.up('button');
+		view.openConsole(button.type);
+	    },
+	},
+    ],
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.button.PendingRevert', {
+    extend: 'Proxmox.button.Button',
+    alias: 'widget.pvePendingRevertButton',
+
+    text: gettext('Revert'),
+    disabled: true,
+    config: {
+	pendingGrid: null,
+	apiurl: undefined,
+    },
+
+    handler: function() {
+	if (!this.pendingGrid) {
+	    this.pendingGrid = this.up('proxmoxPendingObjectGrid');
+	    if (!this.pendingGrid) throw "revert button requires a pendingGrid";
+	}
+	let view = this.pendingGrid;
+
+	let rec = view.getSelectionModel().getSelection()[0];
+	if (!rec) return;
+
+	let rowdef = view.rows[rec.data.key] || {};
+	let keys = rowdef.multiKey || [rec.data.key];
+
+	Proxmox.Utils.API2Request({
+	    url: this.apiurl || view.editorConfig.url,
+	    waitMsgTarget: view,
+	    selModel: view.getSelectionModel(),
+	    method: 'PUT',
+	    params: {
+		'revert': keys.join(','),
+	    },
+	    callback: () => view.reload(),
+	    failure: (response) => Ext.Msg.alert('Error', response.htmlStatus),
+	});
+    },
+});
+/* Button features:
+ * - observe selection changes to enable/disable the button using enableFn()
+ * - pop up confirmation dialog using confirmMsg()
+ *
+ *   does this for the button and every menu item
+ */
+Ext.define('PVE.button.Split', {
+    extend: 'Ext.button.Split',
+    alias: 'widget.pveSplitButton',
+
+    // the selection model to observe
+    selModel: undefined,
+
+    // if 'false' handler will not be called (button disabled)
+    enableFn: function(record) {
+	// do nothing
+    },
+
+    // function(record) or text
+    confirmMsg: false,
+
+    // take special care in confirm box (select no as default).
+    dangerous: false,
+
+    handlerWrapper: function(button, event) {
+	var me = this;
+	var rec, msg;
+	if (me.selModel) {
+	    rec = me.selModel.getSelection()[0];
+	    if (!rec || me.enableFn(rec) === false) {
+		return;
+	    }
+	}
+
+	if (me.confirmMsg) {
+	    msg = me.confirmMsg;
+	    // confirMsg can be boolean or function
+	    if (Ext.isFunction(me.confirmMsg)) {
+		msg = me.confirmMsg(rec);
+	    }
+	    Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1;
+	    Ext.Msg.show({
+		title: gettext('Confirm'),
+		icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
+		msg: msg,
+		buttons: Ext.Msg.YESNO,
+		callback: function(btn) {
+		    if (btn !== 'yes') {
+			return;
+		    }
+		    me.realHandler(button, event, rec);
+		},
+	    });
+	} else {
+	    me.realHandler(button, event, rec);
+	}
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	if (me.handler) {
+	    me.realHandler = me.handler;
+	    me.handler = me.handlerWrapper;
+	}
+
+	if (me.menu && me.menu.items) {
+	    me.menu.items.forEach(function(item) {
+		if (item.handler) {
+		    item.realHandler = item.handler;
+		    item.handler = me.handlerWrapper;
+		}
+
+		if (item.selModel) {
+		    me.mon(item.selModel, "selectionchange", function() {
+			var rec = item.selModel.getSelection()[0];
+			if (!rec || item.enableFn(rec) === false) {
+			    item.setDisabled(true);
+			} else {
+			    item.setDisabled(false);
+			}
+		    });
+		}
+	    });
+	}
+
+	me.callParent();
+
+	if (me.selModel) {
+	    me.mon(me.selModel, "selectionchange", function() {
+		var rec = me.selModel.getSelection()[0];
+		if (!rec || me.enableFn(rec) === false) {
+		    me.setDisabled(true);
+		} else {
+		    me.setDisabled(false);
+		}
+	    });
+	}
+    },
+});
+Ext.define('PVE.controller.StorageEdit', {
+    extend: 'Ext.app.ViewController',
+    alias: 'controller.storageEdit',
+    control: {
+	'field[name=content]': {
+	    change: function(field, value) {
+		const hasImages = Ext.Array.contains(value, 'images');
+		const prealloc = field.up('form').getForm().findField('preallocation');
+		if (prealloc) {
+		    prealloc.setDisabled(!hasImages);
+		}
+
+		var hasBackups = Ext.Array.contains(value, 'backup');
+		var maxfiles = this.lookupReference('maxfiles');
+		if (!maxfiles) {
+		    return;
+		}
+
+		if (!hasBackups) {
+		// clear values which will never be submitted
+		    maxfiles.reset();
+		}
+		maxfiles.setDisabled(!hasBackups);
+	    },
+	},
+    },
+});
+Ext.define('PVE.data.PermPathStore', {
+    extend: 'Ext.data.Store',
+    alias: 'store.pvePermPath',
+    fields: ['value'],
+    autoLoad: false,
+    data: [
+	{ 'value': '/' },
+	{ 'value': '/access' },
+	{ 'value': '/access/groups' },
+	{ 'value': '/access/realm' },
+	{ 'value': '/mapping' },
+	{ 'value': '/mapping/notifications' },
+	{ 'value': '/mapping/pci' },
+	{ 'value': '/mapping/usb' },
+	{ 'value': '/nodes' },
+	{ 'value': '/pool' },
+	{ 'value': '/sdn/zones' },
+	{ 'value': '/storage' },
+	{ 'value': '/vms' },
+    ],
+
+    constructor: function(config) {
+	var me = this;
+
+	config = config || {};
+
+	me.callParent([config]);
+
+	let donePaths = {};
+	me.suspendEvents();
+	PVE.data.ResourceStore.each(function(record) {
+	    let path;
+	    switch (record.get('type')) {
+		case 'node': path = '/nodes/' + record.get('text');
+		    break;
+		case 'qemu': path = '/vms/' + record.get('vmid');
+		    break;
+		case 'lxc': path = '/vms/' + record.get('vmid');
+		    break;
+		case 'sdn': path = '/sdn/zones/' + record.get('sdn');
+		    break;
+		case 'storage': path = '/storage/' + record.get('storage');
+		    break;
+		case 'pool': path = '/pool/' + record.get('pool');
+		    break;
+	    }
+	    if (path !== undefined && !donePaths[path]) {
+		me.add({ value: path });
+		donePaths[path] = 1;
+	    }
+	});
+	me.resumeEvents();
+
+	me.fireEvent('refresh', me);
+	me.fireEvent('datachanged', me);
+
+	me.sort({
+	    property: 'value',
+	    direction: 'ASC',
+	});
+    },
+});
+Ext.define('PVE.data.ResourceStore', {
+    extend: 'Proxmox.data.UpdateStore',
+    singleton: true,
+
+    findVMID: function(vmid) {
+	let me = this;
+	return me.findExact('vmid', parseInt(vmid, 10)) >= 0;
+    },
+
+    // returns the cached data from all nodes
+    getNodes: function() {
+	let me = this;
+
+	let nodes = [];
+	me.each(function(record) {
+	    if (record.get('type') === "node") {
+		nodes.push(record.getData());
+	    }
+	});
+
+	return nodes;
+    },
+
+    storageIsShared: function(storage_path) {
+	let me = this;
+
+	let index = me.findExact('id', storage_path);
+	if (index >= 0) {
+	    return me.getAt(index).data.shared;
+	} else {
+	    return undefined;
+	}
+    },
+
+    guestNode: function(vmid) {
+	let me = this;
+
+	let index = me.findExact('vmid', parseInt(vmid, 10));
+
+	return me.getAt(index).data.node;
+    },
+
+    guestName: function(vmid) {
+	let me = this;
+	let index = me.findExact('vmid', parseInt(vmid, 10));
+	if (index < 0) {
+	    return '-';
+	}
+	let rec = me.getAt(index).data;
+	if ('name' in rec) {
+	    return rec.name;
+	}
+	return '';
+    },
+
+    refresh: function() {
+	let me = this;
+	// can only refresh if we're loaded at least once and are not currently loading
+	if (!me.isLoading() && me.isLoaded()) {
+	    let records = (me.getData().getSource() || me.getData()).getRange();
+	    me.fireEvent('load', me, records);
+	}
+    },
+
+    constructor: function(config) {
+	let me = this;
+
+	config = config || {};
+
+	let field_defaults = {
+	    type: {
+		header: gettext('Type'),
+		type: 'string',
+		renderer: PVE.Utils.render_resource_type,
+		sortable: true,
+		hideable: false,
+		width: 100,
+	    },
+	    id: {
+		header: 'ID',
+		type: 'string',
+		hidden: true,
+		sortable: true,
+		width: 80,
+	    },
+	    running: {
+		header: gettext('Online'),
+		type: 'boolean',
+		renderer: Proxmox.Utils.format_boolean,
+		hidden: true,
+		convert: function(value, record) {
+		    var info = record.data;
+		    return Ext.isNumeric(info.uptime) && info.uptime > 0;
+		},
+	    },
+	    text: {
+		header: gettext('Description'),
+		type: 'string',
+		sortable: true,
+		width: 200,
+		convert: function(value, record) {
+		    if (value) {
+			return value;
+		    }
+
+		    let info = record.data, text;
+		    if (Ext.isNumeric(info.vmid) && info.vmid > 0) {
+			text = String(info.vmid);
+			if (info.name) {
+			    text += " (" + info.name + ')';
+			}
+		    } else { // node, pool, storage
+			text = info[info.type] || info.id;
+			if (info.node && info.type !== 'node') {
+			    text += " (" + info.node + ")";
+			}
+		    }
+
+		    return text;
+		},
+	    },
+	    vmid: {
+		header: 'VMID',
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		width: 80,
+	    },
+	    name: {
+		header: gettext('Name'),
+		hidden: true,
+		sortable: true,
+		type: 'string',
+	    },
+	    disk: {
+		header: gettext('Disk usage'),
+		type: 'integer',
+		renderer: PVE.Utils.render_disk_usage,
+		sortable: true,
+		width: 100,
+		hidden: true,
+	    },
+	    diskuse: {
+		header: gettext('Disk usage') + " %",
+		type: 'number',
+		sortable: true,
+		renderer: PVE.Utils.render_disk_usage_percent,
+		width: 100,
+		calculate: PVE.Utils.calculate_disk_usage,
+		sortType: 'asFloat',
+	    },
+	    maxdisk: {
+		header: gettext('Disk size'),
+		type: 'integer',
+		renderer: Proxmox.Utils.render_size,
+		sortable: true,
+		hidden: true,
+		width: 100,
+	    },
+	    mem: {
+		header: gettext('Memory usage'),
+		type: 'integer',
+		renderer: PVE.Utils.render_mem_usage,
+		sortable: true,
+		hidden: true,
+		width: 100,
+	    },
+	    memuse: {
+		header: gettext('Memory usage') + " %",
+		type: 'number',
+		renderer: PVE.Utils.render_mem_usage_percent,
+		calculate: PVE.Utils.calculate_mem_usage,
+		sortType: 'asFloat',
+		sortable: true,
+		width: 100,
+	    },
+	    maxmem: {
+		header: gettext('Memory size'),
+		type: 'integer',
+		renderer: Proxmox.Utils.render_size,
+		hidden: true,
+		sortable: true,
+		width: 100,
+	    },
+	    cpu: {
+		header: gettext('CPU usage'),
+		type: 'float',
+		renderer: Proxmox.Utils.render_cpu,
+		sortable: true,
+		width: 100,
+	    },
+	    maxcpu: {
+		header: gettext('maxcpu'),
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		width: 60,
+	    },
+	    diskread: {
+		header: gettext('Total Disk Read'),
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		renderer: Proxmox.Utils.format_size,
+		width: 100,
+	    },
+	    diskwrite: {
+		header: gettext('Total Disk Write'),
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		renderer: Proxmox.Utils.format_size,
+		width: 100,
+	    },
+	    netin: {
+		header: gettext('Total NetIn'),
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		renderer: Proxmox.Utils.format_size,
+		width: 100,
+	    },
+	    netout: {
+		header: gettext('Total NetOut'),
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		renderer: Proxmox.Utils.format_size,
+		width: 100,
+	    },
+	    template: {
+		header: gettext('Template'),
+		type: 'integer',
+		hidden: true,
+		sortable: true,
+		width: 60,
+	    },
+	    uptime: {
+		header: gettext('Uptime'),
+		type: 'integer',
+		renderer: Proxmox.Utils.render_uptime,
+		sortable: true,
+		width: 110,
+	    },
+	    node: {
+		header: gettext('Node'),
+		type: 'string',
+		hidden: true,
+		sortable: true,
+		width: 110,
+	    },
+	    storage: {
+		header: gettext('Storage'),
+		type: 'string',
+		hidden: true,
+		sortable: true,
+		width: 110,
+	    },
+	    pool: {
+		header: gettext('Pool'),
+		type: 'string',
+		hidden: true,
+		sortable: true,
+		width: 110,
+	    },
+	    hastate: {
+		header: gettext('HA State'),
+		type: 'string',
+		defaultValue: 'unmanaged',
+		hidden: true,
+		sortable: true,
+	    },
+	    status: {
+		header: gettext('Status'),
+		type: 'string',
+		hidden: true,
+		sortable: true,
+		width: 110,
+	    },
+	    lock: {
+		header: gettext('Lock'),
+		type: 'string',
+		hidden: true,
+		sortable: true,
+		width: 110,
+	    },
+	    hostcpu: {
+		header: gettext('Host CPU usage'),
+		type: 'float',
+		renderer: PVE.Utils.render_hostcpu,
+		calculate: PVE.Utils.calculate_hostcpu,
+		sortType: 'asFloat',
+		sortable: true,
+		width: 100,
+	    },
+	    hostmemuse: {
+		header: gettext('Host Memory usage') + " %",
+		type: 'number',
+		renderer: PVE.Utils.render_hostmem_usage_percent,
+		calculate: PVE.Utils.calculate_hostmem_usage,
+		sortType: 'asFloat',
+		sortable: true,
+		width: 100,
+	    },
+	    tags: {
+		header: gettext('Tags'),
+		renderer: (value) => PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
+		type: 'string',
+		sortable: true,
+		flex: 1,
+	    },
+	    // note: flex only last column to keep info closer together
+	};
+
+	let fields = [];
+	let fieldNames = [];
+	Ext.Object.each(field_defaults, function(key, value) {
+	    var field = { name: key, type: value.type };
+	    if (Ext.isDefined(value.convert)) {
+		field.convert = value.convert;
+	    }
+
+	    if (Ext.isDefined(value.calculate)) {
+		field.calculate = value.calculate;
+	    }
+
+	    if (Ext.isDefined(value.defaultValue)) {
+		field.defaultValue = value.defaultValue;
+	    }
+
+	    fields.push(field);
+	    fieldNames.push(key);
+	});
+
+	Ext.define('PVEResources', {
+	    extend: "Ext.data.Model",
+	    fields: fields,
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/cluster/resources',
+	    },
+	});
+
+	Ext.define('PVETree', {
+	    extend: "Ext.data.Model",
+	    fields: fields,
+	    proxy: { type: 'memory' },
+	});
+
+	Ext.apply(config, {
+	    storeid: 'PVEResources',
+	    model: 'PVEResources',
+	    defaultColumns: function() {
+		let res = [];
+		Ext.Object.each(field_defaults, function(field, info) {
+		    let fieldInfo = Ext.apply({ dataIndex: field }, info);
+		    res.push(fieldInfo);
+		});
+		return res;
+	    },
+	    fieldNames: fieldNames,
+	});
+
+	me.callParent([config]);
+    },
+});
+Ext.define('pve-rrd-node', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{
+	    name: 'cpu',
+	    // percentage
+	    convert: function(value) {
+		return value*100;
+	    },
+	},
+	{
+	    name: 'iowait',
+	    // percentage
+	    convert: function(value) {
+		return value*100;
+	    },
+	},
+	'loadavg',
+	'maxcpu',
+	'memtotal',
+	'memused',
+	'netin',
+	'netout',
+	'roottotal',
+	'rootused',
+	'swaptotal',
+	'swapused',
+	{ type: 'date', dateFormat: 'timestamp', name: 'time' },
+    ],
+});
+
+Ext.define('pve-rrd-guest', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{
+	    name: 'cpu',
+	    // percentage
+	    convert: function(value) {
+		return value*100;
+	    },
+	},
+	'maxcpu',
+	'netin',
+	'netout',
+	'mem',
+	'maxmem',
+	'disk',
+	'maxdisk',
+	'diskread',
+	'diskwrite',
+	{ type: 'date', dateFormat: 'timestamp', name: 'time' },
+    ],
+});
+
+Ext.define('pve-rrd-storage', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'used',
+	'total',
+	{ type: 'date', dateFormat: 'timestamp', name: 'time' },
+    ],
+});
+// This is a container intended to show a field on the first column and one on the second column.
+// One can set a ratio for the field sizes.
+//
+// Works around a limitation of our input panel column1/2 handling that entries are not vertically
+// aligned when one of them has wrapping text (like it happens sometimes with such longer
+// descriptions)
+Ext.define('PVE.container.TwoColumnContainer', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pveTwoColumnContainer',
+
+    layout: {
+	type: 'hbox',
+	align: 'stretch',
+    },
+
+    // The default ratio of the start widget. It an be an integer or a floating point number
+    startFlex: 1,
+
+    // The default ratio of the end widget. It an be an integer or a floating point number
+    endFlex: 1,
+
+    // the padding between the two columns
+    columnPadding: 20,
+
+    // the config of the first widget
+    startColumn: undefined,
+
+    // the config of the second widget
+    endColumn: undefined,
+
+    // same as fields in a panel
+    padding: '0 0 5 0',
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.startColumn) {
+	    throw "no start widget configured";
+	}
+	if (!me.endColumn) {
+	    throw "no end widget configured";
+	}
+
+	Ext.apply(me, {
+	    items: [
+		Ext.applyIf({ flex: me.startFlex }, me.startColumn),
+		{
+		    xtype: 'box',
+		    width: me.columnPadding,
+		},
+		Ext.applyIf({ flex: me.endFlex }, me.endColumn),
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('pve-acme-challenges', {
+    extend: 'Ext.data.Model',
+    fields: ['id', 'type', 'schema'],
+    proxy: {
+	type: 'proxmox',
+	    url: "/api2/json/cluster/acme/challenge-schema",
+    },
+    idProperty: 'id',
+});
+
+Ext.define('PVE.form.ACMEApiSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveACMEApiSelector',
+
+    fieldLabel: gettext('DNS API'),
+    displayField: 'name',
+    valueField: 'id',
+
+    store: {
+	model: 'pve-acme-challenges',
+	autoLoad: true,
+    },
+
+    triggerAction: 'all',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: true,
+    forceSelection: true,
+    anyMatch: true,
+    selectOnFocus: true,
+
+    getSchema: function() {
+	let me = this;
+	let val = me.getValue();
+	if (val) {
+	    let record = me.getStore().findRecord('id', val, 0, false, true, true);
+	    if (record) {
+		return record.data.schema;
+	    }
+	}
+	return {};
+    },
+});
+Ext.define('PVE.form.ACMEAccountSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveACMEAccountSelector',
+
+    displayField: 'name',
+    valueField: 'name',
+
+    store: {
+	model: 'pve-acme-accounts',
+	autoLoad: true,
+    },
+
+    triggerAction: 'all',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: false,
+    forceSelection: true,
+
+    isEmpty: function() {
+	return this.getStore().getData().length === 0;
+    },
+});
+Ext.define('PVE.form.ACMEPluginSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveACMEPluginSelector',
+
+    fieldLabel: gettext('Plugin'),
+    displayField: 'plugin',
+    valueField: 'plugin',
+
+    store: {
+	model: 'pve-acme-plugins',
+	autoLoad: true,
+	filters: item => item.data.type === 'dns',
+    },
+
+    triggerAction: 'all',
+    queryMode: 'local',
+    allowBlank: false,
+    editable: false,
+});
+Ext.define('PVE.form.AgentFeatureSelector', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: ['widget.pveAgentFeatureSelector'],
+
+    viewModel: {},
+
+    items: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    boxLabel: Ext.String.format(gettext('Use {0}'), 'QEMU Guest Agent'),
+	    name: 'enabled',
+	    reference: 'enabled',
+	    uncheckedValue: 0,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    boxLabel: gettext('Run guest-trim after a disk move or VM migration'),
+	    name: 'fstrim_cloned_disks',
+	    bind: {
+		disabled: '{!enabled.checked}',
+	    },
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    boxLabel: gettext('Freeze/thaw guest filesystems on backup for consistency'),
+	    name: 'freeze-fs-on-backup',
+	    reference: 'freeze_fs_on_backup',
+	    bind: {
+		disabled: '{!enabled.checked}',
+	    },
+	    disabled: true,
+	    uncheckedValue: '0',
+	    defaultValue: '1',
+	},
+	{
+	    xtype: 'displayfield',
+	    userCls: 'pmx-hint',
+	    value: gettext('Freeze/thaw for guest filesystems disabled. This can lead to inconsistent disk backups.'),
+	    bind: {
+		hidden: '{freeze_fs_on_backup.checked}',
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    userCls: 'pmx-hint',
+	    value: gettext('Make sure the QEMU Guest Agent is installed in the VM'),
+	    bind: {
+		hidden: '{!enabled.checked}',
+	    },
+	},
+    ],
+
+    advancedItems: [
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'type',
+	    value: '__default__',
+	    deleteEmpty: false,
+	    fieldLabel: 'Type',
+	    comboItems: [
+		['__default__', Proxmox.Utils.defaultText + " (VirtIO)"],
+		['virtio', 'VirtIO'],
+		['isa', 'ISA'],
+	    ],
+	},
+    ],
+
+    onGetValues: function(values) {
+	if (PVE.Parser.parseBoolean(values['freeze-fs-on-backup'])) {
+	    delete values['freeze-fs-on-backup'];
+	}
+
+	const agentstr = PVE.Parser.printPropertyString(values, 'enabled');
+	return { agent: agentstr };
+    },
+
+    setValues: function(values) {
+	let res = PVE.Parser.parsePropertyString(values.agent, 'enabled');
+	if (!Ext.isDefined(res['freeze-fs-on-backup'])) {
+	    res['freeze-fs-on-backup'] = 1;
+	}
+
+	this.callParent([res]);
+    },
+});
+Ext.define('PVE.form.BackupCompressionSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveBackupCompressionSelector'],
+    comboItems: [
+                ['0', Proxmox.Utils.noneText],
+                ['lzo', 'LZO (' + gettext('fast') + ')'],
+                ['gzip', 'GZIP (' + gettext('good') + ')'],
+                ['zstd', 'ZSTD (' + gettext('fast and good') + ')'],
+    ],
+});
+Ext.define('PVE.form.BackupModeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveBackupModeSelector'],
+    comboItems: [
+                ['snapshot', gettext('Snapshot')],
+                ['suspend', gettext('Suspend')],
+                ['stop', gettext('Stop')],
+    ],
+});
+Ext.define('PVE.form.SizeField', {
+    extend: 'Ext.form.FieldContainer',
+    alias: 'widget.pveSizeField',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    unit: 'MiB',
+	    unitPostfix: '',
+	},
+	formulas: {
+	    unitlabel: (get) => get('unit') + get('unitPostfix'),
+	},
+    },
+
+    emptyText: '',
+
+    layout: 'hbox',
+    defaults: {
+	hideLabel: true,
+    },
+
+    units: {
+	'B': 1,
+	'KiB': 1024,
+	'MiB': 1024*1024,
+	'GiB': 1024*1024*1024,
+	'TiB': 1024*1024*1024*1024,
+	'KB': 1000,
+	'MB': 1000*1000,
+	'GB': 1000*1000*1000,
+	'TB': 1000*1000*1000*1000,
+    },
+
+    // display unit (TODO: make (optionally) selectable)
+    unit: 'MiB',
+    unitPostfix: '',
+
+    // use this if the backend saves values in another unit tha bytes, e.g.,
+    // for KiB set it to 'KiB'
+    backendUnit: undefined,
+
+    // allow setting 0 and using it as a submit value
+    allowZero: false,
+
+    emptyValue: null,
+
+    items: [
+	{
+	    xtype: 'numberfield',
+	    cbind: {
+		name: '{name}',
+		emptyText: '{emptyText}',
+		allowZero: '{allowZero}',
+		emptyValue: '{emptyValue}',
+	    },
+	    minValue: 0,
+	    step: 1,
+	    submitLocaleSeparator: false,
+	    fieldStyle: 'text-align: right',
+	    flex: 1,
+	    enableKeyEvents: true,
+	    setValue: function(v) {
+		if (!this._transformed && v !== null) {
+		    let fieldContainer = this.up('fieldcontainer');
+		    let vm = fieldContainer.getViewModel();
+		    let unit = vm.get('unit');
+
+		    v /= fieldContainer.units[unit];
+		    v *= fieldContainer.backendFactor;
+
+		    this._transformed = true;
+		}
+
+		if (Number(v) === 0 && !this.allowZero) {
+		    v = undefined;
+		}
+
+		return Ext.form.field.Text.prototype.setValue.call(this, v);
+	    },
+	    getSubmitValue: function() {
+		let v = this.processRawValue(this.getRawValue());
+		v = v.replace(this.decimalSeparator, '.');
+
+		if (v === undefined || v === '') {
+		    return this.emptyValue;
+		}
+
+		if (Number(v) === 0) {
+		    return this.allowZero ? 0 : null;
+		}
+
+		let fieldContainer = this.up('fieldcontainer');
+		let vm = fieldContainer.getViewModel();
+		let unit = vm.get('unit');
+
+		v = parseFloat(v) * fieldContainer.units[unit];
+		v /= fieldContainer.backendFactor;
+
+		return String(Math.floor(v));
+	    },
+	    listeners: {
+		// our setValue gets only called if we have a value, avoid
+		// transformation of the first user-entered value
+		keydown: function() { this._transformed = true; },
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'unit',
+	    submitValue: false,
+	    padding: '0 0 0 10',
+	    bind: {
+		value: '{unitlabel}',
+	    },
+	    listeners: {
+		change: (f, v) => {
+		    f.originalValue = v;
+		},
+	    },
+	    width: 40,
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	me.unit = me.unit || 'MiB';
+	if (!(me.unit in me.units)) {
+	    throw "unknown unit: " + me.unit;
+	}
+
+	me.backendFactor = 1;
+	if (me.backendUnit !== undefined) {
+	    if (!(me.unit in me.units)) {
+		throw "unknown backend unit: " + me.backendUnit;
+	    }
+	    me.backendFactor = me.units[me.backendUnit];
+	}
+
+	me.callParent(arguments);
+
+	me.getViewModel().set('unit', me.unit);
+	me.getViewModel().set('unitPostfix', me.unitPostfix);
+    },
+});
+
+Ext.define('PVE.form.BandwidthField', {
+    extend: 'PVE.form.SizeField',
+    alias: 'widget.pveBandwidthField',
+
+    unitPostfix: '/s',
+});
+Ext.define('PVE.form.BridgeSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.PVE.form.BridgeSelector'],
+
+    bridgeType: 'any_bridge', // bridge, OVSBridge or any_bridge
+
+    store: {
+	fields: ['iface', 'active', 'type'],
+	filterOnLoad: true,
+	sorters: [
+	    {
+		property: 'iface',
+		direction: 'ASC',
+	    },
+	],
+    },
+    valueField: 'iface',
+    displayField: 'iface',
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Bridge'),
+		dataIndex: 'iface',
+		hideable: false,
+		width: 100,
+	    },
+	    {
+		header: gettext('Active'),
+		width: 60,
+		dataIndex: 'active',
+		renderer: Proxmox.Utils.format_boolean,
+	    },
+	    {
+		header: gettext('Comment'),
+		dataIndex: 'comments',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	],
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: '/api2/json/nodes/' + me.nodename + '/network?type=' +
+		me.bridgeType,
+	});
+
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename;
+	me.nodename = undefined;
+
+        me.callParent();
+
+	me.setNodename(nodename);
+    },
+});
+
+Ext.define('PVE.form.BusTypeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: 'widget.pveBusSelector',
+
+    withVirtIO: true,
+    withUnused: false,
+
+    initComponent: function() {
+	var me = this;
+
+	me.comboItems = [['ide', 'IDE'], ['sata', 'SATA']];
+
+	if (me.withVirtIO) {
+	    me.comboItems.push(['virtio', 'VirtIO Block']);
+	}
+
+	me.comboItems.push(['scsi', 'SCSI']);
+
+	if (me.withUnused) {
+	    me.comboItems.push(['unused', 'Unused']);
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.data.CPUModel', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{ name: 'name' },
+	{ name: 'vendor' },
+	{ name: 'custom' },
+	{ name: 'displayname' },
+    ],
+});
+
+Ext.define('PVE.form.CPUModelSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.CPUModelSelector'],
+
+    valueField: 'name',
+    displayField: 'displayname',
+
+    emptyText: Proxmox.Utils.defaultText + ' (kvm64)',
+    allowBlank: true,
+
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+    autoSelect: false,
+
+    deleteEmpty: true,
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Model'),
+		dataIndex: 'displayname',
+		hideable: false,
+		sortable: true,
+		flex: 3,
+	    },
+	    {
+		header: gettext('Vendor'),
+		dataIndex: 'vendor',
+		hideable: false,
+		sortable: true,
+		flex: 2,
+	    },
+	],
+	width: 360,
+    },
+
+    store: {
+	autoLoad: true,
+	model: 'PVE.data.CPUModel',
+	proxy: {
+	    type: 'proxmox',
+	    url: '/api2/json/nodes/localhost/capabilities/qemu/cpu',
+	},
+	sorters: [
+	    {
+		sorterFn: function(recordA, recordB) {
+		    let a = recordA.data;
+		    let b = recordB.data;
+
+		    let vendorOrder = PVE.Utils.cpu_vendor_order;
+		    let orderA = vendorOrder[a.vendor] || vendorOrder._default_;
+		    let orderB = vendorOrder[b.vendor] || vendorOrder._default_;
+
+		    if (orderA > orderB) {
+			return 1;
+		    } else if (orderA < orderB) {
+			return -1;
+		    }
+
+		    // Within same vendor, sort alphabetically
+		    return a.name.localeCompare(b.name);
+		},
+		direction: 'ASC',
+	    },
+	],
+	listeners: {
+	    load: function(store, records, success) {
+		if (success) {
+		    records.forEach(rec => {
+			rec.data.displayname = rec.data.name.replace(/^custom-/, '');
+
+			let vendor = rec.data.vendor;
+
+			if (rec.data.name === 'host') {
+			    vendor = 'Host';
+			}
+
+			// We receive vendor names as given to QEMU as CPUID
+			vendor = PVE.Utils.cpu_vendor_map[vendor] || vendor;
+
+			if (rec.data.custom) {
+			    vendor = gettext('Custom') + ` (${vendor})`;
+			}
+
+			rec.data.vendor = vendor;
+		    });
+
+		    store.sort();
+		}
+	    },
+	},
+    },
+});
+Ext.define('PVE.form.CacheTypeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.CacheTypeSelector'],
+    comboItems: [
+	['__default__', Proxmox.Utils.defaultText + " (" + gettext('No cache') + ")"],
+	['directsync', 'Direct sync'],
+	['writethrough', 'Write through'],
+	['writeback', 'Write back'],
+	['unsafe', 'Write back (' + gettext('unsafe') + ')'],
+	['none', gettext('No cache')],
+    ],
+});
+Ext.define('PVE.form.CalendarEvent', {
+    extend: 'Ext.form.field.ComboBox',
+    xtype: 'pveCalendarEvent',
+
+    editable: true,
+    emptyText: gettext('Editable'), // FIXME: better way to convey that to confused users?
+
+    valueField: 'value',
+    queryMode: 'local',
+
+    matchFieldWidth: false,
+    listConfig: {
+	maxWidth: 450,
+    },
+
+    store: {
+	field: ['value', 'text'],
+	data: [
+	    { value: '*/30', text: Ext.String.format(gettext("Every {0} minutes"), 30) },
+	    { value: '*/2:00', text: gettext("Every two hours") },
+	    { value: '21:00', text: gettext("Every day") + " 21:00" },
+	    { value: '2,22:30', text: gettext("Every day") + " 02:30, 22:30" },
+	    { value: 'mon..fri 00:00', text: gettext("Monday to Friday") + " 00:00" },
+	    { value: 'mon..fri */1:00', text: gettext("Monday to Friday") + ': ' + gettext("hourly") },
+	    {
+		value: 'mon..fri 7..18:00/15',
+		text: gettext("Monday to Friday") + ', '
+		    + Ext.String.format(gettext('{0} to {1}'), '07:00', '18:45') + ': '
+		    + Ext.String.format(gettext("Every {0} minutes"), 15),
+	    },
+	    { value: 'sun 01:00', text: gettext("Sunday") + " 01:00" },
+	    { value: 'monthly', text: gettext("Every first day of the Month") + " 00:00" },
+	    { value: 'sat *-1..7 15:00', text: gettext("First Saturday each month") + " 15:00" },
+	    { value: 'yearly', text: gettext("First day of the year") + " 00:00" },
+	],
+    },
+
+    tpl: [
+	'<ul class="x-list-plain"><tpl for=".">',
+	    '<li role="option" class="x-boundlist-item">{text}</li>',
+	'</tpl></ul>',
+    ],
+
+    displayTpl: [
+	'<tpl for=".">',
+	    '{value}',
+	'</tpl>',
+    ],
+
+});
+Ext.define('PVE.form.CephPoolSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveCephPoolSelector',
+
+    allowBlank: false,
+    valueField: 'pool_name',
+    displayField: 'pool_name',
+    editable: false,
+    queryMode: 'local',
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	let onlyRBDPools = ({ data }) =>
+	    !data?.application_metadata || !!data?.application_metadata?.rbd;
+
+	var store = Ext.create('Ext.data.Store', {
+	    fields: ['name'],
+	    sorters: 'name',
+	    filters: [
+		onlyRBDPools,
+	    ],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes/' + me.nodename + '/ceph/pool',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+        me.callParent();
+
+	store.load({
+	    callback: function(rec, op, success) {
+		let filteredRec = rec.filter(onlyRBDPools);
+
+		if (success && filteredRec.length > 0) {
+		    me.select(filteredRec[0]);
+		}
+	    },
+	});
+    },
+
+});
+Ext.define('PVE.form.CephFSSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveCephFSSelector',
+
+    allowBlank: false,
+    valueField: 'name',
+    displayField: 'name',
+    editable: false,
+    queryMode: 'local',
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    fields: ['name'],
+	    sorters: 'name',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes/' + me.nodename + '/ceph/fs',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+        me.callParent();
+
+	store.load({
+	    callback: function(rec, op, success) {
+		if (success && rec.length > 0) {
+		    me.select(rec[0]);
+		}
+	    },
+	});
+    },
+
+});
+Ext.define('PVE.form.ComboBoxSetStoreNode', {
+    extend: 'Proxmox.form.ComboGrid',
+    config: {
+	apiBaseUrl: '/api2/json/nodes/',
+	apiSuffix: '',
+    },
+
+    showNodeSelector: false,
+
+    setNodeName: function(value) {
+	let me = this;
+	value ||= Proxmox.NodeName;
+
+	me.getStore().getProxy().setUrl(`${me.apiBaseUrl}${value}${me.apiSuffix}`);
+	me.clearValue();
+    },
+
+    nodeChange: function(_field, value) {
+	let me = this;
+	// disable autoSelect if there is already a selection or we have the picker open
+	if (me.getValue() || me.isExpanded) {
+	    let autoSelect = me.autoSelect;
+	    me.autoSelect = false;
+	    me.store.on('afterload', function() {
+		me.autoSelect = autoSelect;
+	    }, { single: true });
+	}
+	me.setNodeName(value);
+	me.fireEvent('nodechanged', value);
+    },
+
+    tbarMouseDown: function() {
+	this.topBarMousePress = true;
+    },
+
+    tbarMouseUp: function() {
+	let me = this;
+	delete this.topBarMousePress;
+	if (me.focusLeft) {
+	    me.focus();
+	    delete me.focusLeft;
+	}
+    },
+
+    // conditionally prevent the focusLeave handler to continue, preventing collapsing of the picker
+    onFocusLeave: function() {
+	let me = this;
+	me.focusLeft = true;
+	if (!me.topBarMousePress) {
+	    me.callParent(arguments);
+	}
+
+	return undefined;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.showNodeSelector && !PVE.Utils.isStandaloneNode()) {
+	    me.errorHeight = 140;
+	    Ext.apply(me.listConfig ?? {}, {
+		tbar: {
+		    xtype: 'toolbar',
+		    minHeight: 40,
+		    listeners: {
+			mousedown: me.tbarMouseDown,
+			mouseup: me.tbarMouseUp,
+			element: 'el',
+			scope: me,
+		    },
+		    items: [
+			{
+			    xtype: "pveStorageScanNodeSelector",
+			    autoSelect: false,
+			    fieldLabel: gettext('Node to scan'),
+			    listeners: {
+				change: (field, value) => me.nodeChange(field, value),
+			    },
+			},
+		    ],
+		},
+		emptyText: me.listConfig?.emptyText ?? gettext('Nothing found'),
+	    });
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.form.ContentTypeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveContentTypeSelector'],
+
+    cts: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	me.comboItems = [];
+
+	if (me.cts === undefined) {
+	    me.cts = ['images', 'iso', 'vztmpl', 'backup', 'rootdir', 'snippets'];
+	}
+
+	Ext.Array.each(me.cts, function(ct) {
+	    me.comboItems.push([ct, PVE.Utils.format_content_types(ct)]);
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.form.ControllerSelector', {
+    extend: 'Ext.form.FieldContainer',
+    alias: 'widget.pveControllerSelector',
+
+    withVirtIO: true,
+    withUnused: false,
+
+    vmconfig: {}, // used to check for existing devices
+
+    setToFree: function(controllers, busField, deviceIDField) {
+	let me = this;
+	let freeId = PVE.Utils.nextFreeDisk(controllers, me.vmconfig);
+
+	if (freeId !== undefined) {
+	    busField?.setValue(freeId.controller);
+	    deviceIDField.setValue(freeId.id);
+	}
+    },
+
+    updateVMConfig: function(vmconfig) {
+	let me = this;
+	me.vmconfig = Ext.apply({}, vmconfig);
+
+	me.down('field[name=deviceid]').validate();
+    },
+
+    setVMConfig: function(vmconfig, autoSelect) {
+	let me = this;
+
+	me.vmconfig = Ext.apply({}, vmconfig);
+
+	let bussel = me.down('field[name=controller]');
+	let deviceid = me.down('field[name=deviceid]');
+
+	let clist;
+	if (autoSelect === 'cdrom') {
+	    if (!Ext.isDefined(me.vmconfig.ide2)) {
+		bussel.setValue('ide');
+		deviceid.setValue(2);
+		return;
+	    }
+	    clist = ['ide', 'scsi', 'sata'];
+	} else {
+	    // in most cases we want to add a disk to the same controller we previously used
+	    clist = PVE.Utils.sortByPreviousUsage(me.vmconfig);
+	}
+
+	me.setToFree(clist, bussel, deviceid);
+
+	deviceid.validate();
+    },
+
+    getConfId: function() {
+	let me = this;
+	let controller = me.getComponent('controller').getValue() || 'ide';
+	let id = me.getComponent('deviceid').getValue() || 0;
+
+	return `${controller}${id}`;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	Ext.apply(me, {
+	    fieldLabel: gettext('Bus/Device'),
+	    layout: 'hbox',
+	    defaults: {
+                hideLabel: true,
+	    },
+	    items: [
+		{
+		    xtype: 'pveBusSelector',
+		    name: 'controller',
+		    itemId: 'controller',
+		    value: PVE.qemu.OSDefaults.generic.busType,
+		    withVirtIO: me.withVirtIO,
+		    withUnused: me.withUnused,
+		    allowBlank: false,
+		    flex: 2,
+		    listeners: {
+			change: function(t, value) {
+			    if (!value) {
+				return;
+			    }
+			    let field = me.down('field[name=deviceid]');
+			    me.setToFree([value], undefined, field);
+			    field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value] - 1);
+			    field.validate();
+			},
+		    },
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'deviceid',
+		    itemId: 'deviceid',
+		    minValue: 0,
+		    maxValue: PVE.Utils.diskControllerMaxIDs.ide - 1,
+		    value: '0',
+		    flex: 1,
+		    allowBlank: false,
+		    validator: function(value) {
+			if (!me.rendered) {
+			    return undefined;
+			}
+			let controller = me.down('field[name=controller]').getValue();
+			let confid = controller + value;
+			if (Ext.isDefined(me.vmconfig[confid])) {
+			    return "This device is already in use.";
+			}
+			return true;
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	if (me.selectFree) {
+	    me.setVMConfig(me.vmconfig);
+	}
+    },
+});
+Ext.define('PVE.form.DayOfWeekSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveDayOfWeekSelector'],
+    comboItems: [],
+    initComponent: function() {
+	var me = this;
+	me.comboItems = [
+	    ['mon', Ext.util.Format.htmlDecode(Ext.Date.dayNames[1])],
+	    ['tue', Ext.util.Format.htmlDecode(Ext.Date.dayNames[2])],
+	    ['wed', Ext.util.Format.htmlDecode(Ext.Date.dayNames[3])],
+	    ['thu', Ext.util.Format.htmlDecode(Ext.Date.dayNames[4])],
+	    ['fri', Ext.util.Format.htmlDecode(Ext.Date.dayNames[5])],
+	    ['sat', Ext.util.Format.htmlDecode(Ext.Date.dayNames[6])],
+	    ['sun', Ext.util.Format.htmlDecode(Ext.Date.dayNames[0])],
+	];
+	this.callParent();
+    },
+});
+Ext.define('PVE.form.DiskFormatSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: 'widget.pveDiskFormatSelector',
+    comboItems: [
+	['raw', gettext('Raw disk image') + ' (raw)'],
+	['qcow2', gettext('QEMU image format') + ' (qcow2)'],
+	['vmdk', gettext('VMware image format') + ' (vmdk)'],
+    ],
+});
+Ext.define('PVE.form.DiskStorageSelector', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pveDiskStorageSelector',
+
+    layout: 'fit',
+    defaults: {
+	margin: '0 0 5 0',
+    },
+
+    // the fieldLabel for the storageselector
+    storageLabel: gettext('Storage'),
+
+    // the content to show (e.g., images or rootdir)
+    storageContent: undefined,
+
+    // if true, selects the first available storage
+    autoSelect: false,
+
+    allowBlank: false,
+    emptyText: '',
+
+    // hides the selection field
+    // this is always hidden on creation,
+    // and only shown when the storage needs a selection and
+    // hideSelection is not true
+    hideSelection: undefined,
+
+    // hides the size field (e.g, for the efi disk dialog)
+    hideSize: false,
+
+    // hides the format field (e.g. for TPM state)
+    hideFormat: false,
+
+    // sets the initial size value
+    // string because else we get a type confusion
+    defaultSize: '32',
+
+    changeStorage: function(f, value) {
+	var me = this;
+	var formatsel = me.getComponent('diskformat');
+	var hdfilesel = me.getComponent('hdimage');
+	var hdsizesel = me.getComponent('disksize');
+
+	// initial store load, and reset/deletion of the storage
+	if (!value) {
+	    hdfilesel.setDisabled(true);
+	    hdfilesel.setVisible(false);
+
+	    formatsel.setDisabled(true);
+	    return;
+	}
+
+	var rec = f.store.getById(value);
+	// if the storage is not defined, or valid,
+	// we cannot know what to enable/disable
+	if (!rec) {
+	    return;
+	}
+
+	let validFormats = {};
+	let selectFormat = 'raw';
+	if (rec.data.format) {
+	    validFormats = rec.data.format[0]; // 0 is the formats, 1 the default in the backend
+	    delete validFormats.subvol; // we never need subvol in the gui
+	    if (validFormats.qcow2) {
+		selectFormat = 'qcow2';
+	    } else if (validFormats.raw) {
+		selectFormat = 'raw';
+	    } else {
+		selectFormat = rec.data.format[1];
+	    }
+	}
+
+	var select = !!rec.data.select_existing && !me.hideSelection;
+
+	formatsel.setDisabled(me.hideFormat || Ext.Object.getSize(validFormats) <= 1);
+	formatsel.setValue(selectFormat);
+
+	hdfilesel.setDisabled(!select);
+	hdfilesel.setVisible(select);
+	if (select) {
+	    hdfilesel.setStorage(value);
+	}
+
+	hdsizesel.setDisabled(select || me.hideSize);
+	hdsizesel.setVisible(!select && !me.hideSize);
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+	var hdstorage = me.getComponent('hdstorage');
+	var hdfilesel = me.getComponent('hdimage');
+
+	hdstorage.setNodename(nodename);
+	hdfilesel.setNodename(nodename);
+    },
+
+    setDisabled: function(value) {
+	var me = this;
+	var hdstorage = me.getComponent('hdstorage');
+
+	// reset on disable
+	if (value) {
+	    hdstorage.setValue();
+	}
+	hdstorage.setDisabled(value);
+
+	// disabling does not always fire this event and we do not need
+	// the value of the validity
+	hdstorage.fireEvent('validitychange');
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'pveStorageSelector',
+		itemId: 'hdstorage',
+		name: 'hdstorage',
+		fieldLabel: me.storageLabel,
+		nodename: me.nodename,
+		storageContent: me.storageContent,
+		disabled: me.disabled,
+		autoSelect: me.autoSelect,
+		allowBlank: me.allowBlank,
+		emptyText: me.emptyText,
+		listeners: {
+		    change: {
+			fn: me.changeStorage,
+			scope: me,
+		    },
+		},
+	    },
+	    {
+		xtype: 'pveFileSelector',
+		name: 'hdimage',
+		itemId: 'hdimage',
+		fieldLabel: gettext('Disk image'),
+		nodename: me.nodename,
+		disabled: true,
+		hidden: true,
+	    },
+	    {
+		xtype: 'numberfield',
+		itemId: 'disksize',
+		name: 'disksize',
+		fieldLabel: `${gettext('Disk size')} (${gettext('GiB')})`,
+		hidden: me.hideSize,
+		disabled: me.hideSize,
+		minValue: 0.001,
+		maxValue: 128*1024,
+		decimalPrecision: 3,
+		value: me.defaultSize,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'pveDiskFormatSelector',
+		itemId: 'diskformat',
+		name: 'diskformat',
+		fieldLabel: gettext('Format'),
+		nodename: me.nodename,
+		disabled: true,
+		hidden: me.hideFormat || me.storageContent === 'rootdir',
+		value: 'qcow2',
+		allowBlank: false,
+	    },
+	];
+
+	// use it to disable the children but not ourself
+	me.disabled = false;
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.form.FileSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pveFileSelector',
+
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+
+    listeners: {
+	afterrender: function() {
+	    var me = this;
+	    if (!me.disabled) {
+		me.setStorage(me.storage, me.nodename);
+	    }
+	},
+    },
+
+    setStorage: function(storage, nodename) {
+	var me = this;
+
+	var change = false;
+	if (storage && me.storage !== storage) {
+	    me.storage = storage;
+	    change = true;
+	}
+
+	if (nodename && me.nodename !== nodename) {
+	    me.nodename = nodename;
+	    change = true;
+	}
+
+	if (!(me.storage && me.nodename && change)) {
+	    return;
+	}
+
+	var url = '/api2/json/nodes/' + me.nodename + '/storage/' + me.storage + '/content';
+	if (me.storageContent) {
+	    url += '?content=' + me.storageContent;
+	}
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: url,
+	});
+
+	me.store.removeAll();
+	me.store.load();
+    },
+
+    setNodename: function(nodename) {
+	this.setStorage(undefined, nodename);
+    },
+
+    store: {
+	model: 'pve-storage-content',
+    },
+
+    allowBlank: false,
+    autoSelect: false,
+    valueField: 'volid',
+    displayField: 'text',
+
+    listConfig: {
+	width: 600,
+	columns: [
+	    {
+		header: gettext('Name'),
+		dataIndex: 'text',
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Format'),
+		width: 60,
+		dataIndex: 'format',
+	    },
+	    {
+		header: gettext('Size'),
+		width: 100,
+		dataIndex: 'size',
+		renderer: Proxmox.Utils.format_size,
+	    },
+	],
+    },
+});
+Ext.define('PVE.form.FirewallPolicySelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveFirewallPolicySelector'],
+    comboItems: [
+	    ['ACCEPT', 'ACCEPT'],
+	    ['REJECT', 'REJECT'],
+	    ['DROP', 'DROP'],
+	],
+});
+/*
+ *  This is a global search field it loads the /cluster/resources on focus and displays the
+ *  result in a floating grid. Filtering and sorting is done in the customFilter function
+ *
+ *  Accepts key up/down and enter for input, and it opens to CTRL+SHIFT+F and CTRL+SPACE
+ */
+Ext.define('PVE.form.GlobalSearchField', {
+    extend: 'Ext.form.field.Text',
+    alias: 'widget.pveGlobalSearchField',
+
+    emptyText: gettext('Search'),
+    enableKeyEvents: true,
+    selectOnFocus: true,
+    padding: '0 5 0 5',
+
+    grid: {
+	xtype: 'gridpanel',
+	userCls: 'proxmox-tags-full',
+	focusOnToFront: false,
+	floating: true,
+	emptyText: Proxmox.Utils.noneText,
+	width: 600,
+	height: 400,
+	scrollable: {
+	    xtype: 'scroller',
+	    y: true,
+	    x: true,
+	},
+	store: {
+	    model: 'PVEResources',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/extjs/cluster/resources',
+	    },
+	},
+	plugins: {
+	    ptype: 'bufferedrenderer',
+	    trailingBufferZone: 20,
+	    leadingBufferZone: 20,
+	},
+
+	hideMe: function() {
+	    var me = this;
+	    if (typeof me.ctxMenu !== 'undefined' && me.ctxMenu.isVisible()) {
+		return;
+	    }
+	    me.hasFocus = false;
+	    if (!me.textfield.hasFocus) {
+		me.hide();
+	    }
+	},
+
+	setFocus: function() {
+	    var me = this;
+	    me.hasFocus = true;
+	},
+
+	listeners: {
+	    rowclick: function(grid, record) {
+		var me = this;
+		me.textfield.selectAndHide(record.id);
+	    },
+	    itemcontextmenu: function(v, record, item, index, event) {
+		var me = this;
+		me.ctxMenu = PVE.Utils.createCmdMenu(v, record, item, index, event);
+	    },
+	    focusleave: 'hideMe',
+	    focusenter: 'setFocus',
+	},
+
+	columns: [
+	    {
+		text: gettext('Type'),
+		dataIndex: 'type',
+		width: 100,
+		renderer: PVE.Utils.render_resource_type,
+	    },
+	    {
+		text: gettext('Description'),
+		flex: 1,
+		dataIndex: 'text',
+		renderer: function(value, mD, rec) {
+		    let overrides = PVE.UIOptions.tagOverrides;
+		    let tags = PVE.Utils.renderTags(rec.data.tags, overrides);
+		    return `${value}${tags}`;
+		},
+	    },
+	    {
+		text: gettext('Node'),
+		dataIndex: 'node',
+	    },
+	    {
+		text: gettext('Pool'),
+		dataIndex: 'pool',
+	    },
+	],
+    },
+
+    customFilter: function(item) {
+	let me = this;
+
+	if (me.filterVal === '') {
+	    item.data.relevance = 0;
+	    return true;
+	}
+	// different types have different fields to search, e.g., a node will never have a pool
+	const fieldMap = {
+	    'pool': ['type', 'pool', 'text'],
+	    'node': ['type', 'node', 'text'],
+	    'storage': ['type', 'pool', 'node', 'storage'],
+	    'default': ['name', 'type', 'node', 'pool', 'vmid'],
+	};
+	let fields = fieldMap[item.data.type] || fieldMap.default;
+	let fieldArr = fields.map(field => item.data[field]?.toString().toLowerCase());
+	if (item.data.tags) {
+	    let tags = item.data.tags.split(/[;, ]/);
+	    fieldArr.push(...tags);
+	}
+
+	let filterWords = me.filterVal.split(/\s+/);
+
+	// all text is case insensitive and each split-out word is searched for separately.
+	// a row gets 1 point for every partial match, and and additional point for every exact match
+	let match = 0;
+	for (let fieldValue of fieldArr) {
+	    if (fieldValue === undefined || fieldValue === "") {
+		continue;
+	    }
+	    for (let filterWord of filterWords) {
+		if (fieldValue.indexOf(filterWord) !== -1) {
+		    match++; // partial match
+		    if (fieldValue === filterWord) {
+			match++; // exact match is worth more
+		    }
+		}
+	    }
+	}
+	item.data.relevance = match; // set the row's virtual 'relevance' value for ordering
+	return match > 0;
+    },
+
+    updateFilter: function(field, newValue, oldValue) {
+	let me = this;
+	// parse input and filter store, show grid
+	me.grid.store.filterVal = newValue.toLowerCase().trim();
+	me.grid.store.clearFilter(true);
+	me.grid.store.filterBy(me.customFilter);
+	me.grid.getSelectionModel().select(0);
+    },
+
+    selectAndHide: function(id) {
+	var me = this;
+	me.tree.selectById(id);
+	me.grid.hide();
+	me.setValue('');
+	me.blur();
+    },
+
+    onKey: function(field, e) {
+	var me = this;
+	var key = e.getKey();
+
+	switch (key) {
+	    case Ext.event.Event.ENTER:
+		// go to first entry if there is one
+		if (me.grid.store.getCount() > 0) {
+		    me.selectAndHide(me.grid.getSelection()[0].data.id);
+		}
+		break;
+	    case Ext.event.Event.UP:
+		me.grid.getSelectionModel().selectPrevious();
+		break;
+	    case Ext.event.Event.DOWN:
+		me.grid.getSelectionModel().selectNext();
+		break;
+	    case Ext.event.Event.ESC:
+		me.grid.hide();
+		me.blur();
+		break;
+	}
+    },
+
+    loadValues: function(field) {
+	let me = this;
+	me.hasFocus = true;
+	me.grid.textfield = me;
+	me.grid.store.load();
+	me.grid.showBy(me, 'tl-bl');
+    },
+
+    hideGrid: function() {
+	let me = this;
+	me.hasFocus = false;
+	if (!me.grid.hasFocus) {
+	    me.grid.hide();
+	}
+    },
+
+    listeners: {
+	change: {
+	    fn: 'updateFilter',
+	    buffer: 250,
+	},
+	specialkey: 'onKey',
+	focusenter: 'loadValues',
+	focusleave: {
+	    fn: 'hideGrid',
+	    delay: 100,
+	},
+    },
+
+    toggleFocus: function() {
+	let me = this;
+	if (!me.hasFocus) {
+	    me.focus();
+	} else {
+	    me.blur();
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.tree) {
+	    throw "no tree given";
+	}
+
+	me.grid = Ext.create(me.grid);
+
+	me.callParent();
+
+	// bind CTRL + SHIFT + F and CTRL + SPACE to open/close the search
+	me.keymap = new Ext.KeyMap({
+	    target: Ext.get(document),
+	    binding: [{
+		key: 'F',
+		ctrl: true,
+		shift: true,
+		fn: me.toggleFocus,
+		scope: me,
+	    }, {
+		key: ' ',
+		ctrl: true,
+		fn: me.toggleFocus,
+		scope: me,
+	    }],
+	});
+
+	// always select first item and sort by relevance after load
+	me.mon(me.grid.store, 'load', function() {
+	    me.grid.getSelectionModel().select(0);
+	    me.grid.store.sort({
+		property: 'relevance',
+		direction: 'DESC',
+	    });
+	});
+    },
+});
+Ext.define('pve-groups', {
+    extend: 'Ext.data.Model',
+    fields: ['groupid', 'comment', 'users'],
+    proxy: {
+	type: 'proxmox',
+	url: "/api2/json/access/groups",
+    },
+    idProperty: 'groupid',
+});
+
+Ext.define('PVE.form.GroupSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    xtype: 'pveGroupSelector',
+
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+
+    allowBlank: false,
+    autoSelect: false,
+    valueField: 'groupid',
+    displayField: 'groupid',
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Group'),
+		sortable: true,
+		dataIndex: 'groupid',
+		flex: 1,
+	    },
+	    {
+		header: gettext('Comment'),
+		sortable: false,
+		dataIndex: 'comment',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Users'),
+		sortable: false,
+		dataIndex: 'users',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	],
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-groups',
+	    sorters: [{
+		property: 'groupid',
+	    }],
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+
+	store.load();
+    },
+});
+Ext.define('PVE.form.GuestIDSelector', {
+    extend: 'Ext.form.field.Number',
+    alias: 'widget.pveGuestIDSelector',
+
+    allowBlank: false,
+
+    minValue: 100,
+
+    maxValue: 999999999,
+
+    validateExists: undefined,
+
+    loadNextFreeID: false,
+
+    guestType: undefined,
+
+    validator: function(value) {
+	var me = this;
+
+	if (!Ext.isNumeric(value) ||
+	    value < me.minValue ||
+	    value > me.maxValue) {
+	    // check is done by ExtJS
+	    return true;
+	}
+
+	if (me.validateExists === true && !me.exists) {
+	    return me.unknownID;
+	}
+
+	if (me.validateExists === false && me.exists) {
+	    return me.inUseID;
+	}
+
+	return true;
+    },
+
+    initComponent: function() {
+	var me = this;
+	var label = '{0} ID';
+	var unknownID = gettext('This {0} ID does not exist');
+	var inUseID = gettext('This {0} ID is already in use');
+	var type = 'CT/VM';
+
+	if (me.guestType === 'lxc') {
+	    type = 'CT';
+	} else if (me.guestType === 'qemu') {
+	    type = 'VM';
+	}
+
+	me.label = Ext.String.format(label, type);
+	me.unknownID = Ext.String.format(unknownID, type);
+	me.inUseID = Ext.String.format(inUseID, type);
+
+	Ext.apply(me, {
+	    fieldLabel: me.label,
+	    listeners: {
+		'change': function(field, newValue, oldValue) {
+		    if (!Ext.isDefined(me.validateExists)) {
+			return;
+		    }
+		    Proxmox.Utils.API2Request({
+			params: { vmid: newValue },
+			url: '/cluster/nextid',
+			method: 'GET',
+			success: function(response, opts) {
+			    me.exists = false;
+			    me.validate();
+			},
+			failure: function(response, opts) {
+			    me.exists = true;
+			    me.validate();
+			},
+		    });
+		},
+	    },
+	});
+
+        me.callParent();
+
+	if (me.loadNextFreeID) {
+	    Proxmox.Utils.API2Request({
+		url: '/cluster/nextid',
+		method: 'GET',
+		success: function(response, opts) {
+		    me.setRawValue(response.result.data);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.form.hashAlgorithmSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveHashAlgorithmSelector'],
+    config: {
+	deleteEmpty: false,
+    },
+    comboItems: [
+	['__default__', 'None'],
+	['md5', 'MD5'],
+	['sha1', 'SHA-1'],
+	['sha224', 'SHA-224'],
+	['sha256', 'SHA-256'],
+	['sha384', 'SHA-384'],
+	['sha512', 'SHA-512'],
+    ],
+});
+Ext.define('PVE.form.HotplugFeatureSelector', {
+    extend: 'Ext.form.CheckboxGroup',
+    alias: 'widget.pveHotplugFeatureSelector',
+
+    columns: 1,
+    vertical: true,
+
+    defaults: {
+	name: 'hotplugCbGroup',
+	submitValue: false,
+    },
+    items: [
+	{
+	    boxLabel: gettext('Disk'),
+	    inputValue: 'disk',
+	    checked: true,
+	},
+	{
+	    boxLabel: gettext('Network'),
+	    inputValue: 'network',
+	    checked: true,
+	},
+	{
+	    boxLabel: 'USB',
+	    inputValue: 'usb',
+	    checked: true,
+	},
+	{
+	    boxLabel: gettext('Memory'),
+	    inputValue: 'memory',
+	},
+	{
+	    boxLabel: gettext('CPU'),
+	    inputValue: 'cpu',
+	},
+    ],
+
+    setValue: function(value) {
+	var me = this;
+	var newVal = [];
+	if (value === '1') {
+	    newVal = ['disk', 'network', 'usb'];
+	} else if (value !== '0') {
+	    newVal = value.split(',');
+	}
+	me.callParent([{ hotplugCbGroup: newVal }]);
+    },
+
+    // override framework function to
+    // assemble the hotplug value
+    getSubmitData: function() {
+	var me = this,
+	boxes = me.getBoxes(),
+	data = [];
+	Ext.Array.forEach(boxes, function(box) {
+	    if (box.getValue()) {
+		data.push(box.inputValue);
+	    }
+	});
+
+	/* because above is hotplug an array */
+	if (data.length === 0) {
+	    return { 'hotplug': '0' };
+	} else {
+	    return { 'hotplug': data.join(',') };
+	}
+    },
+
+});
+Ext.define('PVE.form.IPProtocolSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveIPProtocolSelector'],
+    valueField: 'p',
+    displayField: 'p',
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Protocol'),
+		dataIndex: 'p',
+		hideable: false,
+		sortable: false,
+		width: 100,
+	    },
+	    {
+		header: gettext('Number'),
+		dataIndex: 'n',
+		hideable: false,
+		sortable: false,
+		width: 50,
+	    },
+	    {
+		header: gettext('Description'),
+		dataIndex: 'd',
+		hideable: false,
+		sortable: false,
+		flex: 1,
+	    },
+	],
+    },
+    store: {
+	    fields: ['p', 'd', 'n'],
+	    data: [
+		{ p: 'tcp', n: 6, d: 'Transmission Control Protocol' },
+		{ p: 'udp', n: 17, d: 'User Datagram Protocol' },
+		{ p: 'icmp', n: 1, d: 'Internet Control Message Protocol' },
+		{ p: 'igmp', n: 2, d: 'Internet Group Management' },
+		{ p: 'ggp', n: 3, d: 'gateway-gateway protocol' },
+		{ p: 'ipencap', n: 4, d: 'IP encapsulated in IP' },
+		{ p: 'st', n: 5, d: 'ST datagram mode' },
+		{ p: 'egp', n: 8, d: 'exterior gateway protocol' },
+		{ p: 'igp', n: 9, d: 'any private interior gateway (Cisco)' },
+		{ p: 'pup', n: 12, d: 'PARC universal packet protocol' },
+		{ p: 'hmp', n: 20, d: 'host monitoring protocol' },
+		{ p: 'xns-idp', n: 22, d: 'Xerox NS IDP' },
+		{ p: 'rdp', n: 27, d: '"reliable datagram" protocol' },
+		{ p: 'iso-tp4', n: 29, d: 'ISO Transport Protocol class 4 [RFC905]' },
+		{ p: 'dccp', n: 33, d: 'Datagram Congestion Control Prot. [RFC4340]' },
+		{ p: 'xtp', n: 36, d: 'Xpress Transfer Protocol' },
+		{ p: 'ddp', n: 37, d: 'Datagram Delivery Protocol' },
+		{ p: 'idpr-cmtp', n: 38, d: 'IDPR Control Message Transport' },
+		{ p: 'ipv6', n: 41, d: 'Internet Protocol, version 6' },
+		{ p: 'ipv6-route', n: 43, d: 'Routing Header for IPv6' },
+		{ p: 'ipv6-frag', n: 44, d: 'Fragment Header for IPv6' },
+		{ p: 'idrp', n: 45, d: 'Inter-Domain Routing Protocol' },
+		{ p: 'rsvp', n: 46, d: 'Reservation Protocol' },
+		{ p: 'gre', n: 47, d: 'General Routing Encapsulation' },
+		{ p: 'esp', n: 50, d: 'Encap Security Payload [RFC2406]' },
+		{ p: 'ah', n: 51, d: 'Authentication Header [RFC2402]' },
+		{ p: 'skip', n: 57, d: 'SKIP' },
+		{ p: 'ipv6-icmp', n: 58, d: 'ICMP for IPv6' },
+		{ p: 'ipv6-nonxt', n: 59, d: 'No Next Header for IPv6' },
+		{ p: 'ipv6-opts', n: 60, d: 'Destination Options for IPv6' },
+		{ p: 'vmtp', n: 81, d: 'Versatile Message Transport' },
+		{ p: 'eigrp', n: 88, d: 'Enhanced Interior Routing Protocol (Cisco)' },
+		{ p: 'ospf', n: 89, d: 'Open Shortest Path First IGP' },
+		{ p: 'ax.25', n: 93, d: 'AX.25 frames' },
+		{ p: 'ipip', n: 94, d: 'IP-within-IP Encapsulation Protocol' },
+		{ p: 'etherip', n: 97, d: 'Ethernet-within-IP Encapsulation [RFC3378]' },
+		{ p: 'encap', n: 98, d: 'Yet Another IP encapsulation [RFC1241]' },
+		{ p: 'pim', n: 103, d: 'Protocol Independent Multicast' },
+		{ p: 'ipcomp', n: 108, d: 'IP Payload Compression Protocol' },
+		{ p: 'vrrp', n: 112, d: 'Virtual Router Redundancy Protocol [RFC5798]' },
+		{ p: 'l2tp', n: 115, d: 'Layer Two Tunneling Protocol [RFC2661]' },
+		{ p: 'isis', n: 124, d: 'IS-IS over IPv4' },
+		{ p: 'sctp', n: 132, d: 'Stream Control Transmission Protocol' },
+		{ p: 'fc', n: 133, d: 'Fibre Channel' },
+		{ p: 'mobility-header', n: 135, d: 'Mobility Support for IPv6 [RFC3775]' },
+		{ p: 'udplite', n: 136, d: 'UDP-Lite [RFC3828]' },
+		{ p: 'mpls-in-ip', n: 137, d: 'MPLS-in-IP [RFC4023]' },
+		{ p: 'hip', n: 139, d: 'Host Identity Protocol' },
+		{ p: 'shim6', n: 140, d: 'Shim6 Protocol [RFC5533]' },
+		{ p: 'wesp', n: 141, d: 'Wrapped Encapsulating Security Payload' },
+		{ p: 'rohc', n: 142, d: 'Robust Header Compression' },
+	    ],
+	},
+});
+Ext.define('PVE.form.IPRefSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveIPRefSelector'],
+
+    base_url: undefined,
+
+    preferredValue: '', // hack: else Form sets dirty flag?
+
+    ref_type: undefined, // undefined = any [undefined, 'ipset' or 'alias']
+
+    valueField: 'scopedref',
+    displayField: 'ref',
+    notFoundIsValid: true,
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.base_url) {
+	    throw "no base_url specified";
+	}
+
+	var url = "/api2/json" + me.base_url;
+	if (me.ref_type) {
+	    url += "?type=" + me.ref_type;
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    autoLoad: true,
+	    fields: [
+		'type',
+		'name',
+		'ref',
+		'comment',
+		'scope',
+		{
+		    name: 'scopedref',
+		    calculate: function(v) {
+			if (v.type === 'alias') {
+			    return `${v.scope}/${v.name}`;
+			} else if (v.type === 'ipset') {
+			    return `+${v.scope}/${v.name}`;
+			} else {
+			    return v.ref;
+			}
+		    },
+		},
+	    ],
+	    idProperty: 'ref',
+	    proxy: {
+		type: 'proxmox',
+		url: url,
+	    },
+	    sorters: {
+		property: 'ref',
+		direction: 'ASC',
+	    },
+	});
+
+	var columns = [];
+
+	if (!me.ref_type) {
+	    columns.push({
+		header: gettext('Type'),
+		dataIndex: 'type',
+		hideable: false,
+		width: 60,
+	    });
+	}
+
+	columns.push(
+	    {
+		header: gettext('Name'),
+		dataIndex: 'ref',
+		hideable: false,
+		width: 140,
+	    },
+	    {
+		header: gettext('Scope'),
+		dataIndex: 'scope',
+		hideable: false,
+		width: 140,
+		renderer: function(value) {
+		    return value === 'dc' ? gettext("Datacenter") : gettext("Guest");
+		},
+	    },
+	    {
+		header: gettext('Comment'),
+		dataIndex: 'comment',
+		renderer: Ext.String.htmlEncode,
+		minWidth: 60,
+		flex: 1,
+	    },
+	);
+
+	Ext.apply(me, {
+	    store: store,
+            listConfig: {
+		columns: columns,
+		width: 500,
+	    },
+	});
+
+	me.on('beforequery', function(queryPlan) {
+	    return !(queryPlan.query === null || queryPlan.query.match(/^\d/));
+	});
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.form.MDevSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    xtype: 'pveMDevSelector',
+
+    store: {
+	fields: ['type', 'available', 'description'],
+	filterOnLoad: true,
+	sorters: [
+	    {
+		property: 'type',
+		direction: 'ASC',
+	    },
+	],
+    },
+    autoSelect: false,
+    valueField: 'type',
+    displayField: 'type',
+    listConfig: {
+	width: 550,
+	columns: [
+	    {
+		header: gettext('Type'),
+		dataIndex: 'type',
+		renderer: function(value, md, rec) {
+		    if (rec.data.name !== undefined) {
+			return `${rec.data.name} (${value})`;
+		    }
+		    return value;
+		},
+		flex: 1,
+	    },
+	    {
+		header: gettext('Avail'),
+		dataIndex: 'available',
+		width: 60,
+	    },
+	    {
+		header: gettext('Description'),
+		dataIndex: 'description',
+		flex: 1,
+		cellWrap: true,
+		renderer: function(value) {
+		    if (!value) {
+			return '';
+		    }
+
+		    return value.split('\n').join('<br>');
+		},
+	    },
+	],
+    },
+
+    setPciID: function(pciid, force) {
+	var me = this;
+
+	if (!force && (!pciid || me.pciid === pciid)) {
+	    return;
+	}
+
+	me.pciid = pciid;
+	me.updateProxy();
+    },
+
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+	me.updateProxy();
+    },
+
+    updateProxy: function() {
+	var me = this;
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: '/api2/json/nodes/' + me.nodename + '/hardware/pci/' + me.pciid + '/mdev',
+	});
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw 'no node name specified';
+	}
+
+        me.callParent();
+
+	if (me.pciid) {
+	    me.setPciID(me.pciid, true);
+	}
+    },
+});
+
+Ext.define('PVE.form.MemoryField', {
+    extend: 'Ext.form.field.Number',
+    alias: 'widget.pveMemoryField',
+
+    allowBlank: false,
+
+    hotplug: false,
+
+    minValue: 32,
+
+    maxValue: 4178944,
+
+    step: 32,
+
+    value: '512', // qm backend default
+
+    allowDecimals: false,
+
+    allowExponential: false,
+
+    computeUpDown: function(value) {
+	var me = this;
+
+	if (!me.hotplug) {
+	    return { up: value + me.step, down: value - me.step };
+	}
+
+	var dimm_size = 512;
+	var prev_dimm_size = 0;
+	var min_size = 1024;
+	var current_size = min_size;
+	var value_up = min_size;
+	var value_down = min_size;
+	var value_start = min_size;
+
+	var i, j;
+	for (j = 0; j < 9; j++) {
+	    for (i = 0; i < 32; i++) {
+		if (value >= current_size && value < current_size + dimm_size) {
+		    value_start = current_size;
+		    value_up = current_size + dimm_size;
+		    value_down = current_size - (i === 0 ? prev_dimm_size : dimm_size);
+		}
+		current_size += dimm_size;
+	    }
+	    prev_dimm_size = dimm_size;
+	    dimm_size = dimm_size*2;
+	}
+
+	return { up: value_up, down: value_down, start: value_start };
+    },
+
+    onSpinUp: function() {
+	var me = this;
+	if (!me.readOnly) {
+	    var res = me.computeUpDown(me.getValue());
+	    me.setValue(Ext.Number.constrain(res.up, me.minValue, me.maxValue));
+	}
+    },
+
+    onSpinDown: function() {
+	var me = this;
+	if (!me.readOnly) {
+	    var res = me.computeUpDown(me.getValue());
+	    me.setValue(Ext.Number.constrain(res.down, me.minValue, me.maxValue));
+	}
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	if (me.hotplug) {
+	    me.minValue = 1024;
+
+	    me.on('blur', function(field) {
+		var value = me.getValue();
+		var res = me.computeUpDown(value);
+		if (value === res.start || value === res.up || value === res.down) {
+		    return;
+		}
+		field.setValue(res.up);
+	    });
+	}
+
+        me.callParent();
+    },
+});
+Ext.define('PVE.form.MultiPCISelector', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveMultiPCISelector',
+
+    emptyText: gettext('No Devices found'),
+
+    mixins: {
+	field: 'Ext.form.field.Field',
+    },
+
+    // will be called after loading finished
+    onLoadCallBack: Ext.emptyFn,
+
+    getValue: function() {
+	let me = this;
+	return me.value ?? [];
+    },
+
+    getSubmitData: function() {
+	let me = this;
+	let res = {};
+	res[me.name] = me.getValue();
+	return res;
+    },
+
+    setValue: function(value) {
+	let me = this;
+
+	value ??= [];
+
+	me.updateSelectedDevices(value);
+
+	return me.mixins.field.setValue.call(me, value);
+    },
+
+    getErrors: function() {
+	let me = this;
+
+	let errorCls = ['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid'];
+
+	if (me.getValue().length < 1) {
+	    let error = gettext("Must choose at least one device");
+	    me.addCls(errorCls);
+	    me.getActionEl()?.dom.setAttribute('data-errorqtip', error);
+
+	    return [error];
+	}
+
+	me.removeCls(errorCls);
+	me.getActionEl()?.dom.setAttribute('data-errorqtip', "");
+
+	return [];
+    },
+
+    viewConfig: {
+	getRowClass: function(record) {
+	    if (record.data.disabled === true) {
+		return 'x-item-disabled';
+	    }
+	    return '';
+	},
+    },
+
+    updateSelectedDevices: function(value = []) {
+	let me = this;
+
+	let recs = [];
+	let store = me.getStore();
+
+	for (const map of value) {
+	    let parsed = PVE.Parser.parsePropertyString(map);
+	    if (parsed.node !== me.nodename) {
+		continue;
+	    }
+
+	    let rec = store.getById(parsed.path);
+	    if (rec) {
+		recs.push(rec);
+	    }
+	}
+
+	me.suspendEvent('change');
+	me.setSelection();
+	me.setSelection(recs);
+	me.resumeEvent('change');
+    },
+
+    setNodename: function(nodename) {
+	let me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.getStore().setProxy({
+	    type: 'proxmox',
+	    url: '/api2/json/nodes/' + me.nodename + '/hardware/pci?pci-class-blacklist=',
+	});
+
+	me.setSelection();
+
+	me.getStore().load({
+	    callback: (recs, op, success) => me.addSlotRecords(recs, op, success),
+	});
+    },
+
+    setMdev: function(mdev) {
+	let me = this;
+	if (mdev) {
+	    me.getStore().addFilter({
+		id: 'mdev-filter',
+		property: 'mdev',
+		value: '1',
+		operator: '=',
+	    });
+	} else {
+	    me.getStore().removeFilter('mdev-filter');
+	}
+	me.setSelection();
+    },
+
+    // adds the virtual 'slot' records (e.g. '0000:01:00') to the store
+    addSlotRecords: function(records, _op, success) {
+	let me = this;
+	if (!success) {
+	    return;
+	}
+
+	let slots = {};
+	records.forEach((rec) => {
+	    let slotname = rec.data.id.slice(0, -2); // remove function
+	    if (slots[slotname] !== undefined) {
+		slots[slotname].count++;
+		rec.set('slot', slots[slotname]);
+		return;
+	    }
+	    slots[slotname] = {
+		count: 1,
+	    };
+
+	    rec.set('slot', slots[slotname]);
+
+	    if (rec.data.id.endsWith('.0')) {
+		slots[slotname].device = rec.data;
+	    }
+	});
+
+	let store = me.getStore();
+
+	for (const [slot, { count, device }] of Object.entries(slots)) {
+	    if (count === 1) {
+		continue;
+	    }
+	    store.add(Ext.apply({}, {
+		id: slot,
+		mdev: undefined,
+		device_name: gettext('Pass through all functions as one device'),
+	    }, device));
+	}
+
+	me.updateSelectedDevices(me.value);
+    },
+
+    selectionChange: function(_grid, selection) {
+	let me = this;
+
+	let ids = {};
+	selection
+	    .filter(rec => rec.data.id.indexOf('.') === -1)
+	    .forEach((rec) => { ids[rec.data.id] = true; });
+
+	let to_disable = [];
+
+	me.getStore().each(rec => {
+	    let id = rec.data.id;
+	    rec.set('disabled', false);
+	    if (id.indexOf('.') === -1) {
+		return;
+	    }
+	    let slot = id.slice(0, -2); // remove function
+
+	    if (ids[slot]) {
+		to_disable.push(rec);
+		rec.set('disabled', true);
+	    }
+	});
+
+	me.suspendEvent('selectionchange');
+	me.getSelectionModel().deselect(to_disable);
+	me.resumeEvent('selectionchange');
+
+	me.value = me.getSelection().map((rec) => {
+	    let res = {
+		path: rec.data.id,
+		node: me.nodename,
+		id: `${rec.data.vendor}:${rec.data.device}`.replace(/0x/g, ''),
+		'subsystem-id': `${rec.data.subsystem_vendor}:${rec.data.subsystem_device}`.replace(/0x/g, ''),
+	    };
+
+	    if (rec.data.iommugroup !== -1) {
+		res.iommugroup = rec.data.iommugroup;
+	    }
+
+	    return PVE.Parser.printPropertyString(res);
+	});
+	me.checkChange();
+    },
+
+    selModel: {
+	type: 'checkboxmodel',
+	mode: 'SIMPLE',
+    },
+
+    columns: [
+	{
+	    header: 'ID',
+	    dataIndex: 'id',
+	    renderer: function(value, _md, rec) {
+		if (value.match(/\.[0-9a-f]/i) && rec.data.slot?.count > 1) {
+		    return `&emsp;${value}`;
+		}
+		return value;
+	    },
+	    width: 150,
+	},
+	{
+	    header: gettext('IOMMU Group'),
+	    dataIndex: 'iommugroup',
+	    renderer: (v, _md, rec) => rec.data.slot === rec.data.id ? '' : v === -1 ? '-' : v,
+	    width: 50,
+	},
+	{
+	    header: gettext('Vendor'),
+	    dataIndex: 'vendor_name',
+	    flex: 3,
+	},
+	{
+	    header: gettext('Device'),
+	    dataIndex: 'device_name',
+	    flex: 6,
+	},
+	{
+	    header: gettext('Mediated Devices'),
+	    dataIndex: 'mdev',
+	    flex: 1,
+	    renderer: function(val) {
+		return Proxmox.Utils.format_boolean(!!val);
+	    },
+	},
+    ],
+
+    listeners: {
+	selectionchange: function() {
+	    this.selectionChange(...arguments);
+	},
+    },
+
+    store: {
+	fields: [
+	    'id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev',
+	    'subsystem_vendor', 'subsystem_device', 'disabled',
+	    {
+		name: 'subsystem-vendor',
+		calculate: function(data) {
+		    return data.subsystem_vendor;
+		},
+	    },
+	    {
+		name: 'subsystem-device',
+		calculate: function(data) {
+		    return data.subsystem_device;
+		},
+	    },
+	],
+	sorters: [
+	    {
+		property: 'id',
+		direction: 'ASC',
+	    },
+	],
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let nodename = me.nodename;
+	me.nodename = undefined;
+
+	me.callParent();
+
+	me.mon(me.getStore(), 'load', me.onLoadCallBack);
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
+
+	me.setNodename(nodename);
+
+	me.initField();
+    },
+});
+Ext.define('PVE.form.NetworkCardSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: 'widget.pveNetworkCardSelector',
+    comboItems: [
+	['e1000', 'Intel E1000'],
+	['e1000e', 'Intel E1000E'],
+	['virtio', 'VirtIO (' + gettext('paravirtualized') + ')'],
+	['rtl8139', 'Realtek RTL8139'],
+	['vmxnet3', 'VMware vmxnet3'],
+    ],
+});
+Ext.define('PVE.form.NodeSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveNodeSelector'],
+
+    // invalidate nodes which are offline
+    onlineValidator: false,
+
+    selectCurNode: false,
+
+    // do not allow those nodes (array)
+    disallowedNodes: undefined,
+
+    // only allow those nodes (array)
+    allowedNodes: undefined,
+
+    valueField: 'node',
+    displayField: 'node',
+    store: {
+	    fields: ['node', 'cpu', 'maxcpu', 'mem', 'maxmem', 'uptime'],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes',
+	    },
+	    sorters: [
+		{
+		    property: 'node',
+		    direction: 'ASC',
+		},
+		{
+		    property: 'mem',
+		    direction: 'DESC',
+		},
+	    ],
+	},
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Node'),
+		dataIndex: 'node',
+		sortable: true,
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Memory usage') + " %",
+		renderer: PVE.Utils.render_mem_usage_percent,
+		sortable: true,
+		width: 100,
+		dataIndex: 'mem',
+	    },
+	    {
+		header: gettext('CPU usage'),
+		renderer: Proxmox.Utils.render_cpu,
+		sortable: true,
+		width: 100,
+		dataIndex: 'cpu',
+	    },
+	],
+    },
+
+    validator: function(value) {
+	let me = this;
+	if (!me.onlineValidator || (me.allowBlank && !value)) {
+	    return true;
+	}
+
+	let offline = [], notAllowed = [];
+	Ext.Array.each(value.split(/\s*,\s*/), function(node) {
+	    let rec = me.store.findRecord(me.valueField, node, 0, false, true, true);
+	    if (!(rec && rec.data) || rec.data.status !== 'online') {
+		offline.push(node);
+	    } else if (me.allowedNodes && !Ext.Array.contains(me.allowedNodes, node)) {
+		notAllowed.push(node);
+	    }
+	});
+
+	if (value && notAllowed.length !== 0) {
+	    return "Node " + notAllowed.join(', ') + " is not allowed for this action!";
+	}
+	if (value && offline.length !== 0) {
+	    return "Node " + offline.join(', ') + " seems to be offline!";
+	}
+	return true;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (me.selectCurNode && PVE.curSelectedNode && PVE.curSelectedNode.data.node) {
+	    me.preferredValue = PVE.curSelectedNode.data.node;
+	}
+
+	me.callParent();
+	me.getStore().load();
+
+	me.getStore().addFilter(new Ext.util.Filter({ // filter out disallowed nodes
+	    filterFn: (item) => !(me.disallowedNodes && me.disallowedNodes.includes(item.data.node)),
+	}));
+
+	me.mon(me.getStore(), 'load', () => me.isValid());
+    },
+});
+Ext.define('PVE.form.NotificationModeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveNotificationModeSelector'],
+    comboItems: [
+	['notification-target', gettext('Target')],
+	['mailto', gettext('E-Mail')],
+    ],
+});
+Ext.define('PVE.form.NotificationTargetSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveNotificationTargetSelector'],
+
+    // set default value to empty array, else it inits it with
+    // null and after the store load it is an empty array,
+    // triggering dirtychange
+    value: [],
+    valueField: 'name',
+    displayField: 'name',
+    deleteEmpty: true,
+    skipEmptyText: true,
+
+    store: {
+	    fields: ['name', 'type', 'comment'],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/cluster/notifications/targets',
+	    },
+	    sorters: [
+		{
+		    property: 'name',
+		    direction: 'ASC',
+		},
+	    ],
+	    autoLoad: true,
+	},
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Target'),
+		dataIndex: 'name',
+		sortable: true,
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Type'),
+		dataIndex: 'type',
+		sortable: true,
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Comment'),
+		dataIndex: 'comment',
+		sortable: true,
+		hideable: false,
+		flex: 2,
+	    },
+	],
+    },
+});
+Ext.define('PVE.form.EmailNotificationSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveEmailNotificationSelector'],
+    comboItems: [
+	['always', gettext('Always')],
+	['failure', gettext('On failure only')],
+    ],
+});
+Ext.define('PVE.form.PCISelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    xtype: 'pvePCISelector',
+
+    store: {
+	fields: ['id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev'],
+	filterOnLoad: true,
+	sorters: [
+	    {
+		property: 'id',
+		direction: 'ASC',
+	    },
+	],
+    },
+
+    autoSelect: false,
+    valueField: 'id',
+    displayField: 'id',
+
+    // can contain a load callback for the store
+    // useful to determine the state of the IOMMU
+    onLoadCallBack: undefined,
+
+    listConfig: {
+	minHeight: 80,
+	width: 800,
+	columns: [
+	    {
+		header: 'ID',
+		dataIndex: 'id',
+		width: 100,
+	    },
+	    {
+		header: gettext('IOMMU Group'),
+		dataIndex: 'iommugroup',
+		renderer: v => v === -1 ? '-' : v,
+		width: 75,
+	    },
+	    {
+		header: gettext('Vendor'),
+		dataIndex: 'vendor_name',
+		flex: 2,
+	    },
+	    {
+		header: gettext('Device'),
+		dataIndex: 'device_name',
+		flex: 6,
+	    },
+	    {
+		header: gettext('Mediated Devices'),
+		dataIndex: 'mdev',
+		flex: 1,
+		renderer: function(val) {
+		    return Proxmox.Utils.format_boolean(!!val);
+		},
+	    },
+	],
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: '/api2/json/nodes/' + me.nodename + '/hardware/pci',
+	});
+
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename;
+	me.nodename = undefined;
+
+        me.callParent();
+
+	if (me.onLoadCallBack !== undefined) {
+	    me.mon(me.getStore(), 'load', me.onLoadCallBack);
+	}
+
+	me.setNodename(nodename);
+    },
+});
+
+Ext.define('pve-mapped-pci-model', {
+    extend: 'Ext.data.Model',
+
+    fields: ['id', 'path', 'vendor', 'device', 'iommugroup', 'mdev', 'description', 'map'],
+    idProperty: 'id',
+});
+
+Ext.define('PVE.form.PCIMapSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    xtype: 'pvePCIMapSelector',
+
+    store: {
+	model: 'pve-mapped-pci-model',
+	filterOnLoad: true,
+	sorters: [
+	    {
+		property: 'id',
+		direction: 'ASC',
+	    },
+	],
+    },
+
+    autoSelect: false,
+    valueField: 'id',
+    displayField: 'id',
+
+    // can contain a load callback for the store
+    // useful to determine the state of the IOMMU
+    onLoadCallBack: undefined,
+
+    listConfig: {
+	width: 800,
+	columns: [
+	    {
+		header: gettext('ID'),
+		dataIndex: 'id',
+		flex: 1,
+	    },
+	    {
+		header: gettext('Description'),
+		dataIndex: 'description',
+		flex: 1,
+		renderer: Ext.String.htmlEncode,
+	    },
+	    {
+		header: gettext('Status'),
+		dataIndex: 'checks',
+		renderer: function(value) {
+		    let me = this;
+
+		    if (!Ext.isArray(value) || !value?.length) {
+			return `<i class="fa fa-check-circle good"></i> ${gettext('Mapping matches host data')}`;
+		    }
+
+		    let checks = [];
+
+		    value.forEach((check) => {
+			let iconCls;
+			switch (check?.severity) {
+			    case 'warning':
+				iconCls = 'fa-exclamation-circle warning';
+				break;
+			    case 'error':
+				iconCls = 'fa-times-circle critical';
+				break;
+			}
+
+			let message = check?.message;
+			let icon = `<i class="fa ${iconCls}"></i>`;
+			if (iconCls !== undefined) {
+			    checks.push(`${icon} ${message}`);
+			}
+		    });
+
+		    return checks.join('<br>');
+		},
+		flex: 3,
+	    },
+	],
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: `/api2/json/cluster/mapping/pci?check-node=${nodename}`,
+	});
+
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename;
+	me.nodename = undefined;
+
+        me.callParent();
+
+	if (me.onLoadCallBack !== undefined) {
+	    me.mon(me.getStore(), 'load', me.onLoadCallBack);
+	}
+
+	me.setNodename(nodename);
+    },
+});
+Ext.define('PVE.form.PermPathSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    xtype: 'pvePermPathSelector',
+
+    valueField: 'value',
+    displayField: 'value',
+    typeAhead: true,
+    queryMode: 'local',
+    width: 380,
+
+    store: {
+	type: 'pvePermPath',
+    },
+});
+Ext.define('PVE.form.PoolSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pvePoolSelector'],
+
+    allowBlank: false,
+    valueField: 'poolid',
+    displayField: 'poolid',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-pools',
+	    sorters: 'poolid',
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    autoSelect: false,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('Pool'),
+			sortable: true,
+			dataIndex: 'poolid',
+			flex: 1,
+		    },
+		    {
+			header: gettext('Comment'),
+			sortable: false,
+			dataIndex: 'comment',
+			renderer: Ext.String.htmlEncode,
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+
+}, function() {
+    Ext.define('pve-pools', {
+	extend: 'Ext.data.Model',
+	fields: ['poolid', 'comment'],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/pools",
+	},
+	idProperty: 'poolid',
+    });
+});
+Ext.define('PVE.form.preallocationSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pvePreallocationSelector'],
+    comboItems: [
+	['__default__', Proxmox.Utils.defaultText],
+	['off', 'Off'],
+	['metadata', 'Metadata'],
+	['falloc', 'Full (posix_fallocate)'],
+	['full', 'Full'],
+    ],
+});
+Ext.define('PVE.form.PrivilegesSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    xtype: 'pvePrivilegesSelector',
+
+    multiSelect: true,
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	Proxmox.Utils.API2Request({
+	    url: '/access/roles/Administrator',
+	    method: 'GET',
+	    success: function(response, options) {
+		let data = Object.keys(response.result.data).map(key => [key, key]);
+
+		me.store.setData(data);
+
+		me.store.sort({
+		    property: 'key',
+		    direction: 'ASC',
+		});
+	    },
+	    failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	});
+    },
+});
+Ext.define('PVE.form.QemuBiosSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveQemuBiosSelector'],
+
+    initComponent: function() {
+	var me = this;
+
+        me.comboItems = [
+	    ['__default__', PVE.Utils.render_qemu_bios('')],
+	    ['seabios', PVE.Utils.render_qemu_bios('seabios')],
+	    ['ovmf', PVE.Utils.render_qemu_bios('ovmf')],
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.form.SDNControllerSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveSDNControllerSelector'],
+
+    allowBlank: false,
+    valueField: 'controller',
+    displayField: 'controller',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-sdn-controller',
+            sorters: {
+                property: 'controller',
+                direction: 'ASC',
+            },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    autoSelect: false,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('Controller'),
+			sortable: true,
+			dataIndex: 'controller',
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+
+}, function() {
+    Ext.define('pve-sdn-controller', {
+	extend: 'Ext.data.Model',
+	fields: ['controller'],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/cluster/sdn/controllers",
+	},
+	idProperty: 'controller',
+    });
+});
+Ext.define('PVE.form.SDNZoneSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveSDNZoneSelector'],
+
+    allowBlank: false,
+    valueField: 'zone',
+    displayField: 'zone',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-sdn-zone',
+            sorters: {
+                property: 'zone',
+                direction: 'ASC',
+            },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    autoSelect: false,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('Zone'),
+			sortable: true,
+			dataIndex: 'zone',
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+
+}, function() {
+    Ext.define('pve-sdn-zone', {
+	extend: 'Ext.data.Model',
+	fields: ['zone'],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/cluster/sdn/zones",
+	},
+	idProperty: 'zone',
+    });
+});
+Ext.define('PVE.form.SDNVnetSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveSDNVnetSelector'],
+
+    allowBlank: false,
+    valueField: 'vnet',
+    displayField: 'vnet',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-sdn-vnet',
+            sorters: {
+                property: 'vnet',
+                direction: 'ASC',
+            },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    autoSelect: false,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('VNet'),
+			sortable: true,
+			dataIndex: 'vnet',
+			flex: 1,
+		    },
+		    {
+			header: gettext('Alias'),
+			flex: 1,
+			dataIndex: 'alias',
+		    },
+		    {
+			header: gettext('Tag'),
+			flex: 1,
+			dataIndex: 'tag',
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+
+}, function() {
+    Ext.define('pve-sdn-vnet', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'alias',
+	    'tag',
+	    'type',
+	    'vnet',
+	    'zone',
+	],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/cluster/sdn/vnets",
+	},
+	idProperty: 'vnet',
+    });
+});
+Ext.define('PVE.form.SDNIpamSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveSDNIpamSelector'],
+
+    allowBlank: false,
+    valueField: 'ipam',
+    displayField: 'ipam',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-sdn-ipam',
+            sorters: {
+                property: 'ipam',
+                direction: 'ASC',
+            },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    autoSelect: false,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('Ipam'),
+			sortable: true,
+			dataIndex: 'ipam',
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+
+}, function() {
+    Ext.define('pve-sdn-ipam', {
+	extend: 'Ext.data.Model',
+	fields: ['ipam'],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/cluster/sdn/ipams",
+	},
+	idProperty: 'ipam',
+    });
+});
+Ext.define('PVE.form.SDNDnsSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveSDNDnsSelector'],
+
+    allowBlank: false,
+    valueField: 'dns',
+    displayField: 'dns',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-sdn-dns',
+            sorters: {
+                property: 'dns',
+                direction: 'ASC',
+            },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    autoSelect: false,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('dns'),
+			sortable: true,
+			dataIndex: 'dns',
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	store.load();
+    },
+
+}, function() {
+    Ext.define('pve-sdn-dns', {
+	extend: 'Ext.data.Model',
+	fields: ['dns'],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/cluster/sdn/dns",
+	},
+	idProperty: 'dns',
+    });
+});
+Ext.define('PVE.form.ScsiHwSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveScsiHwSelector'],
+    comboItems: [
+	['__default__', PVE.Utils.render_scsihw('')],
+	['lsi', PVE.Utils.render_scsihw('lsi')],
+	['lsi53c810', PVE.Utils.render_scsihw('lsi53c810')],
+	['megasas', PVE.Utils.render_scsihw('megasas')],
+	['virtio-scsi-pci', PVE.Utils.render_scsihw('virtio-scsi-pci')],
+	['virtio-scsi-single', PVE.Utils.render_scsihw('virtio-scsi-single')],
+	['pvscsi', PVE.Utils.render_scsihw('pvscsi')],
+    ],
+});
+Ext.define('PVE.form.SecurityGroupsSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveSecurityGroupsSelector'],
+
+    valueField: 'group',
+    displayField: 'group',
+    initComponent: function() {
+	var me = this;
+
+	var store = Ext.create('Ext.data.Store', {
+	    autoLoad: true,
+	    fields: ['group', 'comment'],
+	    idProperty: 'group',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/firewall/groups",
+	    },
+	    sorters: {
+		property: 'group',
+		direction: 'ASC',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('Security Group'),
+			dataIndex: 'group',
+			hideable: false,
+			width: 100,
+		    },
+		    {
+			header: gettext('Comment'),
+			dataIndex: 'comment',
+			renderer: Ext.String.htmlEncode,
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.form.SnapshotSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.PVE.form.SnapshotSelector'],
+
+    valueField: 'name',
+    displayField: 'name',
+
+    loadStore: function(nodename, vmid) {
+	var me = this;
+
+	if (!nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+        if (!vmid) {
+	    return;
+        }
+
+	me.vmid = vmid;
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: '/api2/json/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid +'/snapshot',
+	});
+
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+        if (!me.nodename) {
+            throw "no node name specified";
+        }
+
+        if (!me.vmid) {
+            throw "no VM ID specified";
+        }
+
+	if (!me.guestType) {
+	    throw "no guest type specified";
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    fields: ['name'],
+	    filterOnLoad: true,
+	});
+
+	Ext.apply(me, {
+	    store: store,
+            listConfig: {
+		columns: [
+		    {
+			header: gettext('Snapshot'),
+			dataIndex: 'name',
+			hideable: false,
+			flex: 1,
+		    },
+		],
+	    },
+	});
+
+        me.callParent();
+
+	me.loadStore(me.nodename, me.vmid);
+    },
+});
+Ext.define('PVE.form.SpiceEnhancementSelector', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveSpiceEnhancementSelector',
+
+    viewModel: {},
+
+    items: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    itemId: 'foldersharing',
+	    name: 'foldersharing',
+	    reference: 'foldersharing',
+	    fieldLabel: 'Folder Sharing',
+	    uncheckedValue: 0,
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    itemId: 'videostreaming',
+	    name: 'videostreaming',
+	    value: 'off',
+	    fieldLabel: 'Video Streaming',
+	    comboItems: [
+		['off', 'off'],
+		['all', 'all'],
+		['filter', 'filter'],
+	    ],
+	},
+	{
+	    xtype: 'displayfield',
+	    itemId: 'spicehint',
+	    userCls: 'pmx-hint',
+	    value: gettext('To use these features set the display to SPICE in the hardware settings of the VM.'),
+	    hidden: true,
+	},
+	{
+	    xtype: 'displayfield',
+	    itemId: 'spicefolderhint',
+	    userCls: 'pmx-hint',
+	    value: gettext('Make sure the SPICE WebDav daemon is installed in the VM.'),
+	    bind: {
+		hidden: '{!foldersharing.checked}',
+	    },
+	},
+    ],
+
+    onGetValues: function(values) {
+	var ret = {};
+
+	if (values.videostreaming !== "off") {
+	    ret.videostreaming = values.videostreaming;
+	}
+	if (values.foldersharing) {
+	    ret.foldersharing = 1;
+	}
+	if (Ext.Object.isEmpty(ret)) {
+	    return { 'delete': 'spice_enhancements' };
+	}
+	var enhancements = PVE.Parser.printPropertyString(ret);
+	return { spice_enhancements: enhancements };
+    },
+
+    setValues: function(values) {
+	var vga = PVE.Parser.parsePropertyString(values.vga, 'type');
+	if (!/^qxl\d?$/.test(vga.type)) {
+	    this.down('#spicehint').setVisible(true);
+	}
+	if (values.spice_enhancements) {
+	    var enhancements = PVE.Parser.parsePropertyString(values.spice_enhancements);
+	    enhancements.foldersharing = PVE.Parser.parseBoolean(enhancements.foldersharing, 0);
+	    this.callParent([enhancements]);
+	}
+    },
+});
+Ext.define('PVE.form.StorageScanNodeSelector', {
+    extend: 'PVE.form.NodeSelector',
+    xtype: 'pveStorageScanNodeSelector',
+
+    name: 'storageScanNode',
+    itemId: 'pveStorageScanNodeSelector',
+    fieldLabel: gettext('Scan node'),
+    allowBlank: true,
+    disallowedNodes: undefined,
+    autoSelect: false,
+    submitValue: false,
+    value: null,
+    autoEl: {
+	tag: 'div',
+	'data-qtip': gettext('Scan for available storages on the selected node'),
+    },
+    triggers: {
+	clear: {
+	    handler: function() {
+		let me = this;
+		me.setValue(null);
+	    },
+	},
+    },
+
+    emptyText: Proxmox.NodeName,
+
+    setValue: function(value) {
+	let me = this;
+	me.callParent([value]);
+	me.triggers.clear.setVisible(!!value);
+    },
+});
+Ext.define('PVE.form.StorageSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pveStorageSelector',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: {
+	clusterView: false,
+    },
+
+    allowBlank: false,
+    valueField: 'storage',
+    displayField: 'storage',
+    listConfig: {
+	cbind: {
+	    clusterView: '{clusterView}',
+	},
+	width: 450,
+	columns: [
+	    {
+		header: gettext('Name'),
+		dataIndex: 'storage',
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Type'),
+		width: 75,
+		dataIndex: 'type',
+	    },
+	    {
+		header: gettext('Avail'),
+		width: 90,
+		dataIndex: 'avail',
+		renderer: Proxmox.Utils.format_size,
+		cbind: {
+		    hidden: '{clusterView}',
+		},
+	    },
+	    {
+		header: gettext('Capacity'),
+		width: 90,
+		dataIndex: 'total',
+		renderer: Proxmox.Utils.format_size,
+		cbind: {
+		    hidden: '{clusterView}',
+		},
+	    },
+	    {
+		header: gettext('Nodes'),
+		width: 120,
+		dataIndex: 'nodes',
+		renderer: (value) => value ? value : '-- ' + gettext('All') + ' --',
+		cbind: {
+		    hidden: '{!clusterView}',
+		},
+	    },
+	    {
+		header: gettext('Shared'),
+		width: 70,
+		dataIndex: 'shared',
+		renderer: Proxmox.Utils.format_boolean,
+		cbind: {
+		    hidden: '{!clusterView}',
+		},
+	    },
+	],
+    },
+
+    reloadStorageList: function() {
+	let me = this;
+
+	if (me.clusterView) {
+	    me.getStore().setProxy({
+		type: 'proxmox',
+		url: `/api2/json/storage`,
+	    });
+
+	    // filter here, back-end does not support it currently
+	    let filters = [(storage) => !storage.data.disable];
+
+	    if (me.storageContent) {
+		filters.push(
+		    (storage) => storage.data.content.split(',').includes(me.storageContent),
+		);
+	    }
+
+	    if (me.nodename) {
+		filters.push(
+		    (storage) => !storage.data.nodes || storage.data.nodes.includes(me.nodename),
+		);
+	    }
+
+	    me.getStore().clearFilter();
+	    me.getStore().setFilters(filters);
+	} else {
+	    if (!me.nodename) {
+		return;
+	    }
+
+	    let params = {
+		format: 1,
+	    };
+	    if (me.storageContent) {
+		params.content = me.storageContent;
+	    }
+	    if (me.targetNode) {
+		params.target = me.targetNode;
+		params.enabled = 1; // skip disabled storages
+	    }
+	    me.store.setProxy({
+		type: 'proxmox',
+		url: `/api2/json/nodes/${me.nodename}/storage`,
+		extraParams: params,
+	    });
+	}
+
+	me.store.load(() => me.validate());
+    },
+
+    setTargetNode: function(targetNode) {
+	var me = this;
+
+	if (!targetNode || me.targetNode === targetNode) {
+	    return;
+	}
+
+	if (me.clusterView) {
+	    throw "setting targetNode with clusterView is not implemented";
+	}
+
+	me.targetNode = targetNode;
+
+	me.reloadStorageList();
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	nodename = nodename || '';
+
+	if (me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.reloadStorageList();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	let nodename = me.nodename;
+	me.nodename = undefined;
+
+	var store = Ext.create('Ext.data.Store', {
+	    model: 'pve-storage-status',
+	    sorters: {
+		property: 'storage',
+		direction: 'ASC',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+
+	me.setNodename(nodename);
+    },
+}, function() {
+    Ext.define('pve-storage-status', {
+	extend: 'Ext.data.Model',
+	fields: ['storage', 'active', 'type', 'avail', 'total', 'nodes', 'shared'],
+	idProperty: 'storage',
+    });
+});
+Ext.define('PVE.form.TFASelector', {
+    extend: 'Ext.container.Container',
+    xtype: 'pveTFASelector',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    deleteEmpty: true,
+
+    viewModel: {
+	data: {
+	    type: '__default__',
+	    step: null,
+	    digits: null,
+	    id: null,
+	    key: null,
+	    url: null,
+	},
+
+	formulas: {
+	    isOath: (get) => get('type') === 'oath',
+	    isYubico: (get) => get('type') === 'yubico',
+	    tfavalue: {
+		get: function(get) {
+		    let val = {
+			type: get('type'),
+		    };
+		    if (get('isOath')) {
+			let step = get('step');
+			let digits = get('digits');
+			if (step) {
+			    val.step = step;
+			}
+			if (digits) {
+			    val.digits = digits;
+			}
+		    } else if (get('isYubico')) {
+			let id = get('id');
+			let key = get('key');
+			let url = get('url');
+			val.id = id;
+			val.key = key;
+			if (url) {
+			    val.url = url;
+			}
+		    } else if (val.type === '__default__') {
+			return "";
+		    }
+
+		    return PVE.Parser.printPropertyString(val);
+		},
+		set: function(value) {
+		    let val = PVE.Parser.parseTfaConfig(value);
+		    this.set(val);
+		    this.notify();
+		    // we need to reset the original values, so that
+		    // we can reliably track the state of the form
+		    let form = this.getView().up('form');
+		    if (form.trackResetOnLoad) {
+			let fields = this.getView().query('field[name!="tfa"]');
+			fields.forEach((field) => field.resetOriginalValue());
+		    }
+		},
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'tfa',
+	    hidden: true,
+	    submitValue: true,
+	    cbind: {
+		deleteEmpty: '{deleteEmpty}',
+	    },
+	    bind: {
+		value: "{tfavalue}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    value: '__default__',
+	    deleteEmpty: false,
+	    submitValue: false,
+	    fieldLabel: gettext('Require TFA'),
+	    comboItems: [
+		['__default__', Proxmox.Utils.noneText],
+		['oath', 'OATH/TOTP'],
+		['yubico', 'Yubico'],
+	    ],
+	    bind: {
+		value: "{type}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    hidden: true,
+	    minValue: 10,
+	    submitValue: false,
+	    emptyText: Proxmox.Utils.defaultText + ' (30)',
+	    fieldLabel: gettext('Time Step'),
+	    bind: {
+		value: "{step}",
+		hidden: "{!isOath}",
+		disabled: "{!isOath}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    hidden: true,
+	    submitValue: false,
+	    fieldLabel: gettext('Secret Length'),
+	    minValue: 6,
+	    maxValue: 8,
+	    emptyText: Proxmox.Utils.defaultText + ' (6)',
+	    bind: {
+		value: "{digits}",
+		hidden: "{!isOath}",
+		disabled: "{!isOath}",
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    hidden: true,
+	    submitValue: false,
+	    allowBlank: false,
+	    fieldLabel: 'Yubico API Id',
+	    bind: {
+		value: "{id}",
+		hidden: "{!isYubico}",
+		disabled: "{!isYubico}",
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    hidden: true,
+	    submitValue: false,
+	    allowBlank: false,
+	    fieldLabel: 'Yubico API Key',
+	    bind: {
+		value: "{key}",
+		hidden: "{!isYubico}",
+		disabled: "{!isYubico}",
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    hidden: true,
+	    submitValue: false,
+	    fieldLabel: 'Yubico URL',
+	    bind: {
+		value: "{url}",
+		hidden: "{!isYubico}",
+		disabled: "{!isYubico}",
+	    },
+	},
+    ],
+});
+Ext.define('PVE.form.TokenSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveTokenSelector'],
+
+    allowBlank: false,
+    autoSelect: false,
+    displayField: 'id',
+
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+
+    store: {
+	model: 'pve-tokens',
+	autoLoad: true,
+	proxy: {
+	    type: 'proxmox',
+	    url: 'api2/json/access/users',
+	    extraParams: { 'full': 1 },
+	},
+	sorters: 'id',
+	listeners: {
+	    load: function(store, records, success) {
+		let tokens = [];
+		for (const { data: user } of records) {
+		    if (!user.tokens || user.tokens.length === 0) {
+			continue;
+		    }
+		    for (const token of user.tokens) {
+			tokens.push({
+			    id: `${user.userid}!${token.tokenid}`,
+			    comment: token.comment,
+			});
+		    }
+		}
+		store.loadData(tokens);
+	    },
+	},
+    },
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('API Token'),
+		sortable: true,
+		dataIndex: 'id',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Comment'),
+		sortable: false,
+		dataIndex: 'comment',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	],
+    },
+}, function() {
+    Ext.define('pve-tokens', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'id', 'userid', 'tokenid', 'comment',
+	    { type: 'boolean', name: 'privsep' },
+	    { type: 'date', dateFormat: 'timestamp', name: 'expire' },
+	],
+	idProperty: 'id',
+    });
+});
+Ext.define('PVE.form.USBSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveUSBSelector'],
+
+    allowBlank: false,
+    autoSelect: false,
+    anyMatch: true,
+    displayField: 'product_and_id',
+    valueField: 'usbid',
+    editable: true,
+
+    validator: function(value) {
+	var me = this;
+	if (!value) {
+	    return true; // handled later by allowEmpty in the getErrors call chain
+	}
+	value = me.getValue(); // as the valueField is not the displayfield
+	if (me.type === 'device') {
+	    return (/^[a-f0-9]{4}:[a-f0-9]{4}$/i).test(value);
+	} else if (me.type === 'port') {
+	    return (/^[0-9]+-[0-9]+(\.[0-9]+)*$/).test(value);
+	}
+	return gettext("Invalid Value");
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: `/api2/json/nodes/${me.nodename}/hardware/usb`,
+	});
+
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (me.pveSelNode) {
+	    me.nodename = me.pveSelNode.data.node;
+	}
+
+	var nodename = me.nodename;
+	me.nodename = undefined;
+
+	if (me.type !== 'device' && me.type !== 'port') {
+	    throw "no valid type specified";
+	}
+
+	let store = new Ext.data.Store({
+	    model: `pve-usb-${me.type}`,
+	    filters: [
+		({ data }) => !!data.usbpath && !!data.prodid && String(data.class) !== "9",
+	    ],
+	});
+	let emptyText = '';
+	if (me.type === 'device') {
+	    emptyText = gettext('Passthrough a specific device');
+	} else {
+	    emptyText = gettext('Passthrough a full port');
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    emptyText: emptyText,
+	    listConfig: {
+		minHeight: 80,
+		width: 520,
+		columns: [
+		    {
+			header: me.type === 'device'?gettext('Device'):gettext('Port'),
+			sortable: true,
+			dataIndex: 'usbid',
+			width: 80,
+		    },
+		    {
+			header: gettext('Manufacturer'),
+			sortable: true,
+			dataIndex: 'manufacturer',
+			width: 150,
+		    },
+		    {
+			header: gettext('Product'),
+			sortable: true,
+			dataIndex: 'product',
+			flex: 1,
+		    },
+		    {
+			header: gettext('Speed'),
+			width: 75,
+			sortable: true,
+			dataIndex: 'speed',
+			renderer: function(value) {
+			    let speed2Class = {
+				"10000": "USB 3.1",
+				"5000": "USB 3.0",
+				"480": "USB 2.0",
+				"12": "USB 1.x",
+				"1.5": "USB 1.x",
+			    };
+			    return speed2Class[value] || value + " Mbps";
+			},
+		    },
+		],
+	    },
+	});
+
+	me.callParent();
+
+	me.setNodename(nodename);
+    },
+
+}, function() {
+    Ext.define('pve-usb-device', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    {
+		name: 'usbid',
+		convert: function(val, data) {
+		    if (val) {
+			return val;
+		    }
+		    return data.get('vendid') + ':' + data.get('prodid');
+		},
+	    },
+	    'speed', 'product', 'manufacturer', 'vendid', 'prodid', 'usbpath',
+	    { name: 'port', type: 'number' },
+	    { name: 'level', type: 'number' },
+	    { name: 'class', type: 'number' },
+	    { name: 'devnum', type: 'number' },
+	    { name: 'busnum', type: 'number' },
+	    {
+		name: 'product_and_id',
+		type: 'string',
+		convert: (v, rec) => {
+		    let res = rec.data.product || gettext('Unknown');
+		    res += " (" + rec.data.usbid + ")";
+		    return res;
+		},
+	    },
+	],
+    });
+
+    Ext.define('pve-usb-port', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    {
+		name: 'usbid',
+		convert: function(val, data) {
+		    if (val) {
+			return val;
+		    }
+		    return data.get('busnum') + '-' + data.get('usbpath');
+		},
+	    },
+	    'speed', 'product', 'manufacturer', 'vendid', 'prodid', 'usbpath',
+	    { name: 'port', type: 'number' },
+	    { name: 'level', type: 'number' },
+	    { name: 'class', type: 'number' },
+	    { name: 'devnum', type: 'number' },
+	    { name: 'busnum', type: 'number' },
+	    {
+		name: 'product_and_id',
+		type: 'string',
+		convert: (v, rec) => {
+		    let res = rec.data.product || gettext('Unplugged');
+		    res += " (" + rec.data.usbid + ")";
+		    return res;
+		},
+	    },
+	],
+    });
+});
+Ext.define('PVE.form.USBMapSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pveUSBMapSelector',
+
+    store: {
+	fields: ['name', 'vendor', 'device', 'path'],
+	filterOnLoad: true,
+	sorters: [
+	    {
+		property: 'name',
+		direction: 'ASC',
+	    },
+	],
+    },
+
+    allowBlank: false,
+    autoSelect: false,
+    displayField: 'id',
+    valueField: 'id',
+
+    listConfig: {
+	width: 800,
+	columns: [
+	    {
+		header: gettext('Name'),
+		dataIndex: 'id',
+		flex: 1,
+	    },
+	    {
+		header: gettext('Status'),
+		dataIndex: 'errors',
+		flex: 2,
+		renderer: function(value) {
+		    let me = this;
+
+		    if (!Ext.isArray(value) || !value?.length) {
+			return `<i class="fa fa-check-circle good"></i> ${gettext('Mapping matches host data')}`;
+		    }
+
+		    let errors = [];
+
+		    value.forEach((error) => {
+			let iconCls;
+			switch (error?.severity) {
+			    case 'warning':
+				iconCls = 'fa-exclamation-circle warning';
+				break;
+			    case 'error':
+				iconCls = 'fa-times-circle critical';
+				break;
+			}
+
+			let message = error?.message;
+			let icon = `<i class="fa ${iconCls}"></i>`;
+			if (iconCls !== undefined) {
+			    errors.push(`${icon} ${message}`);
+			}
+		    });
+
+		    return errors.join('<br>');
+		},
+	    },
+	    {
+		header: gettext('Comment'),
+		dataIndex: 'description',
+		flex: 1,
+		renderer: Ext.String.htmlEncode,
+	    },
+	],
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+
+	me.nodename = nodename;
+
+	me.store.setProxy({
+	    type: 'proxmox',
+	    url: `/api2/json/cluster/mapping/usb?check-node=${nodename}`,
+	});
+
+	me.store.load();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename;
+	me.nodename = undefined;
+
+        me.callParent();
+
+	me.setNodename(nodename);
+    },
+});
+Ext.define('pmx-users', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'userid', 'firstname', 'lastname', 'email', 'comment',
+	{ type: 'boolean', name: 'enable' },
+	{ type: 'date', dateFormat: 'timestamp', name: 'expire' },
+    ],
+    proxy: {
+	type: 'proxmox',
+	url: "/api2/json/access/users?full=1",
+    },
+    idProperty: 'userid',
+});
+Ext.define('PVE.form.VlanField', {
+    extend: 'Ext.form.field.Number',
+    alias: ['widget.pveVlanField'],
+
+    deleteEmpty: false,
+
+    emptyText: gettext('no VLAN'),
+
+    fieldLabel: gettext('VLAN Tag'),
+
+    allowBlank: true,
+
+    getSubmitData: function() {
+        var me = this,
+            data = null,
+            val;
+        if (!me.disabled && me.submitValue) {
+            val = me.getSubmitValue();
+            if (val) {
+                data = {};
+                data[me.getName()] = val;
+            } else if (me.deleteEmpty) {
+		data = {};
+                data.delete = me.getName();
+	    }
+        }
+        return data;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.apply(me, {
+	    minValue: 1,
+	    maxValue: 4094,
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.form.VMCPUFlagSelector', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.vmcpuflagselector',
+
+    mixins: {
+	field: 'Ext.form.field.Field',
+    },
+
+    disableSelection: true,
+    columnLines: false,
+    selectable: false,
+    hideHeaders: true,
+
+    scrollable: 'y',
+    height: 200,
+
+    unkownFlags: [],
+
+    store: {
+	type: 'store',
+	fields: ['flag', { name: 'state', defaultValue: '=' }, 'desc'],
+	data: [
+	    // FIXME: let qemu-server host this and autogenerate or get from API call??
+	    { flag: 'md-clear', desc: 'Required to let the guest OS know if MDS is mitigated correctly' },
+	    { flag: 'pcid', desc: 'Meltdown fix cost reduction on Westmere, Sandy-, and IvyBridge Intel CPUs' },
+	    { flag: 'spec-ctrl', desc: 'Allows improved Spectre mitigation with Intel CPUs' },
+	    { flag: 'ssbd', desc: 'Protection for "Speculative Store Bypass" for Intel models' },
+	    { flag: 'ibpb', desc: 'Allows improved Spectre mitigation with AMD CPUs' },
+	    { flag: 'virt-ssbd', desc: 'Basis for "Speculative Store Bypass" protection for AMD models' },
+	    { flag: 'amd-ssbd', desc: 'Improves Spectre mitigation performance with AMD CPUs, best used with "virt-ssbd"' },
+	    { flag: 'amd-no-ssb', desc: 'Notifies guest OS that host is not vulnerable for Spectre on AMD CPUs' },
+	    { flag: 'pdpe1gb', desc: 'Allow guest OS to use 1GB size pages, if host HW supports it' },
+	    { flag: 'hv-tlbflush', desc: 'Improve performance in overcommitted Windows guests. May lead to guest bluescreens on old CPUs.' },
+	    { flag: 'hv-evmcs', desc: 'Improve performance for nested virtualization. Only supported on Intel CPUs.' },
+	    { flag: 'aes', desc: 'Activate AES instruction set for HW acceleration.' },
+	],
+	listeners: {
+	    update: function() {
+		this.commitChanges();
+	    },
+	},
+    },
+
+    getValue: function() {
+	var me = this;
+	var store = me.getStore();
+	var flags = '';
+
+	// ExtJS does not has a nice getAllRecords interface for stores :/
+	store.queryBy(Ext.returnTrue).each(function(rec) {
+	    var s = rec.get('state');
+	    if (s && s !== '=') {
+		var f = rec.get('flag');
+		if (flags === '') {
+		    flags = s + f;
+		} else {
+		    flags += ';' + s + f;
+		}
+	    }
+	});
+
+	flags += me.unkownFlags.join(';');
+
+	return flags;
+    },
+
+    setValue: function(value) {
+	var me = this;
+	var store = me.getStore();
+
+	me.value = value || '';
+
+	me.unkownFlags = [];
+
+	me.getStore().queryBy(Ext.returnTrue).each(function(rec) {
+	    rec.set('state', '=');
+	});
+
+	var flags = value ? value.split(';') : [];
+	flags.forEach(function(flag) {
+	    var sign = flag.substr(0, 1);
+	    flag = flag.substr(1);
+
+	    var rec = store.findRecord('flag', flag, 0, false, true, true);
+	    if (rec !== null) {
+		rec.set('state', sign);
+	    } else {
+		me.unkownFlags.push(flag);
+	    }
+	});
+	store.reload();
+
+	var res = me.mixins.field.setValue.call(me, value);
+
+	return res;
+    },
+    columns: [
+	{
+	    dataIndex: 'state',
+	    renderer: function(v) {
+		switch (v) {
+		    case '=': return 'Default';
+		    case '-': return 'Off';
+		    case '+': return 'On';
+		    default: return 'Unknown';
+		}
+	    },
+	    width: 65,
+	},
+	{
+	    xtype: 'widgetcolumn',
+	    dataIndex: 'state',
+	    width: 95,
+	    onWidgetAttach: function(column, widget, record) {
+		var val = record.get('state') || '=';
+		widget.down('[inputValue=' + val + ']').setValue(true);
+		// TODO: disable if selected CPU model and flag are incompatible
+	    },
+	    widget: {
+		xtype: 'radiogroup',
+		hideLabel: true,
+		layout: 'hbox',
+		validateOnChange: false,
+		value: '=',
+		listeners: {
+		    change: function(f, value) {
+			var v = Object.values(value)[0];
+			f.getWidgetRecord().set('state', v);
+
+			var view = this.up('grid');
+			view.dirty = view.getValue() !== view.originalValue;
+			view.checkDirty();
+			//view.checkChange();
+		    },
+		},
+		items: [
+		    {
+			boxLabel: '-',
+			boxLabelAlign: 'before',
+			inputValue: '-',
+			isFormField: false,
+		    },
+		    {
+			checked: true,
+			inputValue: '=',
+			isFormField: false,
+		    },
+		    {
+			boxLabel: '+',
+			inputValue: '+',
+			isFormField: false,
+		    },
+		],
+	    },
+	},
+	{
+	    dataIndex: 'flag',
+	    width: 100,
+	},
+	{
+	    dataIndex: 'desc',
+	    cellWrap: true,
+	    flex: 1,
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	// static class store, thus gets not recreated, so ensure defaults are set!
+	me.getStore().data.forEach(function(v) {
+	    v.state = '=';
+	});
+
+	me.value = me.originalValue = '';
+
+	me.callParent(arguments);
+    },
+});
+/* filter is a javascript builtin, but extjs calls it also filter */
+Ext.define('PVE.form.VMSelector', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.vmselector',
+
+    mixins: {
+	field: 'Ext.form.field.Field',
+    },
+
+    allowBlank: true,
+    selectAll: false,
+    isFormField: true,
+
+    plugins: 'gridfilters',
+
+    store: {
+	model: 'PVEResources',
+	sorters: 'vmid',
+    },
+
+    userCls: 'proxmox-tags-circle',
+
+    columnsDeclaration: [
+	{
+	    header: 'ID',
+	    dataIndex: 'vmid',
+	    width: 80,
+	    filter: {
+		type: 'number',
+	    },
+	},
+	{
+	    header: gettext('Node'),
+	    dataIndex: 'node',
+	},
+	{
+	    header: gettext('Status'),
+	    dataIndex: 'status',
+	    filter: {
+		type: 'list',
+	    },
+	},
+	{
+	    header: gettext('Name'),
+	    dataIndex: 'name',
+	    flex: 1,
+	    filter: {
+		type: 'string',
+	    },
+	},
+	{
+	    header: gettext('Pool'),
+	    dataIndex: 'pool',
+	    filter: {
+		type: 'list',
+	    },
+	},
+	{
+	    header: gettext('Type'),
+	    dataIndex: 'type',
+	    width: 120,
+	    renderer: function(value) {
+		if (value === 'qemu') {
+		    return gettext('Virtual Machine');
+		} else if (value === 'lxc') {
+		    return gettext('LXC Container');
+		}
+
+		return '';
+	    },
+	    filter: {
+		type: 'list',
+		store: {
+		    data: [
+			{ id: 'qemu', text: gettext('Virtual Machine') },
+			{ id: 'lxc', text: gettext('LXC Container') },
+		    ],
+		    un: function() {
+			// Due to EXTJS-18711. we have to do a static list via a store but to avoid
+			// creating an object, we have to have an empty pseudo un function
+		    },
+		},
+	    },
+	},
+	{
+	    header: gettext('Tags'),
+	    dataIndex: 'tags',
+	    renderer: tags => PVE.Utils.renderTags(tags, PVE.UIOptions.tagOverrides),
+	    flex: 1,
+	},
+	{
+	    header: 'HA ' + gettext('Status'),
+	    dataIndex: 'hastate',
+	    flex: 1,
+	    filter: {
+		type: 'list',
+	    },
+	},
+    ],
+
+    // should be a list of 'dataIndex' values, if 'undefined' all declared columns will be included
+    columnSelection: undefined,
+
+    selModel: {
+	selType: 'checkboxmodel',
+	mode: 'SIMPLE',
+    },
+
+    checkChangeEvents: [
+	'selectionchange',
+	'change',
+    ],
+
+    listeners: {
+	selectionchange: function() {
+	    // to trigger validity and error checks
+	    this.checkChange();
+	},
+    },
+
+    getValue: function() {
+	var me = this;
+	if (me.savedValue !== undefined) {
+	    return me.savedValue;
+	}
+	var sm = me.getSelectionModel();
+	var selection = sm.getSelection();
+	var values = [];
+	var store = me.getStore();
+	selection.forEach(function(item) {
+	    // only add if not filtered
+	    if (store.findExact('vmid', item.data.vmid) !== -1) {
+		values.push(item.data.vmid);
+	    }
+	});
+	return values;
+    },
+
+    setValueSelection: function(value) {
+	let me = this;
+
+	let store = me.getStore();
+	let notFound = [];
+	let selection = value.map(item => {
+	    let found = store.findRecord('vmid', item, 0, false, true, true);
+	    if (!found) {
+		if (Ext.isNumeric(item)) {
+		    notFound.push(item);
+		} else {
+		    console.warn(`invalid item in vm selection: ${item}`);
+		}
+	    }
+	    return found;
+	}).filter(r => r);
+
+	for (const vmid of notFound) {
+	    let rec = store.add({
+		vmid,
+		node: 'unknown',
+	    });
+	    selection.push(rec[0]);
+	}
+
+	let sm = me.getSelectionModel();
+	if (selection.length) {
+	    sm.select(selection);
+	} else {
+	    sm.deselectAll();
+	}
+	// to correctly trigger invalid class
+	me.getErrors();
+    },
+
+    setValue: function(value) {
+	let me = this;
+	value ??= [];
+	if (!Ext.isArray(value)) {
+	    value = value.split(',').filter(v => v !== '');
+	}
+
+	let store = me.getStore();
+	if (!store.isLoaded()) {
+	    me.savedValue = value;
+	    store.on('load', function() {
+		me.setValueSelection(value);
+		delete me.savedValue;
+	    }, { single: true });
+	} else {
+	    me.setValueSelection(value);
+	}
+	return me.mixins.field.setValue.call(me, value);
+    },
+
+    getErrors: function(value) {
+	let me = this;
+	if (!me.isDisabled() && me.allowBlank === false &&
+	    me.getValue().length === 0) {
+	    me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
+	    return [gettext('No VM selected')];
+	}
+
+	me.removeBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
+	return [];
+    },
+
+    setDisabled: function(disabled) {
+	let me = this;
+	let res = me.callParent([disabled]);
+	me.getErrors();
+	return res;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let columns = me.columnsDeclaration.filter((column) =>
+	    me.columnSelection ? me.columnSelection.indexOf(column.dataIndex) !== -1 : true,
+	).map((x) => x);
+
+	me.columns = columns;
+
+	me.callParent();
+
+	me.getStore().load({ params: { type: 'vm' } });
+
+	if (me.nodename) {
+	    me.getStore().addFilter({
+		property: 'node',
+		exactMatch: true,
+		value: me.nodename,
+	    });
+	}
+
+	// only show the relevant guests by default
+	if (me.action) {
+	    var statusfilter = '';
+	    switch (me.action) {
+		case 'startall':
+		    statusfilter = 'stopped';
+		    break;
+		case 'stopall':
+		    statusfilter = 'running';
+		    break;
+	    }
+	    if (statusfilter !== '') {
+		me.getStore().addFilter([{
+		    property: 'template',
+		    value: 0,
+		}, {
+		    id: 'x-gridfilter-status',
+		    operator: 'in',
+		    property: 'status',
+		    value: [statusfilter],
+		}]);
+	    }
+	}
+
+	if (me.selectAll) {
+	    me.mon(me.getStore(), 'load', function() {
+		me.getSelectionModel().selectAll(false);
+	    });
+	}
+    },
+});
+
+
+Ext.define('PVE.form.VMComboSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.vmComboSelector',
+
+    valueField: 'vmid',
+    displayField: 'vmid',
+
+    autoSelect: false,
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+
+    store: {
+	model: 'PVEResources',
+	autoLoad: true,
+	sorters: 'vmid',
+	filters: [{
+	    property: 'type',
+	    value: /lxc|qemu/,
+	}],
+    },
+
+    listConfig: {
+	width: 600,
+	plugins: 'gridfilters',
+	columns: [
+	    {
+		header: 'ID',
+		dataIndex: 'vmid',
+		width: 80,
+		filter: {
+		    type: 'number',
+		},
+	    },
+	    {
+		header: gettext('Name'),
+		dataIndex: 'name',
+		flex: 1,
+		filter: {
+		    type: 'string',
+		},
+	    },
+	    {
+		header: gettext('Node'),
+		dataIndex: 'node',
+	    },
+	    {
+		header: gettext('Status'),
+		dataIndex: 'status',
+		filter: {
+		    type: 'list',
+		},
+	    },
+	    {
+		header: gettext('Pool'),
+		dataIndex: 'pool',
+		hidden: true,
+		filter: {
+		    type: 'list',
+		},
+	    },
+	    {
+		header: gettext('Type'),
+		dataIndex: 'type',
+		width: 120,
+		renderer: function(value) {
+		    if (value === 'qemu') {
+			return gettext('Virtual Machine');
+		    } else if (value === 'lxc') {
+			return gettext('LXC Container');
+		    }
+
+		    return '';
+		},
+		filter: {
+		    type: 'list',
+		    store: {
+			data: [
+			    { id: 'qemu', text: gettext('Virtual Machine') },
+			    { id: 'lxc', text: gettext('LXC Container') },
+			],
+			un: function() { /* due to EXTJS-18711 */ },
+		    },
+		},
+	    },
+	    {
+		header: 'HA ' + gettext('Status'),
+		dataIndex: 'hastate',
+		hidden: true,
+		flex: 1,
+		filter: {
+		    type: 'list',
+		},
+	    },
+	],
+    },
+});
+Ext.define('PVE.form.VNCKeyboardSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.VNCKeyboardSelector'],
+    comboItems: Object.entries(PVE.Utils.kvm_keymaps),
+});
+/*
+ * Top left combobox, used to select a view of the underneath RessourceTree
+ */
+Ext.define('PVE.form.ViewSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: ['widget.pveViewSelector'],
+
+    editable: false,
+    allowBlank: false,
+    forceSelection: true,
+    autoSelect: false,
+    valueField: 'key',
+    displayField: 'value',
+    hideLabel: true,
+    queryMode: 'local',
+
+    initComponent: function() {
+	let me = this;
+
+	let default_views = {
+	    server: {
+		text: gettext('Server View'),
+		groups: ['node'],
+	    },
+	    folder: {
+		text: gettext('Folder View'),
+		groups: ['type'],
+	    },
+	    pool: {
+		text: gettext('Pool View'),
+		groups: ['pool'],
+		// Pool View only lists VMs and Containers
+		filterfn: ({ data }) => data.type === 'qemu' || data.type === 'lxc' || data.type === 'pool',
+	    },
+	};
+	let groupdef = Object.entries(default_views).map(([name, config]) => [name, config.text]);
+
+	let store = Ext.create('Ext.data.Store', {
+	    model: 'KeyValue',
+	    proxy: {
+		type: 'memory',
+		reader: 'array',
+	    },
+	    data: groupdef,
+	    autoload: true,
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    value: groupdef[0][0],
+	    getViewFilter: function() {
+		let view = me.getValue();
+		return Ext.apply({ id: view }, default_views[view] || default_views.server);
+	    },
+	    getState: function() {
+		return { value: me.getValue() };
+	    },
+	    applyState: function(state, doSelect) {
+		let view = me.getValue();
+		if (state && state.value && view !== state.value) {
+		    let record = store.findRecord('key', state.value, 0, false, true, true);
+		    if (record) {
+			me.setValue(state.value, true);
+			if (doSelect) {
+			    me.fireEvent('select', me, [record]);
+			}
+		    }
+		}
+	    },
+	    stateEvents: ['select'],
+	    stateful: true,
+	    stateId: 'pveview',
+	    id: 'view',
+	});
+
+	me.callParent();
+
+	let statechange = function(sp, key, value) {
+	    if (key === me.id) {
+		me.applyState(value, true);
+	    }
+	};
+	let sp = Ext.state.Manager.getProvider();
+	me.mon(sp, 'statechange', statechange, me);
+    },
+});
+Ext.define('PVE.form.iScsiProviderSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveiScsiProviderSelector'],
+    comboItems: [
+	['comstar', 'Comstar'],
+	['istgt', 'istgt'],
+	['iet', 'IET'],
+	['LIO', 'LIO'],
+    ],
+});
+Ext.define('PVE.form.ColorPicker', {
+    extend: 'Ext.form.FieldContainer',
+    alias: 'widget.pveColorPicker',
+
+    defaultBindProperty: 'value',
+
+    config: {
+	value: null,
+    },
+
+    height: 24,
+
+    layout: {
+	type: 'hbox',
+	align: 'stretch',
+    },
+
+    getValue: function() {
+	return this.realvalue.slice(1);
+    },
+
+    setValue: function(value) {
+	let me = this;
+	me.setColor(value);
+	if (value && value.length === 6) {
+	    me.picker.value = value[0] !== '#' ? `#${value}` : value;
+	}
+    },
+
+    setColor: function(value) {
+	let me = this;
+	let oldValue = me.realvalue;
+	me.realvalue = value;
+	let color = value.length === 6 ? `#${value}` : undefined;
+	me.down('#picker').setStyle('background-color', color);
+	me.down('#text').setValue(value ?? "");
+	me.fireEvent('change', me, me.realvalue, oldValue);
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.picker = document.createElement('input');
+	me.picker.type = 'color';
+	me.picker.style = `opacity: 0; border: 0px; width: 100%; height: ${me.height}px`;
+	me.picker.value = `${me.value}`;
+
+	me.items = [
+	    {
+		xtype: 'textfield',
+		itemId: 'text',
+		minLength: !me.allowBlank ? 6 : undefined,
+		maxLength: 6,
+		enforceMaxLength: true,
+		allowBlank: me.allowBlank,
+		emptyText: me.allowBlank ? gettext('Automatic') : undefined,
+		maskRe: /[a-f0-9]/i,
+		regex: /^[a-f0-9]{6}$/i,
+		flex: 1,
+		listeners: {
+		    change: function(field, value) {
+			me.setValue(value);
+		    },
+		},
+	    },
+	    {
+		xtype: 'box',
+		style: {
+		    'margin-left': '1px',
+		    border: '1px solid #cfcfcf',
+		},
+		itemId: 'picker',
+		width: 24,
+		contentEl: me.picker,
+	    },
+	];
+
+	me.callParent();
+	me.picker.oninput = function() {
+	    me.setColor(me.picker.value.slice(1));
+	};
+    },
+});
+
+Ext.define('PVE.form.TagColorGrid', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveTagColorGrid',
+
+    mixins: [
+	'Ext.form.field.Field',
+    ],
+
+    allowBlank: true,
+    selectAll: false,
+    isFormField: true,
+    deleteEmpty: false,
+    selModel: 'checkboxmodel',
+
+    config: {
+	deleteEmpty: false,
+    },
+
+    emptyText: gettext('No Overrides'),
+    viewConfig: {
+	deferEmptyText: false,
+    },
+
+    setValue: function(value) {
+	let me = this;
+	let colors;
+	if (Ext.isObject(value)) {
+	    colors = value.colors;
+	} else {
+	    colors = value;
+	}
+	if (!colors) {
+	    me.getStore().removeAll();
+	    me.checkChange();
+	    return me;
+	}
+	let entries = (colors.split(';') || []).map((entry) => {
+	    let [tag, bg, fg] = entry.split(':');
+	    fg = fg || "";
+	    return {
+		tag,
+		color: bg,
+		text: fg,
+	    };
+	});
+	me.getStore().setData(entries);
+	me.checkChange();
+	return me;
+    },
+
+    getValue: function() {
+	let me = this;
+	let values = [];
+	me.getStore().each((rec) => {
+	    if (rec.data.tag) {
+		let val = `${rec.data.tag}:${rec.data.color}`;
+		if (rec.data.text) {
+		    val += `:${rec.data.text}`;
+		}
+		values.push(val);
+	    }
+	});
+	return values.join(';');
+    },
+
+    getErrors: function(value) {
+	let me = this;
+	let emptyTag = false;
+	let notValidColor = false;
+	let colorRegex = new RegExp(/^[0-9a-f]{6}$/i);
+	me.getStore().each((rec) => {
+	    if (!rec.data.tag) {
+		emptyTag = true;
+	    }
+	    if (!rec.data.color?.match(colorRegex)) {
+		notValidColor = true;
+	    }
+	    if (rec.data.text && !rec.data.text?.match(colorRegex)) {
+		notValidColor = true;
+	    }
+	});
+	let errors = [];
+	if (emptyTag) {
+	    errors.push(gettext('Tag must not be empty.'));
+	}
+	if (notValidColor) {
+	    errors.push(gettext('Not a valid color.'));
+	}
+	return errors;
+    },
+
+    // override framework function to implement deleteEmpty behaviour
+    getSubmitData: function() {
+	let me = this,
+	    data = null,
+	    val;
+	if (!me.disabled && me.submitValue) {
+	    val = me.getValue();
+	    if (val !== null && val !== '') {
+		data = {};
+		data[me.getName()] = val;
+	    } else if (me.getDeleteEmpty()) {
+		data = {};
+		data.delete = me.getName();
+	    }
+	}
+	return data;
+    },
+
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addLine: function() {
+	    let me = this;
+	    me.getView().getStore().add({
+		tag: '',
+		color: '',
+		text: '',
+	    });
+	},
+
+	removeSelection: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection === undefined) {
+		return;
+	    }
+
+	    selection.forEach((sel) => {
+		view.getStore().remove(sel);
+	    });
+	    view.checkChange();
+	},
+
+	tagChange: function(field, newValue, oldValue) {
+	    let me = this;
+	    let rec = field.getWidgetRecord();
+	    if (!rec) {
+		return;
+	    }
+	    if (newValue && newValue !== oldValue) {
+		let newrgb = Proxmox.Utils.stringToRGB(newValue);
+		let newvalue = Proxmox.Utils.rgbToHex(newrgb);
+		if (!rec.get('color')) {
+		    rec.set('color', newvalue);
+		} else if (oldValue) {
+		    let oldrgb = Proxmox.Utils.stringToRGB(oldValue);
+		    let oldvalue = Proxmox.Utils.rgbToHex(oldrgb);
+		    if (rec.get('color') === oldvalue) {
+			rec.set('color', newvalue);
+		    }
+		}
+	    }
+	    me.fieldChange(field, newValue, oldValue);
+	},
+
+	backgroundChange: function(field, newValue, oldValue) {
+	    let me = this;
+	    let rec = field.getWidgetRecord();
+	    if (!rec) {
+		return;
+	    }
+	    if (newValue && newValue !== oldValue) {
+		let newrgb = Proxmox.Utils.hexToRGB(newValue);
+		let newcls = Proxmox.Utils.getTextContrastClass(newrgb);
+		let hexvalue = newcls === 'dark' ? '000000' : 'FFFFFF';
+		if (!rec.get('text')) {
+		    rec.set('text', hexvalue);
+		} else if (oldValue) {
+		    let oldrgb = Proxmox.Utils.hexToRGB(oldValue);
+		    let oldcls = Proxmox.Utils.getTextContrastClass(oldrgb);
+		    let oldvalue = oldcls === 'dark' ? '000000' : 'FFFFFF';
+		    if (rec.get('text') === oldvalue) {
+			rec.set('text', hexvalue);
+		    }
+		}
+	    }
+	    me.fieldChange(field, newValue, oldValue);
+	},
+
+	fieldChange: function(field, newValue, oldValue) {
+	    let me = this;
+	    let view = me.getView();
+	    let rec = field.getWidgetRecord();
+	    if (!rec) {
+		return;
+	    }
+	    let column = field.getWidgetColumn();
+	    rec.set(column.dataIndex, newValue);
+	    view.checkChange();
+	},
+    },
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    handler: 'addLine',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Remove'),
+	    handler: 'removeSelection',
+	    disabled: true,
+	},
+    ],
+
+    columns: [
+	{
+	    header: 'Tag',
+	    dataIndex: 'tag',
+	    xtype: 'widgetcolumn',
+	    onWidgetAttach: function(col, widget, rec) {
+		widget.getStore().setData(PVE.UIOptions.tagList.map(v => ({ tag: v })));
+	    },
+	    widget: {
+		xtype: 'combobox',
+		isFormField: false,
+		maskRe: PVE.Utils.tagCharRegex,
+		allowBlank: false,
+		queryMode: 'local',
+		displayField: 'tag',
+		valueField: 'tag',
+		store: {},
+		listeners: {
+		    change: 'tagChange',
+		},
+	    },
+	    flex: 1,
+	},
+	{
+	    header: gettext('Background'),
+	    xtype: 'widgetcolumn',
+	    flex: 1,
+	    dataIndex: 'color',
+	    widget: {
+		xtype: 'pveColorPicker',
+		isFormField: false,
+		listeners: {
+		    change: 'backgroundChange',
+		},
+	    },
+	},
+	{
+	    header: gettext('Text'),
+	    xtype: 'widgetcolumn',
+	    flex: 1,
+	    dataIndex: 'text',
+	    widget: {
+		xtype: 'pveColorPicker',
+		allowBlank: true,
+		isFormField: false,
+		listeners: {
+		    change: 'fieldChange',
+		},
+	    },
+	},
+    ],
+
+    store: {
+	listeners: {
+	    update: function() {
+		this.commitChanges();
+	    },
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	me.initField();
+    },
+});
+Ext.define('PVE.form.ListField', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pveListField',
+
+    mixins: [
+	'Ext.form.field.Field',
+    ],
+
+    // override for column header
+    fieldTitle: gettext('Item'),
+
+    // will be applied to the textfields
+    maskRe: undefined,
+
+    allowBlank: true,
+    selectAll: false,
+    isFormField: true,
+    deleteEmpty: false,
+    config: {
+	deleteEmpty: false,
+    },
+
+    setValue: function(list) {
+	let me = this;
+	list = Ext.isArray(list) ? list : (list ?? '').split(';').filter(t => t !== '');
+
+	let store = me.lookup('grid').getStore();
+	if (list.length > 0) {
+	    store.setData(list.map(item => ({ item })));
+	} else {
+	    store.removeAll();
+	}
+	me.checkChange();
+	return me;
+    },
+
+    getValue: function() {
+	let me = this;
+	let values = [];
+	me.lookup('grid').getStore().each((rec) => {
+	    if (rec.data.item) {
+		values.push(rec.data.item);
+	    }
+	});
+	return values.join(';');
+    },
+
+    getErrors: function(value) {
+	let me = this;
+	let empty = false;
+	me.lookup('grid').getStore().each((rec) => {
+	    if (!rec.data.item) {
+		empty = true;
+	    }
+	});
+	if (empty) {
+	    return [gettext('Tag must not be empty.')];
+	}
+	return [];
+    },
+
+    // override framework function to implement deleteEmpty behaviour
+    getSubmitData: function() {
+	let me = this,
+	    data = null,
+	    val;
+	if (!me.disabled && me.submitValue) {
+	    val = me.getValue();
+	    if (val !== null && val !== '') {
+		data = {};
+		data[me.getName()] = val;
+	    } else if (me.getDeleteEmpty()) {
+		data = {};
+		data.delete = me.getName();
+	    }
+	}
+	return data;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addLine: function() {
+	    let me = this;
+	    me.lookup('grid').getStore().add({
+		item: '',
+	    });
+	},
+
+	removeSelection: function(field) {
+	    let me = this;
+	    let view = me.getView();
+	    let grid = me.lookup('grid');
+
+	    let record = field.getWidgetRecord();
+	    if (record === undefined) {
+		// this is sometimes called before a record/column is initialized
+		return;
+	    }
+
+	    grid.getStore().remove(record);
+	    view.checkChange();
+	    view.validate();
+	},
+
+	itemChange: function(field, newValue) {
+	    let rec = field.getWidgetRecord();
+	    if (!rec) {
+		return;
+	    }
+	    let column = field.getWidgetColumn();
+	    rec.set(column.dataIndex, newValue);
+	    let list = field.up('pveListField');
+	    list.checkChange();
+	    list.validate();
+	},
+
+	control: {
+	    'grid button': {
+		click: 'removeSelection',
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'grid',
+	    reference: 'grid',
+
+	    viewConfig: {
+		deferEmptyText: false,
+	    },
+
+	    store: {
+		listeners: {
+		    update: function() {
+			this.commitChanges();
+		    },
+		},
+	    },
+	},
+	{
+	    xtype: 'button',
+	    text: gettext('Add'),
+	    iconCls: 'fa fa-plus-circle',
+	    handler: 'addLine',
+	    margin: '5 0 0 0',
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	for (const [key, value] of Object.entries(me.gridConfig ?? {})) {
+	    me.items[0][key] = value;
+	}
+
+	me.items[0].columns = [
+	    {
+		header: me.fieldTtitle,
+		dataIndex: 'item',
+		xtype: 'widgetcolumn',
+		widget: {
+		    xtype: 'textfield',
+		    isFormField: false,
+		    maskRe: me.maskRe,
+		    allowBlank: false,
+		    queryMode: 'local',
+		    listeners: {
+			change: 'itemChange',
+		    },
+		},
+		flex: 1,
+	    },
+	    {
+		xtype: 'widgetcolumn',
+		width: 40,
+		widget: {
+		    xtype: 'button',
+		    iconCls: 'fa fa-trash-o',
+		},
+	    },
+	];
+
+	me.callParent();
+	me.initField();
+    },
+});
+Ext.define('Proxmox.form.Tag', {
+    extend: 'Ext.Component',
+    alias: 'widget.pveTag',
+
+    mode: 'editable',
+
+    tag: '',
+    cls: 'pve-edit-tag',
+
+    tpl: [
+	'<i class="handle fa fa-bars"></i>',
+	'<span>{tag}</span>',
+	'<i class="action fa fa-minus-square"></i>',
+    ],
+
+    focusable: true,
+    getFocusEl: function() {
+	return Ext.get(this.tagEl());
+    },
+
+    onFocus: function() {
+	this.selectText();
+    },
+
+    // contains tags not to show in the picker and not allowing to set
+    filter: [],
+
+    updateFilter: function(tags) {
+	this.filter = tags;
+    },
+
+    onClick: function(event) {
+	let me = this;
+	if (event.target.tagName === 'I' && !event.target.classList.contains('handle')) {
+	    if (me.mode === 'editable') {
+		me.destroy();
+		return;
+	    }
+	} else if (event.target.tagName !== 'SPAN' || me.mode !== 'editable') {
+	    return;
+	}
+	me.selectText();
+    },
+
+    selectText: function(collapseToEnd) {
+	let me = this;
+	let tagEl = me.tagEl();
+	tagEl.contentEditable = true;
+	let range = document.createRange();
+	range.selectNodeContents(tagEl);
+	if (collapseToEnd) {
+	    range.collapse(false);
+	}
+	let sel = window.getSelection();
+	sel.removeAllRanges();
+	sel.addRange(range);
+
+	me.showPicker();
+    },
+
+    showPicker: function() {
+	let me = this;
+	if (!me.picker) {
+	    me.picker = Ext.widget({
+		xtype: 'boundlist',
+		minWidth: 70,
+		scrollable: true,
+		floating: true,
+		hidden: true,
+		userCls: 'proxmox-tags-full',
+		displayField: 'tag',
+		itemTpl: [
+		    '{[Proxmox.Utils.getTagElement(values.tag, PVE.UIOptions.tagOverrides)]}',
+		],
+		store: [],
+		listeners: {
+		    select: function(picker, rec) {
+			me.tagEl().innerHTML = rec.data.tag;
+			me.setTag(rec.data.tag, true);
+			me.selectText(true);
+			me.setColor(rec.data.tag);
+			me.picker.hide();
+		    },
+		},
+	    });
+	}
+	me.picker.getStore()?.clearFilter();
+	let taglist = PVE.UIOptions.tagList.filter(v => !me.filter.includes(v)).map(v => ({ tag: v }));
+	if (taglist.length < 1) {
+	    return;
+	}
+	me.picker.getStore().setData(taglist);
+	me.picker.showBy(me, 'tl-bl');
+	me.picker.setMaxHeight(200);
+    },
+
+    setMode: function(mode) {
+	let me = this;
+	let tagEl = me.tagEl();
+	if (tagEl) {
+	    tagEl.contentEditable = mode === 'editable';
+	}
+	me.removeCls(me.mode);
+	me.addCls(mode);
+	me.mode = mode;
+	if (me.mode !== 'editable') {
+	    me.picker?.hide();
+	}
+    },
+
+    onKeyPress: function(event) {
+	let me = this;
+	let key = event.browserEvent.key;
+	switch (key) {
+	    case 'Enter':
+	    case 'Escape':
+		me.fireEvent('keypress', key);
+		break;
+	    case 'ArrowLeft':
+	    case 'ArrowRight':
+	    case 'Backspace':
+	    case 'Delete':
+		return;
+	    default:
+		if (key.match(PVE.Utils.tagCharRegex)) {
+		    return;
+		}
+		me.setTag(me.tagEl().innerHTML);
+	}
+	event.browserEvent.preventDefault();
+	event.browserEvent.stopPropagation();
+    },
+
+    // for pasting text
+    beforeInput: function(event) {
+	let me = this;
+	me.updateLayout();
+	let tag = event.event.data ?? event.event.dataTransfer?.getData('text/plain');
+	if (!tag) {
+	    return;
+	}
+	if (tag.match(PVE.Utils.tagCharRegex) === null) {
+	    event.event.preventDefault();
+	    event.event.stopPropagation();
+	}
+    },
+
+    onInput: function(event) {
+	let me = this;
+	me.picker.getStore().filter({
+	    property: 'tag',
+	    value: me.tagEl().innerHTML,
+	    anyMatch: true,
+	});
+	me.setTag(me.tagEl().innerHTML);
+    },
+
+    lostFocus: function(list, event) {
+	let me = this;
+	me.picker?.hide();
+	window.getSelection().removeAllRanges();
+    },
+
+    setColor: function(tag) {
+	let me = this;
+	let rgb = PVE.UIOptions.tagOverrides[tag] ?? Proxmox.Utils.stringToRGB(tag);
+
+	let cls = Proxmox.Utils.getTextContrastClass(rgb);
+	let color = Proxmox.Utils.rgbToCss(rgb);
+	me.setUserCls(`proxmox-tag-${cls}`);
+	me.setStyle('background-color', color);
+	if (rgb.length > 3) {
+	    let fgcolor = Proxmox.Utils.rgbToCss([rgb[3], rgb[4], rgb[5]]);
+
+	    me.setStyle('color', fgcolor);
+	} else {
+	    me.setStyle('color');
+	}
+    },
+
+    setTag: function(tag) {
+	let me = this;
+	let oldtag = me.tag;
+	me.tag = tag;
+
+	clearTimeout(me.colorTimeout);
+	me.colorTimeout = setTimeout(() => me.setColor(tag), 200);
+
+	me.updateLayout();
+	if (oldtag !== tag) {
+	    me.fireEvent('change', me, tag, oldtag);
+	}
+    },
+
+    tagEl: function() {
+	return this.el?.dom?.getElementsByTagName('span')?.[0];
+    },
+
+    listeners: {
+	click: 'onClick',
+	focusleave: 'lostFocus',
+	keydown: 'onKeyPress',
+	beforeInput: 'beforeInput',
+	input: 'onInput',
+	element: 'el',
+	scope: 'this',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.data = {
+	    tag: me.tag,
+	};
+
+	me.setTag(me.tag);
+	me.setColor(me.tag);
+	me.setMode(me.mode ?? 'normal');
+	me.callParent();
+    },
+
+    destroy: function() {
+	let me = this;
+	if (me.picker) {
+	    Ext.destroy(me.picker);
+	}
+	clearTimeout(me.colorTimeout);
+	me.callParent();
+    },
+});
+Ext.define('PVE.panel.TagEditContainer', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pveTagEditContainer',
+
+    layout: {
+	type: 'hbox',
+	align: 'middle',
+    },
+
+    // set to false to hide the 'no tags' field and the edit button
+    canEdit: true,
+    editOnly: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	loadTags: function(tagstring = '', force = false) {
+	    let me = this;
+	    let view = me.getView();
+
+	    if (me.oldTags === tagstring && !force) {
+		return;
+	    }
+
+	    view.suspendLayout = true;
+	    me.forEachTag((tag) => {
+		view.remove(tag);
+	    });
+	    me.getViewModel().set('tagCount', 0);
+	    let newtags = tagstring.split(/[;, ]/).filter((t) => !!t) || [];
+	    newtags.forEach((tag) => {
+		me.addTag(tag);
+	    });
+	    view.suspendLayout = false;
+	    view.updateLayout();
+	    if (!force) {
+		me.oldTags = tagstring;
+	    }
+	    me.tagsChanged();
+	},
+
+	onRender: function(v) {
+	    let me = this;
+	    let view = me.getView();
+	    view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags());
+
+	    view.dragzone = Ext.create('Ext.dd.DragZone', v.getEl(), {
+		getDragData: function(e) {
+		    let source = e.getTarget('.handle');
+		    if (!source) {
+			return undefined;
+		    }
+		    let sourceId = source.parentNode.id;
+		    let cmp = Ext.getCmp(sourceId);
+		    let ddel = document.createElement('div');
+		    ddel.classList.add('proxmox-tags-full');
+		    ddel.innerHTML = Proxmox.Utils.getTagElement(cmp.tag, PVE.UIOptions.tagOverrides);
+		    let repairXY = Ext.fly(source).getXY();
+		    cmp.setDisabled(true);
+		    ddel.id = Ext.id();
+		    return {
+			ddel,
+			repairXY,
+			sourceId,
+		    };
+		},
+		onMouseUp: function(target, e, id) {
+		    let cmp = Ext.getCmp(this.dragData.sourceId);
+		    if (cmp && !cmp.isDestroyed) {
+			cmp.setDisabled(false);
+		    }
+		},
+		getRepairXY: function() {
+		    return this.dragData.repairXY;
+		},
+		beforeInvalidDrop: function(target, e, id) {
+		    let cmp = Ext.getCmp(this.dragData.sourceId);
+		    if (cmp && !cmp.isDestroyed) {
+			cmp.setDisabled(false);
+		    }
+		},
+	    });
+	    view.dropzone = Ext.create('Ext.dd.DropZone', v.getEl(), {
+		getTargetFromEvent: function(e) {
+		    return e.getTarget('.proxmox-tag-dark,.proxmox-tag-light');
+		},
+		getIndicator: function() {
+		    if (!view.indicator) {
+			view.indicator = Ext.create('Ext.Component', {
+			    floating: true,
+			    html: '<i class="fa fa-long-arrow-up"></i>',
+			    hidden: true,
+			    shadow: false,
+			});
+		    }
+		    return view.indicator;
+		},
+		onContainerOver: function() {
+		    this.getIndicator().setVisible(false);
+		},
+		notifyOut: function() {
+		    this.getIndicator().setVisible(false);
+		},
+		onNodeOver: function(target, dd, e, data) {
+		    let indicator = this.getIndicator();
+		    indicator.setVisible(true);
+		    indicator.alignTo(Ext.getCmp(target.id), 't50-bl', [-1, -2]);
+		    return this.dropAllowed;
+		},
+		onNodeDrop: function(target, dd, e, data) {
+		    this.getIndicator().setVisible(false);
+		    let sourceCmp = Ext.getCmp(data.sourceId);
+		    if (!sourceCmp) {
+			return;
+		    }
+		    sourceCmp.setDisabled(false);
+		    let targetCmp = Ext.getCmp(target.id);
+		    view.remove(sourceCmp, { destroy: false });
+		    view.insert(view.items.indexOf(targetCmp), sourceCmp);
+		    me.tagsChanged();
+		},
+	    });
+	},
+
+	forEachTag: function(func) {
+	    let me = this;
+	    let view = me.getView();
+	    view.items.each((field) => {
+		if (field.getXType() === 'pveTag') {
+		    func(field);
+		}
+		return true;
+	    });
+	},
+
+	toggleEdit: function(cancel) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let view = me.getView();
+	    let editMode = !vm.get('editMode');
+	    vm.set('editMode', editMode);
+
+	    // get a current tag list for editing
+	    if (editMode) {
+		PVE.UIOptions.update();
+	    }
+
+	    me.forEachTag((tag) => {
+		tag.setMode(editMode ? 'editable' : 'normal');
+	    });
+
+	    if (!vm.get('editMode')) {
+		let tags = [];
+		if (cancel) {
+		    me.loadTags(me.oldTags, true);
+		} else {
+		    let toRemove = [];
+		    me.forEachTag((cmp) => {
+			if (cmp.isVisible() && cmp.tag) {
+			    tags.push(cmp.tag);
+			} else {
+			    toRemove.push(cmp);
+			}
+		    });
+		    toRemove.forEach(cmp => view.remove(cmp));
+		    tags = tags.join(',');
+		    if (me.oldTags !== tags) {
+			me.oldTags = tags;
+			me.loadTags(tags, true);
+			me.getView().fireEvent('change', tags);
+		    }
+		}
+	    }
+	    me.getView().updateLayout();
+	},
+
+	tagsChanged: function() {
+	    let me = this;
+	    let tags = [];
+	    me.forEachTag(cmp => {
+		if (cmp.tag) {
+		    tags.push(cmp.tag);
+		}
+	    });
+	    me.getViewModel().set('isDirty', me.oldTags !== tags.join(','));
+	    me.forEachTag(cmp => {
+		cmp.updateFilter(tags);
+	    });
+	},
+
+	addTag: function(tag, isNew) {
+	    let me = this;
+	    let view = me.getView();
+	    let vm = me.getViewModel();
+	    let index = view.items.length - 5;
+	    if (PVE.UIOptions.shouldSortTags() && !isNew) {
+		index = view.items.findIndexBy(tagField => {
+		    if (tagField.reference === 'noTagsField') {
+			return false;
+		    }
+		    if (tagField.xtype !== 'pveTag') {
+			return true;
+		    }
+		    let a = tagField.tag.toLowerCase();
+		    let b = tag.toLowerCase();
+		    return a > b ? true : a < b ? false : tagField.tag.localeCompare(tag) > 0;
+		}, 1);
+	    }
+	    let tagField = view.insert(index, {
+		xtype: 'pveTag',
+		tag,
+		mode: vm.get('editMode') ? 'editable' : 'normal',
+		listeners: {
+		    change: 'tagsChanged',
+		    destroy: function() {
+			vm.set('tagCount', vm.get('tagCount') - 1);
+			me.tagsChanged();
+		    },
+		    keypress: function(key) {
+			if (vm.get('hideFinishButtons')) {
+			    return;
+			}
+			if (key === 'Enter') {
+			    me.editClick();
+			} else if (key === 'Escape') {
+			    me.cancelClick();
+			}
+		    },
+		},
+	    });
+
+	    if (isNew) {
+		me.tagsChanged();
+		tagField.selectText();
+	    }
+
+	    vm.set('tagCount', vm.get('tagCount') + 1);
+	},
+
+	addTagClick: function(event) {
+	    let me = this;
+	    me.lookup('noTagsField').setVisible(false);
+	    me.addTag('', true);
+	},
+
+	cancelClick: function() {
+	    this.toggleEdit(true);
+	},
+
+	editClick: function() {
+	    this.toggleEdit(false);
+	},
+
+	init: function(view) {
+	    let me = this;
+	    if (view.tags) {
+		me.loadTags(view.tags);
+	    }
+	    me.getViewModel().set('canEdit', view.canEdit);
+	    me.getViewModel().set('editOnly', view.editOnly);
+
+	    me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => {
+		let vm = me.getViewModel();
+		view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags());
+		me.loadTags(me.oldTags, !vm.get('editMode')); // refresh tag colors and order
+	    });
+
+	    if (view.editOnly) {
+		me.toggleEdit();
+	    }
+	},
+    },
+
+    getTags: function() {
+	let me =this;
+	let controller = me.getController();
+	let tags = [];
+	    controller.forEachTag((cmp) => {
+		if (cmp.tag.length) {
+		    tags.push(cmp.tag);
+		}
+	    });
+
+	return tags;
+    },
+
+    viewModel: {
+	data: {
+	    tagCount: 0,
+	    editMode: false,
+	    canEdit: true,
+	    isDirty: false,
+	    editOnly: true,
+	},
+
+	formulas: {
+	    hideNoTags: function(get) {
+		return get('tagCount') !== 0 || !get('canEdit');
+	    },
+	    hideEditBtn: function(get) {
+		return get('editMode') || !get('canEdit');
+	    },
+	    hideFinishButtons: function(get) {
+		return !get('editMode') || get('editOnly');
+	    },
+	},
+    },
+
+    loadTags: function() {
+	return this.getController().loadTags(...arguments);
+    },
+
+    items: [
+	{
+	    xtype: 'box',
+	    reference: 'noTagsField',
+	    bind: {
+		hidden: '{hideNoTags}',
+	    },
+	    html: gettext('No Tags'),
+	    style: {
+		opacity: 0.5,
+	    },
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa fa-plus',
+	    tooltip: gettext('Add Tag'),
+	    bind: {
+		hidden: '{!editMode}',
+	    },
+	    hidden: true,
+	    margin: '0 8 0 5',
+	    ui: 'default-toolbar',
+	    handler: 'addTagClick',
+	},
+	{
+	    xtype: 'tbseparator',
+	    ui: 'horizontal',
+	    bind: {
+		hidden: '{hideFinishButtons}',
+	    },
+	    hidden: true,
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa fa-times',
+	    tooltip: gettext('Cancel Edit'),
+	    bind: {
+		hidden: '{hideFinishButtons}',
+	    },
+	    hidden: true,
+	    margin: '0 5 0 0',
+	    ui: 'default-toolbar',
+	    handler: 'cancelClick',
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa fa-check',
+	    tooltip: gettext('Finish Edit'),
+	    bind: {
+		hidden: '{hideFinishButtons}',
+		disabled: '{!isDirty}',
+	    },
+	    hidden: true,
+	    handler: 'editClick',
+	},
+	{
+	    xtype: 'box',
+	    cls: 'pve-tag-inline-button',
+	    html: `<i data-qtip="${gettext('Edit Tags')}" class="fa fa-pencil"></i>`,
+	    bind: {
+		hidden: '{hideEditBtn}',
+	    },
+	    listeners: {
+		click: 'editClick',
+		element: 'el',
+	    },
+	},
+    ],
+
+    listeners: {
+	render: 'onRender',
+    },
+
+    destroy: function() {
+	let me = this;
+	Ext.destroy(me.dragzone);
+	Ext.destroy(me.dropzone);
+	Ext.destroy(me.indicator);
+	me.callParent();
+    },
+});
+// mostly copied from ExtJS FileButton, but added 'multiple' at the relevant
+// places so we have a file picker where one can select multiple files
+// changes are marked with an 'pmx:' comment
+Ext.define('PVE.form.MultiFileButton', {
+    extend: 'Ext.form.field.FileButton',
+    alias: 'widget.pveMultiFileButton',
+
+    afterTpl: [
+	'<input id="{id}-fileInputEl" data-ref="fileInputEl" class="{childElCls} {inputCls}" ',
+	    'type="file" size="1" name="{inputName}" unselectable="on" multiple ', // pmx: added multiple
+	    '<tpl if="accept != null">accept="{accept}"</tpl>',
+	    '<tpl if="tabIndex != null">tabindex="{tabIndex}"</tpl>',
+	'>',
+    ],
+
+    createFileInput: function(isTemporary) {
+	var me = this,
+	    fileInputEl, listeners;
+
+	fileInputEl = me.fileInputEl = me.el.createChild({
+	    name: me.inputName || me.id,
+	    multiple: true, // pmx: added multiple option
+	    id: !isTemporary ? me.id + '-fileInputEl' : undefined,
+	    cls: me.inputCls + (me.getInherited().rtl ? ' ' + Ext.baseCSSPrefix + 'rtl' : ''),
+	    tag: 'input',
+	    type: 'file',
+	    size: 1,
+	    unselectable: 'on',
+	}, me.afterInputGuard); // Nothing special happens outside of IE/Edge
+
+	// This is our focusEl
+	fileInputEl.dom.setAttribute('data-componentid', me.id);
+
+	if (me.tabIndex !== null) {
+	    me.setTabIndex(me.tabIndex);
+	}
+
+	if (me.accept) {
+	    fileInputEl.dom.setAttribute('accept', me.accept);
+	}
+
+	// We place focus and blur listeners on fileInputEl to activate Button's
+	// focus and blur style treatment
+	listeners = {
+	    scope: me,
+	    change: me.fireChange,
+	    mousedown: me.handlePrompt,
+	    keydown: me.handlePrompt,
+	    focus: me.onFileFocus,
+	    blur: me.onFileBlur,
+	};
+
+	if (me.useTabGuards) {
+	    listeners.keydown = me.onFileInputKeydown;
+	}
+
+	fileInputEl.on(listeners);
+    },
+});
+Ext.define('PVE.form.TagFieldSet', {
+    extend: 'Ext.form.FieldSet',
+    alias: 'widget.pveTagFieldSet',
+    mixins: ['Ext.form.field.Field'],
+
+    title: gettext('Tags'),
+    padding: '0 5 5 5',
+
+    getValue: function() {
+	let me = this;
+	let tags = me.down('pveTagEditContainer').getTags().filter(t => t !== '');
+	return tags.join(';');
+    },
+
+    setValue: function(value) {
+	let me = this;
+	value ??= [];
+	if (!Ext.isArray(value)) {
+	    value = value.split(/[;, ]/).filter(t => t !== '');
+	}
+	me.down('pveTagEditContainer').loadTags(value.join(';'));
+    },
+
+    getErrors: function(value) {
+	value ??= [];
+	if (!Ext.isArray(value)) {
+	    value = value.split(/[;, ]/).filter(t => t !== '');
+	}
+	if (value.some(t => !t.match(PVE.Utils.tagCharRegex))) {
+	    return [gettext("Tags contain invalid characters.")];
+	}
+	return [];
+    },
+
+    getSubmitData: function() {
+	let me = this;
+	let value = me.getValue();
+	if (me.disabled || !me.submitValue || value === '') {
+	    return null;
+	}
+	let data = {};
+	data[me.getName()] = value;
+	return data;
+    },
+
+    layout: 'fit',
+
+    items: [
+	{
+	    xtype: 'pveTagEditContainer',
+	    userCls: 'proxmox-tags-full proxmox-tag-fieldset',
+	    editOnly: true,
+	    allowBlank: true,
+	    layout: 'column',
+	    scrollable: true,
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	me.initField();
+    },
+});
+Ext.define('PVE.form.IsoSelector', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pveIsoSelector',
+    mixins: [
+	'Ext.form.field.Field',
+	'Proxmox.Mixin.CBind',
+    ],
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    nodename: undefined,
+    insideWizard: false,
+    labelWidth: undefined,
+    labelAlign: 'right',
+
+    cbindData: function() {
+	let me = this;
+	return {
+	    nodename: me.nodename,
+	    insideWizard: me.insideWizard,
+	};
+    },
+
+    getValue: function() {
+	return this.lookup('file').getValue();
+    },
+
+    setValue: function(value) {
+	let me = this;
+	if (!value) {
+	    me.lookup('file').reset();
+	    return;
+	}
+	var match = value.match(/^([^:]+):/);
+	if (match) {
+	    me.lookup('storage').setValue(match[1]);
+	    me.lookup('file').setValue(value);
+	}
+    },
+
+    getErrors: function() {
+	let me = this;
+	me.lookup('storage').validate();
+	let file = me.lookup('file');
+	file.validate();
+	let value = file.getValue();
+	if (!value || !value.length) {
+	    return [""]; // for validation
+	}
+	return [];
+    },
+
+    setNodename: function(nodename) {
+	let me = this;
+	me.lookup('storage').setNodename(nodename);
+	me.lookup('file').setStorage(undefined, nodename);
+    },
+
+    setDisabled: function(disabled) {
+	let me = this;
+	me.lookup('storage').setDisabled(disabled);
+	me.lookup('file').setDisabled(disabled);
+	return me.callParent([disabled]);
+    },
+
+    referenceHolder: true,
+
+    items: [
+	{
+	    xtype: 'pveStorageSelector',
+	    reference: 'storage',
+	    isFormField: false,
+	    fieldLabel: gettext('Storage'),
+	    storageContent: 'iso',
+	    allowBlank: false,
+	    cbind: {
+		nodename: '{nodename}',
+		autoSelect: '{insideWizard}',
+		insideWizard: '{insideWizard}',
+		disabled: '{disabled}',
+		labelWidth: '{labelWidth}',
+		labelAlign: '{labelAlign}',
+	    },
+	    listeners: {
+		change: function(f, value) {
+		    let me = this;
+		    let selector = me.up('pveIsoSelector');
+		    selector.lookup('file').setStorage(value);
+		    selector.checkChange();
+		},
+	    },
+	},
+	{
+	    xtype: 'pveFileSelector',
+	    reference: 'file',
+	    isFormField: false,
+	    storageContent: 'iso',
+	    fieldLabel: gettext('ISO image'),
+	    labelAlign: 'right',
+	    cbind: {
+		nodename: '{nodename}',
+		disabled: '{disabled}',
+		labelWidth: '{labelWidth}',
+		labelAlign: '{labelAlign}',
+	    },
+	    allowBlank: false,
+	    listeners: {
+		change: function() {
+		    this.up('pveIsoSelector').checkChange();
+		},
+	    },
+	},
+    ],
+});
+Ext.define('PVE.grid.BackupView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveBackupView'],
+
+    onlineHelp: 'chapter_vzdump',
+
+    stateful: true,
+    stateId: 'grid-guest-backup',
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var vmtype = me.pveSelNode.data.type;
+	if (!vmtype) {
+	    throw "no VM type specified";
+	}
+
+	var vmtypeFilter;
+	if (vmtype === 'lxc' || vmtype === 'openvz') {
+	    vmtypeFilter = function(item) {
+		return PVE.Utils.volume_is_lxc_backup(item.data.volid, item.data.format);
+	    };
+	} else if (vmtype === 'qemu') {
+	    vmtypeFilter = function(item) {
+		return PVE.Utils.volume_is_qemu_backup(item.data.volid, item.data.format);
+	    };
+	} else {
+	    throw "unsupported VM type '" + vmtype + "'";
+	}
+
+	var searchFilter = {
+	    property: 'volid',
+	    value: '',
+	    anyMatch: true,
+	    caseSensitive: false,
+	};
+
+	var vmidFilter = {
+	    property: 'vmid',
+	    value: vmid,
+	    exactMatch: true,
+	};
+
+	me.store = Ext.create('Ext.data.Store', {
+	    model: 'pve-storage-content',
+	    sorters: [
+		{
+		    property: 'vmid',
+		    direction: 'ASC',
+		},
+		{
+		    property: 'vdate',
+		    direction: 'DESC',
+		},
+	    ],
+	    filters: [
+	        vmtypeFilter,
+		searchFilter,
+		vmidFilter,
+		],
+	});
+
+	let updateFilter = function() {
+	    me.store.filter([
+		vmtypeFilter,
+		searchFilter,
+		vmidFilter,
+	    ]);
+	};
+
+	const reload = Ext.Function.createBuffered((options) => {
+	    if (me.store) {
+		me.store.load(options);
+	    }
+	}, 100);
+
+	let isPBS = false;
+	var setStorage = function(storage) {
+	    var url = '/api2/json/nodes/' + nodename + '/storage/' + storage + '/content';
+	    url += '?content=backup';
+
+	    me.store.setProxy({
+		type: 'proxmox',
+		url: url,
+	    });
+
+	    Proxmox.Utils.monStoreErrors(me.view, me.store, true);
+
+	    reload();
+	};
+
+	let file_restore_btn;
+
+	var storagesel = Ext.create('PVE.form.StorageSelector', {
+	    nodename: nodename,
+	    fieldLabel: gettext('Storage'),
+	    labelAlign: 'right',
+	    storageContent: 'backup',
+	    allowBlank: false,
+	    listeners: {
+		change: function(f, value) {
+		    let storage = f.getStore().findRecord('storage', value, 0, false, true, true);
+		    if (storage) {
+			isPBS = storage.data.type === 'pbs';
+			me.getColumns().forEach((column) => {
+			    let id = column.dataIndex;
+			    if (id === 'verification' || id === 'encrypted') {
+				column.setHidden(!isPBS);
+			    }
+			});
+		    } else {
+			isPBS = false;
+		    }
+		    setStorage(value);
+		    if (file_restore_btn) {
+			file_restore_btn.setHidden(!isPBS);
+		    }
+		},
+	    },
+	});
+
+	var storagefilter = Ext.create('Ext.form.field.Text', {
+	    fieldLabel: gettext('Search'),
+	    labelWidth: 50,
+	    labelAlign: 'right',
+	    enableKeyEvents: true,
+	    value: searchFilter.value,
+	    listeners: {
+		buffer: 500,
+		keyup: function(field) {
+		    me.store.clearFilter(true);
+		    searchFilter.value = field.getValue();
+		    updateFilter();
+		},
+	    },
+	});
+
+	var vmidfilterCB = Ext.create('Ext.form.field.Checkbox', {
+	    boxLabel: gettext('Filter VMID'),
+	    value: '1',
+	    listeners: {
+		change: function(cb, value) {
+		    vmidFilter.value = value ? vmid : '';
+		    vmidFilter.exactMatch = !!value;
+		    updateFilter();
+		},
+	    },
+	});
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var backup_btn = Ext.create('Ext.button.Button', {
+	    text: gettext('Backup now'),
+	    handler: function() {
+		var win = Ext.create('PVE.window.Backup', {
+		    nodename: nodename,
+		    vmid: vmid,
+		    vmtype: vmtype,
+		    storage: storagesel.getValue(),
+		    listeners: {
+			close: function() {
+			    reload();
+			},
+		    },
+		});
+		win.show();
+	    },
+	});
+
+	var restore_btn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Restore'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: function(rec) {
+		return !!rec;
+	    },
+	    handler: function(b, e, rec) {
+		let win = Ext.create('PVE.window.Restore', {
+		    nodename: nodename,
+		    vmid: vmid,
+		    volid: rec.data.volid,
+		    volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec),
+		    vmtype: vmtype,
+		    isPBS: isPBS,
+		});
+		win.show();
+		win.on('destroy', reload);
+	    },
+	});
+
+	let delete_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    dangerous: true,
+	    delay: 5,
+	    enableFn: rec => !rec?.data?.protected,
+	    confirmMsg: ({ data }) => {
+		let msg = Ext.String.format(
+		    gettext('Are you sure you want to remove entry {0}'), `'${data.volid}'`);
+		return msg + " " + gettext('This will permanently erase all data.');
+	    },
+	    getUrl: ({ data }) => `/nodes/${nodename}/storage/${storagesel.getValue()}/content/${data.volid}`,
+	    callback: () => reload(),
+	});
+
+	let config_btn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Show Configuration'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: rec => !!rec,
+	    handler: function(b, e, rec) {
+		let storage = storagesel.getValue();
+		if (!storage) {
+		    return;
+		}
+		Ext.create('PVE.window.BackupConfig', {
+		    volume: rec.data.volid,
+		    pveSelNode: me.pveSelNode,
+		    autoShow: true,
+		});
+	    },
+	});
+
+	// declared above so that the storage selector can change this buttons hidden state
+	file_restore_btn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('File Restore'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: rec => !!rec && isPBS,
+	    hidden: !isPBS,
+	    handler: function(b, e, rec) {
+		let storage = storagesel.getValue();
+		let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format);
+		Ext.create('Proxmox.window.FileBrowser', {
+		    title: gettext('File Restore') + " - " + rec.data.text,
+		    listURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/list`,
+		    downloadURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/download`,
+		    extraParams: {
+			volume: rec.data.volid,
+		    },
+		    archive: isVMArchive ? 'all' : undefined,
+		    autoShow: true,
+		});
+	    },
+	});
+
+	Ext.apply(me, {
+	    selModel: sm,
+	    tbar: {
+		overflowHandler: 'scroller',
+		items: [
+		    backup_btn,
+		    '-',
+		    restore_btn,
+		    file_restore_btn,
+		    config_btn,
+		    {
+			xtype: 'proxmoxButton',
+			text: gettext('Edit Notes'),
+			disabled: true,
+			handler: function() {
+			    let volid = sm.getSelection()[0].data.volid;
+			    var storage = storagesel.getValue();
+			    Ext.create('Proxmox.window.Edit', {
+				autoLoad: true,
+				width: 600,
+				height: 400,
+				resizable: true,
+				title: gettext('Notes'),
+				url: `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`,
+				layout: 'fit',
+				items: [
+				    {
+					xtype: 'textarea',
+					layout: 'fit',
+					name: 'notes',
+					height: '100%',
+				    },
+				],
+				listeners: {
+				    destroy: () => reload(),
+				},
+			    }).show();
+			},
+		    },
+		    {
+			xtype: 'proxmoxButton',
+			text: gettext('Change Protection'),
+			disabled: true,
+			handler: function(button, event, record) {
+			    let volid = record.data.volid, storage = storagesel.getValue();
+			    let url = `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`;
+			    Proxmox.Utils.API2Request({
+				url: url,
+				method: 'PUT',
+				waitMsgTarget: me,
+				params: {
+				    'protected': record.data.protected ? 0 : 1,
+				},
+				failure: (response) => Ext.Msg.alert('Error', response.htmlStatus),
+				success: () => {
+				    reload({
+					callback: () => sm.fireEvent('selectionchange', sm, [record]),
+				    });
+				},
+			    });
+			},
+		    },
+		    '-',
+		    delete_btn,
+		    '->',
+		    storagesel,
+		    '-',
+		    vmidfilterCB,
+		    storagefilter,
+		],
+	    },
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    flex: 2,
+		    sortable: true,
+		    renderer: PVE.Utils.render_storage_content,
+		    dataIndex: 'volid',
+		},
+		{
+		    header: gettext('Notes'),
+		    dataIndex: 'notes',
+		    flex: 1,
+		    renderer: Ext.htmlEncode,
+		},
+		{
+		    header: `<i class="fa fa-shield"></i>`,
+		    tooltip: gettext('Protected'),
+		    width: 30,
+		    renderer: v => v ? `<i data-qtip="${gettext('Protected')}" class="fa fa-shield"></i>` : '',
+		    sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0),
+		    dataIndex: 'protected',
+		},
+		{
+		    header: gettext('Date'),
+		    width: 150,
+		    dataIndex: 'vdate',
+		},
+		{
+		    header: gettext('Format'),
+		    width: 100,
+		    dataIndex: 'format',
+		},
+		{
+		    header: gettext('Size'),
+		    width: 100,
+		    renderer: Proxmox.Utils.format_size,
+		    dataIndex: 'size',
+		},
+		{
+		    header: 'VMID',
+		    dataIndex: 'vmid',
+		    hidden: true,
+		},
+		{
+		    header: gettext('Encrypted'),
+		    dataIndex: 'encrypted',
+		    renderer: PVE.Utils.render_backup_encryption,
+		},
+		{
+		    header: gettext('Verify State'),
+		    dataIndex: 'verification',
+		    renderer: PVE.Utils.render_backup_verification,
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.FirewallAliasEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    base_url: undefined,
+
+    alias_name: undefined,
+
+    width: 400,
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = me.alias_name === undefined;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs' + me.base_url;
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs' + me.base_url + '/' + me.alias_name;
+	    me.method = 'PUT';
+	}
+
+	let ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	    isCreate: me.isCreate,
+	    items: [
+		{
+		    xtype: 'textfield',
+		    name: me.isCreate ? 'name' : 'rename',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'cidr',
+		    fieldLabel: gettext('IP/CIDR'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'comment',
+		    fieldLabel: gettext('Comment'),
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    subject: gettext('Alias'),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    let values = response.result.data;
+		    values.rename = values.name;
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+
+Ext.define('pve-fw-aliases', {
+    extend: 'Ext.data.Model',
+
+    fields: ['name', 'cidr', 'comment', 'digest'],
+    idProperty: 'name',
+});
+
+Ext.define('PVE.FirewallAliases', {
+    extend: 'Ext.grid.Panel',
+    alias: ['widget.pveFirewallAliases'],
+
+    onlineHelp: 'pve_firewall_ip_aliases',
+
+    stateful: true,
+    stateId: 'grid-firewall-aliases',
+
+    base_url: undefined,
+
+    title: gettext('Alias'),
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.base_url) {
+	    throw "missing base_url configuration";
+	}
+
+	let store = new Ext.data.Store({
+	    model: 'pve-fw-aliases',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json" + me.base_url,
+	    },
+	    sorters: {
+		property: 'name',
+		direction: 'ASC',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let canEdit = !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'];
+
+	let reload = function() {
+	    let oldrec = sm.getSelection()[0];
+	    store.load(function(records, operation, success) {
+		if (oldrec) {
+		    var rec = store.findRecord('name', oldrec.data.name, 0, false, true, true);
+		    if (rec) {
+			sm.select(rec);
+		    }
+		}
+	    });
+	};
+
+	let run_editor = function() {
+	    let rec = me.getSelectionModel().getSelection()[0];
+	    if (!rec || !canEdit) {
+		return;
+	    }
+	    let win = Ext.create('PVE.FirewallAliasEdit', {
+		base_url: me.base_url,
+		alias_name: rec.data.name,
+	    });
+	    win.show();
+	    win.on('destroy', reload);
+	};
+
+	me.editBtn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: rec => canEdit,
+	    handler: run_editor,
+	});
+
+	me.addBtn = Ext.create('Ext.Button', {
+	    text: gettext('Add'),
+	    disabled: !caps.vms['VM.Config.Network'] && !caps.dc['Sys.Modify'] && !caps.nodes['Sys.Modify'],
+	    handler: function() {
+		var win = Ext.create('PVE.FirewallAliasEdit', {
+		    base_url: me.base_url,
+		});
+		win.on('destroy', reload);
+		win.show();
+	    },
+	});
+
+	me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'],
+	    baseurl: me.base_url + '/',
+	    callback: reload,
+	});
+
+
+	Ext.apply(me, {
+	    store: store,
+	    tbar: [me.addBtn, me.removeBtn, me.editBtn],
+	    selModel: sm,
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    dataIndex: 'name',
+		    flex: 1,
+		},
+		{
+		    header: gettext('IP/CIDR'),
+		    dataIndex: 'cidr',
+		    flex: 1,
+		},
+		{
+		    header: gettext('Comment'),
+		    dataIndex: 'comment',
+		    renderer: Ext.String.htmlEncode,
+		    flex: 3,
+		},
+	    ],
+	    listeners: {
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+	me.on('activate', reload);
+    },
+});
+Ext.define('PVE.FirewallOptions', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.pveFirewallOptions'],
+
+    fwtype: undefined, // 'dc', 'node' or 'vm'
+
+    base_url: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.base_url) {
+	    throw "missing base_url configuration";
+	}
+
+	if (me.fwtype === 'dc' || me.fwtype === 'node' || me.fwtype === 'vm') {
+	    if (me.fwtype === 'node') {
+		me.cwidth1 = 250;
+	    }
+	} else {
+	    throw "unknown firewall option type";
+	}
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let canEdit = caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify'];
+
+	me.rows = {};
+
+	var add_boolean_row = function(name, text, defaultValue) {
+	    me.add_boolean_row(name, text, { defaultValue: defaultValue });
+	};
+	var add_integer_row = function(name, text, minValue, labelWidth) {
+	    me.add_integer_row(name, text, {
+		minValue: minValue,
+		deleteEmpty: true,
+		labelWidth: labelWidth,
+		renderer: function(value) {
+		    if (value === undefined) {
+			return Proxmox.Utils.defaultText;
+		    }
+
+		    return value;
+		},
+	    });
+	};
+
+	var add_log_row = function(name, labelWidth) {
+	    me.rows[name] = {
+		header: name,
+		required: true,
+		defaultValue: 'nolog',
+		editor: {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: name,
+		    fieldDefaults: { labelWidth: labelWidth || 100 },
+		    items: {
+			xtype: 'pveFirewallLogLevels',
+			name: name,
+			fieldLabel: name,
+		    },
+		},
+	    };
+	};
+
+	if (me.fwtype === 'node') {
+	    me.rows.enable = {
+		required: true,
+		defaultValue: 1,
+		header: gettext('Firewall'),
+		renderer: Proxmox.Utils.format_boolean,
+		editor: {
+		    xtype: 'pveFirewallEnableEdit',
+		    defaultValue: 1,
+		},
+	    };
+	    add_boolean_row('nosmurfs', gettext('SMURFS filter'), 1);
+	    add_boolean_row('tcpflags', gettext('TCP flags filter'), 0);
+	    add_boolean_row('ndp', 'NDP', 1);
+	    add_integer_row('nf_conntrack_max', 'nf_conntrack_max', 32768, 120);
+	    add_integer_row('nf_conntrack_tcp_timeout_established',
+			    'nf_conntrack_tcp_timeout_established', 7875, 250);
+	    add_log_row('log_level_in');
+	    add_log_row('log_level_out');
+	    add_log_row('tcp_flags_log_level', 120);
+	    add_log_row('smurf_log_level');
+	    add_boolean_row('nftables', gettext('nftables (tech preview)'), 0);
+	} else if (me.fwtype === 'vm') {
+	    me.rows.enable = {
+		required: true,
+		defaultValue: 0,
+		header: gettext('Firewall'),
+		renderer: Proxmox.Utils.format_boolean,
+		editor: {
+		    xtype: 'pveFirewallEnableEdit',
+		    defaultValue: 0,
+		},
+	    };
+	    add_boolean_row('dhcp', 'DHCP', 1);
+	    add_boolean_row('ndp', 'NDP', 1);
+	    add_boolean_row('radv', gettext('Router Advertisement'), 0);
+	    add_boolean_row('macfilter', gettext('MAC filter'), 1);
+	    add_boolean_row('ipfilter', gettext('IP filter'), 0);
+	    add_log_row('log_level_in');
+	    add_log_row('log_level_out');
+	} else if (me.fwtype === 'dc') {
+	    add_boolean_row('enable', gettext('Firewall'), 0);
+	    add_boolean_row('ebtables', 'ebtables', 1);
+	    me.rows.log_ratelimit = {
+		header: gettext('Log rate limit'),
+		required: true,
+		defaultValue: gettext('Default') + ' (enable=1,rate1/second,burst=5)',
+		editor: {
+		    xtype: 'pveFirewallLograteEdit',
+		    defaultValue: 'enable=1',
+		},
+	    };
+	}
+
+	if (me.fwtype === 'dc' || me.fwtype === 'vm') {
+	    me.rows.policy_in = {
+		header: gettext('Input Policy'),
+		required: true,
+		defaultValue: 'DROP',
+		editor: {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Input Policy'),
+		    items: {
+			xtype: 'pveFirewallPolicySelector',
+			name: 'policy_in',
+			value: 'DROP',
+			fieldLabel: gettext('Input Policy'),
+		    },
+		},
+	    };
+
+	    me.rows.policy_out = {
+		header: gettext('Output Policy'),
+		required: true,
+		defaultValue: 'ACCEPT',
+		editor: {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Output Policy'),
+		    items: {
+			xtype: 'pveFirewallPolicySelector',
+			name: 'policy_out',
+			value: 'ACCEPT',
+			fieldLabel: gettext('Output Policy'),
+		    },
+		},
+	    };
+	}
+
+	var edit_btn = new Ext.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    handler: function() { me.run_editor(); },
+	});
+
+	var set_button_status = function() {
+	    var sm = me.getSelectionModel();
+	    var rec = sm.getSelection()[0];
+
+	    if (!rec) {
+		edit_btn.disable();
+		return;
+	    }
+	    var rowdef = me.rows[rec.data.key];
+	    if (canEdit) {
+		edit_btn.setDisabled(!rowdef.editor);
+	    }
+	};
+
+	Ext.apply(me, {
+	    url: "/api2/json" + me.base_url,
+	    tbar: [edit_btn],
+	    editorConfig: {
+		url: '/api2/extjs/' + me.base_url,
+	    },
+	    listeners: {
+		itemdblclick: () => { if (canEdit) { me.run_editor(); } },
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+    },
+});
+
+
+Ext.define('PVE.FirewallLogLevels', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveFirewallLogLevels'],
+
+    name: 'log',
+    fieldLabel: gettext('Log level'),
+    value: 'nolog',
+    comboItems: [['nolog', 'nolog'], ['emerg', 'emerg'], ['alert', 'alert'],
+	['crit', 'crit'], ['err', 'err'], ['warning', 'warning'],
+	['notice', 'notice'], ['info', 'info'], ['debug', 'debug']],
+});
+Ext.define('PVE.form.FWMacroSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pveFWMacroSelector',
+    allowBlank: true,
+    autoSelect: false,
+    valueField: 'macro',
+    displayField: 'macro',
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Macro'),
+		dataIndex: 'macro',
+		hideable: false,
+		width: 100,
+	    },
+	    {
+		header: gettext('Description'),
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+		dataIndex: 'descr',
+	    },
+	],
+    },
+    initComponent: function() {
+	var me = this;
+
+	var store = Ext.create('Ext.data.Store', {
+	    autoLoad: true,
+	    fields: ['macro', 'descr'],
+	    idProperty: 'macro',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/firewall/macros",
+	    },
+	    sorters: {
+		property: 'macro',
+		direction: 'ASC',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.form.ICMPTypeSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pveICMPTypeSelector',
+    allowBlank: true,
+    autoSelect: false,
+    valueField: 'name',
+    displayField: 'name',
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Type'),
+		dataIndex: 'type',
+		hideable: false,
+		sortable: false,
+		width: 50,
+	    },
+	    {
+		header: gettext('Name'),
+		dataIndex: 'name',
+		hideable: false,
+		sortable: false,
+		flex: 1,
+	    },
+	],
+    },
+    setName: function(value) {
+	this.name = value;
+    },
+});
+
+let ICMP_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', {
+    field: ['type', 'name'],
+    data: [
+	{ type: 'any', name: 'any' },
+	{ type: '0', name: 'echo-reply' },
+	{ type: '3', name: 'destination-unreachable' },
+	{ type: '3/0', name: 'network-unreachable' },
+	{ type: '3/1', name: 'host-unreachable' },
+	{ type: '3/2', name: 'protocol-unreachable' },
+	{ type: '3/3', name: 'port-unreachable' },
+	{ type: '3/4', name: 'fragmentation-needed' },
+	{ type: '3/5', name: 'source-route-failed' },
+	{ type: '3/6', name: 'network-unknown' },
+	{ type: '3/7', name: 'host-unknown' },
+	{ type: '3/9', name: 'network-prohibited' },
+	{ type: '3/10', name: 'host-prohibited' },
+	{ type: '3/11', name: 'TOS-network-unreachable' },
+	{ type: '3/12', name: 'TOS-host-unreachable' },
+	{ type: '3/13', name: 'communication-prohibited' },
+	{ type: '3/14', name: 'host-precedence-violation' },
+	{ type: '3/15', name: 'precedence-cutoff' },
+	{ type: '4', name: 'source-quench' },
+	{ type: '5', name: 'redirect' },
+	{ type: '5/0', name: 'network-redirect' },
+	{ type: '5/1', name: 'host-redirect' },
+	{ type: '5/2', name: 'TOS-network-redirect' },
+	{ type: '5/3', name: 'TOS-host-redirect' },
+	{ type: '8', name: 'echo-request' },
+	{ type: '9', name: 'router-advertisement' },
+	{ type: '10', name: 'router-solicitation' },
+	{ type: '11', name: 'time-exceeded' },
+	{ type: '11/0', name: 'ttl-zero-during-transit' },
+	{ type: '11/1', name: 'ttl-zero-during-reassembly' },
+	{ type: '12', name: 'parameter-problem' },
+	{ type: '12/0', name: 'ip-header-bad' },
+	{ type: '12/1', name: 'required-option-missing' },
+	{ type: '13', name: 'timestamp-request' },
+	{ type: '14', name: 'timestamp-reply' },
+	{ type: '17', name: 'address-mask-request' },
+	{ type: '18', name: 'address-mask-reply' },
+    ],
+});
+let ICMPV6_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', {
+    field: ['type', 'name'],
+    data: [
+	{ type: '1', name: 'destination-unreachable' },
+	{ type: '1/0', name: 'no-route' },
+	{ type: '1/1', name: 'communication-prohibited' },
+	{ type: '1/2', name: 'beyond-scope' },
+	{ type: '1/3', name: 'address-unreachable' },
+	{ type: '1/4', name: 'port-unreachable' },
+	{ type: '1/5', name: 'failed-policy' },
+	{ type: '1/6', name: 'reject-route' },
+	{ type: '2', name: 'packet-too-big' },
+	{ type: '3', name: 'time-exceeded' },
+	{ type: '3/0', name: 'ttl-zero-during-transit' },
+	{ type: '3/1', name: 'ttl-zero-during-reassembly' },
+	{ type: '4', name: 'parameter-problem' },
+	{ type: '4/0', name: 'bad-header' },
+	{ type: '4/1', name: 'unknown-header-type' },
+	{ type: '4/2', name: 'unknown-option' },
+	{ type: '128', name: 'echo-request' },
+	{ type: '129', name: 'echo-reply' },
+	{ type: '133', name: 'router-solicitation' },
+	{ type: '134', name: 'router-advertisement' },
+	{ type: '135', name: 'neighbour-solicitation' },
+	{ type: '136', name: 'neighbour-advertisement' },
+	{ type: '137', name: 'redirect' },
+    ],
+});
+
+Ext.define('PVE.FirewallRulePanel', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    allow_iface: false,
+
+    list_refs_url: undefined,
+
+    onGetValues: function(values) {
+	var me = this;
+
+	// hack: editable ComboGrid returns nothing when empty, so we need to set ''
+	// Also, disabled text fields return nothing, so we need to set ''
+
+	Ext.Array.each(['source', 'dest', 'macro', 'proto', 'sport', 'dport', 'icmp-type', 'log'], function(key) {
+	    if (values[key] === undefined) {
+		values[key] = '';
+	    }
+	});
+
+	delete values.modified_marker;
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.list_refs_url) {
+	    throw "no list_refs_url specified";
+	}
+
+	me.column1 = [
+	    {
+		// hack: we use this field to mark the form 'dirty' when the
+		// record has errors- so that the user can safe the unmodified
+		// form again.
+		xtype: 'hiddenfield',
+		name: 'modified_marker',
+		value: '',
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'type',
+		value: 'in',
+		comboItems: [['in', 'in'], ['out', 'out']],
+		fieldLabel: gettext('Direction'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'action',
+		value: 'ACCEPT',
+		comboItems: [['ACCEPT', 'ACCEPT'], ['DROP', 'DROP'], ['REJECT', 'REJECT']],
+		fieldLabel: gettext('Action'),
+		allowBlank: false,
+	    },
+        ];
+
+	if (me.allow_iface) {
+	    me.column1.push({
+		xtype: 'proxmoxtextfield',
+		name: 'iface',
+		deleteEmpty: !me.isCreate,
+		value: '',
+		fieldLabel: gettext('Interface'),
+	    });
+	} else {
+	    me.column1.push({
+		xtype: 'displayfield',
+		fieldLabel: '',
+		value: '',
+	    });
+	}
+
+	me.column1.push(
+	    {
+		xtype: 'displayfield',
+		fieldLabel: '',
+		height: 7,
+		value: '',
+	    },
+	    {
+		xtype: 'pveIPRefSelector',
+		name: 'source',
+		autoSelect: false,
+		editable: true,
+		base_url: me.list_refs_url,
+		fieldLabel: gettext('Source'),
+		maxLength: 512,
+		maxLengthText: gettext('Too long, consider using IP sets.'),
+	    },
+	    {
+		xtype: 'pveIPRefSelector',
+		name: 'dest',
+		autoSelect: false,
+		editable: true,
+		base_url: me.list_refs_url,
+		fieldLabel: gettext('Destination'),
+		maxLength: 512,
+		maxLengthText: gettext('Too long, consider using IP sets.'),
+	    },
+	);
+
+
+	me.column2 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'enable',
+		checked: false,
+		uncheckedValue: 0,
+		fieldLabel: gettext('Enable'),
+	    },
+	    {
+		xtype: 'pveFWMacroSelector',
+		name: 'macro',
+		fieldLabel: gettext('Macro'),
+		editable: true,
+		allowBlank: true,
+		listeners: {
+		    change: function(f, value) {
+                        if (value === null) {
+			    me.down('field[name=proto]').setDisabled(false);
+			    me.down('field[name=sport]').setDisabled(false);
+			    me.down('field[name=dport]').setDisabled(false);
+                        } else {
+			    me.down('field[name=proto]').setDisabled(true);
+			    me.down('field[name=proto]').setValue('');
+			    me.down('field[name=sport]').setDisabled(true);
+			    me.down('field[name=sport]').setValue('');
+			    me.down('field[name=dport]').setDisabled(true);
+			    me.down('field[name=dport]').setValue('');
+                       }
+                    },
+                },
+	    },
+	    {
+		xtype: 'pveIPProtocolSelector',
+		name: 'proto',
+		autoSelect: false,
+		editable: true,
+		value: '',
+		fieldLabel: gettext('Protocol'),
+		listeners: {
+		    change: function(f, value) {
+			if (value === 'icmp' || value === 'icmpv6' || value === 'ipv6-icmp') {
+			    me.down('field[name=dport]').setHidden(true);
+			    me.down('field[name=dport]').setDisabled(true);
+			    if (value === 'icmp') {
+				me.down('#icmpv4-type').setHidden(false);
+				me.down('#icmpv4-type').setDisabled(false);
+				me.down('#icmpv6-type').setHidden(true);
+				me.down('#icmpv6-type').setDisabled(true);
+			    } else {
+				me.down('#icmpv6-type').setHidden(false);
+				me.down('#icmpv6-type').setDisabled(false);
+				me.down('#icmpv4-type').setHidden(true);
+				me.down('#icmpv4-type').setDisabled(true);
+			    }
+			} else {
+			    me.down('#icmpv4-type').setHidden(true);
+			    me.down('#icmpv4-type').setDisabled(true);
+			    me.down('#icmpv6-type').setHidden(true);
+			    me.down('#icmpv6-type').setDisabled(true);
+			    me.down('field[name=dport]').setHidden(false);
+			    me.down('field[name=dport]').setDisabled(false);
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: '',
+		height: 7,
+		value: '',
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'sport',
+		value: '',
+		fieldLabel: gettext('Source port'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'dport',
+		value: '',
+		fieldLabel: gettext('Dest. port'),
+	    },
+	    {
+		xtype: 'pveICMPTypeSelector',
+		name: 'icmp-type',
+		id: 'icmpv4-type',
+		autoSelect: false,
+		editable: true,
+		hidden: true,
+		disabled: true,
+		value: '',
+		fieldLabel: gettext('ICMP type'),
+		store: ICMP_TYPE_NAMES_STORE,
+	    },
+	    {
+		xtype: 'pveICMPTypeSelector',
+		name: 'icmp-type',
+		id: 'icmpv6-type',
+		autoSelect: false,
+		editable: true,
+		hidden: true,
+		disabled: true,
+		value: '',
+		fieldLabel: gettext('ICMP type'),
+		store: ICMPV6_TYPE_NAMES_STORE,
+	    },
+	];
+
+	me.advancedColumn1 = [
+	    {
+		xtype: 'pveFirewallLogLevels',
+	    },
+	];
+
+	me.columnB = [
+	    {
+		xtype: 'textfield',
+		name: 'comment',
+		value: '',
+		fieldLabel: gettext('Comment'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.FirewallRuleEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    base_url: undefined,
+    list_refs_url: undefined,
+
+    allow_iface: false,
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.base_url) {
+	    throw "no base_url specified";
+	}
+	if (!me.list_refs_url) {
+	    throw "no list_refs_url specified";
+	}
+
+	me.isCreate = me.rule_pos === undefined;
+
+	if (me.isCreate) {
+            me.url = '/api2/extjs' + me.base_url;
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString();
+            me.method = 'PUT';
+        }
+
+	var ipanel = Ext.create('PVE.FirewallRulePanel', {
+	    isCreate: me.isCreate,
+	    list_refs_url: me.list_refs_url,
+	    allow_iface: me.allow_iface,
+	    rule_pos: me.rule_pos,
+	});
+
+	Ext.apply(me, {
+            subject: gettext('Rule'),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    ipanel.setValues(values);
+		    // set icmp-type again after protocol has been set
+		    if (values["icmp-type"] !== undefined) {
+			ipanel.setValues({ "icmp-type": values["icmp-type"] });
+		    }
+		    if (values.errors) {
+			var field = me.query('[isFormField][name=modified_marker]')[0];
+			field.setValue(1);
+			Ext.Function.defer(function() {
+			    var form = ipanel.up('form').getForm();
+			    form.markInvalid(values.errors);
+			}, 100);
+		    }
+		},
+	    });
+	} else if (me.rec) {
+	    ipanel.setValues(me.rec.data);
+	}
+    },
+});
+
+Ext.define('PVE.FirewallGroupRuleEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    base_url: undefined,
+
+    allow_iface: false,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = me.rule_pos === undefined;
+
+	if (me.isCreate) {
+            me.url = '/api2/extjs' + me.base_url;
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString();
+            me.method = 'PUT';
+        }
+
+	var column1 = [
+	    {
+		xtype: 'hiddenfield',
+		name: 'type',
+		value: 'group',
+	    },
+	    {
+		xtype: 'pveSecurityGroupsSelector',
+		name: 'action',
+		value: '',
+		fieldLabel: gettext('Security Group'),
+		allowBlank: false,
+	    },
+	];
+
+	if (me.allow_iface) {
+	    column1.push({
+		xtype: 'proxmoxtextfield',
+		name: 'iface',
+		deleteEmpty: !me.isCreate,
+		value: '',
+		fieldLabel: gettext('Interface'),
+	    });
+	}
+
+	var ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	    isCreate: me.isCreate,
+	    column1: column1,
+	    column2: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'enable',
+		    checked: false,
+		    uncheckedValue: 0,
+		    fieldLabel: gettext('Enable'),
+		},
+	    ],
+	    columnB: [
+		{
+		    xtype: 'textfield',
+		    name: 'comment',
+		    value: '',
+		    fieldLabel: gettext('Comment'),
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+            subject: gettext('Rule'),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+
+Ext.define('PVE.FirewallRules', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveFirewallRules',
+
+    onlineHelp: 'chapter_pve_firewall',
+
+    stateful: true,
+    stateId: 'grid-firewall-rules',
+
+    base_url: undefined,
+    list_refs_url: undefined,
+
+    addBtn: undefined,
+    removeBtn: undefined,
+    editBtn: undefined,
+    groupBtn: undefined,
+
+    tbar_prefix: undefined,
+
+    allow_groups: true,
+    allow_iface: false,
+
+    setBaseUrl: function(url) {
+        var me = this;
+
+	me.base_url = url;
+
+	if (url === undefined) {
+	    me.addBtn.setDisabled(true);
+	    if (me.groupBtn) {
+		me.groupBtn.setDisabled(true);
+	    }
+	    me.store.removeAll();
+	} else {
+	    if (me.canEdit) {
+		me.addBtn.setDisabled(false);
+		if (me.groupBtn) {
+		    me.groupBtn.setDisabled(false);
+		}
+	    }
+	    me.removeBtn.baseurl = url + '/';
+
+	    me.store.setProxy({
+		type: 'proxmox',
+		url: '/api2/json' + url,
+	    });
+
+	    me.store.load();
+	}
+    },
+
+    moveRule: function(from, to) {
+        var me = this;
+
+	if (!me.base_url) {
+	    return;
+	}
+
+	Proxmox.Utils.API2Request({
+	    url: me.base_url + "/" + from,
+	    method: 'PUT',
+	    params: { moveto: to },
+	    waitMsgTarget: me,
+	    failure: function(response, options) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    callback: function() {
+		me.store.load();
+	    },
+	});
+    },
+
+    updateRule: function(rule) {
+        var me = this;
+
+	if (!me.base_url) {
+	    return;
+	}
+
+	rule.enable = rule.enable ? 1 : 0;
+
+	var pos = rule.pos;
+	delete rule.pos;
+	delete rule.errors;
+
+	Proxmox.Utils.API2Request({
+	    url: me.base_url + '/' + pos.toString(),
+	    method: 'PUT',
+	    params: rule,
+	    waitMsgTarget: me,
+	    failure: function(response, options) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    callback: function() {
+		me.store.load();
+	    },
+	});
+    },
+
+
+    initComponent: function() {
+        var me = this;
+
+	if (!me.list_refs_url) {
+	    throw "no list_refs_url specified";
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    model: 'pve-fw-rule',
+	});
+
+	var reload = function() {
+	    store.load();
+	};
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	me.caps = Ext.state.Manager.get('GuiCap');
+	me.canEdit = !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'];
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec || !me.canEdit) {
+		return;
+	    }
+	    var type = rec.data.type;
+
+	    var editor;
+	    if (type === 'in' || type === 'out') {
+		editor = 'PVE.FirewallRuleEdit';
+	    } else if (type === 'group') {
+		editor = 'PVE.FirewallGroupRuleEdit';
+	    } else {
+		return;
+	    }
+
+	    var win = Ext.create(editor, {
+		digest: rec.data.digest,
+		allow_iface: me.allow_iface,
+		base_url: me.base_url,
+		list_refs_url: me.list_refs_url,
+		rule_pos: rec.data.pos,
+	    });
+
+	    win.show();
+	    win.on('destroy', reload);
+	};
+
+	me.editBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Edit'),
+	    disabled: true,
+	    enableFn: rec => me.canEdit,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	me.addBtn = Ext.create('Ext.Button', {
+	    text: gettext('Add'),
+	    disabled: true,
+	    handler: function() {
+		var win = Ext.create('PVE.FirewallRuleEdit', {
+		    allow_iface: me.allow_iface,
+		    base_url: me.base_url,
+		    list_refs_url: me.list_refs_url,
+		});
+		win.on('destroy', reload);
+		win.show();
+	    },
+	});
+
+	var run_copy_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    let type = rec.data.type;
+	    if (!(type === 'in' || type === 'out')) {
+		return;
+	    }
+
+	    let win = Ext.create('PVE.FirewallRuleEdit', {
+		allow_iface: me.allow_iface,
+		base_url: me.base_url,
+		list_refs_url: me.list_refs_url,
+		rec: rec,
+	    });
+	    win.show();
+	    win.on('destroy', reload);
+	};
+
+	me.copyBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Copy'),
+	    selModel: sm,
+	    enableFn: ({ data }) => (data.type === 'in' || data.type === 'out') && me.canEdit,
+	    disabled: true,
+	    handler: run_copy_editor,
+	});
+
+	if (me.allow_groups) {
+	    me.groupBtn = Ext.create('Ext.Button', {
+		text: gettext('Insert') + ': ' +
+		    gettext('Security Group'),
+		disabled: true,
+		handler: function() {
+		    var win = Ext.create('PVE.FirewallGroupRuleEdit', {
+			allow_iface: me.allow_iface,
+			base_url: me.base_url,
+		    });
+		    win.on('destroy', reload);
+		    win.show();
+		},
+	    });
+	}
+
+	me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    enableFn: rec => me.canEdit,
+	    selModel: sm,
+	    baseurl: me.base_url + '/',
+	    confirmMsg: false,
+	    getRecordName: function(rec) {
+		var rule = rec.data;
+		return rule.pos.toString() +
+		    '?digest=' + encodeURIComponent(rule.digest);
+	    },
+	    callback: function() {
+		me.store.load();
+	    },
+	});
+
+	let tbar = me.tbar_prefix ? [me.tbar_prefix] : [];
+	tbar.push(me.addBtn, me.copyBtn);
+	if (me.groupBtn) {
+	    tbar.push(me.groupBtn);
+	}
+	tbar.push(me.removeBtn, me.editBtn);
+
+	let render_errors = function(name, value, metaData, record) {
+	    let errors = record.data.errors;
+	    if (errors && errors[name]) {
+		metaData.tdCls = 'proxmox-invalid-row';
+		let html = '<p>' + Ext.htmlEncode(errors[name]) + '</p>';
+		metaData.tdAttr = 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + html + '"';
+	    }
+	    return value;
+	};
+
+	let columns = [
+	    {
+		// similar to xtype: 'rownumberer',
+		dataIndex: 'pos',
+		resizable: false,
+		minWidth: 65,
+		maxWidth: 83,
+		flex: 1,
+		sortable: false,
+		hideable: false,
+		menuDisabled: true,
+		renderer: function(value, metaData, record, rowIdx, colIdx) {
+		    metaData.tdCls = Ext.baseCSSPrefix + 'grid-cell-special';
+		    let dragHandle = "<i class='pve-grid-fa fa fa-fw fa-reorder cursor-move'></i>";
+		    if (value >= 0) {
+			return dragHandle + value;
+		    }
+		    return dragHandle;
+		},
+	    },
+	    {
+		xtype: 'checkcolumn',
+		header: gettext('On'),
+		dataIndex: 'enable',
+		listeners: {
+		    checkchange: function(column, recordIndex, checked) {
+			var record = me.getStore().getData().items[recordIndex];
+			record.commit();
+			var data = {};
+			Ext.Array.forEach(record.getFields(), function(field) {
+			    data[field.name] = record.get(field.name);
+			});
+			if (!me.allow_iface || !data.iface) {
+			    delete data.iface;
+			}
+			me.updateRule(data);
+		    },
+		},
+		width: 40,
+	    },
+	    {
+		header: gettext('Type'),
+		dataIndex: 'type',
+		renderer: function(value, metaData, record) {
+		    return render_errors('type', value, metaData, record);
+		},
+		minWidth: 60,
+		maxWidth: 80,
+		flex: 2,
+	    },
+	    {
+		header: gettext('Action'),
+		dataIndex: 'action',
+		renderer: function(value, metaData, record) {
+		    return render_errors('action', value, metaData, record);
+		},
+		minWidth: 80,
+		maxWidth: 200,
+		flex: 2,
+	    },
+	    {
+		header: gettext('Macro'),
+		dataIndex: 'macro',
+		renderer: function(value, metaData, record) {
+		    return render_errors('macro', value, metaData, record);
+		},
+		minWidth: 80,
+		flex: 2,
+	    },
+	];
+
+	if (me.allow_iface) {
+	    columns.push({
+		header: gettext('Interface'),
+		dataIndex: 'iface',
+		renderer: function(value, metaData, record) {
+		    return render_errors('iface', value, metaData, record);
+		},
+		minWidth: 80,
+		flex: 2,
+	    });
+	}
+
+	columns.push(
+	    {
+		header: gettext('Protocol'),
+		dataIndex: 'proto',
+		renderer: function(value, metaData, record) {
+		    return render_errors('proto', value, metaData, record);
+		},
+		width: 75,
+	    },
+	    {
+		header: gettext('Source'),
+		dataIndex: 'source',
+		renderer: function(value, metaData, record) {
+		    return render_errors('source', value, metaData, record);
+		},
+		minWidth: 100,
+		flex: 2,
+	    },
+	    {
+		header: gettext('S.Port'),
+		dataIndex: 'sport',
+		renderer: function(value, metaData, record) {
+		    return render_errors('sport', value, metaData, record);
+		},
+		width: 75,
+	    },
+	    {
+		header: gettext('Destination'),
+		dataIndex: 'dest',
+		renderer: function(value, metaData, record) {
+		    return render_errors('dest', value, metaData, record);
+		},
+		minWidth: 100,
+		flex: 2,
+	    },
+	    {
+		header: gettext('D.Port'),
+		dataIndex: 'dport',
+		renderer: function(value, metaData, record) {
+		    return render_errors('dport', value, metaData, record);
+		},
+		width: 75,
+	    },
+	    {
+		header: gettext('Log level'),
+		dataIndex: 'log',
+		renderer: function(value, metaData, record) {
+		    return render_errors('log', value, metaData, record);
+		},
+		width: 100,
+	    },
+	    {
+		header: gettext('Comment'),
+		dataIndex: 'comment',
+		flex: 10,
+		minWidth: 75,
+		renderer: function(value, metaData, record) {
+		    let comment = render_errors('comment', Ext.util.Format.htmlEncode(value), metaData, record) || '';
+		    if (comment.length * 12 > metaData.column.cellWidth) {
+			comment = `<span data-qtip="${comment}">${comment}</span>`;
+		    }
+		    return comment;
+		},
+	    },
+	);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: tbar,
+	    viewConfig: {
+		plugins: [
+		    {
+			ptype: 'gridviewdragdrop',
+			dragGroup: 'FWRuleDDGroup',
+			dropGroup: 'FWRuleDDGroup',
+		    },
+		],
+		listeners: {
+		    beforedrop: function(node, data, dropRec, dropPosition) {
+			if (!dropRec) {
+			    return false; // empty view
+			}
+			let moveto = dropRec.get('pos');
+			if (dropPosition === 'after') {
+			    moveto++;
+			}
+			let pos = data.records[0].get('pos');
+			me.moveRule(pos, moveto);
+			return 0;
+                    },
+		    itemdblclick: run_editor,
+		},
+	    },
+	    sortableColumns: false,
+	    columns: columns,
+	});
+
+	me.callParent();
+
+	if (me.base_url) {
+	    me.setBaseUrl(me.base_url); // load
+	}
+    },
+}, function() {
+    Ext.define('pve-fw-rule', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    { name: 'enable', type: 'boolean' },
+	    'type',
+	    'action',
+	    'macro',
+	    'source',
+	    'dest',
+	    'proto',
+	    'iface',
+	    'dport',
+	    'sport',
+	    'comment',
+	    'pos',
+	    'digest',
+	    'errors',
+	],
+	idProperty: 'pos',
+    });
+});
+Ext.define('PVE.pool.AddVM', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 640,
+    height: 480,
+    isAdd: true,
+    isCreate: true,
+
+    extraRequestParams: {
+	'allow-move': 1,
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.pool) {
+	    throw "no pool specified";
+	}
+
+	me.url = '/pools/';
+	me.method = 'PUT';
+	me.extraRequestParams.poolid = me.pool;
+
+	var vmsField = Ext.create('Ext.form.field.Text', {
+	    name: 'vms',
+	    hidden: true,
+	    allowBlank: false,
+	});
+
+	var vmStore = Ext.create('Ext.data.Store', {
+	    model: 'PVEResources',
+	    sorters: [
+		{
+		    property: 'vmid',
+		    direction: 'ASC',
+		},
+	    ],
+	    filters: [
+		function(item) {
+		    return (item.data.type === 'lxc' || item.data.type === 'qemu') &&item.data.pool !== me.pool;
+		},
+	    ],
+	});
+
+	var vmGrid = Ext.create('widget.grid', {
+	    store: vmStore,
+	    border: true,
+	    height: 360,
+	    scrollable: true,
+	    selModel: {
+		selType: 'checkboxmodel',
+		mode: 'SIMPLE',
+		listeners: {
+		    selectionchange: function(model, selected, opts) {
+			var selectedVms = [];
+			selected.forEach(function(vm) {
+			    selectedVms.push(vm.data.vmid);
+			});
+			vmsField.setValue(selectedVms);
+		    },
+		},
+	    },
+	    columns: [
+		{
+		    header: 'ID',
+		    dataIndex: 'vmid',
+		    width: 60,
+		},
+		{
+		    header: gettext('Node'),
+		    dataIndex: 'node',
+		},
+		{
+		    header: gettext('Current Pool'),
+		    dataIndex: 'pool',
+		},
+		{
+		    header: gettext('Status'),
+		    dataIndex: 'uptime',
+		    renderer: v => v ? Proxmox.Utils.runningText : Proxmox.Utils.stoppedText,
+		},
+		{
+		    header: gettext('Name'),
+		    dataIndex: 'name',
+		    flex: 1,
+		},
+		{
+		    header: gettext('Type'),
+		    dataIndex: 'type',
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    subject: gettext('Virtual Machine'),
+	    items: [
+		vmsField,
+		vmGrid,
+		{
+		    xtype: 'displayfield',
+		    userCls: 'pmx-hint',
+		    value: gettext('Selected guests who are already part of a pool will be removed from it first.'),
+		},
+	    ],
+	});
+
+	me.callParent();
+	vmStore.load();
+    },
+});
+
+Ext.define('PVE.pool.AddStorage', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.pool) {
+	    throw "no pool specified";
+	}
+
+	me.isCreate = true;
+	me.isAdd = true;
+	me.url = "/pools/";
+	me.method = 'PUT';
+	me.extraRequestParams.poolid = me.pool;
+
+	Ext.apply(me, {
+	    subject: gettext('Storage'),
+	    width: 350,
+	    items: [
+		{
+		    xtype: 'pveStorageSelector',
+		    name: 'storage',
+		    nodename: 'localhost',
+		    autoSelect: false,
+		    value: '',
+		    fieldLabel: gettext("Storage"),
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.grid.PoolMembers', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pvePoolMembers'],
+
+    // fixme: dynamic status update ?
+
+    stateful: true,
+    stateId: 'grid-pool-members',
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.pool) {
+	    throw "no pool specified";
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    model: 'PVEResources',
+	    sorters: [
+		{
+		    property: 'type',
+		    direction: 'ASC',
+		},
+	    ],
+	    proxy: {
+		type: 'proxmox',
+		root: 'data[0].members',
+		url: "/api2/json/pools/?poolid=" + me.pool,
+	    },
+	});
+
+	var coldef = PVE.data.ResourceStore.defaultColumns().filter((c) =>
+	    c.dataIndex !== 'tags' && c.dataIndex !== 'lock',
+	);
+
+	var reload = function() {
+	    store.load();
+	};
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var remove_btn = new Proxmox.button.Button({
+	    text: gettext('Remove'),
+	    disabled: true,
+	    selModel: sm,
+	    confirmMsg: function(rec) {
+		return Ext.String.format(gettext('Are you sure you want to remove entry {0}'),
+					 "'" + rec.data.id + "'");
+	    },
+	    handler: function(btn, event, rec) {
+		var params = { 'delete': 1, poolid: me.pool };
+		if (rec.data.type === 'storage') {
+		    params.storage = rec.data.storage;
+		} else if (rec.data.type === 'qemu' || rec.data.type === 'lxc' || rec.data.type === 'openvz') {
+		    params.vms = rec.data.vmid;
+		} else {
+		    throw "unknown resource type";
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: '/pools/',
+		    method: 'PUT',
+		    params: params,
+		    waitMsgTarget: me,
+		    callback: function() {
+			reload();
+		    },
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    },
+		});
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: [
+			    {
+				text: gettext('Virtual Machine'),
+				iconCls: 'pve-itype-icon-qemu',
+				handler: function() {
+				    var win = Ext.create('PVE.pool.AddVM', { pool: me.pool });
+				    win.on('destroy', reload);
+				    win.show();
+				},
+			    },
+			    {
+				text: gettext('Storage'),
+				iconCls: 'pve-itype-icon-storage',
+				handler: function() {
+				    var win = Ext.create('PVE.pool.AddStorage', { pool: me.pool });
+				    win.on('destroy', reload);
+				    win.show();
+				},
+			    },
+			],
+		    }),
+		},
+		remove_btn,
+	    ],
+	    viewConfig: {
+		stripeRows: true,
+            },
+            columns: coldef,
+	    listeners: {
+		itemcontextmenu: PVE.Utils.createCmdMenu,
+		itemdblclick: function(v, record) {
+		    var ws = me.up('pveStdWorkspace');
+		    ws.selectById(record.data.id);
+		},
+		activate: reload,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.window.ReplicaEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveReplicaEdit',
+
+    subject: gettext('Replication Job'),
+
+
+    url: '/cluster/replication',
+    method: 'POST',
+
+    initComponent: function() {
+	var me = this;
+
+	var vmid = me.pveSelNode.data.vmid;
+	var nodename = me.pveSelNode.data.node;
+
+	var items = [];
+
+	items.push({
+	    xtype: me.isCreate && !vmid?'pveGuestIDSelector':'displayfield',
+	    name: 'guest',
+	    fieldLabel: 'CT/VM ID',
+	    value: vmid || '',
+	});
+
+	items.push(
+	    {
+		xtype: me.isCreate ? 'pveNodeSelector':'displayfield',
+		name: 'target',
+		disallowedNodes: [nodename],
+		allowBlank: false,
+		onlineValidator: true,
+		fieldLabel: gettext("Target"),
+	    },
+	    {
+		xtype: 'pveCalendarEvent',
+		fieldLabel: gettext('Schedule'),
+		emptyText: '*/15 - ' + Ext.String.format(gettext('Every {0} minutes'), 15),
+		name: 'schedule',
+	    },
+	    {
+		xtype: 'numberfield',
+		fieldLabel: gettext('Rate limit') + ' (MB/s)',
+		step: 1,
+		minValue: 1,
+		emptyText: gettext('unlimited'),
+		name: 'rate',
+	    },
+	    {
+		xtype: 'textfield',
+		fieldLabel: gettext('Comment'),
+		name: 'comment',
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'enabled',
+		defaultValue: 'on',
+		checked: true,
+		fieldLabel: gettext('Enabled'),
+	    },
+	);
+
+	me.items = [
+	    {
+		xtype: 'inputpanel',
+		itemId: 'ipanel',
+		onlineHelp: 'pvesr_schedule_time_format',
+
+		onGetValues: function(values) {
+		    let win = this.up('window');
+
+		    values.disable = values.enabled ? 0 : 1;
+		    delete values.enabled;
+
+		    PVE.Utils.delete_if_default(values, 'rate', '', win.isCreate);
+		    PVE.Utils.delete_if_default(values, 'disable', 0, win.isCreate);
+		    PVE.Utils.delete_if_default(values, 'schedule', '*/15', win.isCreate);
+		    PVE.Utils.delete_if_default(values, 'comment', '', win.isCreate);
+
+		    if (win.isCreate) {
+			values.type = 'local';
+			let vm = vmid || values.guest;
+			let id = -1;
+			if (win.highestids[vm] !== undefined) {
+			    id = win.highestids[vm];
+			}
+			id++;
+			values.id = vm + '-' + id.toString();
+			delete values.guest;
+		    }
+		    return values;
+		},
+		items: items,
+	    },
+	];
+
+	me.callParent();
+
+	if (me.isCreate) {
+	    me.load({
+		success: function(response) {
+		    var jobs = response.result.data;
+		    var highestids = {};
+		    Ext.Array.forEach(jobs, function(job) {
+			var match = /^([0-9]+)-([0-9]+)$/.exec(job.id);
+			if (match) {
+			    let jobVMID = parseInt(match[1], 10);
+			    let id = parseInt(match[2], 10);
+			    if (highestids[jobVMID] === undefined || highestids[jobVMID] < id) {
+				highestids[jobVMID] = id;
+			    }
+			}
+		    });
+		    me.highestids = highestids;
+		},
+	    });
+	} else {
+	    me.load({
+		success: function(response, options) {
+		    response.result.data.enabled = !response.result.data.disable;
+		    me.setValues(response.result.data);
+		    me.digest = response.result.data.digest;
+		},
+	    });
+	}
+    },
+});
+
+/* callback is a function and string */
+Ext.define('PVE.grid.ReplicaView', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveReplicaView',
+
+    onlineHelp: 'chapter_pvesr',
+
+    stateful: true,
+    stateId: 'grid-pve-replication-status',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addJob: function(button, event, rec) {
+	    let me = this;
+	    let view = me.getView();
+	    Ext.create('PVE.window.ReplicaEdit', {
+		isCreate: true,
+		method: 'POST',
+		pveSelNode: view.pveSelNode,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+		autoShow: true,
+	    });
+	},
+
+	editJob: function(button, event, { data }) {
+	    let me = this;
+	    let view = me.getView();
+	    Ext.create('PVE.window.ReplicaEdit', {
+		url: `/cluster/replication/${data.id}`,
+		method: 'PUT',
+		pveSelNode: view.pveSelNode,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+		autoShow: true,
+	    });
+	},
+
+	scheduleJobNow: function(button, event, rec) {
+	    let me = this;
+	    let view = me.getView();
+	    Proxmox.Utils.API2Request({
+		url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/schedule_now`,
+		method: 'POST',
+		waitMsgTarget: view,
+		callback: () => me.reload(),
+		failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    });
+	},
+
+	showLog: function(button, event, rec) {
+	    let me = this;
+	    let view = this.getView();
+
+	    let logView = Ext.create('Proxmox.panel.LogView', {
+		border: false,
+		url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/log`,
+	    });
+	    let task = Ext.TaskManager.newTask({
+		run: () => logView.requestUpdate(),
+		interval: 1000,
+	    });
+	    let win = Ext.create('Ext.window.Window', {
+		items: [logView],
+		layout: 'fit',
+		width: 800,
+		height: 400,
+		modal: true,
+		title: gettext("Replication Log"),
+		listeners: {
+		    destroy: function() {
+			task.stop();
+			me.reload();
+		    },
+		},
+	    });
+	    task.start();
+	    win.show();
+	},
+
+	reload: function() {
+	    this.getView().rstore.load();
+	},
+
+	dblClick: function(grid, record, item) {
+	    this.editJob(undefined, undefined, record);
+	},
+
+	// currently replication is for cluster only, so disable the whole component for non-cluster
+	checkPrerequisites: function() {
+	    let view = this.getView();
+	    if (PVE.Utils.isStandaloneNode()) {
+		view.mask(gettext("Replication needs at least two nodes"), ['pve-static-mask']);
+	    }
+	},
+
+	control: {
+	    '#': {
+		itemdblclick: 'dblClick',
+		afterlayout: 'checkPrerequisites',
+	    },
+	},
+    },
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    itemId: 'addButton',
+	    handler: 'addJob',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    itemId: 'editButton',
+	    handler: 'editJob',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    itemId: 'removeButton',
+	    baseurl: '/api2/extjs/cluster/replication/',
+	    dangerous: true,
+	    callback: 'reload',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Log'),
+	    itemId: 'logButton',
+	    handler: 'showLog',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Schedule now'),
+	    itemId: 'scheduleNowButton',
+	    handler: 'scheduleJobNow',
+	    disabled: true,
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+	var mode = '';
+	var url = '/cluster/replication';
+
+	me.nodename = me.pveSelNode.data.node;
+	me.vmid = me.pveSelNode.data.vmid;
+
+	me.columns = [
+	    {
+		header: gettext('Enabled'),
+		width: 80,
+		dataIndex: 'enabled',
+		align: 'center',
+		renderer: Proxmox.Utils.renderEnabledIcon,
+		sortable: true,
+	    },
+	    {
+		text: 'ID',
+		dataIndex: 'id',
+		width: 60,
+		hidden: true,
+	    },
+	    {
+		text: gettext('Guest'),
+		dataIndex: 'guest',
+		width: 75,
+	    },
+	    {
+		text: gettext('Job'),
+		dataIndex: 'jobnum',
+		width: 60,
+	    },
+	    {
+		text: gettext('Target'),
+		dataIndex: 'target',
+	    },
+	];
+
+	if (!me.nodename) {
+	    mode = 'dc';
+	    me.stateId = 'grid-pve-replication-dc';
+	} else if (!me.vmid) {
+	    mode = 'node';
+	    url = `/nodes/${me.nodename}/replication`;
+	} else {
+	    mode = 'vm';
+	    url = `/nodes/${me.nodename}/replication?guest=${me.vmid}`;
+	}
+
+	if (mode !== 'dc') {
+	    me.columns.push(
+		{
+		    text: gettext('Status'),
+		    dataIndex: 'state',
+		    minWidth: 160,
+		    flex: 1,
+		    renderer: function(value, metadata, record) {
+			if (record.data.pid) {
+			    metadata.tdCls = 'x-grid-row-loading';
+			    return '';
+			}
+
+			let icons = [], states = [];
+
+			if (record.data.remove_job) {
+			    icons.push('<i class="fa fa-ban warning" title="'
+					+ gettext("Removal Scheduled") + '"></i>');
+			    states.push(gettext("Removal Scheduled"));
+			}
+			if (record.data.error) {
+			    icons.push('<i class="fa fa-times critical" title="'
+					+ gettext("Error") + '"></i>');
+			    states.push(record.data.error);
+			}
+			if (icons.length === 0) {
+			    icons.push('<i class="fa fa-check good"></i>');
+			    states.push(gettext('OK'));
+			}
+
+			return icons.join(',') + ' ' + states.join(',');
+		    },
+		},
+		{
+		    text: gettext('Last Sync'),
+		    dataIndex: 'last_sync',
+		    width: 150,
+		    renderer: function(value, metadata, record) {
+			if (!value) {
+			    return '-';
+			}
+			if (record.data.pid) {
+			    return gettext('syncing');
+			}
+			return Proxmox.Utils.render_timestamp(value);
+		    },
+		},
+		{
+		    text: gettext('Duration'),
+		    dataIndex: 'duration',
+		    width: 60,
+		    renderer: Proxmox.Utils.render_duration,
+		},
+		{
+		    text: gettext('Next Sync'),
+		    dataIndex: 'next_sync',
+		    width: 150,
+		    renderer: function(value) {
+			if (!value) {
+			    return '-';
+			}
+
+			let now = new Date(), next = new Date(value * 1000);
+			if (next < now) {
+			    return gettext('pending');
+			}
+			return Proxmox.Utils.render_timestamp(value);
+		    },
+		},
+	    );
+	}
+
+	me.columns.push(
+	    {
+		text: gettext('Schedule'),
+		width: 75,
+		dataIndex: 'schedule',
+	    },
+	    {
+		text: gettext('Rate limit'),
+		dataIndex: 'rate',
+		renderer: function(value) {
+		    if (!value) {
+			return gettext('unlimited');
+		    }
+
+		    return value.toString() + ' MB/s';
+		},
+		hidden: true,
+	    },
+	    {
+		text: gettext('Comment'),
+		dataIndex: 'comment',
+		renderer: Ext.htmlEncode,
+	    },
+	);
+
+	me.rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    storeid: 'pve-replica-' + me.nodename + me.vmid,
+	    model: mode === 'dc'? 'pve-replication' : 'pve-replication-state',
+	    interval: 3000,
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json" + url,
+	    },
+	});
+
+	me.store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: me.rstore,
+	    sorters: [
+		{
+		    property: 'guest',
+		},
+		{
+		    property: 'jobnum',
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	// we cannot access the log and scheduleNow button
+	// in the datacenter, because
+	// we do not know where/if the jobs runs
+	if (mode === 'dc') {
+	    me.down('#logButton').setHidden(true);
+	    me.down('#scheduleNowButton').setHidden(true);
+	}
+
+	// if we set the warning mask, we do not want to load
+	// or set the mask on store errors
+	if (PVE.Utils.isStandaloneNode()) {
+	    return;
+	}
+
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+
+	me.on('destroy', me.rstore.stopUpdate);
+	me.rstore.startUpdate();
+    },
+}, function() {
+    Ext.define('pve-replication', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'id', 'target', 'comment', 'rate', 'type',
+	    { name: 'guest', type: 'integer' },
+	    { name: 'jobnum', type: 'integer' },
+	    { name: 'schedule', defaultValue: '*/15' },
+	    { name: 'disable', defaultValue: '' },
+	    { name: 'enabled', calculate: function(data) { return !data.disable; } },
+	],
+    });
+
+    Ext.define('pve-replication-state', {
+	extend: 'pve-replication',
+	fields: [
+	    'last_sync', 'next_sync', 'error', 'duration', 'state',
+	    'fail_count', 'remove_job', 'pid',
+	],
+    });
+});
+Ext.define('PVE.grid.ResourceGrid', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveResourceGrid'],
+
+    border: false,
+    defaultSorter: {
+	property: 'type',
+	direction: 'ASC',
+    },
+    userCls: 'proxmox-tags-full',
+    initComponent: function() {
+	let me = this;
+
+	let rstore = PVE.data.ResourceStore;
+
+	let store = Ext.create('Ext.data.Store', {
+	    model: 'PVEResources',
+	    sorters: me.defaultSorter,
+	    proxy: {
+		type: 'memory',
+	    },
+	});
+
+	let textfilter = '';
+	let textfilterMatch = function(item) {
+	    for (const field of ['name', 'storage', 'node', 'type', 'text']) {
+		let v = item.data[field];
+		if (v && v.toLowerCase().indexOf(textfilter) >= 0) {
+		    return true;
+		}
+	    }
+	    return false;
+	};
+
+	let updateGrid = function() {
+	    var filterfn = me.viewFilter ? me.viewFilter.filterfn : null;
+
+	    store.suspendEvents();
+
+	    let nodeidx = {};
+	    let gather_child_nodes;
+	    gather_child_nodes = function(node) {
+		if (!node || !node.childNodes) {
+		    return;
+		}
+		for (let child of node.childNodes) {
+		    let orgNode = rstore.data.get(child.data.id);
+		    if (orgNode) {
+			if ((!filterfn || filterfn(child)) && (!textfilter || textfilterMatch(child))) {
+			    nodeidx[child.data.id] = orgNode;
+			}
+		    }
+		    gather_child_nodes(child);
+		}
+	    };
+	    gather_child_nodes(me.pveSelNode);
+
+	    // remove vanished items
+	    let rmlist = [];
+	    store.each(olditem => {
+		if (!nodeidx[olditem.data.id]) {
+		    rmlist.push(olditem);
+		}
+	    });
+	    if (rmlist.length) {
+		store.remove(rmlist);
+	    }
+
+	    // add new items
+	    let addlist = [];
+	    for (const [_key, item] of Object.entries(nodeidx)) {
+		// getById() use find(), which is slow (ExtJS4 DP5)
+		let olditem = store.data.get(item.data.id);
+		if (!olditem) {
+		    addlist.push(item);
+		    continue;
+		}
+		let changes = false;
+		for (let field of PVE.data.ResourceStore.fieldNames) {
+		    if (field !== 'id' && item.data[field] !== olditem.data[field]) {
+			changes = true;
+			olditem.beginEdit();
+			olditem.set(field, item.data[field]);
+		    }
+		}
+		if (changes) {
+		    olditem.endEdit(true);
+		    olditem.commit(true);
+		}
+	    }
+	    if (addlist.length) {
+		store.add(addlist);
+	    }
+	    store.sort();
+	    store.resumeEvents();
+	    store.fireEvent('refresh', store);
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: true,
+	    stateId: 'grid-resource',
+	    tbar: [
+		'->',
+		gettext('Search') + ':', ' ',
+		{
+		    xtype: 'textfield',
+		    width: 200,
+		    value: textfilter,
+		    enableKeyEvents: true,
+		    listeners: {
+			buffer: 500,
+			keyup: function(field, e) {
+			    textfilter = field.getValue().toLowerCase();
+			    updateGrid();
+			},
+		    },
+		},
+	    ],
+	    viewConfig: {
+		stripeRows: true,
+            },
+	    listeners: {
+		itemcontextmenu: PVE.Utils.createCmdMenu,
+		itemdblclick: function(v, record) {
+		    var ws = me.up('pveStdWorkspace');
+		    ws.selectById(record.data.id);
+		},
+		afterrender: function() {
+		    updateGrid();
+		},
+	    },
+            columns: rstore.defaultColumns(),
+	});
+	me.callParent();
+	me.mon(rstore, 'load', () => updateGrid());
+    },
+});
+/*
+ * Base class for all the multitab config panels
+ *
+ * How to use this:
+ *
+ * You create a subclass of this, and then define your wanted tabs
+ * as items like this:
+ *
+ * items: [{
+ *  title: "myTitle",
+ *  xytpe: "somextype",
+ *  iconCls: 'fa fa-icon',
+ *  groups: ['somegroup'],
+ *  expandedOnInit: true,
+ *  itemId: 'someId'
+ * }]
+ *
+ * this has to be in the declarative syntax, else we
+ * cannot save them for later
+ * (so no Ext.create or Ext.apply of an item in the subclass)
+ *
+ * the groups array expects the itemids of the items
+ * which are the parents, which have to come before they
+ * are used
+ *
+ * if you want following the tree:
+ *
+ * Option1
+ * Option2
+ *   -> SubOption1
+ *	-> SubSubOption1
+ *
+ * the suboption1 group array has to look like this:
+ * groups: ['itemid-of-option2']
+ *
+ * and of subsuboption1:
+ * groups: ['itemid-of-option2', 'itemid-of-suboption1']
+ *
+ * setting the expandedOnInit determines if the item/group is expanded
+ * initially (false by default)
+ */
+Ext.define('PVE.panel.Config', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pvePanelConfig',
+
+    showSearch: true, // add a resource grid with a search button as first tab
+    viewFilter: undefined, // a filter to pass to that resource grid
+
+    tbarSpacing: true, // if true, adds a spacer after the title in tbar
+
+    dockedItems: [{
+	// this is needed for the overflow handler
+	xtype: 'toolbar',
+	overflowHandler: 'scroller',
+	dock: 'left',
+	style: {
+	    padding: 0,
+	    margin: 0,
+	},
+	cls: 'pve-toolbar-bg',
+	items: {
+	    xtype: 'treelist',
+	    itemId: 'menu',
+	    ui: 'pve-nav',
+	    expanderOnly: true,
+	    expanderFirst: false,
+	    animation: false,
+	    singleExpand: false,
+	    listeners: {
+		selectionchange: function(treeList, selection) {
+		    if (!selection) {
+			return;
+		    }
+		    let view = this.up('panel');
+		    view.suspendLayout = true;
+		    view.activateCard(selection.data.id);
+		    view.suspendLayout = false;
+		    view.updateLayout();
+		},
+		itemclick: function(treelist, info) {
+		    var olditem = treelist.getSelection();
+		    var newitem = info.node;
+
+		    // when clicking on the expand arrow, we don't select items, but still want the original behaviour
+		    if (info.select === false) {
+			return;
+		    }
+
+		    // click on a different, open item then leave it open, else toggle the clicked item
+		    if (olditem.data.id !== newitem.data.id &&
+			newitem.data.expanded === true) {
+			info.toggle = false;
+		    } else {
+			info.toggle = true;
+		    }
+		},
+	    },
+	},
+    },
+    {
+	xtype: 'toolbar',
+	itemId: 'toolbar',
+	dock: 'top',
+	height: 36,
+	overflowHandler: 'scroller',
+    }],
+
+    firstItem: '',
+    layout: 'card',
+    border: 0,
+
+    // used for automated test
+    selectById: function(cardid) {
+	var me = this;
+
+	var root = me.store.getRoot();
+	var selection = root.findChild('id', cardid, true);
+
+	if (selection) {
+	    selection.expand();
+	    var menu = me.down('#menu');
+	    menu.setSelection(selection);
+	    return cardid;
+	}
+	return '';
+    },
+
+    activateCard: function(cardid) {
+	var me = this;
+	if (me.savedItems[cardid]) {
+	    var curcard = me.getLayout().getActiveItem();
+	    var newcard = me.add(me.savedItems[cardid]);
+	    me.helpButton.setOnlineHelp(newcard.onlineHelp || me.onlineHelp);
+	    if (curcard) {
+		me.setActiveItem(cardid);
+		me.remove(curcard, true);
+
+		// trigger state change
+
+		var ncard = cardid;
+		// Note: '' is alias for first tab.
+		// First tab can be 'search' or something else
+		if (cardid === me.firstItem) {
+		    ncard = '';
+		}
+		if (me.hstateid) {
+		   me.sp.set(me.hstateid, { value: ncard });
+		}
+	    }
+	}
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	var stateid = me.hstateid;
+
+	me.sp = Ext.state.Manager.getProvider();
+
+	var activeTab; // leaving this undefined means items[0] will be the default tab
+
+	if (stateid) {
+	    let state = me.sp.get(stateid);
+	    if (state && state.value) {
+		// if this tab does not exist, it chooses the first
+		activeTab = state.value;
+	    }
+	}
+
+	// get title
+	var title = me.title || me.pveSelNode.data.text;
+	me.title = undefined;
+
+	// create toolbar
+	var tbar = me.tbar || [];
+	me.tbar = undefined;
+
+	if (!me.onlineHelp) {
+	    // use the onlineHelp property indirection to enforce checking reference validity
+	    let typeToOnlineHelp = {
+		'type/lxc': { onlineHelp: 'chapter_pct' },
+		'type/node': { onlineHelp: 'chapter_system_administration' },
+		'type/pool': { onlineHelp: 'pveum_pools' },
+		'type/qemu': { onlineHelp: 'chapter_virtual_machines' },
+		'type/sdn': { onlineHelp: 'chapter_pvesdn' },
+		'type/storage': { onlineHelp: 'chapter_storage' },
+	    };
+	    me.onlineHelp = typeToOnlineHelp[me.pveSelNode.data.id]?.onlineHelp;
+	}
+
+	if (me.tbarSpacing) {
+	    tbar.unshift('->');
+	}
+	tbar.unshift({
+	    xtype: 'tbtext',
+	    text: title,
+	    baseCls: 'x-panel-header-text',
+	});
+
+	me.helpButton = Ext.create('Proxmox.button.Help', {
+	    hidden: false,
+	    listenToGlobalEvent: false,
+	    onlineHelp: me.onlineHelp || undefined,
+	});
+
+	tbar.push(me.helpButton);
+
+	me.dockedItems[1].items = tbar;
+
+	// include search tab
+	me.items = me.items || [];
+	if (me.showSearch) {
+	    me.items.unshift({
+		xtype: 'pveResourceGrid',
+		itemId: 'search',
+		title: gettext('Search'),
+		iconCls: 'fa fa-search',
+		pveSelNode: me.pveSelNode,
+	    });
+	}
+
+	me.savedItems = {};
+	if (me.items[0]) {
+	    me.firstItem = me.items[0].itemId;
+	}
+
+	me.store = Ext.create('Ext.data.TreeStore', {
+	    root: {
+		expanded: true,
+	    },
+	});
+	var root = me.store.getRoot();
+	me.insertNodes(me.items);
+
+	delete me.items;
+	me.defaults = me.defaults || {};
+	Ext.apply(me.defaults, {
+	    pveSelNode: me.pveSelNode,
+	    viewFilter: me.viewFilter,
+	    workspace: me.workspace,
+	    border: 0,
+	});
+
+	me.callParent();
+
+	var menu = me.down('#menu');
+	var selection = root.findChild('id', activeTab, true) || root.firstChild;
+	var node = selection;
+	while (node !== root) {
+	    node.expand();
+	    node = node.parentNode;
+	}
+	menu.setStore(me.store);
+	menu.setSelection(selection);
+
+	// on a state change,
+	// select the new item
+	var statechange = function(sp, key, state) {
+	    // it the state change is for this panel
+	    if (stateid && key === stateid && state) {
+		// get active item
+		var acard = me.getLayout().getActiveItem().itemId;
+		// get the itemid of the new value
+		var ncard = state.value || me.firstItem;
+		if (ncard && acard !== ncard) {
+		    // select the chosen item
+		    menu.setSelection(root.findChild('id', ncard, true) || root.firstChild);
+		}
+	    }
+	};
+
+	if (stateid) {
+	    me.mon(me.sp, 'statechange', statechange);
+	}
+    },
+
+    insertNodes: function(items) {
+	var me = this;
+	var root = me.store.getRoot();
+
+	items.forEach(function(item) {
+	    var treeitem = Ext.create('Ext.data.TreeModel', {
+		id: item.itemId,
+		text: item.title,
+		iconCls: item.iconCls,
+		leaf: true,
+		expanded: item.expandedOnInit,
+	    });
+	    item.header = false;
+	    if (me.savedItems[item.itemId] !== undefined) {
+		throw "itemId already exists, please use another";
+	    }
+	    me.savedItems[item.itemId] = item;
+
+	    var group;
+	    var curnode = root;
+
+	    // get/create the group items
+	    while (Ext.isArray(item.groups) && item.groups.length > 0) {
+		group = item.groups.shift();
+
+		var child = curnode.findChild('id', group);
+		if (child === null) {
+		    // did not find the group item
+		    // so add it where we are
+		    break;
+		}
+		curnode = child;
+	    }
+
+	    // insert the item
+
+	    // lets see if it already exists
+	    var node = curnode.findChild('id', item.itemId);
+
+	    if (node === null) {
+		curnode.appendChild(treeitem);
+	    } else {
+		// should not happen!
+		throw "id already exists";
+	    }
+	});
+    },
+});
+/*
+ * Input panel for advanced backup options intended to be used as part of an edit/create window.
+ */
+Ext.define('PVE.panel.BackupAdvancedOptions', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveBackupAdvancedOptionsPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function() {
+	let me = this;
+	me.isCreate = !!me.isCreate;
+	return {};
+    },
+
+    viewModel: {
+	data: {},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	toggleFleecing: function(cb, value) {
+	    let me = this;
+	    me.lookup('fleecingStorage').setDisabled(!value);
+	},
+
+	control: {
+	    'proxmoxcheckbox[reference=fleecingEnabled]': {
+		change: 'toggleFleecing',
+	    },
+	},
+    },
+
+    onGetValues: function(formValues) {
+	let me = this;
+	if (me.needMask) { // isMasked() may not yet be true if not rendered once
+	    return {};
+	}
+
+	let options = {};
+
+	if (!me.isCreate) {
+	    options.delete = []; // to avoid having to check this all the time
+	}
+	const deletePropertyOnEdit = me.isCreate
+	    ? () => { /* no-op on create */ }
+	    : key => options.delete.push(key);
+
+	let fleecing = {}, fleecingOptions = ['fleecing-enabled', 'fleecing-storage'];
+	let performance = {}, performanceOptions = ['max-workers', 'pbs-entries-max'];
+
+	for (const [key, value] of Object.entries(formValues)) {
+	    if (performanceOptions.includes(key)) {
+		performance[key] = value;
+	    // deleteEmpty is not currently implemented for pveBandwidthField
+	    } else if (key === 'bwlimit' && value === '') {
+		deletePropertyOnEdit('bwlimit');
+	    } else if (key === 'delete') {
+		if (Array.isArray(value)) {
+		    value.filter(opt => !performanceOptions.includes(opt)).forEach(
+			opt => deletePropertyOnEdit(opt),
+		    );
+		} else if (!performanceOptions.includes(formValues.delete)) {
+		    deletePropertyOnEdit(value);
+		}
+	    } else if (fleecingOptions.includes(key)) {
+		let fleecingKey = key.slice('fleecing-'.length);
+		fleecing[fleecingKey] = value;
+	    } else {
+		options[key] = value;
+	    }
+	}
+
+	if (Object.keys(performance).length > 0) {
+	    options.performance = PVE.Parser.printPropertyString(performance);
+	} else {
+	    deletePropertyOnEdit('performance');
+	}
+
+	if (Object.keys(fleecing).length > 0) {
+	    options.fleecing = PVE.Parser.printPropertyString(fleecing);
+	} else {
+	    deletePropertyOnEdit('fleecing');
+	}
+
+	if (me.isCreate) {
+	    delete options.delete;
+	}
+
+	return options;
+    },
+
+    onSetValues: function(values) {
+	if (values.fleecing) {
+	    for (const [key, value] of Object.entries(values.fleecing)) {
+		values[`fleecing-${key}`] = value;
+	    }
+	    delete values.fleecing;
+	}
+	if (values["pbs-change-detection-mode"] === '__default__') {
+	    delete values["pbs-change-detection-mode"];
+	}
+	return values;
+    },
+
+    updateCompression: function(value, disabled) {
+	this.lookup('zstdThreadCount').setDisabled(disabled || value !== 'zstd');
+    },
+
+    items: [
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'pveBandwidthField',
+		name: 'bwlimit',
+		fieldLabel: gettext('Bandwidth Limit'),
+		emptyText: gettext('Fallback'),
+		backendUnit: 'KiB',
+		allowZero: true,
+		emptyValue: '',
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), 0),
+		},
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: `${gettext('Limit I/O bandwidth.')} ${Ext.String.format(gettext("Schema default: {0}"), 0)}`,
+	    },
+	},
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'proxmoxintegerfield',
+		name: 'zstd',
+		reference: 'zstdThreadCount',
+		fieldLabel: Ext.String.format(gettext('{0} Threads'), 'Zstd'),
+		fieldStyle: 'text-align: right',
+		emptyText: gettext('Fallback'),
+		minValue: 0,
+		cbind: {
+		    deleteEmpty: '{!isCreate}',
+		},
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('With 0, half of the available cores are used'),
+		},
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: `${gettext('Threads used for zstd compression (non-PBS).')} ${Ext.String.format(gettext("Schema default: {0}"), 1)}`,
+	    },
+	},
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'proxmoxintegerfield',
+		name: 'max-workers',
+		minValue: 1,
+		maxValue: 256,
+		fieldLabel: gettext('IO-Workers'),
+		fieldStyle: 'text-align: right',
+		emptyText: gettext('Fallback'),
+		cbind: {
+		    deleteEmpty: '{!isCreate}',
+		},
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: `${gettext('I/O workers in the QEMU process (VMs only).')} ${Ext.String.format(gettext("Schema default: {0}"), 16)}`,
+	    },
+	},
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'proxmoxcheckbox',
+		name: 'fleecing-enabled',
+		reference: 'fleecingEnabled',
+		fieldLabel: gettext('Fleecing'),
+		uncheckedValue: 0,
+		value: 0,
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: gettext('Backup write cache that can reduce IO pressure inside guests (VMs only).'),
+	    },
+	},
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'pveStorageSelector',
+		name: 'fleecing-storage',
+		fieldLabel: gettext('Fleecing Storage'),
+		reference: 'fleecingStorage',
+		clusterView: true,
+		storageContent: 'images',
+		allowBlank: false,
+		disabled: true,
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: gettext('Prefer a fast and local storage, ideally with support for discard and thin-provisioning or sparse files.'),
+	    },
+	},
+	{
+	    // It's part of the 'performance' property string, so have a field to preserve the
+	    // value, but don't expose it. It's a rather niche setting and difficult to
+	    // convey/understand what it does.
+	    xtype: 'proxmoxintegerfield',
+	    name: 'pbs-entries-max',
+	    hidden: true,
+	    fieldLabel: 'TODO',
+	    fieldStyle: 'text-align: right',
+	    emptyText: 'TODO',
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Repeat missed'),
+		name: 'repeat-missed',
+		uncheckedValue: 0,
+		defaultValue: 0,
+		cbind: {
+		    deleteDefaultValue: '{!isCreate}',
+		},
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: gettext("Run jobs as soon as possible if they couldn't start on schedule, for example, due to the node being offline."),
+	    },
+	},
+	{
+	    xtype: 'pveTwoColumnContainer',
+	    startColumn: {
+		xtype: 'proxmoxKVComboBox',
+		fieldLabel: gettext('PBS change detection mode'),
+		name: 'pbs-change-detection-mode',
+		deleteEmpty: true,
+		value: '__default__',
+		comboItems: [
+		    ['__default__', "Default"],
+		    ['data', "Data"],
+		    ['metadata', "Metadata"],
+		],
+	    },
+	    endFlex: 2,
+	    endColumn: {
+		xtype: 'displayfield',
+		value: gettext("EXPERIMENTAL: Mode to detect file changes and archive encoding format for container backups."),
+	    },
+	},
+	{
+	    xtype: 'component',
+	    padding: '5 1',
+	    html: `<span class="pmx-hint">${gettext('Note')}</span>: ${
+	        gettext("The node-specific 'vzdump.conf' or, if this is not set, the default from the config schema is used to determine fallback values.")}`,
+	},
+    ],
+});
+/*
+ * Input panel for prune settings with a keep-all option intended to be used as
+ * part of an edit/create window.
+ */
+Ext.define('PVE.panel.BackupJobPrune', {
+    extend: 'Proxmox.panel.PruneInputPanel',
+    xtype: 'pveBackupJobPrunePanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'vzdump_retention',
+
+    onGetValues: function(formValues) {
+	if (this.needMask) { // isMasked() may not yet be true if not rendered once
+	    return {};
+	} else if (this.isCreate && !this.rendered) {
+	    return this.keepAllDefaultForCreate ? { 'prune-backups': 'keep-all=1' } : {};
+	}
+
+	let options = { 'delete': [] };
+
+	if ('max-protected-backups' in formValues) {
+	    options['max-protected-backups'] = formValues['max-protected-backups'];
+	} else if (this.hasMaxProtected) {
+	    options.delete.push('max-protected-backups');
+	}
+
+	delete formValues['max-protected-backups'];
+	delete formValues.delete;
+
+	let retention = PVE.Parser.printPropertyString(formValues);
+	if (retention === '') {
+	    options.delete.push('prune-backups');
+	} else {
+	    options['prune-backups'] = retention;
+	}
+
+	if (!this.isCreate) {
+	    // always delete old 'maxfiles' on edit, we map it to keep-last on window load
+	    options.delete.push('maxfiles');
+	} else {
+	    delete options.delete;
+	}
+
+	return options;
+    },
+
+    updateComponents: function() {
+	let me = this;
+
+	let keepAll = me.down('proxmoxcheckbox[name=keep-all]').getValue();
+	let anyValue = false;
+	me.query('pmxPruneKeepField').forEach(field => {
+	    anyValue = anyValue || field.getValue() !== null;
+	    field.setDisabled(keepAll);
+	});
+	me.down('component[name=no-keeps-hint]').setHidden(anyValue || keepAll);
+    },
+
+    listeners: {
+	afterrender: function(panel) {
+	    if (panel.needMask) {
+		panel.down('component[name=no-keeps-hint]').setHtml('');
+		panel.mask(
+		    gettext('Backup content type not available for this storage.'),
+		);
+	    } else if (panel.isCreate && panel.keepAllDefaultForCreate) {
+		panel.down('proxmoxcheckbox[name=keep-all]').setValue(true);
+	    }
+	    panel.down('component[name=pbs-hint]').setHidden(!panel.showPBSHint);
+
+	    let maxProtected = panel.down('proxmoxintegerfield[name=max-protected-backups]');
+	    maxProtected.setDisabled(!panel.hasMaxProtected);
+	    maxProtected.setHidden(!panel.hasMaxProtected);
+
+	    panel.query('pmxPruneKeepField').forEach(field => {
+		field.on('change', panel.updateComponents, panel);
+	    });
+	    panel.updateComponents();
+	},
+    },
+
+    columnT: {
+	xtype: 'proxmoxcheckbox',
+	name: 'keep-all',
+	boxLabel: gettext('Keep all backups'),
+	listeners: {
+	    change: function(field, newValue) {
+		let panel = field.up('pveBackupJobPrunePanel');
+		panel.updateComponents();
+	    },
+	},
+    },
+
+    columnB: [
+	{
+	    xtype: 'component',
+	    userCls: 'pmx-hint',
+	    name: 'no-keeps-hint',
+	    hidden: true,
+	    padding: '5 1',
+	    cbind: {
+		html: '{fallbackHintHtml}',
+	    },
+	},
+	{
+	    xtype: 'component',
+	    userCls: 'pmx-hint',
+	    name: 'pbs-hint',
+	    hidden: true,
+	    padding: '5 1',
+	    html: gettext("It's preferred to configure backup retention directly on the Proxmox Backup Server."),
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'max-protected-backups',
+	    fieldLabel: gettext('Maximum Protected'),
+	    minValue: -1,
+	    hidden: true,
+	    disabled: true,
+	    emptyText: 'unlimited with Datastore.Allocate privilege, 5 otherwise',
+	    deleteEmpty: true,
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), -1),
+	    },
+	},
+    ],
+});
+Ext.define('PVE.widget.HealthWidget', {
+    extend: 'Ext.Component',
+    alias: 'widget.pveHealthWidget',
+
+    data: {
+	iconCls: PVE.Utils.get_health_icon(undefined, true),
+	text: '',
+	title: '',
+    },
+
+    style: {
+	'text-align': 'center',
+    },
+
+    tpl: [
+	'<h3>{title}</h3>',
+	'<i class="fa fa-5x {iconCls}"></i>',
+	'<br /><br/>',
+	'{text}',
+    ],
+
+    updateHealth: function(data) {
+	var me = this;
+	me.update(Ext.apply(me.data, data));
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (me.title) {
+	    me.config.data.title = me.title;
+	}
+
+	me.callParent();
+    },
+
+});
+Ext.define('pve-fw-ipsets', {
+    extend: 'Ext.data.Model',
+    fields: ['name', 'comment', 'digest'],
+    idProperty: 'name',
+});
+
+Ext.define('PVE.IPSetList', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveIPSetList',
+
+    stateful: true,
+    stateId: 'grid-firewall-ipsetlist',
+
+    ipset_panel: undefined,
+
+    base_url: undefined,
+
+    addBtn: undefined,
+    removeBtn: undefined,
+    editBtn: undefined,
+
+    initComponent: function() {
+        var me = this;
+
+	if (typeof me.ipset_panel === 'undefined') {
+	    throw "no rule panel specified";
+	}
+
+	if (typeof me.ipset_panel === 'undefined') {
+	    throw "no base_url specified";
+	}
+
+	var store = new Ext.data.Store({
+	    model: 'pve-fw-ipsets',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json" + me.base_url,
+	    },
+	    sorters: {
+		property: 'name',
+		direction: 'ASC',
+	    },
+	});
+
+	var caps = Ext.state.Manager.get('GuiCap');
+	let canEdit = !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'];
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var reload = function() {
+	    var oldrec = sm.getSelection()[0];
+	    store.load(function(records, operation, success) {
+		if (oldrec) {
+		    var rec = store.findRecord('name', oldrec.data.name, 0, false, true, true);
+		    if (rec) {
+			sm.select(rec);
+		    }
+		}
+	    });
+	};
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec || !canEdit) {
+		return;
+	    }
+	    var win = Ext.create('Proxmox.window.Edit', {
+		subject: "IPSet '" + rec.data.name + "'",
+		url: me.base_url,
+		method: 'POST',
+		digest: rec.data.digest,
+		items: [
+		    {
+			xtype: 'hiddenfield',
+			name: 'rename',
+			value: rec.data.name,
+		    },
+		    {
+			xtype: 'textfield',
+			name: 'name',
+			value: rec.data.name,
+			fieldLabel: gettext('Name'),
+			allowBlank: false,
+		    },
+		    {
+			xtype: 'textfield',
+			name: 'comment',
+			value: rec.data.comment,
+			fieldLabel: gettext('Comment'),
+		    },
+		],
+	    });
+	    win.show();
+	    win.on('destroy', reload);
+	};
+
+	me.editBtn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    enableFn: rec => canEdit,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	me.addBtn = new Proxmox.button.Button({
+	    text: gettext('Create'),
+	    handler: function() {
+		sm.deselectAll();
+		var win = Ext.create('Proxmox.window.Edit', {
+		    subject: 'IPSet',
+		    url: me.base_url,
+		    method: 'POST',
+		    items: [
+			{
+			    xtype: 'textfield',
+			    name: 'name',
+			    value: '',
+			    fieldLabel: gettext('Name'),
+			    allowBlank: false,
+			},
+			{
+			    xtype: 'textfield',
+			    name: 'comment',
+			    value: '',
+			    fieldLabel: gettext('Comment'),
+			},
+		    ],
+		});
+		win.show();
+		win.on('destroy', reload);
+	    },
+	});
+
+	me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    enableFn: rec => canEdit,
+	    selModel: sm,
+	    baseurl: me.base_url + '/',
+	    callback: reload,
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    tbar: ['<b>IPSet:</b>', me.addBtn, me.removeBtn, me.editBtn],
+	    selModel: sm,
+	    columns: [
+		{
+		    header: 'IPSet',
+		    dataIndex: 'name',
+		    minWidth: 150,
+		    flex: 1,
+		},
+		{
+		    header: gettext('Comment'),
+		    dataIndex: 'comment',
+		    renderer: Ext.String.htmlEncode,
+		    flex: 4,
+		},
+	    ],
+	    listeners: {
+		itemdblclick: run_editor,
+		select: function(_, rec) {
+		    var url = me.base_url + '/' + rec.data.name;
+		    me.ipset_panel.setBaseUrl(url);
+		},
+		deselect: function() {
+		    me.ipset_panel.setBaseUrl(undefined);
+		},
+		show: reload,
+	    },
+	});
+
+	if (!canEdit) {
+	    me.addBtn.setDisabled(true);
+	}
+
+	me.callParent();
+
+	store.load();
+    },
+});
+
+Ext.define('PVE.IPSetCidrEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    cidr: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = me.cidr === undefined;
+
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs' + me.base_url;
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs' + me.base_url + '/' + me.cidr;
+	    me.method = 'PUT';
+	}
+
+	var column1 = [];
+
+	if (me.isCreate) {
+	    if (!me.list_refs_url) {
+		throw "no alias_base_url specified";
+	    }
+
+	    column1.push({
+		xtype: 'pveIPRefSelector',
+		name: 'cidr',
+		ref_type: 'alias',
+		autoSelect: false,
+		editable: true,
+		base_url: me.list_refs_url,
+		allowBlank: false,
+		fieldLabel: gettext('IP/CIDR'),
+	    });
+	} else {
+	    column1.push({
+		xtype: 'displayfield',
+		name: 'cidr',
+		value: '',
+		fieldLabel: gettext('IP/CIDR'),
+	    });
+	}
+
+	var ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	    isCreate: me.isCreate,
+	    column1: column1,
+	    column2: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'nomatch',
+		    checked: false,
+		    uncheckedValue: 0,
+		    fieldLabel: 'nomatch',
+		},
+	    ],
+	    columnB: [
+		{
+		    xtype: 'textfield',
+		    name: 'comment',
+		    value: '',
+		    fieldLabel: gettext('Comment'),
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    subject: gettext('IP/CIDR'),
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+
+Ext.define('PVE.IPSetGrid', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveIPSetGrid',
+
+    stateful: true,
+    stateId: 'grid-firewall-ipsets',
+
+    base_url: undefined,
+    list_refs_url: undefined,
+
+    addBtn: undefined,
+    removeBtn: undefined,
+    editBtn: undefined,
+
+    setBaseUrl: function(url) {
+        var me = this;
+
+	me.base_url = url;
+
+	if (url === undefined) {
+	    me.addBtn.setDisabled(true);
+	    me.store.removeAll();
+	} else {
+	    if (me.canEdit) {
+		me.addBtn.setDisabled(false);
+	    }
+	    me.removeBtn.baseurl = url + '/';
+	    me.store.setProxy({
+		type: 'proxmox',
+		url: '/api2/json' + url,
+	    });
+
+	    me.store.load();
+	}
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	if (!me.list_refs_url) {
+	    throw "no1 list_refs_url specified";
+	}
+
+	var store = new Ext.data.Store({
+	    model: 'pve-ipset',
+	});
+
+	var reload = function() {
+	    store.load();
+	};
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	me.caps = Ext.state.Manager.get('GuiCap');
+	me.canEdit = !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'];
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec || !me.canEdit) {
+		return;
+	    }
+	    var win = Ext.create('PVE.IPSetCidrEdit', {
+		base_url: me.base_url,
+		cidr: rec.data.cidr,
+	    });
+	    win.show();
+	    win.on('destroy', reload);
+	};
+
+	me.editBtn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    enableFn: rec => me.canEdit,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	me.addBtn = new Proxmox.button.Button({
+	    text: gettext('Add'),
+	    disabled: true,
+	    enableFn: rec => me.canEdit,
+	    handler: function() {
+		if (!me.base_url) {
+		    return;
+		}
+		var win = Ext.create('PVE.IPSetCidrEdit', {
+		    base_url: me.base_url,
+		    list_refs_url: me.list_refs_url,
+		});
+		win.show();
+		win.on('destroy', reload);
+	    },
+	});
+
+	me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    disabled: true,
+	    enableFn: rec => me.canEdit,
+	    selModel: sm,
+	    baseurl: me.base_url + '/',
+	    callback: reload,
+	});
+
+	var render_errors = function(value, metaData, record) {
+	    var errors = record.data.errors;
+	    if (errors) {
+		var msg = errors.cidr || errors.nomatch;
+		if (msg) {
+		    metaData.tdCls = 'proxmox-invalid-row';
+		    var html = '<p>' + Ext.htmlEncode(msg) + '</p>';
+		    metaData.tdAttr = 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' +
+			html.replace(/"/g, '&quot;') + '"';
+		}
+	    }
+	    return value;
+	};
+
+	Ext.apply(me, {
+	    tbar: ['<b>IP/CIDR:</b>', me.addBtn, me.removeBtn, me.editBtn],
+	    store: store,
+	    selModel: sm,
+	    listeners: {
+		itemdblclick: run_editor,
+	    },
+	    columns: [
+		{
+		    xtype: 'rownumberer',
+		    // cannot use width on instantiation as rownumberer hard-wires that in the
+		    // constructor to avoid being overridden by applyDefaults
+		    minWidth: 40,
+		},
+		{
+		    header: gettext('IP/CIDR'),
+		    dataIndex: 'cidr',
+		    minWidth: 150,
+		    flex: 1,
+		    renderer: function(value, metaData, record) {
+			value = render_errors(value, metaData, record);
+			if (record.data.nomatch) {
+			    return '<b>! </b>' + value;
+			}
+			return value;
+		    },
+		},
+		{
+		    header: gettext('Comment'),
+		    dataIndex: 'comment',
+		    flex: 3,
+		    renderer: function(value) {
+			return Ext.util.Format.htmlEncode(value);
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	if (me.base_url) {
+	    me.setBaseUrl(me.base_url); // load
+	}
+    },
+}, function() {
+    Ext.define('pve-ipset', {
+	extend: 'Ext.data.Model',
+	fields: [{ name: 'nomatch', type: 'boolean' },
+		  'cidr', 'comment', 'errors'],
+	idProperty: 'cidr',
+    });
+});
+
+Ext.define('PVE.IPSet', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveIPSet',
+
+    title: 'IPSet',
+
+    onlineHelp: 'pve_firewall_ip_sets',
+
+    list_refs_url: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.list_refs_url) {
+	    throw "no list_refs_url specified";
+	}
+
+	var ipset_panel = Ext.createWidget('pveIPSetGrid', {
+	    region: 'center',
+	    list_refs_url: me.list_refs_url,
+	    border: false,
+	});
+
+	var ipset_list = Ext.createWidget('pveIPSetList', {
+	    region: 'west',
+	    ipset_panel: ipset_panel,
+	    base_url: me.base_url,
+	    width: '50%',
+	    border: false,
+	    split: true,
+	});
+
+	Ext.apply(me, {
+	    layout: 'border',
+	    items: [ipset_list, ipset_panel],
+	    listeners: {
+		show: function() {
+		    ipset_list.fireEvent('show', ipset_list);
+		},
+	    },
+	});
+
+	me.callParent();
+    },
+});
+/*
+ * This is a running chart widget you add time datapoints to it, and we only
+ * show the last x of it used for ceph performance charts
+ */
+Ext.define('PVE.widget.RunningChart', {
+    extend: 'Ext.container.Container',
+    alias: 'widget.pveRunningChart',
+
+    layout: {
+	type: 'hbox',
+	align: 'center',
+    },
+    items: [
+	{
+	    width: 80,
+	    xtype: 'box',
+	    itemId: 'title',
+	    data: {
+		title: '',
+	    },
+	    tpl: '<h3>{title}:</h3>',
+	},
+	{
+	    flex: 1,
+	    xtype: 'cartesian',
+	    height: '100%',
+	    itemId: 'chart',
+	    border: false,
+	    axes: [
+		{
+		    type: 'numeric',
+		    position: 'left',
+		    hidden: true,
+		    minimum: 0,
+		},
+		{
+		    type: 'numeric',
+		    position: 'bottom',
+		    hidden: true,
+		},
+	    ],
+
+	    store: {
+		trackRemoved: false,
+		data: {},
+	    },
+
+	    sprites: [{
+		id: 'valueSprite',
+		type: 'text',
+		text: '0 B/s',
+		textAlign: 'end',
+		textBaseline: 'middle',
+		fontSize: 14,
+	    }],
+
+	    series: [{
+		type: 'line',
+		xField: 'time',
+		yField: 'val',
+		fill: 'true',
+		colors: ['#cfcfcf'],
+		tooltip: {
+		    trackMouse: true,
+		    renderer: function(tooltip, record, ctx) {
+			if (!record || !record.data) return;
+			const view = this.getChart();
+			const date = new Date(record.data.time);
+			const value = view.up().renderer(record.data.val);
+			const line1 = `${view.up().title}: ${value}`;
+			const line2 = Ext.Date.format(date, 'H:i:s');
+			tooltip.setHtml(`${line1}<br />${line2}`);
+		    },
+		},
+		style: {
+		    lineWidth: 1.5,
+		    opacity: 0.60,
+		},
+		marker: {
+		    opacity: 0,
+		    scaling: 0.01,
+		    fx: {
+			duration: 200,
+			easing: 'easeOut',
+		    },
+		},
+		highlightCfg: {
+		    opacity: 1,
+		    scaling: 1.5,
+		},
+	    }],
+	},
+    ],
+
+    // the renderer for the tooltip and last value, default just the value
+    renderer: Ext.identityFn,
+
+    // show the last x seconds default is 5 minutes
+    timeFrame: 5*60,
+
+    checkThemeColors: function() {
+	let me = this;
+	let rootStyle = getComputedStyle(document.documentElement);
+
+	// get color
+	let background = rootStyle.getPropertyValue("--pwt-panel-background").trim() || "#ffffff";
+	let text = rootStyle.getPropertyValue("--pwt-text-color").trim() || "#000000";
+
+	// set the colors
+	me.chart.setBackground(background);
+	me.chart.valuesprite.setAttributes({ fillStyle: text }, true);
+	me.chart.redraw();
+    },
+
+    addDataPoint: function(value, time) {
+	let view = this.chart;
+	let panel = view.up();
+	let now = new Date().getTime();
+	let begin = new Date(now - 1000 * panel.timeFrame).getTime();
+
+	view.store.add({
+	    time: time || now,
+	    val: value || 0,
+	});
+
+	// delete all old records when we have 20 times more datapoints
+	// than seconds in our timeframe (so even a subsecond graph does
+	// not trigger this often)
+	//
+	// records in the store do not take much space, but like this,
+	// we prevent a memory leak when someone has the site open for a long time
+	// with minimal graphical glitches
+	if (view.store.count() > panel.timeFrame * 20) {
+	    var oldData = view.store.getData().createFiltered(function(item) {
+		return item.data.time < begin;
+	    });
+
+	    view.store.remove(oldData.getRange());
+	}
+
+	view.timeaxis.setMinimum(begin);
+	view.timeaxis.setMaximum(now);
+	view.valuesprite.setText(panel.renderer(value || 0).toString());
+	view.valuesprite.setAttributes({
+	    x: view.getWidth() - 15,
+	    y: view.getHeight()/2,
+	}, true);
+	view.redraw();
+    },
+
+    setTitle: function(title) {
+	this.title = title;
+	let titlebox = this.getComponent('title');
+	titlebox.update({ title: title });
+    },
+
+    initComponent: function() {
+	var me = this;
+	me.callParent();
+
+	if (me.title) {
+	    me.getComponent('title').update({ title: me.title });
+	}
+	me.chart = me.getComponent('chart');
+	me.chart.timeaxis = me.chart.getAxes()[1];
+	me.chart.valuesprite = me.chart.getSurface('chart').get('valueSprite');
+	if (me.color) {
+	    me.chart.series[0].setStyle({
+		fill: me.color,
+		stroke: me.color,
+	    });
+	}
+
+	me.checkThemeColors();
+
+	// switch colors on media query changes
+	me.mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
+	me.themeListener = (e) => { me.checkThemeColors(); };
+	me.mediaQueryList.addEventListener("change", me.themeListener);
+    },
+
+    doDestroy: function() {
+	let me = this;
+
+	me.mediaQueryList.removeEventListener("change", me.themeListener);
+
+	me.callParent();
+    },
+});
+/*
+ * This class describes the bottom panel
+ */
+Ext.define('PVE.panel.StatusPanel', {
+    extend: 'Ext.tab.Panel',
+    alias: 'widget.pveStatusPanel',
+
+
+    //title: "Logs",
+    //tabPosition: 'bottom',
+
+    initComponent: function() {
+        var me = this;
+
+	var stateid = 'ltab';
+	var sp = Ext.state.Manager.getProvider();
+
+	var state = sp.get(stateid);
+	if (state && state.value) {
+	    me.activeTab = state.value;
+	}
+
+	Ext.apply(me, {
+	    listeners: {
+		tabchange: function() {
+		    var atab = me.getActiveTab().itemId;
+		    let tabstate = { value: atab };
+		    sp.set(stateid, tabstate);
+		},
+	    },
+	    items: [
+		{
+		    itemId: 'tasks',
+		    title: gettext('Tasks'),
+		    xtype: 'pveClusterTasks',
+		},
+		{
+		    itemId: 'clog',
+		    title: gettext('Cluster log'),
+		    xtype: 'pveClusterLog',
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	me.items.get(0).fireEvent('show', me.items.get(0));
+
+	var statechange = function(_, key, newstate) {
+	    if (key === stateid) {
+		var atab = me.getActiveTab().itemId;
+		let ntab = newstate.value;
+		if (newstate && ntab && atab !== ntab) {
+		    me.setActiveTab(ntab);
+		}
+	    }
+	};
+
+	sp.on('statechange', statechange);
+	me.on('destroy', function() {
+	    sp.un('statechange', statechange);
+	});
+    },
+});
+Ext.define('PVE.panel.GuestStatusView', {
+    extend: 'Proxmox.panel.StatusView',
+    alias: 'widget.pveGuestStatusView',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function(initialConfig) {
+	var me = this;
+	return {
+	    isQemu: me.pveSelNode.data.type === 'qemu',
+	    isLxc: me.pveSelNode.data.type === 'lxc',
+	};
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    if (view.pveSelNode.data.type !== 'lxc') {
+		return;
+	    }
+
+	    const nodename = view.pveSelNode.data.node;
+	    const vmid = view.pveSelNode.data.vmid;
+
+	    Proxmox.Utils.API2Request({
+		url: `/api2/extjs/nodes/${nodename}/lxc/${vmid}/config`,
+		waitMsgTargetView: view,
+		method: 'GET',
+		success: ({ result }) => {
+		    view.down('#unprivileged').updateValue(
+			Proxmox.Utils.format_boolean(result.data.unprivileged));
+		    view.ostype = Ext.htmlEncode(result.data.ostype);
+		},
+	    });
+	},
+    },
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    defaults: {
+	xtype: 'pmxInfoWidget',
+	padding: '2 25',
+    },
+    items: [
+	{
+	    xtype: 'box',
+	    height: 20,
+	},
+	{
+	    itemId: 'status',
+	    title: gettext('Status'),
+	    iconCls: 'fa fa-info fa-fw',
+	    printBar: false,
+	    multiField: true,
+	    renderer: function(record) {
+		var me = this;
+		var text = record.data.status;
+		var qmpstatus = record.data.qmpstatus;
+		if (qmpstatus && qmpstatus !== record.data.status) {
+		    text += ' (' + qmpstatus + ')';
+		}
+		return text;
+	    },
+	},
+	{
+	    itemId: 'hamanaged',
+	    iconCls: 'fa fa-heartbeat fa-fw',
+	    title: gettext('HA State'),
+	    printBar: false,
+	    textField: 'ha',
+	    renderer: PVE.Utils.format_ha,
+	},
+	{
+	    itemId: 'node',
+	    iconCls: 'fa fa-building fa-fw',
+	    title: gettext('Node'),
+	    cbind: {
+		text: '{pveSelNode.data.node}',
+	    },
+	    printBar: false,
+	},
+	{
+	    itemId: 'unprivileged',
+	    iconCls: 'fa fa-lock fa-fw',
+	    title: gettext('Unprivileged'),
+	    printBar: false,
+	    cbind: {
+		hidden: '{isQemu}',
+	    },
+	},
+	{
+	    xtype: 'box',
+	    height: 15,
+	},
+	{
+	    itemId: 'cpu',
+	    iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon',
+	    title: gettext('CPU usage'),
+	    valueField: 'cpu',
+	    maxField: 'cpus',
+	    renderer: Proxmox.Utils.render_cpu_usage,
+	    // in this specific api call
+	    // we already have the correct value for the usage
+	    calculate: Ext.identityFn,
+	},
+	{
+	    itemId: 'memory',
+	    iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon',
+	    title: gettext('Memory usage'),
+	    valueField: 'mem',
+	    maxField: 'maxmem',
+	},
+	{
+	    itemId: 'swap',
+	    iconCls: 'fa fa-refresh fa-fw',
+	    title: gettext('SWAP usage'),
+	    valueField: 'swap',
+	    maxField: 'maxswap',
+	    cbind: {
+		hidden: '{isQemu}',
+		disabled: '{isQemu}',
+	    },
+	},
+	{
+	    itemId: 'rootfs',
+	    iconCls: 'fa fa-hdd-o fa-fw',
+	    title: gettext('Bootdisk size'),
+	    valueField: 'disk',
+	    maxField: 'maxdisk',
+	    printBar: false,
+	    renderer: function(used, max) {
+		var me = this;
+		me.setPrintBar(used > 0);
+		if (used === 0) {
+		    return Proxmox.Utils.render_size(max);
+		} else {
+		    return Proxmox.Utils.render_size_usage(used, max);
+		}
+	    },
+	},
+	{
+	    xtype: 'box',
+	    height: 15,
+	},
+	{
+	    itemId: 'ips',
+	    xtype: 'pveAgentIPView',
+	    cbind: {
+		rstore: '{rstore}',
+		pveSelNode: '{pveSelNode}',
+		hidden: '{isLxc}',
+		disabled: '{isLxc}',
+	    },
+	},
+    ],
+
+    updateTitle: function() {
+	var me = this;
+	var uptime = me.getRecordValue('uptime');
+
+	var text = "";
+	if (Number(uptime) > 0) {
+	    text = " (" + gettext('Uptime') + ': ' + Proxmox.Utils.format_duration_long(uptime)
+		+ ')';
+	}
+
+	let title = `<div class="left-aligned">${me.getRecordValue('name') + text}</div>`;
+
+	if (me.pveSelNode.data.type === 'lxc' && me.ostype && me.ostype !== 'unmanaged') {
+	    // Manual mappings for distros with special casing
+	    const namemap = {
+		'archlinux': 'Arch Linux',
+		'nixos': 'NixOS',
+		'opensuse': 'openSUSE',
+		'centos': 'CentOS',
+	    };
+
+	    const distro = namemap[me.ostype] ?? Ext.String.capitalize(me.ostype);
+	    title += `<div class="right-aligned">
+		<i class="fl-${me.ostype} fl-fw"></i>&nbsp;${distro}</div>`;
+	}
+
+	me.setTitle(title);
+    },
+});
+Ext.define('PVE.guest.Summary', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveGuestSummary',
+
+    scrollable: true,
+    bodyPadding: 5,
+
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	if (!me.workspace) {
+	    throw "no workspace specified";
+	}
+
+	if (!me.statusStore) {
+	    throw "no status storage specified";
+	}
+
+	var type = me.pveSelNode.data.type;
+	var template = !!me.pveSelNode.data.template;
+	var rstore = me.statusStore;
+
+	var items = [
+	    {
+		xtype: template ? 'pveTemplateStatusView' : 'pveGuestStatusView',
+		flex: 1,
+		padding: template ? '5' : '0 5 0 0',
+		itemId: 'gueststatus',
+		pveSelNode: me.pveSelNode,
+		rstore: rstore,
+	    },
+	    {
+		xtype: 'pmxNotesView',
+		flex: 1,
+		padding: template ? '5' : '0 0 0 5',
+		itemId: 'notesview',
+		pveSelNode: me.pveSelNode,
+	    },
+	];
+
+	var rrdstore;
+	if (!template) {
+	    // in non-template mode put the two panels always together
+	    items = [
+		{
+		    xtype: 'container',
+		    height: 300,
+		    layout: {
+			type: 'hbox',
+			align: 'stretch',
+		    },
+		    items: items,
+		},
+	    ];
+
+	    rrdstore = Ext.create('Proxmox.data.RRDStore', {
+		rrdurl: `/api2/json/nodes/${nodename}/${type}/${vmid}/rrddata`,
+		model: 'pve-rrd-guest',
+	    });
+
+	    items.push(
+		{
+		    xtype: 'proxmoxRRDChart',
+		    title: gettext('CPU usage'),
+		    pveSelNode: me.pveSelNode,
+		    fields: ['cpu'],
+		    fieldTitles: [gettext('CPU usage')],
+		    unit: 'percent',
+		    store: rrdstore,
+		},
+		{
+		    xtype: 'proxmoxRRDChart',
+		    title: gettext('Memory usage'),
+		    pveSelNode: me.pveSelNode,
+		    fields: ['maxmem', 'mem'],
+		    fieldTitles: [gettext('Total'), gettext('RAM usage')],
+		    unit: 'bytes',
+		    powerOfTwo: true,
+		    store: rrdstore,
+		},
+		{
+		    xtype: 'proxmoxRRDChart',
+		    title: gettext('Network traffic'),
+		    pveSelNode: me.pveSelNode,
+		    fields: ['netin', 'netout'],
+		    store: rrdstore,
+		},
+		{
+		    xtype: 'proxmoxRRDChart',
+		    title: gettext('Disk IO'),
+		    pveSelNode: me.pveSelNode,
+		    fields: ['diskread', 'diskwrite'],
+		    store: rrdstore,
+		},
+	    );
+	}
+
+	Ext.apply(me, {
+	    tbar: ['->', { xtype: 'proxmoxRRDTypeSelector' }],
+	    items: [
+		{
+		    xtype: 'container',
+		    itemId: 'itemcontainer',
+		    layout: {
+			type: 'column',
+		    },
+		    minWidth: 700,
+		    defaults: {
+			minHeight: 330,
+			padding: 5,
+		    },
+		    items: items,
+		    listeners: {
+			resize: function(container) {
+			    Proxmox.Utils.updateColumns(container);
+			},
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+	if (!template) {
+	    rrdstore.startUpdate();
+	    me.on('destroy', rrdstore.stopUpdate);
+	}
+	let sp = Ext.state.Manager.getProvider();
+	me.mon(sp, 'statechange', function(provider, key, value) {
+	    if (key !== 'summarycolumns') {
+		return;
+	    }
+	    Proxmox.Utils.updateColumns(me.getComponent('itemcontainer'));
+	});
+    },
+});
+Ext.define('PVE.panel.TemplateStatusView', {
+    extend: 'Proxmox.panel.StatusView',
+    alias: 'widget.pveTemplateStatusView',
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    defaults: {
+	xtype: 'pmxInfoWidget',
+	printBar: false,
+	padding: '2 25',
+    },
+    items: [
+	{
+	    xtype: 'box',
+	    height: 20,
+	},
+	{
+	    itemId: 'hamanaged',
+	    iconCls: 'fa fa-heartbeat fa-fw',
+	    title: gettext('HA State'),
+	    printBar: false,
+	    textField: 'ha',
+	    renderer: PVE.Utils.format_ha,
+	},
+	{
+	    itemId: 'node',
+	    iconCls: 'fa fa-fw fa-building',
+	    title: gettext('Node'),
+	},
+	{
+	    xtype: 'box',
+	    height: 20,
+	},
+	{
+	    itemId: 'cpus',
+	    iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon',
+	    title: gettext('Processors'),
+	    textField: 'cpus',
+	},
+	{
+	    itemId: 'memory',
+	    iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon',
+	    title: gettext('Memory'),
+	    textField: 'maxmem',
+	    renderer: Proxmox.Utils.render_size,
+	},
+	{
+	    itemId: 'swap',
+	    iconCls: 'fa fa-refresh fa-fw',
+	    title: gettext('Swap'),
+	    textField: 'maxswap',
+	    renderer: Proxmox.Utils.render_size,
+	},
+	{
+	    itemId: 'disk',
+	    iconCls: 'fa fa-hdd-o fa-fw',
+	    title: gettext('Bootdisk size'),
+	    textField: 'maxdisk',
+	    renderer: Proxmox.Utils.render_size,
+	},
+	{
+	    xtype: 'box',
+	    height: 20,
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	var name = me.pveSelNode.data.name;
+	if (!name) {
+	    throw "no name specified";
+	}
+
+	me.title = name;
+
+	me.callParent();
+	if (me.pveSelNode.data.type !== 'lxc') {
+	    me.remove(me.getComponent('swap'));
+	}
+	me.getComponent('node').updateValue(me.pveSelNode.data.node);
+    },
+});
+Ext.define('PVE.panel.MultiDiskPanel', {
+    extend: 'Ext.panel.Panel',
+
+    setNodename: function(nodename) {
+	this.items.each((panel) => panel.setNodename(nodename));
+    },
+
+    border: false,
+    bodyBorder: false,
+
+    layout: 'card',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	vmconfig: {},
+
+	onAdd: function() {
+	    let me = this;
+	    me.lookup('addButton').setDisabled(true);
+	    me.addDisk();
+	    let count = me.lookup('grid').getStore().getCount() + 1; // +1 is from ide2
+	    me.lookup('addButton').setDisabled(count >= me.maxCount);
+	},
+
+	getNextFreeDisk: function(vmconfig) {
+	    throw "implement in subclass";
+	},
+
+	addPanel: function(itemId, vmconfig, nextFreeDisk) {
+	    throw "implement in subclass";
+	},
+
+	// define in subclass
+	diskSorter: undefined,
+
+	addDisk: function() {
+	    let me = this;
+	    let grid = me.lookup('grid');
+	    let store = grid.getStore();
+
+	    // get free disk id
+	    let vmconfig = me.getVMConfig(true);
+	    let nextFreeDisk = me.getNextFreeDisk(vmconfig);
+	    if (!nextFreeDisk) {
+		return;
+	    }
+
+	    // add store entry + panel
+	    let itemId = 'disk-card-' + ++Ext.idSeed;
+	    let rec = store.add({
+		name: nextFreeDisk.confid,
+		itemId,
+	    })[0];
+
+	    let panel = me.addPanel(itemId, vmconfig, nextFreeDisk);
+	    panel.updateVMConfig(vmconfig);
+
+	    // we need to setup a validitychange handler, so that we can show
+	    // that a disk has invalid fields
+	    let fields = panel.query('field');
+	    fields.forEach((el) => el.on('validitychange', () => {
+		let valid = fields.every((field) => field.isValid());
+		rec.set('valid', valid);
+		me.checkValidity();
+	    }));
+
+	    store.sort(me.diskSorter);
+
+	    // select if the panel added is the only one
+	    if (store.getCount() === 1) {
+		grid.getSelectionModel().select(0, false);
+	    }
+	},
+
+	getBaseVMConfig: function() {
+	    throw "implement in subclass";
+	},
+
+	getVMConfig: function(all) {
+	    let me = this;
+
+	    let vmconfig = me.getBaseVMConfig();
+
+	    me.lookup('grid').getStore().each((rec) => {
+		if (all || rec.get('valid')) {
+		    vmconfig[rec.get('name')] = rec.get('itemId');
+		}
+	    });
+
+	    return vmconfig;
+	},
+
+	checkValidity: function() {
+	    let me = this;
+	    let valid = me.lookup('grid').getStore().findExact('valid', false) === -1;
+	    me.lookup('validationfield').setValue(valid);
+	},
+
+	updateVMConfig: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let grid = me.lookup('grid');
+	    let store = grid.getStore();
+
+	    let vmconfig = me.getVMConfig();
+
+	    let valid = true;
+
+	    store.each((rec) => {
+		let itemId = rec.get('itemId');
+		let name = rec.get('name');
+		let panel = view.getComponent(itemId);
+		if (!panel) {
+		    throw "unexpected missing panel";
+		}
+
+		// copy config for each panel and remote its own id
+		let panel_vmconfig = Ext.apply({}, vmconfig);
+		if (panel_vmconfig[name] === itemId) {
+		    delete panel_vmconfig[name];
+		}
+
+		if (!rec.get('valid')) {
+		    valid = false;
+		}
+
+		panel.updateVMConfig(panel_vmconfig);
+	    });
+
+	    me.lookup('validationfield').setValue(valid);
+
+	    return vmconfig;
+	},
+
+	onChange: function(panel, newVal) {
+	    let me = this;
+	    let store = me.lookup('grid').getStore();
+
+	    let el = store.findRecord('itemId', panel.itemId, 0, false, true, true);
+	    if (el.get('name') === newVal) {
+		// do not update if there was no change
+		return;
+	    }
+
+	    el.set('name', newVal);
+	    el.commit();
+
+	    store.sort(me.diskSorter);
+
+	    // so that it happens after the layouting
+	    setTimeout(function() {
+		me.updateVMConfig();
+	    }, 10);
+	},
+
+	onRemove: function(tableview, rowIndex, colIndex, item, event, record) {
+	    let me = this;
+	    let grid = me.lookup('grid');
+	    let store = grid.getStore();
+	    let removed_idx = store.indexOf(record);
+
+	    let selection = grid.getSelection()[0];
+	    let selected_idx = store.indexOf(selection);
+
+	    if (selected_idx === removed_idx) {
+		let newidx = store.getCount() > removed_idx + 1 ? removed_idx + 1: removed_idx - 1;
+		grid.getSelectionModel().select(newidx, false);
+	    }
+
+	    store.remove(record);
+	    me.getView().remove(record.get('itemId'));
+	    me.lookup('addButton').setDisabled(false);
+	    me.updateVMConfig();
+	    me.checkValidity();
+	},
+
+	onSelectionChange: function(grid, selection) {
+	    let me = this;
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+
+	    me.getView().setActiveItem(selection[0].data.itemId);
+	},
+
+	control: {
+	    'inputpanel': {
+		diskidchange: 'onChange',
+	    },
+	    'grid[reference=grid]': {
+		selectionchange: 'onSelectionChange',
+	    },
+	},
+
+	init: function(view) {
+	    let me = this;
+	    me.onAdd();
+	    me.lookup('grid').getSelectionModel().select(0, false);
+	},
+    },
+
+    dockedItems: [
+	{
+	    xtype: 'container',
+	    layout: {
+		type: 'vbox',
+		align: 'stretch',
+	    },
+	    dock: 'left',
+	    border: false,
+	    width: 130,
+	    items: [
+		{
+		    xtype: 'grid',
+		    hideHeaders: true,
+		    reference: 'grid',
+		    flex: 1,
+		    emptyText: gettext('No Disks'),
+		    margin: '0 0 5 0',
+		    store: {
+			fields: ['name', 'itemId', 'valid'],
+			data: [],
+		    },
+		    columns: [
+			{
+			    dataIndex: 'name',
+			    renderer: function(val, md, rec) {
+				let warn = '';
+				if (!rec.get('valid')) {
+				    warn = ' <i class="fa warning fa-warning"></i>';
+				}
+				return val + warn;
+			    },
+			    flex: 1,
+			},
+			{
+			    xtype: 'actioncolumn',
+			    width: 30,
+			    align: 'center',
+			    menuDisabled: true,
+			    items: [
+				{
+				    iconCls: 'x-fa fa-trash critical',
+				    tooltip: 'Delete',
+				    handler: 'onRemove',
+				    isActionDisabled: 'deleteDisabled',
+				},
+			    ],
+			},
+		    ],
+		},
+		{
+		    xtype: 'button',
+		    reference: 'addButton',
+		    text: gettext('Add'),
+		    iconCls: 'fa fa-plus-circle',
+		    handler: 'onAdd',
+		},
+		{
+		    // dummy field to control wizard validation
+		    xtype: 'textfield',
+		    hidden: true,
+		    reference: 'validationfield',
+		    submitValue: false,
+		    value: true,
+		    validator: (val) => !!val,
+		},
+	    ],
+	},
+    ],
+});
+/*
+ * Left Treepanel, containing all the resources we manage in this datacenter: server nodes, server storages, VMs and Containers
+ */
+Ext.define('PVE.tree.ResourceTree', {
+    extend: 'Ext.tree.TreePanel',
+    alias: ['widget.pveResourceTree'],
+
+    userCls: 'proxmox-tags-circle',
+
+    statics: {
+	typeDefaults: {
+	    node: {
+		iconCls: 'fa fa-building',
+		text: gettext('Nodes'),
+	    },
+	    pool: {
+		iconCls: 'fa fa-tags',
+		text: gettext('Resource Pool'),
+	    },
+	    storage: {
+		iconCls: 'fa fa-database',
+		text: gettext('Storage'),
+	    },
+	    sdn: {
+		iconCls: 'fa fa-th',
+		text: gettext('SDN'),
+	    },
+	    qemu: {
+		iconCls: 'fa fa-desktop',
+		text: gettext('Virtual Machine'),
+	    },
+	    lxc: {
+		//iconCls: 'x-tree-node-lxc',
+		iconCls: 'fa fa-cube',
+		text: gettext('LXC Container'),
+	    },
+	    template: {
+		iconCls: 'fa fa-file-o',
+	    },
+	},
+    },
+
+    useArrows: true,
+
+    // private
+    nodeSortFn: function(node1, node2) {
+	let me = this;
+	let n1 = node1.data, n2 = node2.data;
+
+	if (!n1.groupbyid === !n2.groupbyid) {
+	    let n1IsGuest = n1.type === 'qemu' || n1.type === 'lxc';
+	    let n2IsGuest = n2.type === 'qemu' || n2.type === 'lxc';
+	    if (me['group-guest-types'] || !n1IsGuest || !n2IsGuest) {
+		// first sort (group) by type
+		if (n1.type > n2.type) {
+		    return 1;
+		} else if (n1.type < n2.type) {
+		    return -1;
+		}
+	    }
+
+	    // then sort (group) by ID
+	    if (n1IsGuest) {
+		if (me['group-templates'] && (!n1.template !== !n2.template)) {
+		    return n1.template ? 1 : -1; // sort templates after regular VMs
+		}
+		if (me['sort-field'] === 'vmid') {
+		    if (n1.vmid > n2.vmid) { // prefer VMID as metric for guests
+			return 1;
+		    } else if (n1.vmid < n2.vmid) {
+			return -1;
+		    }
+		} else {
+		    return n1.name.localeCompare(n2.name);
+		}
+	    }
+	    // same types but not a guest
+	    return n1.id > n2.id ? 1 : n1.id < n2.id ? -1 : 0;
+	} else if (n1.groupbyid) {
+	    return -1;
+	} else if (n2.groupbyid) {
+	    return 1;
+	}
+	return 0; // should not happen
+    },
+
+    // private: fast binary search
+    findInsertIndex: function(node, child, start, end) {
+	let me = this;
+
+	let diff = end - start;
+	if (diff <= 0) {
+	    return start;
+	}
+	let mid = start + (diff >> 1);
+
+	let res = me.nodeSortFn(child, node.childNodes[mid]);
+	if (res <= 0) {
+	    return me.findInsertIndex(node, child, start, mid);
+	} else {
+	    return me.findInsertIndex(node, child, mid + 1, end);
+	}
+    },
+
+    setIconCls: function(info) {
+	let cls = PVE.Utils.get_object_icon_class(info.type, info);
+	if (cls !== '') {
+	    info.iconCls = cls;
+	}
+    },
+
+    setText: function(info) {
+	let me = this;
+
+	let status = '';
+	if (info.type === 'storage') {
+	    let usage = info.disk / info.maxdisk;
+	    if (usage >= 0.0 && usage <= 1.0) {
+		let barHeight = (usage * 100).toFixed(0);
+		let remainingHeight = (100 - barHeight).toFixed(0);
+		status = '<div class="usage-wrapper">';
+		status += `<div class="usage-negative" style="height: ${remainingHeight}%"></div>`;
+		status += `<div class="usage" style="height: ${barHeight}%"></div>`;
+		status += '</div> ';
+	    }
+	}
+	if (Ext.isNumeric(info.vmid) && info.vmid > 0) {
+	    if (PVE.UIOptions.getTreeSortingValue('sort-field') !== 'vmid') {
+		info.text = `${info.name} (${String(info.vmid)})`;
+	    }
+	}
+	info.text = `<span>${status}${info.text}</span>`;
+	info.text += PVE.Utils.renderTags(info.tags, PVE.UIOptions.tagOverrides);
+    },
+
+    getToolTip: function(info) {
+	if (info.type === 'pool' || info.groupbyid !== undefined) {
+	    return undefined;
+	}
+
+	let qtips = [gettext('Status') + ': ' + (info.qmpstatus || info.status)];
+	if (info.lock) {
+	    qtips.push(Ext.String.format(gettext('Config locked ({0})'), info.lock));
+	}
+	if (info.hastate !== 'unmanaged') {
+	    qtips.push(gettext('HA State') + ": " + info.hastate);
+	}
+	if (info.type === 'storage') {
+	    let usage = info.disk / info.maxdisk;
+	    if (usage >= 0.0 && usage <= 1.0) {
+		qtips.push(Ext.String.format(gettext("Usage: {0}%"), (usage*100).toFixed(2)));
+	    }
+	}
+
+	let tip = qtips.join(', ');
+	info.tip = tip;
+	return tip;
+    },
+
+    // private
+    addChildSorted: function(node, info) {
+	let me = this;
+
+	me.setIconCls(info);
+	me.setText(info);
+
+	if (info.groupbyid) {
+	    info.text = info.groupbyid;
+	    if (info.type === 'type') {
+		let defaults = PVE.tree.ResourceTree.typeDefaults[info.groupbyid];
+		if (defaults && defaults.text) {
+		    info.text = defaults.text;
+		}
+	    }
+	}
+	let child = Ext.create('PVETree', info);
+
+	if (node.childNodes) {
+	    let pos = me.findInsertIndex(node, child, 0, node.childNodes.length);
+	    node.insertBefore(child, node.childNodes[pos]);
+	} else {
+	    node.insertBefore(child);
+	}
+
+	return child;
+    },
+
+    // private
+    groupChild: function(node, info, groups, level) {
+	let me = this;
+
+	let groupBy = groups[level];
+	let v = info[groupBy];
+
+	if (v) {
+	    let group = node.findChild('groupbyid', v);
+	    if (!group) {
+		let groupinfo;
+		if (info.type === groupBy) {
+		    groupinfo = info;
+		} else {
+		    groupinfo = {
+			type: groupBy,
+			id: groupBy + "/" + v,
+		    };
+		    if (groupBy !== 'type') {
+			groupinfo[groupBy] = v;
+		    }
+		}
+		groupinfo.leaf = false;
+		groupinfo.groupbyid = v;
+		group = me.addChildSorted(node, groupinfo);
+	    }
+	    if (info.type === groupBy) {
+		return group;
+	    }
+	    if (group) {
+		return me.groupChild(group, info, groups, level + 1);
+	    }
+	}
+
+	return me.addChildSorted(node, info);
+    },
+
+    saveSortingOptions: function() {
+	let me = this;
+	let changed = false;
+	for (const key of ['sort-field', 'group-templates', 'group-guest-types']) {
+	    let newValue = PVE.UIOptions.getTreeSortingValue(key);
+	    if (me[key] !== newValue) {
+		me[key] = newValue;
+		changed = true;
+	    }
+	}
+	return changed;
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.saveSortingOptions();
+
+	let rstore = PVE.data.ResourceStore;
+	let sp = Ext.state.Manager.getProvider();
+
+	if (!me.viewFilter) {
+	    me.viewFilter = {};
+	}
+
+	let pdata = {
+	    dataIndex: {},
+	    updateCount: 0,
+	};
+
+	let store = Ext.create('Ext.data.TreeStore', {
+	    model: 'PVETree',
+	    root: {
+		expanded: true,
+		id: 'root',
+		text: gettext('Datacenter'),
+		iconCls: 'fa fa-server',
+	    },
+	});
+
+	let stateid = 'rid';
+
+	const changedFields = [
+	    'text', 'running', 'template', 'status', 'qmpstatus', 'hastate', 'lock', 'tags',
+	];
+
+	let updateTree = function() {
+	    store.suspendEvents();
+
+	    let rootnode = me.store.getRootNode();
+	    // remember selected node (and all parents)
+	    let sm = me.getSelectionModel();
+	    let lastsel = sm.getSelection()[0];
+	    let parents = [];
+	    let sorting_changed = me.saveSortingOptions();
+	    for (let node = lastsel; node; node = node.parentNode) {
+		parents.push(node);
+	    }
+
+	    let groups = me.viewFilter.groups || [];
+	    // explicitly check for node/template, as those are not always grouping attributes
+	    // also check for name for when the tree is sorted by name
+	    let moveCheckAttrs = groups.concat(['node', 'template', 'name']);
+	    let filterfn = me.viewFilter.filterfn;
+
+	    let reselect = false; // for disappeared nodes
+	    let index = pdata.dataIndex;
+	    // remove vanished or moved items and update changed items in-place
+	    for (const [key, olditem] of Object.entries(index)) {
+		// getById() use find(), which is slow (ExtJS4 DP5)
+		let item = rstore.data.get(olditem.data.id);
+
+		let changed = sorting_changed, moved = sorting_changed;
+		if (item) {
+		    // test if any grouping attributes changed, catches migrated tree-nodes in server view too
+		    for (const attr of moveCheckAttrs) {
+			if (item.data[attr] !== olditem.data[attr]) {
+			    moved = true;
+			    break;
+			}
+		    }
+
+		    // tree item has been updated
+		    for (const field of changedFields) {
+			if (item.data[field] !== olditem.data[field]) {
+			    changed = true;
+			    break;
+			}
+		    }
+		    // FIXME: also test filterfn()?
+		}
+
+		if (changed) {
+		    olditem.beginEdit();
+		    let info = olditem.data;
+		    Ext.apply(info, item.data);
+		    me.setIconCls(info);
+		    me.setText(info);
+		    olditem.commit();
+		}
+		if ((!item || moved) && olditem.isLeaf()) {
+		    delete index[key];
+		    let parentNode = olditem.parentNode;
+		    // a selected item moved (migration) or disappeared (destroyed), so deselect that
+		    // node now and try to reselect the moved (or its parent) node later
+		    if (lastsel && olditem.data.id === lastsel.data.id) {
+			reselect = true;
+			sm.deselect(olditem);
+		    }
+		    // store events are suspended, so remove the item manually
+		    store.remove(olditem);
+		    parentNode.removeChild(olditem, true);
+		}
+	    }
+
+	    rstore.each(function(item) { // add new items
+		let olditem = index[item.data.id];
+		if (olditem) {
+		    return;
+		}
+		if (filterfn && !filterfn(item)) {
+		    return;
+		}
+		let info = Ext.apply({ leaf: true }, item.data);
+
+		let child = me.groupChild(rootnode, info, groups, 0);
+		if (child) {
+		    index[item.data.id] = child;
+		}
+	    });
+
+	    store.resumeEvents();
+	    store.fireEvent('refresh', store);
+
+	    // select parent node if original selected node vanished
+	    if (lastsel && !rootnode.findChild('id', lastsel.data.id, true)) {
+		lastsel = rootnode;
+		for (const node of parents) {
+		    if (rootnode.findChild('id', node.data.id, true)) {
+			lastsel = node;
+			break;
+		    }
+		}
+		me.selectById(lastsel.data.id);
+	    } else if (lastsel && reselect) {
+		me.selectById(lastsel.data.id);
+	    }
+
+	    // on first tree load set the selection from the stateful provider
+	    if (!pdata.updateCount) {
+		rootnode.expand();
+		me.applyState(sp.get(stateid));
+	    }
+
+	    pdata.updateCount++;
+	};
+
+	sp.on('statechange', (_sp, key, value) => {
+	    if (key === stateid) {
+		me.applyState(value);
+	    }
+	});
+
+	Ext.apply(me, {
+	    allowSelection: true,
+	    store: store,
+	    viewConfig: {
+		animate: false, // note: animate cause problems with applyState
+	    },
+	    listeners: {
+		itemcontextmenu: PVE.Utils.createCmdMenu,
+		destroy: function() {
+		    rstore.un("load", updateTree);
+		},
+		beforecellmousedown: function(tree, td, cellIndex, record, tr, rowIndex, ev) {
+		    let sm = me.getSelectionModel();
+		    // disable selection when right clicking except if the record is already selected
+		    me.allowSelection = ev.button !== 2 || sm.isSelected(record);
+		},
+		beforeselect: function(tree, record, index, eopts) {
+		    let allow = me.allowSelection;
+		    me.allowSelection = true;
+		    return allow;
+		},
+		itemdblclick: PVE.Utils.openTreeConsole,
+		afterrender: function() {
+		    if (me.tip) {
+			return;
+		    }
+		    let selectors = [
+			'.x-tree-node-text > span:not(.proxmox-tag-dark):not(.proxmox-tag-light)',
+			'.x-tree-icon',
+		    ];
+		    me.tip = Ext.create('Ext.tip.ToolTip', {
+			target: me.el,
+			delegate: selectors.join(', '),
+			trackMouse: true,
+			renderTo: Ext.getBody(),
+			listeners: {
+			    beforeshow: function(tip) {
+				let rec = me.getView().getRecord(tip.triggerElement);
+				let tipText = me.getToolTip(rec.data);
+				if (tipText) {
+				    tip.update(tipText);
+				    return true;
+				}
+				return false;
+			    },
+			},
+		    });
+		},
+	    },
+	    setViewFilter: function(view) {
+		me.viewFilter = view;
+		me.clearTree();
+		updateTree();
+	    },
+	    setDatacenterText: function(clustername) {
+		let rootnode = me.store.getRootNode();
+
+		let rnodeText = gettext('Datacenter');
+		if (clustername !== undefined) {
+		    rnodeText += ' (' + clustername + ')';
+		}
+
+		rootnode.beginEdit();
+		rootnode.data.text = rnodeText;
+		rootnode.commit();
+	    },
+	    clearTree: function() {
+		pdata.updateCount = 0;
+		let rootnode = me.store.getRootNode();
+		rootnode.collapse();
+		rootnode.removeAll();
+		pdata.dataIndex = {};
+		me.getSelectionModel().deselectAll();
+	    },
+	    selectExpand: function(node) {
+		let sm = me.getSelectionModel();
+		if (!sm.isSelected(node)) {
+		    sm.select(node);
+		    for (let iter = node; iter; iter = iter.parentNode) {
+			if (!iter.isExpanded()) {
+			    iter.expand();
+			}
+		    }
+		    me.getView().focusRow(node);
+		}
+	    },
+	    selectById: function(nodeid) {
+		let rootnode = me.store.getRootNode();
+		let node;
+		if (nodeid === 'root') {
+		    node = rootnode;
+		} else {
+		    node = rootnode.findChild('id', nodeid, true);
+		}
+		if (node) {
+		    me.selectExpand(node);
+		}
+		return node;
+	    },
+	    applyState: function(state) {
+		if (state && state.value) {
+		    me.selectById(state.value);
+		} else {
+		    me.getSelectionModel().deselectAll();
+		}
+	    },
+	});
+
+	me.callParent();
+
+	me.getSelectionModel().on('select', (_sm, n) => sp.set(stateid, { value: n.data.id }));
+
+	rstore.on("load", updateTree);
+	rstore.startUpdate();
+    },
+
+});
+Ext.define('PVE.guest.SnapshotTree', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'pveGuestSnapshotTree',
+
+    stateful: true,
+    stateId: 'grid-snapshots',
+
+    viewModel: {
+	data: {
+	    // should be 'qemu' or 'lxc'
+	    type: undefined,
+	    nodename: undefined,
+	    vmid: undefined,
+	    snapshotAllowed: false,
+	    rollbackAllowed: false,
+	    snapshotFeature: false,
+	    running: false,
+	    selected: '',
+	    load_delay: 3000,
+	},
+	formulas: {
+	    canSnapshot: (get) => get('snapshotAllowed') && get('snapshotFeature'),
+	    canRollback: (get) => get('rollbackAllowed') && get('isSnapshot'),
+	    canRemove: (get) => get('snapshotAllowed') && get('isSnapshot'),
+	    isSnapshot: (get) => get('selected') && get('selected') !== 'current',
+	    buttonText: (get) => get('snapshotAllowed') ? gettext('Edit') : gettext('View'),
+	    showMemory: (get) => get('type') === 'qemu',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	newSnapshot: function() {
+	    this.run_editor(false);
+	},
+
+	editSnapshot: function() {
+	    this.run_editor(true);
+	},
+
+	run_editor: function(edit) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let snapname;
+	    if (edit) {
+		snapname = vm.get('selected');
+		if (!snapname || snapname === 'current') { return; }
+	    }
+	    let win = Ext.create('PVE.window.Snapshot', {
+		nodename: vm.get('nodename'),
+		vmid: vm.get('vmid'),
+		viewonly: !vm.get('snapshotAllowed'),
+		type: vm.get('type'),
+		isCreate: !edit,
+		submitText: !edit ? gettext('Take Snapshot') : undefined,
+		snapname: snapname,
+		running: vm.get('running'),
+	    });
+	    win.show();
+	    me.mon(win, 'destroy', me.reload, me);
+	},
+
+	snapshotAction: function(action, method) {
+	    let me = this;
+	    let view = me.getView();
+	    let vm = me.getViewModel();
+	    let snapname = vm.get('selected');
+	    if (!snapname) { return; }
+
+	    let nodename = vm.get('nodename');
+	    let type = vm.get('type');
+	    let vmid = vm.get('vmid');
+
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${nodename}/${type}/${vmid}/snapshot/${snapname}/${action}`,
+		method: method,
+		waitMsgTarget: view,
+		callback: function() {
+		    me.reload();
+		},
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: function(response, options) {
+		    var upid = response.result.data;
+		    var win = Ext.create('Proxmox.window.TaskProgress', { upid: upid });
+		    win.show();
+		},
+	    });
+	},
+
+	rollback: function() {
+	    this.snapshotAction('rollback', 'POST');
+	},
+	remove: function() {
+	    this.snapshotAction('', 'DELETE');
+	},
+	cancel: function() {
+	    this.load_task.cancel();
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let vm = me.getViewModel();
+	    let nodename = vm.get('nodename');
+	    let vmid = vm.get('vmid');
+	    let type = vm.get('type');
+	    let load_delay = vm.get('load_delay');
+
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${nodename}/${type}/${vmid}/snapshot`,
+		method: 'GET',
+		failure: function(response, opts) {
+		    if (me.destroyed) return;
+		    Proxmox.Utils.setErrorMask(view, response.htmlStatus);
+		    me.load_task.delay(load_delay);
+		},
+		success: function(response, opts) {
+		    if (me.destroyed) {
+			// this is in a delayed task, avoid dragons if view has
+			// been destroyed already and go home.
+			return;
+		    }
+		    Proxmox.Utils.setErrorMask(view, false);
+		    var digest = 'invalid';
+		    var idhash = {};
+		    var root = { name: '__root', expanded: true, children: [] };
+		    Ext.Array.each(response.result.data, function(item) {
+			item.leaf = true;
+			item.children = [];
+			if (item.name === 'current') {
+			    vm.set('running', !!item.running);
+			    digest = item.digest + item.running;
+			    item.iconCls = PVE.Utils.get_object_icon_class(vm.get('type'), item);
+			} else {
+			    item.iconCls = 'fa fa-fw fa-history x-fa-tree';
+			}
+			idhash[item.name] = item;
+		    });
+
+		    if (digest !== me.old_digest) {
+			me.old_digest = digest;
+
+			Ext.Array.each(response.result.data, function(item) {
+			    if (item.parent && idhash[item.parent]) {
+				var parent_item = idhash[item.parent];
+				parent_item.children.push(item);
+				parent_item.leaf = false;
+				parent_item.expanded = true;
+				parent_item.expandable = false;
+			    } else {
+				root.children.push(item);
+			    }
+			});
+
+			me.getView().setRootNode(root);
+		    }
+
+		    me.load_task.delay(load_delay);
+		},
+	    });
+
+	    // if we do not have the permissions, we don't have to check
+	    // if we can create a snapshot, since the butten stays disabled
+	    if (!vm.get('snapshotAllowed')) {
+		return;
+	    }
+
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${nodename}/${type}/${vmid}/feature`,
+		params: { feature: 'snapshot' },
+		method: 'GET',
+		success: function(response, options) {
+		    if (me.destroyed) {
+			// this is in a delayed task, the current view could been
+			// destroyed already; then we mustn't do viemodel set
+			return;
+		    }
+		    let res = response.result.data;
+		    vm.set('snapshotFeature', !!res.hasFeature);
+		},
+	    });
+	},
+
+	select: function(grid, val) {
+	    let vm = this.getViewModel();
+	    if (val.length < 1) {
+		vm.set('selected', '');
+		return;
+	    }
+	    vm.set('selected', val[0].data.name);
+	},
+
+	init: function(view) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    me.load_task = new Ext.util.DelayedTask(me.reload, me);
+
+	    if (!view.type) {
+		throw 'guest type not set';
+	    }
+	    vm.set('type', view.type);
+
+	    if (!view.pveSelNode.data.node) {
+		throw "no node name specified";
+	    }
+	    vm.set('nodename', view.pveSelNode.data.node);
+
+	    if (!view.pveSelNode.data.vmid) {
+		throw "no VM ID specified";
+	    }
+	    vm.set('vmid', view.pveSelNode.data.vmid);
+
+	    let caps = Ext.state.Manager.get('GuiCap');
+	    vm.set('snapshotAllowed', !!caps.vms['VM.Snapshot']);
+	    vm.set('rollbackAllowed', !!caps.vms['VM.Snapshot.Rollback']);
+
+	    view.getStore().sorters.add({
+		property: 'order',
+		direction: 'ASC',
+	    });
+
+	    me.reload();
+	},
+    },
+
+    listeners: {
+	selectionchange: 'select',
+	itemdblclick: 'editSnapshot',
+	beforedestroy: 'cancel',
+    },
+
+    layout: 'fit',
+    rootVisible: false,
+    animate: false,
+    sortableColumns: false,
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Take Snapshot'),
+	    disabled: true,
+	    bind: {
+		disabled: "{!canSnapshot}",
+	    },
+	    handler: 'newSnapshot',
+	},
+	'-',
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Rollback'),
+	    disabled: true,
+	    bind: {
+		disabled: '{!canRollback}',
+	    },
+	    confirmMsg: function() {
+		let view = this.up('treepanel');
+		let rec = view.getSelection()[0];
+		let vmid = view.getViewModel().get('vmid');
+		return Proxmox.Utils.format_task_description('qmrollback', vmid) +
+		    ` '${rec.data.name}'? ${gettext("Current state will be lost.")}`;
+	    },
+	    handler: 'rollback',
+	},
+	'-',
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    bind: {
+		text: '{buttonText}',
+		disabled: '{!isSnapshot}',
+	    },
+	    disabled: true,
+	    edit: true,
+	    handler: 'editSnapshot',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Remove'),
+	    disabled: true,
+	    dangerous: true,
+	    bind: {
+		disabled: '{!canRemove}',
+	    },
+	    confirmMsg: function() {
+		let view = this.up('treepanel');
+		let { data } = view.getSelection()[0];
+		return Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${data.name}'`);
+	    },
+	    handler: 'remove',
+	},
+	{
+	    xtype: 'label',
+	    text: gettext("The current guest configuration does not support taking new snapshots"),
+	    hidden: true,
+	    bind: {
+		hidden: "{canSnapshot}",
+	    },
+	},
+    ],
+
+    columnLines: true,
+
+    fields: [
+	'name',
+	'description',
+	'snapstate',
+	'vmstate',
+	'running',
+	{ name: 'snaptime', type: 'date', dateFormat: 'timestamp' },
+	{
+	    name: 'order',
+	    calculate: function(data) {
+		return data.snaptime || (data.name === 'current' ? 'ZZZ' : data.snapstate);
+	    },
+	},
+    ],
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: gettext('Name'),
+	    dataIndex: 'name',
+	    width: 200,
+	    renderer: (value, _, { data }) => data.name !== 'current' ? value : gettext('NOW'),
+	},
+	{
+	    text: gettext('RAM'),
+	    hidden: true,
+	    bind: {
+		hidden: '{!showMemory}',
+	    },
+	    align: 'center',
+	    resizable: false,
+	    dataIndex: 'vmstate',
+	    width: 50,
+	    renderer: (value, _, { data }) => data.name !== 'current' ? Proxmox.Utils.format_boolean(value) : '',
+	},
+	{
+	    text: gettext('Date') + "/" + gettext("Status"),
+	    dataIndex: 'snaptime',
+	    width: 150,
+	    renderer: function(value, metaData, record) {
+		if (record.data.snapstate) {
+		    return record.data.snapstate;
+		} else if (value) {
+		    return Ext.Date.format(value, 'Y-m-d H:i:s');
+		}
+		return '';
+	    },
+	},
+	{
+	    text: gettext('Description'),
+	    dataIndex: 'description',
+	    flex: 1,
+	    renderer: function(value, metaData, record) {
+		if (record.data.name === 'current') {
+		    return gettext("You are here!");
+		} else {
+		    return Ext.String.htmlEncode(value);
+		}
+	    },
+	},
+    ],
+
+});
+Ext.define('PVE.tree.ResourceMapTree', {
+    extend: 'Ext.tree.Panel',
+    alias: 'widget.pveResourceMapTree',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    rootVisible: false,
+
+    emptyText: gettext('No Mapping found'),
+
+    // will be opened on edit
+    editWindowClass: undefined,
+
+    // The base url of the resource
+    baseUrl: undefined,
+
+    // icon class to show on the entries
+    mapIconCls: undefined,
+
+    // if given, should be a function that takes a nodename and returns
+    // the url for getting the data to check the status
+    getStatusCheckUrl: undefined,
+
+    // the result of above api call and the nodename is passed and can set the status
+    checkValidity: undefined,
+
+    // the property that denotes a single map entry for a node
+    entryIdProperty: undefined,
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	const caps = Ext.state.Manager.get('GuiCap');
+	me.canConfigure = !!caps.mapping['Mapping.Modify'];
+
+	return {};
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addMapping: function() {
+	    let me = this;
+	    let view = me.getView();
+	    Ext.create(view.editWindowClass, {
+		url: view.baseUrl,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.load(),
+		},
+	    });
+	},
+
+	add: function(_grid, _rI, _cI, _item, _e, rec) {
+	    let me = this;
+	    if (rec.data.type !== 'entry') {
+		return;
+	    }
+
+	    me.openMapEditWindow(rec.data.name);
+	},
+
+	editDblClick: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+
+	    me.edit(selection[0]);
+	},
+
+	editAction: function(_grid, _rI, _cI, _item, _e, rec) {
+	    this.edit(rec);
+	},
+
+	edit: function(rec) {
+	    let me = this;
+	    if (rec.data.type === 'map') {
+		return;
+	    }
+
+	    me.openMapEditWindow(rec.data.name, rec.data.node, rec.data.type === 'entry');
+	},
+
+	openMapEditWindow: function(name, nodename, entryOnly) {
+	    let me = this;
+	    let view = me.getView();
+
+	    Ext.create(view.editWindowClass, {
+		url: `${view.baseUrl}/${name}`,
+		autoShow: true,
+		autoLoad: true,
+		entryOnly,
+		nodename,
+		name,
+		listeners: {
+		    destroy: () => me.load(),
+		},
+	    });
+	},
+
+	remove: function(_grid, _rI, _cI, _item, _e, rec) {
+	    let me = this;
+	    let msg, id;
+	    let view = me.getView();
+	    let confirmMsg;
+	    switch (rec.data.type) {
+		case 'entry':
+		    msg = gettext("Are you sure you want to remove '{0}'");
+		    confirmMsg = Ext.String.format(msg, rec.data.name);
+		    break;
+		case 'node':
+		    msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'");
+		    confirmMsg = Ext.String.format(msg, rec.data.node, rec.data.name);
+		    break;
+		case 'map':
+		    msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'");
+		    id = rec.data[view.entryIdProperty];
+		    confirmMsg = Ext.String.format(msg, id, rec.data.node, rec.data.name);
+		    break;
+		default:
+		    throw "invalid type";
+	    }
+	    Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function(btn) {
+		if (btn === 'yes') {
+		    me.executeRemove(rec.data);
+		}
+	    });
+	},
+
+	executeRemove: function(data) {
+	    let me = this;
+	    let view = me.getView();
+
+	    let url = `${view.baseUrl}/${data.name}`;
+	    let method = 'PUT';
+	    let params = {
+		digest: me.lookup[data.name].digest,
+	    };
+	    let map = me.lookup[data.name].map;
+	    switch (data.type) {
+		case 'entry':
+		    method = 'DELETE';
+		    params = undefined;
+		    break;
+		case 'node':
+		    params.map = PVE.Parser.filterPropertyStringList(map, (e) => e.node !== data.node);
+		    break;
+		case 'map':
+		    params.map = PVE.Parser.filterPropertyStringList(map, (e) =>
+			Object.entries(e).some(([key, value]) => data[key] !== value));
+		    break;
+		default:
+		    throw "invalid type";
+	    }
+	    if (!params?.map.length) {
+		method = 'DELETE';
+		params = undefined;
+	    }
+	    Proxmox.Utils.API2Request({
+		url,
+		method,
+		params,
+		success: function() {
+		    me.load();
+		},
+	    });
+	},
+
+	load: function() {
+	    let me = this;
+	    let view = me.getView();
+	    Proxmox.Utils.API2Request({
+		url: view.baseUrl,
+		method: 'GET',
+		failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		success: function({ result: { data } }) {
+		    let lookup = {};
+		    data.forEach((entry) => {
+			lookup[entry.id] = Ext.apply({}, entry);
+			entry.iconCls = 'fa fa-fw fa-folder-o';
+			entry.name = entry.id;
+			entry.text = entry.id;
+			entry.type = 'entry';
+
+			let nodes = {};
+			for (const map of entry.map) {
+			    let parsed = PVE.Parser.parsePropertyString(map);
+			    parsed.iconCls = view.mapIconCls;
+			    parsed.leaf = true;
+			    parsed.name = entry.id;
+			    parsed.text = parsed[view.entryIdProperty];
+			    parsed.type = 'map';
+
+			    if (nodes[parsed.node] === undefined) {
+				nodes[parsed.node] = {
+				    children: [],
+				    expanded: true,
+				    iconCls: 'fa fa-fw fa-building-o',
+				    leaf: false,
+				    name: entry.id,
+				    node: parsed.node,
+				    text: parsed.node,
+				    type: 'node',
+				};
+			    }
+			    nodes[parsed.node].children.push(parsed);
+			}
+			delete entry.id;
+			entry.children = Object.values(nodes);
+			entry.leaf = entry.children.length === 0;
+		    });
+		    me.lookup = lookup;
+		    if (view.getStatusCheckUrl !== undefined && view.checkValidity !== undefined) {
+			me.loadStatusData();
+		    }
+		    view.setRootNode({
+			children: data,
+		    });
+		    let root = view.getRootNode();
+		    root.expand();
+		    root.childNodes.forEach(node => node.expand());
+		},
+	    });
+	},
+
+	nodeLoadingState: {},
+
+	loadStatusData: function() {
+	    let me = this;
+	    let view = me.getView();
+	    PVE.data.ResourceStore.getNodes().forEach(({ node }) => {
+		me.nodeLoadingState[node] = true;
+		let url = view.getStatusCheckUrl(node);
+		Proxmox.Utils.API2Request({
+		    url,
+		    method: 'GET',
+		    failure: function(response) {
+			me.nodeLoadingState[node] = false;
+			view.getRootNode()?.cascade(function(rec) {
+			    if (rec.data.node !== node) {
+				return;
+			    }
+
+			    rec.set('valid', 0);
+			    rec.set('errmsg', response.htmlStatus);
+			    rec.commit();
+			});
+		    },
+		    success: function({ result: { data } }) {
+			me.nodeLoadingState[node] = false;
+			view.checkValidity(data, node);
+		    },
+		});
+	    });
+	},
+
+	renderStatus: function(value, _metadata, record) {
+	    let me = this;
+	    if (record.data.type !== 'map') {
+		return '';
+	    }
+	    let iconCls;
+	    let status;
+	    if (value === undefined) {
+		if (me.nodeLoadingState[record.data.node]) {
+		    iconCls = 'fa-spinner fa-spin';
+		    status = gettext('Loading...');
+		} else {
+		    iconCls = 'fa-question-circle';
+		    status = gettext('Unknown Node');
+		}
+	    } else {
+		let state = value ? 'good' : 'critical';
+		iconCls = PVE.Utils.get_health_icon(state, true);
+		status = value ? gettext("Mapping matches host data") : record.data.errmsg || Proxmox.Utils.unknownText;
+	    }
+	    return `<i class="fa ${iconCls}"></i> ${status}`;
+	},
+
+	getAddClass: function(v, mD, rec) {
+	    let cls = 'fa fa-plus-circle';
+	    if (rec.data.type !== 'entry' || rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length) {
+		cls += ' pmx-action-hidden';
+	    }
+	    return cls;
+	},
+
+	isAddDisabled: function(v, r, c, i, rec) {
+	    return rec.data.type !== 'entry' || rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length;
+	},
+
+	init: function(view) {
+	    let me = this;
+
+	    ['editWindowClass', 'baseUrl', 'mapIconCls', 'entryIdProperty'].forEach((property) => {
+		if (view[property] === undefined) {
+		    throw `No ${property} defined`;
+		}
+	    });
+
+	    me.load();
+	},
+    },
+
+    store: {
+	sorters: 'text',
+	data: {},
+    },
+
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    handler: 'addMapping',
+	    cbind: {
+		disabled: '{!canConfigure}',
+	    },
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'editDblClick',
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let columns = [...me.columns];
+	columns.splice(1, 0, {
+	    xtype: 'actioncolumn',
+	    text: gettext('Actions'),
+	    width: 80,
+	    items: [
+		{
+		    getTip: (v, m, { data }) =>
+			Ext.String.format(gettext("Add new host mapping for '{0}'"), data.name),
+		    getClass: 'getAddClass',
+		    isActionDisabled: 'isAddDisabled',
+		    handler: 'add',
+		},
+		{
+		    iconCls: 'fa fa-pencil',
+		    getTip: (v, m, { data }) => data.type === 'entry'
+			? Ext.String.format(gettext("Edit Mapping '{0}'"), data.name)
+			: Ext.String.format(gettext("Edit Mapping '{0}' for '{1}'"), data.name, data.node),
+		    getClass: (v, m, { data }) => data.type !== 'map' ? 'fa fa-pencil' : 'pmx-hidden',
+		    isActionDisabled: (v, r, c, i, rec) => rec.data.type === 'map',
+		    handler: 'editAction',
+		},
+		{
+		    iconCls: 'fa fa-trash-o',
+		    getTip: (v, m, { data }) => data.type === 'entry'
+			? Ext.String.format(gettext("Remove '{0}'"), data.name)
+			: data.type === 'node'
+			    ? Ext.String.format(gettext("Remove mapping for '{0}'"), data.node)
+			    : Ext.String.format(gettext("Remove mapping '{0}'"), data.path),
+		    handler: 'remove',
+		},
+	    ],
+	});
+	me.columns = columns;
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.DhcpTree', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'pveDhcpTree',
+
+    layout: 'fit',
+    rootVisible: false,
+    animate: false,
+
+    store: {
+	sorters: ['ip', 'name'],
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	reload: function() {
+	    let me = this;
+
+	    Proxmox.Utils.API2Request({
+		url: `/cluster/sdn/ipams/pve/status`,
+		method: 'GET',
+		success: function(response, opts) {
+		    let root = {
+			name: '__root',
+			expanded: true,
+			children: [],
+		    };
+
+		    let zones = {};
+		    let vnets = {};
+		    let subnets = {};
+
+		    response.result.data.forEach((element) => {
+			element.leaf = true;
+
+			if (!(element.zone in zones)) {
+			    let zone = {
+				name: element.zone,
+				type: 'zone',
+				iconCls: 'fa fa-th',
+				expanded: true,
+				children: [],
+			    };
+
+			    zones[element.zone] = zone;
+			    root.children.push(zone);
+			}
+
+			if (!(element.vnet in vnets)) {
+			    let vnet = {
+				name: element.vnet,
+				zone: element.zone,
+				type: 'vnet',
+				iconCls: 'fa fa-network-wired x-fa-treepanel',
+				expanded: true,
+				children: [],
+			    };
+
+			    vnets[element.vnet] = vnet;
+			    zones[element.zone].children.push(vnet);
+			}
+
+			if (!(element.subnet in subnets)) {
+			    let subnet = {
+				name: element.subnet,
+				zone: element.zone,
+				vnet: element.vnet,
+				type: 'subnet',
+				iconCls: 'x-tree-icon-none',
+				expanded: true,
+				children: [],
+			    };
+
+			    subnets[element.subnet] = subnet;
+			    vnets[element.vnet].children.push(subnet);
+			}
+
+			element.type = 'mapping';
+			element.iconCls = 'x-tree-icon-none';
+			subnets[element.subnet].children.push(element);
+		    });
+
+		    me.getView().setRootNode(root);
+		},
+	    });
+	},
+
+	init: function(view) {
+	    let me = this;
+	    me.reload();
+	},
+
+	onDelete: function(table, rI, cI, item, e, { data }) {
+	    let me = this;
+	    let view = me.getView();
+
+	    Ext.Msg.show({
+		title: gettext('Confirm'),
+		icon: Ext.Msg.WARNING,
+		message: Ext.String.format(gettext('Are you sure you want to remove DHCP mapping {0}'), `${data.mac} / ${data.ip}`),
+		buttons: Ext.Msg.YESNO,
+		defaultFocus: 'no',
+		callback: function(btn) {
+		    if (btn !== 'yes') {
+		        return;
+		    }
+
+		    let params = {
+			zone: data.zone,
+			mac: data.mac,
+			ip: data.ip,
+		    };
+
+		    let encodedParams = Ext.Object.toQueryString(params);
+
+		    let url = `/cluster/sdn/vnets/${data.vnet}/ips?${encodedParams}`;
+
+		    Proxmox.Utils.API2Request({
+			url,
+			method: 'DELETE',
+			waitMsgTarget: view,
+			failure: function(response, opts) {
+			    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			},
+			callback: me.reload.bind(me),
+		    });
+		},
+	    });
+	},
+
+	editAction: function(_grid, _rI, _cI, _item, _e, rec) {
+	    this.edit(rec);
+	},
+
+	editDblClick: function() {
+	    let me = this;
+
+	    let view = me.getView();
+	    let selection = view.getSelection();
+
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+
+	    me.edit(selection[0]);
+	},
+
+	edit: function(rec) {
+	    let me = this;
+
+	    if (rec.data.type === 'mapping' && !rec.data.gateway) {
+		me.openEditWindow(rec.data);
+	    }
+	},
+
+	openEditWindow: function(data) {
+	    let me = this;
+
+	    Ext.create('PVE.sdn.IpamEdit', {
+		autoShow: true,
+		mapping: data,
+		extraRequestParams: {
+		    vmid: data.vmid,
+		    mac: data.mac,
+		    zone: data.zone,
+		    vnet: data.vnet,
+		},
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+    },
+
+    listeners: {
+	itemdblclick: 'editDblClick',
+    },
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Reload'),
+	    handler: 'reload',
+	},
+    ],
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: gettext('Name / VMID'),
+	    dataIndex: 'name',
+	    width: 200,
+	    renderer: function(value, meta, record) {
+		if (record.get('gateway')) {
+		    return gettext('Gateway');
+		}
+
+		return record.get('name') ?? record.get('vmid') ?? ' ';
+	    },
+	},
+	{
+	    text: gettext('IP Address'),
+	    dataIndex: 'ip',
+	    width: 200,
+	},
+	{
+	    text: 'MAC',
+	    dataIndex: 'mac',
+	    width: 200,
+	},
+	{
+	    text: gettext('Gateway'),
+	    dataIndex: 'gateway',
+	    width: 200,
+	},
+	{
+	    header: gettext('Actions'),
+	    xtype: 'actioncolumn',
+	    dataIndex: 'text',
+	    width: 150,
+	    items: [
+		{
+		    handler: function(table, rI, cI, item, e, { data }) {
+			let me = this;
+
+			Ext.create('PVE.sdn.IpamEdit', {
+			    autoShow: true,
+			    mapping: {},
+			    isCreate: true,
+			    extraRequestParams: {
+				vnet: data.name,
+				zone: data.zone,
+			    },
+			    listeners: {
+				destroy: () => {
+				    me.up('pveDhcpTree').controller.reload();
+				},
+			    },
+			});
+		    },
+		    getTip: (v, m, rec) => gettext('Add'),
+		    getClass: (v, m, { data }) => {
+			if (data.type === 'vnet') {
+			    return 'fa fa-plus-square';
+			}
+
+			return 'pmx-hidden';
+		    },
+                },
+		{
+		    handler: 'editAction',
+		    getTip: (v, m, rec) => gettext('Edit'),
+		    getClass: (v, m, { data }) => {
+			if (data.type === 'mapping' && !data.gateway) {
+			    return 'fa fa-pencil fa-fw';
+			}
+
+			return 'pmx-hidden';
+		    },
+                },
+		{
+		    handler: 'onDelete',
+		    getTip: (v, m, rec) => gettext('Delete'),
+		    getClass: (v, m, { data }) => {
+			if (data.type === 'mapping' && !data.gateway) {
+			    return 'fa critical fa-trash-o';
+			}
+
+			return 'pmx-hidden';
+		    },
+                },
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.window.Backup', {
+    extend: 'Ext.window.Window',
+
+    resizable: false,
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	if (!me.vmtype) {
+	    throw "no VM type specified";
+	}
+
+	let compressionSelector = Ext.create('PVE.form.BackupCompressionSelector', {
+	    name: 'compress',
+	    value: 'zstd',
+	    fieldLabel: gettext('Compression'),
+	});
+
+	let modeSelector = Ext.create('PVE.form.BackupModeSelector', {
+	    fieldLabel: gettext('Mode'),
+	    value: 'snapshot',
+	    name: 'mode',
+	});
+
+	let mailtoField = Ext.create('Ext.form.field.Text', {
+	    fieldLabel: gettext('Send email to'),
+	    name: 'mailto',
+	    emptyText: Proxmox.Utils.noneText,
+	});
+
+	let notificationModeSelector = Ext.create({
+	    xtype: 'proxmoxKVComboBox',
+	    comboItems: [
+		['auto', gettext('Auto')],
+		['legacy-sendmail', gettext('Email (legacy)')],
+		['notification-system', gettext('Notification system')],
+	    ],
+	    fieldLabel: gettext('Notification mode'),
+	    name: 'notification-mode',
+	    value: 'auto',
+	    listeners: {
+		change: function(field, value) {
+		    mailtoField.setDisabled(value === 'notification-system');
+		},
+	    },
+	});
+
+	const keepNames = [
+	    ['keep-last', gettext('Keep Last')],
+	    ['keep-hourly', gettext('Keep Hourly')],
+	    ['keep-daily', gettext('Keep Daily')],
+	    ['keep-weekly', gettext('Keep Weekly')],
+	    ['keep-monthly', gettext('Keep Monthly')],
+	    ['keep-yearly', gettext('Keep Yearly')],
+	];
+
+	let pruneSettings = keepNames.map(
+	    name => Ext.create('Ext.form.field.Display', {
+		name: name[0],
+		fieldLabel: name[1],
+		hidden: true,
+	    }),
+	);
+
+	let removeCheckbox = Ext.create('Proxmox.form.Checkbox', {
+	    name: 'remove',
+	    checked: false,
+	    hidden: true,
+	    uncheckedValue: 0,
+	    fieldLabel: gettext('Prune'),
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Prune older backups afterwards'),
+	    },
+	    handler: function(checkbox, value) {
+		pruneSettings.forEach(field => field.setHidden(!value));
+		me.down('label[name="pruneLabel"]').setHidden(!value);
+	    },
+	});
+
+	let initialDefaults = false;
+
+	var storagesel = Ext.create('PVE.form.StorageSelector', {
+	    nodename: me.nodename,
+	    name: 'storage',
+	    fieldLabel: gettext('Storage'),
+	    storageContent: 'backup',
+	    allowBlank: false,
+	    listeners: {
+		change: function(f, v) {
+		    if (!initialDefaults) {
+			me.setLoading(false);
+		    }
+
+		    if (v === null || v === undefined || v === '') {
+			return;
+		    }
+
+		    let store = f.getStore();
+		    let rec = store.findRecord('storage', v, 0, false, true, true);
+
+		    if (rec && rec.data && rec.data.type === 'pbs') {
+			compressionSelector.setValue('zstd');
+			compressionSelector.setDisabled(true);
+		    } else if (!compressionSelector.getEditable()) {
+			compressionSelector.setDisabled(false);
+		    }
+
+		    Proxmox.Utils.API2Request({
+			url: `/nodes/${me.nodename}/vzdump/defaults`,
+			method: 'GET',
+			params: {
+			    storage: v,
+			},
+			waitMsgTarget: me,
+			success: function(response, opts) {
+			    const data = response.result.data;
+
+			    if (!initialDefaults && data.mailto !== undefined) {
+				mailtoField.setValue(data.mailto);
+			    }
+			    if (!initialDefaults && data['notification-mode'] !== undefined) {
+				notificationModeSelector.setValue(data['notification-mode']);
+			    }
+			    if (!initialDefaults && data.mode !== undefined) {
+				modeSelector.setValue(data.mode);
+			    }
+			    if (!initialDefaults && (data['notes-template'] ?? false)) {
+				me.down('field[name=notes-template]').setValue(
+				    PVE.Utils.unEscapeNotesTemplate(data['notes-template']),
+				);
+			    }
+
+			    initialDefaults = true;
+
+			    // always update storage dependent properties
+			    if (data['prune-backups'] !== undefined) {
+				const keepParams = PVE.Parser.parsePropertyString(
+				    data["prune-backups"],
+				);
+				if (!keepParams['keep-all']) {
+				    removeCheckbox.setHidden(false);
+				    pruneSettings.forEach(function(field) {
+					const keep = keepParams[field.name];
+					if (keep) {
+					    field.setValue(keep);
+					} else {
+					    field.reset();
+					}
+				    });
+				    return;
+				}
+			    }
+
+			    // no defaults or keep-all=1
+			    removeCheckbox.setHidden(true);
+			    removeCheckbox.setValue(false);
+			    pruneSettings.forEach(field => field.reset());
+			},
+			failure: function(response, opts) {
+			    initialDefaults = true;
+
+			    removeCheckbox.setHidden(true);
+			    removeCheckbox.setValue(false);
+			    pruneSettings.forEach(field => field.reset());
+
+			    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			},
+		    });
+		},
+	    },
+	});
+
+	let protectedCheckbox = Ext.create('Proxmox.form.Checkbox', {
+	    name: 'protected',
+	    checked: false,
+	    uncheckedValue: 0,
+	    fieldLabel: gettext('Protected'),
+	});
+
+	me.formPanel = Ext.create('Proxmox.panel.InputPanel', {
+	    bodyPadding: 10,
+	    border: false,
+	    column1: [
+		storagesel,
+		modeSelector,
+		protectedCheckbox,
+	    ],
+	    column2: [
+		compressionSelector,
+		notificationModeSelector,
+		mailtoField,
+		removeCheckbox,
+	    ],
+	    columnB: [
+		{
+		    xtype: 'textareafield',
+		    name: 'notes-template',
+		    fieldLabel: gettext('Notes'),
+		    anchor: '100%',
+		    value: '{{guestname}}',
+		},
+		{
+		    xtype: 'box',
+		    style: {
+			margin: '8px 0px',
+			'line-height': '1.5em',
+		    },
+		    html: Ext.String.format(
+			gettext('Possible template variables are: {0}'),
+			PVE.Utils.notesTemplateVars.map(v => `<code>{{${v}}}</code>`).join(', '),
+		    ),
+		},
+		{
+		    xtype: 'label',
+		    name: 'pruneLabel',
+		    text: gettext('Storage Retention Configuration') + ':',
+		    hidden: true,
+		},
+		{
+		    layout: 'hbox',
+		    border: false,
+		    defaults: {
+			border: false,
+			layout: 'anchor',
+			flex: 1,
+		    },
+		    items: [
+			{
+			    padding: '0 10 0 0',
+			    defaults: {
+				labelWidth: 110,
+			    },
+			    items: [
+				pruneSettings[0],
+				pruneSettings[2],
+				pruneSettings[4],
+			    ],
+			},
+			{
+			    padding: '0 0 0 10',
+			    defaults: {
+				labelWidth: 110,
+			    },
+			    items: [
+				pruneSettings[1],
+				pruneSettings[3],
+				pruneSettings[5],
+			    ],
+			},
+		    ],
+		},
+	    ],
+	});
+
+	var submitBtn = Ext.create('Ext.Button', {
+	    text: gettext('Backup'),
+	    handler: function() {
+		var storage = storagesel.getValue();
+		let values = me.formPanel.getValues();
+		var params = {
+		    storage: storage,
+		    vmid: me.vmid,
+		    mode: values.mode,
+		    remove: values.remove,
+		};
+
+		if (values.mailto) {
+		    params.mailto = values.mailto;
+		}
+
+		if (values['notification-mode']) {
+		    params['notification-mode'] = values['notification-mode'];
+		}
+
+		if (values.compress) {
+		    params.compress = values.compress;
+		}
+
+		if (values.protected) {
+		    params.protected = values.protected;
+		}
+
+		if (values['notes-template']) {
+		    params['notes-template'] = PVE.Utils.escapeNotesTemplate(
+			values['notes-template']);
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: '/nodes/' + me.nodename + '/vzdump',
+		    params: params,
+		    method: 'POST',
+		    failure: function(response, opts) {
+			Ext.Msg.alert('Error', response.htmlStatus);
+		    },
+		    success: function(response, options) {
+			// close later so we reload the grid
+			// after the task has completed
+			me.hide();
+
+			var upid = response.result.data;
+
+			var win = Ext.create('Proxmox.window.TaskViewer', {
+			    upid: upid,
+			    listeners: {
+				close: function() {
+				    me.close();
+				},
+			    },
+			});
+			win.show();
+		    },
+		});
+	    },
+	});
+
+	var helpBtn = Ext.create('Proxmox.button.Help', {
+	    onlineHelp: 'chapter_vzdump',
+	    listenToGlobalEvent: false,
+	    hidden: false,
+	});
+
+	var title = gettext('Backup') + " " +
+	    (me.vmtype === 'lxc' ? "CT" : "VM") +
+	    " " + me.vmid;
+
+	Ext.apply(me, {
+	    title: title,
+	    modal: true,
+	    layout: 'auto',
+	    border: false,
+	    width: 600,
+	    items: [me.formPanel],
+	    buttons: [helpBtn, '->', submitBtn],
+	    listeners: {
+		afterrender: function() {
+		    /// cleared within the storage selector's change listener
+		    me.setLoading(gettext('Please wait...'));
+		    storagesel.setValue(me.storage);
+		},
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.window.BackupConfig', {
+    extend: 'Ext.window.Window',
+    title: gettext('Configuration'),
+    width: 600,
+    height: 400,
+    layout: 'fit',
+    modal: true,
+    items: {
+	xtype: 'component',
+	itemId: 'configtext',
+	autoScroll: true,
+	style: {
+	    'white-space': 'pre',
+	    'font-family': 'monospace',
+	    padding: '5px',
+	},
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.volume) {
+	    throw "no volume specified";
+	}
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.callParent();
+
+	Proxmox.Utils.API2Request({
+	    url: "/nodes/" + nodename + "/vzdump/extractconfig",
+	    method: 'GET',
+	    params: {
+		volume: me.volume,
+	    },
+	    failure: function(response, opts) {
+		me.close();
+		Ext.Msg.alert('Error', response.htmlStatus);
+	    },
+	    success: function(response, options) {
+		me.show();
+		me.down('#configtext').update(Ext.htmlEncode(response.result.data));
+	    },
+	});
+    },
+});
+Ext.define('PVE.window.BulkAction', {
+    extend: 'Ext.window.Window',
+
+    resizable: true,
+    width: 800,
+    height: 600,
+    modal: true,
+    layout: {
+	type: 'fit',
+    },
+    border: false,
+
+    // the action to set, currently there are: `startall`, `migrateall`, `stopall`, `suspendall`
+    action: undefined,
+
+    submit: function(params) {
+	let me = this;
+
+	Proxmox.Utils.API2Request({
+	    params: params,
+	    url: `/nodes/${me.nodename}/${me.action}`,
+	    waitMsgTarget: me,
+	    method: 'POST',
+	    failure: response => Ext.Msg.alert('Error', response.htmlStatus),
+	    success: function({ result }, options) {
+		Ext.create('Proxmox.window.TaskViewer', {
+		    autoShow: true,
+		    upid: result.data,
+		    listeners: {
+			destroy: () => me.close(),
+		    },
+		});
+		me.hide();
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (!me.action) {
+	    throw "no action specified";
+	}
+	if (!me.btnText) {
+	    throw "no button text specified";
+	}
+	if (!me.title) {
+	    throw "no title specified";
+	}
+
+	let items = [];
+	if (me.action === 'migrateall') {
+	    items.push(
+		{
+		    xtype: 'fieldcontainer',
+		    layout: 'hbox',
+		    items: [{
+			flex: 1,
+			xtype: 'pveNodeSelector',
+			name: 'target',
+			disallowedNodes: [me.nodename],
+			fieldLabel: gettext('Target node'),
+			labelWidth: 200,
+			allowBlank: false,
+			onlineValidator: true,
+			padding: '0 10 0 0',
+		    },
+		    {
+			xtype: 'proxmoxintegerfield',
+			name: 'maxworkers',
+			minValue: 1,
+			maxValue: 100,
+			value: 1,
+			fieldLabel: gettext('Parallel jobs'),
+			allowBlank: false,
+			flex: 1,
+		    }],
+		},
+		{
+		    xtype: 'fieldcontainer',
+		    layout: 'hbox',
+		    items: [{
+			xtype: 'proxmoxcheckbox',
+			fieldLabel: gettext('Allow local disk migration'),
+			name: 'with-local-disks',
+			labelWidth: 200,
+			checked: true,
+			uncheckedValue: 0,
+			flex: 1,
+			padding: '0 10 0 0',
+		    },
+		    {
+			itemId: 'lxcwarning',
+			xtype: 'displayfield',
+			userCls: 'pmx-hint',
+			value: 'Warning: Running CTs will be migrated in Restart Mode.',
+			hidden: true, // only visible if running container chosen
+			flex: 1,
+		    }],
+		},
+	    );
+	} else if (me.action === 'startall') {
+	    items.push({
+		xtype: 'hiddenfield',
+		name: 'force',
+		value: 1,
+	    });
+	} else if (me.action === 'stopall') {
+	    items.push({
+		xtype: 'fieldcontainer',
+		layout: 'hbox',
+		items: [{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'force-stop',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Force Stop'),
+		    boxLabel: gettext('Force stop guest if shutdown times out.'),
+		    checked: true,
+		    uncheckedValue: 0,
+		    flex: 1,
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'timeout',
+		    fieldLabel: gettext('Timeout (s)'),
+		    labelWidth: 120,
+		    emptyText: '180',
+		    minValue: 0,
+		    maxValue: 7200,
+		    allowBlank: true,
+		    flex: 1,
+		}],
+	    });
+	}
+
+	let refreshLxcWarning = function(vmids, records) {
+	    let showWarning = records.some(
+		item => vmids.includes(item.data.vmid) && item.data.type === 'lxc' && item.data.status === 'running',
+	    );
+	    me.down('#lxcwarning').setVisible(showWarning);
+	};
+
+	let defaultStatus = me.action === 'migrateall' ? '' : me.action === 'startall' ? 'stopped' : 'running';
+	let defaultType = me.action === 'suspendall' ? 'qemu' : '';
+
+	let statusMap = [];
+	let poolMap = [];
+	let haMap = [];
+	let tagMap = [];
+	PVE.data.ResourceStore.each((rec) => {
+	    if (['qemu', 'lxc'].indexOf(rec.data.type) !== -1) {
+		statusMap[rec.data.status] = true;
+	    }
+	    if (rec.data.type === 'pool') {
+		poolMap[rec.data.pool] = true;
+	    }
+	    if (rec.data.hastate !== "") {
+		haMap[rec.data.hastate] = true;
+	    }
+	    if (rec.data.tags !== "") {
+		rec.data.tags.split(/[,; ]/).forEach((tag) => {
+		    if (tag !== '') {
+			tagMap[tag] = true;
+		    }
+		});
+	    }
+	});
+
+	let statusList = Object.keys(statusMap).map(key => [key, key]);
+	statusList.unshift(['', gettext('All')]);
+	let poolList = Object.keys(poolMap).map(key => [key, key]);
+	let tagList = Object.keys(tagMap).map(key => ({ value: key }));
+	let haList = Object.keys(haMap).map(key => [key, key]);
+
+	let clearFilters = function() {
+	    me.down('#namefilter').setValue('');
+	    ['name', 'status', 'pool', 'type', 'hastate', 'includetag', 'excludetag', 'vmid'].forEach((filter) => {
+		me.down(`#${filter}filter`).setValue('');
+	    });
+	};
+
+	let filterChange = function() {
+	    let nameValue = me.down('#namefilter').getValue();
+	    let filterCount = 0;
+
+	    if (nameValue !== '') {
+		filterCount++;
+	    }
+
+	    let arrayFiltersData = [];
+	    ['pool', 'hastate'].forEach((filter) => {
+		let selected = me.down(`#${filter}filter`).getValue() ?? [];
+		if (selected.length) {
+		    filterCount++;
+		    arrayFiltersData.push([filter, [...selected]]);
+		}
+	    });
+
+	    let singleFiltersData = [];
+	    ['status', 'type'].forEach((filter) => {
+		let selected = me.down(`#${filter}filter`).getValue() ?? '';
+		if (selected.length) {
+		    filterCount++;
+		    singleFiltersData.push([filter, selected]);
+		}
+	    });
+
+	    let includeTags = me.down('#includetagfilter').getValue() ?? [];
+	    if (includeTags.length) {
+		filterCount++;
+	    }
+	    let excludeTags = me.down('#excludetagfilter').getValue() ?? [];
+	    if (excludeTags.length) {
+		filterCount++;
+	    }
+
+	    let fieldSet = me.down('#filters');
+	    let clearBtn = me.down('#clearBtn');
+	    if (filterCount) {
+		fieldSet.setTitle(Ext.String.format(gettext('Filters ({0})'), filterCount));
+		clearBtn.setDisabled(false);
+	    } else {
+		fieldSet.setTitle(gettext('Filters'));
+		clearBtn.setDisabled(true);
+	    }
+
+	    let filterFn = function(value) {
+		let name = value.data.name.toLowerCase().indexOf(nameValue.toLowerCase()) !== -1;
+		let arrayFilters = arrayFiltersData.every(([filter, selected]) =>
+		    !selected.length || selected.indexOf(value.data[filter]) !== -1);
+		let singleFilters = singleFiltersData.every(([filter, selected]) =>
+		    !selected.length || value.data[filter].indexOf(selected) !== -1);
+		let tags = value.data.tags.split(/[;, ]/).filter(t => !!t);
+		let includeFilter = !includeTags.length || tags.some(tag => includeTags.indexOf(tag) !== -1);
+		let excludeFilter = !excludeTags.length || tags.every(tag => excludeTags.indexOf(tag) === -1);
+
+		return name && arrayFilters && singleFilters && includeFilter && excludeFilter;
+	    };
+	    let vmselector = me.down('#vms');
+	    vmselector.getStore().setFilters({
+		id: 'customFilter',
+		filterFn,
+	    });
+	    vmselector.checkChange();
+	    if (me.action === 'migrateall') {
+		let records = vmselector.getSelection();
+		refreshLxcWarning(vmselector.getValue(), records);
+	    }
+	};
+
+	items.push({
+	    xtype: 'fieldset',
+	    itemId: 'filters',
+	    collapsible: true,
+	    title: gettext('Filters'),
+	    layout: 'hbox',
+	    items: [
+		{
+		    xtype: 'container',
+		    flex: 1,
+		    padding: 5,
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    defaults: {
+			listeners: {
+			    change: filterChange,
+			},
+			isFormField: false,
+		    },
+		    items: [
+			{
+			    fieldLabel: gettext("Name"),
+			    itemId: 'namefilter',
+			    xtype: 'textfield',
+			},
+			{
+			    xtype: 'combobox',
+			    itemId: 'statusfilter',
+			    fieldLabel: gettext("Status"),
+			    emptyText: gettext('All'),
+			    editable: false,
+			    value: defaultStatus,
+			    store: statusList,
+			},
+			{
+			    xtype: 'combobox',
+			    itemId: 'poolfilter',
+			    fieldLabel: gettext("Pool"),
+			    emptyText: gettext('All'),
+			    editable: false,
+			    multiSelect: true,
+			    store: poolList,
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    flex: 1,
+		    padding: 5,
+		    defaults: {
+			listeners: {
+			    change: filterChange,
+			},
+			isFormField: false,
+		    },
+		    items: [
+			{
+			    xtype: 'combobox',
+			    itemId: 'typefilter',
+			    fieldLabel: gettext("Type"),
+			    emptyText: gettext('All'),
+			    editable: false,
+			    value: defaultType,
+			    store: [
+				['', gettext('All')],
+				['lxc', gettext('CT')],
+				['qemu', gettext('VM')],
+			    ],
+			},
+			{
+			    xtype: 'proxmoxComboGrid',
+			    itemId: 'includetagfilter',
+			    fieldLabel: gettext("Include Tags"),
+			    emptyText: gettext('All'),
+			    editable: false,
+			    multiSelect: true,
+			    valueField: 'value',
+			    displayField: 'value',
+			    listConfig: {
+				userCls: 'proxmox-tags-full',
+				columns: [
+				    {
+					dataIndex: 'value',
+					flex: 1,
+					renderer: value =>
+					    PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
+				    },
+				],
+			    },
+			    store: {
+				data: tagList,
+			    },
+			    listeners: {
+				change: filterChange,
+			    },
+			},
+			{
+			    xtype: 'proxmoxComboGrid',
+			    itemId: 'excludetagfilter',
+			    fieldLabel: gettext("Exclude Tags"),
+			    emptyText: gettext('None'),
+			    multiSelect: true,
+			    editable: false,
+			    valueField: 'value',
+			    displayField: 'value',
+			    listConfig: {
+				userCls: 'proxmox-tags-full',
+				columns: [
+				    {
+					dataIndex: 'value',
+					flex: 1,
+					renderer: value =>
+					    PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
+				    },
+				],
+			    },
+			    store: {
+				data: tagList,
+			    },
+			    listeners: {
+				change: filterChange,
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    flex: 1,
+		    padding: 5,
+		    defaults: {
+			listeners: {
+			    change: filterChange,
+			},
+			isFormField: false,
+		    },
+		    items: [
+			{
+			    xtype: 'combobox',
+			    itemId: 'hastatefilter',
+			    fieldLabel: gettext("HA status"),
+			    emptyText: gettext('All'),
+			    multiSelect: true,
+			    editable: false,
+			    store: haList,
+			    listeners: {
+				change: filterChange,
+			    },
+			},
+			{
+			    xtype: 'container',
+			    layout: {
+				type: 'vbox',
+				align: 'end',
+			    },
+			    items: [
+				{
+				    xtype: 'button',
+				    itemId: 'clearBtn',
+				    text: gettext('Clear Filters'),
+				    disabled: true,
+				    handler: clearFilters,
+				},
+			    ],
+			},
+		    ],
+		},
+	    ],
+	});
+
+	items.push({
+	    xtype: 'vmselector',
+	    itemId: 'vms',
+	    name: 'vms',
+	    flex: 1,
+	    height: 300,
+	    selectAll: true,
+	    allowBlank: false,
+	    plugins: '',
+	    nodename: me.nodename,
+	    listeners: {
+		selectionchange: function(vmselector, records) {
+		    if (me.action === 'migrateall') {
+			let vmids = me.down('#vms').getValue();
+			refreshLxcWarning(vmids, records);
+		    }
+		},
+	    },
+	});
+
+	me.formPanel = Ext.create('Ext.form.Panel', {
+	    bodyPadding: 10,
+	    border: false,
+	    layout: {
+		type: 'vbox',
+		align: 'stretch',
+	    },
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: items,
+	});
+
+	let form = me.formPanel.getForm();
+
+	let submitBtn = Ext.create('Ext.Button', {
+	    text: me.btnText,
+	    handler: function() {
+		form.isValid();
+		me.submit(form.getValues());
+	    },
+	});
+
+	Ext.apply(me, {
+	    items: [me.formPanel],
+	    buttons: [submitBtn],
+	});
+
+	me.callParent();
+
+	form.on('validitychange', function() {
+	    let valid = form.isValid();
+	    submitBtn.setDisabled(!valid);
+	});
+	form.isValid();
+
+	filterChange();
+    },
+});
+Ext.define('PVE.ceph.Install', {
+    extend: 'Ext.window.Window',
+    xtype: 'pveCephInstallWindow',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 220,
+    header: false,
+    resizable: false,
+    draggable: false,
+    modal: true,
+    nodename: undefined,
+    shadow: false,
+    border: false,
+    bodyBorder: false,
+    closable: false,
+    cls: 'install-mask',
+    bodyCls: 'install-mask',
+    layout: {
+        align: 'stretch',
+        pack: 'center',
+	type: 'vbox',
+    },
+    viewModel: {
+	data: {
+	      isInstalled: false,
+	},
+	formulas: {
+	    buttonText: function(get) {
+		if (get('isInstalled')) {
+		    return gettext('Configure Ceph');
+		} else {
+		    return gettext('Install Ceph');
+		}
+	    },
+	    windowText: function(get) {
+		if (get('isInstalled')) {
+		    return `<p class="install-mask">
+		    ${Ext.String.format(gettext('{0} is not initialized.'), 'Ceph')}
+		    ${gettext('You need to create an initial config once.')}</p>`;
+		} else {
+		    return '<p class="install-mask">' +
+		    Ext.String.format(gettext('{0} is not installed on this node.'), 'Ceph') + '<br>' +
+		    gettext('Would you like to install it now?') + '</p>';
+		}
+	    },
+	},
+    },
+    items: [
+	{
+	    bind: {
+		html: '{windowText}',
+	    },
+	    border: false,
+	    padding: 5,
+	    bodyCls: 'install-mask',
+
+	},
+	{
+	    xtype: 'button',
+	    bind: {
+		text: '{buttonText}',
+	    },
+	    viewModel: {},
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	    handler: function() {
+		let view = this.up('pveCephInstallWindow');
+		let wizzard = Ext.create('PVE.ceph.CephInstallWizard', {
+		    nodename: view.nodename,
+		});
+		wizzard.getViewModel().set('isInstalled', this.getViewModel().get('isInstalled'));
+		wizzard.show();
+		view.mon(wizzard, 'beforeClose', function() {
+		    view.fireEvent("cephInstallWindowClosed");
+		    view.close();
+		});
+	    },
+	},
+    ],
+});
+Ext.define('PVE.window.Clone', {
+    extend: 'Ext.window.Window',
+
+    resizable: false,
+
+    isTemplate: false,
+
+    onlineHelp: 'qm_copy_and_clone',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'panel[reference=cloneform]': {
+		validitychange: 'disableSubmit',
+	    },
+	},
+	disableSubmit: function(form) {
+	    this.lookupReference('submitBtn').setDisabled(!form.isValid());
+	},
+    },
+
+    statics: {
+	// display a snapshot selector only if needed
+	wrap: function(nodename, vmid, isTemplate, guestType) {
+	    Proxmox.Utils.API2Request({
+		url: '/nodes/' + nodename + '/' + guestType + '/' + vmid +'/snapshot',
+		failure: function(response, opts) {
+		    Ext.Msg.alert('Error', response.htmlStatus);
+		},
+		success: function(response, opts) {
+		    var snapshotList = response.result.data;
+		    var hasSnapshots = !(snapshotList.length === 1 &&
+			snapshotList[0].name === 'current');
+
+		    Ext.create('PVE.window.Clone', {
+			nodename: nodename,
+			guestType: guestType,
+			vmid: vmid,
+			isTemplate: isTemplate,
+			hasSnapshots: hasSnapshots,
+		    }).show();
+		},
+	    });
+	},
+    },
+
+    create_clone: function(values) {
+	var me = this;
+
+	var params = { newid: values.newvmid };
+
+	if (values.snapname && values.snapname !== 'current') {
+	    params.snapname = values.snapname;
+	}
+
+	if (values.pool) {
+	    params.pool = values.pool;
+	}
+
+	if (values.name) {
+	    if (me.guestType === 'lxc') {
+		params.hostname = values.name;
+	    } else {
+		params.name = values.name;
+	    }
+	}
+
+	if (values.target) {
+	    params.target = values.target;
+	}
+
+	if (values.clonemode === 'copy') {
+	    params.full = 1;
+	    if (values.hdstorage) {
+		params.storage = values.hdstorage;
+		if (values.diskformat && me.guestType !== 'lxc') {
+		    params.format = values.diskformat;
+		}
+	    }
+	}
+
+	Proxmox.Utils.API2Request({
+	    params: params,
+	    url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/clone',
+	    waitMsgTarget: me,
+	    method: 'POST',
+	    failure: function(response, opts) {
+		Ext.Msg.alert('Error', response.htmlStatus);
+	    },
+	    success: function(response, options) {
+		me.close();
+	    },
+	});
+    },
+
+    // disable the Storage selector when clone mode is linked clone
+    updateVisibility: function() {
+	var me = this;
+	var clonemode = me.lookupReference('clonemodesel').getValue();
+	var disksel = me.lookup('diskselector');
+	disksel.setDisabled(clonemode === 'clone');
+    },
+
+    // add to the list of valid nodes each node where
+    // all the VM disks are available
+    verifyFeature: function() {
+	var me = this;
+
+	var snapname = me.lookupReference('snapshotsel').getValue();
+	var clonemode = me.lookupReference('clonemodesel').getValue();
+
+	var params = { feature: clonemode };
+	if (snapname !== 'current') {
+	    params.snapname = snapname;
+	}
+
+	Proxmox.Utils.API2Request({
+	    waitMsgTarget: me,
+	    url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/feature',
+	    params: params,
+	    method: 'GET',
+	    failure: function(response, opts) {
+		me.lookupReference('submitBtn').setDisabled(true);
+		Ext.Msg.alert('Error', response.htmlStatus);
+	    },
+	    success: function(response, options) {
+		var res = response.result.data;
+
+		me.lookupReference('targetsel').allowedNodes = res.nodes;
+		me.lookupReference('targetsel').validate();
+	    },
+	});
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	if (!me.snapname) {
+	    me.snapname = 'current';
+	}
+
+	if (!me.guestType) {
+	    throw "no Guest Type specified";
+	}
+
+	var titletext = me.guestType === 'lxc' ? 'CT' : 'VM';
+	if (me.isTemplate) {
+	    titletext += ' Template';
+	}
+	me.title = "Clone " + titletext + " " + me.vmid;
+
+	var col1 = [];
+	var col2 = [];
+
+	col1.push({
+	    xtype: 'pveNodeSelector',
+	    name: 'target',
+	    reference: 'targetsel',
+	    fieldLabel: gettext('Target node'),
+	    selectCurNode: true,
+	    allowBlank: false,
+	    onlineValidator: true,
+	    listeners: {
+		change: function(f, value) {
+		    me.lookup('diskselector').getComponent('hdstorage').setTargetNode(value);
+		},
+	    },
+	});
+
+	var modelist = [['copy', gettext('Full Clone')]];
+	if (me.isTemplate) {
+	    modelist.push(['clone', gettext('Linked Clone')]);
+	}
+
+	col1.push({
+	    xtype: 'pveGuestIDSelector',
+	    name: 'newvmid',
+	    guestType: me.guestType,
+	    value: '',
+	    loadNextFreeID: true,
+	    validateExists: false,
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'name',
+	    vtype: 'DnsName',
+	    allowBlank: true,
+	    fieldLabel: me.guestType === 'lxc' ? gettext('Hostname') : gettext('Name'),
+	},
+	{
+	    xtype: 'pvePoolSelector',
+	    fieldLabel: gettext('Resource Pool'),
+	    name: 'pool',
+	    value: '',
+	    allowBlank: true,
+	},
+	);
+
+	col2.push({
+	    xtype: 'proxmoxKVComboBox',
+	    fieldLabel: gettext('Mode'),
+	    name: 'clonemode',
+	    reference: 'clonemodesel',
+	    allowBlank: false,
+	    hidden: !me.isTemplate,
+	    value: me.isTemplate ? 'clone' : 'copy',
+	    comboItems: modelist,
+	    listeners: {
+		change: function(t, value) {
+		    me.updateVisibility();
+		    me.verifyFeature();
+		},
+	    },
+	},
+	{
+	    xtype: 'PVE.form.SnapshotSelector',
+	    name: 'snapname',
+	    reference: 'snapshotsel',
+	    fieldLabel: gettext('Snapshot'),
+	    nodename: me.nodename,
+	    guestType: me.guestType,
+	    vmid: me.vmid,
+	    hidden: !!(me.isTemplate || !me.hasSnapshots),
+	    disabled: false,
+	    allowBlank: false,
+	    value: me.snapname,
+	    listeners: {
+		change: function(f, value) {
+		    me.verifyFeature();
+		},
+	    },
+	},
+	{
+	    xtype: 'pveDiskStorageSelector',
+	    reference: 'diskselector',
+	    nodename: me.nodename,
+	    autoSelect: false,
+	    hideSize: true,
+	    hideSelection: true,
+	    storageLabel: gettext('Target Storage'),
+	    allowBlank: true,
+	    storageContent: me.guestType === 'qemu' ? 'images' : 'rootdir',
+	    emptyText: gettext('Same as source'),
+	    disabled: !!me.isTemplate, // because default mode is clone for templates
+	});
+
+	var formPanel = Ext.create('Ext.form.Panel', {
+	    bodyPadding: 10,
+	    reference: 'cloneform',
+	    border: false,
+	    layout: 'hbox',
+	    defaultType: 'container',
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    flex: 1,
+		    padding: '0 10 0 0',
+		    layout: 'anchor',
+		    items: col1,
+		},
+		{
+		    flex: 1,
+		    padding: '0 0 0 10',
+		    layout: 'anchor',
+		    items: col2,
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    modal: true,
+	    width: 600,
+	    height: 250,
+	    border: false,
+	    layout: 'fit',
+	    buttons: [{
+		xtype: 'proxmoxHelpButton',
+		listenToGlobalEvent: false,
+		hidden: false,
+		onlineHelp: me.onlineHelp,
+	    },
+	    '->',
+	    {
+		reference: 'submitBtn',
+		text: gettext('Clone'),
+		disabled: true,
+		handler: function() {
+		    var cloneForm = me.lookupReference('cloneform');
+		    if (cloneForm.isValid()) {
+			me.create_clone(cloneForm.getValues());
+		    }
+		},
+	    }],
+	    items: [formPanel],
+	});
+
+	me.callParent();
+
+	me.verifyFeature();
+    },
+});
+Ext.define('PVE.FirewallEnableEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveFirewallEnableEdit'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    subject: gettext('Firewall'),
+    cbindData: {
+	defaultValue: 0,
+    },
+    width: 350,
+
+    items: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'enable',
+	    uncheckedValue: 0,
+	    cbind: {
+		defaultValue: '{defaultValue}',
+		checked: '{defaultValue}',
+	    },
+	    deleteDefaultValue: false,
+	    fieldLabel: gettext('Firewall'),
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'warning',
+	    userCls: 'pmx-hint',
+	    value: gettext('Warning: Firewall still disabled at datacenter level!'),
+	    hidden: true,
+	},
+    ],
+
+    beforeShow: function() {
+	var me = this;
+
+	Proxmox.Utils.API2Request({
+	    url: '/api2/extjs/cluster/firewall/options',
+	    method: 'GET',
+	    failure: function(response, opts) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		if (!response.result.data.enable) {
+		    me.down('displayfield[name=warning]').setVisible(true);
+		}
+	    },
+	});
+    },
+});
+Ext.define('PVE.FirewallLograteInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveFirewallLograteInputPanel',
+
+    viewModel: {},
+
+    items: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'enable',
+	    reference: 'enable',
+	    fieldLabel: gettext('Enable'),
+	    value: true,
+	},
+	{
+	    layout: 'hbox',
+	    border: false,
+	    items: [
+		{
+		    xtype: 'numberfield',
+		    name: 'rate',
+		    fieldLabel: gettext('Log rate limit'),
+		    minValue: 1,
+		    maxValue: 99,
+		    allowBlank: false,
+		    flex: 2,
+		    value: 1,
+		},
+		{
+		    xtype: 'box',
+		    html: '<div style="margin: auto; padding: 2.5px;"><b>/</b></div>',
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'unit',
+		    comboItems: [
+			['second', 'second'],
+			['minute', 'minute'],
+			['hour', 'hour'],
+			['day', 'day'],
+		    ],
+		    allowBlank: false,
+		    flex: 1,
+		    value: 'second',
+		},
+	    ],
+	},
+	{
+	    xtype: 'numberfield',
+	    name: 'burst',
+	    fieldLabel: gettext('Log burst limit'),
+	    minValue: 1,
+	    maxValue: 99,
+	    value: 5,
+	},
+    ],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	let cfg = {
+	    enable: values.enable !== undefined ? 1 : 0,
+	    rate: values.rate + '/' + values.unit,
+	    burst: values.burst,
+	};
+	let properties = PVE.Parser.printPropertyString(cfg, undefined);
+	if (properties === '') {
+	    return { 'delete': 'log_ratelimit' };
+	}
+	return { log_ratelimit: properties };
+    },
+
+    setValues: function(values) {
+	let me = this;
+
+	let properties = {};
+	if (values.log_ratelimit !== undefined) {
+	    properties = PVE.Parser.parsePropertyString(values.log_ratelimit, 'enable');
+	    if (properties.rate) {
+		var matches = properties.rate.match(/^(\d+)\/(second|minute|hour|day)$/);
+		if (matches) {
+		    properties.rate = matches[1];
+		    properties.unit = matches[2];
+		}
+	    }
+	}
+	me.callParent([properties]);
+    },
+});
+
+Ext.define('PVE.FirewallLograteEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveFirewallLograteEdit',
+
+    subject: gettext('Log rate limit'),
+
+    items: [{
+	xtype: 'pveFirewallLograteInputPanel',
+    }],
+    autoLoad: true,
+});
+/*global u2f*/
+Ext.define('PVE.window.LoginWindow', {
+    extend: 'Ext.window.Window',
+
+    viewModel: {
+	data: {
+	    openid: false,
+	},
+	formulas: {
+	    button_text: function(get) {
+		if (get("openid") === true) {
+		    return gettext("Login (OpenID redirect)");
+		} else {
+		    return gettext("Login");
+		}
+	    },
+	},
+    },
+
+    controller: {
+
+	xclass: 'Ext.app.ViewController',
+
+	onLogon: async function() {
+	    var me = this;
+
+	    var form = this.lookupReference('loginForm');
+	    var unField = this.lookupReference('usernameField');
+	    var saveunField = this.lookupReference('saveunField');
+	    var view = this.getView();
+
+	    if (!form.isValid()) {
+		return;
+	    }
+
+	    let creds = form.getValues();
+
+	    if (this.getViewModel().data.openid === true) {
+		const redirectURL = location.origin;
+		Proxmox.Utils.API2Request({
+		    url: '/api2/extjs/access/openid/auth-url',
+		    params: {
+			realm: creds.realm,
+			"redirect-url": redirectURL,
+		    },
+		    method: 'POST',
+		    success: function(resp, opts) {
+			window.location = resp.result.data;
+		    },
+		    failure: function(resp, opts) {
+			Proxmox.Utils.authClear();
+			form.unmask();
+			Ext.MessageBox.alert(
+			    gettext('Error'),
+			    gettext('OpenID redirect failed.') + `<br>${resp.htmlStatus}`,
+			);
+		    },
+		});
+		return;
+	    }
+
+	    view.el.mask(gettext('Please wait...'), 'x-mask-loading');
+
+	    // set or clear username
+	    var sp = Ext.state.Manager.getProvider();
+	    if (saveunField.getValue() === true) {
+		sp.set(unField.getStateId(), unField.getValue());
+	    } else {
+		sp.clear(unField.getStateId());
+	    }
+	    sp.set(saveunField.getStateId(), saveunField.getValue());
+
+	    try {
+		// Request updated authentication mechanism:
+		creds['new-format'] = 1;
+
+		let resp = await Proxmox.Async.api2({
+		    url: '/api2/extjs/access/ticket',
+		    params: creds,
+		    method: 'POST',
+		});
+
+		let data = resp.result.data;
+		if (data.ticket.startsWith("PVE:!tfa!")) {
+		    // Store first factor login information first:
+		    data.LoggedOut = true;
+		    Proxmox.Utils.setAuthData(data);
+
+		    data = await me.performTFAChallenge(data);
+
+		    // Fill in what we copy over from the 1st factor:
+		    data.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
+		    data.username = Proxmox.UserName;
+		    me.success(data);
+		} else if (Ext.isDefined(data.NeedTFA)) {
+		    // Store first factor login information first:
+		    data.LoggedOut = true;
+		    Proxmox.Utils.setAuthData(data);
+
+		    if (Ext.isDefined(data.U2FChallenge)) {
+			me.perform_u2f(data);
+		    } else {
+			me.perform_otp();
+		    }
+		} else {
+		    me.success(data);
+		}
+	    } catch (error) {
+		me.failure(error);
+	    }
+	},
+
+	/* START NEW TFA CODE (pbs copy) */
+	performTFAChallenge: async function(data) {
+	    let me = this;
+
+	    let userid = data.username;
+	    let ticket = data.ticket;
+	    let challenge = JSON.parse(decodeURIComponent(
+	        ticket.split(':')[1].slice("!tfa!".length),
+	    ));
+
+	    let resp = await new Promise((resolve, reject) => {
+		Ext.create('Proxmox.window.TfaLoginWindow', {
+		    userid,
+		    ticket,
+		    challenge,
+		    onResolve: value => resolve(value),
+		    onReject: reject,
+		}).show();
+	    });
+
+	    return resp.result.data;
+	},
+	/* END NEW TFA CODE (pbs copy) */
+
+	failure: function(resp) {
+	    var me = this;
+	    var view = me.getView();
+	    view.el.unmask();
+	    var handler = function() {
+		var uf = me.lookupReference('usernameField');
+		uf.focus(true, true);
+	    };
+
+	    let emsg = gettext("Login failed. Please try again");
+
+	    if (resp.failureType === "connect") {
+		emsg = gettext("Connection failure. Network error or Proxmox VE services not running?");
+	    }
+
+	    Ext.MessageBox.alert(gettext('Error'), emsg, handler);
+	},
+	success: function(data) {
+	    var me = this;
+	    var view = me.getView();
+	    var handler = view.handler || Ext.emptyFn;
+	    handler.call(me, data);
+	    view.close();
+	},
+
+	perform_otp: function() {
+	    var me = this;
+	    var win = Ext.create('PVE.window.TFALoginWindow', {
+		onLogin: function(value) {
+		    me.finish_tfa(value);
+		},
+		onCancel: function() {
+		    Proxmox.LoggedOut = false;
+		    Proxmox.Utils.authClear();
+		    me.getView().show();
+		},
+	    });
+	    win.show();
+	},
+
+	perform_u2f: function(data) {
+	    var me = this;
+	    // Show the message:
+	    var msg = Ext.Msg.show({
+		title: 'U2F: '+gettext('Verification'),
+		message: gettext('Please press the button on your U2F Device'),
+		buttons: [],
+	    });
+	    var chlg = data.U2FChallenge;
+	    var key = {
+		version: chlg.version,
+		keyHandle: chlg.keyHandle,
+	    };
+	    u2f.sign(chlg.appId, chlg.challenge, [key], function(res) {
+		msg.close();
+		if (res.errorCode) {
+		    Proxmox.Utils.authClear();
+		    Ext.Msg.alert(gettext('Error'), PVE.Utils.render_u2f_error(res.errorCode));
+		    return;
+		}
+		delete res.errorCode;
+		me.finish_tfa(JSON.stringify(res));
+	    });
+	},
+	finish_tfa: function(res) {
+	    var me = this;
+	    var view = me.getView();
+	    view.el.mask(gettext('Please wait...'), 'x-mask-loading');
+	    Proxmox.Utils.API2Request({
+		url: '/api2/extjs/access/tfa',
+		params: {
+		    response: res,
+		},
+		method: 'POST',
+		timeout: 5000, // it'll delay both success & failure
+		success: function(resp, opts) {
+		    view.el.unmask();
+		    // Fill in what we copy over from the 1st factor:
+		    var data = resp.result.data;
+		    data.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
+		    data.username = Proxmox.UserName;
+		    // Finish logging in:
+		    me.success(data);
+		},
+		failure: function(resp, opts) {
+		    Proxmox.Utils.authClear();
+		    me.failure(resp);
+		},
+	    });
+	},
+
+	control: {
+	    'field[name=username]': {
+		specialkey: function(f, e) {
+		    if (e.getKey() === e.ENTER) {
+			var pf = this.lookupReference('passwordField');
+			if (!pf.getValue()) {
+			    pf.focus(false);
+			}
+		    }
+		},
+	    },
+	    'field[name=lang]': {
+		change: function(f, value) {
+		    var dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
+		    Ext.util.Cookies.set('PVELangCookie', value, dt);
+		    this.getView().mask(gettext('Please wait...'), 'x-mask-loading');
+		    window.location.reload();
+		},
+	    },
+	    'field[name=realm]': {
+		change: function(f, value) {
+		    let record = f.store.getById(value);
+		    if (record === undefined) return;
+		    let data = record.data;
+		    this.getViewModel().set("openid", data.type === "openid");
+		},
+	    },
+	   'button[reference=loginButton]': {
+		click: 'onLogon',
+	    },
+	    '#': {
+		show: function() {
+		    var me = this;
+
+		    var sp = Ext.state.Manager.getProvider();
+		    var checkboxField = this.lookupReference('saveunField');
+		    var unField = this.lookupReference('usernameField');
+
+		    var checked = sp.get(checkboxField.getStateId());
+		    checkboxField.setValue(checked);
+
+		    if (checked === true) {
+			var username = sp.get(unField.getStateId());
+			unField.setValue(username);
+			var pwField = this.lookupReference('passwordField');
+			pwField.focus();
+		    }
+
+		    let auth = Proxmox.Utils.getOpenIDRedirectionAuthorization();
+		    if (auth !== undefined) {
+			Proxmox.Utils.authClear();
+
+			let loginForm = this.lookupReference('loginForm');
+			loginForm.mask(gettext('OpenID login - please wait...'), 'x-mask-loading');
+
+			const redirectURL = location.origin;
+
+			Proxmox.Utils.API2Request({
+			    url: '/api2/extjs/access/openid/login',
+			    params: {
+				state: auth.state,
+				code: auth.code,
+				"redirect-url": redirectURL,
+			    },
+			    method: 'POST',
+			    failure: function(response) {
+				loginForm.unmask();
+				let error = response.htmlStatus;
+				Ext.MessageBox.alert(
+				    gettext('Error'),
+				    gettext('OpenID login failed, please try again') + `<br>${error}`,
+				    () => { window.location = redirectURL; },
+				);
+			    },
+			    success: function(response, options) {
+				loginForm.unmask();
+				let data = response.result.data;
+				history.replaceState(null, '', redirectURL);
+				me.success(data);
+			    },
+			});
+		    }
+		},
+	    },
+	},
+    },
+
+    width: 400,
+    modal: true,
+    border: false,
+    draggable: true,
+    closable: false,
+    resizable: false,
+    layout: 'auto',
+
+    title: gettext('Proxmox VE Login'),
+
+    defaultFocus: 'usernameField',
+    defaultButton: 'loginButton',
+
+    items: [{
+	xtype: 'form',
+	layout: 'form',
+	url: '/api2/extjs/access/ticket',
+	reference: 'loginForm',
+
+	fieldDefaults: {
+	    labelAlign: 'right',
+	    allowBlank: false,
+	},
+
+	items: [
+	    {
+		xtype: 'textfield',
+		fieldLabel: gettext('User name'),
+		name: 'username',
+		itemId: 'usernameField',
+		reference: 'usernameField',
+		stateId: 'login-username',
+		inputAttrTpl: 'autocomplete=username',
+		bind: {
+		    visible: "{!openid}",
+		    disabled: "{openid}",
+		},
+	    },
+	    {
+		xtype: 'textfield',
+		inputType: 'password',
+		fieldLabel: gettext('Password'),
+		name: 'password',
+		reference: 'passwordField',
+		inputAttrTpl: 'autocomplete=current-password',
+		bind: {
+		    visible: "{!openid}",
+		    disabled: "{openid}",
+		},
+	    },
+	    {
+		xtype: 'pmxRealmComboBox',
+		name: 'realm',
+	    },
+	    {
+		xtype: 'proxmoxLanguageSelector',
+		fieldLabel: gettext('Language'),
+		value: PVE.Utils.getUiLanguage(),
+		name: 'lang',
+		reference: 'langField',
+		submitValue: false,
+	    },
+	],
+	buttons: [
+	    {
+		xtype: 'checkbox',
+		fieldLabel: gettext('Save User name'),
+		name: 'saveusername',
+		reference: 'saveunField',
+		stateId: 'login-saveusername',
+		labelWidth: 250,
+		labelAlign: 'right',
+		submitValue: false,
+		bind: {
+		    visible: "{!openid}",
+		},
+	    },
+	    {
+		bind: {
+		    text: "{button_text}",
+		},
+		reference: 'loginButton',
+	    },
+	],
+    }],
+ });
+Ext.define('PVE.window.Migrate', {
+    extend: 'Ext.window.Window',
+
+    vmtype: undefined,
+    nodename: undefined,
+    vmid: undefined,
+    maxHeight: 450,
+
+    viewModel: {
+	data: {
+	    vmid: undefined,
+	    nodename: undefined,
+	    vmtype: undefined,
+	    running: false,
+	    qemu: {
+		onlineHelp: 'qm_migration',
+		commonName: 'VM',
+	    },
+	    lxc: {
+		onlineHelp: 'pct_migration',
+		commonName: 'CT',
+	    },
+	    migration: {
+		possible: true,
+		preconditions: [],
+		'with-local-disks': 0,
+		mode: undefined,
+		allowedNodes: undefined,
+		overwriteLocalResourceCheck: false,
+		hasLocalResources: false,
+	    },
+
+	},
+
+	formulas: {
+	    setMigrationMode: function(get) {
+		if (get('running')) {
+		    if (get('vmtype') === 'qemu') {
+			return gettext('Online');
+		    } else {
+			return gettext('Restart Mode');
+		    }
+		} else {
+		    return gettext('Offline');
+		}
+	    },
+	    setStorageselectorHidden: function(get) {
+		    if (get('migration.with-local-disks') && get('running')) {
+			return false;
+		    } else {
+			return true;
+		    }
+	    },
+	    setLocalResourceCheckboxHidden: function(get) {
+		if (get('running') || !get('migration.hasLocalResources') ||
+		    Proxmox.UserName !== 'root@pam') {
+		    return true;
+		} else {
+		    return false;
+		}
+	    },
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'panel[reference=formPanel]': {
+		validityChange: function(panel, isValid) {
+		    this.getViewModel().set('migration.possible', isValid);
+		    this.checkMigratePreconditions();
+		},
+	    },
+	},
+
+	init: function(view) {
+	    var me = this,
+		vm = view.getViewModel();
+
+	    if (!view.nodename) {
+		throw "missing custom view config: nodename";
+	    }
+	    vm.set('nodename', view.nodename);
+
+	    if (!view.vmid) {
+		throw "missing custom view config: vmid";
+	    }
+	    vm.set('vmid', view.vmid);
+
+	    if (!view.vmtype) {
+		throw "missing custom view config: vmtype";
+	    }
+	    vm.set('vmtype', view.vmtype);
+
+	    view.setTitle(
+		Ext.String.format('{0} {1} {2}', gettext('Migrate'), vm.get(view.vmtype).commonName, view.vmid),
+	    );
+	    me.lookup('proxmoxHelpButton').setHelpConfig({
+		onlineHelp: vm.get(view.vmtype).onlineHelp,
+	    });
+	    me.lookup('formPanel').isValid();
+	},
+
+	onTargetChange: function(nodeSelector) {
+	    // Always display the storages of the currently seleceted migration target
+	    this.lookup('pveDiskStorageSelector').setNodename(nodeSelector.value);
+	    this.checkMigratePreconditions();
+	},
+
+	startMigration: function() {
+	    var me = this,
+		view = me.getView(),
+		vm = me.getViewModel();
+
+	    var values = me.lookup('formPanel').getValues();
+	    var params = {
+		target: values.target,
+	    };
+
+	    if (vm.get('migration.mode')) {
+		params[vm.get('migration.mode')] = 1;
+	    }
+	    if (vm.get('migration.with-local-disks')) {
+		params['with-local-disks'] = 1;
+	    }
+	    //offline migration to a different storage currently might fail at a late stage
+	    //(i.e. after some disks have been moved), so don't expose it yet in the GUI
+	    if (vm.get('migration.with-local-disks') && vm.get('running') && values.targetstorage) {
+		params.targetstorage = values.targetstorage;
+	    }
+
+	    if (vm.get('migration.overwriteLocalResourceCheck')) {
+		params.force = 1;
+	    }
+
+	    Proxmox.Utils.API2Request({
+		params: params,
+		url: '/nodes/' + vm.get('nodename') + '/' + vm.get('vmtype') + '/' + vm.get('vmid') + '/migrate',
+		waitMsgTarget: view,
+		method: 'POST',
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: function(response, options) {
+		    var upid = response.result.data;
+		    var extraTitle = Ext.String.format(' ({0} ---> {1})', vm.get('nodename'), params.target);
+
+		    Ext.create('Proxmox.window.TaskViewer', {
+			upid: upid,
+			extraTitle: extraTitle,
+		    }).show();
+
+		    view.close();
+		},
+	    });
+	},
+
+	checkMigratePreconditions: async function(resetMigrationPossible) {
+	    var me = this,
+		vm = me.getViewModel();
+
+	    var vmrec = PVE.data.ResourceStore.findRecord('vmid', vm.get('vmid'),
+			0, false, false, true);
+	    if (vmrec && vmrec.data && vmrec.data.running) {
+		vm.set('running', true);
+	    }
+
+	    me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')];
+
+	    if (vm.get('vmtype') === 'qemu') {
+		await me.checkQemuPreconditions(resetMigrationPossible);
+	    } else {
+		me.checkLxcPreconditions(resetMigrationPossible);
+	    }
+
+	    // Only allow nodes where the local storage is available in case of offline migration
+	    // where storage migration is not possible
+	    me.lookup('pveNodeSelector').allowedNodes = vm.get('migration.allowedNodes');
+
+	    me.lookup('formPanel').isValid();
+	},
+
+	checkQemuPreconditions: async function(resetMigrationPossible) {
+	    let me = this,
+		vm = me.getViewModel(),
+		migrateStats;
+
+	    if (vm.get('running')) {
+		vm.set('migration.mode', 'online');
+	    }
+
+	    try {
+		if (me.fetchingNodeMigrateInfo && me.fetchingNodeMigrateInfo === vm.get('nodename')) {
+		    return;
+		}
+		me.fetchingNodeMigrateInfo = vm.get('nodename');
+		let { result } = await Proxmox.Async.api2({
+		    url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`,
+		    method: 'GET',
+		});
+		migrateStats = result.data;
+		me.fetchingNodeMigrateInfo = false;
+	    } catch (error) {
+		Ext.Msg.alert(gettext('Error'), error.htmlStatus);
+		return;
+	    }
+
+	    if (migrateStats.running) {
+		vm.set('running', true);
+	    }
+	    // Get migration object from viewmodel to prevent to many bind callbacks
+	    let migration = vm.get('migration');
+	    if (resetMigrationPossible) {
+		migration.possible = true;
+	    }
+	    migration.preconditions = [];
+
+	    if (migrateStats.allowed_nodes) {
+		migration.allowedNodes = migrateStats.allowed_nodes;
+		let target = me.lookup('pveNodeSelector').value;
+		if (target.length && !migrateStats.allowed_nodes.includes(target)) {
+		    let disallowed = migrateStats.not_allowed_nodes[target] ?? {};
+		    if (disallowed.unavailable_storages !== undefined) {
+			let missingStorages = disallowed.unavailable_storages.join(', ');
+
+			migration.possible = false;
+			migration.preconditions.push({
+			    text: 'Storage (' + missingStorages + ') not available on selected target. ' +
+			      'Start VM to use live storage migration or select other target node',
+			    severity: 'error',
+			});
+		    }
+
+		    if (disallowed['unavailable-resources'] !== undefined) {
+			let unavailableResources = disallowed['unavailable-resources'].join(', ');
+
+			migration.possible = false;
+			migration.preconditions.push({
+			    text: 'Mapped Resources (' + unavailableResources + ') not available on selected target. ',
+			    severity: 'error',
+			});
+		    }
+		}
+	    }
+
+	    let blockingResources = [];
+	    let mappedResources = migrateStats['mapped-resources'] ?? [];
+
+	    for (const res of migrateStats.local_resources) {
+		if (mappedResources.indexOf(res) === -1) {
+		    blockingResources.push(res);
+		}
+	    }
+
+	    if (blockingResources.length) {
+		migration.hasLocalResources = true;
+		if (!migration.overwriteLocalResourceCheck || vm.get('running')) {
+		    migration.possible = false;
+		    migration.preconditions.push({
+			text: Ext.String.format('Can\'t migrate VM with local resources: {0}',
+			blockingResources.join(', ')),
+			severity: 'error',
+		    });
+		} else {
+		    migration.preconditions.push({
+			text: Ext.String.format('Migrate VM with local resources: {0}. ' +
+			'This might fail if resources aren\'t available on the target node.',
+			blockingResources.join(', ')),
+			severity: 'warning',
+		    });
+		}
+	    }
+
+	    if (mappedResources && mappedResources.length) {
+		if (vm.get('running')) {
+		    migration.possible = false;
+		    migration.preconditions.push({
+			text: Ext.String.format('Can\'t migrate running VM with mapped resources: {0}',
+			mappedResources.join(', ')),
+			severity: 'error',
+		    });
+		}
+	    }
+
+	    if (migrateStats.local_disks.length) {
+		migrateStats.local_disks.forEach(function(disk) {
+		    if (disk.cdrom && disk.cdrom === 1) {
+			if (!disk.volid.includes('vm-' + vm.get('vmid') + '-cloudinit')) {
+			    migration.possible = false;
+			    migration.preconditions.push({
+				text: "Can't migrate VM with local CD/DVD",
+				severity: 'error',
+			    });
+			}
+		    } else {
+			let size = disk.size ? '(' + Proxmox.Utils.render_size(disk.size) + ')' : '';
+			migration['with-local-disks'] = 1;
+			migration.preconditions.push({
+			    text: Ext.String.format('Migration with local disk might take long: {0} {1}', disk.volid, size),
+			    severity: 'warning',
+			});
+		    }
+		});
+	    }
+
+	    vm.set('migration', migration);
+	},
+	checkLxcPreconditions: function(resetMigrationPossible) {
+	    let vm = this.getViewModel();
+	    if (vm.get('running')) {
+		vm.set('migration.mode', 'restart');
+	    }
+	},
+    },
+
+    width: 600,
+    modal: true,
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+    border: false,
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'formPanel',
+	    bodyPadding: 10,
+	    border: false,
+	    layout: 'hbox',
+	    items: [
+		{
+		    xtype: 'container',
+		    flex: 1,
+		    items: [{
+			xtype: 'displayfield',
+			name: 'source',
+			fieldLabel: gettext('Source node'),
+			bind: {
+			    value: '{nodename}',
+			},
+		    },
+		    {
+			xtype: 'displayfield',
+			reference: 'migrationMode',
+			fieldLabel: gettext('Mode'),
+			bind: {
+			    value: '{setMigrationMode}',
+			},
+		    }],
+		},
+		{
+		    xtype: 'container',
+		    flex: 1,
+		    items: [{
+			xtype: 'pveNodeSelector',
+			reference: 'pveNodeSelector',
+			name: 'target',
+			fieldLabel: gettext('Target node'),
+			allowBlank: false,
+			disallowedNodes: undefined,
+			onlineValidator: true,
+			listeners: {
+			    change: 'onTargetChange',
+			},
+		    },
+		    {
+			    xtype: 'pveStorageSelector',
+			    reference: 'pveDiskStorageSelector',
+			    name: 'targetstorage',
+			    fieldLabel: gettext('Target storage'),
+			    storageContent: 'images',
+			    allowBlank: true,
+			    autoSelect: false,
+			    emptyText: gettext('Current layout'),
+			    bind: {
+				hidden: '{setStorageselectorHidden}',
+			    },
+		    },
+		    {
+			xtype: 'proxmoxcheckbox',
+			name: 'overwriteLocalResourceCheck',
+			fieldLabel: gettext('Force'),
+			autoEl: {
+			    tag: 'div',
+			    'data-qtip': 'Overwrite local resources unavailable check',
+			},
+			bind: {
+			    hidden: '{setLocalResourceCheckboxHidden}',
+			    value: '{migration.overwriteLocalResourceCheck}',
+			},
+			listeners: {
+			    change: {
+				fn: 'checkMigratePreconditions',
+				extraArg: true,
+			    },
+			},
+		}],
+		},
+	    ],
+	},
+	{
+	    xtype: 'gridpanel',
+	    reference: 'preconditionGrid',
+	    selectable: false,
+	    flex: 1,
+	    columns: [{
+		text: '',
+		dataIndex: 'severity',
+		renderer: function(v) {
+		    switch (v) {
+			case 'warning':
+			    return '<i class="fa fa-exclamation-triangle warning"></i> ';
+			case 'error':
+			    return '<i class="fa fa-times critical"></i>';
+			default:
+			    return v;
+		    }
+		},
+		width: 35,
+	    },
+	    {
+		text: 'Info',
+		dataIndex: 'text',
+		cellWrap: true,
+		flex: 1,
+	    }],
+	    bind: {
+		hidden: '{!migration.preconditions.length}',
+		store: {
+		    fields: ['severity', 'text'],
+		    data: '{migration.preconditions}',
+		    sorters: 'text',
+		},
+	    },
+	},
+
+    ],
+    buttons: [
+	{
+	    xtype: 'proxmoxHelpButton',
+	    reference: 'proxmoxHelpButton',
+	    onlineHelp: 'pct_migration',
+	    listenToGlobalEvent: false,
+	    hidden: false,
+	},
+	'->',
+	{
+	    xtype: 'button',
+	    reference: 'submitButton',
+	    text: gettext('Migrate'),
+	    handler: 'startMigration',
+	    bind: {
+		disabled: '{!migration.possible}',
+	    },
+	},
+    ],
+});
+Ext.define('pve-prune-list', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'type',
+	'vmid',
+	{
+	    name: 'ctime',
+	    type: 'date',
+	    dateFormat: 'timestamp',
+	},
+    ],
+});
+
+Ext.define('PVE.PruneInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pvePruneInputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	// the API expects a single prune-backups property string
+	let pruneBackups = PVE.Parser.printPropertyString(values);
+	values = {
+	    'prune-backups': pruneBackups,
+	    'type': me.backup_type,
+	    'vmid': me.backup_id,
+	};
+
+	return values;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    if (!view.url) {
+		throw "no url specified";
+	    }
+	    if (!view.backup_type) {
+		throw "no backup_type specified";
+	    }
+	    if (!view.backup_id) {
+		throw "no backup_id specified";
+	    }
+
+	    this.reload(); // initial load
+	},
+
+	reload: function() {
+	    let view = this.getView();
+
+	    // helper to allow showing why a backup is kept
+	    let addKeepReasons = function(backups, params) {
+		const rules = [
+		    'keep-last',
+		    'keep-hourly',
+		    'keep-daily',
+		    'keep-weekly',
+		    'keep-monthly',
+		    'keep-yearly',
+		    'keep-all', // when all keep options are not set
+		];
+		let counter = {};
+
+		backups.sort((a, b) => b.ctime - a.ctime);
+
+		let ruleIndex = -1;
+		let nextRule = function() {
+		    let rule;
+		    do {
+			ruleIndex++;
+			rule = rules[ruleIndex];
+		    } while (!params[rule] && rule !== 'keep-all');
+		    counter[rule] = 0;
+		    return rule;
+		};
+
+		let rule = nextRule();
+		for (let backup of backups) {
+		    if (backup.mark === 'keep') {
+			counter[rule]++;
+			if (rule !== 'keep-all') {
+			    backup.keepReason = rule + ': ' + counter[rule];
+			    if (counter[rule] >= params[rule]) {
+				rule = nextRule();
+			    }
+			} else {
+			    backup.keepReason = rule;
+			}
+		    }
+		}
+	    };
+
+	    let params = view.getValues();
+	    let keepParams = PVE.Parser.parsePropertyString(params["prune-backups"]);
+
+	    Proxmox.Utils.API2Request({
+		url: view.url,
+		method: "GET",
+		params: params,
+		callback: function() {
+		    // for easy breakpoint setting
+		},
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: function(response, options) {
+		    var data = response.result.data;
+		    addKeepReasons(data, keepParams);
+		    view.pruneStore.setData(data);
+		},
+	    });
+	},
+
+	control: {
+	    field: { change: 'reload' },
+	},
+    },
+
+    column1: [
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-last',
+	    fieldLabel: gettext('keep-last'),
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-hourly',
+	    fieldLabel: gettext('keep-hourly'),
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-daily',
+	    fieldLabel: gettext('keep-daily'),
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-weekly',
+	    fieldLabel: gettext('keep-weekly'),
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-monthly',
+	    fieldLabel: gettext('keep-monthly'),
+	},
+	{
+	    xtype: 'pmxPruneKeepField',
+	    name: 'keep-yearly',
+	    fieldLabel: gettext('keep-yearly'),
+	},
+    ],
+
+    initComponent: function() {
+        var me = this;
+
+	me.pruneStore = Ext.create('Ext.data.Store', {
+	    model: 'pve-prune-list',
+	    sorters: { property: 'ctime', direction: 'DESC' },
+	});
+
+	me.column2 = [
+	    {
+		xtype: 'grid',
+		height: 200,
+		store: me.pruneStore,
+		columns: [
+		    {
+			header: gettext('Backup Time'),
+			sortable: true,
+			dataIndex: 'ctime',
+			renderer: function(value, metaData, record) {
+			    let text = Ext.Date.format(value, 'Y-m-d H:i:s');
+			    if (record.data.mark === 'remove') {
+				return '<div style="text-decoration: line-through;">'+ text +'</div>';
+			    } else {
+				return text;
+			    }
+			},
+			flex: 1,
+		    },
+		    {
+			text: 'Keep (reason)',
+			dataIndex: 'mark',
+			renderer: function(value, metaData, record) {
+			    if (record.data.mark === 'keep') {
+				return 'true (' + record.data.keepReason + ')';
+			    } else if (record.data.mark === 'protected') {
+				return 'true (protected)';
+			    } else if (record.data.mark === 'renamed') {
+				return 'true (renamed)';
+			    } else {
+				return 'false';
+			    }
+			},
+			flex: 1,
+		    },
+		],
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.window.Prune', {
+    extend: 'Proxmox.window.Edit',
+
+    method: 'DELETE',
+    submitText: gettext("Prune"),
+
+    fieldDefaults: { labelWidth: 130 },
+
+    isCreate: true,
+
+    initComponent: function() {
+        var me = this;
+
+	if (!me.nodename) {
+	    throw "no nodename specified";
+	}
+	if (!me.storage) {
+	    throw "no storage specified";
+	}
+	if (!me.backup_type) {
+	    throw "no backup_type specified";
+	}
+	if (me.backup_type !== 'qemu' && me.backup_type !== 'lxc') {
+	    throw "unknown backup type: " + me.backup_type;
+	}
+	if (!me.backup_id) {
+	    throw "no backup_id specified";
+	}
+
+	let title = Ext.String.format(
+	    gettext("Prune Backups for '{0}' on Storage '{1}'"),
+	    me.backup_type + '/' + me.backup_id,
+	    me.storage,
+	);
+
+	Ext.apply(me, {
+	    url: '/api2/extjs/nodes/' + me.nodename + '/storage/' + me.storage + "/prunebackups",
+	    title: title,
+	    items: [
+		{
+		    xtype: 'pvePruneInputPanel',
+		    url: '/api2/extjs/nodes/' + me.nodename + '/storage/' + me.storage + "/prunebackups",
+		    backup_type: me.backup_type,
+		    backup_id: me.backup_id,
+		    storage: me.storage,
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.window.Restore', {
+    extend: 'Ext.window.Window', // fixme: Proxmox.window.Edit?
+
+    resizable: false,
+    width: 500,
+    modal: true,
+    layout: 'auto',
+    border: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    '#liveRestore': {
+		change: function(el, newVal) {
+		    let liveWarning = this.lookupReference('liveWarning');
+		    liveWarning.setHidden(!newVal);
+		    let start = this.lookupReference('start');
+		    start.setDisabled(newVal);
+		},
+	    },
+	    'form': {
+		validitychange: function(f, valid) {
+		    this.lookupReference('doRestoreBtn').setDisabled(!valid);
+		},
+	    },
+	},
+
+	doRestore: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let values = view.down('form').getForm().getValues();
+
+	    let params = {
+		vmid: view.vmid || values.vmid,
+		force: view.vmid ? 1 : 0,
+	    };
+	    if (values.unique) {
+		params.unique = 1;
+	    }
+	    if (values.start && !values['live-restore']) {
+		params.start = 1;
+	    }
+	    if (values['live-restore']) {
+		params['live-restore'] = 1;
+	    }
+	    if (values.storage) {
+		params.storage = values.storage;
+	    }
+
+	    ['bwlimit', 'cores', 'name', 'memory', 'sockets'].forEach(opt => {
+		if ((values[opt] ?? '') !== '') {
+		    params[opt] = values[opt];
+		}
+	    });
+
+	    if (params.name && view.vmtype === 'lxc') {
+		params.hostname = params.name;
+		delete params.name;
+	    }
+
+	    let confirmMsg;
+	    if (view.vmtype === 'lxc') {
+		params.ostemplate = view.volid;
+		params.restore = 1;
+		if (values.unprivileged !== 'keep') {
+		    params.unprivileged = values.unprivileged;
+		}
+		confirmMsg = Proxmox.Utils.format_task_description('vzrestore', params.vmid);
+	    } else if (view.vmtype === 'qemu') {
+		params.archive = view.volid;
+		confirmMsg = Proxmox.Utils.format_task_description('qmrestore', params.vmid);
+	    } else {
+		throw 'unknown VM type';
+	    }
+
+	    let executeRestore = () => {
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${view.nodename}/${view.vmtype}`,
+		    params: params,
+		    method: 'POST',
+		    waitMsgTarget: view,
+		    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		    success: function(response, options) {
+			Ext.create('Proxmox.window.TaskViewer', {
+			    autoShow: true,
+			    upid: response.result.data,
+			});
+			view.close();
+		    },
+		});
+	    };
+
+	    if (view.vmid) {
+		confirmMsg += `. ${Ext.String.format(
+		    gettext('This will permanently erase current {0} data.'),
+		    view.vmtype === 'lxc' ? 'CT' : 'VM',
+		)}`;
+		if (view.vmtype === 'lxc') {
+		    confirmMsg += `<br>${gettext('Mount point volumes are also erased.')}`;
+		}
+		Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function(btn) {
+		    if (btn === 'yes') {
+			executeRestore();
+		    }
+		});
+	    } else {
+		executeRestore();
+	    }
+	},
+
+	afterRender: function() {
+	    let view = this.getView();
+
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${view.nodename}/vzdump/extractconfig`,
+		method: 'GET',
+		waitMsgTarget: view,
+		params: {
+		    volume: view.volid,
+		},
+		failure: response => Ext.Msg.alert('Error', response.htmlStatus),
+		success: function(response, options) {
+		    let allStoragesAvailable = true;
+
+		    response.result.data.split('\n').forEach(line => {
+			let [_, key, value] = line.match(/^([^:]+):\s*(\S+)\s*$/) ?? [];
+
+			if (!key) {
+			    return;
+			}
+
+			if (key === '#qmdump#map') {
+			    let match = value.match(/^(\S+):(\S+):(\S*):(\S*):$/) ?? [];
+			    // if a /dev/XYZ disk was backed up, ther is no storage hint
+			    allStoragesAvailable &&= !!match[3] && !!PVE.data.ResourceStore.getById(
+				`storage/${view.nodename}/${match[3]}`);
+			} else if (key === 'name' || key === 'hostname') {
+			    view.lookupReference('nameField').setEmptyText(value);
+			} else if (key === 'memory' || key === 'cores' || key === 'sockets') {
+			    view.lookupReference(`${key}Field`).setEmptyText(value);
+			}
+		    });
+
+		    if (!allStoragesAvailable) {
+			let storagesel = view.down('pveStorageSelector[name=storage]');
+			storagesel.allowBlank = false;
+			storagesel.setEmptyText('');
+		    }
+		},
+	    });
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (!me.volid) {
+	    throw "no volume ID specified";
+	}
+	if (!me.vmtype) {
+	    throw "no vmtype specified";
+	}
+
+	let storagesel = Ext.create('PVE.form.StorageSelector', {
+	    nodename: me.nodename,
+	    name: 'storage',
+	    value: '',
+	    fieldLabel: gettext('Storage'),
+	    storageContent: me.vmtype === 'lxc' ? 'rootdir' : 'images',
+	    // when restoring a container without specifying a storage, the backend defaults
+	    // to 'local', which is unintuitive and 'rootdir' might not even be allowed on it
+	    allowBlank: me.vmtype !== 'lxc',
+	    emptyText: me.vmtype === 'lxc' ? '' : gettext('From backup configuration'),
+	    autoSelect: me.vmtype === 'lxc',
+	});
+
+	let items = [
+	    {
+		xtype: 'displayfield',
+		value: me.volidText || me.volid,
+		fieldLabel: gettext('Source'),
+	    },
+	    storagesel,
+	    {
+		xtype: 'pmxDisplayEditField',
+		name: 'vmid',
+		fieldLabel: me.vmtype === 'lxc' ? 'CT' : 'VM',
+		value: me.vmid,
+		editable: !me.vmid,
+		editConfig: {
+		    xtype: 'pveGuestIDSelector',
+		    guestType: me.vmtype,
+		    loadNextFreeID: true,
+		    validateExists: false,
+		},
+	    },
+	    {
+		xtype: 'pveBandwidthField',
+		name: 'bwlimit',
+		backendUnit: 'KiB',
+		allowZero: true,
+		fieldLabel: gettext('Bandwidth Limit'),
+		emptyText: gettext('Defaults to target storage restore limit'),
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext("Use '0' to disable all bandwidth limits."),
+		},
+	    },
+	    {
+		xtype: 'fieldcontainer',
+		layout: 'hbox',
+		items: [{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'unique',
+		    fieldLabel: gettext('Unique'),
+		    flex: 1,
+		    autoEl: {
+			tag: 'div',
+			'data-qtip': gettext('Autogenerate unique properties, e.g., MAC addresses'),
+		    },
+		    checked: false,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'start',
+		    reference: 'start',
+		    flex: 1,
+		    fieldLabel: gettext('Start after restore'),
+		    labelWidth: 105,
+		    checked: false,
+		}],
+	    },
+	];
+
+	if (me.vmtype === 'lxc') {
+	    items.push(
+		{
+		    xtype: 'radiogroup',
+		    fieldLabel: gettext('Privilege Level'),
+		    reference: 'noVNCScalingGroup',
+		    height: '15px', // renders faster with value assigned
+		    layout: {
+			type: 'hbox',
+			algin: 'stretch',
+		    },
+		    autoEl: {
+			tag: 'div',
+			'data-qtip':
+			    gettext('Choose if you want to keep or override the privilege level of the restored Container.'),
+		    },
+		    items: [
+			{
+			    xtype: 'radiofield',
+			    name: 'unprivileged',
+			    inputValue: 'keep',
+			    boxLabel: gettext('From Backup'),
+			    flex: 1,
+			    checked: true,
+			},
+			{
+			    xtype: 'radiofield',
+			    name: 'unprivileged',
+			    inputValue: '1',
+			    boxLabel: gettext('Unprivileged'),
+			    flex: 1,
+			},
+			{
+			    xtype: 'radiofield',
+			    name: 'unprivileged',
+			    inputValue: '0',
+			    boxLabel: gettext('Privileged'),
+			    flex: 1,
+			    //margin: '0 0 0 10',
+			},
+		    ],
+		},
+	    );
+	} else if (me.vmtype === 'qemu') {
+	    items.push({
+		xtype: 'proxmoxcheckbox',
+		name: 'live-restore',
+		itemId: 'liveRestore',
+		flex: 1,
+		fieldLabel: gettext('Live restore'),
+		checked: false,
+		hidden: !me.isPBS,
+	    },
+	    {
+		xtype: 'displayfield',
+		reference: 'liveWarning',
+		// TODO: Remove once more tested/stable?
+		value: gettext('Note: If anything goes wrong during the live-restore, new data written by the VM may be lost.'),
+		userCls: 'pmx-hint',
+		hidden: true,
+	    });
+	}
+
+	items.push({
+	    xtype: 'fieldset',
+	    title: `${gettext('Override Settings')}:`,
+	    layout: 'hbox',
+	    defaults: {
+		border: false,
+		layout: 'anchor',
+		flex: 1,
+	    },
+	    items: [
+		{
+		    padding: '0 10 0 0',
+		    items: [{
+			xtype: 'textfield',
+			fieldLabel: me.vmtype === 'lxc' ? gettext('Hostname') : gettext('Name'),
+			name: 'name',
+			vtype: 'DnsName',
+			reference: 'nameField',
+			allowBlank: true,
+		    }, {
+			xtype: 'proxmoxintegerfield',
+			fieldLabel: gettext('Cores'),
+			name: 'cores',
+			reference: 'coresField',
+			minValue: 1,
+			maxValue: 128,
+			allowBlank: true,
+		    }],
+		},
+		{
+		    padding: '0 0 0 10',
+		    items: [
+		    {
+			xtype: 'pveMemoryField',
+			fieldLabel: gettext('Memory'),
+			name: 'memory',
+			reference: 'memoryField',
+			value: '',
+			allowBlank: true,
+		    },
+		    {
+			xtype: 'proxmoxintegerfield',
+			fieldLabel: gettext('Sockets'),
+			name: 'sockets',
+			reference: 'socketsField',
+			minValue: 1,
+			maxValue: 4,
+			allowBlank: true,
+			hidden: me.vmtype !== 'qemu',
+			disabled: me.vmtype !== 'qemu',
+		    }],
+		},
+	    ],
+	});
+
+	let title = gettext('Restore') + ": " + (me.vmtype === 'lxc' ? 'CT' : 'VM');
+	if (me.vmid) {
+	    title = `${gettext('Overwrite')} ${title} ${me.vmid}`;
+	}
+
+	Ext.apply(me, {
+	    title: title,
+	    items: [
+		{
+		    xtype: 'form',
+		    bodyPadding: 10,
+		    border: false,
+		    fieldDefaults: {
+			labelWidth: 100,
+			anchor: '100%',
+		    },
+		    items: items,
+		},
+	    ],
+	    buttons: [
+		{
+		    text: gettext('Restore'),
+		    reference: 'doRestoreBtn',
+		    handler: 'doRestore',
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+/*
+ * SafeDestroy window with additional checkboxes for removing guests
+ */
+Ext.define('PVE.window.SafeDestroyGuest', {
+    extend: 'Proxmox.window.SafeDestroy',
+    alias: 'widget.pveSafeDestroyGuest',
+
+    additionalItems: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'purge',
+	    reference: 'purgeCheckbox',
+	    boxLabel: gettext('Purge from job configurations'),
+	    checked: false,
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Remove from replication, HA and backup jobs'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'destroyUnreferenced',
+	    reference: 'destroyUnreferencedCheckbox',
+	    boxLabel: gettext('Destroy unreferenced disks owned by guest'),
+	    checked: false,
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Scan all enabled storages for unreferenced disks and delete them.'),
+	    },
+	},
+    ],
+
+    note: gettext('Referenced disks will always be destroyed.'),
+
+    getParams: function() {
+	let me = this;
+
+	const purgeCheckbox = me.lookupReference('purgeCheckbox');
+	me.params.purge = purgeCheckbox.checked ? 1 : 0;
+
+	const destroyUnreferencedCheckbox = me.lookupReference('destroyUnreferencedCheckbox');
+	me.params["destroy-unreferenced-disks"] = destroyUnreferencedCheckbox.checked ? 1 : 0;
+
+	return me.callParent();
+    },
+});
+/*
+ * SafeDestroy window with additional checkboxes for removing a storage on the disk level.
+ */
+Ext.define('PVE.window.SafeDestroyStorage', {
+    extend: 'Proxmox.window.SafeDestroy',
+    alias: 'widget.pveSafeDestroyStorage',
+
+    showProgress: true,
+
+    additionalItems: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'wipeDisks',
+	    reference: 'wipeDisksCheckbox',
+	    boxLabel: gettext('Cleanup Disks'),
+	    checked: true,
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Wipe labels and other left-overs'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'cleanupConfig',
+	    reference: 'cleanupConfigCheckbox',
+	    boxLabel: gettext('Cleanup Storage Configuration'),
+	    checked: true,
+	},
+    ],
+
+    getParams: function() {
+	let me = this;
+
+	me.params['cleanup-disks'] = me.lookupReference('wipeDisksCheckbox').checked ? 1 : 0;
+	me.params['cleanup-config'] = me.lookupReference('cleanupConfigCheckbox').checked ? 1 : 0;
+
+	return me.callParent();
+    },
+});
+Ext.define('PVE.window.Settings', {
+    extend: 'Ext.window.Window',
+
+    width: '800px',
+    title: gettext('My Settings'),
+    iconCls: 'fa fa-gear',
+    modal: true,
+    bodyPadding: 10,
+    resizable: false,
+
+    buttons: [
+	{
+	    xtype: 'proxmoxHelpButton',
+	    onlineHelp: 'gui_my_settings',
+	    hidden: false,
+	},
+	'->',
+	{
+	    text: gettext('Close'),
+	    handler: function() {
+		this.up('window').close();
+	    },
+	},
+    ],
+
+    layout: 'hbox',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    var me = this;
+	    var sp = Ext.state.Manager.getProvider();
+
+	    var username = sp.get('login-username') || Proxmox.Utils.noneText;
+	    me.lookupReference('savedUserName').setValue(Ext.String.htmlEncode(username));
+	    var vncMode = sp.get('novnc-scaling') || 'auto';
+	    me.lookupReference('noVNCScalingGroup').setValue({ noVNCScalingField: vncMode });
+
+	    let summarycolumns = sp.get('summarycolumns', 'auto');
+	    me.lookup('summarycolumns').setValue(summarycolumns);
+
+	    me.lookup('guestNotesCollapse').setValue(sp.get('guest-notes-collapse', 'never'));
+	    me.lookup('editNotesOnDoubleClick').setValue(sp.get('edit-notes-on-double-click', false));
+
+	    var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight'];
+	    settings.forEach(function(setting) {
+		var val = localStorage.getItem('pve-xterm-' + setting);
+		if (val !== undefined && val !== null) {
+		    var field = me.lookup(setting);
+		    field.setValue(val);
+		    field.resetOriginalValue();
+		}
+	    });
+	},
+
+	set_button_status: function() {
+	    let me = this;
+	    let form = me.lookup('xtermform');
+
+	    let valid = form.isValid(), dirty = form.isDirty();
+	    let hasValues = Object.values(form.getValues()).some(v => !!v);
+
+	    me.lookup('xtermsave').setDisabled(!dirty || !valid);
+	    me.lookup('xtermreset').setDisabled(!hasValues);
+	},
+
+	control: {
+	    '#xtermjs form': {
+		dirtychange: 'set_button_status',
+		validitychange: 'set_button_status',
+	    },
+	    '#xtermjs button': {
+		click: function(button) {
+		    var me = this;
+		    var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight'];
+		    settings.forEach(function(setting) {
+			var field = me.lookup(setting);
+			if (button.reference === 'xtermsave') {
+			    var value = field.getValue();
+			    if (value) {
+				localStorage.setItem('pve-xterm-' + setting, value);
+			    } else {
+				localStorage.removeItem('pve-xterm-' + setting);
+			    }
+			} else if (button.reference === 'xtermreset') {
+			    field.setValue(undefined);
+			    localStorage.removeItem('pve-xterm-' + setting);
+			}
+			field.resetOriginalValue();
+		    });
+		    me.set_button_status();
+		},
+	    },
+	    'button[name=reset]': {
+		click: function() {
+		    let blacklist = ['GuiCap', 'login-username', 'dash-storages'];
+		    let sp = Ext.state.Manager.getProvider();
+		    for (const state of Object.keys(sp.state)) {
+			if (!blacklist.includes(state)) {
+			    sp.clear(state);
+			}
+		    }
+		    window.location.reload();
+		},
+	    },
+	    'button[name=clear-username]': {
+		click: function() {
+		    let me = this;
+		    me.lookupReference('savedUserName').setValue(Proxmox.Utils.noneText);
+		    Ext.state.Manager.getProvider().clear('login-username');
+		},
+	    },
+	    'grid[reference=dashboard-storages]': {
+		selectionchange: function(grid, selected) {
+		    var me = this;
+		    var sp = Ext.state.Manager.getProvider();
+
+		    // saves the selected storageids as "id1,id2,id3,..." or clears the variable
+		    if (selected.length > 0) {
+			sp.set('dash-storages', Ext.Array.pluck(selected, 'id').join(','));
+		    } else {
+			sp.clear('dash-storages');
+		    }
+		},
+		afterrender: function(grid) {
+		    let store = grid.getStore();
+		    let storages = Ext.state.Manager.getProvider().get('dash-storages') || '';
+
+		    let items = [];
+		    storages.split(',').forEach(storage => {
+			if (storage !== '') { // we have to get the records to be able to select them
+			    let item = store.getById(storage);
+			    if (item) {
+				items.push(item);
+			    }
+			}
+		    });
+		    grid.suspendEvent('selectionchange');
+		    grid.getSelectionModel().select(items);
+		    grid.resumeEvent('selectionchange');
+		},
+	    },
+	    'field[reference=summarycolumns]': {
+		change: (el, newValue) => Ext.state.Manager.getProvider().set('summarycolumns', newValue),
+	    },
+	    'field[reference=guestNotesCollapse]': {
+		change: (e, v) => Ext.state.Manager.getProvider().set('guest-notes-collapse', v),
+	    },
+	    'field[reference=editNotesOnDoubleClick]': {
+		change: (e, v) => Ext.state.Manager.getProvider().set('edit-notes-on-double-click', v),
+	    },
+	},
+    },
+
+    items: [{
+	xtype: 'fieldset',
+	flex: 1,
+	title: gettext('Webinterface Settings'),
+	margin: '5',
+	layout: {
+	    type: 'vbox',
+	    align: 'left',
+	},
+	defaults: {
+	    width: '100%',
+	    margin: '0 0 10 0',
+	},
+	items: [
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Dashboard Storages'),
+		labelAlign: 'left',
+		labelWidth: '50%',
+	    },
+	    {
+		xtype: 'grid',
+		maxHeight: 150,
+		reference: 'dashboard-storages',
+		selModel: {
+		    selType: 'checkboxmodel',
+		},
+		columns: [{
+		    header: gettext('Name'),
+		    dataIndex: 'storage',
+		    flex: 1,
+		}, {
+		    header: gettext('Node'),
+		    dataIndex: 'node',
+		    flex: 1,
+		}],
+		store: {
+		    type: 'diff',
+		    field: ['type', 'storage', 'id', 'node'],
+		    rstore: PVE.data.ResourceStore,
+		    filters: [{
+			property: 'type',
+			value: 'storage',
+		    }],
+		    sorters: ['node', 'storage'],
+		},
+	    },
+	    {
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    },
+	    {
+		xtype: 'container',
+		layout: 'hbox',
+		items: [
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('Saved User Name') + ':',
+			labelWidth: 150,
+			stateId: 'login-username',
+			reference: 'savedUserName',
+			flex: 1,
+			value: '',
+		    },
+		    {
+			xtype: 'button',
+			cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+			text: gettext('Reset'),
+			name: 'clear-username',
+		    },
+		],
+	    },
+	    {
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    },
+	    {
+		xtype: 'container',
+		layout: 'hbox',
+		items: [
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('Layout') + ':',
+			flex: 1,
+		    },
+		    {
+			xtype: 'button',
+			cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+			text: gettext('Reset'),
+			tooltip: gettext('Reset all layout changes (for example, column widths)'),
+			name: 'reset',
+		    },
+		],
+	    },
+	    {
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		fieldLabel: gettext('Summary columns') + ':',
+		labelWidth: 125,
+		stateId: 'summarycolumns',
+		reference: 'summarycolumns',
+		comboItems: [
+		    ['auto', 'auto'],
+		    ['1', '1'],
+		    ['2', '2'],
+		    ['3', '3'],
+		],
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		fieldLabel: gettext('Guest Notes') + ':',
+		labelWidth: 125,
+		stateId: 'guest-notes-collapse',
+		reference: 'guestNotesCollapse',
+		comboItems: [
+		    ['never', 'Show by default'],
+		    ['always', 'Collapse by default'],
+		    ['auto', 'auto (Collapse if empty)'],
+		],
+	    },
+	    {
+		xtype: 'checkbox',
+		fieldLabel: gettext('Notes'),
+		labelWidth: 125,
+		boxLabel: gettext('Open editor on double-click'),
+		reference: 'editNotesOnDoubleClick',
+		inputValue: true,
+		uncheckedValue: false,
+	    },
+	],
+    },
+    {
+	xtype: 'container',
+	layout: 'vbox',
+	flex: 1,
+	margin: '5',
+	defaults: {
+	    width: '100%',
+	    // right margin ensures that the right border of the fieldsets
+	    // is shown
+	    margin: '0 2 10 0',
+	},
+	items: [
+	    {
+		xtype: 'fieldset',
+		itemId: 'xtermjs',
+		title: gettext('xterm.js Settings'),
+		items: [{
+		    xtype: 'form',
+		    reference: 'xtermform',
+		    border: false,
+		    layout: {
+			type: 'vbox',
+			algin: 'left',
+		    },
+		    defaults: {
+			width: '100%',
+			margin: '0 0 10 0',
+		    },
+		    items: [
+			{
+			    xtype: 'textfield',
+			    name: 'fontFamily',
+			    reference: 'fontFamily',
+			    emptyText: Proxmox.Utils.defaultText,
+			    fieldLabel: gettext('Font-Family'),
+			},
+			{
+			    xtype: 'proxmoxintegerfield',
+			    emptyText: Proxmox.Utils.defaultText,
+			    name: 'fontSize',
+			    reference: 'fontSize',
+			    minValue: 1,
+			    fieldLabel: gettext('Font-Size'),
+			},
+			{
+			    xtype: 'numberfield',
+			    name: 'letterSpacing',
+			    reference: 'letterSpacing',
+			    emptyText: Proxmox.Utils.defaultText,
+			    fieldLabel: gettext('Letter Spacing'),
+			},
+			{
+			    xtype: 'numberfield',
+			    name: 'lineHeight',
+			    minValue: 0.1,
+			    reference: 'lineHeight',
+			    emptyText: Proxmox.Utils.defaultText,
+			    fieldLabel: gettext('Line Height'),
+			},
+			{
+			    xtype: 'container',
+			    layout: {
+				type: 'hbox',
+				pack: 'end',
+			    },
+			    defaults: {
+				margin: '0 0 0 5',
+			    },
+			    items: [
+				{
+				    xtype: 'button',
+				    reference: 'xtermreset',
+				    disabled: true,
+				    text: gettext('Reset'),
+				},
+				{
+				    xtype: 'button',
+				    reference: 'xtermsave',
+				    disabled: true,
+				    text: gettext('Save'),
+				},
+			    ],
+			},
+		    ],
+		}],
+	    }, {
+		xtype: 'fieldset',
+		title: gettext('noVNC Settings'),
+		items: [
+		    {
+			xtype: 'radiogroup',
+			fieldLabel: gettext('Scaling mode'),
+			reference: 'noVNCScalingGroup',
+			height: '15px', // renders faster with value assigned
+			layout: {
+			    type: 'hbox',
+			},
+			items: [
+			    {
+				xtype: 'radiofield',
+				name: 'noVNCScalingField',
+				inputValue: 'auto',
+				boxLabel: 'Auto',
+			    },
+			    {
+				xtype: 'radiofield',
+				name: 'noVNCScalingField',
+				inputValue: 'scale',
+				boxLabel: 'Local Scaling',
+				margin: '0 0 0 10',
+			    }, {
+				xtype: 'radiofield',
+				name: 'noVNCScalingField',
+				inputValue: 'off',
+				boxLabel: 'Off',
+				margin: '0 0 0 10',
+			    },
+			],
+			listeners: {
+			    change: function(el, { noVNCScalingField }) {
+				let provider = Ext.state.Manager.getProvider();
+				if (noVNCScalingField === 'auto') {
+				    provider.clear('novnc-scaling');
+				} else {
+				    provider.set('novnc-scaling', noVNCScalingField);
+				}
+			    },
+			},
+		    },
+		],
+	    },
+	],
+    }],
+});
+Ext.define('PVE.window.Snapshot', {
+    extend: 'Proxmox.window.Edit',
+
+    viewModel: {
+	data: {
+	    type: undefined,
+	    isCreate: undefined,
+	    running: false,
+	    guestAgentEnabled: false,
+	},
+	formulas: {
+	    runningWithoutGuestAgent: (get) => get('type') === 'qemu' && get('running') && !get('guestAgentEnabled'),
+	    shouldWarnAboutFS: (get) => get('isCreate') && get('runningWithoutGuestAgent') && get('!vmstate.checked'),
+	},
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (me.type === 'lxc') {
+	    delete values.vmstate;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+	var vm = me.getViewModel();
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	if (!me.type) {
+	    throw "no type specified";
+	}
+
+	vm.set('type', me.type);
+	vm.set('running', me.running);
+	vm.set('isCreate', me.isCreate);
+
+	if (me.type === 'qemu' && me.isCreate) {
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${me.nodename}/${me.type}/${me.vmid}/config`,
+		params: { 'current': '1' },
+		method: 'GET',
+		success: function(response, options) {
+		    let res = response.result.data;
+		    let enabled = PVE.Parser.parsePropertyString(res.agent, 'enabled');
+		    vm.set('guestAgentEnabled', !!PVE.Parser.parseBoolean(enabled.enabled));
+		},
+	    });
+	}
+
+	me.items = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'snapname',
+		value: me.snapname,
+		fieldLabel: gettext('Name'),
+		vtype: 'ConfigId',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'displayfield',
+		hidden: me.isCreate,
+		disabled: me.isCreate,
+		name: 'snaptime',
+		renderer: PVE.Utils.render_timestamp_human_readable,
+		fieldLabel: gettext('Timestamp'),
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		hidden: me.type !== 'qemu' || !me.isCreate || !me.running,
+		disabled: me.type !== 'qemu' || !me.isCreate || !me.running,
+		name: 'vmstate',
+		reference: 'vmstate',
+		uncheckedValue: 0,
+		defaultValue: 0,
+		checked: 1,
+		fieldLabel: gettext('Include RAM'),
+	    },
+	    {
+		xtype: 'textareafield',
+		grow: true,
+		editable: !me.viewonly,
+		name: 'description',
+		fieldLabel: gettext('Description'),
+	    },
+	    {
+		xtype: 'displayfield',
+		userCls: 'pmx-hint',
+		name: 'fswarning',
+		hidden: true,
+		value: gettext('It is recommended to either include the RAM or use the QEMU Guest Agent when taking a snapshot of a running VM to avoid inconsistencies.'),
+		bind: {
+		    hidden: '{!shouldWarnAboutFS}',
+		},
+	    },
+	    {
+		title: gettext('Settings'),
+		hidden: me.isCreate,
+		xtype: 'grid',
+		itemId: 'summary',
+		border: true,
+		height: 200,
+		store: {
+		    model: 'KeyValue',
+		    sorters: [
+			{
+			    property: 'key',
+			    direction: 'ASC',
+			},
+		    ],
+		},
+		columns: [
+		    {
+			header: gettext('Key'),
+			width: 150,
+			dataIndex: 'key',
+		    },
+		    {
+			header: gettext('Value'),
+			flex: 1,
+			dataIndex: 'value',
+		    },
+		],
+	    },
+	];
+
+	me.url = `/nodes/${me.nodename}/${me.type}/${me.vmid}/snapshot`;
+
+	let subject;
+	if (me.isCreate) {
+	    subject = (me.type === 'qemu' ? 'VM' : 'CT') + me.vmid + ' ' + gettext('Snapshot');
+	    me.method = 'POST';
+	    me.showTaskViewer = true;
+	} else {
+	    subject = `${gettext('Snapshot')} ${me.snapname}`;
+	    me.url += `/${me.snapname}/config`;
+	}
+
+	Ext.apply(me, {
+	    subject: subject,
+	    width: me.isCreate ? 450 : 620,
+	    height: me.isCreate ? undefined : 420,
+	});
+
+	me.callParent();
+
+	if (!me.snapname) {
+	    return;
+	}
+
+	me.load({
+	    success: function(response) {
+		let kvarray = [];
+		Ext.Object.each(response.result.data, function(key, value) {
+		    if (key === 'description' || key === 'snaptime') {
+			return;
+		    }
+		    kvarray.push({ key: key, value: value });
+		});
+
+		let summarystore = me.down('#summary').getStore();
+		summarystore.suspendEvents();
+		summarystore.add(kvarray);
+		summarystore.sort();
+		summarystore.resumeEvents();
+		summarystore.fireEvent('refresh', summarystore);
+
+		me.setValues(response.result.data);
+	    },
+	});
+    },
+});
+Ext.define('PVE.panel.StartupInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    onlineHelp: 'qm_startup_and_shutdown',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var res = PVE.Parser.printStartup(values);
+
+	if (res === undefined || res === '') {
+	    return { 'delete': 'startup' };
+	}
+
+	return { startup: res };
+    },
+
+    setStartup: function(value) {
+	var me = this;
+
+	var startup = PVE.Parser.parseStartup(value);
+	if (startup) {
+	    me.setValues(startup);
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'textfield',
+		name: 'order',
+		defaultValue: '',
+		emptyText: 'any',
+		fieldLabel: gettext('Start/Shutdown order'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'up',
+		defaultValue: '',
+		emptyText: 'default',
+		fieldLabel: gettext('Startup delay'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'down',
+		defaultValue: '',
+		emptyText: 'default',
+		fieldLabel: gettext('Shutdown timeout'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.window.StartupEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveWindowStartupEdit',
+    onlineHelp: undefined,
+
+    initComponent: function() {
+	let me = this;
+
+	let ipanelConfig = me.onlineHelp ? { onlineHelp: me.onlineHelp } : {};
+	let ipanel = Ext.create('PVE.panel.StartupInputPanel', ipanelConfig);
+
+	Ext.applyIf(me, {
+	    subject: gettext('Start/Shutdown order'),
+	    fieldDefaults: {
+		labelWidth: 120,
+	    },
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		me.vmconfig = response.result.data;
+		ipanel.setStartup(me.vmconfig.startup);
+	    },
+	});
+    },
+});
+Ext.define('PVE.window.DownloadUrlToStorage', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveStorageDownloadUrl',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    isCreate: true,
+
+    method: 'POST',
+
+    showTaskViewer: true,
+
+    title: gettext('Download from URL'),
+    submitText: gettext('Download'),
+
+    cbindData: function(initialConfig) {
+	var me = this;
+	return {
+	    nodename: me.nodename,
+	    storage: me.storage,
+	    content: me.content,
+	};
+    },
+
+    cbind: {
+	url: '/nodes/{nodename}/storage/{storage}/download-url',
+    },
+
+
+    viewModel: {
+	data: {
+	    size: '-',
+	    mimetype: '-',
+	    enableQuery: true,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	urlChange: function(field) {
+	    this.resetMetaInfo();
+	    this.setQueryEnabled();
+	},
+	setQueryEnabled: function() {
+	    this.getViewModel().set('enableQuery', true);
+	},
+	resetMetaInfo: function() {
+	    let vm = this.getViewModel();
+	    vm.set('size', '-');
+	    vm.set('mimetype', '-');
+	},
+
+	urlCheck: function(field) {
+	    let me = this;
+	    let view = me.getView();
+
+	    const queryParam = view.getValues();
+
+	    me.getViewModel().set('enableQuery', false);
+	    me.resetMetaInfo();
+	    let urlField = view.down('[name=url]');
+
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${view.nodename}/query-url-metadata`,
+		method: 'GET',
+		params: {
+		    url: queryParam.url,
+		    'verify-certificates': queryParam['verify-certificates'],
+		},
+		waitMsgTarget: view,
+		failure: res => {
+		    urlField.setValidation(res.result.message);
+		    urlField.validate();
+		    Ext.MessageBox.alert(gettext('Error'), res.htmlStatus);
+		    // re-enable so one can directly requery, e.g., if it was just a network hiccup
+		    me.setQueryEnabled();
+		},
+		success: function(res, opt) {
+		    urlField.setValidation();
+		    urlField.validate();
+
+		    let data = res.result.data;
+
+		    let filename = data.filename || "";
+		    let compression = '__default__';
+		    if (view.content === 'iso') {
+			const matches = filename.match(/^(.+)\.(gz|lzo|zst)$/i);
+			if (matches) {
+			    filename = matches[1];
+			    compression = matches[2].toLowerCase();
+			}
+		    }
+
+		    view.setValues({
+			filename,
+			compression,
+			size: (data.size && Proxmox.Utils.format_size(data.size)) || gettext("Unknown"),
+			mimetype: data.mimetype || gettext("Unknown"),
+		    });
+		},
+	    });
+	},
+
+	hashChange: function(field) {
+	    let checksum = Ext.getCmp('downloadUrlChecksum');
+	    if (field.getValue() === '__default__') {
+		checksum.setDisabled(true);
+		checksum.setValue("");
+		checksum.allowBlank = true;
+	    } else {
+		checksum.setDisabled(false);
+		checksum.allowBlank = false;
+	    }
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    border: false,
+	    onGetValues: function(values) {
+		if (typeof values.checksum === 'string') {
+		    values.checksum = values.checksum.trim();
+		}
+		return values;
+	    },
+	    columnT: [
+		{
+		    xtype: 'fieldcontainer',
+		    layout: 'hbox',
+		    fieldLabel: gettext('URL'),
+		    items: [
+			{
+			    xtype: 'textfield',
+			    name: 'url',
+			    emptyText: gettext("Enter URL to download"),
+			    allowBlank: false,
+			    flex: 1,
+			    listeners: {
+				change: 'urlChange',
+			    },
+			},
+			{
+			    xtype: 'button',
+			    name: 'check',
+			    text: gettext('Query URL'),
+			    margin: '0 0 0 5',
+			    bind: {
+				disabled: '{!enableQuery}',
+			    },
+			    listeners: {
+				click: 'urlCheck',
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'filename',
+		    allowBlank: false,
+		    fieldLabel: gettext('File name'),
+		    emptyText: gettext("Please (re-)query URL to get meta information"),
+		},
+	    ],
+	    column1: [
+		{
+		    xtype: 'displayfield',
+		    name: 'size',
+		    fieldLabel: gettext('File size'),
+		    bind: {
+			value: '{size}',
+		    },
+		},
+	    ],
+	    column2: [
+		{
+		    xtype: 'displayfield',
+		    name: 'mimetype',
+		    fieldLabel: gettext('MIME type'),
+		    bind: {
+			value: '{mimetype}',
+		    },
+		},
+	    ],
+	    advancedColumn1: [
+		{
+		    xtype: 'pveHashAlgorithmSelector',
+		    name: 'checksum-algorithm',
+		    fieldLabel: gettext('Hash algorithm'),
+		    allowBlank: true,
+		    hasNoneOption: true,
+		    value: '__default__',
+		    listeners: {
+			change: 'hashChange',
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'checksum',
+		    fieldLabel: gettext('Checksum'),
+		    allowBlank: true,
+		    disabled: true,
+		    emptyText: gettext('none'),
+		    id: 'downloadUrlChecksum',
+		},
+	    ],
+	    advancedColumn2: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'verify-certificates',
+		    fieldLabel: gettext('Verify certificates'),
+		    uncheckedValue: 0,
+		    checked: true,
+		    listeners: {
+			change: 'setQueryEnabled',
+		    },
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'compression',
+		    fieldLabel: gettext('Decompression algorithm'),
+		    allowBlank: true,
+		    hasNoneOption: true,
+		    deleteEmpty: false,
+		    value: '__default__',
+		    comboItems: [
+				['__default__', Proxmox.Utils.NoneText],
+				['lzo', 'LZO'],
+				['gz', 'GZIP'],
+				['zst', 'ZSTD'],
+		    ],
+		    cbind: {
+			hidden: get => get('content') !== 'iso',
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'hiddenfield',
+	    name: 'content',
+	    cbind: {
+		value: '{content}',
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (!me.storage) {
+	    throw "no storage ID specified";
+	}
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.window.UploadToStorage', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pveStorageUpload',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    resizable: false,
+    modal: true,
+
+    title: gettext('Upload'),
+
+    acceptedExtensions: {
+	iso: ['.img', '.iso'],
+	vztmpl: ['.tar.gz', '.tar.xz', '.tar.zst'],
+    },
+
+    cbindData: function(initialConfig) {
+	const me = this;
+	const ext = me.acceptedExtensions[me.content] || [];
+
+	me.url = `/nodes/${me.nodename}/storage/${me.storage}/upload`;
+
+	return {
+	    extensions: ext.join(', '),
+	    filenameRegex: RegExp('^.*(?:' + ext.join('|').replaceAll('.', '\\.') + ')$', 'i'),
+	};
+    },
+
+    viewModel: {
+	data: {
+	    size: '-',
+	    mimetype: '-',
+	    filename: '',
+	},
+    },
+
+    controller: {
+	submit: function(button) {
+	    const view = this.getView();
+	    const form = this.lookup('formPanel').getForm();
+	    const abortBtn = this.lookup('abortBtn');
+	    const pbar = this.lookup('progressBar');
+
+	    const updateProgress = function(per, bytes) {
+		let text = (per * 100).toFixed(2) + '%';
+		if (bytes) {
+		    text += " (" + Proxmox.Utils.format_size(bytes) + ')';
+		}
+		pbar.updateProgress(per, text);
+	    };
+
+	    const fd = new FormData();
+
+	    button.setDisabled(true);
+	    abortBtn.setDisabled(false);
+
+	    fd.append("content", view.content);
+
+	    const fileField = form.findField('file');
+	    const file = fileField.fileInputEl.dom.files[0];
+	    fileField.setDisabled(true);
+
+	    const filenameField = form.findField('filename');
+	    const filename = filenameField.getValue();
+	    filenameField.setDisabled(true);
+
+	    const algorithmField = form.findField('checksum-algorithm');
+	    algorithmField.setDisabled(true);
+	    if (algorithmField.getValue() !== '__default__') {
+		fd.append("checksum-algorithm", algorithmField.getValue());
+
+		const checksumField = form.findField('checksum');
+		fd.append("checksum", checksumField.getValue()?.trim());
+		checksumField.setDisabled(true);
+	    }
+
+	    fd.append("filename", file, filename);
+
+	    pbar.setVisible(true);
+	    updateProgress(0);
+
+	    const xhr = new XMLHttpRequest();
+	    view.xhr = xhr;
+
+	    xhr.addEventListener("load", function(e) {
+		if (xhr.status === 200) {
+		    view.hide();
+
+		    const result = JSON.parse(xhr.response);
+		    const upid = result.data;
+		    Ext.create('Proxmox.window.TaskViewer', {
+			autoShow: true,
+			upid: upid,
+			taskDone: view.taskDone,
+			listeners: {
+			    destroy: function() {
+				view.close();
+			    },
+			},
+		    });
+
+		    return;
+		}
+		const err = Ext.htmlEncode(xhr.statusText);
+		let msg = `${gettext('Error')} ${xhr.status.toString()}: ${err}`;
+		if (xhr.responseText !== "") {
+		    const result = Ext.decode(xhr.responseText);
+		    result.message = msg;
+		    msg = Proxmox.Utils.extractRequestError(result, true);
+		}
+		Ext.Msg.alert(gettext('Error'), msg, btn => view.close());
+	    }, false);
+
+	    xhr.addEventListener("error", function(e) {
+		const err = e.target.status.toString();
+		const msg = `Error '${err}' occurred while receiving the document.`;
+		Ext.Msg.alert(gettext('Error'), msg, btn => view.close());
+	    });
+
+	    xhr.upload.addEventListener("progress", function(evt) {
+		if (evt.lengthComputable) {
+		    const percentComplete = evt.loaded / evt.total;
+		    updateProgress(percentComplete, evt.loaded);
+		}
+	    }, false);
+
+	    xhr.open("POST", `/api2/json${view.url}`, true);
+	    xhr.send(fd);
+	},
+
+	validitychange: function(f, valid) {
+	    const submitBtn = this.lookup('submitBtn');
+	    submitBtn.setDisabled(!valid);
+	},
+
+	fileChange: function(input) {
+	    const vm = this.getViewModel();
+	    const name = input.value.replace(/^.*(\/|\\)/, '');
+	    const fileInput = input.fileInputEl.dom;
+	    vm.set('filename', name);
+	    vm.set('size', (fileInput.files[0] && Proxmox.Utils.format_size(fileInput.files[0].size)) || '-');
+	    vm.set('mimetype', (fileInput.files[0] && fileInput.files[0].type) || '-');
+	},
+
+	hashChange: function(field, value) {
+	    const checksum = this.lookup('downloadUrlChecksum');
+	    if (value === '__default__') {
+		checksum.setDisabled(true);
+		checksum.setValue("");
+	    } else {
+		checksum.setDisabled(false);
+	    }
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'formPanel',
+	    method: 'POST',
+	    waitMsgTarget: true,
+	    bodyPadding: 10,
+	    border: false,
+	    width: 400,
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+            },
+	    items: [
+		{
+		    xtype: 'filefield',
+		    name: 'file',
+		    buttonText: gettext('Select File'),
+		    allowBlank: false,
+		    fieldLabel: gettext('File'),
+		    cbind: {
+			accept: '{extensions}',
+		    },
+		    listeners: {
+			change: 'fileChange',
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'filename',
+		    allowBlank: false,
+		    fieldLabel: gettext('File name'),
+		    bind: {
+			value: '{filename}',
+		    },
+		    cbind: {
+			regex: '{filenameRegex}',
+		    },
+		    regexText: gettext('Wrong file extension'),
+		},
+		{
+		    xtype: 'displayfield',
+		    name: 'size',
+		    fieldLabel: gettext('File size'),
+		    bind: {
+			value: '{size}',
+		    },
+		},
+		{
+		    xtype: 'displayfield',
+		    name: 'mimetype',
+		    fieldLabel: gettext('MIME type'),
+		    bind: {
+			value: '{mimetype}',
+		    },
+		},
+		{
+		    xtype: 'pveHashAlgorithmSelector',
+		    name: 'checksum-algorithm',
+		    fieldLabel: gettext('Hash algorithm'),
+		    allowBlank: true,
+		    hasNoneOption: true,
+		    value: '__default__',
+		    listeners: {
+			change: 'hashChange',
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'checksum',
+		    fieldLabel: gettext('Checksum'),
+		    allowBlank: false,
+		    disabled: true,
+		    emptyText: gettext('none'),
+		    reference: 'downloadUrlChecksum',
+		},
+		{
+		    xtype: 'progressbar',
+		    text: 'Ready',
+		    hidden: true,
+		    reference: 'progressBar',
+		},
+		{
+		    xtype: 'hiddenfield',
+		    name: 'content',
+		    cbind: {
+			value: '{content}',
+		    },
+		},
+	    ],
+	   listeners: {
+		validitychange: 'validitychange',
+	   },
+	},
+    ],
+
+    buttons: [
+	{
+	    xtype: 'button',
+	    text: gettext('Abort'),
+	    reference: 'abortBtn',
+	    disabled: true,
+	    handler: function() {
+		const me = this;
+		me.up('pveStorageUpload').close();
+	    },
+	},
+	{
+	    text: gettext('Upload'),
+	    reference: 'submitBtn',
+	    disabled: true,
+	    handler: 'submit',
+	},
+    ],
+
+    listeners: {
+	close: function() {
+	    const me = this;
+	    if (me.xhr) {
+		me.xhr.abort();
+		delete me.xhr;
+	    }
+	},
+    },
+
+    initComponent: function() {
+        const me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (!me.storage) {
+	    throw "no storage ID specified";
+	}
+	if (!me.acceptedExtensions[me.content]) {
+	    throw "content type not supported";
+	}
+
+        me.callParent();
+    },
+});
+Ext.define('PVE.window.ScheduleSimulator', {
+    extend: 'Ext.window.Window',
+
+    title: gettext('Job Schedule Simulator'),
+
+    viewModel: {
+	data: {
+	    simulatedOnce: false,
+	},
+	formulas: {
+	    gridEmptyText: get => get('simulatedOnce') ? Proxmox.Utils.NoneText : gettext('No simulation done'),
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	close: function() {
+	    this.getView().close();
+	},
+	simulate: function() {
+	    let me = this;
+	    let schedule = me.lookup('schedule').getValue();
+	    if (!schedule) {
+		return;
+	    }
+	    let iterations = me.lookup('iterations').getValue() || 10;
+	    Proxmox.Utils.API2Request({
+		url: '/cluster/jobs/schedule-analyze',
+		method: 'GET',
+		params: {
+		    schedule,
+		    iterations,
+		},
+		failure: response => {
+		    me.getViewModel().set('simulatedOnce', true);
+		    me.lookup('grid').getStore().setData([]);
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: function(response) {
+		    let schedules = response.result.data;
+		    me.lookup('grid').getStore().setData(schedules);
+		    me.getViewModel().set('simulatedOnce', true);
+		},
+	    });
+	},
+
+	scheduleChanged: function(field, value) {
+	    this.lookup('simulateBtn').setDisabled(!value);
+	},
+
+	renderDate: function(value) {
+	    let date = new Date(value*1000);
+	    return date.toLocaleDateString();
+	},
+
+	renderTime: function(value) {
+	    let date = new Date(value*1000);
+	    return date.toLocaleTimeString();
+	},
+
+	init: function(view) {
+	    let me = this;
+	    if (view.schedule) {
+		me.lookup('schedule').setValue(view.schedule);
+	    }
+	},
+    },
+
+    bodyPadding: 10,
+    modal: true,
+    resizable: false,
+    width: 600,
+
+    layout: 'fit',
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    column1: [
+		{
+		    xtype: 'pveCalendarEvent',
+		    reference: 'schedule',
+		    fieldLabel: gettext('Schedule'),
+		    listeners: {
+			change: 'scheduleChanged',
+		    },
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    reference: 'iterations',
+		    fieldLabel: gettext('Iterations'),
+		    minValue: 1,
+		    maxValue: 100,
+		    value: 10,
+		},
+		{
+		    xtype: 'container',
+		    layout: 'hbox',
+		    items: [
+			{
+			    xtype: 'box',
+			    flex: 1,
+			},
+			{
+			    xtype: 'button',
+			    reference: 'simulateBtn',
+			    text: gettext('Simulate'),
+			    handler: 'simulate',
+			    disabled: true,
+			},
+		    ],
+		},
+	    ],
+
+	    column2: [
+		{
+		    xtype: 'grid',
+		    reference: 'grid',
+		    bind: {
+			emptyText: '{gridEmptyText}',
+		    },
+		    scrollable: true,
+		    height: 300,
+		    columns: [
+			{
+			    text: gettext('Date'),
+			    renderer: 'renderDate',
+			    dataIndex: 'timestamp',
+			    flex: 1,
+			},
+			{
+			    text: gettext('Time'),
+			    renderer: 'renderTime',
+			    dataIndex: 'timestamp',
+			    align: 'right',
+			    flex: 1,
+			},
+		    ],
+		    store: {
+			fields: ['timestamp'],
+			data: [],
+			sorter: 'timestamp',
+		    },
+		},
+	    ],
+	},
+    ],
+
+    buttons: [
+	{
+	    text: gettext('Done'),
+	    handler: 'close',
+	},
+    ],
+});
+Ext.define('PVE.window.Wizard', {
+    extend: 'Ext.window.Window',
+
+    activeTitle: '', // used for automated testing
+
+    width: 720,
+    height: 540,
+
+    modal: true,
+    border: false,
+
+    draggable: true,
+    closable: true,
+    resizable: false,
+
+    layout: 'border',
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+
+	let values = {};
+
+	me.down('form').getForm().getFields().each(field => {
+	    if (!field.up('inputpanel') && (!dirtyOnly || field.isDirty())) {
+		Proxmox.Utils.assemble_field_data(values, field.getSubmitData());
+	    }
+	});
+
+	me.query('inputpanel').forEach(panel => {
+	    Proxmox.Utils.assemble_field_data(values, panel.getValues(dirtyOnly));
+	});
+
+        return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var tabs = me.items || [];
+	delete me.items;
+
+	/*
+	 * Items may have the following functions:
+	 * validator(): per tab custom validation
+	 * onSubmit(): submit handler
+	 * onGetValues(): overwrite getValues results
+	 */
+
+	Ext.Array.each(tabs, function(tab) {
+	    tab.disabled = true;
+	});
+	tabs[0].disabled = false;
+
+	let maxidx = 0, curidx = 0;
+
+	let check_card = function(card) {
+	    let fields = card.query('field, fieldcontainer');
+	    if (card.isXType('fieldcontainer')) {
+		fields.unshift(card);
+	    }
+	    let valid = true;
+	    for (const field of fields) {
+		// Note: not all fielcontainer have isValid()
+		if (Ext.isFunction(field.isValid) && !field.isValid()) {
+		    valid = false;
+		}
+	    }
+	    if (Ext.isFunction(card.validator)) {
+		return card.validator();
+	    }
+	    return valid;
+	};
+
+	let disableTab = function(card) {
+	    let tp = me.down('#wizcontent');
+	    for (let idx = tp.items.indexOf(card); idx < tp.items.getCount(); idx++) {
+		let tab = tp.items.getAt(idx);
+		if (tab) {
+		    tab.disable();
+		}
+	    }
+	};
+
+	let tabchange = function(tp, newcard, oldcard) {
+	    if (newcard.onSubmit) {
+		me.down('#next').setVisible(false);
+		me.down('#submit').setVisible(true);
+	    } else {
+		me.down('#next').setVisible(true);
+		me.down('#submit').setVisible(false);
+	    }
+	    let valid = check_card(newcard);
+	    me.down('#next').setDisabled(!valid);
+	    me.down('#submit').setDisabled(!valid);
+	    me.down('#back').setDisabled(tp.items.indexOf(newcard) === 0);
+
+	    let idx = tp.items.indexOf(newcard);
+	    if (idx > maxidx) {
+		maxidx = idx;
+	    }
+	    curidx = idx;
+
+	    let ntab = tp.items.getAt(idx + 1);
+	    if (valid && ntab && !newcard.onSubmit) {
+		ntab.enable();
+	    }
+	};
+
+	if (me.subject && !me.title) {
+	    me.title = Proxmox.Utils.dialog_title(me.subject, true, false);
+	}
+
+	let sp = Ext.state.Manager.getProvider();
+	let advancedOn = sp.get('proxmox-advanced-cb');
+
+	Ext.apply(me, {
+	    items: [
+		{
+		    xtype: 'form',
+		    region: 'center',
+		    layout: 'fit',
+		    border: false,
+		    margins: '5 5 0 5',
+		    fieldDefaults: {
+			labelWidth: 100,
+			anchor: '100%',
+		    },
+		    items: [{
+			itemId: 'wizcontent',
+			xtype: 'tabpanel',
+			activeItem: 0,
+			bodyPadding: 0,
+			listeners: {
+			    afterrender: function(tp) {
+				tabchange(tp, this.getActiveTab());
+			    },
+			    tabchange: function(tp, newcard, oldcard) {
+				tabchange(tp, newcard, oldcard);
+			    },
+			},
+			defaults: {
+			    padding: 10,
+			},
+			items: tabs,
+		    }],
+		},
+	    ],
+	    fbar: [
+		{
+		    xtype: 'proxmoxHelpButton',
+		    itemId: 'help',
+		},
+		'->',
+		{
+		    xtype: 'proxmoxcheckbox',
+		    boxLabelAlign: 'before',
+		    boxLabel: gettext('Advanced'),
+		    value: advancedOn,
+		    listeners: {
+			change: function(_, value) {
+			    let tp = me.down('#wizcontent');
+			    tp.query('inputpanel').forEach(function(ip) {
+				ip.setAdvancedVisible(value);
+			    });
+			    sp.set('proxmox-advanced-cb', value);
+			},
+		    },
+		},
+		{
+		    text: gettext('Back'),
+		    disabled: true,
+		    itemId: 'back',
+		    minWidth: 60,
+		    handler: function() {
+			let tp = me.down('#wizcontent');
+			let prev = tp.items.indexOf(tp.getActiveTab()) - 1;
+			if (prev < 0) {
+			    return;
+			}
+			let ntab = tp.items.getAt(prev);
+			if (ntab) {
+			    tp.setActiveTab(ntab);
+			}
+		    },
+		},
+		{
+		    text: gettext('Next'),
+		    disabled: true,
+		    itemId: 'next',
+		    minWidth: 60,
+		    handler: function() {
+			let tp = me.down('#wizcontent');
+			let activeTab = tp.getActiveTab();
+			if (!check_card(activeTab)) {
+			    return;
+			}
+			let next = tp.items.indexOf(activeTab) + 1;
+			let ntab = tp.items.getAt(next);
+			if (ntab) {
+			    ntab.enable();
+			    tp.setActiveTab(ntab);
+			}
+		    },
+		},
+		{
+		    text: gettext('Finish'),
+		    minWidth: 60,
+		    hidden: true,
+		    itemId: 'submit',
+		    handler: function() {
+			let tp = me.down('#wizcontent');
+			tp.getActiveTab().onSubmit();
+		    },
+		},
+	    ],
+	});
+	me.callParent();
+
+	Ext.Array.each(me.query('inputpanel'), function(panel) {
+	    panel.setAdvancedVisible(advancedOn);
+	});
+
+	Ext.Array.each(me.query('field'), function(field) {
+	    let validcheck = function() {
+		let tp = me.down('#wizcontent');
+
+		// check validity for current to last enabled tab, as local change may affect validity of a later one
+		for (let i = curidx; i <= maxidx && i < tp.items.getCount(); i++) {
+		    let tab = tp.items.getAt(i);
+		    let valid = check_card(tab);
+
+		    // only set the buttons on the current panel
+		    if (i === curidx) {
+			me.down('#next').setDisabled(!valid);
+			me.down('#submit').setDisabled(!valid);
+		    }
+		    // if a panel is invalid, then disable all following, else enable the next tab
+		    let nextTab = tp.items.getAt(i + 1);
+		    if (!valid) {
+			disableTab(nextTab);
+			return;
+		    } else if (nextTab && !tab.onSubmit) {
+			nextTab.enable();
+		    }
+		}
+	    };
+	    field.on('change', validcheck);
+	    field.on('validitychange', validcheck);
+	});
+    },
+});
+Ext.define('PVE.window.GuestDiskReassign', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    resizable: false,
+    modal: true,
+    width: 350,
+    border: false,
+    layout: 'fit',
+    showReset: false,
+    showProgress: true,
+    method: 'POST',
+
+    viewModel: {
+	data: {
+	    mpType: '',
+	},
+	formulas: {
+	    mpMaxCount: get => get('mpType') === 'mp'
+		? PVE.Utils.lxc_mp_counts.mps - 1
+		: PVE.Utils.lxc_mp_counts.unused - 1,
+	},
+    },
+
+    cbindData: function() {
+	let me = this;
+	return {
+	    vmid: me.vmid,
+	    disk: me.disk,
+	    isQemu: me.type === 'qemu',
+	    nodename: me.nodename,
+	    url: () => {
+		let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume';
+		return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`;
+	    },
+	};
+    },
+
+    cbind: {
+	title: get => get('isQemu') ? gettext('Reassign Disk') : gettext('Reassign Volume'),
+	submitText: get => get('title'),
+	qemu: '{isQemu}',
+	url: '{url}',
+    },
+
+    getValues: function() {
+	let me = this;
+	let values = me.formPanel.getForm().getValues();
+
+	let params = {
+	    vmid: me.vmid,
+	    'target-vmid': values.targetVmid,
+	};
+
+	params[me.qemu ? 'disk' : 'volume'] = me.disk;
+
+	if (me.qemu) {
+	    params['target-disk'] = `${values.controller}${values.deviceid}`;
+	} else {
+	    params['target-volume'] = `${values.mpType}${values.mpId}`;
+	}
+	return params;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	initViewModel: function(model) {
+	    let view = this.getView();
+	    let mpTypeValue = view.disk.match(/^unused\d+/) ? 'unused' : 'mp';
+	    model.set('mpType', mpTypeValue);
+	},
+
+	onMpTypeChange: function(value) {
+	    let view = this.getView();
+	    view.getViewModel().set('mpType', value.getValue());
+	    view.lookup('mpIdSelector').validate();
+	},
+
+	onTargetVMChange: function(f, vmid) {
+	    let me = this;
+	    let view = me.getView();
+	    let diskSelector = view.lookup('diskSelector');
+	    if (!vmid) {
+		diskSelector.setVMConfig(null);
+		me.VMConfig = null;
+		return;
+	    }
+
+	    let url = `/nodes/${view.nodename}/${view.type}/${vmid}/config`;
+	    Proxmox.Utils.API2Request({
+		url: url,
+		method: 'GET',
+		failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		success: function({ result }, options) {
+		    if (view.qemu) {
+			diskSelector.setVMConfig(result.data);
+			diskSelector.setDisabled(false);
+		    } else {
+			let mpIdSelector = view.lookup('mpIdSelector');
+			let mpType = view.lookup('mpType');
+
+			view.VMConfig = result.data;
+
+			mpIdSelector.setValue(
+			    PVE.Utils.nextFreeLxcMP(
+				view.getViewModel().get('mpType'),
+				view.VMConfig,
+			    ).id,
+			);
+
+			mpType.setDisabled(false);
+			mpIdSelector.setDisabled(false);
+			mpIdSelector.validate();
+		    }
+		},
+	    });
+	},
+    },
+
+    defaultFocus: 'sourceDisk',
+    items: [
+	{
+	    xtype: 'displayfield',
+	    name: 'sourceDisk',
+	    fieldLabel: gettext('Source'),
+	    cbind: {
+		name: get => get('isQemu') ? 'disk' : 'volume',
+		value: '{disk}',
+	    },
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'vmComboSelector',
+	    name: 'targetVmid',
+	    allowBlank: false,
+	    fieldLabel: gettext('Target Guest'),
+	    store: {
+		model: 'PVEResources',
+		autoLoad: true,
+		sorters: 'vmid',
+		cbind: {}, // for nested cbinds
+		filters: [
+		    {
+			property: 'type',
+			cbind: { value: '{type}' },
+		    },
+		    {
+			property: 'node',
+			cbind: { value: '{nodename}' },
+		    },
+		    // FIXME: remove, artificial restriction that doesn't gains us anything..
+		    {
+			property: 'vmid',
+			operator: '!=',
+			cbind: { value: '{vmid}' },
+		    },
+		    {
+			property: 'template',
+			value: 0,
+		    },
+		],
+	    },
+	    listeners: { change: 'onTargetVMChange' },
+	},
+	{
+	    xtype: 'pveControllerSelector',
+	    reference: 'diskSelector',
+	    withUnused: true,
+	    disabled: true,
+	    cbind: {
+		hidden: '{!isQemu}',
+	    },
+	},
+	{
+	    xtype: 'container',
+	    layout: 'hbox',
+	    cbind: {
+		hidden: '{isQemu}',
+		disabled: '{isQemu}',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    cbind: {
+			editable: get => !get('disk').match(/^unused\d+/),
+			value: get => get('disk').match(/^unused\d+/) ? 'unused' : 'mp',
+		    },
+		    disabled: true,
+		    name: 'mpType',
+		    reference: 'mpType',
+		    fieldLabel: gettext('Add as'),
+		    submitValue: true,
+		    flex: 4,
+		    editConfig: {
+			xtype: 'proxmoxKVComboBox',
+			name: 'mpTypeCombo',
+			deleteEmpty: false,
+			cbind: {
+			    hidden: '{isQemu}',
+			},
+			comboItems: [
+			    ['mp', gettext('Mount Point')],
+			    ['unused', gettext('Unused')],
+			],
+			listeners: { change: 'onMpTypeChange' },
+		    },
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'mpId',
+		    reference: 'mpIdSelector',
+		    minValue: 0,
+		    flex: 1,
+		    allowBlank: false,
+		    validateOnChange: true,
+		    disabled: true,
+		    bind: {
+			maxValue: '{mpMaxCount}',
+		    },
+		    validator: function(value) {
+			let view = this.up('window');
+			let type = view.getViewModel().get('mpType');
+			if (Ext.isDefined(view.VMConfig[`${type}${value}`])) {
+			    return "Mount point is already in use.";
+			}
+			return true;
+		    },
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	if (!me.type) {
+	    throw "no type specified";
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.GuestStop', {
+    extend: 'Ext.window.MessageBox',
+
+    closeAction: 'destroy',
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (!me.vm) {
+	    throw "no vm specified";
+	}
+
+	let isQemuVM = me.vm.type === 'qemu';
+	let overruleTaskType = isQemuVM ? 'qmshutdown' : 'vzshutdown';
+
+	me.taskType = isQemuVM ? 'qmstop' : 'vzstop';
+	me.url = `/nodes/${me.nodename}/${me.vm.type}/${me.vm.vmid}/status/stop`;
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let hasSysModify = !!caps.nodes['Sys.Modify'];
+
+	// offer to overrule if there is at least one matching shutdown task and the guest is not
+	// HA-enabled. Also allow users to abort tasks started by one of their API tokens.
+	let activeShutdownTask = Ext.getStore('pve-cluster-tasks')?.findBy(task =>
+	    (hasSysModify || task.data.user === Proxmox.UserName) &&
+	    task.data.id === me.vm.vmid.toString() &&
+	    task.data.status === undefined &&
+	    task.data.type === overruleTaskType,
+	) !== -1;
+	let haEnabled = me.vm.hastate && me.vm.hastate !== 'unmanaged';
+
+	me.callParent();
+
+	// message box has its actual content in a sub-container, the top one is just for layouting
+	me.promptContainer.add({
+	    xtype: 'proxmoxcheckbox',
+	    name: 'overrule-shutdown',
+	    checked: !haEnabled && activeShutdownTask,
+	    boxLabel: gettext('Overrule active shutdown tasks'),
+	    hidden: !(hasSysModify || activeShutdownTask),
+	    disabled: !(hasSysModify || activeShutdownTask) || haEnabled,
+	    padding: '3 0 0 0',
+	});
+    },
+
+    handler: function(btn) {
+	let me = this;
+	if (btn === 'yes') {
+	    let overruleField = me.promptContainer.down('proxmoxcheckbox[name=overrule-shutdown]');
+	    let params = !overruleField.isDisabled() && overruleField.getSubmitValue()
+		? { 'overrule-shutdown': 1 }
+		: undefined;
+	    Proxmox.Utils.API2Request({
+		url: me.url,
+		waitMsgTarget: me,
+		method: 'POST',
+		params: params,
+		failure: response => Ext.Msg.alert('Error', response.htmlStatus),
+	    });
+	}
+    },
+
+    show: function() {
+	let me = this;
+	let cfg = {
+	    title: gettext('Confirm'),
+	    icon: Ext.Msg.WARNING,
+	    msg: Proxmox.Utils.format_task_description(me.taskType, me.vm.vmid),
+	    buttons: Ext.Msg.YESNO,
+	    callback: btn => me.handler(btn),
+	};
+	me.callParent([cfg]);
+    },
+});
+Ext.define('PVE.window.TreeSettingsEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveTreeSettingsEdit',
+
+    title: gettext('Tree Settings'),
+    isCreate: false,
+
+    url: '#', // ignored as submit() gets overriden here, but the parent class requires it
+
+    width: 450,
+    fieldDefaults: {
+	labelWidth: 150,
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    items: [
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'sort-field',
+		    fieldLabel: gettext('Sort Key'),
+		    comboItems: [
+			['__default__', `${Proxmox.Utils.defaultText} (VMID)`],
+			['vmid', 'VMID'],
+			['name', gettext('Name')],
+		    ],
+		    defaultValue: '__default__',
+		    value: '__default__',
+		    deleteEmpty: false,
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'group-templates',
+		    fieldLabel: gettext('Group Templates'),
+		    comboItems: [
+			['__default__', `${Proxmox.Utils.defaultText} (${gettext("Yes")})`],
+			[1, gettext('Yes')],
+			[0, gettext('No')],
+		    ],
+		    defaultValue: '__default__',
+		    value: '__default__',
+		    deleteEmpty: false,
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'group-guest-types',
+		    fieldLabel: gettext('Group Guest Types'),
+		    comboItems: [
+			['__default__', `${Proxmox.Utils.defaultText} (${gettext("Yes")})`],
+			[1, gettext('Yes')],
+			[0, gettext('No')],
+		    ],
+		    defaultValue: '__default__',
+		    value: '__default__',
+		    deleteEmpty: false,
+		},
+		{
+		    xtype: 'displayfield',
+		    userCls: 'pmx-hint',
+		    value: gettext('Settings are saved in the local storage of the browser'),
+		},
+	    ],
+	},
+    ],
+
+    submit: function() {
+	let me = this;
+
+	let localStorage = Ext.state.Manager.getProvider();
+	localStorage.set('pve-tree-sorting', me.down('inputpanel').getValues() || null);
+
+	me.apiCallDone();
+	me.close();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	let localStorage = Ext.state.Manager.getProvider();
+	me.down('inputpanel').setValues(localStorage.get('pve-tree-sorting'));
+    },
+
+});
+Ext.define('PVE.window.PCIMapEditWindow', {
+    extend: 'Proxmox.window.Edit',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 800,
+
+    subject: gettext('PCI mapping'),
+
+    onlineHelp: 'resource_mapping',
+
+    method: 'POST',
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	me.isCreate = (!me.name || !me.nodename) && !me.entryOnly;
+	me.method = me.name ? 'PUT' : 'POST';
+	me.hideMapping = !!me.entryOnly;
+	me.hideComment = me.name && !me.entryOnly;
+	me.hideNodeSelector = me.nodename || me.entryOnly;
+	me.hideNode = !me.nodename || !me.hideNodeSelector;
+	return {
+	    name: me.name,
+	    nodename: me.nodename,
+	};
+    },
+
+    submitUrl: function(_url, data) {
+	let me = this;
+	let name = me.method === 'PUT' ? me.name : '';
+	return `/cluster/mapping/pci/${name}`;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	onGetValues: function(values) {
+	    let me = this;
+	    let view = me.getView();
+	    if (view.method === "POST") {
+		delete me.digest;
+	    }
+
+	    if (values.iommugroup === -1) {
+		delete values.iommugroup;
+	    }
+
+	    let nodename = values.node ?? view.nodename;
+	    delete values.node;
+	    if (me.originalMap) {
+		let otherMaps = PVE.Parser
+		    .filterPropertyStringList(me.originalMap, (e) => e.node !== nodename);
+		if (otherMaps.length) {
+		    values.map = values.map.concat(otherMaps);
+		}
+	    }
+
+	    return values;
+	},
+
+	onSetValues: function(values) {
+	    let me = this;
+	    let view = me.getView();
+	    me.originalMap = [...values.map];
+	    let configuredNodes = [];
+	    values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => {
+		configuredNodes.push(e.node);
+		return e.node === view.nodename;
+	    });
+
+	    me.lookup('nodeselector').disallowedNodes = configuredNodes;
+	    return values;
+	},
+
+	checkIommu: function(store, records, success) {
+	    let me = this;
+	    if (!success || !records.length) {
+		return;
+	    }
+	    me.lookup('iommu_warning').setVisible(
+		records.every((val) => val.data.iommugroup === -1),
+	    );
+
+	    let value = me.lookup('pciselector').getValue();
+	    me.checkIsolated(value);
+	},
+
+	checkIsolated: function(value) {
+	    let me = this;
+
+	    let store = me.lookup('pciselector').getStore();
+
+	    let isIsolated = function(entry) {
+		let isolated = true;
+		let parsed = PVE.Parser.parsePropertyString(entry);
+		parsed.iommugroup = parseInt(parsed.iommugroup, 10);
+		if (!parsed.iommugroup) {
+		    return isolated;
+		}
+		store.each(({ data }) => {
+		    let isSubDevice = data.id.startsWith(parsed.path);
+		    if (data.iommugroup === parsed.iommugroup && data.id !== parsed.path && !isSubDevice) {
+			isolated = false;
+			return false;
+		    }
+		    return true;
+		});
+		return isolated;
+	    };
+
+	    let showWarning = false;
+	    if (Ext.isArray(value)) {
+		for (const entry of value) {
+		    if (!isIsolated(entry)) {
+			showWarning = true;
+			break;
+		    }
+		}
+	    } else {
+		showWarning = isIsolated(value);
+	    }
+	    me.lookup('group_warning').setVisible(showWarning);
+	},
+
+	mdevChange: function(mdevField, value) {
+	    this.lookup('pciselector').setMdev(value);
+	},
+
+	nodeChange: function(_field, value) {
+	    this.lookup('pciselector').setNodename(value);
+	},
+
+	pciChange: function(_field, value) {
+	    let me = this;
+	    me.lookup('multiple_warning').setVisible(Ext.isArray(value) && value.length > 1);
+	    me.checkIsolated(value);
+	},
+
+	control: {
+	    'field[name=mdev]': {
+		change: 'mdevChange',
+	    },
+	    'pveNodeSelector': {
+		change: 'nodeChange',
+	    },
+	    'pveMultiPCISelector': {
+		change: 'pciChange',
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    onGetValues: function(values) {
+		return this.up('window').getController().onGetValues(values);
+	    },
+
+	    onSetValues: function(values) {
+		return this.up('window').getController().onSetValues(values);
+	    },
+
+	    columnT: [
+		{
+		    xtype: 'displayfield',
+		    reference: 'iommu_warning',
+		    hidden: true,
+		    columnWidth: 1,
+		    padding: '0 0 10 0',
+		    value: gettext('No IOMMU detected, please activate it. See Documentation for further information.'),
+		    userCls: 'pmx-hint',
+		},
+		{
+		    xtype: 'displayfield',
+		    reference: 'multiple_warning',
+		    hidden: true,
+		    columnWidth: 1,
+		    padding: '0 0 10 0',
+		    value: gettext('When multiple devices are selected, the first free one will be chosen on guest start.'),
+		    userCls: 'pmx-hint',
+		},
+		{
+		    xtype: 'displayfield',
+		    reference: 'group_warning',
+		    hidden: true,
+		    columnWidth: 1,
+		    padding: '0 0 10 0',
+		    itemId: 'iommuwarning',
+		    value: gettext('A selected device is not in a separate IOMMU group, make sure this is intended.'),
+		    userCls: 'pmx-hint',
+		},
+	    ],
+
+	    column1: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    fieldLabel: gettext('Name'),
+		    labelWidth: 120,
+		    cbind: {
+			editable: '{!name}',
+			value: '{name}',
+			submitValue: '{isCreate}',
+		    },
+		    name: 'id',
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Mapping on Node'),
+		    labelWidth: 120,
+		    name: 'node',
+		    cbind: {
+			value: '{nodename}',
+			disabled: '{hideNode}',
+			hidden: '{hideNode}',
+		    },
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'pveNodeSelector',
+		    reference: 'nodeselector',
+		    fieldLabel: gettext('Mapping on Node'),
+		    labelWidth: 120,
+		    name: 'node',
+		    cbind: {
+			disabled: '{hideNodeSelector}',
+			hidden: '{hideNodeSelector}',
+		    },
+		    allowBlank: false,
+		},
+	    ],
+
+	    column2: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Use with Mediated Devices'),
+		    labelWidth: 200,
+		    reference: 'mdev',
+		    name: 'mdev',
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+			disabled: '{hideComment}',
+		    },
+		},
+	    ],
+
+	    columnB: [
+		{
+		    xtype: 'pveMultiPCISelector',
+		    fieldLabel: gettext('Device'),
+		    labelWidth: 120,
+		    height: 300,
+		    reference: 'pciselector',
+		    name: 'map',
+		    cbind: {
+			nodename: '{nodename}',
+			disabled: '{hideMapping}',
+			hidden: '{hideMapping}',
+		    },
+		    allowBlank: false,
+		    onLoadCallBack: 'checkIommu',
+		    margin: '0 0 10 0',
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    fieldLabel: gettext('Comment'),
+		    labelWidth: 120,
+		    submitValue: true,
+		    name: 'description',
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+			disabled: '{hideComment}',
+			hidden: '{hideComment}',
+		    },
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.window.USBMapEditWindow', {
+    extend: 'Proxmox.window.Edit',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	me.isCreate = !me.name;
+	me.method = me.isCreate ? 'POST' : 'PUT';
+	me.hideMapping = !!me.entryOnly;
+	me.hideComment = me.name && !me.entryOnly;
+	me.hideNodeSelector = me.nodename || me.entryOnly;
+	me.hideNode = !me.nodename || !me.hideNodeSelector;
+	return {
+	    name: me.name,
+	    nodename: me.nodename,
+	};
+    },
+
+    submitUrl: function(_url, data) {
+	let me = this;
+	let name = me.isCreate ? '' : me.name;
+	return `/cluster/mapping/usb/${name}`;
+    },
+
+    title: gettext('Add USB mapping'),
+
+    onlineHelp: 'resource_mapping',
+
+    method: 'POST',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	onGetValues: function(values) {
+	    let me = this;
+	    let view = me.getView();
+	    values.node ??= view.nodename;
+
+	    let type = me.getView().down('radiofield').getGroupValue();
+	    let name = values.name;
+	    let description = values.description;
+	    delete values.description;
+	    delete values.name;
+
+	    if (type === 'path') {
+		let usbsel = me.lookup(type);
+		let usbDev = usbsel.getStore().findRecord('usbid', values[type], 0, false, true, true);
+
+		if (!usbDev) {
+		    return {};
+		}
+		values.id = `${usbDev.data.vendid}:${usbDev.data.prodid}`;
+	    }
+
+	    let map = [];
+	    if (me.originalMap) {
+		map = PVE.Parser.filterPropertyStringList(me.originalMap, (e) => e.node !== values.node);
+	    }
+	    if (values.id) {
+		map.push(PVE.Parser.printPropertyString(values));
+	    }
+
+	    values = { map };
+	    if (description) {
+		values.description = description;
+	    }
+
+	    if (view.isCreate) {
+		values.id = name;
+	    }
+
+	    return values;
+	},
+
+	onSetValues: function(values) {
+	    let me = this;
+	    let view = me.getView();
+	    me.originalMap = [...values.map];
+	    let configuredNodes = [];
+	    PVE.Parser.filterPropertyStringList(values.map, (e) => {
+		configuredNodes.push(e.node);
+		if (e.node === view.nodename) {
+		    values = e;
+		}
+		return false;
+	    });
+
+	    me.lookup('nodeselector').disallowedNodes = configuredNodes;
+	    if (values.path) {
+		values.usb = 'path';
+	    }
+
+	    return values;
+	},
+
+	modeChange: function(field, value) {
+	    let me = this;
+	    let type = field.inputValue;
+	    let usbsel = me.lookup(type);
+	    usbsel.setDisabled(!value);
+	},
+
+	nodeChange: function(_field, value) {
+	    this.lookup('id').setNodename(value);
+	    this.lookup('path').setNodename(value);
+	},
+
+
+	init: function(view) {
+	    let me = this;
+
+	    if (!view.nodename) {
+		//throw "no nodename given";
+	    }
+	},
+
+	control: {
+	    'radiofield': {
+		change: 'modeChange',
+	    },
+	    'pveNodeSelector': {
+		change: 'nodeChange',
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    onGetValues: function(values) {
+		return this.up('window').getController().onGetValues(values);
+	    },
+
+	    onSetValues: function(values) {
+		return this.up('window').getController().onSetValues(values);
+	    },
+
+	    column1: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    fieldLabel: gettext('Name'),
+		    cbind: {
+			editable: '{!name}',
+			value: '{name}',
+			submitValue: '{isCreate}',
+		    },
+		    name: 'name',
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Mapping on Node'),
+		    labelWidth: 120,
+		    name: 'node',
+		    cbind: {
+			value: '{nodename}',
+			disabled: '{hideNode}',
+			hidden: '{hideNode}',
+		    },
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'pveNodeSelector',
+		    reference: 'nodeselector',
+		    fieldLabel: gettext('Mapping on Node'),
+		    labelWidth: 120,
+		    name: 'node',
+		    cbind: {
+			disabled: '{hideNodeSelector}',
+			hidden: '{hideNodeSelector}',
+		    },
+		    allowBlank: false,
+		},
+	    ],
+
+	    column2: [
+		{
+		    xtype: 'fieldcontainer',
+		    defaultType: 'radiofield',
+		    layout: 'fit',
+		    cbind: {
+			disabled: '{hideMapping}',
+			hidden: '{hideMapping}',
+		    },
+		    items: [
+			{
+			    name: 'usb',
+			    inputValue: 'id',
+			    checked: true,
+			    boxLabel: gettext('Use USB Vendor/Device ID'),
+			    submitValue: false,
+			},
+			{
+			    xtype: 'pveUSBSelector',
+			    type: 'device',
+			    reference: 'id',
+			    name: 'id',
+			    cbind: {
+				nodename: '{nodename}',
+				disabled: '{hideMapping}',
+			    },
+			    editable: true,
+			    allowBlank: false,
+			    fieldLabel: gettext('Choose Device'),
+			    labelAlign: 'right',
+			},
+			{
+			    name: 'usb',
+			    inputValue: 'path',
+			    boxLabel: gettext('Use USB Port'),
+			    submitValue: false,
+			},
+			{
+			    xtype: 'pveUSBSelector',
+			    disabled: true,
+			    name: 'path',
+			    reference: 'path',
+			    cbind: {
+				nodename: '{nodename}',
+			    },
+			    editable: true,
+			    type: 'port',
+			    allowBlank: false,
+			    fieldLabel: gettext('Choose Port'),
+			    labelAlign: 'right',
+			},
+		    ],
+		},
+	    ],
+
+	    columnB: [
+		{
+		    xtype: 'proxmoxtextfield',
+		    fieldLabel: gettext('Comment'),
+		    submitValue: true,
+		    name: 'description',
+		    cbind: {
+			disabled: '{hideComment}',
+			hidden: '{hideComment}',
+		    },
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.window.GuestImport', {
+    extend: 'Proxmox.window.Edit', // fixme: Proxmox.window.Edit?
+    alias: 'widget.pveGuestImportWindow',
+
+    title: gettext('Import Guest'),
+
+    width: 720,
+    bodyPadding: 0,
+
+    submitUrl: function() {
+	let me = this;
+	return `/nodes/${me.nodename}/qemu`;
+    },
+
+    isAdd: true,
+    isCreate: true,
+    submitText: gettext('Import'),
+    showTaskViewer: true,
+    method: 'POST',
+
+    loadUrl: function(_url, { storage, nodename, volumeName }) {
+	let args = Ext.Object.toQueryString({ volume: volumeName });
+	return `/nodes/${nodename}/storage/${storage}/import-metadata?${args}`;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	setNodename: function(_column, widget) {
+	    let me = this;
+	    let view = me.getView();
+	    widget.setNodename(view.nodename);
+	},
+
+	diskStorageChange: function(storageSelector, value) {
+	    let me = this;
+
+	    let grid = me.lookup('diskGrid');
+	    let rec = storageSelector.getWidgetRecord();
+	    let validFormats = storageSelector.store.getById(value)?.data.format;
+	    grid.query('pveDiskFormatSelector').some((selector) => {
+		if (selector.getWidgetRecord().data.id !== rec.data.id) {
+		    return false;
+		}
+
+		if (validFormats?.[0]?.qcow2) {
+		    selector.setDisabled(false);
+		    selector.setValue('qcow2');
+		} else {
+		    selector.setValue('raw');
+		    selector.setDisabled(true);
+		}
+
+		return true;
+	    });
+	},
+
+	isoStorageChange: function(storageSelector, value) {
+	    let me = this;
+
+	    let grid = me.lookup('cdGrid');
+	    let rec = storageSelector.getWidgetRecord();
+	    grid.query('pveFileSelector').some((selector) => {
+		if (selector.getWidgetRecord().data.id !== rec.data.id) {
+		    return false;
+		}
+
+		selector.setStorage(value);
+		if (!value) {
+		    selector.setValue('');
+		}
+
+		return true;
+	    });
+	},
+
+	onOSBaseChange: function(_field, value) {
+	    let me = this;
+	    let ostype = me.lookup('ostype');
+	    let store = ostype.getStore();
+	    store.setData(PVE.Utils.kvm_ostypes[value]);
+	    let old_val = ostype.getValue();
+	    if (old_val && store.find('val', old_val) !== -1) {
+		ostype.setValue(old_val);
+	    } else {
+		ostype.setValue(store.getAt(0));
+	    }
+	},
+
+	calculateConfig: function() {
+	    let me = this;
+	    let inputPanel = me.lookup('mainInputPanel');
+	    let summaryGrid = me.lookup('summaryGrid');
+	    let values = inputPanel.getValues();
+	    summaryGrid.getStore().setData(Object.entries(values).map(([key, value]) => ({ key, value })));
+	},
+
+	calculateAdditionalCDIdx: function() {
+	    let me = this;
+
+	    let maxIde = me.getMaxControllerId('ide');
+	    let maxSata = me.getMaxControllerId('sata');
+	    // only ide0 and ide2 can be used reliably for isos (e.g. for q35)
+	    if (maxIde < 0) {
+		return 'ide0';
+	    }
+	    if (maxIde < 2) {
+		return 'ide2';
+	    }
+	    if (maxSata < PVE.Utils.diskControllerMaxIDs.sata - 1) {
+		return `sata${maxSata+1}`;
+	    }
+
+	    return '';
+	},
+
+	// assume assigned sata disks indices are continuous, so without holes
+	getMaxControllerId: function(controller) {
+	    let me = this;
+	    let view = me.getView();
+	    if (!controller) {
+		return -1;
+	    }
+
+	    let max = view[`max${controller}`];
+	    if (max !== undefined) {
+		return max;
+	    }
+
+	    max = -1;
+	    for (const key of Object.keys(me.getView().vmConfig)) {
+		if (!key.toLowerCase().startsWith(controller)) {
+		    continue;
+		}
+		let idx = parseInt(key.slice(controller.length), 10);
+		if (idx > max) {
+		    max = idx;
+		}
+	    }
+	    me.lookup('diskGrid').getStore().each(rec => {
+		if (!rec.data.id.toLowerCase().startsWith(controller)) {
+		    return;
+		}
+		let idx = parseInt(rec.data.id.slice(controller.length), 10);
+		if (idx > max) {
+		    max = idx;
+		}
+	    });
+	    me.lookup('cdGrid').getStore().each(rec => {
+		if (!rec.data.id.toLowerCase().startsWith(controller) || rec.data.hidden) {
+		    return;
+		}
+		let idx = parseInt(rec.data.id.slice(controller.length), 10);
+		if (idx > max) {
+		    max = idx;
+		}
+	    });
+
+	    view[`max${controller}`] = max;
+	    return max;
+	},
+
+	renderDisk: function(value, metaData, record, rowIndex, colIndex, store, tableView) {
+	    let diskGrid = tableView.grid ?? this.lookup('diskGrid');
+	    if (diskGrid.diskMap) {
+		let mappedID = diskGrid.diskMap[value];
+		if (mappedID) {
+		    let prefix = '';
+		    if (mappedID === value) { // mapped to the same value means we ran out of IDs
+			let warning = gettext('Too many disks, could not map to SATA.');
+			prefix = `<i data-qtip="${warning}" class="fa fa-exclamation-triangle warning"></i> `;
+		    }
+		    return `${prefix}${mappedID}`;
+		}
+	    }
+	    return value;
+	},
+
+	refreshGrids: function() {
+	    this.lookup('diskGrid').reconfigure();
+	    this.lookup('cdGrid').reconfigure();
+	    this.lookup('netGrid').reconfigure();
+	},
+
+	onOSTypeChange: function(_cb, value) {
+	    let me = this;
+	    if (!value) {
+		return;
+	    }
+	    let store = me.lookup('cdGrid').getStore();
+	    let collection = store.getData().getSource() ?? store.getData();
+	    let rec = collection.find('autogenerated', true);
+
+	    let isWindows = (value ?? '').startsWith('w');
+	    if (rec) {
+		rec.set('hidden', !isWindows);
+		rec.commit();
+	    }
+	    let prepareVirtio = me.lookup('prepareForVirtIO').getValue();
+	    let defaultScsiHw = me.getView().vmConfig.scsihw ?? '__default__';
+	    me.lookup('scsihw').setValue(prepareVirtio && isWindows ? 'virtio-scsi-single' : defaultScsiHw);
+
+	    me.refreshGrids();
+	},
+
+	onPrepareVirtioChange: function(_cb, value) {
+	    let me = this;
+	    let view = me.getView();
+	    let diskGrid = me.lookup('diskGrid');
+
+	    diskGrid.diskMap = {};
+	    if (value) {
+		const hasAdditionalSataCDROM =
+		    me.getViewModel().get('isWindows') && view.additionalCdIdx?.startsWith('sata');
+
+		diskGrid.getStore().each(rec => {
+		    let diskID = rec.data.id;
+		    if (!diskID.toLowerCase().startsWith('scsi')) {
+			return; // continue
+		    }
+		    let offset = parseInt(diskID.slice(4), 10);
+		    let newIdx = offset + me.getMaxControllerId('sata') + 1;
+		    if (hasAdditionalSataCDROM) {
+			newIdx++;
+		    }
+		    let mappedID = `sata${newIdx}`;
+		    if (newIdx >= PVE.Utils.diskControllerMaxIDs.sata) {
+			mappedID = diskID; // map to self so that the renderer can detect that we're out of IDs
+		    }
+		    diskGrid.diskMap[diskID] = mappedID;
+		});
+	    }
+
+	    let scsihw = me.lookup('scsihw');
+	    scsihw.suspendEvents();
+	    scsihw.setValue(value ? 'virtio-scsi-single' : me.getView().vmConfig.scsihw);
+	    scsihw.resumeEvents();
+
+	    me.refreshGrids();
+	},
+
+	onScsiHwChange: function(_field, value) {
+	    let me = this;
+	    me.getView().vmConfig.scsihw = value;
+	},
+
+	onUniqueMACChange: function(_cb, value) {
+	    let me = this;
+
+	    me.getViewModel().set('uniqueMACAdresses', value);
+
+	    me.lookup('netGrid').reconfigure();
+	},
+
+	renderMacAddress: function(value, metaData, record, rowIndex, colIndex, store, view) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    return !vm.get('uniqueMACAdresses') && value ? value : 'auto';
+	},
+
+	control: {
+	    'grid field': {
+		// update records from widgetcolumns
+		change: function(widget, value) {
+		    let rec = widget.getWidgetRecord();
+		    rec.set(widget.name, value);
+		    rec.commit();
+		},
+	    },
+	    'grid[reference=diskGrid] pveStorageSelector': {
+		change: 'diskStorageChange',
+	    },
+	    'grid[reference=cdGrid] pveStorageSelector': {
+		change: 'isoStorageChange',
+	    },
+	    'field[name=osbase]': {
+		change: 'onOSBaseChange',
+	    },
+	    'panel[reference=summaryTab]': {
+		activate: 'calculateConfig',
+	    },
+	    'proxmoxcheckbox[reference=prepareForVirtIO]': {
+		change: 'onPrepareVirtioChange',
+	    },
+	    'combobox[name=ostype]': {
+		change: 'onOSTypeChange',
+	    },
+	    'pveScsiHwSelector': {
+		change: 'onScsiHwChange',
+	    },
+	    'proxmoxcheckbox[name=uniqueMACs]': {
+		change: 'onUniqueMACChange',
+	    },
+	},
+    },
+
+    viewModel: {
+	data: {
+	    coreCount: 1,
+	    socketCount: 1,
+	    liveImport: false,
+	    os: 'l26',
+	    maxCdDrives: false,
+	    uniqueMACAdresses: false,
+	    warnings: [],
+	},
+
+	formulas: {
+	    totalCoreCount: get => get('socketCount') * get('coreCount'),
+	    hideWarnings: get => get('warnings').length === 0,
+	    warningsText: get => '<ul style="margin: 0; padding-left: 20px;">'
+	        + get('warnings').map(w => `<li>${w}</li>`).join('') + '</ul>',
+	    liveImportNote: get => !get('liveImport') ? ''
+	        : gettext('Note: If anything goes wrong during the live-import, new data written by the VM may be lost.'),
+	    isWindows: get => (get('os') ?? '').startsWith('w'),
+	},
+    },
+
+    items: [{
+	xtype: 'tabpanel',
+	defaults: {
+	    bodyPadding: 10,
+	},
+	items: [
+	    {
+		title: gettext('General'),
+		xtype: 'inputpanel',
+		reference: 'mainInputPanel',
+		onGetValues: function(values) {
+		    let me = this;
+		    let view = me.up('pveGuestImportWindow');
+		    let vm = view.getViewModel();
+		    let diskGrid = view.lookup('diskGrid');
+
+		    // from pveDiskStorageSelector
+		    let defaultStorage = values.hdstorage;
+		    let defaultFormat = values.diskformat;
+		    delete values.hdstorage;
+		    delete values.diskformat;
+
+		    let defaultBridge = values.defaultBridge;
+		    delete values.defaultBridge;
+
+		    let config = { ...view.vmConfig };
+		    Ext.apply(config, values);
+
+		    if (config.scsi0) {
+			config.scsi0 = config.scsi0.replace('local:0,', 'local:0,format=qcow2,');
+		    }
+
+		    let parsedBoot = PVE.Parser.parsePropertyString(config.boot ?? '');
+		    if (parsedBoot.order) {
+			parsedBoot.order = parsedBoot.order.split(';');
+		    }
+
+		    let diskMap = diskGrid.diskMap ?? {};
+		    diskGrid.getStore().each(rec => {
+			if (!rec.data.enable) {
+			    return;
+			}
+			let id = diskMap[rec.data.id] ?? rec.data.id;
+			if (id !== rec.data.id && parsedBoot?.order) {
+			    let idx = parsedBoot.order.indexOf(rec.data.id);
+			    if (idx !== -1) {
+				parsedBoot.order[idx] = id;
+			    }
+			}
+			let data = {
+			    ...rec.data,
+			};
+			delete data.enable;
+			delete data.id;
+			delete data.size;
+			if (!data.file) {
+			    data.file = defaultStorage;
+			    data.format = defaultFormat;
+			}
+			data.file += ':0'; // for our special api format
+			if (id === 'efidisk0') {
+			    delete data['import-from'];
+			}
+			config[id] = PVE.Parser.printQemuDrive(data);
+		    });
+
+		    if (parsedBoot.order) {
+			parsedBoot.order = parsedBoot.order.join(';');
+		    }
+		    config.boot = PVE.Parser.printPropertyString(parsedBoot);
+
+		    view.lookup('netGrid').getStore().each((rec) => {
+			if (!rec.data.enable) {
+			    return;
+			}
+			let id = rec.data.id;
+			let data = {
+			    ...rec.data,
+			};
+			delete data.enable;
+			delete data.id;
+			if (!data.bridge) {
+			    data.bridge = defaultBridge;
+			}
+			if (vm.get('uniqueMACAdresses')) {
+			    data.macaddr = undefined;
+			}
+			config[id] = PVE.Parser.printQemuNetwork(data);
+		    });
+
+		    view.lookup('cdGrid').getStore().each((rec) => {
+			if (!rec.data.enable) {
+			    return;
+			}
+			let id = rec.data.id;
+			let cd = {
+			    media: 'cdrom',
+			    file: rec.data.file ? rec.data.file : 'none',
+			};
+			config[id] = PVE.Parser.printPropertyString(cd);
+		    });
+
+		    config.scsihw = view.lookup('scsihw').getValue();
+
+		    if (view.lookup('liveimport').getValue()) {
+			config['live-restore'] = 1;
+		    }
+
+		    // remove __default__ values
+		    for (const [key, value] of Object.entries(config)) {
+			if (value === '__default__') {
+			    delete config[key];
+			}
+		    }
+
+		    return config;
+		},
+
+		column1: [
+		    {
+			xtype: 'pveGuestIDSelector',
+			name: 'vmid',
+			fieldLabel: 'VM',
+			guestType: 'qemu',
+			loadNextFreeID: true,
+			validateExists: false,
+		    },
+		    {
+			xtype: 'proxmoxintegerfield',
+			fieldLabel: gettext('Sockets'),
+			name: 'sockets',
+			reference: 'socketsField',
+			value: 1,
+			minValue: 1,
+			maxValue: 128,
+			allowBlank: true,
+			bind: {
+			    value: '{socketCount}',
+			},
+		    },
+		    {
+			xtype: 'proxmoxintegerfield',
+			fieldLabel: gettext('Cores'),
+			name: 'cores',
+			reference: 'coresField',
+			value: 1,
+			minValue: 1,
+			maxValue: 1024,
+			allowBlank: true,
+			bind: {
+			    value: '{coreCount}',
+			},
+		    },
+		    {
+			xtype: 'pveMemoryField',
+			fieldLabel: gettext('Memory') + ' (MiB)',
+			name: 'memory',
+			reference: 'memoryField',
+			value: 512,
+			allowBlank: true,
+		    },
+		    { xtype: 'displayfield' }, // spacer
+		    { xtype: 'displayfield' }, // spacer
+		    {
+			xtype: 'pveDiskStorageSelector',
+			reference: 'defaultStorage',
+			storageLabel: gettext('Default Storage'),
+			storageContent: 'images',
+			autoSelect: true,
+			hideSize: true,
+			name: 'defaultStorage',
+		    },
+		],
+
+		column2: [
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Name'),
+			name: 'name',
+			vtype: 'DnsName',
+			reference: 'nameField',
+			allowBlank: true,
+		    },
+		    {
+			xtype: 'CPUModelSelector',
+			name: 'cpu',
+			reference: 'cputype',
+			value: 'x86-64-v2-AES',
+			fieldLabel: gettext('CPU Type'),
+		    },
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('Total cores'),
+			name: 'totalcores',
+			isFormField: false,
+			bind: {
+			    value: '{totalCoreCount}',
+			},
+		    },
+		    {
+			xtype: 'combobox',
+			submitValue: false,
+			name: 'osbase',
+			fieldLabel: gettext('OS Type'),
+			editable: false,
+			queryMode: 'local',
+			value: 'Linux',
+			store: Object.keys(PVE.Utils.kvm_ostypes),
+		    },
+		    {
+			xtype: 'combobox',
+			name: 'ostype',
+			reference: 'ostype',
+			fieldLabel: gettext('Version'),
+			value: 'l26',
+			allowBlank: false,
+			editable: false,
+			queryMode: 'local',
+			valueField: 'val',
+			displayField: 'desc',
+			bind: {
+			    value: '{os}',
+			},
+			store: {
+			    fields: ['desc', 'val'],
+			    data: PVE.Utils.kvm_ostypes.Linux,
+			},
+		    },
+		    { xtype: 'displayfield' }, // spacer
+		    {
+			xtype: 'PVE.form.BridgeSelector',
+			reference: 'defaultBridge',
+			name: 'defaultBridge',
+			allowBlank: false,
+			fieldLabel: gettext('Default Bridge'),
+		    },
+		],
+
+		columnB: [
+		    {
+			xtype: 'proxmoxcheckbox',
+			fieldLabel: gettext('Live Import'),
+			reference: 'liveimport',
+			isFormField: false,
+			boxLabel: gettext('Starts a previously stopped VM on Proxmox VE and imports the disks in the background.'),
+			bind: {
+			    value: '{liveImport}',
+			},
+		    },
+		    {
+			xtype: 'displayfield',
+			userCls: 'pmx-hint black',
+			value: gettext('Note: If anything goes wrong during the live-import, new data written by the VM may be lost.'),
+			bind: {
+			    hidden: '{!liveImport}',
+			},
+		    },
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('Warnings'),
+			labelWidth: 200,
+			hidden: true,
+			bind: {
+			    hidden: '{hideWarnings}',
+			},
+		    },
+		    {
+			xtype: 'displayfield',
+			reference: 'warningText',
+			userCls: 'pmx-hint',
+			hidden: true,
+			bind: {
+			    hidden: '{hideWarnings}',
+			    value: '{warningsText}',
+			},
+		    },
+		],
+	    },
+	    {
+		title: gettext('Advanced'),
+		xtype: 'inputpanel',
+
+		// the first inputpanel handles all values, so prevent value leakage here
+		onGetValues: () => ({}),
+
+		columnT: [
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('Disks'),
+			labelWidth: 200,
+		    },
+		    {
+			xtype: 'grid',
+			reference: 'diskGrid',
+			minHeight: 60,
+			maxHeight: 150,
+			store: {
+			    data: [],
+			    sorters: [
+				'id',
+			    ],
+			},
+			columns: [
+			    {
+				xtype: 'checkcolumn',
+				header: gettext('Use'),
+				width: 50,
+				dataIndex: 'enable',
+				listeners: {
+				    checkchange: function(_column, _rowIndex, _checked, record) {
+					record.commit();
+				    },
+				},
+			    },
+			    {
+				text: gettext('Disk'),
+				dataIndex: 'id',
+				renderer: 'renderDisk',
+			    },
+			    {
+				text: gettext('Source'),
+				dataIndex: 'import-from',
+				flex: 1,
+				renderer: function(value) {
+				    return value.replace(/^.*\//, '');
+				},
+			    },
+			    {
+				text: gettext('Size'),
+				dataIndex: 'size',
+				renderer: (value) => {
+				    if (Ext.isNumeric(value)) {
+					return Proxmox.Utils.render_size(value);
+				    }
+				    return value ?? Proxmox.Utils.unknownText;
+				},
+			    },
+			    {
+				text: gettext('Storage'),
+				dataIndex: 'file',
+				xtype: 'widgetcolumn',
+				width: 150,
+				widget: {
+				    xtype: 'pveStorageSelector',
+				    isFormField: false,
+				    autoSelect: false,
+				    allowBlank: true,
+				    emptyText: gettext('From Default'),
+				    name: 'file',
+				    storageContent: 'images',
+				},
+				onWidgetAttach: 'setNodename',
+			    },
+			    {
+				text: gettext('Format'),
+				dataIndex: 'format',
+				xtype: 'widgetcolumn',
+				width: 150,
+				widget: {
+				    xtype: 'pveDiskFormatSelector',
+				    name: 'format',
+				    disabled: true,
+				    isFormField: false,
+				    matchFieldWidth: false,
+				},
+			    },
+			],
+		    },
+		],
+
+		column1: [
+		    {
+			xtype: 'proxmoxcheckbox',
+			boxLabel: gettext('Prepare for VirtIO-SCSI'),
+			reference: 'prepareForVirtIO',
+			name: 'prepareForVirtIO',
+			submitValue: false,
+			disabled: true,
+			bind: {
+			    disabled: '{!isWindows}',
+			},
+			autoEl: {
+			    tag: 'div',
+			    'data-qtip': gettext('Maps SCSI disks to SATA and changes the SCSI Controller. Useful for a quicker switch to VirtIO-SCSI attached disks'),
+			},
+		    },
+		],
+
+		column2: [
+		    {
+			xtype: 'pveScsiHwSelector',
+			reference: 'scsihw',
+			name: 'scsihw',
+			value: '__default__',
+			submitValue: false,
+			fieldLabel: gettext('SCSI Controller'),
+		    },
+		],
+
+		columnB: [
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('CD/DVD Drives'),
+			labelWidth: 200,
+		    },
+		    {
+			xtype: 'grid',
+			reference: 'cdGrid',
+			minHeight: 60,
+			maxHeight: 150,
+			store: {
+			    data: [],
+			    sorters: [
+				'id',
+			    ],
+			    filters: [
+				function(rec) {
+				    return !rec.data.hidden;
+				},
+			    ],
+			},
+			columns: [
+			    {
+				xtype: 'checkcolumn',
+				header: gettext('Use'),
+				width: 50,
+				dataIndex: 'enable',
+				listeners: {
+				    checkchange: function(_column, _rowIndex, _checked, record) {
+					record.commit();
+				    },
+				},
+			    },
+			    {
+				text: gettext('Slot'),
+				dataIndex: 'id',
+				sorted: true,
+			    },
+			    {
+				text: gettext('Storage'),
+				xtype: 'widgetcolumn',
+				width: 150,
+				widget: {
+				    xtype: 'pveStorageSelector',
+				    isFormField: false,
+				    autoSelect: false,
+				    allowBlank: true,
+				    emptyText: Proxmox.Utils.noneText,
+				    storageContent: 'iso',
+				},
+				onWidgetAttach: 'setNodename',
+			    },
+			    {
+				text: gettext('ISO'),
+				dataIndex: 'file',
+				xtype: 'widgetcolumn',
+				flex: 1,
+				widget: {
+				    xtype: 'pveFileSelector',
+				    name: 'file',
+				    isFormField: false,
+				    allowBlank: true,
+				    emptyText: Proxmox.Utils.noneText,
+				    storageContent: 'iso',
+				},
+				onWidgetAttach: 'setNodename',
+			    },
+			],
+		    },
+		    {
+			xtype: 'displayfield',
+			fieldLabel: gettext('Network Interfaces'),
+			labelWidth: 200,
+			style: {
+			    paddingTop: '10px',
+			},
+		    },
+		    {
+			xtype: 'grid',
+			minHeight: 58,
+			maxHeight: 150,
+			reference: 'netGrid',
+			store: {
+			    data: [],
+			    sorters: [
+				'id',
+			    ],
+			},
+			columns: [
+			    {
+				xtype: 'checkcolumn',
+				header: gettext('Use'),
+				width: 50,
+				dataIndex: 'enable',
+				listeners: {
+				    checkchange: function(_column, _rowIndex, _checked, record) {
+					record.commit();
+				    },
+				},
+			    },
+			    {
+				text: gettext('ID'),
+				dataIndex: 'id',
+			    },
+			    {
+				text: gettext('MAC address'),
+				flex: 7,
+				dataIndex: 'macaddr',
+				renderer: 'renderMacAddress',
+			    },
+			    {
+				text: gettext('Model'),
+				flex: 7,
+				dataIndex: 'model',
+				xtype: 'widgetcolumn',
+				widget: {
+				    xtype: 'pveNetworkCardSelector',
+				    name: 'model',
+				    isFormField: false,
+				    allowBlank: false,
+				},
+			    },
+			    {
+				text: gettext('Bridge'),
+				dataIndex: 'bridge',
+				xtype: 'widgetcolumn',
+				flex: 6,
+				widget: {
+				    xtype: 'PVE.form.BridgeSelector',
+				    name: 'bridge',
+				    isFormField: false,
+				    autoSelect: false,
+				    allowBlank: true,
+				    emptyText: gettext('From Default'),
+				},
+				onWidgetAttach: 'setNodename',
+			    },
+			    {
+				text: gettext('VLAN Tag'),
+				dataIndex: 'tag',
+				xtype: 'widgetcolumn',
+				flex: 5,
+				widget: {
+				    xtype: 'pveVlanField',
+				    fieldLabel: undefined,
+				    name: 'tag',
+				    isFormField: false,
+				    allowBlank: true,
+				},
+			    },
+			],
+		    },
+		    {
+			xtype: 'proxmoxcheckbox',
+			name: 'uniqueMACs',
+			boxLabel: gettext('Unique MAC addresses'),
+			uncheckedValue: false,
+			value: false,
+		    },
+		],
+	    },
+	    {
+		title: gettext('Resulting Config'),
+		reference: 'summaryTab',
+		items: [
+		    {
+			xtype: 'grid',
+			reference: 'summaryGrid',
+			maxHeight: 400,
+			scrollable: true,
+			store: {
+			    model: 'KeyValue',
+			    sorters: [{
+				property: 'key',
+				direction: 'ASC',
+			    }],
+			},
+			columns: [
+			    { header: 'Key', width: 150, dataIndex: 'key' },
+			    { header: 'Value', flex: 1, dataIndex: 'value' },
+			],
+		    },
+		],
+	    },
+	],
+    }],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.volumeName) {
+	    throw "no volumeName given";
+	}
+
+	if (!me.storage) {
+	    throw "no storage given";
+	}
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	me.callParent();
+
+	me.setTitle(Ext.String.format(gettext('Import Guest - {0}'), `${me.storage}:${me.volumeName}`));
+
+	me.lookup('defaultStorage').setNodename(me.nodename);
+	me.lookup('defaultBridge').setNodename(me.nodename);
+
+	let renderWarning = w => {
+	    const warningsCatalogue = {
+		'cdrom-image-ignored': gettext("CD-ROM images cannot get imported, if required you can reconfigure the '{0}' drive in the 'Advanced' tab."),
+		'nvme-unsupported': gettext("NVMe disks are currently not supported, '{0}' will get attaced as SCSI"),
+		'ovmf-with-lsi-unsupported': gettext("OVMF is built without LSI drivers, scsi hardware was set to '{1}'"),
+		'serial-port-socket-only': gettext("Serial socket '{0}' will be mapped to a socket"),
+		'guest-is-running': gettext('Virtual guest seems to be running on source host. Import might fail or have inconsistent state!'),
+		'efi-state-lost': Ext.String.format(
+		    gettext('EFI state cannot be imported, you may need to reconfigure the boot order (see {0})'),
+		    '<a href="https://pve.proxmox.com/wiki/OVMF/UEFI_Boot_Entries">OVMF/UEFI Boot Entries</a>',
+		),
+	    };
+            let message = warningsCatalogue[w.type];
+	    if (!w.type || !message) {
+		return w.message ?? w.type ?? gettext('Unknown warning');
+	    }
+	    return Ext.String.format(message, w.key ?? 'unknown', w.value ?? 'unknown');
+	};
+
+	me.load({
+	    success: function(response) {
+		let data = response.result.data;
+		me.vmConfig = data['create-args'];
+
+		let disks = [];
+		for (const [id, value] of Object.entries(data.disks ?? {})) {
+		    let volid = Ext.htmlEncode('<none>');
+		    let size = 'auto';
+		    if (Ext.isObject(value)) {
+			volid = value.volid;
+			size = value.size;
+		    }
+		    disks.push({
+			id,
+			enable: true,
+			size,
+			'import-from': volid,
+			format: 'raw',
+		    });
+		}
+
+		let nets = [];
+		for (const [id, parsed] of Object.entries(data.net ?? {})) {
+		    parsed.id = id;
+		    parsed.enable = true;
+		    nets.push(parsed);
+		}
+
+		let cdroms = [];
+		for (const [id, value] of Object.entries(me.vmConfig)) {
+		    if (!Ext.isString(value) || !value.match(/media=cdrom/)) {
+			continue;
+		    }
+		    cdroms.push({
+			enable: true,
+			hidden: false,
+			id,
+		    });
+		    delete me.vmConfig[id];
+		}
+
+		me.lookup('diskGrid').getStore().setData(disks);
+		me.lookup('netGrid').getStore().setData(nets);
+		me.lookup('cdGrid').getStore().setData(cdroms);
+
+		let additionalCdIdx = me.getController().calculateAdditionalCDIdx();
+		if (additionalCdIdx === '') {
+		    me.getViewModel().set('maxCdDrives', true);
+		} else if (cdroms.length === 0) {
+		    me.additionalCdIdx = additionalCdIdx;
+		    me.lookup('cdGrid').getStore().add({
+			enable: true,
+			hidden: !(me.vmConfig.ostype ?? '').startsWith('w'),
+			id: additionalCdIdx,
+			autogenerated: true,
+		    });
+		}
+
+		me.getViewModel().set('warnings', data.warnings.map(w => renderWarning(w)));
+
+		let osinfo = PVE.Utils.get_kvm_osinfo(me.vmConfig.ostype ?? '');
+		let prepareForVirtIO = (me.vmConfig.ostype ?? '').startsWith('w') && (me.vmConfig.bios ?? '').indexOf('ovmf') !== -1;
+
+		me.setValues({
+		    osbase: osinfo.base,
+		    ...me.vmConfig,
+		});
+
+
+		me.lookup('prepareForVirtIO').setValue(prepareForVirtIO);
+	    },
+	});
+    },
+});
+Ext.define('PVE.ha.FencingView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveFencingView'],
+
+    onlineHelp: 'ha_manager_fencing',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-ha-fencing',
+	    data: [],
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: false,
+	    viewConfig: {
+		trackOver: false,
+		deferEmptyText: false,
+		emptyText: gettext('Use watchdog based fencing.'),
+	    },
+	    columns: [
+		{
+		    header: gettext('Node'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'node',
+		},
+		{
+		    header: gettext('Command'),
+		    flex: 1,
+		    dataIndex: 'command',
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-ha-fencing', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'node', 'command', 'digest',
+	],
+    });
+});
+Ext.define('PVE.ha.GroupInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    onlineHelp: 'ha_manager_groups',
+
+    groupId: undefined,
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = 'group';
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	let update_nodefield, update_node_selection;
+
+	let sm = Ext.create('Ext.selection.CheckboxModel', {
+	    mode: 'SIMPLE',
+	    listeners: {
+		selectionchange: function(model, selected) {
+		    update_nodefield(selected);
+		},
+	    },
+	});
+
+	let store = Ext.create('Ext.data.Store', {
+	    fields: ['node', 'mem', 'cpu', 'priority'],
+	    data: PVE.data.ResourceStore.getNodes(), // use already cached data to avoid an API call
+	    proxy: {
+		type: 'memory',
+		reader: { type: 'json' },
+	    },
+	    sorters: [
+		{
+		    property: 'node',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+
+	var nodegrid = Ext.createWidget('grid', {
+	    store: store,
+	    border: true,
+	    height: 300,
+	    selModel: sm,
+	    columns: [
+		{
+		    header: gettext('Node'),
+		    flex: 1,
+		    dataIndex: 'node',
+		},
+		{
+		    header: gettext('Memory usage') + " %",
+		    renderer: PVE.Utils.render_mem_usage_percent,
+		    sortable: true,
+		    width: 150,
+		    dataIndex: 'mem',
+		},
+		{
+		    header: gettext('CPU usage'),
+		    renderer: Proxmox.Utils.render_cpu,
+		    sortable: true,
+		    width: 150,
+		    dataIndex: 'cpu',
+		},
+		{
+		    header: gettext('Priority'),
+		    xtype: 'widgetcolumn',
+		    dataIndex: 'priority',
+		    sortable: true,
+		    stopSelection: true,
+		    widget: {
+			xtype: 'proxmoxintegerfield',
+			minValue: 0,
+			maxValue: 1000,
+			isFormField: false,
+			listeners: {
+			    change: function(numberfield, value, old_value) {
+				let record = numberfield.getWidgetRecord();
+				record.set('priority', value);
+				update_nodefield(sm.getSelection());
+				record.commit();
+			    },
+			},
+		    },
+		},
+	    ],
+	});
+
+	let nodefield = Ext.create('Ext.form.field.Hidden', {
+	    name: 'nodes',
+	    value: '',
+	    listeners: {
+		change: function(field, value) {
+		    update_node_selection(value);
+		},
+	    },
+	    isValid: function() {
+		let value = this.getValue();
+		return value && value.length !== 0;
+	    },
+	});
+
+	update_node_selection = function(string) {
+	    sm.deselectAll(true);
+
+	    string.split(',').forEach(function(e, idx, array) {
+		let [node, priority] = e.split(':');
+		store.each(function(record) {
+		    if (record.get('node') === node) {
+			sm.select(record, true);
+			record.set('priority', priority);
+			record.commit();
+		    }
+		});
+	    });
+	    nodegrid.reconfigure(store);
+	};
+
+	update_nodefield = function(selected) {
+	    let nodes = selected
+		.map(({ data }) => data.node + (data.priority ? `:${data.priority}` : ''))
+		.join(',');
+
+	    // nodefield change listener calls us again, which results in a
+	    // endless recursion, suspend the event temporary to avoid this
+	    nodefield.suspendEvent('change');
+	    nodefield.setValue(nodes);
+	    nodefield.resumeEvent('change');
+	};
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'group',
+		value: me.groupId || '',
+		fieldLabel: 'ID',
+		vtype: 'StorageId',
+		allowBlank: false,
+	    },
+	    nodefield,
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'restricted',
+		uncheckedValue: 0,
+		fieldLabel: 'restricted',
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'nofailback',
+		uncheckedValue: 0,
+		fieldLabel: 'nofailback',
+	    },
+	];
+
+	me.columnB = [
+	    {
+		xtype: 'textfield',
+		name: 'comment',
+		fieldLabel: gettext('Comment'),
+	    },
+	    nodegrid,
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.ha.GroupEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    groupId: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.groupId;
+
+	if (me.isCreate) {
+            me.url = '/api2/extjs/cluster/ha/groups';
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs/cluster/ha/groups/' + me.groupId;
+            me.method = 'PUT';
+        }
+
+	var ipanel = Ext.create('PVE.ha.GroupInputPanel', {
+	    isCreate: me.isCreate,
+	    groupId: me.groupId,
+	});
+
+	Ext.apply(me, {
+            subject: gettext('HA Group'),
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.ha.GroupSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveHAGroupSelector'],
+
+    autoSelect: false,
+    valueField: 'group',
+    displayField: 'group',
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Group'),
+		width: 100,
+		sortable: true,
+		dataIndex: 'group',
+	    },
+	    {
+		header: gettext('Nodes'),
+		width: 100,
+		sortable: false,
+		dataIndex: 'nodes',
+	    },
+	    {
+		header: gettext('Comment'),
+		flex: 1,
+		dataIndex: 'comment',
+		renderer: Ext.String.htmlEncode,
+	    },
+	],
+    },
+    store: {
+	    model: 'pve-ha-groups',
+	    sorters: {
+		property: 'group',
+		direction: 'ASC',
+	    },
+    },
+
+    initComponent: function() {
+	var me = this;
+	me.callParent();
+	me.getStore().load();
+    },
+
+}, function() {
+    Ext.define('pve-ha-groups', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'group', 'type', 'digest', 'nodes', 'comment',
+	    {
+		name: 'restricted',
+		type: 'boolean',
+	    },
+	    {
+		name: 'nofailback',
+		type: 'boolean',
+	    },
+	],
+	proxy: {
+            type: 'proxmox',
+	    url: "/api2/json/cluster/ha/groups",
+	},
+	idProperty: 'group',
+    });
+});
+Ext.define('PVE.ha.GroupsView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveHAGroupsView'],
+
+    onlineHelp: 'ha_manager_groups',
+
+    stateful: true,
+    stateId: 'grid-ha-groups',
+
+    initComponent: function() {
+	var me = this;
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var store = new Ext.data.Store({
+	    model: 'pve-ha-groups',
+	    sorters: {
+		property: 'group',
+		direction: 'ASC',
+	    },
+	});
+
+	var reload = function() {
+	    store.load();
+	};
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+            Ext.create('PVE.ha.GroupEdit', {
+                groupId: rec.data.group,
+		listeners: {
+		    destroy: () => store.load(),
+		},
+		autoShow: true,
+            });
+	};
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/ha/groups/',
+	    callback: () => store.load(),
+	});
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Create'),
+		    disabled: !caps.nodes['Sys.Console'],
+		    handler: function() {
+			Ext.create('PVE.ha.GroupEdit', {
+			    listeners: {
+				destroy: () => store.load(),
+			    },
+			    autoShow: true,
+			});
+		    },
+		},
+		edit_btn,
+		remove_btn,
+	    ],
+	    columns: [
+		{
+		    header: gettext('Group'),
+		    width: 150,
+		    sortable: true,
+		    dataIndex: 'group',
+		},
+		{
+		    header: 'restricted',
+		    width: 100,
+		    sortable: true,
+		    renderer: Proxmox.Utils.format_boolean,
+		    dataIndex: 'restricted',
+		},
+		{
+		    header: 'nofailback',
+		    width: 100,
+		    sortable: true,
+		    renderer: Proxmox.Utils.format_boolean,
+		    dataIndex: 'nofailback',
+		},
+		{
+		    header: gettext('Nodes'),
+		    flex: 1,
+		    sortable: false,
+		    dataIndex: 'nodes',
+		},
+		{
+		    header: gettext('Comment'),
+		    flex: 1,
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'comment',
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		beforeselect: (grid, record, index, eOpts) => caps.nodes['Sys.Console'],
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.ha.VMResourceInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    onlineHelp: 'ha_manager_resource_config',
+    vmid: undefined,
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (values.vmid) {
+	    values.sid = values.vmid;
+	}
+	delete values.vmid;
+
+	PVE.Utils.delete_if_default(values, 'group', '', me.isCreate);
+	PVE.Utils.delete_if_default(values, 'max_restart', '1', me.isCreate);
+	PVE.Utils.delete_if_default(values, 'max_relocate', '1', me.isCreate);
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+	var MIN_QUORUM_VOTES = 3;
+
+	var disabledHint = Ext.createWidget({
+	    xtype: 'displayfield', // won't get submitted by default
+	    userCls: 'pmx-hint',
+	    value: 'Disabling the resource will stop the guest system. ' +
+	    'See the online help for details.',
+	    hidden: true,
+	});
+
+	var fewVotesHint = Ext.createWidget({
+	    itemId: 'fewVotesHint',
+	    xtype: 'displayfield',
+	    userCls: 'pmx-hint',
+	    value: 'At least three quorum votes are recommended for reliable HA.',
+	    hidden: true,
+	});
+
+	Proxmox.Utils.API2Request({
+	    url: '/cluster/config/nodes',
+	    method: 'GET',
+	    failure: function(response) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response) {
+		var nodes = response.result.data;
+		var votes = 0;
+		Ext.Array.forEach(nodes, function(node) {
+		    var vote = parseInt(node.quorum_votes, 10); // parse as base 10
+		    votes += vote || 0; // parseInt might return NaN, which is false
+		});
+
+		if (votes < MIN_QUORUM_VOTES) {
+		    fewVotesHint.setVisible(true);
+		}
+	    },
+	});
+
+	var vmidStore = me.vmid ? {} : {
+	    model: 'PVEResources',
+	    autoLoad: true,
+	    sorters: 'vmid',
+	    filters: [
+		{
+		    property: 'type',
+		    value: /lxc|qemu/,
+		},
+		{
+		    property: 'hastate',
+		    value: /unmanaged/,
+		},
+	    ],
+	};
+
+	// value is a string above, but a number below
+	me.column1 = [
+	    {
+		xtype: me.vmid ? 'displayfield' : 'vmComboSelector',
+		submitValue: me.isCreate,
+		name: 'vmid',
+		fieldLabel: me.vmid && me.guestType === 'ct' ? 'CT' : 'VM',
+		value: me.vmid,
+		store: vmidStore,
+		validateExists: true,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'max_restart',
+		fieldLabel: gettext('Max. Restart'),
+		value: 1,
+		minValue: 0,
+		maxValue: 10,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'max_relocate',
+		fieldLabel: gettext('Max. Relocate'),
+		value: 1,
+		minValue: 0,
+		maxValue: 10,
+		allowBlank: false,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'pveHAGroupSelector',
+		name: 'group',
+		fieldLabel: gettext('Group'),
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'state',
+		value: 'started',
+		fieldLabel: gettext('Request State'),
+		comboItems: [
+		    ['started', 'started'],
+		    ['stopped', 'stopped'],
+		    ['ignored', 'ignored'],
+		    ['disabled', 'disabled'],
+		],
+		listeners: {
+		    'change': function(field, newValue) {
+			if (newValue === 'disabled') {
+			    disabledHint.setVisible(true);
+			} else if (disabledHint.isVisible()) {
+				disabledHint.setVisible(false);
+			    }
+		    },
+		},
+	    },
+	    disabledHint,
+	];
+
+	me.columnB = [
+	    {
+		xtype: 'textfield',
+		name: 'comment',
+		fieldLabel: gettext('Comment'),
+	    },
+	    fewVotesHint,
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.ha.VMResourceEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    vmid: undefined,
+    guestType: undefined,
+    isCreate: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	if (me.isCreate === undefined) {
+	    me.isCreate = !me.vmid;
+	}
+
+	if (me.isCreate) {
+            me.url = '/api2/extjs/cluster/ha/resources';
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs/cluster/ha/resources/' + me.vmid;
+            me.method = 'PUT';
+        }
+
+	var ipanel = Ext.create('PVE.ha.VMResourceInputPanel', {
+	    isCreate: me.isCreate,
+	    vmid: me.vmid,
+	    guestType: me.guestType,
+	});
+
+	Ext.apply(me, {
+	    subject: gettext('Resource') + ': ' + gettext('Container') +
+	    '/' + gettext('Virtual Machine'),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+
+		    var regex = /^(\S+):(\S+)$/;
+		    var res = regex.exec(values.sid);
+
+		    if (res[1] !== 'vm' && res[1] !== 'ct') {
+			throw "got unexpected resource type";
+		    }
+
+		    values.vmid = res[2];
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.ha.ResourcesView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveHAResourcesView'],
+
+    onlineHelp: 'ha_manager_resources',
+
+    stateful: true,
+    stateId: 'grid-ha-resources',
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.rstore) {
+	    throw "no store given";
+	}
+
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+	let store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: me.rstore,
+	    filters: {
+		property: 'type',
+		value: 'service',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    let sid = rec.data.sid;
+
+	    let res = sid.match(/^(\S+):(\S+)$/);
+	    if (!res || (res[1] !== 'vm' && res[1] !== 'ct')) {
+		console.warn(`unknown HA service ID type ${sid}`);
+		return;
+	    }
+	    let [, guestType, vmid] = res;
+	    Ext.create('PVE.ha.VMResourceEdit', {
+		guestType: guestType,
+		vmid: vmid,
+		listeners: {
+		    destroy: () => me.rstore.load(),
+		},
+		autoShow: true,
+            });
+	};
+
+	let caps = Ext.state.Manager.get('GuiCap');
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    disabled: !caps.nodes['Sys.Console'],
+		    handler: function() {
+			Ext.create('PVE.ha.VMResourceEdit', {
+			    listeners: {
+				destroy: () => me.rstore.load(),
+			    },
+			    autoShow: true,
+			});
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Edit'),
+		    disabled: true,
+		    selModel: sm,
+		    handler: run_editor,
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    selModel: sm,
+		    getUrl: function(rec) {
+			return `/cluster/ha/resources/${rec.get('sid')}`;
+		    },
+		    callback: () => me.rstore.load(),
+		},
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'sid',
+		},
+		{
+		    header: gettext('State'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'state',
+		},
+		{
+		    header: gettext('Node'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'node',
+		},
+		{
+		    header: gettext('Request State'),
+		    width: 100,
+		    hidden: true,
+		    sortable: true,
+		    renderer: v => v || 'started',
+		    dataIndex: 'request_state',
+		},
+		{
+		    header: gettext('CRM State'),
+		    width: 100,
+		    hidden: true,
+		    sortable: true,
+		    dataIndex: 'crm_state',
+		},
+		{
+		    header: gettext('Name'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'vname',
+		},
+		{
+		    header: gettext('Max. Restart'),
+		    width: 100,
+		    sortable: true,
+		    renderer: (v) => v === undefined ? '1' : v,
+		    dataIndex: 'max_restart',
+		},
+		{
+		    header: gettext('Max. Relocate'),
+		    width: 100,
+		    sortable: true,
+		    renderer: (v) => v === undefined ? '1' : v,
+		    dataIndex: 'max_relocate',
+		},
+		{
+		    header: gettext('Group'),
+		    width: 200,
+		    sortable: true,
+		    renderer: function(value, metaData, { data }) {
+			if (data.errors && data.errors.group) {
+			    metaData.tdCls = 'proxmox-invalid-row';
+			    let html = `<p>${Ext.htmlEncode(data.errors.group)}</p>`;
+			    metaData.tdAttr = 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + html + '"';
+			}
+			return value;
+		    },
+		    dataIndex: 'group',
+		},
+		{
+		    header: gettext('Description'),
+		    flex: 1,
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'comment',
+		},
+	    ],
+	    listeners: {
+		beforeselect: (grid, record, index, eOpts) => caps.nodes['Sys.Console'],
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.ha.Status', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveHAStatus',
+
+    onlineHelp: 'chapter_ha_manager',
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.rstore = Ext.create('Proxmox.data.ObjectStore', {
+	    interval: me.interval,
+	    model: 'pve-ha-status',
+	    storeid: 'pve-store-' + ++Ext.idSeed,
+	    groupField: 'type',
+	    proxy: {
+                type: 'proxmox',
+		url: '/api2/json/cluster/ha/status/current',
+	    },
+	});
+
+	me.items = [{
+	    xtype: 'pveHAStatusView',
+	    title: gettext('Status'),
+	    rstore: me.rstore,
+	    border: 0,
+	    collapsible: true,
+	    padding: '0 0 20 0',
+	}, {
+	    xtype: 'pveHAResourcesView',
+	    flex: 1,
+	    collapsible: true,
+	    title: gettext('Resources'),
+	    border: 0,
+	    rstore: me.rstore,
+	}];
+
+	me.callParent();
+	me.on('activate', me.rstore.startUpdate);
+    },
+});
+Ext.define('PVE.ha.StatusView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveHAStatusView'],
+
+    onlineHelp: 'chapter_ha_manager',
+
+    sortPriority: {
+	quorum: 1,
+	master: 2,
+	lrm: 3,
+	service: 4,
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.rstore) {
+	    throw "no rstore given";
+	}
+
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+
+	var store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: me.rstore,
+	    sortAfterUpdate: true,
+	    sorters: [{
+		sorterFn: function(rec1, rec2) {
+		    var p1 = me.sortPriority[rec1.data.type];
+		    var p2 = me.sortPriority[rec2.data.type];
+		    return p1 !== p2 ? p1 > p2 ? 1 : -1 : 0;
+		},
+	    }],
+	    filters: {
+		property: 'type',
+		value: 'service',
+		operator: '!=',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: false,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: gettext('Type'),
+		    width: 80,
+		    dataIndex: 'type',
+		},
+		{
+		    header: gettext('Status'),
+		    width: 80,
+		    flex: 1,
+		    dataIndex: 'status',
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+    },
+}, function() {
+    Ext.define('pve-ha-status', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'id', 'type', 'node', 'status', 'sid',
+	    'state', 'group', 'comment',
+	    'max_restart', 'max_relocate', 'type',
+	    'crm_state', 'request_state',
+	    {
+		name: 'vname',
+		convert: function(value, record) {
+		    let sid = record.data.sid;
+		    if (!sid) return '';
+
+		    let res = sid.match(/^(\S+):(\S+)$/);
+		    if (res[1] !== 'vm' && res[1] !== 'ct') {
+			return '-';
+		    }
+		    let vmid = res[2];
+		    return PVE.data.ResourceStore.guestName(vmid);
+		},
+	    },
+	],
+	idProperty: 'id',
+    });
+});
+Ext.define('PVE.dc.ACLAdd', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveACLAdd'],
+
+    url: '/access/acl',
+    method: 'PUT',
+    isAdd: true,
+    isCreate: true,
+
+    width: 400,
+
+    initComponent: function() {
+        let me = this;
+
+	let items = [
+	    {
+		xtype: me.path ? 'hiddenfield' : 'pvePermPathSelector',
+		name: 'path',
+		value: me.path,
+		allowBlank: false,
+		fieldLabel: gettext('Path'),
+	    },
+	];
+
+	if (me.aclType === 'group') {
+	    me.subject = gettext("Group Permission");
+	    items.push({
+		xtype: 'pveGroupSelector',
+		name: 'groups',
+		fieldLabel: gettext('Group'),
+	    });
+	} else if (me.aclType === 'user') {
+	    me.subject = gettext("User Permission");
+	    items.push({
+		xtype: 'pmxUserSelector',
+		name: 'users',
+		fieldLabel: gettext('User'),
+	    });
+	} else if (me.aclType === 'token') {
+	    me.subject = gettext("API Token Permission");
+	    items.push({
+		xtype: 'pveTokenSelector',
+		name: 'tokens',
+		fieldLabel: gettext('API Token'),
+	    });
+	} else {
+	    throw "unknown ACL type";
+	}
+
+	items.push({
+	    xtype: 'pmxRoleSelector',
+	    name: 'roles',
+	    value: 'NoAccess',
+	    fieldLabel: gettext('Role'),
+	});
+
+	if (!me.path) {
+	    items.push({
+		xtype: 'proxmoxcheckbox',
+		name: 'propagate',
+		checked: true,
+		uncheckedValue: 0,
+		fieldLabel: gettext('Propagate'),
+	    });
+	}
+
+	let ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	    items: items,
+	    onlineHelp: 'pveum_permission_management',
+	});
+
+	Ext.apply(me, {
+	    items: [ipanel],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.dc.ACLView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveACLView'],
+
+    onlineHelp: 'chapter_user_management',
+
+    stateful: true,
+    stateId: 'grid-acls',
+
+    // use fixed path
+    path: undefined,
+
+    initComponent: function() {
+	let me = this;
+
+	let store = Ext.create('Ext.data.Store', {
+	    model: 'pve-acl',
+	    proxy: {
+                type: 'proxmox',
+		url: "/api2/json/access/acl",
+	    },
+	    sorters: {
+		property: 'path',
+		direction: 'ASC',
+	    },
+	});
+
+	if (me.path) {
+	    store.addFilter(Ext.create('Ext.util.Filter', {
+		filterFn: item => item.data.path === me.path,
+	    }));
+	}
+
+	let render_ugid = function(ugid, metaData, record) {
+	    if (record.data.type === 'group') {
+		return '@' + ugid;
+	    }
+
+	    return Ext.String.htmlEncode(ugid);
+	};
+
+	let columns = [
+	    {
+		header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'),
+		flex: 1,
+		sortable: true,
+		renderer: render_ugid,
+		dataIndex: 'ugid',
+	    },
+	    {
+		header: gettext('Role'),
+		flex: 1,
+		sortable: true,
+		dataIndex: 'roleid',
+	    },
+	];
+
+	if (!me.path) {
+	    columns.unshift({
+		header: gettext('Path'),
+		flex: 1,
+		sortable: true,
+		dataIndex: 'path',
+	    });
+	    columns.push({
+		header: gettext('Propagate'),
+		width: 80,
+		sortable: true,
+		dataIndex: 'propagate',
+	    });
+	}
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let remove_btn = new Proxmox.button.Button({
+	    text: gettext('Remove'),
+	    disabled: true,
+	    selModel: sm,
+	    confirmMsg: gettext('Are you sure you want to remove this entry'),
+	    handler: function(btn, event, rec) {
+		var params = {
+		    'delete': 1,
+		    path: rec.data.path,
+		    roles: rec.data.roleid,
+		};
+		if (rec.data.type === 'group') {
+		    params.groups = rec.data.ugid;
+		} else if (rec.data.type === 'user') {
+		    params.users = rec.data.ugid;
+		} else if (rec.data.type === 'token') {
+		    params.tokens = rec.data.ugid;
+		} else {
+		    throw 'unknown data type';
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: '/access/acl',
+		    params: params,
+		    method: 'PUT',
+		    waitMsgTarget: me,
+		    callback: () => store.load(),
+		    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		});
+	    },
+	});
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: {
+			xtype: 'menu',
+			items: [
+			    {
+				text: gettext('Group Permission'),
+				iconCls: 'fa fa-fw fa-group',
+				handler: function() {
+				    var win = Ext.create('PVE.dc.ACLAdd', {
+					aclType: 'group',
+					path: me.path,
+				    });
+				    win.on('destroy', () => store.load());
+				    win.show();
+				},
+			    },
+			    {
+				text: gettext('User Permission'),
+				iconCls: 'fa fa-fw fa-user',
+				handler: function() {
+				    var win = Ext.create('PVE.dc.ACLAdd', {
+					aclType: 'user',
+					path: me.path,
+				    });
+				    win.on('destroy', () => store.load());
+				    win.show();
+				},
+			    },
+			    {
+				text: gettext('API Token Permission'),
+				iconCls: 'fa fa-fw fa-user-o',
+				handler: function() {
+				    let win = Ext.create('PVE.dc.ACLAdd', {
+					aclType: 'token',
+					path: me.path,
+				    });
+				    win.on('destroy', () => store.load());
+				    win.show();
+				},
+			    },
+			],
+		    },
+		},
+		remove_btn,
+	    ],
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: columns,
+	    listeners: {
+		activate: () => store.load(),
+	    },
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-acl', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'path', 'type', 'ugid', 'roleid',
+	    {
+		name: 'propagate',
+		type: 'boolean',
+	    },
+	],
+    });
+});
+Ext.define('pve-acme-accounts', {
+    extend: 'Ext.data.Model',
+    fields: ['name'],
+    proxy: {
+	type: 'proxmox',
+	    url: "/api2/json/cluster/acme/account",
+    },
+    idProperty: 'name',
+});
+
+Ext.define('pve-acme-plugins', {
+    extend: 'Ext.data.Model',
+    fields: ['type', 'plugin', 'api'],
+    proxy: {
+	type: 'proxmox',
+	url: "/api2/json/cluster/acme/plugins",
+    },
+    idProperty: 'plugin',
+});
+
+Ext.define('PVE.dc.ACMEAccountView', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveACMEAccountView',
+
+    title: gettext('Accounts'),
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addAccount: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let defaultExists = view.getStore().findExact('name', 'default') !== -1;
+	    Ext.create('PVE.node.ACMEAccountCreate', {
+		defaultExists,
+		taskDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	viewAccount: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+	    Ext.create('PVE.node.ACMEAccountView', {
+		accountname: selection[0].data.name,
+	    }).show();
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.getStore().rstore.load();
+	},
+
+	showTaskAndReload: function(options, success, response) {
+	    let me = this;
+	    if (!success) return;
+
+	    let upid = response.result.data;
+	    Ext.create('Proxmox.window.TaskProgress', {
+		upid,
+		taskDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+    },
+
+    minHeight: 150,
+    emptyText: gettext('No Accounts configured'),
+
+    columns: [
+	{
+	    dataIndex: 'name',
+	    text: gettext('Name'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Add'),
+	    selModel: false,
+	    handler: 'addAccount',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('View'),
+	    handler: 'viewAccount',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    baseurl: '/cluster/acme/account',
+	    callback: 'showTaskAndReload',
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'viewAccount',
+    },
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	rstore: {
+	    type: 'update',
+	    storeid: 'pve-acme-accounts',
+	    model: 'pve-acme-accounts',
+	    autoStart: true,
+	},
+	sorters: 'name',
+    },
+});
+
+Ext.define('PVE.dc.ACMEPluginView', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveACMEPluginView',
+
+    title: gettext('Challenge Plugins'),
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addPlugin: function() {
+	    let me = this;
+	    Ext.create('PVE.dc.ACMEPluginEditor', {
+		isCreate: true,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	editPlugin: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+	    let plugin = selection[0].data.plugin;
+	    Ext.create('PVE.dc.ACMEPluginEditor', {
+		url: `/cluster/acme/plugins/${plugin}`,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.getStore().rstore.load();
+	},
+    },
+
+    minHeight: 150,
+    emptyText: gettext('No Plugins configured'),
+
+    columns: [
+	{
+	    dataIndex: 'plugin',
+	    text: gettext('Plugin'),
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+	{
+	    dataIndex: 'api',
+	    text: 'API',
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Add'),
+	    handler: 'addPlugin',
+	    selModel: false,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    handler: 'editPlugin',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    baseurl: '/cluster/acme/plugins',
+	    callback: 'reload',
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'editPlugin',
+    },
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	rstore: {
+	    type: 'update',
+	    storeid: 'pve-acme-plugins',
+	    model: 'pve-acme-plugins',
+	    autoStart: true,
+	    filters: item => !!item.data.api,
+	},
+	sorters: 'plugin',
+    },
+});
+
+Ext.define('PVE.dc.ACMEClusterView', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveACMEClusterView',
+
+    onlineHelp: 'sysadmin_certificate_management',
+
+    items: [
+	{
+	    region: 'north',
+	    border: false,
+	    xtype: 'pveACMEAccountView',
+	},
+	{
+	    region: 'center',
+	    border: false,
+	    xtype: 'pveACMEPluginView',
+	},
+    ],
+});
+Ext.define('PVE.dc.ACMEPluginEditor', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveACMEPluginEditor',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'sysadmin_certs_acme_plugins',
+
+    isAdd: true,
+    isCreate: false,
+
+    width: 550,
+    url: '/cluster/acme/plugins/',
+
+    subject: 'ACME DNS Plugin',
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    // we dynamically create fields from the given schema
+	    // things we have to do here:
+	    // * save which fields we created to remove them again
+	    // * split the data from the generic 'data' field into the boxes
+	    // * on deletion collect those values again
+	    // * save the original values of the data field
+	    createdFields: {},
+	    createdInitially: false,
+	    originalValues: {},
+	    createSchemaFields: function(schema) {
+		let me = this;
+		// we know where to add because we define it right below
+		let container = me.down('container');
+		let datafield = me.down('field[name=data]');
+		let hintfield = me.down('field[name=hint]');
+		if (!me.createdInitially) {
+		    [me.originalValues] = PVE.Parser.parseACMEPluginData(datafield.getValue());
+		}
+
+		// collect values from custom fields and add it to 'data'',
+		// then remove the custom fields
+		let data = [];
+		for (const [name, field] of Object.entries(me.createdFields)) {
+		    let value = field.getValue();
+		    if (value !== undefined && value !== null && value !== '') {
+			data.push(`${name}=${value}`);
+		    }
+		    container.remove(field);
+		}
+		let datavalue = datafield.getValue();
+		if (datavalue !== undefined && datavalue !== null && datavalue !== '') {
+		    data.push(datavalue);
+		}
+		datafield.setValue(data.join('\n'));
+
+		me.createdFields = {};
+
+		if (typeof schema.fields !== 'object') {
+		    schema.fields = {};
+		}
+		// create custom fields according to schema
+		let gotSchemaField = false;
+		let cmp = (a, b) => a[0].localeCompare(b[0]);
+		for (const [name, definition] of Object.entries(schema.fields).sort(cmp)) {
+		    let xtype;
+		    switch (definition.type) {
+			case 'string':
+			    xtype = 'proxmoxtextfield';
+			    break;
+			case 'integer':
+			    xtype = 'proxmoxintegerfield';
+			    break;
+			case 'number':
+			    xtype = 'numberfield';
+			    break;
+			default:
+			    console.warn(`unknown type '${definition.type}'`);
+			    xtype = 'proxmoxtextfield';
+			    break;
+		    }
+
+		    let label = name;
+		    if (typeof definition.name === "string") {
+			label = definition.name;
+		    }
+
+		    let field = Ext.create({
+			xtype,
+			name: `custom_${name}`,
+			fieldLabel: label,
+			width: '100%',
+			labelWidth: 150,
+			labelSeparator: '=',
+			emptyText: definition.default || '',
+			autoEl: definition.description ? {
+			    tag: 'div',
+			    'data-qtip': definition.description,
+			} : undefined,
+		    });
+
+		    me.createdFields[name] = field;
+		    container.add(field);
+		    gotSchemaField = true;
+		}
+		datafield.setHidden(gotSchemaField); // prefer schema-fields
+
+		if (schema.description) {
+		    hintfield.setValue(schema.description);
+		    hintfield.setHidden(false);
+		} else {
+		    hintfield.setValue('');
+		    hintfield.setHidden(true);
+		}
+
+		// parse data from field and set it to the custom ones
+		let extradata = [];
+		[data, extradata] = PVE.Parser.parseACMEPluginData(datafield.getValue());
+		for (const [key, value] of Object.entries(data)) {
+		    if (me.createdFields[key]) {
+			me.createdFields[key].setValue(value);
+			me.createdFields[key].originalValue = me.originalValues[key];
+		    } else {
+			extradata.push(`${key}=${value}`);
+		    }
+		}
+		datafield.setValue(extradata.join('\n'));
+		if (!me.createdInitially) {
+		    datafield.resetOriginalValue();
+		    me.createdInitially = true; // save that we initally set that
+		}
+	    },
+	    onGetValues: function(values) {
+		let me = this;
+		let win = me.up('pveACMEPluginEditor');
+		if (win.isCreate) {
+		    values.id = values.plugin;
+		    values.type = 'dns'; // the only one for now
+		}
+		delete values.plugin;
+
+		PVE.Utils.delete_if_default(values, 'validation-delay', '30', win.isCreate);
+
+		let data = '';
+		for (const [name, field] of Object.entries(me.createdFields)) {
+		    let value = field.getValue();
+		    if (value !== null && value !== undefined && value !== '') {
+			data += `${name}=${value}\n`;
+		    }
+		    delete values[`custom_${name}`];
+		}
+		values.data = Ext.util.Base64.encode(data + values.data);
+		return values;
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    cbind: {
+			editable: (get) => get('isCreate'),
+			submitValue: (get) => get('isCreate'),
+		    },
+		    editConfig: {
+			flex: 1,
+			xtype: 'proxmoxtextfield',
+			allowBlank: false,
+		    },
+		    name: 'plugin',
+		    labelWidth: 150,
+		    fieldLabel: gettext('Plugin ID'),
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'validation-delay',
+		    labelWidth: 150,
+		    fieldLabel: gettext('Validation Delay'),
+		    emptyText: 30,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    minValue: 0,
+		    maxValue: 48*60*60,
+		},
+		{
+		    xtype: 'pveACMEApiSelector',
+		    name: 'api',
+		    labelWidth: 150,
+		    listeners: {
+			change: function(selector) {
+			    let schema = selector.getSchema();
+			    selector.up('inputpanel').createSchemaFields(schema);
+			},
+		    },
+		},
+		{
+		    xtype: 'textarea',
+		    fieldLabel: gettext('API Data'),
+		    labelWidth: 150,
+		    name: 'data',
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Hint'),
+		    labelWidth: 150,
+		    name: 'hint',
+		    hidden: true,
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, opts) {
+		    me.setValues(response.result.data);
+		},
+	    });
+	} else {
+	    me.method = 'POST';
+	}
+    },
+});
+Ext.define('PVE.panel.AuthBase', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveAuthBasePanel',
+
+    type: '',
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (!values.port) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'port' });
+	    }
+	    delete values.port;
+	}
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let options = PVE.Utils.authSchema[me.type];
+
+	if (!me.column1) { me.column1 = []; }
+	if (!me.column2) { me.column2 = []; }
+	if (!me.columnB) { me.columnB = []; }
+
+	// first field is name
+	me.column1.unshift({
+	    xtype: me.isCreate ? 'textfield' : 'displayfield',
+	    name: 'realm',
+	    fieldLabel: gettext('Realm'),
+	    value: me.realm,
+	    allowBlank: false,
+	});
+
+	// last field is default'
+	me.column1.push({
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Default'),
+	    name: 'default',
+	    uncheckedValue: 0,
+	});
+
+	if (options.tfa) {
+	    // last field of column2is tfa
+	    me.column2.push({
+		xtype: 'pveTFASelector',
+		deleteEmpty: !me.isCreate,
+	    });
+	}
+
+	me.columnB.push({
+	    xtype: 'textfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.dc.AuthEditBase', {
+    extend: 'Proxmox.window.Edit',
+
+    onlineHelp: 'pveum_authentication_realms',
+
+    isAdd: true,
+
+    fieldDefaults: {
+	labelWidth: 120,
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.realm;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/access/domains';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/access/domains/' + me.realm;
+	    me.method = 'PUT';
+	}
+
+	let authConfig = PVE.Utils.authSchema[me.authType];
+	if (!authConfig) {
+	    throw 'unknown auth type';
+	} else if (!authConfig.add && me.isCreate) {
+	    throw 'trying to add non addable realm';
+	}
+
+	me.subject = authConfig.name;
+
+	let items;
+	let bodyPadding;
+	if (authConfig.syncipanel) {
+	    bodyPadding = 0;
+	    items = {
+		xtype: 'tabpanel',
+		region: 'center',
+		layout: 'fit',
+		bodyPadding: 10,
+		items: [
+		    {
+			title: gettext('General'),
+			realm: me.realm,
+			xtype: authConfig.ipanel,
+			isCreate: me.isCreate,
+			type: me.authType,
+		    },
+		    {
+			title: gettext('Sync Options'),
+			realm: me.realm,
+			xtype: authConfig.syncipanel,
+			isCreate: me.isCreate,
+			type: me.authType,
+		    },
+		],
+	    };
+	} else {
+	    items = [{
+		realm: me.realm,
+		xtype: authConfig.ipanel,
+		isCreate: me.isCreate,
+		type: me.authType,
+	    }];
+	}
+
+	Ext.apply(me, {
+	    items,
+	    bodyPadding,
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var data = response.result.data || {};
+		    // just to be sure (should not happen)
+		    if (data.type !== me.authType) {
+			me.close();
+			throw "got wrong auth type";
+		    }
+		    me.setValues(data);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.panel.ADInputPanel', {
+    extend: 'PVE.panel.AuthBase',
+    xtype: 'pveAuthADPanel',
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.type !== 'ad') {
+	    throw 'invalid type';
+	}
+
+	me.column1 = [
+	    {
+		xtype: 'textfield',
+		name: 'domain',
+		fieldLabel: gettext('Domain'),
+		emptyText: 'company.net',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Case-Sensitive'),
+		name: 'case-sensitive',
+		uncheckedValue: 0,
+		checked: true,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'textfield',
+		fieldLabel: gettext('Server'),
+		name: 'server1',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		fieldLabel: gettext('Fallback Server'),
+		deleteEmpty: !me.isCreate,
+		name: 'server2',
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'port',
+		fieldLabel: gettext('Port'),
+		minValue: 1,
+		maxValue: 65535,
+		emptyText: gettext('Default'),
+		submitEmptyText: false,
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'mode',
+		fieldLabel: gettext('Mode'),
+		editable: false,
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'],
+		    ['ldap', 'LDAP'],
+		    ['ldap+starttls', 'STARTTLS'],
+		    ['ldaps', 'LDAPS'],
+		],
+		value: '__default__',
+		deleteEmpty: !me.isCreate,
+		listeners: {
+		    change: function(field, newValue) {
+			let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]');
+			if (newValue === 'ldap' || newValue === '__default__') {
+			    verifyCheckbox.disable();
+			    verifyCheckbox.setValue(0);
+			} else {
+			    verifyCheckbox.enable();
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Verify Certificate'),
+		name: 'verify',
+		uncheckedValue: 0,
+		disabled: true,
+		checked: false,
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Verify TLS certificate of the server'),
+		},
+	    },
+	];
+
+	me.advancedItems = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Check connection'),
+		name: 'check-connection',
+		uncheckedValue: 0,
+		checked: true,
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip':
+			gettext('Verify connection parameters and bind credentials on save'),
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+    onGetValues: function(values) {
+	let me = this;
+
+	if (!values.verify) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'verify' });
+	    }
+	    delete values.verify;
+	}
+
+	if (!me.isCreate) {
+	    // Delete old `secure` parameter. It has been deprecated in favor to the
+	    // `mode` parameter. Migration happens automatically in `onSetValues`.
+	    Proxmox.Utils.assemble_field_data(values, { 'delete': 'secure' });
+	}
+
+
+	return me.callParent([values]);
+    },
+
+    onSetValues(values) {
+	let me = this;
+
+	if (values.secure !== undefined && !values.mode) {
+	    // If `secure` is set, use it to determine the correct setting for `mode`
+	    // `secure` is later deleted by `onSetValues` .
+	    // In case *both* are set, we simply ignore `secure` and use
+	    // whatever `mode` is set to.
+	    values.mode = values.secure ? 'ldaps' : 'ldap';
+	}
+
+	return me.callParent([values]);
+    },
+});
+Ext.define('PVE.panel.LDAPInputPanel', {
+    extend: 'PVE.panel.AuthBase',
+    xtype: 'pveAuthLDAPPanel',
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.type !== 'ldap') {
+	    throw 'invalid type';
+	}
+
+	me.column1 = [
+	    {
+		xtype: 'textfield',
+		name: 'base_dn',
+		fieldLabel: gettext('Base Domain Name'),
+		emptyText: 'CN=Users,DC=Company,DC=net',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'user_attr',
+		emptyText: 'uid / sAMAccountName',
+		fieldLabel: gettext('User Attribute Name'),
+		allowBlank: false,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'textfield',
+		fieldLabel: gettext('Server'),
+		name: 'server1',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		fieldLabel: gettext('Fallback Server'),
+		deleteEmpty: !me.isCreate,
+		name: 'server2',
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'port',
+		fieldLabel: gettext('Port'),
+		minValue: 1,
+		maxValue: 65535,
+		emptyText: gettext('Default'),
+		submitEmptyText: false,
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'mode',
+		fieldLabel: gettext('Mode'),
+		editable: false,
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'],
+		    ['ldap', 'LDAP'],
+		    ['ldap+starttls', 'STARTTLS'],
+		    ['ldaps', 'LDAPS'],
+		],
+		value: '__default__',
+		deleteEmpty: !me.isCreate,
+		listeners: {
+		    change: function(field, newValue) {
+			let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]');
+			if (newValue === 'ldap' || newValue === '__default__') {
+			    verifyCheckbox.disable();
+			    verifyCheckbox.setValue(0);
+			} else {
+			    verifyCheckbox.enable();
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Verify Certificate'),
+		name: 'verify',
+		uncheckedValue: 0,
+		disabled: true,
+		checked: false,
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Verify TLS certificate of the server'),
+		},
+	    },
+	];
+
+	me.advancedItems = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Check connection'),
+		name: 'check-connection',
+		uncheckedValue: 0,
+		checked: true,
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip':
+			gettext('Verify connection parameters and bind credentials on save'),
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+    onGetValues: function(values) {
+	let me = this;
+
+	if (!values.verify) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'verify' });
+	    }
+	    delete values.verify;
+	}
+
+	if (!me.isCreate) {
+	    // Delete old `secure` parameter. It has been deprecated in favor to the
+	    // `mode` parameter. Migration happens automatically in `onSetValues`.
+	    Proxmox.Utils.assemble_field_data(values, { 'delete': 'secure' });
+	}
+
+	return me.callParent([values]);
+    },
+
+    onSetValues(values) {
+	let me = this;
+
+	if (values.secure !== undefined && !values.mode) {
+	    // If `secure` is set, use it to determine the correct setting for `mode`
+	    // `secure` is later deleted by `onSetValues` .
+	    // In case *both* are set, we simply ignore `secure` and use
+	    // whatever `mode` is set to.
+	    values.mode = values.secure ? 'ldaps' : 'ldap';
+	}
+
+	return me.callParent([values]);
+    },
+});
+
+Ext.define('PVE.panel.LDAPSyncInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveAuthLDAPSyncPanel',
+
+    editableAttributes: ['email'],
+    editableDefaults: ['scope', 'enable-new'],
+    default_opts: {},
+    sync_attributes: {},
+
+    // (de)construct the sync-attributes from the list above,
+    // not touching all others
+    onGetValues: function(values) {
+	let me = this;
+	me.editableDefaults.forEach((attr) => {
+	    if (values[attr]) {
+		me.default_opts[attr] = values[attr];
+		delete values[attr];
+	    } else {
+		delete me.default_opts[attr];
+	    }
+	});
+	let vanished_opts = [];
+	['acl', 'entry', 'properties'].forEach((prop) => {
+	    if (values[`remove-vanished-${prop}`]) {
+		vanished_opts.push(prop);
+	    }
+	    delete values[`remove-vanished-${prop}`];
+	});
+	me.default_opts['remove-vanished'] = vanished_opts.join(';');
+
+	values['sync-defaults-options'] = PVE.Parser.printPropertyString(me.default_opts);
+	me.editableAttributes.forEach((attr) => {
+	    if (values[attr]) {
+		me.sync_attributes[attr] = values[attr];
+		delete values[attr];
+	    } else {
+		delete me.sync_attributes[attr];
+	    }
+	});
+	values.sync_attributes = PVE.Parser.printPropertyString(me.sync_attributes);
+
+	PVE.Utils.delete_if_default(values, 'sync-defaults-options');
+	PVE.Utils.delete_if_default(values, 'sync_attributes');
+
+	// Force values.delete to be an array
+	if (typeof values.delete === 'string') {
+	   values.delete = values.delete.split(',');
+	}
+
+	if (me.isCreate) {
+	    delete values.delete; // on create we cannot delete values
+	}
+
+	return values;
+    },
+
+    setValues: function(values) {
+	let me = this;
+	if (values.sync_attributes) {
+	    me.sync_attributes = PVE.Parser.parsePropertyString(values.sync_attributes);
+	    delete values.sync_attributes;
+	    me.editableAttributes.forEach((attr) => {
+		if (me.sync_attributes[attr]) {
+		    values[attr] = me.sync_attributes[attr];
+		}
+	    });
+	}
+	if (values['sync-defaults-options']) {
+	    me.default_opts = PVE.Parser.parsePropertyString(values['sync-defaults-options']);
+	    delete values.default_opts;
+	    me.editableDefaults.forEach((attr) => {
+		if (me.default_opts[attr]) {
+		    values[attr] = me.default_opts[attr];
+		}
+	    });
+
+	    if (me.default_opts['remove-vanished']) {
+		let opts = me.default_opts['remove-vanished'].split(';');
+		for (const opt of opts) {
+		    values[`remove-vanished-${opt}`] = 1;
+		}
+	    }
+	}
+	return me.callParent([values]);
+    },
+
+    column1: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'bind_dn',
+	    deleteEmpty: true,
+	    emptyText: Proxmox.Utils.noneText,
+	    fieldLabel: gettext('Bind User'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    inputType: 'password',
+	    name: 'password',
+	    emptyText: gettext('Unchanged'),
+	    fieldLabel: gettext('Bind Password'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'email',
+	    fieldLabel: gettext('E-Mail attribute'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'group_name_attr',
+	    deleteEmpty: true,
+	    fieldLabel: gettext('Groupname attr.'),
+	},
+	{
+	    xtype: 'displayfield',
+	    value: gettext('Default Sync Options'),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'scope',
+	    emptyText: Proxmox.Utils.NoneText,
+	    fieldLabel: gettext('Scope'),
+	    value: '__default__',
+	    deleteEmpty: false,
+	    comboItems: [
+		['__default__', Proxmox.Utils.NoneText],
+		['users', gettext('Users')],
+		['groups', gettext('Groups')],
+		['both', gettext('Users and Groups')],
+	    ],
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'user_classes',
+	    fieldLabel: gettext('User classes'),
+	    deleteEmpty: true,
+	    emptyText: 'inetorgperson, posixaccount, person, user',
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'group_classes',
+	    fieldLabel: gettext('Group classes'),
+	    deleteEmpty: true,
+	    emptyText: 'groupOfNames, group, univentionGroup, ipausergroup',
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'filter',
+	    fieldLabel: gettext('User Filter'),
+	    deleteEmpty: true,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'group_filter',
+	    fieldLabel: gettext('Group Filter'),
+	    deleteEmpty: true,
+	},
+	{
+	    // fake for spacing
+	    xtype: 'displayfield',
+	    value: ' ',
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    value: '__default__',
+	    deleteEmpty: false,
+	    comboItems: [
+		[
+		    '__default__',
+		    Ext.String.format(
+			gettext("{0} ({1})"),
+			Proxmox.Utils.yesText,
+			Proxmox.Utils.defaultText,
+		    ),
+		],
+		['1', Proxmox.Utils.yesText],
+		['0', Proxmox.Utils.noText],
+	    ],
+	    name: 'enable-new',
+	    fieldLabel: gettext('Enable new users'),
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'fieldset',
+	    title: gettext('Remove Vanished Options'),
+	    items: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('ACL'),
+		    name: 'remove-vanished-acl',
+		    boxLabel: gettext('Remove ACLs of vanished users and groups.'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Entry'),
+		    name: 'remove-vanished-entry',
+		    boxLabel: gettext('Remove vanished user and group entries.'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Properties'),
+		    name: 'remove-vanished-properties',
+		    boxLabel: gettext('Remove vanished properties from synced users.'),
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.panel.OpenIDInputPanel', {
+    extend: 'PVE.panel.AuthBase',
+    xtype: 'pveAuthOpenIDPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (!values.verify) {
+	    if (!me.isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'verify' });
+	    }
+	    delete values.verify;
+	}
+
+	return me.callParent([values]);
+    },
+
+    columnT: [
+	{
+	    xtype: 'textfield',
+	    name: 'issuer-url',
+	    fieldLabel: gettext('Issuer URL'),
+	    allowBlank: false,
+	},
+    ],
+
+    column1: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Client ID'),
+	    name: 'client-id',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Client Key'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	    name: 'client-key',
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Autocreate Users'),
+	    name: 'autocreate',
+	    value: 0,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'username-claim',
+	    fieldLabel: gettext('Username Claim'),
+	    editConfig: {
+		xtype: 'proxmoxKVComboBox',
+		editable: true,
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText],
+		    ['subject', 'subject'],
+		    ['username', 'username'],
+		    ['email', 'email'],
+		],
+	    },
+	    cbind: {
+		value: get => get('isCreate') ? '__default__' : Proxmox.Utils.defaultText,
+		deleteEmpty: '{!isCreate}',
+		editable: '{isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'scopes',
+	    fieldLabel: gettext('Scopes'),
+	    emptyText: `${Proxmox.Utils.defaultText} (email profile)`,
+	    submitEmpty: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'prompt',
+	    fieldLabel: gettext('Prompt'),
+	    editable: true,
+	    emptyText: gettext('Auth-Provider Default'),
+	    comboItems: [
+		['__default__', gettext('Auth-Provider Default')],
+		['none', 'none'],
+		['login', 'login'],
+		['consent', 'consent'],
+		['select_account', 'select_account'],
+	    ],
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    advancedColumnB: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'acr-values',
+	    fieldLabel: gettext('ACR Values'),
+	    submitEmpty: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.type !== 'openid') {
+	    throw 'invalid type';
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.dc.AuthView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveAuthView'],
+
+    onlineHelp: 'pveum_authentication_realms',
+
+    stateful: true,
+    stateId: 'grid-authrealms',
+
+    viewConfig: {
+	trackOver: false,
+    },
+
+    columns: [
+	{
+	    header: gettext('Realm'),
+	    width: 100,
+	    sortable: true,
+	    dataIndex: 'realm',
+	},
+	{
+	    header: gettext('Type'),
+	    width: 100,
+	    sortable: true,
+	    dataIndex: 'type',
+	},
+	{
+	    header: gettext('TFA'),
+	    width: 100,
+	    sortable: true,
+	    dataIndex: 'tfa',
+	},
+	{
+	    header: gettext('Comment'),
+	    sortable: false,
+	    dataIndex: 'comment',
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    store: {
+	model: 'pmx-domains',
+	sorters: {
+	    property: 'realm',
+	    direction: 'ASC',
+	},
+    },
+
+    openEditWindow: function(authType, realm) {
+	let me = this;
+	Ext.create('PVE.dc.AuthEditBase', {
+	    authType,
+	    realm,
+	    listeners: {
+		destroy: () => me.reload(),
+	    },
+	}).show();
+    },
+
+    reload: function() {
+	let me = this;
+	me.getStore().load();
+    },
+
+    run_editor: function() {
+	let me = this;
+	let rec = me.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+	me.openEditWindow(rec.data.type, rec.data.realm);
+    },
+
+    open_sync_window: function() {
+	let me = this;
+	let rec = me.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+	Ext.create('PVE.dc.SyncWindow', {
+	    realm: rec.data.realm,
+	    listeners: {
+		destroy: () => me.reload(),
+	    },
+	}).show();
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	let items = [];
+	for (const [authType, config] of Object.entries(PVE.Utils.authSchema)) {
+	    if (!config.add) { continue; }
+	    items.push({
+		text: config.name,
+		iconCls: 'fa fa-fw ' + (config.iconCls || 'fa-address-book-o'),
+		handler: () => me.openEditWindow(authType),
+	    });
+	}
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: {
+			items: items,
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Edit'),
+		    disabled: true,
+		    handler: () => me.run_editor(),
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    baseurl: '/access/domains/',
+		    enableFn: (rec) => PVE.Utils.authSchema[rec.data.type].add,
+		    callback: () => me.reload(),
+		},
+		'-',
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Sync'),
+		    disabled: true,
+		    enableFn: (rec) => Boolean(PVE.Utils.authSchema[rec.data.type].syncipanel),
+		    handler: () => me.open_sync_window(),
+		},
+	    ],
+	    listeners: {
+		itemdblclick: () => me.run_editor(),
+	    },
+	});
+
+	me.callParent();
+	me.reload();
+    },
+});
+Ext.define('PVE.dc.BackupDiskTree', {
+    extend: 'Ext.tree.Panel',
+    alias: 'widget.pveBackupDiskTree',
+
+    folderSort: true,
+    rootVisible: false,
+
+    store: {
+	sorters: 'id',
+	data: {},
+    },
+
+    tools: [
+	{
+	    type: 'expand',
+	    tooltip: gettext('Expand All'),
+	    callback: panel => panel.expandAll(),
+	},
+	{
+	    type: 'collapse',
+	    tooltip: gettext('Collapse All'),
+	    callback: panel => panel.collapseAll(),
+	},
+    ],
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: gettext('Guest Image'),
+	    renderer: function(value, meta, record) {
+		if (record.data.type) {
+		    // guest level
+		    let ret = value;
+		    if (record.data.name) {
+			ret += " (" + record.data.name + ")";
+		    }
+		    return ret;
+		} else {
+		    // extJS needs unique IDs but we only want to show the volumes key from "vmid:key"
+		    return value.split(':')[1] + " - " + record.data.name;
+		}
+	    },
+	    dataIndex: 'id',
+	    flex: 6,
+	},
+	{
+	    text: gettext('Type'),
+	    dataIndex: 'type',
+	    flex: 1,
+	},
+	{
+	    text: gettext('Backup Job'),
+	    renderer: PVE.Utils.render_backup_status,
+	    dataIndex: 'included',
+	    flex: 3,
+	},
+    ],
+
+    reload: function() {
+	let me = this;
+	let sm = me.getSelectionModel();
+
+	Proxmox.Utils.API2Request({
+	    url: `/cluster/backup/${me.jobid}/included_volumes`,
+	    waitMsgTarget: me,
+	    method: 'GET',
+	    failure: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		sm.deselectAll();
+		me.setRootNode(response.result.data);
+		me.expandAll();
+	    },
+	});
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.jobid) {
+	    throw "no job id specified";
+	}
+
+	var sm = Ext.create('Ext.selection.TreeModel', {});
+
+	Ext.apply(me, {
+	    selModel: sm,
+	    fields: ['id', 'type',
+		{
+		    type: 'string',
+		    name: 'iconCls',
+		    calculate: function(data) {
+			var txt = 'fa x-fa-tree fa-';
+			if (data.leaf && !data.type) {
+			    return txt + 'hdd-o';
+			} else if (data.type === 'qemu') {
+			    return txt + 'desktop';
+			} else if (data.type === 'lxc') {
+			    return txt + 'cube';
+			} else {
+			    return txt + 'question-circle';
+			}
+		    },
+		},
+	    ],
+	    header: {
+		items: [{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Search'),
+		    labelWidth: 50,
+		    emptyText: 'Name, VMID, Type',
+		    width: 200,
+		    padding: '0 5 0 0',
+		    enableKeyEvents: true,
+		    listeners: {
+			buffer: 500,
+			keyup: function(field) {
+			    let searchValue = field.getValue().toLowerCase();
+			    me.store.clearFilter(true);
+			    me.store.filterBy(function(record) {
+				let data = {};
+				if (record.data.depth === 0) {
+				    return true;
+				} else if (record.data.depth === 1) {
+				    data = record.data;
+				} else if (record.data.depth === 2) {
+				    data = record.parentNode.data;
+				}
+
+				for (const property of ['name', 'id', 'type']) {
+				    if (!data[property]) {
+					continue;
+				    }
+				    let v = data[property].toString();
+				    if (v !== undefined) {
+					v = v.toLowerCase();
+					if (v.includes(searchValue)) {
+					    return true;
+					}
+				    }
+				}
+				return false;
+			    });
+			},
+		    },
+		}],
+	    },
+	});
+
+	me.callParent();
+
+	me.reload();
+    },
+});
+
+Ext.define('PVE.dc.BackupInfo', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveBackupInfo',
+
+    viewModel: {
+	data: {
+	    retentionType: 'none',
+	},
+	formulas: {
+	    hasRetention: (get) => get('retentionType') !== 'none',
+	    retentionKeepAll: (get) => get('retentionType') === 'all',
+	},
+    },
+
+    padding: '5 0 5 10',
+
+    column1: [
+	{
+	    xtype: 'displayfield',
+	    name: 'node',
+	    fieldLabel: gettext('Node'),
+	    renderer: value => value || `-- ${gettext('All')} --`,
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'storage',
+	    fieldLabel: gettext('Storage'),
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'schedule',
+	    fieldLabel: gettext('Schedule'),
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'next-run',
+	    fieldLabel: gettext('Next Run'),
+	    renderer: PVE.Utils.render_next_event,
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'selMode',
+	    fieldLabel: gettext('Selection mode'),
+	},
+    ],
+    column2: [
+	{
+	    xtype: 'displayfield',
+	    name: 'notification-policy',
+	    fieldLabel: gettext('Notification'),
+	    renderer: function(value) {
+		let record = this.up('pveBackupInfo')?.record;
+
+		// Fall back to old value, in case this option is not migrated yet.
+		let policy = value || record?.mailnotification || 'always';
+
+		let when = gettext('Always');
+		if (policy === 'failure') {
+		    when = gettext('On failure only');
+		} else if (policy === 'never') {
+		    when = gettext('Never');
+		}
+
+		// Notification-target takes precedence
+		let target = record?.['notification-target'] ||
+		    record?.mailto ||
+		    gettext('No target configured');
+
+		return `${when} (${target})`;
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'compress',
+	    fieldLabel: gettext('Compression'),
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'mode',
+	    fieldLabel: gettext('Mode'),
+	    renderer: function(value) {
+		const modeToDisplay = {
+		    snapshot: gettext('Snapshot'),
+		    stop: gettext('Stop'),
+		    suspend: gettext('Snapshot'),
+		};
+		return modeToDisplay[value] ?? gettext('Unknown');
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'enabled',
+	    fieldLabel: gettext('Enabled'),
+	    renderer: v => PVE.Parser.parseBoolean(v.toString()) ? gettext('Yes') : gettext('No'),
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'pool',
+	    fieldLabel: gettext('Pool to backup'),
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'displayfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    renderer: Ext.String.htmlEncode,
+	},
+	{
+	    xtype: 'fieldset',
+	    title: gettext('Retention Configuration'),
+	    layout: 'hbox',
+	    collapsible: true,
+	    defaults: {
+		border: false,
+		layout: 'anchor',
+		flex: 1,
+	    },
+	    bind: {
+		hidden: '{!hasRetention}',
+	    },
+	    items: [
+		{
+		    padding: '0 10 0 0',
+		    defaults: {
+			labelWidth: 110,
+		    },
+		    items: [{
+			xtype: 'displayfield',
+			name: 'keep-all',
+			fieldLabel: gettext('Keep All'),
+			renderer: Proxmox.Utils.format_boolean,
+			bind: {
+			    hidden: '{!retentionKeepAll}',
+			},
+		    }].concat(
+			[
+			    ['keep-last', gettext('Keep Last')],
+			    ['keep-hourly', gettext('Keep Hourly')],
+			].map(
+			    name => ({
+				xtype: 'displayfield',
+				name: name[0],
+				fieldLabel: name[1],
+				bind: {
+				    hidden: '{!hasRetention || retentionKeepAll}',
+				},
+			    }),
+			),
+		    ),
+		},
+		{
+		    padding: '0 0 0 10',
+		    defaults: {
+			labelWidth: 110,
+		    },
+		    items: [
+			['keep-daily', gettext('Keep Daily')],
+			['keep-weekly', gettext('Keep Weekly')],
+		    ].map(
+			name => ({
+			    xtype: 'displayfield',
+			    name: name[0],
+			    fieldLabel: name[1],
+			    bind: {
+				hidden: '{!hasRetention || retentionKeepAll}',
+			    },
+			}),
+		    ),
+		},
+		{
+		    padding: '0 0 0 10',
+		    defaults: {
+			labelWidth: 110,
+		    },
+		    items: [
+			['keep-monthly', gettext('Keep Monthly')],
+			['keep-yearly', gettext('Keep Yearly')],
+		    ].map(
+			name => ({
+			    xtype: 'displayfield',
+			    name: name[0],
+			    fieldLabel: name[1],
+			    bind: {
+				hidden: '{!hasRetention || retentionKeepAll}',
+			    },
+			}),
+		    ),
+		},
+	    ],
+	},
+    ],
+
+    setValues: function(values) {
+	var me = this;
+	let vm = me.getViewModel();
+
+        Ext.iterate(values, function(fieldId, val) {
+	    let field = me.query('[isFormField][name=' + fieldId + ']')[0];
+	    if (field) {
+		field.setValue(val);
+            }
+	});
+
+	if (values['prune-backups'] || values.maxfiles !== undefined) {
+	    let keepValues;
+	    if (values['prune-backups']) {
+		keepValues = values['prune-backups'];
+	    } else if (values.maxfiles > 0) {
+		keepValues = { 'keep-last': values.maxfiles };
+	    } else {
+		keepValues = { 'keep-all': 1 };
+	    }
+
+	    vm.set('retentionType', keepValues['keep-all'] ? 'all' : 'other');
+
+	    // set values of all keep-X fields
+	    ['all', 'last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].forEach(time => {
+		let name = `keep-${time}`;
+		me.query(`[isFormField][name=${name}]`)[0]?.setValue(keepValues[name]);
+	    });
+	} else {
+	    vm.set('retentionType', 'none');
+	}
+
+	// selection Mode depends on the presence/absence of several keys
+	let selModeField = me.query('[isFormField][name=selMode]')[0];
+	let selMode = 'none';
+	if (values.vmid) {
+	    selMode = gettext('Include selected VMs');
+	}
+	if (values.all) {
+	    selMode = gettext('All');
+	}
+	if (values.exclude) {
+	     selMode = gettext('Exclude selected VMs');
+	}
+	if (values.pool) {
+	    selMode = gettext('Pool based');
+	}
+	selModeField.setValue(selMode);
+
+	if (!values.pool) {
+	    let poolField = me.query('[isFormField][name=pool]')[0];
+	    poolField.setVisible(0);
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.record) {
+	    throw "no data provided";
+	}
+	me.callParent();
+
+	me.setValues(me.record);
+    },
+});
+
+
+Ext.define('PVE.dc.BackedGuests', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveBackedGuests',
+
+    stateful: true,
+    stateId: 'grid-dc-backed-guests',
+
+    textfilter: '',
+
+    columns: [
+	{
+	    header: gettext('Type'),
+	    dataIndex: "type",
+	    renderer: PVE.Utils.render_resource_type,
+	    flex: 1,
+	    sortable: true,
+	},
+	{
+	    header: 'VMID',
+	    dataIndex: 'vmid',
+	    flex: 1,
+	    sortable: true,
+	},
+	{
+	    header: gettext('Name'),
+	    dataIndex: 'name',
+	    flex: 2,
+	    sortable: true,
+	},
+    ],
+    viewConfig: {
+	stripeRows: true,
+	trackOver: false,
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.store.clearFilter(true);
+
+	Ext.apply(me, {
+	    tbar: [
+	        '->',
+		gettext('Search') + ':',
+		' ',
+		{
+		    xtype: 'textfield',
+		    width: 200,
+		    emptyText: 'Name, VMID, Type',
+		    enableKeyEvents: true,
+		    listeners: {
+			buffer: 500,
+			keyup: function(field) {
+			    let searchValue = field.getValue().toLowerCase();
+			    me.store.clearFilter(true);
+			    me.store.filterBy(function(record) {
+				let data = record.data;
+				for (const property of ['name', 'vmid', 'type']) {
+				    if (data[property] === null) {
+					continue;
+				    }
+				    let v = data[property].toString();
+				    if (v !== undefined) {
+					if (v.toLowerCase().includes(searchValue)) {
+					    return true;
+					}
+				    }
+				}
+				return false;
+			    });
+			},
+		    },
+		},
+	    ],
+	});
+	me.callParent();
+    },
+});
+Ext.define('PVE.dc.BackupEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveDcBackupEdit'],
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    defaultFocus: undefined,
+
+    subject: gettext("Backup Job"),
+    width: 720,
+    bodyPadding: 0,
+
+    url: '/api2/extjs/cluster/backup',
+    method: 'POST',
+    isCreate: true,
+
+    cbindData: function() {
+	let me = this;
+	if (me.jobid) {
+	    me.isCreate = false;
+	    me.method = 'PUT';
+	    me.url += `/${me.jobid}`;
+	}
+	return {};
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	onGetValues: function(values) {
+	    let me = this;
+	    let isCreate = me.getView().isCreate;
+	    if (!values.node) {
+		if (!isCreate) {
+		    Proxmox.Utils.assemble_field_data(values, { 'delete': 'node' });
+		}
+		delete values.node;
+	    }
+
+	    // Get rid of new-old parameters for notification settings.
+	    // These should only be set for those selected few who ran
+	    // pve-manager from pvetest.
+	    if (!isCreate) {
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-policy' });
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-target' });
+	    }
+
+	    if (!values.id && isCreate) {
+		values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13);
+	    }
+
+	    let selMode = values.selMode;
+	    delete values.selMode;
+
+	    if (selMode === 'all') {
+		values.all = 1;
+		values.exclude = '';
+		delete values.vmid;
+	    } else if (selMode === 'exclude') {
+		values.all = 1;
+		values.exclude = values.vmid;
+		delete values.vmid;
+	    } else if (selMode === 'pool') {
+		delete values.vmid;
+	    }
+
+	    if (selMode !== 'pool') {
+		delete values.pool;
+	    }
+	    return values;
+	},
+
+	nodeChange: function(f, value) {
+	    let me = this;
+	    me.lookup('storageSelector').setNodename(value);
+	    let vmgrid = me.lookup('vmgrid');
+	    let store = vmgrid.getStore();
+
+	    store.clearFilter();
+	    store.filterBy(function(rec) {
+		return !value || rec.get('node') === value;
+	    });
+
+	    let mode = me.lookup('modeSelector').getValue();
+	    if (mode === 'all') {
+		vmgrid.selModel.selectAll(true);
+	    }
+	    if (mode === 'pool') {
+		me.selectPoolMembers();
+	    }
+	},
+
+	storageChange: function(f, v) {
+	    let me = this;
+	    let rec = f.getStore().findRecord('storage', v, 0, false, true, true);
+	    let compressionSelector = me.lookup('compressionSelector');
+
+	    if (rec?.data?.type === 'pbs') {
+		compressionSelector.setValue('zstd');
+		compressionSelector.setDisabled(true);
+	    } else if (!compressionSelector.getEditable()) {
+		compressionSelector.setDisabled(false);
+	    }
+	},
+
+	selectPoolMembers: function() {
+	    let me = this;
+	    let mode = me.lookup('modeSelector').getValue();
+
+	    if (mode !== 'pool') {
+		return;
+	    }
+
+	    let vmgrid = me.lookup('vmgrid');
+	    let poolid = me.lookup('poolSelector').getValue();
+
+	    vmgrid.getSelectionModel().deselectAll(true);
+	    if (!poolid) {
+		return;
+	    }
+	    vmgrid.getStore().filter([
+		{
+		    id: 'poolFilter',
+		    property: 'pool',
+		    value: poolid,
+		},
+	    ]);
+	    vmgrid.selModel.selectAll(true);
+	},
+
+	modeChange: function(f, value, oldValue) {
+	    let me = this;
+	    let vmgrid = me.lookup('vmgrid');
+	    vmgrid.getStore().removeFilter('poolFilter');
+
+	    if (oldValue === 'all' && value !== 'all') {
+		vmgrid.getSelectionModel().deselectAll(true);
+	    }
+
+	    if (value === 'all') {
+		vmgrid.getSelectionModel().selectAll(true);
+	    }
+
+	    if (value === 'pool') {
+		me.selectPoolMembers();
+	    }
+	},
+
+	compressionChange: function(f, value, oldValue) {
+	    this.getView().lookup('backupAdvanced').updateCompression(value, f.isDisabled());
+	},
+
+	compressionDisable: function(f) {
+	    this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), true);
+	},
+
+	compressionEnable: function(f) {
+	    this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), false);
+	},
+
+	prepareValues: function(data) {
+	    let me = this;
+	    let viewModel = me.getViewModel();
+
+	    // Migrate 'new'-old notification-policy back to old-old mailnotification.
+	    // Only should affect users who used pve-manager from pvetest. This was a remnant of
+	    // notifications before the  overhaul.
+	    let policy = data['notification-policy'];
+	    if (policy === 'always' || policy === 'failure') {
+		data.mailnotification = policy;
+	    }
+
+	    if (data.exclude) {
+		data.vmid = data.exclude;
+		data.selMode = 'exclude';
+	    } else if (data.all) {
+		data.vmid = '';
+		data.selMode = 'all';
+	    } else if (data.pool) {
+		data.selMode = 'pool';
+		data.selPool = data.pool;
+	    } else {
+		data.selMode = 'include';
+	    }
+	    viewModel.set('selMode', data.selMode);
+
+	    if (data['prune-backups']) {
+		Object.assign(data, data['prune-backups']);
+		delete data['prune-backups'];
+	    } else if (data.maxfiles !== undefined) {
+		if (data.maxfiles > 0) {
+		    data['keep-last'] = data.maxfiles;
+		} else {
+		    data['keep-all'] = 1;
+		}
+		delete data.maxfiles;
+	    }
+
+	    if (data['notes-template']) {
+		data['notes-template'] =
+		    PVE.Utils.unEscapeNotesTemplate(data['notes-template']);
+	    }
+
+	    if (data.performance) {
+		Object.assign(data, data.performance);
+		delete data.performance;
+	    }
+
+	    return data;
+	},
+
+	init: function(view) {
+	    let me = this;
+
+	    if (view.isCreate) {
+		me.lookup('modeSelector').setValue('include');
+	    } else {
+		view.load({
+		    success: function(response, _options) {
+			let values = me.prepareValues(response.result.data);
+			view.setValues(values);
+		    },
+		});
+	    }
+	},
+    },
+
+    viewModel: {
+	data: {
+	    selMode: 'include',
+	    notificationMode: '__default__',
+	    mailto: '',
+	    mailNotification: 'always',
+	},
+
+	formulas: {
+	    poolMode: (get) => get('selMode') === 'pool',
+	    disableVMSelection: (get) => get('selMode') !== 'include' &&
+		get('selMode') !== 'exclude',
+	    showMailtoFields: (get) =>
+		['auto', 'legacy-sendmail', '__default__'].includes(get('notificationMode')),
+	    enableMailnotificationField: (get) => {
+		let mode = get('notificationMode');
+		let mailto = get('mailto');
+
+		return (['auto', '__default__'].includes(mode) && mailto) ||
+		    mode === 'legacy-sendmail';
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'tabpanel',
+	    region: 'center',
+	    layout: 'fit',
+	    bodyPadding: 10,
+	    items: [
+		{
+		    xtype: 'container',
+		    title: gettext('General'),
+		    region: 'center',
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    items: [
+			{
+			    xtype: 'inputpanel',
+			    onlineHelp: 'chapter_vzdump',
+			    column1: [
+				{
+				    xtype: 'pveNodeSelector',
+				    name: 'node',
+				    fieldLabel: gettext('Node'),
+				    allowBlank: true,
+				    editable: true,
+				    autoSelect: false,
+				    emptyText: '-- ' + gettext('All') + ' --',
+				    listeners: {
+					change: 'nodeChange',
+				    },
+				},
+				{
+				    xtype: 'pveStorageSelector',
+				    reference: 'storageSelector',
+				    fieldLabel: gettext('Storage'),
+				    clusterView: true,
+				    storageContent: 'backup',
+				    allowBlank: false,
+				    name: 'storage',
+				    listeners: {
+					change: 'storageChange',
+				    },
+				},
+				{
+				    xtype: 'pveCalendarEvent',
+				    fieldLabel: gettext('Schedule'),
+				    allowBlank: false,
+				    name: 'schedule',
+				},
+				{
+				    xtype: 'proxmoxKVComboBox',
+				    reference: 'modeSelector',
+				    comboItems: [
+					['include', gettext('Include selected VMs')],
+					['all', gettext('All')],
+					['exclude', gettext('Exclude selected VMs')],
+					['pool', gettext('Pool based')],
+				    ],
+				    fieldLabel: gettext('Selection mode'),
+				    name: 'selMode',
+				    value: '',
+				    bind: {
+					value: '{selMode}',
+				    },
+				    listeners: {
+					change: 'modeChange',
+				    },
+				},
+				{
+				    xtype: 'pvePoolSelector',
+				    reference: 'poolSelector',
+				    fieldLabel: gettext('Pool to backup'),
+				    hidden: true,
+				    allowBlank: false,
+				    name: 'pool',
+				    listeners: {
+					change: 'selectPoolMembers',
+				    },
+				    bind: {
+					hidden: '{!poolMode}',
+					disabled: '{!poolMode}',
+				    },
+				},
+			    ],
+			    column2: [
+				{
+				    xtype: 'proxmoxKVComboBox',
+				    comboItems: [
+					[
+					    '__default__',
+					    Ext.String.format(
+						gettext('{0} (Auto)'), Proxmox.Utils.defaultText,
+					    ),
+					],
+					['auto', gettext('Auto')],
+					['legacy-sendmail', gettext('Email (legacy)')],
+					['notification-system', gettext('Notification system')],
+				    ],
+				    fieldLabel: gettext('Notification mode'),
+				    name: 'notification-mode',
+				    value: '__default__',
+				    cbind: {
+					deleteEmpty: '{!isCreate}',
+				    },
+				    bind: {
+					value: '{notificationMode}',
+				    },
+				},
+				{
+				    xtype: 'textfield',
+				    fieldLabel: gettext('Send email to'),
+				    name: 'mailto',
+				    bind: {
+					hidden: '{!showMailtoFields}',
+					value: '{mailto}',
+				    },
+				},
+				{
+				    xtype: 'pveEmailNotificationSelector',
+				    fieldLabel: gettext('Send email'),
+				    name: 'mailnotification',
+				    cbind: {
+					value: (get) => get('isCreate') ? 'always' : '',
+					deleteEmpty: '{!isCreate}',
+				    },
+				    bind: {
+					hidden: '{!showMailtoFields}',
+					disabled: '{!enableMailnotificationField}',
+					value: '{mailNotification}',
+				    },
+				},
+				{
+				    xtype: 'pveBackupCompressionSelector',
+				    reference: 'compressionSelector',
+				    fieldLabel: gettext('Compression'),
+				    name: 'compress',
+				    cbind: {
+					deleteEmpty: '{!isCreate}',
+				    },
+				    value: 'zstd',
+				    listeners: {
+					change: 'compressionChange',
+					disable: 'compressionDisable',
+					enable: 'compressionEnable',
+				    },
+				},
+				{
+				    xtype: 'pveBackupModeSelector',
+				    fieldLabel: gettext('Mode'),
+				    value: 'snapshot',
+				    name: 'mode',
+				},
+				{
+				    xtype: 'proxmoxcheckbox',
+				    fieldLabel: gettext('Enable'),
+				    name: 'enabled',
+				    uncheckedValue: 0,
+				    defaultValue: 1,
+				    checked: true,
+				},
+			    ],
+			    columnB: [
+				{
+				    xtype: 'proxmoxtextfield',
+				    name: 'comment',
+				    fieldLabel: gettext('Job Comment'),
+				    cbind: {
+					deleteEmpty: '{!isCreate}',
+				    },
+				    autoEl: {
+					tag: 'div',
+					'data-qtip': gettext('Description of the job'),
+				    },
+				},
+				{
+				    xtype: 'vmselector',
+				    reference: 'vmgrid',
+				    height: 300,
+				    name: 'vmid',
+				    disabled: true,
+				    allowBlank: false,
+				    columnSelection: ['vmid', 'node', 'status', 'name', 'type'],
+				    bind: {
+					disabled: '{disableVMSelection}',
+				    },
+				},
+			    ],
+			    onGetValues: function(values) {
+				return this.up('window').getController().onGetValues(values);
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'pveBackupJobPrunePanel',
+		    title: gettext('Retention'),
+		    cbind: {
+			isCreate: '{isCreate}',
+		    },
+		    keepAllDefaultForCreate: false,
+		    showPBSHint: false,
+		    fallbackHintHtml: gettext('Without any keep option, the storage\'s configuration or node\'s vzdump.conf is used as fallback'),
+		},
+		{
+		    xtype: 'inputpanel',
+		    title: gettext('Note Template'),
+		    region: 'center',
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    onGetValues: function(values) {
+			if (values['notes-template']) {
+			    values['notes-template'] =
+				PVE.Utils.escapeNotesTemplate(values['notes-template']);
+			}
+			return values;
+		    },
+		    items: [
+			{
+			    xtype: 'textarea',
+			    name: 'notes-template',
+			    fieldLabel: gettext('Backup Notes'),
+			    height: 100,
+			    maxLength: 512,
+			    cbind: {
+				deleteEmpty: '{!isCreate}',
+				value: (get) => get('isCreate') ? '{{guestname}}' : undefined,
+			    },
+			},
+			{
+			    xtype: 'box',
+			    style: {
+				margin: '8px 0px',
+				'line-height': '1.5em',
+			    },
+			    html: gettext('The notes are added to each backup created by this job.')
+				+ '<br>'
+				+ Ext.String.format(
+				    gettext('Possible template variables are: {0}'),
+				    PVE.Utils.notesTemplateVars.map(v => `<code>{{${v}}}</code>`).join(', '),
+				),
+			},
+		    ],
+		},
+		{
+		    xtype: 'pveBackupAdvancedOptionsPanel',
+		    reference: 'backupAdvanced',
+		    title: gettext('Advanced'),
+		    cbind: {
+			isCreate: '{isCreate}',
+		    },
+		},
+	    ],
+	},
+    ],
+});
+
+Ext.define('PVE.dc.BackupView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveDcBackupView'],
+
+    onlineHelp: 'chapter_vzdump',
+
+    allText: '-- ' + gettext('All') + ' --',
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-cluster-backup',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/backup",
+	    },
+	});
+
+	let not_backed_store = new Ext.data.Store({
+	    sorters: 'vmid',
+	    proxy: {
+		type: 'proxmox',
+		url: 'api2/json/cluster/backup-info/not-backed-up',
+	    },
+	});
+
+	let noBackupJobInfoButton;
+	let reload = function() {
+	    store.load();
+	    not_backed_store.load({
+		callback: records => noBackupJobInfoButton.setVisible(records.length > 0),
+	    });
+	};
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    Ext.create('PVE.dc.BackupEdit', {
+		autoShow: true,
+		jobid: rec.data.id,
+		listeners: {
+		    destroy: () => reload(),
+		},
+	    });
+	};
+
+	let run_detail = function() {
+	    let record = sm.getSelection()[0];
+	    if (!record) {
+		return;
+	    }
+	    Ext.create('Ext.window.Window', {
+		modal: true,
+		width: 800,
+		height: Ext.getBody().getViewSize().height > 1000 ? 800 : 600, // factor out as common infra?
+		resizable: true,
+		layout: 'fit',
+		title: gettext('Backup Details'),
+		items: [
+		    {
+			xtype: 'panel',
+			region: 'center',
+			layout: {
+			    type: 'vbox',
+			    align: 'stretch',
+			},
+			items: [
+			    {
+				xtype: 'pveBackupInfo',
+				flex: 0,
+				layout: 'fit',
+				record: record.data,
+			    },
+			    {
+				xtype: 'pveBackupDiskTree',
+				title: gettext('Included disks'),
+				flex: 1,
+				jobid: record.data.id,
+			    },
+			],
+		    },
+		],
+	    }).show();
+	};
+
+	let run_backup_now = function(job) {
+	    job = Ext.clone(job);
+
+	    let jobNode = job.node;
+	    // Remove properties related to scheduling
+	    delete job.enabled;
+	    delete job.starttime;
+	    delete job.dow;
+	    delete job.id;
+	    delete job.schedule;
+	    delete job.type;
+	    delete job.node;
+	    delete job.comment;
+	    delete job['next-run'];
+	    delete job['repeat-missed'];
+	    job.all = job.all === true ? 1 : 0;
+
+	    ['performance', 'prune-backups', 'fleecing'].forEach(key => {
+		if (job[key]) {
+		    job[key] = PVE.Parser.printPropertyString(job[key]);
+		}
+	    });
+
+	    let allNodes = PVE.data.ResourceStore.getNodes();
+	    let nodes = allNodes.filter(node => node.status === 'online').map(node => node.node);
+	    let errors = [];
+
+	    if (jobNode !== undefined) {
+		if (!nodes.includes(jobNode)) {
+		    Ext.Msg.alert('Error', "Node '"+ jobNode +"' from backup job isn't online!");
+		    return;
+		}
+		nodes = [jobNode];
+	    } else {
+		let unkownNodes = allNodes.filter(node => node.status !== 'online');
+		if (unkownNodes.length > 0) {errors.push(unkownNodes.map(node => node.node + ": " + gettext("Node is offline")));}
+	    }
+	    let jobTotalCount = nodes.length, jobsStarted = 0;
+
+	    Ext.Msg.show({
+		title: gettext('Please wait...'),
+		closable: false,
+		progress: true,
+		progressText: '0/' + jobTotalCount,
+	    });
+
+	    let postRequest = function() {
+		jobsStarted++;
+		Ext.Msg.updateProgress(jobsStarted / jobTotalCount, jobsStarted + '/' + jobTotalCount);
+
+		if (jobsStarted === jobTotalCount) {
+		    Ext.Msg.hide();
+		    if (errors.length > 0) {
+			Ext.Msg.alert('Error', 'Some errors have been encountered:<br />' + errors.join('<br />'));
+		    }
+		}
+	    };
+
+	    nodes.forEach(node => Proxmox.Utils.API2Request({
+		url: '/nodes/' + node + '/vzdump',
+		method: 'POST',
+		params: job,
+		failure: function(response, opts) {
+		    errors.push(node + ': ' + response.htmlStatus);
+		    postRequest();
+		},
+		success: postRequest,
+	    }));
+	};
+
+	var edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	var run_btn = new Proxmox.button.Button({
+	    text: gettext('Run now'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: function() {
+		var rec = sm.getSelection()[0];
+		if (!rec) {
+		    return;
+		}
+
+		Ext.Msg.show({
+		    title: gettext('Confirm'),
+		    icon: Ext.Msg.QUESTION,
+		    msg: gettext('Start the selected backup job now?'),
+		    buttons: Ext.Msg.YESNO,
+		    callback: function(btn) {
+			if (btn !== 'yes') {
+			    return;
+			}
+			run_backup_now(rec.data);
+		    },
+		});
+	    },
+	});
+
+	var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/backup',
+	    callback: function() {
+		reload();
+	    },
+	});
+
+	var detail_btn = new Proxmox.button.Button({
+	    text: gettext('Job Detail'),
+	    disabled: true,
+	    tooltip: gettext('Show job details and which guests and volumes are affected by the backup job'),
+	    selModel: sm,
+	    handler: run_detail,
+	});
+
+	noBackupJobInfoButton = new Proxmox.button.Button({
+	    text: `${gettext('Show')}: ${gettext('Guests Without Backup Job')}`,
+	    tooltip: gettext('Some guests are not covered by any backup job.'),
+	    iconCls: 'fa fa-fw fa-exclamation-circle',
+	    hidden: true,
+	    handler: () => {
+		Ext.create('Ext.window.Window', {
+		    autoShow: true,
+		    modal: true,
+		    width: 600,
+		    height: 500,
+		    resizable: true,
+		    layout: 'fit',
+		    title: gettext('Guests Without Backup Job'),
+		    items: [
+			{
+			    xtype: 'panel',
+			    region: 'center',
+			    layout: {
+				type: 'vbox',
+				align: 'stretch',
+			    },
+			    items: [
+				{
+				    xtype: 'pveBackedGuests',
+				    flex: 1,
+				    layout: 'fit',
+				    store: not_backed_store,
+				},
+			    ],
+			},
+		    ],
+		});
+	    },
+	});
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    stateful: true,
+	    stateId: 'grid-dc-backup',
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    dockedItems: [{
+		xtype: 'toolbar',
+		overflowHandler: 'scroller',
+		dock: 'top',
+		items: [
+		    {
+			text: gettext('Add'),
+			handler: function() {
+			    var win = Ext.create('PVE.dc.BackupEdit', {});
+			    win.on('destroy', reload);
+			    win.show();
+			},
+		    },
+		    '-',
+		    remove_btn,
+		    edit_btn,
+		    detail_btn,
+		    '-',
+		    run_btn,
+		    '->',
+		    noBackupJobInfoButton,
+		    '-',
+		    {
+			xtype: 'proxmoxButton',
+			selModel: null,
+			text: gettext('Schedule Simulator'),
+			handler: () => {
+			    let record = sm.getSelection()[0];
+			    let schedule;
+			    if (record) {
+				schedule = record.data.schedule;
+			    }
+			    Ext.create('PVE.window.ScheduleSimulator', {
+				autoShow: true,
+				schedule,
+			    });
+			},
+		    },
+		],
+	    }],
+	    columns: [
+		{
+		    header: gettext('Enabled'),
+		    width: 80,
+		    dataIndex: 'enabled',
+		    align: 'center',
+		    renderer: Proxmox.Utils.renderEnabledIcon,
+		    sortable: true,
+		},
+		{
+		    header: gettext('ID'),
+		    dataIndex: 'id',
+		    hidden: true,
+		},
+		{
+		    header: gettext('Node'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'node',
+		    renderer: function(value) {
+			if (value) {
+			    return value;
+			}
+			return me.allText;
+		    },
+		},
+		{
+		    header: gettext('Schedule'),
+		    width: 150,
+		    dataIndex: 'schedule',
+		},
+		{
+		    text: gettext('Next Run'),
+		    dataIndex: 'next-run',
+		    width: 150,
+		    renderer: PVE.Utils.render_next_event,
+		},
+		{
+		    header: gettext('Storage'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'storage',
+		},
+		{
+		    header: gettext('Comment'),
+		    dataIndex: 'comment',
+		    renderer: Ext.htmlEncode,
+		    sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''),
+		    flex: 1,
+		},
+		{
+		    header: gettext('Retention'),
+		    dataIndex: 'prune-backups',
+		    renderer: v => v ? PVE.Parser.printPropertyString(v) : gettext('Fallback from storage config'),
+		    flex: 2,
+		},
+		{
+		    header: gettext('Selection'),
+		    flex: 4,
+		    sortable: false,
+		    dataIndex: 'vmid',
+		    renderer: PVE.Utils.render_backup_selection,
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-cluster-backup', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'id',
+	    'compress',
+	    'dow',
+	    'exclude',
+	    'mailto',
+	    'mode',
+	    'node',
+	    'pool',
+	    'prune-backups',
+	    'starttime',
+	    'storage',
+	    'vmid',
+	    { name: 'enabled', type: 'boolean' },
+	    { name: 'all', type: 'boolean' },
+	],
+    });
+});
+Ext.define('pve-cluster-nodes', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'node', { type: 'integer', name: 'nodeid' }, 'ring0_addr', 'ring1_addr',
+	{ type: 'integer', name: 'quorum_votes' },
+    ],
+    proxy: {
+        type: 'proxmox',
+	url: "/api2/json/cluster/config/nodes",
+    },
+    idProperty: 'nodeid',
+});
+
+Ext.define('pve-cluster-info', {
+    extend: 'Ext.data.Model',
+    proxy: {
+        type: 'proxmox',
+	url: "/api2/json/cluster/config/join",
+    },
+});
+
+Ext.define('PVE.ClusterAdministration', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveClusterAdministration',
+
+    title: gettext('Cluster Administration'),
+    onlineHelp: 'chapter_pvecm',
+
+    border: false,
+    defaults: { border: false },
+
+    viewModel: {
+	parent: null,
+	data: {
+	    totem: {},
+	    nodelist: [],
+	    preferred_node: {
+		name: '',
+		fp: '',
+		addr: '',
+	    },
+	    isInCluster: false,
+	    nodecount: 0,
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'panel',
+	    title: gettext('Cluster Information'),
+	    controller: {
+		xclass: 'Ext.app.ViewController',
+
+		init: function(view) {
+		    view.store = Ext.create('Proxmox.data.UpdateStore', {
+			autoStart: true,
+			interval: 15 * 1000,
+			storeid: 'pve-cluster-info',
+			model: 'pve-cluster-info',
+		    });
+		    view.store.on('load', this.onLoad, this);
+		    view.on('destroy', view.store.stopUpdate);
+		},
+
+		onLoad: function(store, records, success, operation) {
+		    let vm = this.getViewModel();
+
+		    let data = records?.[0]?.data;
+		    if (!success || !data || !data.nodelist?.length) {
+			let error = operation.getError();
+			if (error) {
+			    let msg = Proxmox.Utils.getResponseErrorMessage(error);
+			    if (error.status !== 424 && !msg.match(/node is not in a cluster/i)) {
+				// an actual error, not just the "not in a cluster one", so show it!
+				Proxmox.Utils.setErrorMask(this.getView(), msg);
+			    }
+			}
+			vm.set('totem', {});
+			vm.set('isInCluster', false);
+			vm.set('nodelist', []);
+			vm.set('preferred_node', {
+			    name: '',
+			    addr: '',
+			    fp: '',
+			});
+			return;
+		    }
+		    vm.set('totem', data.totem);
+		    vm.set('isInCluster', !!data.totem.cluster_name);
+		    vm.set('nodelist', data.nodelist);
+
+		    let nodeinfo = data.nodelist.find(el => el.name === data.preferred_node);
+
+		    let links = {};
+		    let ring_addr = [];
+		    PVE.Utils.forEachCorosyncLink(nodeinfo, (num, link) => {
+			links[num] = link;
+			ring_addr.push(link);
+		    });
+
+		    vm.set('preferred_node', {
+			name: data.preferred_node,
+			addr: nodeinfo.pve_addr,
+			peerLinks: links,
+			ring_addr: ring_addr,
+			fp: nodeinfo.pve_fp,
+		    });
+		},
+
+		onCreate: function() {
+		    let view = this.getView();
+		    view.store.stopUpdate();
+		    Ext.create('PVE.ClusterCreateWindow', {
+			autoShow: true,
+			listeners: {
+			    destroy: function() {
+				view.store.startUpdate();
+			    },
+			},
+		    });
+		},
+
+		onClusterInfo: function() {
+		    let vm = this.getViewModel();
+		    Ext.create('PVE.ClusterInfoWindow', {
+			autoShow: true,
+			joinInfo: {
+			    ipAddress: vm.get('preferred_node.addr'),
+			    fingerprint: vm.get('preferred_node.fp'),
+			    peerLinks: vm.get('preferred_node.peerLinks'),
+			    ring_addr: vm.get('preferred_node.ring_addr'),
+			    totem: vm.get('totem'),
+			},
+		    });
+		},
+
+		onJoin: function() {
+		    let view = this.getView();
+		    view.store.stopUpdate();
+		    Ext.create('PVE.ClusterJoinNodeWindow', {
+			autoShow: true,
+			listeners: {
+			    destroy: function() {
+				view.store.startUpdate();
+			    },
+			},
+		    });
+		},
+	    },
+	    tbar: [
+		{
+		    text: gettext('Create Cluster'),
+		    reference: 'createButton',
+		    handler: 'onCreate',
+		    bind: {
+			disabled: '{isInCluster}',
+		    },
+		},
+		{
+		    text: gettext('Join Information'),
+		    reference: 'addButton',
+		    handler: 'onClusterInfo',
+		    bind: {
+			disabled: '{!isInCluster}',
+		    },
+		},
+		{
+		    text: gettext('Join Cluster'),
+		    reference: 'joinButton',
+		    handler: 'onJoin',
+		    bind: {
+			disabled: '{isInCluster}',
+		    },
+		},
+	    ],
+	    layout: 'hbox',
+	    bodyPadding: 5,
+	    items: [
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Cluster Name'),
+		    bind: {
+			value: '{totem.cluster_name}',
+			hidden: '{!isInCluster}',
+		    },
+		    flex: 1,
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Config Version'),
+		    bind: {
+			value: '{totem.config_version}',
+			hidden: '{!isInCluster}',
+		    },
+		    flex: 1,
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Number of Nodes'),
+		    labelWidth: 120,
+		    bind: {
+			value: '{nodecount}',
+			hidden: '{!isInCluster}',
+		    },
+		    flex: 1,
+		},
+		{
+		    xtype: 'displayfield',
+		    value: gettext('Standalone node - no cluster defined'),
+		    bind: {
+			hidden: '{isInCluster}',
+		    },
+		    flex: 1,
+		},
+	    ],
+	},
+	{
+	    xtype: 'grid',
+	    title: gettext('Cluster Nodes'),
+	    autoScroll: true,
+	    enableColumnHide: false,
+	    controller: {
+		xclass: 'Ext.app.ViewController',
+
+		init: function(view) {
+		    view.rstore = Ext.create('Proxmox.data.UpdateStore', {
+			autoLoad: true,
+			xtype: 'update',
+			interval: 5 * 1000,
+			autoStart: true,
+			storeid: 'pve-cluster-nodes',
+			model: 'pve-cluster-nodes',
+		    });
+		    view.setStore(Ext.create('Proxmox.data.DiffStore', {
+			rstore: view.rstore,
+			sorters: {
+			    property: 'nodeid',
+			    direction: 'ASC',
+			},
+		    }));
+		    Proxmox.Utils.monStoreErrors(view, view.rstore);
+		    view.rstore.on('load', this.onLoad, this);
+		    view.on('destroy', view.rstore.stopUpdate);
+		},
+
+		onLoad: function(store, records, success) {
+		    let view = this.getView();
+		    let vm = this.getViewModel();
+
+		    if (!success || !records || !records.length) {
+			vm.set('nodecount', 0);
+			return;
+		    }
+		    vm.set('nodecount', records.length);
+
+		    // show/hide columns according to used links
+		    let linkIndex = view.columns.length;
+		    Ext.each(view.columns, (col, i) => {
+			if (col.linkNumber !== undefined) {
+			    col.setHidden(true);
+			    // save offset at which link columns start, so we can address them directly below
+			    if (i < linkIndex) {
+				linkIndex = i;
+			    }
+			}
+		    });
+
+		    PVE.Utils.forEachCorosyncLink(records[0].data,
+			(linknum, val) => {
+			    if (linknum > 7) {
+				return;
+			    }
+			    view.columns[linkIndex + linknum].setHidden(false);
+			},
+		    );
+		},
+	    },
+	    columns: {
+		items: [
+		    {
+			header: gettext('Nodename'),
+			hidden: false,
+			dataIndex: 'name',
+		    },
+		    {
+			header: gettext('ID'),
+			minWidth: 100,
+			width: 100,
+			flex: 0,
+			hidden: false,
+			dataIndex: 'nodeid',
+		    },
+		    {
+			header: gettext('Votes'),
+			minWidth: 100,
+			width: 100,
+			flex: 0,
+			hidden: false,
+			dataIndex: 'quorum_votes',
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 0),
+			dataIndex: 'ring0_addr',
+			linkNumber: 0,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 1),
+			dataIndex: 'ring1_addr',
+			linkNumber: 1,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 2),
+			dataIndex: 'ring2_addr',
+			linkNumber: 2,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 3),
+			dataIndex: 'ring3_addr',
+			linkNumber: 3,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 4),
+			dataIndex: 'ring4_addr',
+			linkNumber: 4,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 5),
+			dataIndex: 'ring5_addr',
+			linkNumber: 5,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 6),
+			dataIndex: 'ring6_addr',
+			linkNumber: 6,
+		    },
+		    {
+			header: Ext.String.format(gettext('Link {0}'), 7),
+			dataIndex: 'ring7_addr',
+			linkNumber: 7,
+		    },
+		],
+		defaults: {
+		    flex: 1,
+		    hidden: true,
+		    minWidth: 150,
+		},
+	    },
+	},
+    ],
+});
+Ext.define('PVE.ClusterCreateWindow', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveClusterCreateWindow',
+
+    title: gettext('Create Cluster'),
+    width: 600,
+
+    method: 'POST',
+    url: '/cluster/config',
+
+    isCreate: true,
+    subject: gettext('Cluster'),
+    showTaskViewer: true,
+
+    onlineHelp: 'pvecm_create_cluster',
+
+    items: {
+	xtype: 'inputpanel',
+	items: [{
+	    xtype: 'textfield',
+	    fieldLabel: gettext('Cluster Name'),
+	    allowBlank: false,
+	    maxLength: 15,
+	    name: 'clustername',
+	},
+	{
+	    xtype: 'fieldcontainer',
+	    fieldLabel: gettext("Cluster Network"),
+	    items: [
+		{
+		    xtype: 'pveCorosyncLinkEditor',
+		    infoText: gettext("Multiple links are used as failover, lower numbers have higher priority."),
+		    name: 'links',
+		},
+	    ],
+	}],
+    },
+});
+
+Ext.define('PVE.ClusterInfoWindow', {
+    extend: 'Ext.window.Window',
+    xtype: 'pveClusterInfoWindow',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 800,
+    modal: true,
+    resizable: false,
+    title: gettext('Cluster Join Information'),
+
+    joinInfo: {
+	ipAddress: undefined,
+	fingerprint: undefined,
+	totem: {},
+    },
+
+    items: [
+	{
+	    xtype: 'component',
+	    border: false,
+	    padding: '10 10 10 10',
+	    html: gettext("Copy the Join Information here and use it on the node you want to add."),
+	},
+	{
+	    xtype: 'container',
+	    layout: 'form',
+	    border: false,
+	    padding: '0 10 10 10',
+	    items: [
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('IP Address'),
+		    cbind: {
+			value: '{joinInfo.ipAddress}',
+		    },
+		    editable: false,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Fingerprint'),
+		    cbind: {
+			value: '{joinInfo.fingerprint}',
+		    },
+		    editable: false,
+		},
+		{
+		    xtype: 'textarea',
+		    inputId: 'pveSerializedClusterInfo',
+		    fieldLabel: gettext('Join Information'),
+		    grow: true,
+		    cbind: {
+			joinInfo: '{joinInfo}',
+		    },
+		    editable: false,
+		    listeners: {
+			afterrender: function(field) {
+			    if (!field.joinInfo) {
+				return;
+			    }
+			    var jsons = Ext.JSON.encode(field.joinInfo);
+			    var base64s = Ext.util.Base64.encode(jsons);
+			    field.setValue(base64s);
+			},
+		    },
+		},
+	    ],
+	},
+    ],
+    dockedItems: [{
+	dock: 'bottom',
+	xtype: 'toolbar',
+	items: [{
+	    xtype: 'button',
+	    handler: function(b) {
+		var el = document.getElementById('pveSerializedClusterInfo');
+		el.select();
+		document.execCommand("copy");
+	    },
+	    text: gettext('Copy Information'),
+	}],
+    }],
+});
+
+Ext.define('PVE.ClusterJoinNodeWindow', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveClusterJoinNodeWindow',
+
+    title: gettext('Cluster Join'),
+    width: 800,
+
+    method: 'POST',
+    url: '/cluster/config/join',
+
+    defaultFocus: 'textarea[name=serializedinfo]',
+    isCreate: true,
+    bind: {
+	submitText: '{submittxt}',
+    },
+    showTaskViewer: true,
+
+    onlineHelp: 'pvecm_join_node_to_cluster',
+
+    viewModel: {
+	parent: null,
+	data: {
+	    info: {
+		fp: '',
+		ip: '',
+		clusterName: '',
+	    },
+	    hasAssistedInfo: false,
+	},
+	formulas: {
+	    submittxt: function(get) {
+		let cn = get('info.clusterName');
+		if (cn) {
+		    return Ext.String.format(gettext('Join {0}'), `'${cn}'`);
+		}
+		return gettext('Join');
+	    },
+	    showClusterFields: (get) => {
+		let manualMode = !get('assistedEntry.checked');
+		return get('hasAssistedInfo') || manualMode;
+	    },
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    '#': {
+		close: function() {
+		    delete PVE.Utils.silenceAuthFailures;
+		},
+	    },
+	    'proxmoxcheckbox[name=assistedEntry]': {
+		change: 'onInputTypeChange',
+	    },
+	    'textarea[name=serializedinfo]': {
+		change: 'recomputeSerializedInfo',
+		enable: 'resetField',
+	    },
+	    'textfield': {
+		disable: 'resetField',
+	    },
+	},
+	resetField: function(field) {
+	    field.reset();
+	},
+	onInputTypeChange: function(field, assistedInput) {
+	    let linkEditor = this.lookup('linkEditor');
+
+	    // this also clears all links
+	    linkEditor.setAllowNumberEdit(!assistedInput);
+
+	    if (!assistedInput) {
+		linkEditor.setInfoText();
+		linkEditor.setDefaultLinks();
+	    }
+	},
+	recomputeSerializedInfo: function(field, value) {
+	    let vm = this.getViewModel();
+
+	    let assistedEntryBox = this.lookup('assistedEntry');
+
+	    if (!assistedEntryBox.getValue()) {
+		// not in assisted entry mode, nothing to do
+		vm.set('hasAssistedInfo', false);
+		return;
+	    }
+
+	    let linkEditor = this.lookup('linkEditor');
+
+	    let jsons = Ext.util.Base64.decode(value);
+	    let joinInfo = Ext.JSON.decode(jsons, true);
+
+	    let info = {
+		fp: '',
+		ip: '',
+		clusterName: '',
+	    };
+
+	    if (!(joinInfo && joinInfo.totem)) {
+		field.valid = false;
+		linkEditor.setLinks([]);
+		linkEditor.setInfoText();
+		vm.set('hasAssistedInfo', false);
+	    } else {
+		let interfaces = joinInfo.totem.interface;
+		let links = Object.values(interfaces).map(iface => {
+		    let linkNumber = iface.linknumber;
+		    let peerLink;
+		    if (joinInfo.peerLinks) {
+			peerLink = joinInfo.peerLinks[linkNumber];
+		    }
+		    return {
+			number: linkNumber,
+			value: '',
+			text: peerLink ? Ext.String.format(gettext("peer's link address: {0}"), peerLink) : '',
+			allowBlank: false,
+		    };
+		});
+
+		linkEditor.setInfoText();
+		if (links.length === 1 && joinInfo.ring_addr !== undefined &&
+		    joinInfo.ring_addr[0] === joinInfo.ipAddress
+		) {
+		    links[0].allowBlank = true;
+		    links[0].emptyText = gettext("IP resolved by node's hostname");
+		}
+
+		linkEditor.setLinks(links);
+
+		info = {
+		    ip: joinInfo.ipAddress,
+		    fp: joinInfo.fingerprint,
+		    clusterName: joinInfo.totem.cluster_name,
+		};
+		field.valid = true;
+		vm.set('hasAssistedInfo', true);
+	    }
+	    vm.set('info', info);
+	},
+    },
+
+    submit: function() {
+	// joining may produce temporarily auth failures, ignore as long the task runs
+	PVE.Utils.silenceAuthFailures = true;
+	this.callParent();
+    },
+
+    taskDone: function(success) {
+	delete PVE.Utils.silenceAuthFailures;
+	if (success) {
+	    // reload always (if user wasn't faster), but wait a bit for pveproxy
+	    Ext.defer(function() {
+		window.location.reload(true);
+	    }, 5000);
+	    let txt = gettext('Cluster join task finished, node certificate may have changed, reload GUI!');
+	    // ensure user cannot do harm
+	    Ext.getBody().mask(txt, ['pve-static-mask']);
+	    // TaskView may hide above mask, so tell him directly
+	    Ext.Msg.show({
+		title: gettext('Join Task Finished'),
+		icon: Ext.Msg.INFO,
+		msg: txt,
+	    });
+	}
+    },
+
+    items: [{
+	xtype: 'proxmoxcheckbox',
+	reference: 'assistedEntry',
+	name: 'assistedEntry',
+	itemId: 'assistedEntry',
+	submitValue: false,
+	value: true,
+	autoEl: {
+	    tag: 'div',
+	    'data-qtip': gettext('Select if join information should be extracted from pasted cluster information, deselect for manual entering'),
+	},
+	boxLabel: gettext('Assisted join: Paste encoded cluster join information and enter password.'),
+    },
+    {
+	xtype: 'textarea',
+	name: 'serializedinfo',
+	submitValue: false,
+	allowBlank: false,
+	fieldLabel: gettext('Information'),
+	emptyText: gettext('Paste encoded Cluster Information here'),
+	validator: function(val) {
+	    return val === '' || this.valid || gettext('Does not seem like a valid encoded Cluster Information!');
+	},
+	bind: {
+	    disabled: '{!assistedEntry.checked}',
+	    hidden: '{!assistedEntry.checked}',
+	},
+	value: '',
+    },
+    {
+	xtype: 'panel',
+	width: 776,
+	layout: {
+	    type: 'hbox',
+	    align: 'center',
+	},
+	bind: {
+	    hidden: '{!showClusterFields}',
+	},
+	items: [
+	    {
+		xtype: 'textfield',
+		flex: 1,
+		margin: '0 5px 0 0',
+		fieldLabel: gettext('Peer Address'),
+		allowBlank: false,
+		bind: {
+		    value: '{info.ip}',
+		    readOnly: '{assistedEntry.checked}',
+		},
+		name: 'hostname',
+	    },
+	    {
+		xtype: 'textfield',
+		flex: 1,
+		margin: '0 0 10px 5px',
+		inputType: 'password',
+		emptyText: gettext("Peer's root password"),
+		fieldLabel: gettext('Password'),
+		allowBlank: false,
+		name: 'password',
+	    },
+	],
+    },
+    {
+	xtype: 'textfield',
+	fieldLabel: gettext('Fingerprint'),
+	allowBlank: false,
+	bind: {
+	    value: '{info.fp}',
+	    readOnly: '{assistedEntry.checked}',
+	    hidden: '{!showClusterFields}',
+	},
+	name: 'fingerprint',
+    },
+    {
+	xtype: 'fieldcontainer',
+	fieldLabel: gettext("Cluster Network"),
+	bind: {
+	    hidden: '{!showClusterFields}',
+	},
+	items: [
+	    {
+		xtype: 'pveCorosyncLinkEditor',
+		itemId: 'linkEditor',
+		reference: 'linkEditor',
+		allowNumberEdit: false,
+	    },
+	],
+    }],
+});
+/*
+ * Datacenter config panel, located in the center of the ViewPort after the Datacenter view is selected
+ */
+
+Ext.define('PVE.dc.Config', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.PVE.dc.Config',
+
+    onlineHelp: 'pve_admin_guide',
+
+    initComponent: function() {
+        var me = this;
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	me.items = [];
+
+	Ext.apply(me, {
+	    title: gettext("Datacenter"),
+	    hstateid: 'dctab',
+	});
+
+	if (caps.dc['Sys.Audit']) {
+	    me.items.push({
+		title: gettext('Summary'),
+		xtype: 'pveDcSummary',
+		iconCls: 'fa fa-book',
+		itemId: 'summary',
+	    },
+	    {
+		xtype: 'pmxNotesView',
+		title: gettext('Notes'),
+		iconCls: 'fa fa-sticky-note-o',
+		itemId: 'notes',
+	    },
+	    {
+		title: gettext('Cluster'),
+		xtype: 'pveClusterAdministration',
+		iconCls: 'fa fa-server',
+		itemId: 'cluster',
+	    },
+	    {
+		title: 'Ceph',
+		itemId: 'ceph',
+		iconCls: 'fa fa-ceph',
+		xtype: 'pveNodeCephStatus',
+	    },
+	    {
+		xtype: 'pveDcOptionView',
+		title: gettext('Options'),
+		iconCls: 'fa fa-gear',
+		itemId: 'options',
+	    });
+	}
+
+	if (caps.storage['Datastore.Allocate'] || caps.dc['Sys.Audit']) {
+	    me.items.push({
+		xtype: 'pveStorageView',
+		title: gettext('Storage'),
+		iconCls: 'fa fa-database',
+		itemId: 'storage',
+	    });
+	}
+
+
+	if (caps.dc['Sys.Audit']) {
+	    me.items.push({
+		xtype: 'pveDcBackupView',
+		iconCls: 'fa fa-floppy-o',
+		title: gettext('Backup'),
+		itemId: 'backup',
+	    },
+	    {
+		xtype: 'pveReplicaView',
+		iconCls: 'fa fa-retweet',
+		title: gettext('Replication'),
+		itemId: 'replication',
+	    },
+	    {
+		xtype: 'pveACLView',
+		title: gettext('Permissions'),
+		iconCls: 'fa fa-unlock',
+		itemId: 'permissions',
+		expandedOnInit: true,
+	    });
+	}
+
+	me.items.push({
+	    xtype: 'pveUserView',
+	    groups: ['permissions'],
+	    iconCls: 'fa fa-user',
+	    title: gettext('Users'),
+	    itemId: 'users',
+	});
+
+	me.items.push({
+	    xtype: 'pveTokenView',
+	    groups: ['permissions'],
+	    iconCls: 'fa fa-user-o',
+	    title: gettext('API Tokens'),
+	    itemId: 'apitokens',
+	});
+
+	me.items.push({
+	    xtype: 'pmxTfaView',
+	    title: gettext('Two Factor'),
+	    groups: ['permissions'],
+	    iconCls: 'fa fa-key',
+	    itemId: 'tfa',
+	    yubicoEnabled: true,
+	    issuerName: `Proxmox VE - ${PVE.ClusterName || Proxmox.NodeName}`,
+	});
+
+	if (caps.dc['Sys.Audit']) {
+	    me.items.push({
+		xtype: 'pveGroupView',
+		title: gettext('Groups'),
+		iconCls: 'fa fa-users',
+		groups: ['permissions'],
+		itemId: 'groups',
+	    },
+	    {
+		xtype: 'pvePoolView',
+		title: gettext('Pools'),
+		iconCls: 'fa fa-tags',
+		groups: ['permissions'],
+		itemId: 'pools',
+	    },
+	    {
+		xtype: 'pveRoleView',
+		title: gettext('Roles'),
+		iconCls: 'fa fa-male',
+		groups: ['permissions'],
+		itemId: 'roles',
+	    },
+	    {
+		title: gettext('Realms'),
+		xtype: 'panel',
+		layout: {
+		    type: 'border',
+		},
+		groups: ['permissions'],
+		iconCls: 'fa fa-address-book-o',
+		itemId: 'domains',
+		items: [
+		    {
+			xtype: 'pveAuthView',
+			region: 'center',
+			border: false,
+		    },
+		    {
+			xtype: 'pveRealmSyncJobView',
+			title: gettext('Realm Sync Jobs'),
+			region: 'south',
+			collapsible: true,
+			animCollapse: false,
+			border: false,
+			height: '50%',
+		    },
+		],
+	    },
+	    {
+		xtype: 'pveHAStatus',
+		title: 'HA',
+		iconCls: 'fa fa-heartbeat',
+		itemId: 'ha',
+	    },
+	    {
+		title: gettext('Groups'),
+		groups: ['ha'],
+		xtype: 'pveHAGroupsView',
+		iconCls: 'fa fa-object-group',
+		itemId: 'ha-groups',
+	    },
+	    {
+		title: gettext('Fencing'),
+		groups: ['ha'],
+		iconCls: 'fa fa-bolt',
+		xtype: 'pveFencingView',
+		itemId: 'ha-fencing',
+	    });
+	    // always show on initial load, will be hiddea later if the SDN API calls don't exist,
+	    // else it won't be shown at first if the user initially loads with DC selected
+	    if (PVE.SDNInfo || PVE.SDNInfo === undefined) {
+		me.items.push({
+		    xtype: 'pveSDNStatus',
+		    title: gettext('SDN'),
+		    iconCls: 'fa fa-sdn x-fa-sdn-treelist',
+		    hidden: true,
+		    itemId: 'sdn',
+		    expandedOnInit: true,
+		},
+		{
+		    xtype: 'pveSDNZoneView',
+		    groups: ['sdn'],
+		    title: gettext('Zones'),
+		    hidden: true,
+		    iconCls: 'fa fa-th',
+		    itemId: 'sdnzone',
+		},
+		{
+		    xtype: 'pveSDNVnet',
+		    groups: ['sdn'],
+		    title: 'VNets',
+		    hidden: true,
+		    iconCls: 'fa fa-network-wired x-fa-sdn-treelist',
+		    itemId: 'sdnvnet',
+		},
+		{
+		    xtype: 'pveSDNOptions',
+		    groups: ['sdn'],
+		    title: gettext('Options'),
+		    hidden: true,
+		    iconCls: 'fa fa-gear',
+		    itemId: 'sdnoptions',
+		},
+		{
+		    xtype: 'pveDhcpTree',
+		    groups: ['sdn'],
+		    title: gettext('IPAM'),
+		    hidden: true,
+		    iconCls: 'fa fa-map-signs',
+		    itemId: 'sdnmappings',
+		});
+	    }
+
+	    if (Proxmox.UserName === 'root@pam') {
+		me.items.push({
+		    xtype: 'pveACMEClusterView',
+		    title: 'ACME',
+		    iconCls: 'fa fa-certificate',
+		    itemId: 'acme',
+		});
+	    }
+
+	    me.items.push({
+		xtype: 'pveFirewallRules',
+		title: gettext('Firewall'),
+		allow_iface: true,
+		base_url: '/cluster/firewall/rules',
+		list_refs_url: '/cluster/firewall/refs',
+		iconCls: 'fa fa-shield',
+		itemId: 'firewall',
+	    },
+	    {
+		xtype: 'pveFirewallOptions',
+		title: gettext('Options'),
+		groups: ['firewall'],
+		iconCls: 'fa fa-gear',
+		base_url: '/cluster/firewall/options',
+		onlineHelp: 'pve_firewall_cluster_wide_setup',
+		fwtype: 'dc',
+		itemId: 'firewall-options',
+	    },
+	    {
+		xtype: 'pveSecurityGroups',
+		title: gettext('Security Group'),
+		groups: ['firewall'],
+		iconCls: 'fa fa-group',
+		itemId: 'firewall-sg',
+	    },
+	    {
+		xtype: 'pveFirewallAliases',
+		title: gettext('Alias'),
+		groups: ['firewall'],
+		iconCls: 'fa fa-external-link',
+		base_url: '/cluster/firewall/aliases',
+		itemId: 'firewall-aliases',
+	    },
+	    {
+		xtype: 'pveIPSet',
+		title: 'IPSet',
+		groups: ['firewall'],
+		iconCls: 'fa fa-list-ol',
+		base_url: '/cluster/firewall/ipset',
+		list_refs_url: '/cluster/firewall/refs',
+		itemId: 'firewall-ipset',
+	    },
+	    {
+		xtype: 'pveMetricServerView',
+		title: gettext('Metric Server'),
+		iconCls: 'fa fa-bar-chart',
+		itemId: 'metricservers',
+		onlineHelp: 'external_metric_server',
+	    });
+	}
+
+	if (caps.mapping['Mapping.Audit'] ||
+	    caps.mapping['Mapping.Use'] ||
+	    caps.mapping['Mapping.Modify']) {
+	    me.items.push(
+		{
+		    xtype: 'container',
+		    onlineHelp: 'resource_mapping',
+		    title: gettext('Resource Mappings'),
+		    itemId: 'resources',
+		    iconCls: 'fa fa-folder-o',
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+			multi: true,
+		    },
+		    scrollable: true,
+		    defaults: {
+			border: false,
+		    },
+		    items: [
+			{
+			    xtype: 'pveDcPCIMapView',
+			    title: gettext('PCI Devices'),
+			    flex: 1,
+			},
+			{
+			    xtype: 'splitter',
+			    collapsible: false,
+			    performCollapse: false,
+			},
+			{
+			    xtype: 'pveDcUSBMapView',
+			    title: gettext('USB Devices'),
+			    flex: 1,
+			},
+		    ],
+		},
+	    );
+	}
+
+	if (caps.mapping['Mapping.Audit'] ||
+	    caps.mapping['Mapping.Use'] ||
+	    caps.mapping['Mapping.Modify']) {
+	    me.items.push(
+		{
+		    xtype: 'pmxNotificationConfigView',
+		    title: gettext('Notifications'),
+		    itemId: 'notification-targets',
+		    iconCls: 'fa fa-bell-o',
+		    baseUrl: '/cluster/notifications',
+		},
+	    );
+	}
+
+	if (caps.dc['Sys.Audit']) {
+	    me.items.push({
+		xtype: 'pveDcSupport',
+		title: gettext('Support'),
+		itemId: 'support',
+		iconCls: 'fa fa-comments-o',
+	    });
+	}
+
+	me.callParent();
+   },
+});
+Ext.define('PVE.form.CorosyncLinkEditorController', {
+    extend: 'Ext.app.ViewController',
+    alias: 'controller.pveCorosyncLinkEditorController',
+
+    addLinkIfEmpty: function() {
+	let view = this.getView();
+	if (view.items || view.items.length === 0) {
+	    this.addLink();
+	}
+    },
+
+    addEmptyLink: function() {
+	this.addLink(); // discard parameters to allow being called from 'handler'
+    },
+
+    addLink: function(link) {
+	let me = this;
+	let view = me.getView();
+	let vm = view.getViewModel();
+
+	let linkCount = vm.get('linkCount');
+	if (linkCount >= vm.get('maxLinkCount')) {
+	    return;
+	}
+
+	link = link || {};
+
+	if (link.number === undefined) {
+	    link.number = me.getNextFreeNumber();
+	}
+	if (link.value === undefined) {
+	    link.value = me.getNextFreeNetwork();
+	}
+
+	let linkSelector = Ext.create('PVE.form.CorosyncLinkSelector', {
+	    maxLinkNumber: vm.get('maxLinkCount') - 1,
+	    allowNumberEdit: vm.get('allowNumberEdit'),
+	    allowBlankNetwork: link.allowBlank,
+	    initNumber: link.number,
+	    initNetwork: link.value,
+	    text: link.text,
+	    emptyText: link.emptyText,
+
+	    // needs to be set here, because we need to update the viewmodel
+	    removeBtnHandler: function() {
+		let curLinkCount = vm.get('linkCount');
+
+		if (curLinkCount <= 1) {
+		    return;
+		}
+
+		vm.set('linkCount', curLinkCount - 1);
+
+		// 'this' is the linkSelector here
+		view.remove(this);
+
+		me.updateDeleteButtonState();
+	    },
+	});
+
+	view.add(linkSelector);
+
+	linkCount++;
+	vm.set('linkCount', linkCount);
+
+	me.updateDeleteButtonState();
+    },
+
+    // ExtJS trips on binding this for some reason, so do it manually
+    updateDeleteButtonState: function() {
+	let view = this.getView();
+	let vm = view.getViewModel();
+
+	let disabled = vm.get('linkCount') <= 1;
+
+	let deleteButtons = view.query('button[cls=removeLinkBtn]');
+	Ext.Array.each(deleteButtons, btn => {
+	    btn.setDisabled(disabled);
+	});
+    },
+
+    getNextFreeNetwork: function() {
+	let view = this.getView();
+	let vm = view.getViewModel();
+
+	let networksInUse = view.query('proxmoxNetworkSelector').map(selector => selector.value);
+
+	for (const network of vm.get('networks')) {
+	    if (!networksInUse.includes(network)) {
+		return network;
+	    }
+	}
+	return undefined; // default to empty field, user has to set up link manually
+    },
+
+    getNextFreeNumber: function() {
+	let view = this.getView();
+	let vm = view.getViewModel();
+
+	let numbersInUse = view.query('numberfield').map(field => field.value);
+
+	for (let i = 0; i < vm.get('maxLinkCount'); i++) {
+	    if (!numbersInUse.includes(i)) {
+		return i;
+	    }
+	}
+	// all numbers in use, this should never happen since add button is disabled automatically
+	return 0;
+    },
+});
+
+Ext.define('PVE.form.CorosyncLinkSelector', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveCorosyncLinkSelector',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+    cbindData: [],
+
+    // config
+    maxLinkNumber: 7,
+    allowNumberEdit: true,
+    allowBlankNetwork: false,
+    removeBtnHandler: undefined,
+    emptyText: '',
+
+    // values
+    initNumber: 0,
+    initNetwork: '',
+    text: '',
+
+    layout: 'hbox',
+    bodyPadding: 5,
+    border: 0,
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: 'Link',
+	    cbind: {
+		hidden: '{allowNumberEdit}',
+		value: '{initNumber}',
+	    },
+	    width: 45,
+	    labelWidth: 30,
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'numberfield',
+	    fieldLabel: 'Link',
+	    cbind: {
+		maxValue: '{maxLinkNumber}',
+		hidden: '{!allowNumberEdit}',
+		value: '{initNumber}',
+	    },
+	    width: 80,
+	    labelWidth: 30,
+	    minValue: 0,
+	    submitValue: false, // see getSubmitValue of network selector
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxNetworkSelector',
+	    cbind: {
+		allowBlank: '{allowBlankNetwork}',
+		value: '{initNetwork}',
+		emptyText: '{emptyText}',
+	    },
+	    autoSelect: false,
+	    valueField: 'address',
+	    displayField: 'address',
+	    width: 220,
+	    margin: '0 5px 0 5px',
+	    getSubmitValue: function() {
+		let me = this;
+		// link number is encoded into key, so we need to set field name before value retrieval
+		let linkNumber = me.prev('numberfield').getValue(); // always the correct one
+		me.name = 'link' + linkNumber;
+		return me.getValue();
+	    },
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa fa-trash-o',
+	    cls: 'removeLinkBtn',
+	    cbind: {
+		hidden: '{!allowNumberEdit}',
+	    },
+	    handler: function() {
+		let me = this;
+		let parent = me.up('pveCorosyncLinkSelector');
+		if (parent.removeBtnHandler !== undefined) {
+		    parent.removeBtnHandler();
+		}
+	    },
+	},
+	{
+	    xtype: 'label',
+	    margin: '-1px 0 0 5px',
+
+	    // for muted effect
+	    cls: 'x-form-item-label-default',
+
+	    cbind: {
+		text: '{text}',
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	let numSelect = me.down('numberfield');
+	let netSelect = me.down('proxmoxNetworkSelector');
+
+	numSelect.validator = me.createNoDuplicatesValidator(
+		'numberfield',
+		gettext("Duplicate link number not allowed."),
+	);
+
+	netSelect.validator = me.createNoDuplicatesValidator(
+		'proxmoxNetworkSelector',
+		gettext("Duplicate link address not allowed."),
+	);
+    },
+
+    createNoDuplicatesValidator: function(queryString, errorMsg) { // linkSelector generator
+	let view = this; // eslint-disable-line consistent-this
+	/** @this is the field itself, as the validator this is called from scopes it that way */
+	return function(val) {
+	    let me = this;
+	    let form = view.up('form');
+	    let linkEditor = view.up('pveCorosyncLinkEditor');
+
+	    if (!form.validating) {
+		// avoid recursion/double validation by setting temporary states
+		me.validating = true;
+		form.validating = true;
+
+		// validate all other fields as well, to always mark both
+		// parties involved in a 'duplicate' error
+		form.isValid();
+
+		form.validating = false;
+		me.validating = false;
+	    } else if (me.validating) {
+		// we'll be validated by the original call in the other if-branch, avoid double work
+		return true;
+	    }
+
+	    if (val === undefined || (val instanceof String && val.length === 0)) {
+		return true; // let this be caught by allowBlank, if at all
+	    }
+
+	    let allFields = linkEditor.query(queryString);
+	    for (const field of allFields) {
+		if (field !== me && String(field.getValue()) === String(val)) {
+		    return errorMsg;
+		}
+	    }
+	    return true;
+	};
+    },
+});
+
+Ext.define('PVE.form.CorosyncLinkEditor', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveCorosyncLinkEditor',
+
+    controller: 'pveCorosyncLinkEditorController',
+
+    // only initial config, use setter otherwise
+    allowNumberEdit: true,
+
+    viewModel: {
+	data: {
+	    linkCount: 0,
+	    maxLinkCount: 8,
+	    networks: null,
+	    allowNumberEdit: true,
+	    infoText: '',
+	},
+	formulas: {
+	    addDisabled: function(get) {
+		return !get('allowNumberEdit') ||
+		    get('linkCount') >= get('maxLinkCount');
+	    },
+	    dockHidden: function(get) {
+		return !(get('allowNumberEdit') || get('infoText'));
+	    },
+	},
+    },
+
+    dockedItems: [{
+	xtype: 'toolbar',
+	dock: 'bottom',
+	defaultButtonUI: 'default',
+	border: false,
+	padding: '6 0 6 0',
+	bind: {
+	    hidden: '{dockHidden}',
+	},
+	items: [
+	    {
+		xtype: 'button',
+		text: gettext('Add'),
+		bind: {
+		    disabled: '{addDisabled}',
+		    hidden: '{!allowNumberEdit}',
+		},
+		handler: 'addEmptyLink',
+	    },
+	    {
+		xtype: 'label',
+		bind: {
+		    text: '{infoText}',
+		},
+	    },
+	],
+    }],
+
+    setInfoText: function(text) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	vm.set('infoText', text || '');
+    },
+
+    setLinks: function(links) {
+	let me = this;
+	let controller = me.getController();
+	let vm = me.getViewModel();
+
+	me.removeAll();
+	vm.set('linkCount', 0);
+
+	Ext.Array.each(links, link => controller.addLink(link));
+    },
+
+    setDefaultLinks: function() {
+	let me = this;
+	let controller = me.getController();
+	let vm = me.getViewModel();
+
+	me.removeAll();
+	vm.set('linkCount', 0);
+	controller.addLink();
+    },
+
+    // clears all links
+    setAllowNumberEdit: function(allow) {
+	let me = this;
+	let vm = me.getViewModel();
+	vm.set('allowNumberEdit', allow);
+	me.removeAll();
+	vm.set('linkCount', 0);
+    },
+
+    items: [{
+	// No links is never a valid scenario, but can occur during a slow load
+	xtype: 'hiddenfield',
+	submitValue: false,
+	isValid: function() {
+	    let me = this;
+	    let vm = me.up('pveCorosyncLinkEditor').getViewModel();
+	    return vm.get('linkCount') > 0;
+	},
+    }],
+
+    initComponent: function() {
+	let me = this;
+	let vm = me.getViewModel();
+	let controller = me.getController();
+
+	vm.set('allowNumberEdit', me.allowNumberEdit);
+	vm.set('infoText', me.infoText || '');
+
+	me.callParent();
+
+	// Request local node networks to pre-populate first link.
+	Proxmox.Utils.API2Request({
+	    url: '/nodes/localhost/network',
+	    method: 'GET',
+	    waitMsgTarget: me,
+	    success: response => {
+		let data = response.result.data;
+		if (data.length > 0) {
+		    data.sort((a, b) => a.iface.localeCompare(b.iface));
+		    let addresses = [];
+		    for (let net of data) {
+			if (net.address) {
+			    addresses.push(net.address);
+			}
+			if (net.address6) {
+			    addresses.push(net.address6);
+			}
+		    }
+
+		    vm.set('networks', addresses);
+		}
+
+		// Always have at least one link, but account for delay in API,
+		// someone might have called 'setLinks' in the meantime -
+		// except if 'allowNumberEdit' is false, in which case we're
+		// probably waiting for the user to input the join info
+		if (vm.get('allowNumberEdit')) {
+		    controller.addLinkIfEmpty();
+		}
+	    },
+	    failure: () => {
+		if (vm.get('allowNumberEdit')) {
+		    controller.addLinkIfEmpty();
+		}
+	    },
+	});
+    },
+});
+
+Ext.define('PVE.dc.GroupEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveDcGroupEdit'],
+
+    initComponent: function() {
+        var me = this;
+
+        me.isCreate = !me.groupid;
+
+        var url;
+        var method;
+
+        if (me.isCreate) {
+            url = '/api2/extjs/access/groups';
+            method = 'POST';
+        } else {
+            url = '/api2/extjs/access/groups/' + me.groupid;
+            method = 'PUT';
+        }
+
+        Ext.applyIf(me, {
+            subject: gettext('Group'),
+            url: url,
+            method: method,
+            items: [
+                {
+		    xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
+		    fieldLabel: gettext('Name'),
+		    name: 'groupid',
+		    value: me.groupid,
+		    allowBlank: false,
+		},
+                {
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Comment'),
+		    name: 'comment',
+		    allowBlank: true,
+		},
+            ],
+        });
+
+        me.callParent();
+
+        if (!me.isCreate) {
+            me.load();
+        }
+    },
+});
+Ext.define('PVE.dc.GroupView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveGroupView'],
+
+    onlineHelp: 'pveum_groups',
+
+    stateful: true,
+    stateId: 'grid-groups',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-groups',
+	    sorters: {
+		property: 'groupid',
+		direction: 'ASC',
+	    },
+	});
+
+        var reload = function() {
+            store.load();
+        };
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    callback: function() {
+		reload();
+	    },
+	    baseurl: '/access/groups/',
+	});
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+            var win = Ext.create('PVE.dc.GroupEdit', {
+                groupid: rec.data.groupid,
+            });
+            win.on('destroy', reload);
+            win.show();
+	};
+
+	var edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	var tbar = [
+            {
+		text: gettext('Create'),
+		handler: function() {
+		    var win = Ext.create('PVE.dc.GroupEdit', {});
+		    win.on('destroy', reload);
+		    win.show();
+		},
+            },
+	    edit_btn, remove_btn,
+        ];
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: tbar,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    width: 200,
+		    sortable: true,
+		    dataIndex: 'groupid',
+		},
+		{
+		    header: gettext('Comment'),
+		    sortable: false,
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'comment',
+		    flex: 1,
+		},
+		{
+		    header: gettext('Users'),
+		    sortable: false,
+		    dataIndex: 'users',
+		    renderer: Ext.String.htmlEncode,
+		    flex: 1,
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.dc.Guests', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveDcGuests',
+
+
+    title: gettext('Guests'),
+    height: 250,
+    layout: {
+	type: 'table',
+	columns: 2,
+	tableAttrs: {
+	    style: {
+		width: '100%',
+	    },
+	},
+    },
+    bodyPadding: '0 20 20 20',
+
+    defaults: {
+	xtype: 'box',
+	padding: '0 50 0 50',
+	style: {
+	    'text-align': 'center',
+	    'line-height': '1.5em',
+	    'font-size': '14px',
+	},
+    },
+    items: [
+	{
+	    itemId: 'qemu',
+	    data: {
+		running: 0,
+		paused: 0,
+		stopped: 0,
+		template: 0,
+	    },
+	    cls: 'centered-flex-column',
+	    tpl: [
+		'<h3>' + gettext("Virtual Machines") + '</h3>',
+		'<div>',
+		    '<div class="left-aligned">',
+			'<i class="good fa fa-fw fa-play-circle">&nbsp;</i>',
+			gettext('Running'),
+		    '</div>',
+		    '<div class="right-aligned">{running}</div>',
+		'</div>',
+		'<tpl if="paused &gt; 0">',
+		    '<div>',
+			'<div class="left-aligned">',
+			    '<i class="warning fa fa-fw fa-pause-circle">&nbsp;</i>',
+			    gettext('Paused'),
+			'</div>',
+			'<div class="right-aligned">{paused}</div>',
+		    '</div>',
+		'</tpl>',
+		'<div>',
+		    '<div class="left-aligned">',
+			'<i class="faded fa fa-fw fa-stop-circle">&nbsp;</i>',
+			gettext('Stopped'),
+		    '</div>',
+		    '<div class="right-aligned">{stopped}</div>',
+		'</div>',
+		'<tpl if="template &gt; 0">',
+		    '<div>',
+			'<div class="left-aligned">',
+			    '<i class="fa fa-fw fa-circle-o">&nbsp;</i>',
+			    gettext('Templates'),
+			'</div>',
+			'<div class="right-aligned">{template}</div>',
+		    '</div>',
+		'</tpl>',
+	    ],
+	},
+	{
+	    itemId: 'lxc',
+	    data: {
+		running: 0,
+		paused: 0,
+		stopped: 0,
+		template: 0,
+	    },
+	    cls: 'centered-flex-column',
+	    tpl: [
+		'<h3>' + gettext("LXC Container") + '</h3>',
+		'<div>',
+		    '<div class="left-aligned">',
+			'<i class="good fa fa-fw fa-play-circle">&nbsp;</i>',
+			gettext('Running'),
+		    '</div>',
+		    '<div class="right-aligned">{running}</div>',
+		'</div>',
+		'<tpl if="paused &gt; 0">',
+		    '<div>',
+			'<div class="left-aligned">',
+			    '<i class="warning fa fa-fw fa-pause-circle">&nbsp;</i>',
+			    gettext('Paused'),
+			'</div>',
+			'<div class="right-aligned">{paused}</div>',
+		    '</div>',
+		'</tpl>',
+		'<div>',
+		    '<div class="left-aligned">',
+			'<i class="faded fa fa-fw fa-stop-circle">&nbsp;</i>',
+			gettext('Stopped'),
+		    '</div>',
+		    '<div class="right-aligned">{stopped}</div>',
+		'</div>',
+		'<tpl if="template &gt; 0">',
+		    '<div>',
+			'<div class="left-aligned">',
+			    '<i class="fa fa-fw fa-circle-o">&nbsp;</i>',
+			    gettext('Templates'),
+			'</div>',
+			'<div class="right-aligned">{template}</div>',
+		    '</div>',
+		'</tpl>',
+	    ],
+	},
+	{
+	    itemId: 'error',
+	    colspan: 2,
+	    data: {
+		num: 0,
+	    },
+	    columnWidth: 1,
+	    padding: '10 250 0 250',
+	    tpl: [
+		'<tpl if="num &gt; 0">',
+		    '<div class="left-aligned">',
+			'<i class="critical fa fa-fw fa-times-circle">&nbsp;</i>',
+			gettext('Error'),
+		    '</div>',
+		    '<div class="right-aligned">{num}</div>',
+		'</tpl>',
+	    ],
+	},
+    ],
+
+    updateValues: function(qemu, lxc, error) {
+	let me = this;
+
+	let lazyUpdate = (query, newData) => {
+	    let el = me.getComponent(query);
+            let currentData = el.data;
+
+	    let keys = Object.keys(newData);
+	    if (keys.length === Object.keys(currentData).length) {
+		if (keys.every(k => newData[k] === currentData[k])) {
+		    return; // all stayed the same here, return early to avoid bogus regeneration
+		}
+	    }
+	    el.update(newData);
+	};
+	lazyUpdate('qemu', qemu);
+	lazyUpdate('lxc', lxc);
+	lazyUpdate('error', { num: error });
+    },
+});
+Ext.define('PVE.dc.Health', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveDcHealth',
+
+    title: gettext('Health'),
+
+    bodyPadding: 10,
+    height: 250,
+    layout: {
+	type: 'hbox',
+	align: 'stretch',
+    },
+
+    defaults: {
+	flex: 1,
+	xtype: 'box',
+	style: {
+	    'text-align': 'center',
+	},
+    },
+
+    nodeList: [],
+    nodeIndex: 0,
+
+    updateStatus: function(store, records, success) {
+	let me = this;
+	if (!success) {
+	    return;
+	}
+
+	let cluster = {
+	    iconCls: PVE.Utils.get_health_icon('good', true),
+	    text: gettext("Standalone node - no cluster defined"),
+	};
+	let nodes = {
+	    online: 0,
+	    offline: 0,
+	};
+	let numNodes = 1; // by default we have one node
+	for (const { data } of records) {
+	    if (data.type === 'node') {
+		nodes[data.online === 1 ? 'online':'offline']++;
+	    } else if (data.type === 'cluster') {
+		cluster.text = `${gettext("Cluster")}: ${data.name}, ${gettext("Quorate")}: `;
+		cluster.text += Proxmox.Utils.format_boolean(data.quorate);
+		if (data.quorate !== 1) {
+		    cluster.iconCls = PVE.Utils.get_health_icon('critical', true);
+		}
+		numNodes = data.nodes;
+	    }
+	}
+
+	if (numNodes !== nodes.online + nodes.offline) {
+	    nodes.offline = numNodes - nodes.online;
+	}
+
+	me.getComponent('clusterstatus').updateHealth(cluster);
+	me.getComponent('nodestatus').update(nodes);
+    },
+
+    updateCeph: function(store, records, success) {
+	let me = this;
+	let cephstatus = me.getComponent('ceph');
+	if (!success || records.length < 1) {
+	    if (cephstatus.isVisible()) {
+		return; // if ceph status is already visible don't stop to update
+	    }
+	    // try all nodes until we either get a successful api call, or we tried all nodes
+	    if (++me.nodeIndex >= me.nodeList.length) {
+		me.cephstore.stopUpdate();
+	    } else {
+		store.getProxy().setUrl(`/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`);
+	    }
+	    return;
+	}
+
+	let state = PVE.Utils.render_ceph_health(records[0].data.health || {});
+	cephstatus.updateHealth(state);
+	cephstatus.setVisible(true);
+    },
+
+    listeners: {
+	destroy: function() {
+	    let me = this;
+	    me.cephstore.stopUpdate();
+	},
+    },
+
+    items: [
+	{
+	    itemId: 'clusterstatus',
+	    xtype: 'pveHealthWidget',
+	    title: gettext('Status'),
+	},
+	{
+	    itemId: 'nodestatus',
+	    data: {
+		online: 0,
+		offline: 0,
+	    },
+	    tpl: [
+		'<h3>' + gettext('Nodes') + '</h3><br />',
+		'<div style="width: 150px;margin: auto;font-size: 12pt">',
+		'<div class="left-aligned">',
+		'<i class="good fa fa-fw fa-check">&nbsp;</i>',
+		gettext('Online'),
+		'</div>',
+		'<div class="right-aligned">{online}</div>',
+		'<br /><br />',
+		'<div class="left-aligned">',
+		'<i class="critical fa fa-fw fa-times">&nbsp;</i>',
+		gettext('Offline'),
+		'</div>',
+		'<div class="right-aligned">{offline}</div>',
+		'</div>',
+	    ],
+	},
+	{
+	    itemId: 'ceph',
+	    width: 250,
+	    columnWidth: undefined,
+	    userCls: 'pointer',
+	    title: 'Ceph',
+	    xtype: 'pveHealthWidget',
+	    hidden: true,
+	    listeners: {
+		element: 'el',
+		click: function() {
+		    Ext.state.Manager.getProvider().set('dctab', { value: 'ceph' }, true);
+		},
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	me.nodeList = PVE.data.ResourceStore.getNodes();
+	me.nodeIndex = 0;
+	me.cephstore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 3000,
+	    storeid: 'pve-cluster-ceph',
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`,
+	    },
+	});
+	me.callParent();
+	me.mon(me.cephstore, 'load', me.updateCeph, me);
+	me.cephstore.startUpdate();
+    },
+});
+/* This class defines the "Cluster log" tab of the bottom status panel
+ * A log entry is a timestamp associated with an action on a cluster
+ */
+
+Ext.define('PVE.dc.Log', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveClusterLog'],
+
+    initComponent: function() {
+	let me = this;
+
+	let logstore = Ext.create('Proxmox.data.UpdateStore', {
+	    storeid: 'pve-cluster-log',
+	    model: 'proxmox-cluster-log',
+	    proxy: {
+                type: 'proxmox',
+		url: '/api2/json/cluster/log',
+	    },
+	});
+	let store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: logstore,
+	    appendAtStart: true,
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: false,
+
+	    viewConfig: {
+		trackOver: false,
+		stripeRows: true,
+		getRowClass: function(record, index) {
+		    let pri = record.get('pri');
+		    if (pri && pri <= 3) {
+			return "proxmox-invalid-row";
+		    }
+		    return undefined;
+		},
+	    },
+	    sortableColumns: false,
+	    columns: [
+		{
+		    header: gettext("Time"),
+		    dataIndex: 'time',
+		    width: 150,
+		    renderer: function(value) {
+			return Ext.Date.format(value, "M d H:i:s");
+		    },
+		},
+		{
+		    header: gettext("Node"),
+		    dataIndex: 'node',
+		    width: 150,
+		},
+		{
+		    header: gettext("Service"),
+		    dataIndex: 'tag',
+		    width: 100,
+		},
+		{
+		    header: "PID",
+		    dataIndex: 'pid',
+		    width: 100,
+		},
+		{
+		    header: gettext("User name"),
+		    dataIndex: 'user',
+		    renderer: Ext.String.htmlEncode,
+		    width: 150,
+		},
+		{
+		    header: gettext("Severity"),
+		    dataIndex: 'pri',
+		    renderer: PVE.Utils.render_serverity,
+		    width: 100,
+		},
+		{
+		    header: gettext("Message"),
+		    dataIndex: 'msg',
+		    renderer: Ext.String.htmlEncode,
+		    flex: 1,
+		},
+	    ],
+	    listeners: {
+		activate: () => logstore.startUpdate(),
+		deactivate: () => logstore.stopUpdate(),
+		destroy: () => logstore.stopUpdate(),
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.dc.NodeView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveDcNodeView',
+
+    title: gettext('Nodes'),
+    disableSelection: true,
+    scrollable: true,
+
+    columns: [
+	{
+	    header: gettext('Name'),
+	    flex: 1,
+	    sortable: true,
+	    dataIndex: 'name',
+	},
+	{
+	    header: 'ID',
+	    width: 40,
+	    sortable: true,
+	    dataIndex: 'nodeid',
+	},
+	{
+	    header: gettext('Online'),
+	    width: 60,
+	    sortable: true,
+	    dataIndex: 'online',
+	    renderer: function(value) {
+		var cls = value?'good':'critical';
+		return '<i class="fa ' + PVE.Utils.get_health_icon(cls) + '"><i/>';
+	    },
+	},
+	{
+	    header: gettext('Support'),
+	    width: 100,
+	    sortable: true,
+	    dataIndex: 'level',
+	    renderer: PVE.Utils.render_support_level,
+	},
+	{
+	    header: gettext('Server Address'),
+	    width: 115,
+	    sortable: true,
+	    dataIndex: 'ip',
+	},
+	{
+	    header: gettext('CPU usage'),
+	    sortable: true,
+	    width: 110,
+	    dataIndex: 'cpuusage',
+	    tdCls: 'x-progressbar-default-cell',
+	    xtype: 'widgetcolumn',
+	    widget: {
+		xtype: 'pveProgressBar',
+	    },
+	},
+	{
+	    header: gettext('Memory usage'),
+	    width: 110,
+	    sortable: true,
+	    tdCls: 'x-progressbar-default-cell',
+	    dataIndex: 'memoryusage',
+	    xtype: 'widgetcolumn',
+	    widget: {
+		xtype: 'pveProgressBar',
+	    },
+	},
+	{
+	    header: gettext('Uptime'),
+	    sortable: true,
+	    dataIndex: 'uptime',
+	    align: 'right',
+	    renderer: Proxmox.Utils.render_uptime,
+	},
+    ],
+
+    stateful: true,
+    stateId: 'grid-cluster-nodes',
+    tools: [
+	{
+	    type: 'up',
+	    handler: function() {
+		let view = this.up('grid');
+		view.setHeight(Math.max(view.getHeight() - 50, 250));
+	    },
+	},
+	{
+	    type: 'down',
+	    handler: function() {
+		let view = this.up('grid');
+		view.setHeight(view.getHeight() + 50);
+	    },
+	},
+    ],
+}, function() {
+    Ext.define('pve-dc-nodes', {
+	extend: 'Ext.data.Model',
+	fields: ['id', 'type', 'name', 'nodeid', 'ip', 'level', 'local', 'online'],
+	idProperty: 'id',
+    });
+});
+
+Ext.define('PVE.widget.ProgressBar', {
+    extend: 'Ext.Progress',
+    alias: 'widget.pveProgressBar',
+
+    animate: true,
+    textTpl: [
+	'{percent}%',
+    ],
+
+    setValue: function(value) {
+	let me = this;
+
+	me.callParent([value]);
+
+	me.removeCls(['warning', 'critical']);
+
+	if (value > 0.89) {
+	    me.addCls('critical');
+	} else if (value > 0.75) {
+	    me.addCls('warning');
+	}
+    },
+});
+Ext.define('PVE.dc.OptionView', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.pveDcOptionView'],
+
+    onlineHelp: 'datacenter_configuration_file',
+
+    monStoreErrors: true,
+    userCls: 'proxmox-tags-full',
+
+    add_inputpanel_row: function(name, text, opts) {
+	var me = this;
+
+	opts = opts || {};
+	me.rows = me.rows || {};
+
+	let canEdit = !Object.prototype.hasOwnProperty.call(opts, 'caps') || opts.caps;
+	me.rows[name] = {
+	    required: true,
+	    defaultValue: opts.defaultValue,
+	    header: text,
+	    renderer: opts.renderer,
+	    editor: canEdit ? {
+		xtype: 'proxmoxWindowEdit',
+		width: opts.width || 350,
+		subject: text,
+		onlineHelp: opts.onlineHelp,
+		fieldDefaults: {
+		    labelWidth: opts.labelWidth || 100,
+		},
+		setValues: function(values) {
+		    var edit_value = values[name];
+
+		    if (opts.parseBeforeSet) {
+			edit_value = PVE.Parser.parsePropertyString(edit_value);
+		    }
+
+		    Ext.Array.each(this.query('inputpanel'), function(panel) {
+			panel.setValues(edit_value);
+		    });
+		},
+		url: opts.url,
+		items: [{
+		    xtype: 'inputpanel',
+		    onGetValues: function(values) {
+			if (values === undefined || Object.keys(values).length === 0) {
+			    return { 'delete': name };
+			}
+			var ret_val = {};
+			ret_val[name] = PVE.Parser.printPropertyString(values);
+			return ret_val;
+		    },
+		    items: opts.items,
+		}],
+	    } : undefined,
+	};
+    },
+
+    render_bwlimits: function(value) {
+	if (!value) {
+	    return gettext("None");
+	}
+
+	let parsed = PVE.Parser.parsePropertyString(value);
+	return Object.entries(parsed)
+	    .map(([k, v]) => k + ": " + Proxmox.Utils.format_size(v * 1024) + "/s")
+	    .join(',');
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.add_combobox_row('keyboard', gettext('Keyboard Layout'), {
+	    renderer: PVE.Utils.render_kvm_language,
+	    comboItems: Object.entries(PVE.Utils.kvm_keymaps),
+	    defaultValue: '__default__',
+	    deleteEmpty: true,
+	});
+	me.add_text_row('http_proxy', gettext('HTTP proxy'), {
+	    defaultValue: Proxmox.Utils.noneText,
+	    vtype: 'HttpProxy',
+	    deleteEmpty: true,
+	});
+	me.add_combobox_row('console', gettext('Console Viewer'), {
+	    renderer: PVE.Utils.render_console_viewer,
+	    comboItems: Object.entries(PVE.Utils.console_map),
+	    defaultValue: '__default__',
+	    deleteEmpty: true,
+	});
+	me.add_text_row('email_from', gettext('Email from address'), {
+	    deleteEmpty: true,
+	    vtype: 'proxmoxMail',
+	    defaultValue: 'root@$hostname',
+	});
+	me.add_text_row('mac_prefix', gettext('MAC address prefix'), {
+	    deleteEmpty: true,
+	    vtype: 'MacPrefix',
+	    defaultValue: 'BC:24:11',
+	});
+	me.add_inputpanel_row('migration', gettext('Migration Settings'), {
+	    renderer: PVE.Utils.render_as_property_string,
+	    labelWidth: 120,
+	    url: "/api2/extjs/cluster/options",
+	    defaultKey: 'type',
+	    items: [{
+		xtype: 'displayfield',
+		name: 'type',
+		fieldLabel: gettext('Type'),
+		value: 'secure',
+		submitValue: true,
+	    }, {
+		xtype: 'proxmoxNetworkSelector',
+		name: 'network',
+		fieldLabel: gettext('Network'),
+		value: null,
+		emptyText: Proxmox.Utils.defaultText,
+		autoSelect: false,
+		skipEmptyText: true,
+	    }],
+	});
+	me.add_inputpanel_row('ha', gettext('HA Settings'), {
+	    renderer: PVE.Utils.render_dc_ha_opts,
+	    labelWidth: 120,
+	    url: "/api2/extjs/cluster/options",
+	    onlineHelp: 'ha_manager_shutdown_policy',
+	    items: [{
+		xtype: 'proxmoxKVComboBox',
+		name: 'shutdown_policy',
+		fieldLabel: gettext('Shutdown Policy'),
+		deleteEmpty: false,
+		value: '__default__',
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText + ' (conditional)'],
+		    ['freeze', 'freeze'],
+		    ['failover', 'failover'],
+		    ['migrate', 'migrate'],
+		    ['conditional', 'conditional'],
+		],
+		defaultValue: '__default__',
+	    }],
+	});
+	me.add_inputpanel_row('crs', gettext('Cluster Resource Scheduling'), {
+	    renderer: PVE.Utils.render_as_property_string,
+	    width: 450,
+	    labelWidth: 120,
+	    url: "/api2/extjs/cluster/options",
+	    onlineHelp: 'ha_manager_crs',
+	    items: [{
+		xtype: 'proxmoxKVComboBox',
+		name: 'ha',
+		fieldLabel: gettext('HA Scheduling'),
+		deleteEmpty: false,
+		value: '__default__',
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText + ' (basic)'],
+		    ['basic', 'Basic (Resource Count)'],
+		    ['static', 'Static Load'],
+		],
+		defaultValue: '__default__',
+	    }, {
+		xtype: 'proxmoxcheckbox',
+		name: 'ha-rebalance-on-start',
+		fieldLabel: gettext('Rebalance on Start'),
+		boxLabel: gettext('Use CRS to select the least loaded node when starting an HA service'),
+		value: 0,
+	    }],
+	});
+	me.add_inputpanel_row('u2f', gettext('U2F Settings'), {
+	    renderer: v => !v ? Proxmox.Utils.NoneText : PVE.Parser.printPropertyString(v),
+	    width: 450,
+	    url: "/api2/extjs/cluster/options",
+	    onlineHelp: 'pveum_configure_u2f',
+	    items: [{
+		xtype: 'textfield',
+		name: 'appid',
+		fieldLabel: gettext('U2F AppID URL'),
+		emptyText: gettext('Defaults to origin'),
+		value: '',
+		deleteEmpty: true,
+		skipEmptyText: true,
+		submitEmptyText: false,
+	    }, {
+		xtype: 'textfield',
+		name: 'origin',
+		fieldLabel: gettext('U2F Origin'),
+		emptyText: gettext('Defaults to requesting host URI'),
+		value: '',
+		deleteEmpty: true,
+		skipEmptyText: true,
+		submitEmptyText: false,
+	    },
+	    {
+		xtype: 'box',
+		height: 25,
+		html: `<span class='pmx-hint'>${gettext('Note:')}</span> `
+		    + Ext.String.format(gettext('{0} is deprecated, use {1}'), 'U2F', 'WebAuthn'),
+	    },
+	    {
+		xtype: 'displayfield',
+		userCls: 'pmx-hint',
+		value: gettext('NOTE: Changing an AppID breaks existing U2F registrations!'),
+	    }],
+	});
+	me.add_inputpanel_row('webauthn', gettext('WebAuthn Settings'), {
+	    renderer: v => !v ? Proxmox.Utils.NoneText : PVE.Parser.printPropertyString(v),
+	    width: 450,
+	    url: "/api2/extjs/cluster/options",
+	    onlineHelp: 'pveum_configure_webauthn',
+	    items: [{
+		xtype: 'textfield',
+		fieldLabel: gettext('Name'),
+		name: 'rp', // NOTE: relying party consists of name and id, this is the name
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		fieldLabel: gettext('Origin'),
+		emptyText: Ext.String.format(gettext("Domain Lockdown (e.g., {0})"), document.location.origin),
+		name: 'origin',
+		allowBlank: true,
+	    },
+	    {
+		xtype: 'textfield',
+		fieldLabel: 'ID',
+		name: 'id',
+		allowBlank: false,
+		listeners: {
+		    dirtychange: (f, isDirty) =>
+			f.up('panel').down('box[id=idChangeWarning]').setHidden(!f.originalValue || !isDirty),
+		},
+	    },
+	    {
+		xtype: 'container',
+		layout: 'hbox',
+		items: [
+		    {
+			xtype: 'box',
+			flex: 1,
+		    },
+		    {
+			xtype: 'button',
+			text: gettext('Auto-fill'),
+			iconCls: 'fa fa-fw fa-pencil-square-o',
+			handler: function(button, ev) {
+			    let panel = this.up('panel');
+			    let fqdn = document.location.hostname;
+
+			    panel.down('field[name=rp]').setValue(fqdn);
+
+			    let idField = panel.down('field[name=id]');
+			    let currentID = idField.getValue();
+			    if (!currentID || currentID.length === 0) {
+				idField.setValue(fqdn);
+			    }
+			},
+		    },
+		],
+	    },
+	    {
+		xtype: 'box',
+		height: 25,
+		html: `<span class='pmx-hint'>${gettext('Note:')}</span> `
+		    + gettext('WebAuthn requires using a trusted certificate.'),
+	    },
+	    {
+		xtype: 'box',
+		id: 'idChangeWarning',
+		hidden: true,
+		padding: '5 0 0 0',
+		html: '<i class="fa fa-exclamation-triangle warning"></i> '
+		    + gettext('Changing the ID breaks existing WebAuthn TFA entries.'),
+	    }],
+	});
+	me.add_inputpanel_row('bwlimit', gettext('Bandwidth Limits'), {
+	    renderer: me.render_bwlimits,
+	    width: 450,
+	    url: "/api2/extjs/cluster/options",
+	    parseBeforeSet: true,
+	    labelWidth: 120,
+	    items: [{
+		xtype: 'pveBandwidthField',
+		name: 'default',
+		fieldLabel: gettext('Default'),
+		emptyText: gettext('none'),
+		backendUnit: "KiB",
+	    },
+	    {
+		xtype: 'pveBandwidthField',
+		name: 'restore',
+		fieldLabel: gettext('Backup Restore'),
+		emptyText: gettext('default'),
+		backendUnit: "KiB",
+	    },
+	    {
+		xtype: 'pveBandwidthField',
+		name: 'migration',
+		fieldLabel: gettext('Migration'),
+		emptyText: gettext('default'),
+		backendUnit: "KiB",
+	    },
+	    {
+		xtype: 'pveBandwidthField',
+		name: 'clone',
+		fieldLabel: gettext('Clone'),
+		emptyText: gettext('default'),
+		backendUnit: "KiB",
+	    },
+	    {
+		xtype: 'pveBandwidthField',
+		name: 'move',
+		fieldLabel: gettext('Disk Move'),
+		emptyText: gettext('default'),
+		backendUnit: "KiB",
+	    }],
+	});
+	me.add_integer_row('max_workers', gettext('Maximal Workers/bulk-action'), {
+	    deleteEmpty: true,
+	    defaultValue: 4,
+	    minValue: 1,
+	    maxValue: 64, // arbitrary but generous limit as limits are good
+	});
+	me.add_inputpanel_row('next-id', gettext('Next Free VMID Range'), {
+	    renderer: PVE.Utils.render_as_property_string,
+	    url: "/api2/extjs/cluster/options",
+	    items: [{
+		xtype: 'proxmoxintegerfield',
+		name: 'lower',
+		fieldLabel: gettext('Lower'),
+		emptyText: '100',
+		minValue: 100,
+		maxValue: 1000 * 1000 * 1000 - 1,
+		submitValue: true,
+	    }, {
+		xtype: 'proxmoxintegerfield',
+		name: 'upper',
+		fieldLabel: gettext('Upper'),
+		emptyText: '1.000.000',
+		minValue: 100,
+		maxValue: 1000 * 1000 * 1000 - 1,
+		submitValue: true,
+	    }],
+	});
+	me.rows['tag-style'] = {
+	    required: true,
+	    renderer: (value) => {
+		if (value === undefined) {
+		    return gettext('No Overrides');
+		}
+		let colors = PVE.UIOptions.parseTagOverrides(value?.['color-map']);
+		let shape = value.shape;
+		let shapeText = PVE.UIOptions.tagTreeStyles[shape ?? '__default__'];
+		let txt = Ext.String.format(gettext("Tree Shape: {0}"), shapeText);
+		let orderText = PVE.UIOptions.tagOrderOptions[value.ordering ?? '__default__'];
+		txt += `, ${Ext.String.format(gettext("Ordering: {0}"), orderText)}`;
+		if (value['case-sensitive']) {
+		    txt += `, ${gettext('Case-Sensitive')}`;
+		}
+		if (Object.keys(colors).length > 0) {
+		    txt += `, ${gettext('Color Overrides')}: `;
+		    for (const tag of Object.keys(colors)) {
+			txt += Proxmox.Utils.getTagElement(tag, colors);
+		    }
+		}
+		return txt;
+	    },
+	    header: gettext('Tag Style Override'),
+	    editor: {
+		xtype: 'proxmoxWindowEdit',
+		width: 800,
+		subject: gettext('Tag Color Override'),
+		onlineHelp: 'datacenter_configuration_file',
+		fieldDefaults: {
+		    labelWidth: 100,
+		},
+		url: '/api2/extjs/cluster/options',
+		items: [
+		    {
+			xtype: 'inputpanel',
+			setValues: function(values) {
+			    if (values === undefined) {
+				return undefined;
+			    }
+			    values = values?.['tag-style'] ?? {};
+			    values.shape = values.shape || '__default__';
+			    values.colors = values['color-map'];
+			    return Proxmox.panel.InputPanel.prototype.setValues.call(this, values);
+			},
+			onGetValues: function(values) {
+			    let style = {};
+			    if (values.colors) {
+				style['color-map'] = values.colors;
+			    }
+			    if (values.shape && values.shape !== '__default__') {
+				style.shape = values.shape;
+			    }
+			    if (values.ordering) {
+				style.ordering = values.ordering;
+			    }
+			    if (values['case-sensitive']) {
+				style['case-sensitive'] = 1;
+			    }
+			    let value = PVE.Parser.printPropertyString(style);
+			    if (value === '') {
+				return {
+				    'delete': 'tag-style',
+				};
+			    }
+			    return {
+				'tag-style': value,
+			    };
+			},
+			items: [
+			    {
+
+				name: 'shape',
+				xtype: 'proxmoxComboGrid',
+				fieldLabel: gettext('Tree Shape'),
+				valueField: 'value',
+				displayField: 'display',
+				allowBlank: false,
+				listConfig: {
+				    columns: [
+					{
+					    header: gettext('Option'),
+					    dataIndex: 'display',
+					    flex: 1,
+					},
+					{
+					    header: gettext('Preview'),
+					    dataIndex: 'value',
+					    renderer: function(value) {
+						let cls = value ?? '__default__';
+						if (value === '__default__') {
+						    cls = 'circle';
+						}
+						let tags = PVE.Utils.renderTags('preview');
+						return `<div class="proxmox-tags-${cls}">${tags}</div>`;
+					    },
+					    flex: 1,
+					},
+				    ],
+				},
+				store: {
+				    data: Object.entries(PVE.UIOptions.tagTreeStyles).map(v => ({
+					value: v[0],
+					display: v[1],
+				    })),
+				},
+				deleteDefault: true,
+				defaultValue: '__default__',
+				deleteEmpty: true,
+			    },
+			    {
+				name: 'ordering',
+				xtype: 'proxmoxKVComboBox',
+				fieldLabel: gettext('Ordering'),
+				comboItems: Object.entries(PVE.UIOptions.tagOrderOptions),
+				defaultValue: '__default__',
+				value: '__default__',
+				deleteEmpty: true,
+			    },
+			    {
+				name: 'case-sensitive',
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Case-Sensitive'),
+				boxLabel: gettext('Applies to new edits'),
+				value: 0,
+			    },
+			    {
+				xtype: 'displayfield',
+				fieldLabel: gettext('Color Overrides'),
+			    },
+			    {
+				name: 'colors',
+				xtype: 'pveTagColorGrid',
+				deleteEmpty: true,
+				height: 300,
+			    },
+			],
+		    },
+		],
+	    },
+	};
+
+	me.rows['user-tag-access'] = {
+	    required: true,
+	    renderer: (value) => {
+		if (value === undefined) {
+		    return Ext.String.format(gettext('Mode: {0}'), 'free');
+		}
+		let mode = value?.['user-allow'] ?? 'free';
+		let list = value?.['user-allow-list']?.join(',') ?? '';
+		let modeTxt = Ext.String.format(gettext('Mode: {0}'), mode);
+		let overrides = PVE.UIOptions.tagOverrides;
+		let tags = PVE.Utils.renderTags(list, overrides);
+		let listTxt = tags !== '' ? `, ${gettext('Pre-defined:')} ${tags}` : '';
+		return `${modeTxt}${listTxt}`;
+	    },
+	    header: gettext('User Tag Access'),
+	    editor: {
+		xtype: 'pveUserTagAccessEdit',
+	    },
+	};
+
+	me.rows['registered-tags'] = {
+	    required: true,
+	    renderer: (value) => {
+		if (value === undefined) {
+		    return gettext('No Registered Tags');
+		}
+		let overrides = PVE.UIOptions.tagOverrides;
+		return PVE.Utils.renderTags(value.join(','), overrides);
+	    },
+	    header: gettext('Registered Tags'),
+	    editor: {
+		xtype: 'pveRegisteredTagEdit',
+	    },
+	};
+
+	me.selModel = Ext.create('Ext.selection.RowModel', {});
+
+	Ext.apply(me, {
+	    tbar: [{
+		text: gettext('Edit'),
+		xtype: 'proxmoxButton',
+		disabled: true,
+		handler: function() { me.run_editor(); },
+		selModel: me.selModel,
+	    }],
+	    url: "/api2/json/cluster/options",
+	    editorConfig: {
+		url: "/api2/extjs/cluster/options",
+	    },
+	    interval: 5000,
+	    cwidth1: 200,
+	    listeners: {
+		itemdblclick: me.run_editor,
+	    },
+	});
+
+	me.callParent();
+
+	// set the new value for the default console
+	me.mon(me.rstore, 'load', function(store, records, success) {
+	    if (!success) {
+		return;
+	    }
+
+	    var rec = store.getById('console');
+	    PVE.UIOptions.options.console = rec.data.value;
+	    if (rec.data.value === '__default__') {
+		delete PVE.UIOptions.options.console;
+	    }
+
+	    PVE.UIOptions.options['tag-style'] = store.getById('tag-style')?.data?.value;
+	    PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']);
+	    PVE.UIOptions.fireUIConfigChanged();
+	});
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+    },
+});
+Ext.define('pve-permissions', {
+    extend: 'Ext.data.TreeModel',
+    fields: [
+	'text', 'type',
+	{
+	    type: 'boolean', name: 'propagate',
+	},
+    ],
+});
+
+Ext.define('PVE.dc.PermissionGridPanel', {
+    extend: 'Ext.tree.Panel',
+    alias: 'widget.pveUserPermissionGrid',
+
+    onlineHelp: 'chapter_user_management',
+
+    scrollable: true,
+    layout: 'fit',
+    rootVisible: false,
+    animate: false,
+    sortableColumns: false,
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    header: gettext('Path') + '/' + gettext('Permission'),
+	    dataIndex: 'text',
+	    flex: 6,
+	},
+	{
+	    header: gettext('Propagate'),
+	    dataIndex: 'propagate',
+	    flex: 1,
+	    renderer: function(value) {
+		if (Ext.isDefined(value)) {
+		    return Proxmox.Utils.format_boolean(value);
+		}
+		return '';
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	Proxmox.Utils.API2Request({
+	    url: '/access/permissions?userid=' + me.userid,
+	    method: 'GET',
+	    failure: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, response.htmlStatus);
+		me.load_task.delay(me.load_delay);
+	    },
+	    success: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, false);
+		let result = Ext.decode(response.responseText);
+		let data = result.data || {};
+
+		let root = {
+		    name: '__root',
+		    expanded: true,
+		    children: [],
+		};
+		let idhash = {
+		    '/': {
+			children: [],
+			text: '/',
+			type: 'path',
+		    },
+		};
+		Ext.Object.each(data, function(path, perms) {
+		    let path_item = {
+			text: path,
+			type: 'path',
+			children: [],
+		    };
+		    Ext.Object.each(perms, function(perm, propagate) {
+			let perm_item = {
+			    text: perm,
+			    type: 'perm',
+			    propagate: propagate === 1,
+			    iconCls: 'fa fa-fw fa-unlock',
+			    leaf: true,
+			};
+			path_item.children.push(perm_item);
+			path_item.expandable = true;
+		    });
+		    idhash[path] = path_item;
+		});
+
+		Ext.Object.each(idhash, function(path, item) {
+		    let parent_item = idhash['/'];
+		    if (path === '/') {
+			parent_item = root;
+			item.expanded = true;
+		    } else {
+			let split_path = path.split('/');
+			while (split_path.pop()) {
+			    let parent_path = split_path.join('/');
+			    if (idhash[parent_path]) {
+				parent_item = idhash[parent_path];
+				break;
+			    }
+			}
+		    }
+		    parent_item.children.push(item);
+		});
+
+		me.setRootNode(root);
+	    },
+	});
+
+	me.callParent();
+
+	me.store.sorters.add(new Ext.util.Sorter({
+	    sorterFn: function(rec1, rec2) {
+		let v1 = rec1.data.text,
+		    v2 = rec2.data.text;
+		if (rec1.data.type !== rec2.data.type) {
+		    v2 = rec1.data.type;
+		    v1 = rec2.data.type;
+		}
+		if (v1 > v2) {
+		    return 1;
+		} else if (v1 < v2) {
+		    return -1;
+		}
+		return 0;
+	    },
+	}));
+    },
+});
+
+Ext.define('PVE.dc.PermissionView', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.userShowPermissionWindow',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    scrollable: true,
+    width: 800,
+    height: 600,
+    layout: 'fit',
+    cbind: {
+	title: (get) => Ext.String.htmlEncode(get('userid')) +
+	    ` - ${gettext('Granted Permissions')}`,
+    },
+    items: [{
+	xtype: 'pveUserPermissionGrid',
+	cbind: {
+	    userid: '{userid}',
+	},
+    }],
+});
+Ext.define('PVE.dc.PoolEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveDcPoolEdit'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    subject: gettext('Pool'),
+
+    cbindData: {
+	poolid: '',
+	isCreate: (cfg) => !cfg.poolid,
+    },
+
+    cbind: {
+	url: get => `/api2/extjs/pools/${!get('isCreate') ? '?poolid=' + get('poolid') : ''}`,
+	method: get => get('isCreate') ? 'POST' : 'PUT',
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    fieldLabel: gettext('Name'),
+	    cbind: {
+		editable: '{isCreate}',
+		value: '{poolid}',
+	    },
+	    name: 'poolid',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'textfield',
+	    fieldLabel: gettext('Comment'),
+	    name: 'comment',
+	    allowBlank: true,
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	if (me.poolid) {
+	    me.load({
+		success: function(response) {
+		    let data = response.result.data;
+		    if (Ext.isArray(data)) {
+			me.setValues(data[0]);
+		    } else {
+			me.setValues(data);
+		    }
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.dc.PoolView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pvePoolView'],
+
+    onlineHelp: 'pveum_pools',
+
+    stateful: true,
+    stateId: 'grid-pools',
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-pools',
+	    sorters: {
+		property: 'poolid',
+		direction: 'ASC',
+	    },
+	});
+
+        var reload = function() {
+            store.load();
+        };
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/pools/',
+	    callback: function() {
+		reload();
+	    },
+	    getUrl: function(rec) {
+		return '/pools/?poolid=' + rec.getId();
+	    },
+	});
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+            var win = Ext.create('PVE.dc.PoolEdit', {
+                poolid: rec.data.poolid,
+            });
+            win.on('destroy', reload);
+            win.show();
+	};
+
+	var edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	var tbar = [
+            {
+		text: gettext('Create'),
+		handler: function() {
+		    var win = Ext.create('PVE.dc.PoolEdit', {});
+		    win.on('destroy', reload);
+		    win.show();
+		},
+            },
+	    edit_btn, remove_btn,
+        ];
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: tbar,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    width: 200,
+		    sortable: true,
+		    dataIndex: 'poolid',
+		},
+		{
+		    header: gettext('Comment'),
+		    sortable: false,
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'comment',
+		    flex: 1,
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.dc.RoleEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveDcRoleEdit',
+
+    width: 400,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.roleid;
+
+	var url;
+	var method;
+
+	if (me.isCreate) {
+	    url = '/api2/extjs/access/roles';
+	    method = 'POST';
+	} else {
+	    url = '/api2/extjs/access/roles/' + me.roleid;
+	    method = 'PUT';
+	}
+
+	Ext.applyIf(me, {
+	    subject: gettext('Role'),
+	    url: url,
+	    method: method,
+	    items: [
+		{
+		    xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
+		    name: 'roleid',
+		    value: me.roleid,
+		    allowBlank: false,
+		    fieldLabel: gettext('Name'),
+		},
+		{
+		    xtype: 'pvePrivilegesSelector',
+		    name: 'privs',
+		    value: me.privs,
+		    allowBlank: false,
+		    fieldLabel: gettext('Privileges'),
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response) {
+		    var data = response.result.data;
+		    var keys = Ext.Object.getKeys(data);
+
+		    me.setValues({
+			privs: keys,
+			roleid: me.roleid,
+		    });
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.dc.RoleView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveRoleView'],
+
+    onlineHelp: 'pveum_roles',
+
+    stateful: true,
+    stateId: 'grid-roles',
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pmx-roles',
+	    sorters: {
+		property: 'roleid',
+		direction: 'ASC',
+	    },
+	});
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    if (rec.data.special) {
+		return;
+	    }
+	    Ext.create('PVE.dc.RoleEdit', {
+		roleid: rec.data.roleid,
+		privs: rec.data.privs,
+		listeners: {
+		    destroy: () => store.load(),
+		},
+		autoShow: true,
+	    });
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: gettext('Built-In'),
+		    width: 65,
+		    sortable: true,
+		    dataIndex: 'special',
+		    renderer: Proxmox.Utils.format_boolean,
+		},
+		{
+		    header: gettext('Name'),
+		    width: 150,
+		    sortable: true,
+		    dataIndex: 'roleid',
+		},
+		{
+		    itemid: 'privs',
+		    header: gettext('Privileges'),
+		    sortable: false,
+		    renderer: (value, metaData) => {
+			if (!value) {
+			    return '-';
+			}
+			metaData.style = 'white-space:normal;'; // allow word wrap
+			return value.replace(/,/g, ' ');
+		    },
+		    variableRowHeight: true,
+		    dataIndex: 'privs',
+		    flex: 1,
+		},
+	    ],
+	    listeners: {
+		activate: function() {
+		    store.load();
+		},
+		itemdblclick: run_editor,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Create'),
+		    handler: function() {
+			Ext.create('PVE.dc.RoleEdit', {
+			    listeners: {
+				destroy: () => store.load(),
+			    },
+			    autoShow: true,
+			});
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Edit'),
+		    disabled: true,
+		    selModel: sm,
+		    handler: run_editor,
+		    enableFn: (rec) => !rec.data.special,
+		},
+		{
+		    xtype: 'proxmoxStdRemoveButton',
+		    selModel: sm,
+		    callback: () => store.load(),
+		    baseurl: '/access/roles/',
+		    enableFn: (rec) => !rec.data.special,
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('pve-security-groups', {
+    extend: 'Ext.data.Model',
+
+    fields: ['group', 'comment', 'digest'],
+    idProperty: 'group',
+});
+
+Ext.define('PVE.SecurityGroupEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    base_url: "/cluster/firewall/groups",
+
+    allow_iface: false,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = me.group_name === undefined;
+
+	var subject;
+
+        me.url = '/api2/extjs' + me.base_url;
+        me.method = 'POST';
+
+	var items = [
+	    {
+		xtype: 'textfield',
+		name: 'group',
+		value: me.group_name || '',
+		fieldLabel: gettext('Name'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'comment',
+		value: me.group_comment || '',
+		fieldLabel: gettext('Comment'),
+	    },
+	];
+
+	if (me.isCreate) {
+	    subject = gettext('Security Group');
+        } else {
+	    subject = gettext('Security Group') + " '" + me.group_name + "'";
+	    items.push({
+		xtype: 'hiddenfield',
+		name: 'rename',
+		value: me.group_name,
+	    });
+        }
+
+	var ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	// InputPanel does not have a 'create' property, does it need a 'isCreate'
+	    isCreate: me.isCreate,
+	    items: items,
+	});
+
+
+	Ext.apply(me, {
+            subject: subject,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.SecurityGroupList', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveSecurityGroupList',
+
+    stateful: true,
+    stateId: 'grid-securitygroups',
+
+    rulePanel: undefined,
+
+    addBtn: undefined,
+    removeBtn: undefined,
+    editBtn: undefined,
+
+    base_url: "/cluster/firewall/groups",
+
+    initComponent: function() {
+	let me = this;
+	if (!me.base_url) {
+	    throw "no base_url specified";
+	}
+
+	let store = new Ext.data.Store({
+	    model: 'pve-security-groups',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json' + me.base_url,
+	    },
+	    sorters: {
+		property: 'group',
+		direction: 'ASC',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let canEdit = !!caps.dc['Sys.Modify'];
+
+	let reload = function() {
+	    let oldrec = sm.getSelection()[0];
+	    store.load((records, operation, success) => {
+		if (oldrec) {
+		    let rec = store.findRecord('group', oldrec.data.group, 0, false, true, true);
+		    if (rec) {
+			sm.select(rec);
+		    }
+		}
+	    });
+	};
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec || !canEdit) {
+		return;
+	    }
+	    Ext.create('PVE.SecurityGroupEdit', {
+		digest: rec.data.digest,
+		group_name: rec.data.group,
+		group_comment: rec.data.comment,
+		listeners: {
+		    destroy: () => reload(),
+		},
+		autoShow: true,
+	    });
+	};
+
+	me.editBtn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    enableFn: rec => canEdit,
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+	me.addBtn = new Proxmox.button.Button({
+	    text: gettext('Create'),
+	    disabled: !canEdit,
+	    handler: function() {
+		sm.deselectAll();
+		var win = Ext.create('PVE.SecurityGroupEdit', {});
+		win.show();
+		win.on('destroy', reload);
+	    },
+	});
+
+	me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: me.base_url + '/',
+	    enableFn: (rec) => canEdit && rec && me.base_url,
+	    callback: () => reload(),
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    tbar: ['<b>' + gettext('Group') + ':</b>', me.addBtn, me.removeBtn, me.editBtn],
+	    selModel: sm,
+	    columns: [
+		{
+		    header: gettext('Group'),
+		    dataIndex: 'group',
+		    width: '100',
+		},
+		{
+		    header: gettext('Comment'),
+		    dataIndex: 'comment',
+		    renderer: Ext.String.htmlEncode,
+		    flex: 1,
+		},
+	    ],
+	    listeners: {
+		itemdblclick: run_editor,
+		select: function(_sm, rec) {
+		    if (!me.rulePanel) {
+			me.rulePanel = me.up('panel').down('pveFirewallRules');
+		    }
+		    me.rulePanel.setBaseUrl(`/cluster/firewall/groups/${rec.data.group}`);
+		},
+		deselect: function() {
+		    if (!me.rulePanel) {
+			me.rulePanel = me.up('panel').down('pveFirewallRules');
+		    }
+		    me.rulePanel.setBaseUrl(undefined);
+		},
+		show: reload,
+	    },
+	});
+
+	me.callParent();
+
+	store.load();
+    },
+});
+
+Ext.define('PVE.SecurityGroups', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSecurityGroups',
+
+    title: 'Security Groups',
+    onlineHelp: 'pve_firewall_security_groups',
+
+    layout: 'border',
+
+    items: [
+	{
+	    xtype: 'pveFirewallRules',
+	    region: 'center',
+	    allow_groups: false,
+	    list_refs_url: '/cluster/firewall/refs',
+	    tbar_prefix: '<b>' + gettext('Rules') + ':</b>',
+	    border: false,
+	},
+	{
+	    xtype: 'pveSecurityGroupList',
+	    region: 'west',
+	    width: '25%',
+	    border: false,
+	    split: true,
+	},
+    ],
+    listeners: {
+	show: function() {
+	    let sglist = this.down('pveSecurityGroupList');
+	    sglist.fireEvent('show', sglist);
+	},
+    },
+});
+Ext.define('PVE.dc.StorageView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveStorageView'],
+
+    onlineHelp: 'chapter_storage',
+
+    stateful: true,
+    stateId: 'grid-dc-storage',
+
+    createStorageEditWindow: function(type, sid) {
+	let schema = PVE.Utils.storageSchema[type];
+	if (!schema || !schema.ipanel) {
+	    throw "no editor registered for storage type: " + type;
+	}
+
+	Ext.create('PVE.storage.BaseEdit', {
+	    paneltype: 'PVE.storage.' + schema.ipanel,
+	    type: type,
+	    storageId: sid,
+	    canDoBackups: schema.backups,
+	    autoShow: true,
+	    listeners: {
+		destroy: this.reloadStore,
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-storage',
+	    proxy: {
+                type: 'proxmox',
+		url: "/api2/json/storage",
+	    },
+	    sorters: {
+		property: 'storage',
+		direction: 'ASC',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    let { type, storage } = rec.data;
+	    me.createStorageEditWindow(type, storage);
+	};
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/storage/',
+	    callback: () => store.load(),
+	});
+
+	// else we cannot dynamically generate the add menu handlers
+	let addHandleGenerator = function(type) {
+	    return function() { me.createStorageEditWindow(type); };
+	};
+	let addMenuItems = [];
+	for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
+	    if (storage.hideAdd) {
+		continue;
+	    }
+	    addMenuItems.push({
+		text: PVE.Utils.format_storage_type(type),
+		iconCls: 'fa fa-fw fa-' + storage.faIcon,
+		handler: addHandleGenerator(type),
+	    });
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: () => store.load(),
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: addMenuItems,
+		    }),
+		},
+		remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    flex: 2,
+		    sortable: true,
+		    dataIndex: 'storage',
+		},
+		{
+		    header: gettext('Type'),
+		    flex: 1,
+		    sortable: true,
+		    dataIndex: 'type',
+		    renderer: PVE.Utils.format_storage_type,
+		},
+		{
+		    header: gettext('Content'),
+		    flex: 3,
+		    sortable: true,
+		    dataIndex: 'content',
+		    renderer: PVE.Utils.format_content_types,
+		},
+		{
+		    header: gettext('Path') + '/' + gettext('Target'),
+		    flex: 2,
+		    sortable: true,
+		    dataIndex: 'path',
+		    renderer: function(value, metaData, record) {
+			if (record.data.target) {
+			    return record.data.target;
+			}
+			return value;
+		    },
+		},
+		{
+		    header: gettext('Shared'),
+		    flex: 1,
+		    sortable: true,
+		    dataIndex: 'shared',
+		    renderer: Proxmox.Utils.format_boolean,
+		},
+		{
+		    header: gettext('Enabled'),
+		    flex: 1,
+		    sortable: true,
+		    dataIndex: 'disable',
+		    renderer: Proxmox.Utils.format_neg_boolean,
+		},
+		{
+		    header: gettext('Bandwidth Limit'),
+		    flex: 2,
+		    sortable: true,
+		    dataIndex: 'bwlimit',
+		},
+	    ],
+	    listeners: {
+		activate: () => store.load(),
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-storage', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'path', 'type', 'content', 'server', 'portal', 'target', 'export', 'storage',
+	    { name: 'shared', type: 'boolean' },
+	    { name: 'disable', type: 'boolean' },
+	],
+	idProperty: 'storage',
+    });
+});
+Ext.define('PVE.dc.Summary', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveDcSummary',
+
+    scrollable: true,
+
+    bodyPadding: 5,
+
+    layout: 'column',
+
+    defaults: {
+	padding: 5,
+	columnWidth: 1,
+    },
+
+    items: [
+	{
+	    itemId: 'dcHealth',
+	    xtype: 'pveDcHealth',
+	},
+	{
+	    itemId: 'dcGuests',
+	    xtype: 'pveDcGuests',
+	},
+	{
+	    title: gettext('Resources'),
+	    xtype: 'panel',
+	    minHeight: 250,
+	    bodyPadding: 5,
+	    layout: 'hbox',
+	    defaults: {
+		xtype: 'proxmoxGauge',
+		flex: 1,
+	    },
+	    items: [
+		{
+		    title: gettext('CPU'),
+		    itemId: 'cpu',
+		},
+		{
+		    title: gettext('Memory'),
+		    itemId: 'memory',
+		},
+		{
+		    title: gettext('Storage'),
+		    itemId: 'storage',
+		},
+	    ],
+	},
+	{
+	    itemId: 'nodeview',
+	    xtype: 'pveDcNodeView',
+	    height: 250,
+	},
+	{
+	    title: gettext('Subscriptions'),
+	    height: 220,
+	    items: [
+		{
+		    xtype: 'pveHealthWidget',
+		    itemId: 'subscriptions',
+		    userCls: 'pointer',
+		    listeners: {
+			element: 'el',
+			click: function() {
+			    if (this.component.userCls === 'pointer') {
+				window.open('https://www.proxmox.com/en/proxmox-virtual-environment/pricing', '_blank');
+			    }
+			},
+		    },
+		},
+	    ],
+	},
+    ],
+
+    listeners: {
+	resize: function(panel) {
+	    Proxmox.Utils.updateColumns(panel);
+	},
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	var rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 3000,
+	    storeid: 'pve-cluster-status',
+	    model: 'pve-dc-nodes',
+	    proxy: {
+                type: 'proxmox',
+                url: "/api2/json/cluster/status",
+	    },
+	});
+
+	var gridstore = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: rstore,
+	    filters: {
+		property: 'type',
+		value: 'node',
+	    },
+	    sorters: {
+		property: 'id',
+		direction: 'ASC',
+	    },
+	});
+
+	me.callParent();
+
+	me.getComponent('nodeview').setStore(gridstore);
+
+	var gueststatus = me.getComponent('dcGuests');
+
+	var cpustat = me.down('#cpu');
+	var memorystat = me.down('#memory');
+	var storagestat = me.down('#storage');
+	var sp = Ext.state.Manager.getProvider();
+
+	me.mon(PVE.data.ResourceStore, 'load', function(curstore, results) {
+	    me.suspendLayout = true;
+
+	    let cpu = 0, maxcpu = 0;
+	    let memory = 0, maxmem = 0;
+
+	    let used = 0, total = 0;
+	    let countedStorage = {}, usableStorages = {};
+	    let storages = sp.get('dash-storages') || '';
+	    storages.split(',').filter(v => v !== '').forEach(storage => {
+		usableStorages[storage] = true;
+	    });
+
+	    let qemu = {
+		running: 0,
+		paused: 0,
+		stopped: 0,
+		template: 0,
+	    };
+	    let lxc = {
+		running: 0,
+		paused: 0,
+		stopped: 0,
+		template: 0,
+	    };
+	    let error = 0;
+
+	    for (const { data } of results) {
+		switch (data.type) {
+		    case 'node':
+			cpu += data.cpu * data.maxcpu;
+			maxcpu += data.maxcpu || 0;
+			memory += data.mem || 0;
+			maxmem += data.maxmem || 0;
+
+			if (gridstore.getById(data.id)) {
+			    let griditem = gridstore.getById(data.id);
+			    griditem.set('cpuusage', data.cpu);
+			    let max = data.maxmem || 1;
+			    let val = data.mem || 0;
+			    griditem.set('memoryusage', val / max);
+			    griditem.set('uptime', data.uptime);
+			    griditem.commit(); // else the store marks the field as dirty
+			}
+			break;
+		    case 'storage': {
+			let sid = !data.shared || data.storage === 'local' ? data.id : data.storage;
+			if (!Ext.Object.isEmpty(usableStorages)) {
+			    if (usableStorages[data.id] !== true) {
+				break;
+			    }
+			    sid = data.id;
+			} else if (countedStorage[sid]) {
+			    break;
+			}
+			used += data.disk;
+			total += data.maxdisk;
+			countedStorage[sid] = true;
+			break;
+		    }
+		    case 'qemu':
+			qemu[data.template ? 'template' : data.status]++;
+			if (data.hastate === 'error') {
+			    error++;
+			}
+			break;
+		    case 'lxc':
+			lxc[data.template ? 'template' : data.status]++;
+			if (data.hastate === 'error') {
+			    error++;
+			}
+			break;
+		    default: break;
+		}
+	    }
+
+	    let text = Ext.String.format(gettext('of {0} CPU(s)'), maxcpu);
+	    cpustat.updateValue(cpu/maxcpu, text);
+
+	    text = Ext.String.format(gettext('{0} of {1}'), Proxmox.Utils.render_size(memory), Proxmox.Utils.render_size(maxmem));
+	    memorystat.updateValue(memory/maxmem, text);
+
+	    text = Ext.String.format(gettext('{0} of {1}'), Proxmox.Utils.render_size(used), Proxmox.Utils.render_size(total));
+	    storagestat.updateValue(used/total, text);
+
+	    gueststatus.updateValues(qemu, lxc, error);
+
+	    me.suspendLayout = false;
+	    me.updateLayout(true);
+	});
+
+	let dcHealth = me.getComponent('dcHealth');
+	me.mon(rstore, 'load', dcHealth.updateStatus, dcHealth);
+
+	let subs = me.down('#subscriptions');
+	me.mon(rstore, 'load', function(store, records, success) {
+	    var level;
+	    var mixed = false;
+	    for (let i = 0; i < records.length; i++) {
+		let node = records[i];
+		if (node.get('type') !== 'node' || node.get('status') === 'offline') {
+		    continue;
+		}
+
+		let curlevel = node.get('level');
+		if (curlevel === '') { // no subscription beats all, set it and break the loop
+		    level = '';
+		    break;
+		}
+
+		if (level === undefined) { // save level
+		    level = curlevel;
+		} else if (level !== curlevel) { // detect different levels
+		    mixed = true;
+		}
+	    }
+
+	    let data = {
+		title: Proxmox.Utils.unknownText,
+		text: Proxmox.Utils.unknownText,
+		iconCls: PVE.Utils.get_health_icon(undefined, true),
+	    };
+	    if (level === '') {
+		data = {
+		    title: gettext('No Subscription'),
+		    iconCls: PVE.Utils.get_health_icon('critical', true),
+		    text: gettext('You have at least one node without subscription.'),
+		};
+		subs.setUserCls('pointer');
+	    } else if (mixed) {
+		data = {
+		    title: gettext('Mixed Subscriptions'),
+		    iconCls: PVE.Utils.get_health_icon('warning', true),
+		    text: gettext('Warning: Your subscription levels are not the same.'),
+		};
+		subs.setUserCls('pointer');
+	    } else if (level) {
+		data = {
+		    title: PVE.Utils.render_support_level(level),
+		    iconCls: PVE.Utils.get_health_icon('good', true),
+		    text: gettext('Your subscription status is valid.'),
+		};
+		subs.setUserCls('');
+	    }
+
+	    subs.setData(data);
+	});
+
+	me.on('destroy', function() {
+	    rstore.stopUpdate();
+	});
+
+	me.mon(sp, 'statechange', function(provider, key, value) {
+	    if (key !== 'summarycolumns') {
+		return;
+	    }
+	    Proxmox.Utils.updateColumns(me);
+	});
+
+	rstore.startUpdate();
+    },
+
+});
+Ext.define('PVE.dc.Support', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveDcSupport',
+    pveGuidePath: '/pve-docs/index.html',
+    onlineHelp: 'getting_help',
+
+    invalidHtml: '<h1>No valid subscription</h1>' + PVE.Utils.noSubKeyHtml,
+
+    communityHtml: 'Please use the public community <a target="_blank" href="https://forum.proxmox.com">forum</a> for any questions.',
+
+    activeHtml: 'Please use our <a target="_blank" href="https://my.proxmox.com">support portal</a> for any questions. You can also use the public community <a target="_blank" href="https://forum.proxmox.com">forum</a> to get additional information.',
+
+    bugzillaHtml: '<h1>Bug Tracking</h1>Our bug tracking system is available <a target="_blank" href="https://bugzilla.proxmox.com">here</a>.',
+
+    docuHtml: function() {
+	var me = this;
+	var guideUrl = window.location.origin + me.pveGuidePath;
+	var text = Ext.String.format('<h1>Documentation</h1>'
+	+ 'The official Proxmox VE Administration Guide'
+	+ ' is included with this installation and can be browsed at '
+	+ '<a target="_blank" href="{0}">{0}</a>', guideUrl);
+	return text;
+    },
+
+    updateActive: function(data) {
+	var me = this;
+
+	var html = '<h1>' + data.productname + '</h1>' + me.activeHtml;
+	html += '<br><br>' + me.docuHtml();
+	html += '<br><br>' + me.bugzillaHtml;
+
+	me.update(html);
+    },
+
+    updateCommunity: function(data) {
+	var me = this;
+
+	var html = '<h1>' + data.productname + '</h1>' + me.communityHtml;
+	html += '<br><br>' + me.docuHtml();
+	html += '<br><br>' + me.bugzillaHtml;
+
+	me.update(html);
+    },
+
+    updateInactive: function(data) {
+	var me = this;
+	me.update(me.invalidHtml);
+    },
+
+    initComponent: function() {
+        let me = this;
+
+	let reload = function() {
+	    Proxmox.Utils.API2Request({
+		url: '/nodes/localhost/subscription',
+		method: 'GET',
+		waitMsgTarget: me,
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    me.update(`${gettext('Unable to load subscription status')}: ${response.htmlStatus}`);
+		},
+		success: function(response, opts) {
+		    let data = response.result.data;
+		    if (data?.status.toLowerCase() === 'active') {
+			if (data.level === 'c') {
+			    me.updateCommunity(data);
+			} else {
+			    me.updateActive(data);
+			}
+		    } else {
+			me.updateInactive(data);
+		    }
+		},
+	    });
+	};
+
+	Ext.apply(me, {
+	    autoScroll: true,
+	    bodyStyle: 'padding:10px',
+	    listeners: {
+		activate: reload,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.dc.SyncWindow', {
+    extend: 'Ext.window.Window',
+
+    title: gettext('Realm Sync'),
+
+    width: 600,
+    bodyPadding: 10,
+    modal: true,
+    resizable: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'form': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    me.lookup('preview_btn').setDisabled(!valid);
+		    me.lookup('sync_btn').setDisabled(!valid);
+		},
+	    },
+	    'button': {
+		click: function(btn) {
+		    if (btn.reference === 'help_btn') return;
+		    this.sync_realm(btn.reference === 'preview_btn');
+		},
+	    },
+	},
+
+	sync_realm: function(is_preview) {
+	    let me = this;
+	    let view = me.getView();
+	    let ipanel = me.lookup('ipanel');
+	    let params = ipanel.getValues();
+
+	    let vanished_opts = [];
+	    ['acl', 'entry', 'properties'].forEach((prop) => {
+		if (params[`remove-vanished-${prop}`]) {
+		    vanished_opts.push(prop);
+		}
+		delete params[`remove-vanished-${prop}`];
+	    });
+	    if (vanished_opts.length > 0) {
+		params['remove-vanished'] = vanished_opts.join(';');
+	    } else {
+		params['remove-vanished'] = 'none';
+	    }
+
+	    params['dry-run'] = is_preview ? 1 : 0;
+	    Proxmox.Utils.API2Request({
+		url: `/access/domains/${view.realm}/sync`,
+		waitMsgTarget: view,
+		method: 'POST',
+		params,
+		failure: function(response) {
+		    view.show();
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: function(response) {
+		    view.hide();
+		    Ext.create('Proxmox.window.TaskViewer', {
+			upid: response.result.data,
+			listeners: {
+			    destroy: function() {
+				if (is_preview) {
+				    view.show();
+				} else {
+				    view.close();
+				}
+			    },
+			},
+		    }).show();
+		},
+	    });
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'form',
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+	    },
+	    items: [{
+		xtype: 'inputpanel',
+		reference: 'ipanel',
+		column1: [
+		    {
+			xtype: 'proxmoxKVComboBox',
+			name: 'scope',
+			fieldLabel: gettext('Scope'),
+			value: '',
+			emptyText: gettext('No default available'),
+			deleteEmpty: false,
+			allowBlank: false,
+			comboItems: [
+			    ['users', gettext('Users')],
+			    ['groups', gettext('Groups')],
+			    ['both', gettext('Users and Groups')],
+			],
+		    },
+		],
+
+		column2: [
+		    {
+			xtype: 'proxmoxKVComboBox',
+			value: '1',
+			deleteEmpty: false,
+			allowBlank: false,
+			comboItems: [
+			    ['1', Proxmox.Utils.yesText],
+			    ['0', Proxmox.Utils.noText],
+			],
+			name: 'enable-new',
+			fieldLabel: gettext('Enable new'),
+		    },
+		],
+
+		columnB: [
+		    {
+			xtype: 'fieldset',
+			title: gettext('Remove Vanished Options'),
+			items: [
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('ACL'),
+				name: 'remove-vanished-acl',
+				boxLabel: gettext('Remove ACLs of vanished users and groups.'),
+			    },
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Entry'),
+				name: 'remove-vanished-entry',
+				boxLabel: gettext('Remove vanished user and group entries.'),
+			    },
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Properties'),
+				name: 'remove-vanished-properties',
+				boxLabel: gettext('Remove vanished properties from synced users.'),
+			    },
+			],
+		    },
+		    {
+			xtype: 'displayfield',
+			reference: 'defaulthint',
+			value: gettext('Default sync options can be set by editing the realm.'),
+			userCls: 'pmx-hint',
+			hidden: true,
+		    },
+		],
+	    }],
+	},
+    ],
+
+    buttons: [
+	{
+	    xtype: 'proxmoxHelpButton',
+	    reference: 'help_btn',
+	    onlineHelp: 'pveum_ldap_sync',
+	    hidden: false,
+	},
+	'->',
+	{
+	    text: gettext('Preview'),
+	    reference: 'preview_btn',
+	},
+	{
+	    text: gettext('Sync'),
+	    reference: 'sync_btn',
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.realm) {
+	    throw "no realm defined";
+	}
+
+	me.callParent();
+
+	Proxmox.Utils.API2Request({
+	    url: `/access/domains/${me.realm}`,
+	    waitMsgTarget: me,
+	    method: 'GET',
+	    failure: function(response) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		me.close();
+	    },
+	    success: function(response) {
+		let default_options = response.result.data['sync-defaults-options'];
+		if (default_options) {
+		    let options = PVE.Parser.parsePropertyString(default_options);
+		    if (options['remove-vanished']) {
+			let opts = options['remove-vanished'].split(';');
+			for (const opt of opts) {
+			    options[`remove-vanished-${opt}`] = 1;
+			}
+		    }
+		    let ipanel = me.lookup('ipanel');
+		    ipanel.setValues(options);
+		} else {
+		    me.lookup('defaulthint').setVisible(true);
+		}
+
+		// check validity for button state
+		me.lookup('form').isValid();
+	    },
+	});
+    },
+});
+/* This class defines the "Tasks" tab of the bottom status panel
+ * Tasks are jobs with a start, end and log output
+ */
+
+Ext.define('PVE.dc.Tasks', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveClusterTasks'],
+
+    initComponent: function() {
+	let me = this;
+
+	let taskstore = Ext.create('Proxmox.data.UpdateStore', {
+	    storeId: 'pve-cluster-tasks',
+	    model: 'proxmox-tasks',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/cluster/tasks',
+	    },
+	});
+	let store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: taskstore,
+	    sortAfterUpdate: true,
+	    appendAtStart: true,
+	    sorters: [
+		{
+		    property: 'pid',
+		    direction: 'DESC',
+		},
+		{
+		    property: 'starttime',
+		    direction: 'DESC',
+		},
+	    ],
+
+	});
+
+	let run_task_viewer = function() {
+	    var sm = me.getSelectionModel();
+	    var rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    var win = Ext.create('Proxmox.window.TaskViewer', {
+		upid: rec.data.upid,
+		endtime: rec.data.endtime,
+	    });
+	    win.show();
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: false,
+	    viewConfig: {
+		trackOver: false,
+		stripeRows: true, // does not work with getRowClass()
+		getRowClass: function(record, index) {
+		    let taskState = record.get('status');
+		    if (taskState) {
+			let parsed = Proxmox.Utils.parse_task_status(taskState);
+			if (parsed === 'warning') {
+			    return "proxmox-warning-row";
+			} else if (parsed !== 'ok') {
+			    return "proxmox-invalid-row";
+			}
+		    }
+		    return '';
+		},
+	    },
+	    sortableColumns: false,
+	    columns: [
+		{
+		    header: gettext("Start Time"),
+		    dataIndex: 'starttime',
+		    width: 150,
+		    renderer: function(value) {
+			return Ext.Date.format(value, "M d H:i:s");
+		    },
+		},
+		{
+		    header: gettext("End Time"),
+		    dataIndex: 'endtime',
+		    width: 150,
+		    renderer: function(value, metaData, record) {
+			if (record.data.pid) {
+			    if (record.data.type === "vncproxy" ||
+				record.data.type === "vncshell" ||
+				record.data.type === "spiceproxy") {
+				metaData.tdCls = "x-grid-row-console";
+			    } else {
+				metaData.tdCls = "x-grid-row-loading";
+			    }
+			    return "";
+			}
+			return Ext.Date.format(value, "M d H:i:s");
+		    },
+		},
+		{
+		    header: gettext("Node"),
+		    dataIndex: 'node',
+		    width: 100,
+		},
+		{
+		    header: gettext("User name"),
+		    dataIndex: 'user',
+		    renderer: Ext.String.htmlEncode,
+		    width: 150,
+		},
+		{
+		    header: gettext("Description"),
+		    dataIndex: 'upid',
+		    flex: 1,
+		    renderer: Proxmox.Utils.render_upid,
+		},
+		{
+		    header: gettext("Status"),
+		    dataIndex: 'status',
+		    width: 200,
+		    renderer: function(value, metaData, record) {
+			if (record.data.pid) {
+			    if (record.data.type !== "vncproxy") {
+				metaData.tdCls = "x-grid-row-loading";
+			    }
+			    return "";
+			}
+			return Proxmox.Utils.format_task_status(value);
+		    },
+		},
+	    ],
+	    listeners: {
+		itemdblclick: run_task_viewer,
+		show: () => taskstore.startUpdate(),
+		destroy: () => taskstore.stopUpdate(),
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.dc.TokenEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveDcTokenEdit'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    subject: gettext('Token'),
+    onlineHelp: 'pveum_tokens',
+
+    isAdd: true,
+    isCreate: false,
+    fixedUser: false,
+
+    method: 'POST',
+    url: '/api2/extjs/access/users/',
+
+    defaultFocus: 'field[disabled=false][hidden=false][name=tokenid]',
+
+    items: {
+	xtype: 'inputpanel',
+	onGetValues: function(values) {
+	    let me = this;
+	    let win = me.up('pveDcTokenEdit');
+	    win.url = '/api2/extjs/access/users/';
+	    let uid = encodeURIComponent(values.userid);
+	    let tid = encodeURIComponent(values.tokenid);
+	    delete values.userid;
+	    delete values.tokenid;
+
+	    win.url += `${uid}/token/${tid}`;
+	    return values;
+	},
+	column1: [
+	    {
+		xtype: 'pmxDisplayEditField',
+		cbind: {
+		    editable: (get) => get('isCreate') && !get('fixedUser'),
+		},
+		submitValue: true,
+		editConfig: {
+		    xtype: 'pmxUserSelector',
+		    allowBlank: false,
+		},
+		name: 'userid',
+		value: Proxmox.UserName,
+		renderer: Ext.String.htmlEncode,
+		fieldLabel: gettext('User'),
+	    },
+	    {
+		xtype: 'pmxDisplayEditField',
+		cbind: {
+		    editable: '{isCreate}',
+		},
+		name: 'tokenid',
+		fieldLabel: gettext('Token ID'),
+		submitValue: true,
+		minLength: 2,
+		allowBlank: false,
+	    },
+	],
+	column2: [
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'privsep',
+		checked: true,
+		uncheckedValue: 0,
+		fieldLabel: gettext('Privilege Separation'),
+	    },
+	    {
+		xtype: 'pmxExpireDate',
+		name: 'expire',
+	    },
+	],
+	columnB: [
+	    {
+		xtype: 'textfield',
+		name: 'comment',
+		fieldLabel: gettext('Comment'),
+	    },
+	],
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    me.setValues(response.result.data);
+		},
+	    });
+	}
+    },
+    apiCallDone: function(success, response, options) {
+	let res = response.result.data;
+	if (!success || !res.value) {
+	    return;
+	}
+
+	Ext.create('PVE.dc.TokenShow', {
+	    autoShow: true,
+	    tokenid: res['full-tokenid'],
+	    secret: res.value,
+	});
+    },
+});
+
+Ext.define('PVE.dc.TokenShow', {
+    extend: 'Ext.window.Window',
+    alias: ['widget.pveTokenShow'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 600,
+    modal: true,
+    resizable: false,
+    title: gettext('Token Secret'),
+
+    items: [
+	{
+	    xtype: 'container',
+	    layout: 'form',
+	    bodyPadding: 10,
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+            },
+	    padding: '0 10 10 10',
+	    items: [
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Token ID'),
+		    cbind: {
+			value: '{tokenid}',
+		    },
+		    editable: false,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Secret'),
+		    inputId: 'token-secret-value',
+		    cbind: {
+			value: '{secret}',
+		    },
+		    editable: false,
+		},
+	    ],
+	},
+	{
+	    xtype: 'component',
+	    border: false,
+	    padding: '10 10 10 10',
+	    userCls: 'pmx-hint',
+	    html: gettext('Please record the API token secret - it will only be displayed now'),
+	},
+    ],
+    buttons: [
+	{
+	    handler: function(b) {
+		document.getElementById('token-secret-value').select();
+		document.execCommand("copy");
+	    },
+	    text: gettext('Copy Secret Value'),
+	    iconCls: 'fa fa-clipboard',
+	},
+    ],
+});
+Ext.define('PVE.dc.TokenView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveTokenView'],
+
+    onlineHelp: 'chapter_user_management',
+
+    stateful: true,
+    stateId: 'grid-tokens',
+
+    // use fixed user
+    fixedUser: undefined,
+
+    initComponent: function() {
+	let me = this;
+
+	let caps = Ext.state.Manager.get('GuiCap');
+
+	let store = new Ext.data.Store({
+            id: "tokens",
+	    model: 'pve-tokens',
+	    sorters: 'id',
+	});
+
+	let reload = function() {
+	    if (me.fixedUser) {
+		Proxmox.Utils.API2Request({
+		    url: `/access/users/${encodeURIComponent(me.fixedUser)}/token`,
+		    method: 'GET',
+		    failure: function(response, opts) {
+			Proxmox.Utils.setErrorMask(me, response.htmlStatus);
+			me.load_task.delay(me.load_delay);
+		    },
+		    success: function(response, opts) {
+			Proxmox.Utils.setErrorMask(me, false);
+			let result = Ext.decode(response.responseText);
+			let data = result.data || [];
+			let records = [];
+			Ext.Array.each(data, function(token) {
+			    let r = {};
+			    r.id = me.fixedUser + '!' + token.tokenid;
+			    r.userid = me.fixedUser;
+			    r.tokenid = token.tokenid;
+			    r.comment = token.comment;
+			    r.expire = token.expire;
+			    r.privsep = token.privsep === 1;
+			    records.push(r);
+			});
+			store.loadData(records);
+		    },
+		});
+		return;
+	    }
+	    Proxmox.Utils.API2Request({
+		url: '/access/users/?full=1',
+		method: 'GET',
+		failure: function(response, opts) {
+		    Proxmox.Utils.setErrorMask(me, response.htmlStatus);
+		    me.load_task.delay(me.load_delay);
+		},
+		success: function(response, opts) {
+		    Proxmox.Utils.setErrorMask(me, false);
+		    let result = Ext.decode(response.responseText);
+		    let data = result.data || [];
+		    let records = [];
+		    Ext.Array.each(data, function(user) {
+			let tokens = user.tokens || [];
+			Ext.Array.each(tokens, function(token) {
+			    let r = {};
+			    r.id = user.userid + '!' + token.tokenid;
+			    r.userid = user.userid;
+			    r.tokenid = token.tokenid;
+			    r.comment = token.comment;
+			    r.expire = token.expire;
+			    r.privsep = token.privsep === 1;
+			    records.push(r);
+			});
+		    });
+		    store.loadData(records);
+		},
+	    });
+	};
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let urlFromRecord = (rec) => {
+	    let uid = encodeURIComponent(rec.data.userid);
+	    let tid = encodeURIComponent(rec.data.tokenid);
+	    return `/access/users/${uid}/token/${tid}`;
+	};
+
+	let run_editor = function(rec) {
+	    if (!caps.access['User.Modify']) {
+		return;
+	    }
+
+	    let win = Ext.create('PVE.dc.TokenEdit', {
+		method: 'PUT',
+		url: urlFromRecord(rec),
+	    });
+	    win.setValues(rec.data);
+	    win.on('destroy', reload);
+	    win.show();
+	};
+
+        let tbar = [
+            {
+		text: gettext('Add'),
+		disabled: !caps.access['User.Modify'],
+		handler: function(btn, e, rec) {
+		    let data = {};
+		    if (me.fixedUser) {
+			data.userid = me.fixedUser;
+			data.fixedUser = true;
+		    } else if (rec && rec.data) {
+			data.userid = rec.data.userid;
+		    }
+		    let win = Ext.create('PVE.dc.TokenEdit', {
+			isCreate: true,
+			fixedUser: me.fixedUser,
+		    });
+		    win.setValues(data);
+		    win.on('destroy', reload);
+		    win.show();
+		},
+            },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Edit'),
+		disabled: true,
+		enableFn: (rec) => !!caps.access['User.Modify'],
+		selModel: sm,
+		handler: (btn, e, rec) => run_editor(rec),
+	    },
+	    {
+		xtype: 'proxmoxStdRemoveButton',
+		selModel: sm,
+		enableFn: (rec) => !!caps.access['User.Modify'],
+		callback: reload,
+		getUrl: urlFromRecord,
+	    },
+	    '-',
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Show Permissions'),
+		disabled: true,
+		selModel: sm,
+		handler: function(btn, event, rec) {
+		    Ext.create('PVE.dc.PermissionView', {
+			autoShow: true,
+			userid: rec.data.id,
+		    });
+		},
+	    },
+        ];
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: tbar,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: gettext('User name'),
+		    dataIndex: 'userid',
+		    renderer: (uid) => {
+			let realmIndex = uid.lastIndexOf('@');
+			let user = Ext.String.htmlEncode(uid.substr(0, realmIndex));
+			let realm = Ext.String.htmlEncode(uid.substr(realmIndex));
+			return `${user} <span style='float:right;'>${realm}</span>`;
+		    },
+		    hidden: !!me.fixedUser,
+		    flex: 2,
+		},
+		{
+		    header: gettext('Token Name'),
+		    dataIndex: 'tokenid',
+		    hideable: false,
+		    flex: 1,
+		},
+		{
+		    header: gettext('Expire'),
+		    dataIndex: 'expire',
+		    hideable: false,
+		    renderer: Proxmox.Utils.format_expire,
+		    flex: 1,
+		},
+		{
+		    header: gettext('Comment'),
+		    dataIndex: 'comment',
+		    renderer: Ext.String.htmlEncode,
+		    flex: 3,
+		},
+		{
+		    header: gettext('Privilege Separation'),
+		    dataIndex: 'privsep',
+		    hideable: false,
+		    renderer: Proxmox.Utils.format_boolean,
+		    flex: 1,
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: (view, rec) => run_editor(rec),
+	    },
+	});
+
+	if (me.fixedUser) {
+	    reload();
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.window.TokenView', {
+    extend: 'Ext.window.Window',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    modal: true,
+    subject: gettext('API Tokens'),
+    scrollable: true,
+    layout: 'fit',
+    width: 800,
+    height: 400,
+    cbind: {
+	title: gettext('API Tokens') + ' - {userid}',
+    },
+    items: [{
+	xtype: 'pveTokenView',
+	cbind: {
+	    fixedUser: '{userid}',
+	},
+    }],
+});
+Ext.define('PVE.dc.UserEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveDcUserEdit'],
+
+    isAdd: true,
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = !me.userid;
+
+	let url = '/api2/extjs/access/users';
+	let method = 'POST';
+	if (!me.isCreate) {
+	    url += '/' + encodeURIComponent(me.userid);
+	    method = 'PUT';
+	}
+
+	let verifypw, pwfield;
+	let validate_pw = function() {
+	    if (verifypw.getValue() !== pwfield.getValue()) {
+		return gettext("Passwords do not match");
+	    }
+	    return true;
+	};
+	verifypw = Ext.createWidget('textfield', {
+	    inputType: 'password',
+	    fieldLabel: gettext('Confirm password'),
+	    name: 'verifypassword',
+	    submitValue: false,
+	    disabled: true,
+	    hidden: true,
+	    validator: validate_pw,
+	});
+
+	pwfield = Ext.createWidget('textfield', {
+	    inputType: 'password',
+	    fieldLabel: gettext('Password'),
+	    minLength: 5,
+	    name: 'password',
+	    disabled: true,
+	    hidden: true,
+	    validator: validate_pw,
+	});
+
+	let column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'userid',
+		fieldLabel: gettext('User name'),
+		value: me.userid,
+		renderer: Ext.String.htmlEncode,
+		allowBlank: false,
+		submitValue: !!me.isCreate,
+	    },
+	    pwfield,
+	    verifypw,
+	    {
+		xtype: 'pveGroupSelector',
+		name: 'groups',
+		multiSelect: true,
+		allowBlank: true,
+		fieldLabel: gettext('Group'),
+	    },
+	    {
+		xtype: 'pmxExpireDate',
+		name: 'expire',
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Enabled'),
+		name: 'enable',
+		uncheckedValue: 0,
+		defaultValue: 1,
+		checked: true,
+	    },
+	];
+
+	let column2 = [
+	    {
+		xtype: 'textfield',
+		name: 'firstname',
+		fieldLabel: gettext('First Name'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'lastname',
+		fieldLabel: gettext('Last Name'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'email',
+		fieldLabel: gettext('E-Mail'),
+		vtype: 'proxmoxMail',
+	    },
+	];
+
+	if (me.isCreate) {
+	    column1.splice(1, 0, {
+		xtype: 'pmxRealmComboBox',
+		name: 'realm',
+		fieldLabel: gettext('Realm'),
+		allowBlank: false,
+		matchFieldWidth: false,
+		listConfig: { width: 300 },
+		listeners: {
+		    change: function(combo, realm) {
+			me.realm = realm;
+			pwfield.setVisible(realm === 'pve');
+			pwfield.setDisabled(realm !== 'pve');
+			verifypw.setVisible(realm === 'pve');
+			verifypw.setDisabled(realm !== 'pve');
+		    },
+		},
+		submitValue: false,
+	    });
+	}
+
+	var ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	    column1: column1,
+	    column2: column2,
+	    columnB: [
+		{
+		    xtype: 'textfield',
+		    name: 'comment',
+		    fieldLabel: gettext('Comment'),
+		},
+	    ],
+	    advancedItems: [
+		{
+		    xtype: 'textfield',
+		    name: 'keys',
+		    fieldLabel: gettext('Key IDs'),
+		},
+	    ],
+	    onGetValues: function(values) {
+		if (me.realm) {
+		    values.userid = values.userid + '@' + me.realm;
+		}
+		if (!values.password) {
+		    delete values.password;
+		}
+		return values;
+	    },
+	});
+
+	Ext.applyIf(me, {
+	    subject: gettext('User'),
+	    url: url,
+	    method: method,
+	    fieldDefaults: {
+		labelWidth: 110, // some translation are quite long (e.g., Spanish)
+	    },
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var data = response.result.data;
+		    me.setValues(data);
+		    if (data.keys) {
+			if (data.keys === 'x' ||
+			    data.keys === 'x!oath' ||
+			    data.keys === 'x!u2f' ||
+			    data.keys === 'x!yubico') {
+			    me.down('[name="keys"]').setDisabled(1);
+			}
+		    }
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.dc.UserView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveUserView'],
+
+    onlineHelp: 'pveum_users',
+
+    stateful: true,
+    stateId: 'grid-users',
+
+    initComponent: function() {
+	var me = this;
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var store = new Ext.data.Store({
+            id: "users",
+	    model: 'pmx-users',
+	    sorters: {
+		property: 'userid',
+		direction: 'ASC',
+	    },
+	});
+	let reload = () => store.load();
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/access/users/',
+	    dangerous: true,
+	    enableFn: rec => caps.access['User.Modify'] && rec.data.userid !== 'root@pam',
+	    callback: () => reload(),
+	});
+	let run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec || !caps.access['User.Modify']) {
+		return;
+	    }
+	    Ext.create('PVE.dc.UserEdit', {
+		userid: rec.data.userid,
+		autoShow: true,
+		listeners: {
+		    destroy: () => reload(),
+		},
+	    });
+	};
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    enableFn: function(rec) {
+		return !!caps.access['User.Modify'];
+	    },
+	    selModel: sm,
+	    handler: run_editor,
+	});
+	let pwchange_btn = new Proxmox.button.Button({
+	    text: gettext('Password'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: function(record) {
+		let type = record.data['realm-type'];
+		if (type) {
+		    if (PVE.Utils.authSchema[type]) {
+			return !!PVE.Utils.authSchema[type].pwchange;
+		    }
+		}
+		return false;
+	    },
+	    handler: function(btn, event, rec) {
+		Ext.create('Proxmox.window.PasswordEdit', {
+		    userid: rec.data.userid,
+		    confirmCurrentPassword: Proxmox.UserName !== 'root@pam',
+		    autoShow: true,
+		    listeners: {
+			destroy: () => reload(),
+		    },
+		});
+	    },
+	});
+
+	var perm_btn = new Proxmox.button.Button({
+	    text: gettext('Permissions'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: function(btn, event, rec) {
+		Ext.create('PVE.dc.PermissionView', {
+		    userid: rec.data.userid,
+		    autoShow: true,
+		    listeners: {
+			destroy: () => reload(),
+		    },
+		});
+	    },
+	});
+
+	let unlock_btn = new Proxmox.button.Button({
+	    text: gettext('Unlock TFA'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: rec => !!(caps.access['User.Modify'] &&
+	        (rec.data['totp-locked'] || rec.data['tfa-locked-until'])),
+	    handler: function(btn, event, rec) {
+		Ext.Msg.confirm(
+		    Ext.String.format(gettext('Unlock TFA authentication for {0}'), rec.data.userid),
+		    gettext("Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?"),
+		    function(btn_response) {
+			if (btn_response === 'yes') {
+			    Proxmox.Utils.API2Request({
+				url: `/access/users/${rec.data.userid}/unlock-tfa`,
+				waitMsgTarget: me,
+				method: 'PUT',
+				failure: function(response, options) {
+				    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+				},
+				success: function(response, options) {
+				    reload();
+				},
+			    });
+			}
+		    },
+		);
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    disabled: !caps.access['User.Modify'],
+		    handler: function() {
+			Ext.create('PVE.dc.UserEdit', {
+			    autoShow: true,
+			    listeners: {
+				destroy: () => reload(),
+			    },
+			});
+		    },
+		},
+		'-',
+		edit_btn,
+		remove_btn,
+		'-',
+		pwchange_btn,
+		'-',
+		perm_btn,
+		'-',
+		unlock_btn,
+	    ],
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: gettext('User name'),
+		    width: 200,
+		    sortable: true,
+		    renderer: Proxmox.Utils.render_username,
+		    dataIndex: 'userid',
+		},
+		{
+		    header: gettext('Realm'),
+		    width: 100,
+		    sortable: true,
+		    renderer: Proxmox.Utils.render_realm,
+		    dataIndex: 'userid',
+		},
+		{
+		    header: gettext('Enabled'),
+		    width: 80,
+		    sortable: true,
+		    renderer: Proxmox.Utils.format_boolean,
+		    dataIndex: 'enable',
+		},
+		{
+		    header: gettext('Expire'),
+		    width: 80,
+		    sortable: true,
+		    renderer: Proxmox.Utils.format_expire,
+		    dataIndex: 'expire',
+		},
+		{
+		    header: gettext('Name'),
+		    width: 150,
+		    sortable: true,
+		    renderer: PVE.Utils.render_full_name,
+		    dataIndex: 'firstname',
+		},
+		{
+		    header: 'TFA',
+		    width: 120,
+		    sortable: true,
+		    renderer: function(v, metaData, record) {
+			let tfa_type = PVE.Parser.parseTfaType(v);
+			if (tfa_type === undefined) {
+			    return Proxmox.Utils.noText;
+			}
+
+			if (tfa_type !== 1) {
+			    return tfa_type;
+			}
+
+			let locked_until = record.data['tfa-locked-until'];
+			if (locked_until !== undefined) {
+			    let now = new Date().getTime() / 1000;
+			    if (locked_until > now) {
+				return gettext('Locked');
+			    }
+			}
+
+			if (record.data['totp-locked']) {
+			    return gettext('TOTP Locked');
+			}
+
+			return Proxmox.Utils.yesText;
+		    },
+		    dataIndex: 'keys',
+		},
+		{
+		    header: gettext('Groups'),
+		    dataIndex: 'groups',
+		    renderer: Ext.htmlEncode,
+		    flex: 2,
+		},
+		{
+		    header: gettext('Comment'),
+		    sortable: false,
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'comment',
+		    flex: 3,
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, store);
+    },
+});
+Ext.define('PVE.dc.MetricServerView', {
+    extend: 'Ext.grid.Panel',
+    alias: ['widget.pveMetricServerView'],
+
+    stateful: true,
+    stateId: 'grid-metricserver',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	render_type: function(value) {
+	    switch (value) {
+		case 'influxdb': return "InfluxDB";
+		case 'graphite': return "Graphite";
+		default: return Proxmox.Utils.unknownText;
+	    }
+	},
+
+	editWindow: function(xtype, id) {
+	    let me = this;
+	    Ext.create(`PVE.dc.${xtype}Edit`, {
+		serverid: id,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	addServer: function(button) {
+	    this.editWindow(button.text);
+	},
+
+	editServer: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+
+	    let cfg = selection[0].data;
+
+	    let xtype = me.render_type(cfg.type);
+	    me.editWindow(xtype, cfg.id);
+	},
+
+	reload: function() {
+	    this.getView().getStore().load();
+	},
+    },
+
+    store: {
+	autoLoad: true,
+	id: 'metricservers',
+	proxy: {
+	    type: 'proxmox',
+	    url: '/api2/json/cluster/metrics/server',
+	},
+    },
+
+    columns: [
+	{
+	    text: gettext('Name'),
+	    flex: 2,
+	    dataIndex: 'id',
+	},
+	{
+	    text: gettext('Type'),
+	    flex: 1,
+	    dataIndex: 'type',
+	    renderer: 'render_type',
+	},
+	{
+	    text: gettext('Enabled'),
+	    dataIndex: 'disable',
+	    width: 100,
+	    renderer: Proxmox.Utils.format_neg_boolean,
+	},
+	{
+	    text: gettext('Server'),
+	    width: 200,
+	    dataIndex: 'server',
+	},
+	{
+	    text: gettext('Port'),
+	    width: 100,
+	    dataIndex: 'port',
+	},
+    ],
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    menu: [
+		{
+		    text: 'Graphite',
+		    iconCls: 'fa fa-fw fa-bar-chart',
+		    handler: 'addServer',
+		},
+		{
+		    text: 'InfluxDB',
+		    iconCls: 'fa fa-fw fa-bar-chart',
+		    handler: 'addServer',
+		},
+	    ],
+	},
+	{
+	    text: gettext('Edit'),
+	    xtype: 'proxmoxButton',
+	    handler: 'editServer',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    baseurl: `/api2/extjs/cluster/metrics/server`,
+	    callback: 'reload',
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'editServer',
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore());
+    },
+});
+
+Ext.define('PVE.dc.MetricServerBaseEdit', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function() {
+	let me = this;
+	me.isCreate = !me.serverid;
+	me.serverid = me.serverid || "";
+	me.url = `/api2/extjs/cluster/metrics/server/${me.serverid}`;
+	me.method = me.isCreate ? 'POST' : 'PUT';
+	if (!me.isCreate) {
+	    me.subject = `${me.subject}: ${me.serverid}`;
+	}
+	return {};
+    },
+
+    submitUrl: function(url, values) {
+	return this.isCreate ? `${url}/${values.id}` : url;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	if (me.serverid) {
+	    me.load({
+		success: function(response, options) {
+		    let values = response.result.data;
+		    values.enable = !values.disable;
+		    me.down('inputpanel').setValues(values);
+		},
+	    });
+	}
+    },
+});
+
+Ext.define('PVE.dc.InfluxDBEdit', {
+    extend: 'PVE.dc.MetricServerBaseEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'metric_server_influxdb',
+
+    subject: 'InfluxDB',
+
+    cbindData: function() {
+	let me = this;
+	me.callParent();
+	me.tokenEmptyText = me.isCreate ? '' : gettext('unchanged');
+	return {};
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    cbind: {
+		isCreate: '{isCreate}',
+	    },
+	    onGetValues: function(values) {
+		let me = this;
+		values.disable = values.enable ? 0 : 1;
+		delete values.enable;
+		PVE.Utils.delete_if_default(values, 'verify-certificate', '1', me.isCreate);
+		return values;
+	    },
+
+	    column1: [
+		{
+		    xtype: 'hidden',
+		    name: 'type',
+		    value: 'influxdb',
+		    cbind: {
+			submitValue: '{isCreate}',
+		    },
+		},
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'id',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: false,
+		    cbind: {
+			editable: '{isCreate}',
+			value: '{serverid}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'server',
+		    fieldLabel: gettext('Server'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'port',
+		    fieldLabel: gettext('Port'),
+		    value: 8089,
+		    minValue: 1,
+		    maximum: 65536,
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'influxdbproto',
+		    fieldLabel: gettext('Protocol'),
+		    value: '__default__',
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    comboItems: [
+			['__default__', 'UDP'],
+			['http', 'HTTP'],
+			['https', 'HTTPS'],
+		    ],
+		    listeners: {
+			change: function(field, value) {
+			    let me = this;
+			    let view = me.up('inputpanel');
+			    let isUdp = value !== 'http' && value !== 'https';
+			    view.down('field[name=organization]').setDisabled(isUdp);
+			    view.down('field[name=bucket]').setDisabled(isUdp);
+			    view.down('field[name=token]').setDisabled(isUdp);
+			    view.down('field[name=api-path-prefix]').setDisabled(isUdp);
+			    view.down('field[name=mtu]').setDisabled(!isUdp);
+			    view.down('field[name=timeout]').setDisabled(isUdp);
+			    view.down('field[name=max-body-size]').setDisabled(isUdp);
+			    view.down('field[name=verify-certificate]').setDisabled(value !== 'https');
+			},
+		    },
+		},
+	    ],
+
+	    column2: [
+		{
+		    xtype: 'checkbox',
+		    name: 'enable',
+		    fieldLabel: gettext('Enabled'),
+		    inputValue: 1,
+		    uncheckedValue: 0,
+		    checked: true,
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'organization',
+		    fieldLabel: gettext('Organization'),
+		    emptyText: 'proxmox',
+		    disabled: true,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'bucket',
+		    fieldLabel: gettext('Bucket'),
+		    emptyText: 'proxmox',
+		    disabled: true,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'token',
+		    fieldLabel: gettext('Token'),
+		    disabled: true,
+		    allowBlank: true,
+		    deleteEmpty: false,
+		    submitEmpty: false,
+		    cbind: {
+			disabled: '{!isCreate}',
+			emptyText: '{tokenEmptyText}',
+		    },
+		},
+	    ],
+
+	    advancedColumn1: [
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'api-path-prefix',
+		    fieldLabel: gettext('API Path Prefix'),
+		    allowBlank: true,
+		    disabled: true,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'timeout',
+		    fieldLabel: gettext('Timeout (s)'),
+		    disabled: true,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    minValue: 1,
+		    emptyText: 1,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'verify-certificate',
+		    fieldLabel: gettext('Verify Certificate'),
+		    value: 1,
+		    uncheckedValue: 0,
+		    disabled: true,
+		},
+	    ],
+
+	    advancedColumn2: [
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'max-body-size',
+		    fieldLabel: gettext('Batch Size (b)'),
+		    minValue: 1,
+		    emptyText: '25000000',
+		    submitEmpty: false,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'mtu',
+		    fieldLabel: 'MTU',
+		    minValue: 1,
+		    emptyText: '1500',
+		    submitEmpty: false,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+	    ],
+	},
+    ],
+});
+
+Ext.define('PVE.dc.GraphiteEdit', {
+    extend: 'PVE.dc.MetricServerBaseEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'metric_server_graphite',
+
+    subject: 'Graphite',
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+
+	    onGetValues: function(values) {
+		values.disable = values.enable ? 0 : 1;
+		delete values.enable;
+		return values;
+	    },
+
+	    column1: [
+		{
+		    xtype: 'hidden',
+		    name: 'type',
+		    value: 'graphite',
+		    cbind: {
+			submitValue: '{isCreate}',
+		    },
+		},
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'id',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: false,
+		    cbind: {
+			editable: '{isCreate}',
+			value: '{serverid}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'server',
+		    fieldLabel: gettext('Server'),
+		    allowBlank: false,
+		},
+	    ],
+
+	    column2: [
+		{
+		    xtype: 'checkbox',
+		    name: 'enable',
+		    fieldLabel: gettext('Enabled'),
+		    inputValue: 1,
+		    uncheckedValue: 0,
+		    checked: true,
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'port',
+		    fieldLabel: gettext('Port'),
+		    value: 2003,
+		    minimum: 1,
+		    maximum: 65536,
+		    allowBlank: false,
+		},
+		{
+		    fieldLabel: gettext('Path'),
+		    xtype: 'proxmoxtextfield',
+		    emptyText: 'proxmox',
+		    name: 'path',
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+	    ],
+
+	    advancedColumn1: [
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'proto',
+		    fieldLabel: gettext('Protocol'),
+		    value: '__default__',
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    comboItems: [
+			['__default__', 'UDP'],
+			['tcp', 'TCP'],
+		    ],
+		    listeners: {
+			change: function(field, value) {
+			    let me = this;
+			    me.up('inputpanel').down('field[name=timeout]').setDisabled(value !== 'tcp');
+			    me.up('inputpanel').down('field[name=mtu]').setDisabled(value === 'tcp');
+			},
+		    },
+		},
+	    ],
+
+	    advancedColumn2: [
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'mtu',
+		    fieldLabel: 'MTU',
+		    minimum: 1,
+		    emptyText: '1500',
+		    submitEmpty: false,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxintegerfield',
+		    name: 'timeout',
+		    fieldLabel: gettext('TCP Timeout'),
+		    disabled: true,
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    minValue: 1,
+		    emptyText: 1,
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.dc.UserTagAccessEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveUserTagAccessEdit',
+
+    subject: gettext('User Tag Access'),
+    onlineHelp: 'datacenter_configuration_file',
+
+    url: '/api2/extjs/cluster/options',
+
+    hintText: gettext('NOTE: The following tags are also defined as registered tags.'),
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	tagChange: function(field, value) {
+	    let me = this;
+	    let view = me.getView();
+	    let also_registered = [];
+	    value = Ext.isArray(value) ? value : value.split(';');
+	    value.forEach(tag => {
+		if (view.registered_tags.indexOf(tag) !== -1) {
+		    also_registered.push(tag);
+		}
+	    });
+	    let hint_field = me.lookup('hintField');
+	    hint_field.setVisible(also_registered.length > 0);
+	    if (also_registered.length > 0) {
+		hint_field.setValue(`${view.hintText} ${also_registered.join(', ')}`);
+	    }
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    setValues: function(values) {
+		this.up('pveUserTagAccessEdit').registered_tags = values?.['registered-tags'] ?? [];
+		let data = values?.['user-tag-access'] ?? {};
+		return Proxmox.panel.InputPanel.prototype.setValues.call(this, data);
+	    },
+	    onGetValues: function(values) {
+		if (values === undefined || Object.keys(values).length === 0) {
+		    return { 'delete': 'user-tag-access' };
+		}
+		return {
+		    'user-tag-access': PVE.Parser.printPropertyString(values),
+		};
+	    },
+	    items: [
+		{
+		    name: 'user-allow',
+		    fieldLabel: gettext('Mode'),
+		    xtype: 'proxmoxKVComboBox',
+		    deleteEmpty: false,
+		    value: '__default__',
+		    comboItems: [
+			['__default__', Proxmox.Utils.defaultText + ' (free)'],
+			['free', 'free'],
+			['existing', 'existing'],
+			['list', 'list'],
+			['none', 'none'],
+		    ],
+		    defaultValue: '__default__',
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Predefined Tags'),
+		},
+		{
+		    name: 'user-allow-list',
+		    xtype: 'pveListField',
+		    emptyText: gettext('No Tags defined'),
+		    fieldTitle: gettext('Tag'),
+		    maskRe: PVE.Utils.tagCharRegex,
+		    gridConfig: {
+			height: 200,
+			scrollable: true,
+		    },
+		    listeners: {
+			change: 'tagChange',
+		    },
+		},
+		{
+		    hidden: true,
+		    xtype: 'displayfield',
+		    reference: 'hintField',
+		    userCls: 'pmx-hint',
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.dc.RegisteredTagsEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveRegisteredTagEdit',
+
+    subject: gettext('Registered Tags'),
+    onlineHelp: 'datacenter_configuration_file',
+
+    url: '/api2/extjs/cluster/options',
+
+    hintText: gettext('NOTE: The following tags are also defined in the user allow list.'),
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	tagChange: function(field, value) {
+	    let me = this;
+	    let view = me.getView();
+	    let also_allowed = [];
+	    value = Ext.isArray(value) ? value : value.split(';');
+	    value.forEach(tag => {
+		if (view.allowed_tags.indexOf(tag) !== -1) {
+		    also_allowed.push(tag);
+		}
+	    });
+	    let hint_field = me.lookup('hintField');
+	    hint_field.setVisible(also_allowed.length > 0);
+	    if (also_allowed.length > 0) {
+		hint_field.setValue(`${view.hintText} ${also_allowed.join(', ')}`);
+	    }
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    setValues: function(values) {
+		let allowed_tags = values?.['user-tag-access']?.['user-allow-list'] ?? [];
+		this.up('pveRegisteredTagEdit').allowed_tags = allowed_tags;
+		let tags = values?.['registered-tags'];
+		return Proxmox.panel.InputPanel.prototype.setValues.call(this, { tags });
+	    },
+	    onGetValues: function(values) {
+		if (!values.tags) {
+		    return {
+			'delete': 'registered-tags',
+		    };
+		} else {
+		    return {
+			'registered-tags': values.tags,
+		    };
+		}
+	    },
+	    items: [
+		{
+		    name: 'tags',
+		    xtype: 'pveListField',
+		    maskRe: PVE.Utils.tagCharRegex,
+		    gridConfig: {
+			height: 200,
+			scrollable: true,
+			emptyText: gettext('No Tags defined'),
+		    },
+		    listeners: {
+			change: 'tagChange',
+		    },
+		},
+		{
+		    hidden: true,
+		    xtype: 'displayfield',
+		    reference: 'hintField',
+		    userCls: 'pmx-hint',
+		},
+	    ],
+	},
+    ],
+});
+Ext.define('PVE.dc.RealmSyncJobView', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveRealmSyncJobView',
+
+    stateful: true,
+    stateId: 'grid-realmsyncjobs',
+
+    emptyText: Ext.String.format(gettext('No {0} configured'), gettext('Realm Sync Job')),
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addRealmSyncJob: function(button) {
+	    let me = this;
+	    Ext.create(`PVE.dc.RealmSyncJobEdit`, {
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	editRealmSyncJob: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+
+	    Ext.create(`PVE.dc.RealmSyncJobEdit`, {
+		jobid: selection[0].data.id,
+		autoShow: true,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	},
+
+	runNow: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (!selection || selection.length < 1) {
+		return;
+	    }
+
+	    let params = selection[0].data;
+	    let realm = params.realm;
+
+	    let propertiesToDelete = ['comment', 'realm', 'id', 'type', 'schedule', 'last-run', 'next-run', 'enabled'];
+	    for (const prop of propertiesToDelete) {
+		delete params[prop];
+	    }
+
+	    Proxmox.Utils.API2Request({
+		url: `/access/domains/${realm}/sync`,
+		params,
+		waitMsgTarget: view,
+		method: 'POST',
+		failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		success: function(response, options) {
+		    Ext.create('Proxmox.window.TaskProgress', {
+			autoShow: true,
+			upid: response.result.data,
+			taskDone: () => { me.reload(); },
+		    });
+		},
+	    });
+	},
+
+	reload: function() {
+	    this.getView().getStore().load();
+	},
+    },
+
+    store: {
+	autoLoad: true,
+	id: 'realm-syncs',
+	proxy: {
+	    type: 'proxmox',
+	    url: '/api2/json/cluster/jobs/realm-sync',
+	},
+    },
+
+    viewConfig: {
+	getRowClass: (record, _index) => record.get('enabled') ? '' : 'proxmox-disabled-row',
+    },
+
+    columns: [
+	{
+	    header: gettext('Enabled'),
+	    width: 80,
+	    dataIndex: 'enabled',
+	    sortable: true,
+	    align: 'center',
+	    stopSelection: false,
+	    renderer: Proxmox.Utils.renderEnabledIcon,
+	},
+	{
+	    text: gettext('Name'),
+	    flex: 1,
+	    dataIndex: 'id',
+	    hidden: true,
+	},
+	{
+	    text: gettext('Realm'),
+	    width: 200,
+	    dataIndex: 'realm',
+	},
+	{
+	    header: gettext('Schedule'),
+	    width: 150,
+	    dataIndex: 'schedule',
+	},
+	{
+	    text: gettext('Next Run'),
+	    dataIndex: 'next-run',
+	    width: 150,
+	    renderer: PVE.Utils.render_next_event,
+	},
+	{
+	    header: gettext('Comment'),
+	    dataIndex: 'comment',
+	    renderer: Ext.htmlEncode,
+	    sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''),
+	    flex: 1,
+	},
+    ],
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    handler: 'addRealmSyncJob',
+	},
+	{
+	    text: gettext('Edit'),
+	    xtype: 'proxmoxButton',
+	    handler: 'editRealmSyncJob',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    baseurl: `/api2/extjs/cluster/jobs/realm-sync`,
+	    callback: 'reload',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    handler: 'runNow',
+	    disabled: true,
+	    text: gettext('Run Now'),
+	},
+    ],
+
+    listeners: {
+	itemdblclick: 'editRealmSyncJob',
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore());
+    },
+});
+
+Ext.define('PVE.dc.RealmSyncJobEdit', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    subject: gettext('Realm Sync Job'),
+    onlineHelp: 'pveum_ldap_sync',
+
+    // don't focus the schedule field on edit
+    defaultFocus: 'field[name=id]',
+
+    cbindData: function() {
+	let me = this;
+	me.isCreate = !me.jobid;
+	me.jobid = me.jobid || "";
+	let url = '/api2/extjs/cluster/jobs/realm-sync';
+	me.url = me.jobid ? `${url}/${me.jobid}` : url;
+	me.method = me.isCreate ? 'POST' : 'PUT';
+	if (!me.isCreate) {
+	    me.subject = `${me.subject}: ${me.jobid}`;
+	}
+	return {};
+    },
+
+    submitUrl: function(url, values) {
+	return this.isCreate ? `${url}/${values.id}` : url;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	updateDefaults: function(_field, newValue) {
+	    let me = this;
+
+	    ['scope', 'enable-new', 'schedule'].forEach((reference) => {
+		me.lookup(reference)?.setDisabled(false);
+	    });
+
+	    // only update on create
+	    if (!me.getView().isCreate) {
+		return;
+	    }
+	    Proxmox.Utils.API2Request({
+		url: `/access/domains/${newValue}`,
+		success: function(response) {
+		    // first reset the fields to their default
+		    ['acl', 'entry', 'properties'].forEach(opt => {
+			me.lookup(`remove-vanished-${opt}`)?.setValue(false);
+		    });
+		    me.lookup('enable-new')?.setValue('1');
+		    me.lookup('scope')?.setValue(undefined);
+
+		    let options = response?.result?.data?.['sync-defaults-options'];
+		    if (options) {
+			let parsed = PVE.Parser.parsePropertyString(options);
+			if (parsed['remove-vanished']) {
+			    let opts = parsed['remove-vanished'].split(';');
+			    for (const opt of opts) {
+				me.lookup(`remove-vanished-${opt}`)?.setValue(true);
+			    }
+			    delete parsed['remove-vanished'];
+			}
+			for (const [name, value] of Object.entries(parsed)) {
+			    me.lookup(name)?.setValue(value);
+			}
+		    }
+		},
+	    });
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+
+	    cbind: {
+		isCreate: '{isCreate}',
+	    },
+
+	    onGetValues: function(values) {
+		let me = this;
+
+		let vanished_opts = [];
+		['acl', 'entry', 'properties'].forEach((prop) => {
+		    if (values[`remove-vanished-${prop}`]) {
+			vanished_opts.push(prop);
+		    }
+		    delete values[`remove-vanished-${prop}`];
+		});
+
+		if (!values.id && me.isCreate) {
+		    values.id = 'realmsync-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13);
+		}
+
+		if (vanished_opts.length > 0) {
+		    values['remove-vanished'] = vanished_opts.join(';');
+		} else {
+		    values['remove-vanished'] = 'none';
+		}
+
+		PVE.Utils.delete_if_default(values, 'node', '');
+
+		if (me.isCreate) {
+		    delete values.delete; // on create we cannot delete values
+		}
+
+		return values;
+	    },
+
+	    column1: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    editConfig: {
+			xtype: 'pmxRealmComboBox',
+			storeFilter: rec => rec.data.type === 'ldap' || rec.data.type === 'ad',
+		    },
+		    listConfig: {
+			emptyText: `<div class="x-grid-empty">${gettext('No LDAP/AD Realm found')}</div>`,
+		    },
+		    cbind: {
+			editable: '{isCreate}',
+		    },
+		    listeners: {
+			change: 'updateDefaults',
+		    },
+		    fieldLabel: gettext('Realm'),
+		    name: 'realm',
+		    reference: 'realm',
+		},
+		{
+		    xtype: 'pveCalendarEvent',
+		    fieldLabel: gettext('Schedule'),
+		    disabled: true,
+		    allowBlank: false,
+		    name: 'schedule',
+		    reference: 'schedule',
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Enable Job'),
+		    name: 'enabled',
+		    reference: 'enabled',
+		    uncheckedValue: 0,
+		    defaultValue: 1,
+		    checked: true,
+		},
+	    ],
+
+	    column2: [
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'scope',
+		    reference: 'scope',
+		    disabled: true,
+		    fieldLabel: gettext('Scope'),
+		    value: '',
+		    emptyText: gettext('No default available'),
+		    deleteEmpty: false,
+		    allowBlank: false,
+		    comboItems: [
+			['users', gettext('Users')],
+			['groups', gettext('Groups')],
+			['both', gettext('Users and Groups')],
+		    ],
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    value: '1',
+		    deleteEmpty: false,
+		    disabled: true,
+		    allowBlank: false,
+		    comboItems: [
+			['1', Proxmox.Utils.yesText],
+			['0', Proxmox.Utils.noText],
+		    ],
+		    name: 'enable-new',
+		    reference: 'enable-new',
+		    fieldLabel: gettext('Enable New'),
+		},
+	    ],
+
+	    columnB: [
+		{
+		    xtype: 'fieldset',
+		    title: gettext('Remove Vanished Options'),
+		    items: [
+			{
+			    xtype: 'proxmoxcheckbox',
+			    fieldLabel: gettext('ACL'),
+			    name: 'remove-vanished-acl',
+			    reference: 'remove-vanished-acl',
+			    boxLabel: gettext('Remove ACLs of vanished users and groups.'),
+			},
+			{
+			    xtype: 'proxmoxcheckbox',
+			    fieldLabel: gettext('Entry'),
+			    name: 'remove-vanished-entry',
+			    reference: 'remove-vanished-entry',
+			    boxLabel: gettext('Remove vanished user and group entries.'),
+			},
+			{
+			    xtype: 'proxmoxcheckbox',
+			    fieldLabel: gettext('Properties'),
+			    name: 'remove-vanished-properties',
+			    reference: 'remove-vanished-properties',
+			    boxLabel: gettext('Remove vanished properties from synced users.'),
+			},
+		    ],
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'comment',
+		    fieldLabel: gettext('Job Comment'),
+		    cbind: {
+			deleteEmpty: '{!isCreate}',
+		    },
+		    autoEl: {
+			tag: 'div',
+			'data-qtip': gettext('Description of the job'),
+		    },
+		},
+		{
+		    xtype: 'displayfield',
+		    reference: 'defaulthint',
+		    value: gettext('Default sync options can be set by editing the realm.'),
+		    userCls: 'pmx-hint',
+		    hidden: true,
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	if (me.jobid) {
+	    me.load({
+		success: function(response, options) {
+		    let values = response.result.data;
+
+		    if (values['remove-vanished']) {
+			let opts = values['remove-vanished'].split(';');
+			for (const opt of opts) {
+			    values[`remove-vanished-${opt}`] = 1;
+			}
+		    }
+		    me.down('inputpanel').setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('pve-resource-pci-tree', {
+    extend: 'Ext.data.Model',
+    idProperty: 'internalId',
+    fields: ['type', 'text', 'path', 'id', 'subsystem-id', 'iommugroup', 'description', 'digest'],
+});
+
+Ext.define('PVE.dc.PCIMapView', {
+    extend: 'PVE.tree.ResourceMapTree',
+    alias: 'widget.pveDcPCIMapView',
+
+    editWindowClass: 'PVE.window.PCIMapEditWindow',
+    baseUrl: '/cluster/mapping/pci',
+    mapIconCls: 'pve-itype-icon-pci',
+    getStatusCheckUrl: (node) => `/nodes/${node}/hardware/pci?pci-class-blacklist=`,
+    entryIdProperty: 'path',
+
+    checkValidity: function(data, node) {
+	let me = this;
+	let ids = {};
+	data.forEach((entry) => {
+	    ids[entry.id] = entry;
+	});
+	me.getRootNode()?.cascade(function(rec) {
+	    if (rec.data.node !== node || rec.data.type !== 'map') {
+		return;
+	    }
+
+	    let id = rec.data.path;
+	    if (!id.match(/\.\d$/)) {
+		id += '.0';
+	    }
+	    let device = ids[id];
+	    if (!device) {
+		rec.set('valid', 0);
+		rec.set('errmsg', Ext.String.format(gettext("Cannot find PCI id {0}"), id));
+		rec.commit();
+		return;
+	    }
+
+
+	    let deviceId = `${device.vendor}:${device.device}`.replace(/0x/g, '');
+	    let subId = `${device.subsystem_vendor}:${device.subsystem_device}`.replace(/0x/g, '');
+
+	    let toCheck = {
+		id: deviceId,
+		'subsystem-id': subId,
+		iommugroup: device.iommugroup !== -1 ? device.iommugroup : undefined,
+	    };
+
+	    let valid = 1;
+	    let errors = [];
+	    let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')");
+	    for (const [key, validValue] of Object.entries(toCheck)) {
+		if (`${rec.data[key]}` !== `${validValue}`) {
+		    errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue));
+		    valid = 0;
+		}
+	    }
+
+	    rec.set('valid', valid);
+	    rec.set('errmsg', errors.join('<br>'));
+	    rec.commit();
+	});
+    },
+
+    store: {
+	sorters: 'text',
+	model: 'pve-resource-pci-tree',
+	data: {},
+    },
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: gettext('ID/Node/Path'),
+	    dataIndex: 'text',
+	    width: 200,
+	},
+	{
+	    text: gettext('Vendor/Device'),
+	    dataIndex: 'id',
+	},
+	{
+	    text: gettext('Subsystem Vendor/Device'),
+	    dataIndex: 'subsystem-id',
+	},
+	{
+	    text: gettext('IOMMU-Group'),
+	    dataIndex: 'iommugroup',
+	},
+	{
+	    header: gettext('Status'),
+	    dataIndex: 'valid',
+	    flex: 1,
+	    renderer: 'renderStatus',
+	},
+	{
+	    header: gettext('Comment'),
+	    dataIndex: 'description',
+	    renderer: function(value, _meta, record) {
+		return Ext.String.htmlEncode(value ?? record.data.comment);
+	    },
+	    flex: 1,
+	},
+    ],
+});
+Ext.define('pve-resource-usb-tree', {
+    extend: 'Ext.data.Model',
+    idProperty: 'internalId',
+    fields: ['type', 'text', 'path', 'id', 'description', 'digest'],
+});
+
+Ext.define('PVE.dc.USBMapView', {
+    extend: 'PVE.tree.ResourceMapTree',
+    alias: 'widget.pveDcUSBMapView',
+
+    editWindowClass: 'PVE.window.USBMapEditWindow',
+    baseUrl: '/cluster/mapping/usb',
+    mapIconCls: 'fa fa-usb',
+    getStatusCheckUrl: (node) => `/nodes/${node}/hardware/usb`,
+    entryIdProperty: 'id',
+
+    checkValidity: function(data, node) {
+	let me = this;
+	let ids = {};
+	let paths = {};
+	data.forEach((entry) => {
+	    ids[`${entry.vendid}:${entry.prodid}`] = entry;
+	    paths[`${entry.busnum}-${entry.usbpath}`] = entry;
+	});
+	me.getRootNode()?.cascade(function(rec) {
+	    if (rec.data.node !== node || rec.data.type !== 'map') {
+		return;
+	    }
+
+	    let device;
+	    if (rec.data.path) {
+		device = paths[rec.data.path];
+	    }
+	    device ??= ids[rec.data.id];
+
+	    if (!device) {
+		rec.set('valid', 0);
+		rec.set('errmsg', Ext.String.format(gettext("Cannot find USB device {0}"), rec.data.id));
+		rec.commit();
+		return;
+	    }
+
+
+	    let deviceId = `${device.vendid}:${device.prodid}`.replace(/0x/g, '');
+
+	    let toCheck = {
+		id: deviceId,
+	    };
+
+	    let valid = 1;
+	    let errors = [];
+	    let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')");
+	    for (const [key, validValue] of Object.entries(toCheck)) {
+		if (rec.data[key] !== validValue) {
+		    errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue));
+		    valid = 0;
+		}
+	    }
+
+	    rec.set('valid', valid);
+	    rec.set('errmsg', errors.join('<br>'));
+	    rec.commit();
+	});
+    },
+
+    store: {
+	sorters: 'text',
+	model: 'pve-resource-usb-tree',
+	data: {},
+    },
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: gettext('ID/Node/Vendor&Device'),
+	    dataIndex: 'text',
+	    width: 200,
+	},
+	{
+	    text: gettext('Path'),
+	    dataIndex: 'path',
+	},
+	{
+	    header: gettext('Status'),
+	    dataIndex: 'valid',
+	    flex: 1,
+	    renderer: 'renderStatus',
+	},
+	{
+	    header: gettext('Comment'),
+	    dataIndex: 'description',
+	    renderer: function(value, _meta, record) {
+		return Ext.String.htmlEncode(value ?? record.data.comment);
+	    },
+	    flex: 1,
+	},
+    ],
+});
+Ext.define('PVE.lxc.CmdMenu', {
+    extend: 'Ext.menu.Menu',
+
+    showSeparator: false,
+    initComponent: function() {
+	let me = this;
+
+	let info = me.pveSelNode.data;
+	if (!info.node) {
+	    throw "no node name specified";
+	}
+	if (!info.vmid) {
+	    throw "no CT ID specified";
+	}
+
+	let vm_command = function(cmd, params) {
+	    Proxmox.Utils.API2Request({
+		params: params,
+		url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`,
+		method: 'POST',
+		failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    });
+	};
+	let confirmedVMCommand = (cmd, params) => {
+	    let msg = Proxmox.Utils.format_task_description(`vz${cmd}`, info.vmid);
+	    Ext.Msg.confirm(gettext('Confirm'), msg, btn => {
+		if (btn === 'yes') {
+		    vm_command(cmd, params);
+		}
+	    });
+	};
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let standalone = PVE.Utils.isStandaloneNode();
+
+	let running = false, stopped = true, suspended = false;
+	switch (info.status) {
+	    case 'running':
+		running = true;
+		stopped = false;
+		break;
+	    case 'paused':
+		stopped = false;
+		suspended = true;
+		break;
+	    default: break;
+	}
+
+	me.title = 'CT ' + info.vmid;
+
+	me.items = [
+	    {
+		text: gettext('Start'),
+		iconCls: 'fa fa-fw fa-play',
+		disabled: running,
+		handler: () => vm_command('start'),
+	    },
+	    {
+		text: gettext('Shutdown'),
+		iconCls: 'fa fa-fw fa-power-off',
+		disabled: stopped || suspended,
+		handler: () => confirmedVMCommand('shutdown'),
+	    },
+	    {
+		text: gettext('Stop'),
+		iconCls: 'fa fa-fw fa-stop',
+		disabled: stopped,
+		tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'),
+		handler: () => {
+		    Ext.create('PVE.GuestStop', {
+			nodename: info.node,
+			vm: info,
+			autoShow: true,
+		    });
+		},
+	    },
+	    {
+		text: gettext('Reboot'),
+		iconCls: 'fa fa-fw fa-refresh',
+		disabled: stopped,
+		tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'),
+		handler: () => confirmedVMCommand('reboot'),
+	    },
+	    {
+		xtype: 'menuseparator',
+		hidden: (standalone || !caps.vms['VM.Migrate']) && !caps.vms['VM.Allocate'] && !caps.vms['VM.Clone'],
+	    },
+	    {
+		text: gettext('Clone'),
+		iconCls: 'fa fa-fw fa-clone',
+		hidden: !caps.vms['VM.Clone'],
+		handler: () => PVE.window.Clone.wrap(info.node, info.vmid, me.isTemplate, 'lxc'),
+	    },
+	    {
+		text: gettext('Migrate'),
+		iconCls: 'fa fa-fw fa-send-o',
+		hidden: standalone || !caps.vms['VM.Migrate'],
+		handler: function() {
+		    Ext.create('PVE.window.Migrate', {
+			vmtype: 'lxc',
+			nodename: info.node,
+			vmid: info.vmid,
+			autoShow: true,
+		    });
+		},
+	    },
+	    {
+		text: gettext('Convert to template'),
+		iconCls: 'fa fa-fw fa-file-o',
+		handler: function() {
+		    let msg = Proxmox.Utils.format_task_description('vztemplate', info.vmid);
+		    Ext.Msg.confirm(gettext('Confirm'), msg, function(btn) {
+			if (btn === 'yes') {
+			    Proxmox.Utils.API2Request({
+				url: `/nodes/${info.node}/lxc/${info.vmid}/template`,
+				method: 'POST',
+				failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus),
+			    });
+			}
+		    });
+		},
+	    },
+	    { xtype: 'menuseparator' },
+	    {
+		text: gettext('Console'),
+		iconCls: 'fa fa-fw fa-terminal',
+		handler: () =>
+		    PVE.Utils.openDefaultConsoleWindow(true, 'lxc', info.vmid, info.node, info.vmname),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.lxc.Config', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.pveLXCConfig',
+
+    onlineHelp: 'chapter_pct',
+
+    userCls: 'proxmox-tags-full',
+
+    initComponent: function() {
+        var me = this;
+	var vm = me.pveSelNode.data;
+
+	var nodename = vm.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = vm.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var template = !!vm.template;
+
+	var running = !!vm.uptime;
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var base_url = '/nodes/' + nodename + '/lxc/' + vmid;
+
+	me.statusStore = Ext.create('Proxmox.data.ObjectStore', {
+	    url: '/api2/json' + base_url + '/status/current',
+	    interval: 1000,
+	});
+
+	var vm_command = function(cmd, params) {
+	    Proxmox.Utils.API2Request({
+		params: params,
+		url: base_url + "/status/" + cmd,
+		waitMsgTarget: me,
+		method: 'POST',
+		failure: function(response, opts) {
+		    Ext.Msg.alert('Error', response.htmlStatus);
+		},
+	    });
+	};
+
+	var startBtn = Ext.create('Ext.Button', {
+	    text: gettext('Start'),
+	    disabled: !caps.vms['VM.PowerMgmt'] || running,
+	    hidden: template,
+	    handler: function() {
+		vm_command('start');
+	    },
+	    iconCls: 'fa fa-play',
+	});
+
+	var shutdownBtn = Ext.create('PVE.button.Split', {
+	    text: gettext('Shutdown'),
+	    disabled: !caps.vms['VM.PowerMgmt'] || !running,
+	    hidden: template,
+	    confirmMsg: Proxmox.Utils.format_task_description('vzshutdown', vmid),
+	    handler: function() {
+		vm_command('shutdown');
+	    },
+	    menu: {
+		items: [{
+		    text: gettext('Reboot'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    confirmMsg: Proxmox.Utils.format_task_description('vzreboot', vmid),
+		    tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'),
+		    handler: function() {
+			vm_command("reboot");
+		    },
+		    iconCls: 'fa fa-refresh',
+		},
+		{
+		    text: gettext('Stop'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'),
+		    handler: function() {
+			Ext.create('PVE.GuestStop', {
+			    nodename: nodename,
+			    vm: vm,
+			    autoShow: true,
+			});
+		    },
+		    iconCls: 'fa fa-stop',
+		}],
+	    },
+	    iconCls: 'fa fa-power-off',
+	});
+
+	var migrateBtn = Ext.create('Ext.Button', {
+	    text: gettext('Migrate'),
+	    disabled: !caps.vms['VM.Migrate'],
+	    hidden: PVE.Utils.isStandaloneNode(),
+	    handler: function() {
+		var win = Ext.create('PVE.window.Migrate', {
+		    vmtype: 'lxc',
+		    nodename: nodename,
+		    vmid: vmid,
+		});
+		win.show();
+	    },
+	    iconCls: 'fa fa-send-o',
+	});
+
+	var moreBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('More'),
+	    menu: {
+ items: [
+		{
+		    text: gettext('Clone'),
+		    iconCls: 'fa fa-fw fa-clone',
+		    hidden: !caps.vms['VM.Clone'],
+		    handler: function() {
+			PVE.window.Clone.wrap(nodename, vmid, template, 'lxc');
+		    },
+		},
+		{
+		    text: gettext('Convert to template'),
+		    disabled: template,
+		    xtype: 'pveMenuItem',
+		    iconCls: 'fa fa-fw fa-file-o',
+		    hidden: !caps.vms['VM.Allocate'],
+		    confirmMsg: Proxmox.Utils.format_task_description('vztemplate', vmid),
+		    handler: function() {
+			Proxmox.Utils.API2Request({
+			    url: base_url + '/template',
+			    waitMsgTarget: me,
+			    method: 'POST',
+			    failure: function(response, opts) {
+				Ext.Msg.alert('Error', response.htmlStatus);
+			    },
+			});
+		    },
+		},
+		{
+		    iconCls: 'fa fa-heartbeat ',
+		    hidden: !caps.nodes['Sys.Console'],
+		    text: gettext('Manage HA'),
+		    handler: function() {
+			var ha = vm.hastate;
+			Ext.create('PVE.ha.VMResourceEdit', {
+			    vmid: vmid,
+			    guestType: 'ct',
+			    isCreate: !ha || ha === 'unmanaged',
+			}).show();
+		    },
+		},
+		{
+		    text: gettext('Remove'),
+		    disabled: !caps.vms['VM.Allocate'],
+		    itemId: 'removeBtn',
+		    handler: function() {
+			Ext.create('PVE.window.SafeDestroyGuest', {
+			    url: base_url,
+			    item: { type: 'CT', id: vmid },
+			    taskName: 'vzdestroy',
+			}).show();
+		    },
+		    iconCls: 'fa fa-trash-o',
+		},
+	    ],
+},
+	});
+
+	var consoleBtn = Ext.create('PVE.button.ConsoleButton', {
+	    disabled: !caps.vms['VM.Console'],
+	    consoleType: 'lxc',
+	    consoleName: vm.name,
+	    hidden: template,
+	    nodename: nodename,
+	    vmid: vmid,
+	});
+
+	var statusTxt = Ext.create('Ext.toolbar.TextItem', {
+	    data: {
+		lock: undefined,
+	    },
+	    tpl: [
+		'<tpl if="lock">',
+		'<i class="fa fa-lg fa-lock"></i> ({lock})',
+		'</tpl>',
+	    ],
+	});
+
+	let tagsContainer = Ext.create('PVE.panel.TagEditContainer', {
+	    tags: vm.tags,
+	    canEdit: !!caps.vms['VM.Config.Options'],
+	    listeners: {
+		change: function(tags) {
+		    Proxmox.Utils.API2Request({
+			url: base_url + '/config',
+			method: 'PUT',
+			params: {
+			    tags,
+			},
+			success: function() {
+			    me.statusStore.load();
+			},
+			failure: function(response) {
+			    Ext.Msg.alert('Error', response.htmlStatus);
+			    me.statusStore.load();
+			},
+		    });
+		},
+	    },
+	});
+
+	let vm_text = `${vm.vmid} (${vm.name})`;
+
+	Ext.apply(me, {
+	    title: Ext.String.format(gettext("Container {0} on node '{1}'"), vm_text, nodename),
+	    hstateid: 'lxctab',
+	    tbarSpacing: false,
+	    tbar: [statusTxt, tagsContainer, '->', startBtn, shutdownBtn, migrateBtn, consoleBtn, moreBtn],
+	    defaults: { statusStore: me.statusStore },
+	    items: [
+		{
+		    title: gettext('Summary'),
+		    xtype: 'pveGuestSummary',
+		    iconCls: 'fa fa-book',
+		    itemId: 'summary',
+		},
+	    ],
+	});
+
+	if (caps.vms['VM.Console'] && !template) {
+	    me.items.push(
+		{
+		    title: gettext('Console'),
+		    itemId: 'consolejs',
+		    iconCls: 'fa fa-terminal',
+		    xtype: 'pveNoVncConsole',
+		    vmid: vmid,
+		    consoleType: 'lxc',
+		    xtermjs: true,
+		    nodename: nodename,
+		},
+	    );
+	}
+
+	me.items.push(
+	    {
+		title: gettext('Resources'),
+		itemId: 'resources',
+		expandedOnInit: true,
+		iconCls: 'fa fa-cube',
+		xtype: 'pveLxcRessourceView',
+	    },
+	    {
+		title: gettext('Network'),
+		iconCls: 'fa fa-exchange',
+		itemId: 'network',
+		xtype: 'pveLxcNetworkView',
+	    },
+	    {
+		title: gettext('DNS'),
+		iconCls: 'fa fa-globe',
+		itemId: 'dns',
+		xtype: 'pveLxcDNS',
+	    },
+	    {
+		title: gettext('Options'),
+		itemId: 'options',
+		iconCls: 'fa fa-gear',
+		xtype: 'pveLxcOptions',
+	    },
+	    {
+		title: gettext('Task History'),
+		itemId: 'tasks',
+		iconCls: 'fa fa-list-alt',
+		xtype: 'proxmoxNodeTasks',
+		nodename: nodename,
+		preFilter: {
+		    vmid,
+		},
+	    },
+	);
+
+	if (caps.vms['VM.Backup']) {
+	    me.items.push({
+		title: gettext('Backup'),
+		iconCls: 'fa fa-floppy-o',
+		xtype: 'pveBackupView',
+		itemId: 'backup',
+	    },
+	    {
+		title: gettext('Replication'),
+		iconCls: 'fa fa-retweet',
+		xtype: 'pveReplicaView',
+		itemId: 'replication',
+	    });
+	}
+
+	if ((caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] ||
+	    caps.vms['VM.Audit']) && !template) {
+	    me.items.push({
+		title: gettext('Snapshots'),
+		iconCls: 'fa fa-history',
+		xtype: 'pveGuestSnapshotTree',
+		type: 'lxc',
+		itemId: 'snapshot',
+	    });
+	}
+
+	if (caps.vms['VM.Audit']) {
+	    me.items.push(
+		{
+		    xtype: 'pveFirewallRules',
+		    title: gettext('Firewall'),
+		    iconCls: 'fa fa-shield',
+		    allow_iface: true,
+		    base_url: base_url + '/firewall/rules',
+		    list_refs_url: base_url + '/firewall/refs',
+		    itemId: 'firewall',
+		},
+		{
+		    xtype: 'pveFirewallOptions',
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-gear',
+		    onlineHelp: 'pve_firewall_vm_container_configuration',
+		    title: gettext('Options'),
+		    base_url: base_url + '/firewall/options',
+		    fwtype: 'vm',
+		    itemId: 'firewall-options',
+		},
+		{
+		    xtype: 'pveFirewallAliases',
+		    title: gettext('Alias'),
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-external-link',
+		    base_url: base_url + '/firewall/aliases',
+		    itemId: 'firewall-aliases',
+		},
+		{
+		    xtype: 'pveIPSet',
+		    title: gettext('IPSet'),
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-list-ol',
+		    base_url: base_url + '/firewall/ipset',
+		    list_refs_url: base_url + '/firewall/refs',
+		    itemId: 'firewall-ipset',
+		},
+	    );
+	}
+
+	if (caps.vms['VM.Console']) {
+	    me.items.push(
+		{
+		    title: gettext('Log'),
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-list',
+		    onlineHelp: 'chapter_pve_firewall',
+		    itemId: 'firewall-fwlog',
+		    xtype: 'proxmoxLogView',
+		    url: '/api2/extjs' + base_url + '/firewall/log',
+		    log_select_timespan: true,
+		    submitFormat: 'U',
+		},
+	    );
+	}
+
+	if (caps.vms['Permissions.Modify']) {
+	    me.items.push({
+		xtype: 'pveACLView',
+		title: gettext('Permissions'),
+		itemId: 'permissions',
+		iconCls: 'fa fa-unlock',
+		path: '/vms/' + vmid,
+	    });
+	}
+
+	me.callParent();
+
+	var prevStatus = 'unknown';
+	me.mon(me.statusStore, 'load', function(s, records, success) {
+	    var status;
+	    var lock;
+	    var rec;
+
+	    if (!success) {
+		status = 'unknown';
+	    } else {
+		rec = s.data.get('status');
+		status = rec ? rec.data.value : 'unknown';
+		rec = s.data.get('template');
+		template = rec ? rec.data.value : false;
+		rec = s.data.get('lock');
+		lock = rec ? rec.data.value : undefined;
+	    }
+
+	    statusTxt.update({ lock: lock });
+
+	    rec = s.data.get('tags');
+	    tagsContainer.loadTags(rec?.data?.value);
+
+	    startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status === 'running' || template);
+	    shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running');
+	    me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped');
+	    consoleBtn.setDisabled(template);
+
+	    if (prevStatus === 'stopped' && status === 'running') {
+		let con = me.down('#consolejs');
+		if (con) {
+		    con.reload();
+		}
+	    }
+
+	    prevStatus = status;
+	});
+
+	me.on('afterrender', function() {
+	    me.statusStore.startUpdate();
+	});
+
+	me.on('destroy', function() {
+	    me.statusStore.stopUpdate();
+	});
+    },
+});
+Ext.define('PVE.lxc.CreateWizard', {
+    extend: 'PVE.window.Wizard',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    storage: '',
+	    unprivileged: true,
+	},
+	formulas: {
+	    cgroupMode: function(get) {
+		const nodeInfo = PVE.data.ResourceStore.getNodes().find(
+		    node => node.node === get('nodename'),
+		);
+		return nodeInfo ? nodeInfo['cgroup-mode'] : 2;
+	    },
+	},
+    },
+
+    cbindData: {
+	nodename: undefined,
+    },
+
+    subject: gettext('LXC Container'),
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    title: gettext('General'),
+	    onlineHelp: 'pct_general',
+	    column1: [
+		{
+		    xtype: 'pveNodeSelector',
+		    name: 'nodename',
+		    cbind: {
+			selectCurNode: '{!nodename}',
+			preferredValue: '{nodename}',
+		    },
+		    bind: {
+			value: '{nodename}',
+		    },
+		    fieldLabel: gettext('Node'),
+		    allowBlank: false,
+		    onlineValidator: true,
+		},
+		{
+		    xtype: 'pveGuestIDSelector',
+		    name: 'vmid', // backend only knows vmid
+		    guestType: 'lxc',
+		    value: '',
+		    loadNextFreeID: true,
+		    validateExists: false,
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'hostname',
+		    vtype: 'DnsName',
+		    value: '',
+		    fieldLabel: gettext('Hostname'),
+		    skipEmptyText: true,
+		    allowBlank: true,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'unprivileged',
+		    value: true,
+		    bind: {
+			value: '{unprivileged}',
+		    },
+		    fieldLabel: gettext('Unprivileged container'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'features',
+		    inputValue: 'nesting=1',
+		    value: true,
+		    bind: {
+			disabled: '{!unprivileged}',
+		    },
+		    fieldLabel: gettext('Nesting'),
+		},
+	    ],
+	    column2: [
+		{
+		    xtype: 'pvePoolSelector',
+		    fieldLabel: gettext('Resource Pool'),
+		    name: 'pool',
+		    value: '',
+		    allowBlank: true,
+		},
+		{
+		    xtype: 'textfield',
+		    inputType: 'password',
+		    name: 'password',
+		    value: '',
+		    fieldLabel: gettext('Password'),
+		    allowBlank: false,
+		    minLength: 5,
+		    change: function(f, value) {
+			if (f.rendered) {
+			    f.up().down('field[name=confirmpw]').validate();
+			}
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    inputType: 'password',
+		    name: 'confirmpw',
+		    value: '',
+		    fieldLabel: gettext('Confirm password'),
+		    allowBlank: true,
+		    submitValue: false,
+		    validator: function(value) {
+			var pw = this.up().down('field[name=password]').getValue();
+			if (pw !== value) {
+			    return "Passwords do not match!";
+			}
+			return true;
+		    },
+		},
+		{
+		    xtype: 'textarea',
+		    name: 'ssh-public-keys',
+		    value: '',
+		    fieldLabel: gettext('SSH public key(s)'),
+		    allowBlank: true,
+		    validator: function(value) {
+			let pwfield = this.up().down('field[name=password]');
+			if (value.length) {
+			    let keys = value.indexOf('\n') !== -1 ? value.split('\n') : [value];
+			    if (keys.some(key => key !== '' && !PVE.Parser.parseSSHKey(key))) {
+				return "Failed to recognize ssh key";
+			    }
+			    pwfield.allowBlank = true;
+			} else {
+			    pwfield.allowBlank = false;
+			}
+			pwfield.validate();
+			return true;
+		    },
+		    afterRender: function() {
+			if (!window.FileReader) {
+			    return; // No FileReader support in this browser
+			}
+			let cancelEvent = ev => {
+			    ev = ev.event;
+			    if (ev.preventDefault) {
+				ev.preventDefault();
+			    }
+			};
+			this.inputEl.on('dragover', cancelEvent);
+			this.inputEl.on('dragenter', cancelEvent);
+			this.inputEl.on('drop', ev => {
+			    cancelEvent(ev);
+			    let files = ev.event.dataTransfer.files;
+			    PVE.Utils.loadSSHKeyFromFile(files[0], v => this.setValue(v));
+			});
+		    },
+		},
+		{
+		    xtype: 'pveMultiFileButton',
+		    name: 'file',
+		    hidden: !window.FileReader,
+		    text: gettext('Load SSH Key File'),
+		    listeners: {
+			change: function(btn, e, value) {
+			    e = e.event;
+			    let field = this.up().down('textarea[name=ssh-public-keys]');
+			    for (const file of e?.target?.files ?? []) {
+				PVE.Utils.loadSSHKeyFromFile(file, v => {
+				    let oldValue = field.getValue();
+				    field.setValue(oldValue ? `${oldValue}\n${v.trim()}` : v.trim());
+				});
+			    }
+			    btn.reset();
+			},
+		    },
+		},
+	    ],
+	    advancedColumnB: [
+		{
+		    xtype: 'pveTagFieldSet',
+		    name: 'tags',
+		    maxHeight: 150,
+		},
+	    ],
+	},
+	{
+	    xtype: 'inputpanel',
+	    title: gettext('Template'),
+	    onlineHelp: 'pct_container_images',
+	    column1: [
+		{
+		    xtype: 'pveStorageSelector',
+		    name: 'tmplstorage',
+		    fieldLabel: gettext('Storage'),
+		    storageContent: 'vztmpl',
+		    autoSelect: true,
+		    allowBlank: false,
+		    bind: {
+			value: '{storage}',
+			nodename: '{nodename}',
+		    },
+		},
+		{
+		    xtype: 'pveFileSelector',
+		    name: 'ostemplate',
+		    storageContent: 'vztmpl',
+		    fieldLabel: gettext('Template'),
+		    bind: {
+			storage: '{storage}',
+			nodename: '{nodename}',
+		    },
+		    allowBlank: false,
+		},
+	    ],
+	},
+	{
+	    xtype: 'pveMultiMPPanel',
+	    title: gettext('Disks'),
+	    insideWizard: true,
+	    isCreate: true,
+	    unused: false,
+	    confid: 'rootfs',
+	},
+	{
+	    xtype: 'pveLxcCPUInputPanel',
+	    title: gettext('CPU'),
+	    insideWizard: true,
+	},
+	{
+	    xtype: 'pveLxcMemoryInputPanel',
+	    title: gettext('Memory'),
+	    insideWizard: true,
+	},
+	{
+	    xtype: 'pveLxcNetworkInputPanel',
+	    title: gettext('Network'),
+	    insideWizard: true,
+	    bind: {
+		nodename: '{nodename}',
+	    },
+	    isCreate: true,
+	},
+	{
+	    xtype: 'pveLxcDNSInputPanel',
+	    title: gettext('DNS'),
+	    insideWizard: true,
+	},
+	{
+	    title: gettext('Confirm'),
+	    layout: 'fit',
+	    items: [
+		{
+		    xtype: 'grid',
+		    store: {
+			model: 'KeyValue',
+			sorters: [{
+				property: 'key',
+				direction: 'ASC',
+			}],
+		    },
+		    columns: [
+			{ header: 'Key', width: 150, dataIndex: 'key' },
+			{ header: 'Value', flex: 1, dataIndex: 'value' },
+		    ],
+		},
+	    ],
+	    dockedItems: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'start',
+		    dock: 'bottom',
+		    margin: '5 0 0 0',
+		    boxLabel: gettext('Start after created'),
+		},
+	    ],
+	    listeners: {
+		show: function(panel) {
+		    let wizard = this.up('window');
+		    let kv = wizard.getValues();
+		    let data = [];
+		    Ext.Object.each(kv, function(key, value) {
+			if (key === 'delete' || key === 'tmplstorage') { // ignore
+			    return;
+			}
+			if (key === 'password') { // don't show pw
+			    return;
+			}
+			data.push({ key: key, value: value });
+		    });
+
+		    let summaryStore = panel.down('grid').getStore();
+		    summaryStore.suspendEvents();
+		    summaryStore.removeAll();
+		    summaryStore.add(data);
+		    summaryStore.sort();
+		    summaryStore.resumeEvents();
+		    summaryStore.fireEvent('refresh');
+		},
+	    },
+	    onSubmit: function() {
+		let wizard = this.up('window');
+		let kv = wizard.getValues();
+		delete kv.delete;
+
+		let nodename = kv.nodename;
+		delete kv.nodename;
+		delete kv.tmplstorage;
+
+		if (!kv.pool.length) {
+		    delete kv.pool;
+		}
+		if (!kv.password.length && kv['ssh-public-keys']) {
+		    delete kv.password;
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${nodename}/lxc`,
+		    waitMsgTarget: wizard,
+		    method: 'POST',
+		    params: kv,
+		    success: function(response, opts) {
+			Ext.create('Proxmox.window.TaskViewer', {
+			    autoShow: true,
+			    upid: response.result.data,
+			});
+			wizard.close();
+		    },
+		    failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		});
+	    },
+	},
+    ],
+});
+Ext.define('PVE.lxc.DeviceInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    autoComplete: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+    },
+
+    setVMConfig: function(vmconfig) {
+	let me = this;
+	me.vmconfig = vmconfig;
+
+	if (me.isCreate) {
+	    PVE.Utils.forEachLxcDev((i, name) => {
+		if (!Ext.isDefined(vmconfig[name])) {
+		    me.confid = name;
+		    me.down('field[name=devid]').setValue(i);
+		    return false;
+		}
+		return undefined;
+	    });
+	}
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+	let confid = me.isCreate ? "dev" + values.devid : me.confid;
+	delete values.devid;
+	let val = PVE.Parser.printPropertyString(values, 'path');
+	let ret = {};
+	ret[confid] = val;
+	return ret;
+    },
+
+    items: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'devid',
+	    minValue: 0,
+	    maxValue: PVE.Utils.lxc_dev_count - 1,
+	    hidden: true,
+	    allowBlank: false,
+	    disabled: true,
+	    cbind: {
+		disabled: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'path',
+	    fieldLabel: gettext('Device Path'),
+	    labelWidth: 120,
+	    editable: true,
+	    allowBlank: false,
+	    emptyText: '/dev/xyz',
+	    validator: v => v.startsWith('/dev/') ? true : gettext("Path has to start with /dev/"),
+	},
+    ],
+
+    advancedColumn1: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'uid',
+	    editable: true,
+	    fieldLabel: Ext.String.format(gettext('{0} in CT'), 'UID'),
+	    labelWidth: 120,
+	    emptyText: '0',
+	    minValue: 0,
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'gid',
+	    editable: true,
+	    fieldLabel: Ext.String.format(gettext('{0} in CT'), 'GID'),
+	    labelWidth: 120,
+	    emptyText: '0',
+	    minValue: 0,
+	},
+    ],
+
+    advancedColumn2: [
+	{
+	    xtype: 'textfield',
+	    name: 'mode',
+	    editable: true,
+	    fieldLabel: Ext.String.format(gettext('Access Mode in CT')),
+	    labelWidth: 120,
+	    emptyText: '0660',
+	    validator: function(value) {
+		if (/^0[0-7]{3}$|^$/i.test(value)) {
+		    return true;
+		}
+		return gettext("Access mode has to be an octal number");
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.lxc.DeviceEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    vmconfig: undefined,
+
+    isAdd: true,
+    width: 450,
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = !me.confid;
+
+	let ipanel = Ext.create('PVE.lxc.DeviceInputPanel', {
+	    confid: me.confid,
+	    isCreate: me.isCreate,
+	    pveSelNode: me.pveSelNode,
+	});
+
+	let subject;
+	if (me.isCreate) {
+	    subject = gettext('Device');
+	} else {
+	    subject = gettext('Device') + ' (' + me.confid + ')';
+	}
+
+	Ext.apply(me, {
+	    subject: subject,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		ipanel.setVMConfig(response.result.data);
+		if (me.isCreate) {
+		    return;
+		}
+
+		let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'path');
+
+		let values = {
+		    path: data.path,
+		    mode: data.mode,
+		    uid: data.uid,
+		    gid: data.gid,
+		};
+
+		ipanel.setValues(values);
+	    },
+	});
+    },
+});
+Ext.define('PVE.lxc.DNSInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveLxcDNSInputPanel',
+
+    insideWizard: false,
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var deletes = [];
+	if (!values.searchdomain && !me.insideWizard) {
+	    deletes.push('searchdomain');
+	}
+
+	if (values.nameserver) {
+	    let list = values.nameserver.split(/[ ,;]+/);
+	    values.nameserver = list.join(' ');
+	} else if (!me.insideWizard) {
+	    deletes.push('nameserver');
+	}
+
+	if (deletes.length) {
+	    values.delete = deletes.join(',');
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var items = [
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'searchdomain',
+		skipEmptyText: true,
+		fieldLabel: gettext('DNS domain'),
+		emptyText: gettext('use host settings'),
+		allowBlank: true,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		fieldLabel: gettext('DNS servers'),
+		vtype: 'IP64AddressWithSuffixList',
+		allowBlank: true,
+		emptyText: gettext('use host settings'),
+		name: 'nameserver',
+		itemId: 'nameserver',
+	    },
+	];
+
+	if (me.insideWizard) {
+	    me.column1 = items;
+	} else {
+	    me.items = items;
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.lxc.DNSEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	var ipanel = Ext.create('PVE.lxc.DNSInputPanel');
+
+	Ext.apply(me, {
+	    subject: gettext('Resources'),
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+
+		    if (values.nameserver) {
+			values.nameserver.replace(/[,;]/, ' ');
+			values.nameserver.replace(/^\s+/, '');
+		    }
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+
+Ext.define('PVE.lxc.DNS', {
+    extend: 'Proxmox.grid.PendingObjectGrid',
+    alias: ['widget.pveLxcDNS'],
+
+    onlineHelp: 'pct_container_network',
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var rows = {
+	    hostname: {
+		required: true,
+		defaultValue: me.pveSelNode.data.name,
+		header: gettext('Hostname'),
+		editor: caps.vms['VM.Config.Network'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Hostname'),
+		    items: {
+			xtype: 'inputpanel',
+			items: {
+			    fieldLabel: gettext('Hostname'),
+			    xtype: 'textfield',
+			    name: 'hostname',
+			    vtype: 'DnsName',
+			    allowBlank: true,
+			    emptyText: 'CT' + vmid.toString(),
+			},
+			onGetValues: function(values) {
+			    var params = values;
+			    if (values.hostname === undefined ||
+				values.hostname === null ||
+				values.hostname === '') {
+				params = { hostname: 'CT'+vmid.toString() };
+			    }
+			    return params;
+			},
+		    },
+		} : undefined,
+	    },
+	    searchdomain: {
+		header: gettext('DNS domain'),
+		defaultValue: '',
+		editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined,
+		renderer: function(value) {
+		    return value || gettext('use host settings');
+		},
+	    },
+	    nameserver: {
+		header: gettext('DNS server'),
+		defaultValue: '',
+		editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined,
+		renderer: function(value) {
+		    return value || gettext('use host settings');
+		},
+	    },
+	};
+
+	var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config';
+
+	var reload = function() {
+	    me.rstore.load();
+	};
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    var rowdef = rows[rec.data.key];
+	    if (!rowdef.editor) {
+		return;
+	    }
+
+	    var win;
+	    if (Ext.isString(rowdef.editor)) {
+		win = Ext.create(rowdef.editor, {
+		    pveSelNode: me.pveSelNode,
+		    confid: rec.data.key,
+		    url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config',
+		});
+	    } else {
+		var config = Ext.apply({
+		    pveSelNode: me.pveSelNode,
+		    confid: rec.data.key,
+		    url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config',
+		}, rowdef.editor);
+		win = Ext.createWidget(rowdef.editor.xtype, config);
+		win.load();
+	    }
+	    //win.load();
+	    win.show();
+	    win.on('destroy', reload);
+	};
+
+	var edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: function(rec) {
+		var rowdef = rows[rec.data.key];
+		return !!rowdef.editor;
+	    },
+	    handler: run_editor,
+	});
+
+	var revert_btn = new PVE.button.PendingRevert();
+
+	var set_button_status = function() {
+	    let button_sm = me.getSelectionModel();
+	    let rec = button_sm.getSelection()[0];
+
+	    if (!rec) {
+		edit_btn.disable();
+		return;
+	    }
+	    let key = rec.data.key;
+
+	    let rowdef = rows[key];
+	    edit_btn.setDisabled(!rowdef.editor);
+
+	    let pending = rec.data.delete || me.hasPendingChanges(key);
+	    revert_btn.setDisabled(!pending);
+	};
+
+	Ext.apply(me, {
+	    url: "/api2/json/nodes/" + nodename + "/lxc/" + vmid + "/pending",
+	    selModel: sm,
+	    cwidth1: 150,
+	    interval: 5000,
+	    run_editor: run_editor,
+	    tbar: [edit_btn, revert_btn],
+	    rows: rows,
+	    editorConfig: {
+		url: "/api2/extjs/" + baseurl,
+	    },
+	    listeners: {
+		itemdblclick: run_editor,
+		selectionchange: set_button_status,
+		activate: reload,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+
+	me.mon(me.getStore(), 'datachanged', function() {
+	    set_button_status();
+	});
+    },
+});
+Ext.define('PVE.lxc.FeaturesInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveLxcFeaturesInputPanel',
+
+    // used to save the mounts fstypes until sending
+    mounts: [],
+
+    fstypes: ['nfs', 'cifs'],
+
+    viewModel: {
+	parent: null,
+	data: {
+	    unprivileged: false,
+	},
+	formulas: {
+	    privilegedOnly: function(get) {
+		return get('unprivileged') ? gettext('privileged only') : '';
+	    },
+	    unprivilegedOnly: function(get) {
+		return !get('unprivileged') ? gettext('unprivileged only') : '';
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('keyctl'),
+	    name: 'keyctl',
+	    bind: {
+		disabled: '{!unprivileged}',
+		boxLabel: '{unprivilegedOnly}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Nesting'),
+	    name: 'nesting',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'nfs',
+	    fieldLabel: 'NFS',
+	    bind: {
+		disabled: '{unprivileged}',
+		boxLabel: '{privilegedOnly}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'cifs',
+	    fieldLabel: 'SMB/CIFS',
+	    bind: {
+		disabled: '{unprivileged}',
+		boxLabel: '{privilegedOnly}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'fuse',
+	    fieldLabel: 'FUSE',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'mknod',
+	    fieldLabel: gettext('Create Device Nodes'),
+	    boxLabel: gettext('Experimental'),
+	},
+    ],
+
+    onGetValues: function(values) {
+	var me = this;
+	var mounts = me.mounts;
+	me.fstypes.forEach(function(fs) {
+	    if (values[fs]) {
+		mounts.push(fs);
+	    }
+	    delete values[fs];
+	});
+
+	if (mounts.length) {
+	    values.mount = mounts.join(';');
+	}
+
+	var featuresstring = PVE.Parser.printPropertyString(values, undefined);
+	if (featuresstring === '') {
+	    return { 'delete': 'features' };
+	}
+	return { features: featuresstring };
+    },
+
+    setValues: function(values) {
+	var me = this;
+
+	me.viewModel.set('unprivileged', values.unprivileged);
+
+	if (values.features) {
+	    var res = PVE.Parser.parsePropertyString(values.features);
+	    me.mounts = [];
+	    if (res.mount) {
+		res.mount.split(/[; ]/).forEach(function(item) {
+		    if (me.fstypes.indexOf(item) === -1) {
+			me.mounts.push(item);
+		    } else {
+			res[item] = 1;
+		    }
+		});
+	    }
+	    this.callParent([res]);
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.mounts = []; // reset state
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.lxc.FeaturesEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveLxcFeaturesEdit',
+
+    subject: gettext('Features'),
+    autoLoad: true,
+    width: 350,
+
+    items: [{
+	xtype: 'pveLxcFeaturesInputPanel',
+    }],
+});
+Ext.define('PVE.lxc.MountPointInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveLxcMountPointInputPanel',
+
+    onlineHelp: 'pct_container_storage',
+
+    insideWizard: false,
+
+    unused: false, // add unused disk imaged
+    unprivileged: false,
+
+    vmconfig: {}, // used to select unused disks
+
+    setUnprivileged: function(unprivileged) {
+	var me = this;
+	var vm = me.getViewModel();
+	me.unprivileged = unprivileged;
+	vm.set('unpriv', unprivileged);
+    },
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var confid = me.confid || "mp"+values.mpid;
+	me.mp.file = me.down('field[name=file]').getValue();
+
+	if (me.unused) {
+	    confid = "mp"+values.mpid;
+	} else if (me.isCreate) {
+	    me.mp.file = values.hdstorage + ':' + values.disksize;
+	}
+
+	// delete unnecessary fields
+	delete values.mpid;
+	delete values.hdstorage;
+	delete values.disksize;
+	delete values.diskformat;
+
+	let setMPOpt = (k, src, v) => PVE.Utils.propertyStringSet(me.mp, src, k, v);
+
+	setMPOpt('mp', values.mp);
+	let mountOpts = (values.mountoptions || []).join(';');
+	setMPOpt('mountoptions', values.mountoptions, mountOpts);
+	setMPOpt('mp', values.mp);
+	setMPOpt('backup', values.backup);
+	setMPOpt('quota', values.quota);
+	setMPOpt('ro', values.ro);
+	setMPOpt('acl', values.acl);
+	setMPOpt('replicate', values.replicate);
+
+	let res = {};
+	res[confid] = PVE.Parser.printLxcMountPoint(me.mp);
+	return res;
+    },
+
+    setMountPoint: function(mp) {
+	let me = this;
+	let vm = me.getViewModel();
+	vm.set('mptype', mp.type);
+	if (mp.mountoptions) {
+	    mp.mountoptions = mp.mountoptions.split(';');
+	}
+	me.mp = mp;
+	me.filterMountOptions();
+	me.setValues(mp);
+    },
+
+    filterMountOptions: function() {
+	let me = this;
+	if (me.confid === 'rootfs') {
+	    let field = me.down('field[name=mountoptions]');
+	    let exclude = ['nodev', 'noexec'];
+	    let filtered = field.comboItems.filter(v => !exclude.includes(v[0]));
+	    field.setComboItems(filtered);
+	}
+    },
+
+    updateVMConfig: function(vmconfig) {
+	let me = this;
+	let vm = me.getViewModel();
+	me.vmconfig = vmconfig;
+	vm.set('unpriv', vmconfig.unprivileged);
+	me.down('field[name=mpid]').validate();
+    },
+
+    setVMConfig: function(vmconfig) {
+	let me = this;
+
+	me.updateVMConfig(vmconfig);
+	PVE.Utils.forEachLxcMP((bus, i, name) => {
+	    if (!Ext.isDefined(vmconfig[name])) {
+		me.down('field[name=mpid]').setValue(i);
+		return false;
+	    }
+	    return undefined;
+	});
+    },
+
+    setNodename: function(nodename) {
+	let me = this;
+	let vm = me.getViewModel();
+	vm.set('node', nodename);
+	me.down('#diskstorage').setNodename(nodename);
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'field[name=mpid]': {
+		change: function(field, value) {
+		    let me = this;
+		    let view = this.getView();
+		    if (view.confid !== 'rootfs') {
+			view.fireEvent('diskidchange', view, `mp${value}`);
+		    }
+		    field.validate();
+		},
+	    },
+	    '#hdstorage': {
+		change: function(field, newValue) {
+		    let me = this;
+		    if (!newValue) {
+			return;
+		    }
+
+		    let rec = field.store.getById(newValue);
+		    if (!rec) {
+			return;
+		    }
+		    me.getViewModel().set('type', rec.data.type);
+		},
+	    },
+	},
+	init: function(view) {
+	    let me = this;
+	    let vm = this.getViewModel();
+	    view.mp = {};
+	    vm.set('confid', view.confid);
+	    vm.set('unused', view.unused);
+	    vm.set('node', view.nodename);
+	    vm.set('unpriv', view.unprivileged);
+	    vm.set('hideStorSelector', view.unused || !view.isCreate);
+
+	    if (view.isCreate) { // can be array if created from unused disk
+		vm.set('isIncludedInBackup', true);
+		if (view.insideWizard) {
+		    view.filterMountOptions();
+		}
+	    }
+	    if (view.selectFree) {
+		view.setVMConfig(view.vmconfig);
+	    }
+	},
+    },
+
+    viewModel: {
+	data: {
+	    unpriv: false,
+	    unused: false,
+	    showStorageSelector: false,
+	    mptype: '',
+	    type: '',
+	    confid: '',
+	    node: '',
+	},
+
+	formulas: {
+	    quota: function(get) {
+		return !(get('type') === 'zfs' ||
+			 get('type') === 'zfspool' ||
+			 get('unpriv') ||
+			 get('isBind'));
+	    },
+	    hasMP: function(get) {
+		return !!get('confid') && !get('unused');
+	    },
+	    isRoot: function(get) {
+		return get('confid') === 'rootfs';
+	    },
+	    isBind: function(get) {
+		return get('mptype') === 'bind';
+	    },
+	    isBindOrRoot: function(get) {
+		return get('isBind') || get('isRoot');
+	    },
+	},
+    },
+
+    column1: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'mpid',
+	    fieldLabel: gettext('Mount Point ID'),
+	    minValue: 0,
+	    maxValue: PVE.Utils.lxc_mp_counts.mp - 1,
+	    hidden: true,
+	    allowBlank: false,
+	    disabled: true,
+	    bind: {
+		hidden: '{hasMP}',
+		disabled: '{hasMP}',
+	    },
+	    validator: function(value) {
+		let view = this.up('inputpanel');
+		if (!view.rendered) {
+		    return undefined;
+		}
+		if (Ext.isDefined(view.vmconfig["mp"+value])) {
+		    return "Mount point is already in use.";
+		}
+		return true;
+	    },
+	},
+	{
+	    xtype: 'pveDiskStorageSelector',
+	    itemId: 'diskstorage',
+	    storageContent: 'rootdir',
+	    hidden: true,
+	    autoSelect: true,
+	    selectformat: false,
+	    defaultSize: 8,
+	    bind: {
+		hidden: '{hideStorSelector}',
+		disabled: '{hideStorSelector}',
+		nodename: '{node}',
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    disabled: true,
+	    submitValue: false,
+	    fieldLabel: gettext('Disk image'),
+	    name: 'file',
+	    bind: {
+		hidden: '{!hideStorSelector}',
+	    },
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'textfield',
+	    name: 'mp',
+	    value: '',
+	    emptyText: gettext('/some/path'),
+	    allowBlank: false,
+	    disabled: true,
+	    fieldLabel: gettext('Path'),
+	    bind: {
+		hidden: '{isRoot}',
+		disabled: '{isRoot}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'backup',
+	    fieldLabel: gettext('Backup'),
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Include volume in backup job'),
+	    },
+	    bind: {
+		hidden: '{isRoot}',
+		disabled: '{isBindOrRoot}',
+		value: '{isIncludedInBackup}',
+	    },
+	},
+    ],
+
+    advancedColumn1: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'quota',
+	    defaultValue: 0,
+	    bind: {
+		disabled: '{!quota}',
+	    },
+	    fieldLabel: gettext('Enable quota'),
+	    listeners: {
+		disable: function() {
+		    this.reset();
+		},
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'ro',
+	    defaultValue: 0,
+	    bind: {
+		hidden: '{isRoot}',
+		disabled: '{isRoot}',
+	    },
+	    fieldLabel: gettext('Read-only'),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'mountoptions',
+	    fieldLabel: gettext('Mount options'),
+	    deleteEmpty: false,
+	    comboItems: [
+		['lazytime', 'lazytime'],
+		['noatime', 'noatime'],
+		['nodev', 'nodev'],
+		['noexec', 'noexec'],
+		['nosuid', 'nosuid'],
+	    ],
+	    multiSelect: true,
+	    value: [],
+	    allowBlank: true,
+	},
+    ],
+
+    advancedColumn2: [
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'acl',
+	    fieldLabel: 'ACLs',
+	    deleteEmpty: false,
+	    comboItems: [
+		['__default__', Proxmox.Utils.defaultText],
+		['1', Proxmox.Utils.enabledText],
+		['0', Proxmox.Utils.disabledText],
+	    ],
+	    value: '__default__',
+	    bind: {
+		disabled: '{isBind}',
+	    },
+	    allowBlank: true,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    inputValue: '0', // reverses the logic
+	    name: 'replicate',
+	    fieldLabel: gettext('Skip replication'),
+	},
+    ],
+});
+
+Ext.define('PVE.lxc.MountPointEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    unprivileged: false,
+
+    initComponent: function() {
+	let me = this;
+
+	let nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	let unused = me.confid && me.confid.match(/^unused\d+$/);
+
+	me.isCreate = me.confid ? unused : true;
+
+	let ipanel = Ext.create('PVE.lxc.MountPointInputPanel', {
+	    confid: me.confid,
+	    nodename: nodename,
+	    unused: unused,
+	    unprivileged: me.unprivileged,
+	    isCreate: me.isCreate,
+	});
+
+	let subject;
+	if (unused) {
+	    subject = gettext('Unused Disk');
+	} else if (me.isCreate) {
+	    subject = gettext('Mount Point');
+	} else {
+	    subject = gettext('Mount Point') + ' (' + me.confid + ')';
+	}
+
+	Ext.apply(me, {
+	    subject: subject,
+	    defaultFocus: me.confid !== 'rootfs' ? 'textfield[name=mp]' : 'tool',
+	    items: ipanel,
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		ipanel.setVMConfig(response.result.data);
+
+		if (me.confid) {
+		    let value = response.result.data[me.confid];
+		    let mp = PVE.Parser.parseLxcMountPoint(value);
+		    if (!mp) {
+			Ext.Msg.alert(gettext('Error'), 'Unable to parse mount point options');
+			me.close();
+			return;
+		    }
+		    ipanel.setMountPoint(mp);
+		    me.isValid(); // trigger validation
+		}
+	    },
+	});
+    },
+});
+Ext.define('PVE.window.MPResize', {
+    extend: 'Ext.window.Window',
+
+    resizable: false,
+
+    resize_disk: function(disk, size) {
+	var me = this;
+        var params = { disk: disk, size: '+' + size + 'G' };
+
+	Proxmox.Utils.API2Request({
+	    params: params,
+	    url: '/nodes/' + me.nodename + '/lxc/' + me.vmid + '/resize',
+	    waitMsgTarget: me,
+	    method: 'PUT',
+	    failure: function(response, opts) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		var upid = response.result.data;
+		var win = Ext.create('Proxmox.window.TaskViewer', { upid: upid });
+		win.show();
+		me.close();
+	    },
+	});
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var items = [
+	    {
+		xtype: 'displayfield',
+		name: 'disk',
+		value: me.disk,
+		fieldLabel: gettext('Disk'),
+		vtype: 'StorageId',
+		allowBlank: false,
+	    },
+	];
+
+	me.hdsizesel = Ext.createWidget('numberfield', {
+	    name: 'size',
+	    minValue: 0,
+	    maxValue: 128*1024,
+	    decimalPrecision: 3,
+	    value: '0',
+	    fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`,
+	    allowBlank: false,
+	});
+
+	items.push(me.hdsizesel);
+
+	me.formPanel = Ext.create('Ext.form.Panel', {
+	    bodyPadding: 10,
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 120,
+		anchor: '100%',
+	    },
+	    items: items,
+	});
+
+	var form = me.formPanel.getForm();
+
+	var submitBtn;
+
+	me.title = gettext('Resize disk');
+	submitBtn = Ext.create('Ext.Button', {
+	    text: gettext('Resize disk'),
+	    handler: function() {
+		if (form.isValid()) {
+		    var values = form.getValues();
+		    me.resize_disk(me.disk, values.size);
+		}
+	    },
+	});
+
+	Ext.apply(me, {
+	    modal: true,
+	    border: false,
+	    layout: 'fit',
+	    buttons: [submitBtn],
+	    items: [me.formPanel],
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.lxc.NetworkInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveLxcNetworkInputPanel',
+
+    insideWizard: false,
+
+    onlineHelp: 'pct_container_network',
+
+    setNodename: function(nodename) {
+	let me = this;
+
+	if (!nodename || me.nodename === nodename) {
+	    return;
+	}
+	me.nodename = nodename;
+
+	let bridgeSelector = me.query("[isFormField][name=bridge]")[0];
+	bridgeSelector.setNodename(nodename);
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+
+	let id;
+	if (me.isCreate) {
+	    id = values.id;
+	    delete values.id;
+	} else {
+	    id = me.ifname;
+	}
+	let newdata = {};
+	if (id) {
+	    if (values.ipv6mode !== 'static') {
+		values.ip6 = values.ipv6mode;
+	    }
+	    if (values.ipv4mode !== 'static') {
+		values.ip = values.ipv4mode;
+	    }
+	    newdata[id] = PVE.Parser.printLxcNetwork(values);
+	}
+	return newdata;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let cdata = {};
+	if (me.insideWizard) {
+	    me.ifname = 'net0';
+	    cdata.name = 'eth0';
+	    me.dataCache = {};
+	}
+	cdata.firewall = me.insideWizard || me.isCreate;
+
+	if (!me.dataCache) {
+	    throw "no dataCache specified";
+	}
+
+	if (!me.isCreate) {
+	    if (!me.ifname) {
+		throw "no interface name specified";
+	    }
+	    if (!me.dataCache[me.ifname]) {
+		throw "no such interface '" + me.ifname + "'";
+	    }
+	    cdata = PVE.Parser.parseLxcNetwork(me.dataCache[me.ifname]);
+	}
+
+	for (let i = 0; i < 32; i++) {
+	    let ifname = 'net' + i.toString();
+	    if (me.isCreate && !me.dataCache[ifname]) {
+		me.ifname = ifname;
+		break;
+	    }
+	}
+
+	me.column1 = [
+	    {
+		xtype: 'hidden',
+		name: 'id',
+		value: me.ifname,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'name',
+		fieldLabel: gettext('Name'),
+		emptyText: '(e.g., eth0)',
+		allowBlank: false,
+		value: cdata.name,
+		validator: function(value) {
+		    for (const [key, netRaw] of Object.entries(me.dataCache)) {
+			if (!key.match(/^net\d+/) || key === me.ifname) {
+			    continue;
+			}
+			let net = PVE.Parser.parseLxcNetwork(netRaw);
+			if (net.name === value) {
+			    return "interface name already in use";
+			}
+		    }
+		    return true;
+		},
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'hwaddr',
+		fieldLabel: gettext('MAC address'),
+		vtype: 'MacAddress',
+		value: cdata.hwaddr,
+		allowBlank: true,
+		emptyText: 'auto',
+	    },
+	    {
+		xtype: 'PVE.form.BridgeSelector',
+		name: 'bridge',
+		nodename: me.nodename,
+		fieldLabel: gettext('Bridge'),
+		value: cdata.bridge,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'pveVlanField',
+		name: 'tag',
+		value: cdata.tag,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Firewall'),
+		name: 'firewall',
+		value: cdata.firewall,
+	    },
+	];
+
+	let dhcp4 = cdata.ip === 'dhcp';
+	if (dhcp4) {
+	    cdata.ip = '';
+	    cdata.gw = '';
+	}
+
+	let auto6 = cdata.ip6 === 'auto';
+	let dhcp6 = cdata.ip6 === 'dhcp';
+	if (auto6 || dhcp6) {
+	    cdata.ip6 = '';
+	    cdata.gw6 = '';
+	}
+
+	me.column2 = [
+	    {
+		layout: {
+		    type: 'hbox',
+		    align: 'middle',
+		},
+		border: false,
+		margin: '0 0 5 0',
+		items: [
+		    {
+			xtype: 'label',
+			text: 'IPv4:', // do not localize
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('Static'),
+			name: 'ipv4mode',
+			inputValue: 'static',
+			checked: !dhcp4,
+			margin: '0 0 0 10',
+			listeners: {
+			    change: function(cb, value) {
+				me.down('field[name=ip]').setEmptyText(
+				    value ? Proxmox.Utils.NoneText : "",
+				);
+				me.down('field[name=ip]').setDisabled(!value);
+				me.down('field[name=gw]').setDisabled(!value);
+			    },
+			},
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: 'DHCP', // do not localize
+			name: 'ipv4mode',
+			inputValue: 'dhcp',
+			checked: dhcp4,
+			margin: '0 0 0 10',
+		    },
+		],
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'ip',
+		vtype: 'IPCIDRAddress',
+		value: cdata.ip,
+		emptyText: dhcp4 ? '' : Proxmox.Utils.NoneText,
+		disabled: dhcp4,
+		fieldLabel: 'IPv4/CIDR', // do not localize
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'gw',
+		value: cdata.gw,
+		vtype: 'IPAddress',
+		disabled: dhcp4,
+		fieldLabel: gettext('Gateway') + ' (IPv4)',
+		margin: '0 0 3 0', // override bottom margin to account for the menuseparator
+	    },
+	    {
+		xtype: 'menuseparator',
+		height: '3',
+		margin: '0',
+	    },
+	    {
+		layout: {
+		    type: 'hbox',
+		    align: 'middle',
+		},
+		border: false,
+		margin: '0 0 5 0',
+		items: [
+		    {
+			xtype: 'label',
+			text: 'IPv6:', // do not localize
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('Static'),
+			name: 'ipv6mode',
+			inputValue: 'static',
+			checked: !(auto6 || dhcp6),
+			margin: '0 0 0 10',
+			listeners: {
+			    change: function(cb, value) {
+				me.down('field[name=ip6]').setEmptyText(
+				    value ? Proxmox.Utils.NoneText : "",
+				);
+				me.down('field[name=ip6]').setDisabled(!value);
+				me.down('field[name=gw6]').setDisabled(!value);
+			    },
+			},
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: 'DHCP', // do not localize
+			name: 'ipv6mode',
+			inputValue: 'dhcp',
+			checked: dhcp6,
+			margin: '0 0 0 10',
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: 'SLAAC', // do not localize
+			name: 'ipv6mode',
+			inputValue: 'auto',
+			checked: auto6,
+			margin: '0 0 0 10',
+		    },
+		],
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'ip6',
+		value: cdata.ip6,
+		emptyText: dhcp6 || auto6 ? '' : Proxmox.Utils.NoneText,
+		vtype: 'IP6CIDRAddress',
+		disabled: dhcp6 || auto6,
+		fieldLabel: 'IPv6/CIDR', // do not localize
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'gw6',
+		vtype: 'IP6Address',
+		value: cdata.gw6,
+		disabled: dhcp6 || auto6,
+		fieldLabel: gettext('Gateway') + ' (IPv6)',
+	    },
+	];
+
+	me.advancedColumn1 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Disconnect'),
+		name: 'link_down',
+		value: cdata.link_down,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		fieldLabel: 'MTU',
+		emptyText: gettext('Same as bridge'),
+		name: 'mtu',
+		value: cdata.mtu,
+		minValue: 576,
+		maxValue: 65535,
+	    },
+	];
+
+	me.advancedColumn2 = [
+	    {
+		xtype: 'numberfield',
+		name: 'rate',
+		fieldLabel: gettext('Rate limit') + ' (MB/s)',
+		minValue: 0,
+		maxValue: 10*1024,
+		value: cdata.rate,
+		emptyText: 'unlimited',
+		allowBlank: true,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.lxc.NetworkEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.dataCache) {
+	    throw "no dataCache specified";
+	}
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    subject: gettext('Network Device') + ' (veth)',
+	    digest: me.dataCache.digest,
+	    items: [
+		{
+		    xtype: 'pveLxcNetworkInputPanel',
+		    ifname: me.ifname,
+		    nodename: me.nodename,
+		    dataCache: me.dataCache,
+		    isCreate: me.isCreate,
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.lxc.NetworkView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveLxcNetworkView',
+
+    onlineHelp: 'pct_container_network',
+
+    dataCache: {}, // used to store result of last load
+
+    stateful: true,
+    stateId: 'grid-lxc-network',
+
+    load: function() {
+	let me = this;
+
+	Proxmox.Utils.setErrorMask(me, true);
+
+	Proxmox.Utils.API2Request({
+	    url: me.url,
+	    failure: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, gettext('Error') + ': ' + response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		Proxmox.Utils.setErrorMask(me, false);
+		let result = Ext.decode(response.responseText);
+		me.dataCache = result.data || {};
+		let records = [];
+		for (const [key, value] of Object.entries(me.dataCache)) {
+		    if (key.match(/^net\d+/)) {
+			let net = PVE.Parser.parseLxcNetwork(value);
+			net.id = key;
+			records.push(net);
+		    }
+		}
+		me.store.loadData(records);
+		me.down('button[name=addButton]').setDisabled(records.length >= 32);
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	let vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	let caps = Ext.state.Manager.get('GuiCap');
+
+	me.url = `/nodes/${nodename}/lxc/${vmid}/config`;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-lxc-network',
+	    sorters: [
+		{
+		    property: 'id',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec || !caps.vms['VM.Config.Network']) {
+		return false; // disable default-propagation when triggered by grid dblclick
+	    }
+	    Ext.create('PVE.lxc.NetworkEdit', {
+		url: me.url,
+		nodename: nodename,
+		dataCache: me.dataCache,
+		ifname: rec.data.id,
+		listeners: {
+		    destroy: () => me.load(),
+		},
+		autoShow: true,
+	    });
+	    return undefined; // make eslint happier
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    name: 'addButton',
+		    disabled: !caps.vms['VM.Config.Network'],
+		    handler: function() {
+			Ext.create('PVE.lxc.NetworkEdit', {
+			    url: me.url,
+			    nodename: nodename,
+			    isCreate: true,
+			    dataCache: me.dataCache,
+			    listeners: {
+				destroy: () => me.load(),
+			    },
+			    autoShow: true,
+			});
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Remove'),
+		    disabled: true,
+		    selModel: sm,
+		    enableFn: function(rec) {
+			return !!caps.vms['VM.Config.Network'];
+		    },
+		    confirmMsg: ({ data }) =>
+			Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${data.id}'`),
+		    handler: function(btn, e, rec) {
+			Proxmox.Utils.API2Request({
+			    url: me.url,
+			    waitMsgTarget: me,
+			    method: 'PUT',
+			    params: {
+				'delete': rec.data.id,
+				digest: me.dataCache.digest,
+			    },
+			    callback: () => me.load(),
+			    failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+			});
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Edit'),
+		    selModel: sm,
+		    disabled: true,
+		    enableFn: rec => !!caps.vms['VM.Config.Network'],
+		    handler: run_editor,
+		},
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    width: 50,
+		    dataIndex: 'id',
+		},
+		{
+		    header: gettext('Name'),
+		    width: 80,
+		    dataIndex: 'name',
+		},
+		{
+		    header: gettext('Bridge'),
+		    width: 80,
+		    dataIndex: 'bridge',
+		},
+		{
+		    header: gettext('Firewall'),
+		    width: 80,
+		    dataIndex: 'firewall',
+		    renderer: Proxmox.Utils.format_boolean,
+		},
+		{
+		    header: gettext('VLAN Tag'),
+		    width: 80,
+		    dataIndex: 'tag',
+		},
+		{
+		    header: gettext('MAC address'),
+		    width: 110,
+		    dataIndex: 'hwaddr',
+		},
+		{
+		    header: gettext('IP address'),
+		    width: 150,
+		    dataIndex: 'ip',
+		    renderer: function(value, metaData, rec) {
+			if (rec.data.ip && rec.data.ip6) {
+			    return rec.data.ip + "<br>" + rec.data.ip6;
+			} else if (rec.data.ip6) {
+			    return rec.data.ip6;
+			} else {
+			    return rec.data.ip;
+			}
+		    },
+		},
+		{
+		    header: gettext('Gateway'),
+		    width: 150,
+		    dataIndex: 'gw',
+		    renderer: function(value, metaData, rec) {
+			if (rec.data.gw && rec.data.gw6) {
+			    return rec.data.gw + "<br>" + rec.data.gw6;
+			} else if (rec.data.gw6) {
+			    return rec.data.gw6;
+			} else {
+			    return rec.data.gw;
+			}
+		    },
+		},
+		{
+		    header: gettext('MTU'),
+		    width: 80,
+		    dataIndex: 'mtu',
+		},
+		{
+		    header: gettext('Disconnected'),
+		    width: 100,
+		    dataIndex: 'link_down',
+		    renderer: Proxmox.Utils.format_boolean,
+		},
+	    ],
+	    listeners: {
+		activate: me.load,
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+   },
+}, function() {
+    Ext.define('pve-lxc-network', {
+	extend: "Ext.data.Model",
+	proxy: { type: 'memory' },
+	fields: [
+	    'id',
+	    'name',
+	    'hwaddr',
+	    'bridge',
+	    'ip',
+	    'gw',
+	    'ip6',
+	    'gw6',
+	    'tag',
+	    'firewall',
+	    'mtu',
+	    'link_down',
+	],
+    });
+});
+
+Ext.define('PVE.lxc.Options', {
+    extend: 'Proxmox.grid.PendingObjectGrid',
+    alias: ['widget.pveLxcOptions'],
+
+    onlineHelp: 'pct_options',
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var rows = {
+	    onboot: {
+		header: gettext('Start at boot'),
+		defaultValue: '',
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Start at boot'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'onboot',
+			uncheckedValue: 0,
+			defaultValue: 0,
+			fieldLabel: gettext('Start at boot'),
+		    },
+		} : undefined,
+	    },
+	    startup: {
+		header: gettext('Start/Shutdown order'),
+		defaultValue: '',
+		renderer: PVE.Utils.render_kvm_startup,
+		editor: caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify']
+		    ? {
+			xtype: 'pveWindowStartupEdit',
+			onlineHelp: 'pct_startup_and_shutdown',
+		    } : undefined,
+	    },
+	    ostype: {
+		header: gettext('OS Type'),
+		defaultValue: Proxmox.Utils.unknownText,
+	    },
+	    arch: {
+		header: gettext('Architecture'),
+		defaultValue: Proxmox.Utils.unknownText,
+	    },
+	    console: {
+		header: '/dev/console',
+		defaultValue: 1,
+		renderer: Proxmox.Utils.format_enabled_toggle,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: '/dev/console',
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'console',
+			uncheckedValue: 0,
+			defaultValue: 1,
+			deleteDefaultValue: true,
+			checked: true,
+			fieldLabel: '/dev/console',
+		    },
+		} : undefined,
+	    },
+	    tty: {
+		header: gettext('TTY count'),
+		defaultValue: 2,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('TTY count'),
+		    items: {
+			xtype: 'proxmoxintegerfield',
+			name: 'tty',
+			minValue: 0,
+			maxValue: 6,
+			value: 2,
+			fieldLabel: gettext('TTY count'),
+			emptyText: gettext('Default'),
+			deleteEmpty: true,
+		    },
+		} : undefined,
+	    },
+	    cmode: {
+		header: gettext('Console mode'),
+		defaultValue: 'tty',
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Console mode'),
+		    items: {
+			xtype: 'proxmoxKVComboBox',
+			name: 'cmode',
+			deleteEmpty: true,
+			value: '__default__',
+			comboItems: [
+			    ['__default__', Proxmox.Utils.defaultText + " (tty)"],
+			    ['tty', "/dev/tty[X]"],
+			    ['console', "/dev/console"],
+			    ['shell', "shell"],
+			],
+			fieldLabel: gettext('Console mode'),
+		    },
+		} : undefined,
+	    },
+	    protection: {
+		header: gettext('Protection'),
+		defaultValue: false,
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Protection'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'protection',
+			uncheckedValue: 0,
+			defaultValue: 0,
+			deleteDefaultValue: true,
+			fieldLabel: gettext('Enabled'),
+		    },
+		} : undefined,
+	    },
+	    unprivileged: {
+		header: gettext('Unprivileged container'),
+		renderer: Proxmox.Utils.format_boolean,
+		defaultValue: 0,
+	    },
+	    features: {
+		header: gettext('Features'),
+		defaultValue: Proxmox.Utils.noneText,
+		editor: 'PVE.lxc.FeaturesEdit',
+	    },
+	    hookscript: {
+		header: gettext('Hookscript'),
+	    },
+	};
+
+	var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config';
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    enableFn: function(rec) {
+		var rowdef = rows[rec.data.key];
+		return !!rowdef.editor;
+	    },
+	    handler: function() { me.run_editor(); },
+	});
+
+	var revert_btn = new PVE.button.PendingRevert();
+
+	var set_button_status = function() {
+	    let button_sm = me.getSelectionModel();
+	    let rec = button_sm.getSelection()[0];
+
+	    if (!rec) {
+		edit_btn.disable();
+		return;
+	    }
+
+	    var key = rec.data.key;
+	    var pending = rec.data.delete || me.hasPendingChanges(key);
+	    var rowdef = rows[key];
+
+	    if (key === 'features') {
+		let unprivileged = me.getStore().getById('unprivileged').data.value;
+		let root = Proxmox.UserName === 'root@pam';
+		let vmalloc = caps.vms['VM.Allocate'];
+		edit_btn.setDisabled(!(root || (vmalloc && unprivileged)));
+	    } else {
+		edit_btn.setDisabled(!rowdef.editor);
+	    }
+
+	    revert_btn.setDisabled(!pending);
+	};
+
+
+	Ext.apply(me, {
+	    url: "/api2/json/nodes/" + nodename + "/lxc/" + vmid + "/pending",
+	    selModel: sm,
+	    interval: 5000,
+	    tbar: [edit_btn, revert_btn],
+	    rows: rows,
+	    editorConfig: {
+		url: '/api2/extjs/' + baseurl,
+	    },
+	    listeners: {
+		itemdblclick: me.run_editor,
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+
+	me.mon(me.getStore(), 'datachanged', function() {
+	    set_button_status();
+	});
+    },
+});
+
+var labelWidth = 120;
+
+Ext.define('PVE.lxc.MemoryEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.apply(me, {
+	    subject: gettext('Memory'),
+	    items: Ext.create('PVE.lxc.MemoryInputPanel'),
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+
+
+Ext.define('PVE.lxc.CPUEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveLxcCPUEdit',
+
+    viewModel: {
+	data: {
+	    cgroupMode: 2,
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.getViewModel().set('cgroupMode', me.cgroupMode);
+
+	Ext.apply(me, {
+	    subject: gettext('CPU'),
+	    items: Ext.create('PVE.lxc.CPUInputPanel'),
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+
+// The view model of the parent shoul contain a 'cgroupMode' variable (or params for v2 are used).
+Ext.define('PVE.lxc.CPUInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveLxcCPUInputPanel',
+
+    onlineHelp: 'pct_cpu',
+
+    insideWizard: false,
+
+    viewModel: {
+	formulas: {
+	    cpuunitsDefault: (get) => get('cgroupMode') === 1 ? 1024 : 100,
+	    cpuunitsMax: (get) => get('cgroupMode') === 1 ? 500000 : 10000,
+	},
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+	let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault');
+
+	PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard);
+	PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard);
+
+	return values;
+    },
+
+    advancedColumn1: [
+	{
+	    xtype: 'numberfield',
+	    name: 'cpulimit',
+	    minValue: 0,
+	    value: '',
+	    step: 1,
+	    fieldLabel: gettext('CPU limit'),
+	    allowBlank: true,
+	    emptyText: gettext('unlimited'),
+	},
+    ],
+
+    advancedColumn2: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'cpuunits',
+	    fieldLabel: gettext('CPU units'),
+	    value: '',
+	    minValue: 8,
+	    maxValue: '10000',
+	    emptyText: '100',
+	    bind: {
+		emptyText: '{cpuunitsDefault}',
+		maxValue: '{cpuunitsMax}',
+	    },
+	    labelWidth: labelWidth,
+	    deleteEmpty: true,
+	    allowBlank: true,
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'cores',
+		minValue: 1,
+		maxValue: 8192,
+		value: me.insideWizard ? 1 : '',
+		fieldLabel: gettext('Cores'),
+		allowBlank: true,
+		deleteEmpty: true,
+		emptyText: gettext('unlimited'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.lxc.MemoryInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveLxcMemoryInputPanel',
+
+    onlineHelp: 'pct_memory',
+
+    insideWizard: false,
+
+    initComponent: function() {
+	var me = this;
+
+	var items = [
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'memory',
+		minValue: 16,
+		value: '512',
+		step: 32,
+		fieldLabel: gettext('Memory') + ' (MiB)',
+		labelWidth: labelWidth,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'swap',
+		minValue: 0,
+		value: '512',
+		step: 32,
+		fieldLabel: gettext('Swap') + ' (MiB)',
+		labelWidth: labelWidth,
+		allowBlank: false,
+	    },
+	];
+
+	if (me.insideWizard) {
+	    me.column1 = items;
+	} else {
+	    me.items = items;
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.lxc.RessourceView', {
+    extend: 'Proxmox.grid.PendingObjectGrid',
+    alias: ['widget.pveLxcRessourceView'],
+
+    onlineHelp: 'pct_configuration',
+
+    renderKey: function(key, metaData, rec, rowIndex, colIndex, store) {
+	let me = this;
+	let rowdef = me.rows[key] || {};
+
+	let txt = rowdef.header || key;
+	let icon = '';
+
+	metaData.tdAttr = "valign=middle";
+	if (rowdef.tdCls) {
+	    metaData.tdCls = rowdef.tdCls;
+	} else if (rowdef.iconCls) {
+	    icon = `<i class='pve-grid-fa fa fa-fw fa-${rowdef.iconCls}'></i>`;
+	    metaData.tdCls += " pve-itype-fa";
+	}
+	// only return icons in grid but not remove dialog
+	if (rowIndex !== undefined) {
+	    return icon + txt;
+	} else {
+	    return txt;
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var caps = Ext.state.Manager.get('GuiCap');
+	var diskCap = caps.vms['VM.Config.Disk'];
+
+	var mpeditor = caps.vms['VM.Config.Disk'] ? 'PVE.lxc.MountPointEdit' : undefined;
+
+	const nodeInfo = PVE.data.ResourceStore.getNodes().find(node => node.node === nodename);
+	let cpuEditor = {
+	    xtype: 'pveLxcCPUEdit',
+	    cgroupMode: nodeInfo['cgroup-mode'],
+	};
+
+	var rows = {
+	    memory: {
+		header: gettext('Memory'),
+		editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined,
+		defaultValue: 512,
+		tdCls: 'pmx-itype-icon-memory',
+		group: 1,
+		renderer: function(value) {
+		    return Proxmox.Utils.format_size(value*1024*1024);
+		},
+	    },
+	    swap: {
+		header: gettext('Swap'),
+		editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined,
+		defaultValue: 512,
+		iconCls: 'refresh',
+		group: 2,
+		renderer: function(value) {
+		    return Proxmox.Utils.format_size(value*1024*1024);
+		},
+	    },
+	    cores: {
+		header: gettext('Cores'),
+		editor: caps.vms['VM.Config.CPU'] ? cpuEditor : undefined,
+		defaultValue: '',
+		tdCls: 'pmx-itype-icon-processor',
+		group: 3,
+		renderer: function(value) {
+		    var cpulimit = me.getObjectValue('cpulimit');
+		    var cpuunits = me.getObjectValue('cpuunits');
+		    var res;
+		    if (value) {
+			res = value;
+		    } else {
+			res = gettext('unlimited');
+		    }
+
+		    if (cpulimit) {
+			res += ' [cpulimit=' + cpulimit + ']';
+		    }
+
+		    if (cpuunits) {
+			res += ' [cpuunits=' + cpuunits + ']';
+		    }
+		    return res;
+		},
+	    },
+	    rootfs: {
+		header: gettext('Root Disk'),
+		defaultValue: Proxmox.Utils.noneText,
+		editor: mpeditor,
+		iconCls: 'hdd-o',
+		group: 4,
+	    },
+	    cpulimit: {
+		visible: false,
+	    },
+	    cpuunits: {
+		visible: false,
+	    },
+	    unprivileged: {
+		visible: false,
+	    },
+	};
+
+	PVE.Utils.forEachLxcMP(function(bus, i, confid) {
+	    var group = 5;
+	    var header;
+	    if (bus === 'mp') {
+		header = gettext('Mount Point') + ' (' + confid + ')';
+	    } else {
+		header = gettext('Unused Disk') + ' ' + i;
+		group += 1;
+	    }
+	    rows[confid] = {
+		group: group,
+		order: i,
+		tdCls: 'pve-itype-icon-storage',
+		editor: mpeditor,
+		header: header,
+	    };
+	}, true);
+
+	let deveditor = Proxmox.UserName === 'root@pam' ? 'PVE.lxc.DeviceEdit' : undefined;
+
+	PVE.Utils.forEachLxcDev(function(i, confid) {
+	    rows[confid] = {
+		group: 7,
+		order: i,
+		tdCls: 'pve-itype-icon-pci',
+		editor: deveditor,
+		header: gettext('Device') + ' (' + confid + ')',
+	    };
+	});
+
+	var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config';
+
+	me.selModel = Ext.create('Ext.selection.RowModel', {});
+
+	var run_resize = function() {
+	    var rec = me.selModel.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    var win = Ext.create('PVE.window.MPResize', {
+		disk: rec.data.key,
+		nodename: nodename,
+		vmid: vmid,
+	    });
+
+	    win.show();
+	};
+
+	var run_remove = function(b, e, rec) {
+	    Proxmox.Utils.API2Request({
+		url: '/api2/extjs/' + baseurl,
+		waitMsgTarget: me,
+		method: 'PUT',
+		params: {
+		    'delete': rec.data.key,
+		},
+		failure: function(response, opts) {
+		    Ext.Msg.alert('Error', response.htmlStatus);
+		},
+	    });
+	};
+
+	let run_move = function() {
+	    let rec = me.selModel.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    var win = Ext.create('PVE.window.HDMove', {
+		disk: rec.data.key,
+		nodename: nodename,
+		vmid: vmid,
+		type: 'lxc',
+	    });
+
+	    win.show();
+
+	    win.on('destroy', me.reload, me);
+	};
+
+	let run_reassign = function() {
+	    let rec = me.selModel.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+
+	    Ext.create('PVE.window.GuestDiskReassign', {
+		disk: rec.data.key,
+		nodename: nodename,
+		autoShow: true,
+		vmid: vmid,
+		type: 'lxc',
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    });
+	};
+
+	var edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    selModel: me.selModel,
+	    disabled: true,
+	    enableFn: function(rec) {
+		if (!rec) {
+		    return false;
+		}
+		var rowdef = rows[rec.data.key];
+		return !!rowdef.editor;
+	    },
+	    handler: function() { me.run_editor(); },
+	});
+
+	var remove_btn = new Proxmox.button.Button({
+	    text: gettext('Remove'),
+	    defaultText: gettext('Remove'),
+	    altText: gettext('Detach'),
+	    selModel: me.selModel,
+	    disabled: true,
+	    dangerous: true,
+	    confirmMsg: function(rec) {
+		let warn = Ext.String.format(gettext('Are you sure you want to remove entry {0}'));
+		if (this.text === this.altText) {
+		    warn = gettext('Are you sure you want to detach entry {0}');
+		}
+		let rendered = me.renderKey(rec.data.key, {}, rec);
+		let msg = Ext.String.format(warn, `'${rendered}'`);
+
+		if (rec.data.key.match(/^unused\d+$/)) {
+		    msg += " " + gettext('This will permanently erase all data.');
+		}
+		return msg;
+	    },
+	    handler: run_remove,
+	    listeners: {
+		render: function(btn) {
+		    // hack: calculate the max button width on first display to prevent the whole
+		    // toolbar to move when we switch between the "Remove" and "Detach" labels
+		    let def = btn.getSize().width;
+
+		    btn.setText(btn.altText);
+		    let alt = btn.getSize().width;
+
+		    btn.setText(btn.defaultText);
+
+		    let optimal = alt > def ? alt : def;
+		    btn.setSize({ width: optimal });
+		},
+	    },
+	});
+
+	let move_menuitem = new Ext.menu.Item({
+	    text: gettext('Move Storage'),
+	    tooltip: gettext('Move volume to another storage'),
+	    iconCls: 'fa fa-database',
+	    selModel: me.selModel,
+	    handler: run_move,
+	});
+
+	let reassign_menuitem = new Ext.menu.Item({
+	    text: gettext('Reassign Owner'),
+	    tooltip: gettext('Reassign volume to another CT'),
+	    iconCls: 'fa fa-cube',
+	    handler: run_reassign,
+	    reference: 'reassing_item',
+	});
+
+	let resize_menuitem = new Ext.menu.Item({
+	    text: gettext('Resize'),
+	    iconCls: 'fa fa-plus',
+	    selModel: me.selModel,
+	    handler: run_resize,
+	});
+
+	let volumeaction_btn = new Proxmox.button.Button({
+	    text: gettext('Volume Action'),
+	    disabled: true,
+	    menu: {
+		items: [
+		    move_menuitem,
+		    reassign_menuitem,
+		    resize_menuitem,
+		],
+	    },
+	});
+
+	let revert_btn = new PVE.button.PendingRevert();
+
+	let set_button_status = function() {
+	    let rec = me.selModel.getSelection()[0];
+
+	    if (!rec) {
+		edit_btn.disable();
+		remove_btn.disable();
+		volumeaction_btn.disable();
+		revert_btn.disable();
+		return;
+	    }
+	    let { key, value, 'delete': isDelete } = rec.data;
+	    let rowdef = rows[key];
+
+	    let pending = isDelete || me.hasPendingChanges(key);
+	    let isRootFS = key === 'rootfs';
+	    let isDisk = isRootFS || key.match(/^(mp|unused)\d+/);
+	    let isUnusedDisk = key.match(/^unused\d+/);
+	    let isUsedDisk = isDisk && !isUnusedDisk;
+	    let isDevice = key.match(/^dev\d+/);
+
+	    let noedit = isDelete || !rowdef.editor;
+	    if (!noedit && Proxmox.UserName !== 'root@pam' && key.match(/^mp\d+$/)) {
+		let mp = PVE.Parser.parseLxcMountPoint(value);
+		if (mp.type !== 'volume') {
+		    noedit = true;
+		}
+	    }
+	    edit_btn.setDisabled(noedit);
+
+	    volumeaction_btn.setDisabled(!isDisk || !diskCap);
+	    move_menuitem.setDisabled(isUnusedDisk);
+	    reassign_menuitem.setDisabled(isRootFS);
+	    resize_menuitem.setDisabled(isUnusedDisk);
+
+	    remove_btn.setDisabled(!(isDisk || isDevice) || isRootFS || !diskCap || pending);
+	    revert_btn.setDisabled(!pending);
+
+	    remove_btn.setText(isUsedDisk ? remove_btn.altText : remove_btn.defaultText);
+	};
+
+	let sorterFn = function(rec1, rec2) {
+	    let v1 = rec1.data.key, v2 = rec2.data.key;
+
+	    let g1 = rows[v1].group || 0, g2 = rows[v2].group || 0;
+	    if (g1 - g2 !== 0) {
+		return g1 - g2;
+	    }
+
+	    let order1 = rows[v1].order || 0, order2 = rows[v2].order || 0;
+	    if (order1 - order2 !== 0) {
+		return order1 - order2;
+	    }
+
+	    if (v1 > v2) {
+		return 1;
+	    } else if (v1 < v2) {
+	        return -1;
+	    } else {
+		return 0;
+	    }
+	};
+
+	Ext.apply(me, {
+	    url: `/api2/json/nodes/${nodename}/lxc/${vmid}/pending`,
+	    selModel: me.selModel,
+	    interval: 2000,
+	    cwidth1: 170,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: [
+			    {
+				text: gettext('Mount Point'),
+				iconCls: 'fa fa-fw fa-hdd-o black',
+				disabled: !caps.vms['VM.Config.Disk'],
+				handler: function() {
+				    Ext.create('PVE.lxc.MountPointEdit', {
+					autoShow: true,
+					url: `/api2/extjs/${baseurl}`,
+					unprivileged: me.getObjectValue('unprivileged'),
+					pveSelNode: me.pveSelNode,
+					listeners: {
+					    destroy: () => me.reload(),
+					},
+				    });
+				},
+			    },
+			    {
+				text: gettext('Device Passthrough'),
+				iconCls: 'pve-itype-icon-pci',
+				disabled: Proxmox.UserName !== 'root@pam',
+				handler: function() {
+				    Ext.create('PVE.lxc.DeviceEdit', {
+					autoShow: true,
+					url: `/api2/extjs/${baseurl}`,
+					pveSelNode: me.pveSelNode,
+					listeners: {
+					    destroy: () => me.reload(),
+					},
+				    });
+				},
+			    },
+			],
+		    }),
+		},
+		edit_btn,
+		remove_btn,
+		volumeaction_btn,
+		revert_btn,
+	    ],
+	    rows: rows,
+	    sorterFn: sorterFn,
+	    editorConfig: {
+		pveSelNode: me.pveSelNode,
+		url: '/api2/extjs/' + baseurl,
+	    },
+	    listeners: {
+		itemdblclick: me.run_editor,
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+	me.on('deactivate', me.rstore.stopUpdate);
+
+	me.mon(me.getStore(), 'datachanged', function() {
+	    set_button_status();
+	});
+
+	Ext.apply(me.editorConfig, { unprivileged: me.getObjectValue('unprivileged') });
+    },
+});
+Ext.define('PVE.lxc.MultiMPPanel', {
+    extend: 'PVE.panel.MultiDiskPanel',
+    alias: 'widget.pveMultiMPPanel',
+
+    onlineHelp: 'pct_container_storage',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	// count of mps + rootfs
+	maxCount: PVE.Utils.lxc_mp_counts.mp + 1,
+
+	getNextFreeDisk: function(vmconfig) {
+	    let nextFreeDisk;
+	    if (!vmconfig.rootfs) {
+		return {
+		    confid: 'rootfs',
+		};
+	    } else {
+		for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) {
+		    let confid = `mp${i}`;
+		    if (!vmconfig[confid]) {
+			nextFreeDisk = {
+			    confid,
+			};
+			break;
+		    }
+		}
+	    }
+	    return nextFreeDisk;
+	},
+
+	addPanel: function(itemId, vmconfig, nextFreeDisk) {
+	    let me = this;
+	    return me.getView().add({
+		vmconfig,
+		border: false,
+		showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'),
+		xtype: 'pveLxcMountPointInputPanel',
+		confid: nextFreeDisk.confid === 'rootfs' ? 'rootfs' : null,
+		bind: {
+		    nodename: '{nodename}',
+		    unprivileged: '{unprivileged}',
+		},
+		padding: '0 5 0 10',
+		itemId,
+		selectFree: true,
+		isCreate: true,
+		insideWizard: true,
+	    });
+	},
+
+	getBaseVMConfig: function() {
+	    let me = this;
+
+	    return {
+		unprivileged: me.getViewModel().get('unprivileged'),
+	    };
+	},
+
+	diskSorter: {
+	    sorterFn: function(rec1, rec2) {
+		if (rec1.data.name === 'rootfs') {
+		    return -1;
+		} else if (rec2.data.name === 'rootfs') {
+		    return 1;
+		}
+
+		let mp_match = /^mp(\d+)$/;
+		let [, id1] = mp_match.exec(rec1.data.name);
+		let [, id2] = mp_match.exec(rec2.data.name);
+
+		return parseInt(id1, 10) - parseInt(id2, 10);
+	    },
+	},
+
+	deleteDisabled: (view, rI, cI, item, rec) => rec.data.name === 'rootfs',
+    },
+});
+Ext.define('PVE.menu.Item', {
+    extend: 'Ext.menu.Item',
+    alias: 'widget.pveMenuItem',
+
+    // set to wrap the handler callback in a confirm dialog  showing this text
+    confirmMsg: false,
+
+    // set to focus 'No' instead of 'Yes' button and show a warning symbol
+    dangerous: false,
+
+    initComponent: function() {
+	let me = this;
+	if (me.handler) {
+	    me.setHandler(me.handler, me.scope);
+	}
+	me.callParent();
+    },
+
+    setHandler: function(fn, scope) {
+	let me = this;
+	me.scope = scope;
+	me.handler = function(button, e) {
+	    if (me.confirmMsg) {
+		Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1;
+		Ext.Msg.show({
+		    title: gettext('Confirm'),
+		    icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
+		    msg: me.confirmMsg,
+		    buttons: Ext.Msg.YESNO,
+		    defaultFocus: me.dangerous ? 'no' : 'yes',
+		    callback: function(btn) {
+			if (btn === 'yes') {
+			    Ext.callback(fn, me.scope, [me, e], 0, me);
+			}
+		    },
+		});
+	    } else {
+		Ext.callback(fn, me.scope, [me, e], 0, me);
+	    }
+	};
+    },
+});
+Ext.define('PVE.menu.TemplateMenu', {
+    extend: 'Ext.menu.Menu',
+
+    initComponent: function() {
+	let me = this;
+
+	let info = me.pveSelNode.data;
+	if (!info.node) {
+	    throw "no node name specified";
+	}
+	if (!info.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	let guestType = me.pveSelNode.data.type;
+	if (guestType !== 'qemu' && guestType !== 'lxc') {
+	    throw `invalid guest type ${guestType}`;
+	}
+
+	let template = me.pveSelNode.data.template;
+
+	me.title = (guestType === 'qemu' ? 'VM ' : 'CT ') + info.vmid;
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let standaloneNode = PVE.Utils.isStandaloneNode();
+
+	me.items = [
+	    {
+		text: gettext('Migrate'),
+		iconCls: 'fa fa-fw fa-send-o',
+		hidden: standaloneNode || !caps.vms['VM.Migrate'],
+		handler: function() {
+		    Ext.create('PVE.window.Migrate', {
+			vmtype: guestType,
+			nodename: info.node,
+			vmid: info.vmid,
+			autoShow: true,
+		    });
+		},
+	    },
+	    {
+		text: gettext('Clone'),
+		iconCls: 'fa fa-fw fa-clone',
+		hidden: !caps.vms['VM.Clone'],
+		handler: function() {
+		    Ext.create('PVE.window.Clone', {
+			nodename: info.node,
+			guestType: guestType,
+			vmid: info.vmid,
+			isTemplate: template,
+			autoShow: true,
+		    });
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.ceph.CephInstallWizardInfo', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveCephInstallWizardInfo',
+
+    html: `<h3>Ceph?</h3>
+    <blockquote cite="https://ceph.com/"><p>"<b>Ceph</b> is a unified,
+    distributed storage system, designed for excellent performance, reliability,
+    and scalability."</p></blockquote>
+    <p>
+    <b>Ceph</b> is currently <b>not installed</b> on this node. This wizard
+    will guide you through the installation. Click on the next button below
+    to begin. After the initial installation, the wizard will offer to create
+    an initial configuration. This configuration step is only
+    needed once per cluster and will be skipped if a config is already present.
+    </p>
+    <p>
+    Before starting the installation, please take a look at our documentation,
+    by clicking the help button below. If you want to gain deeper knowledge about
+    Ceph, visit <a target="_blank" href="https://docs.ceph.com/en/latest/">ceph.com</a>.
+    </p>`,
+});
+
+Ext.define('PVE.ceph.CephVersionSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    xtype: 'pveCephVersionSelector',
+
+    fieldLabel: gettext('Ceph version to install'),
+
+    displayField: 'display',
+    valueField: 'release',
+
+    queryMode: 'local',
+    editable: false,
+    forceSelection: true,
+
+    store: {
+	fields: [
+	    'release',
+	    'version',
+	    {
+		name: 'display',
+		calculate: d => `${d.release} (${d.version})`,
+	    },
+	],
+	proxy: {
+	    type: 'memory',
+	    reader: {
+		type: 'json',
+	    },
+	},
+	data: [
+	    { release: "quincy", version: "17.2" },
+	    { release: "reef", version: "18.2" },
+	],
+    },
+});
+
+Ext.define('PVE.ceph.CephHighestVersionDisplay', {
+    extend: 'Ext.form.field.Display',
+    xtype: 'pveCephHighestVersionDisplay',
+
+    fieldLabel: gettext('Ceph in the cluster'),
+
+    value: 'unknown',
+
+    // called on success with (release, versionTxt, versionParts)
+    gotNewestVersion: Ext.emptyFn,
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent(arguments);
+
+	Proxmox.Utils.API2Request({
+	    method: 'GET',
+	    url: '/cluster/ceph/metadata',
+	    params: {
+		scope: 'versions',
+	    },
+	    waitMsgTarget: me,
+	    success: (response) => {
+		let res = response.result;
+		if (!res || !res.data || !res.data.node) {
+		    me.setValue(
+			gettext('Could not detect a ceph installation in the cluster'),
+		    );
+		    return;
+		}
+		let nodes = res.data.node;
+		if (me.nodename) {
+		    // can happen on ceph purge, we do not yet cleanup old version data
+		    delete nodes[me.nodename];
+		}
+
+		let maxversion = [];
+		let maxversiontext = "";
+		for (const [_nodename, data] of Object.entries(nodes)) {
+		    let version = data.version.parts;
+		    if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) {
+			maxversion = version;
+			maxversiontext = data.version.str;
+		    }
+		}
+		// FIXME: get from version selector store
+		const major2release = {
+		    13: 'luminous',
+		    14: 'nautilus',
+		    15: 'octopus',
+		    16: 'pacific',
+		    17: 'quincy',
+		    18: 'reef',
+		    19: 'squid',
+		};
+		let release = major2release[maxversion[0]] || 'unknown';
+		let newestVersionTxt = `${Ext.String.capitalize(release)} (${maxversiontext})`;
+
+		if (release === 'unknown') {
+		    me.setValue(
+			gettext('Could not detect a ceph installation in the cluster'),
+		    );
+		} else {
+		    me.setValue(Ext.String.format(
+			gettext('Newest ceph version in cluster is {0}'),
+			newestVersionTxt,
+		    ));
+		}
+		me.gotNewestVersion(release, maxversiontext, maxversion);
+	    },
+	    failure: function(response, opts) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	});
+    },
+});
+
+Ext.define('PVE.ceph.CephInstallWizard', {
+    extend: 'PVE.window.Wizard',
+    alias: 'widget.pveCephInstallWizard',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    resizable: false,
+    nodename: undefined,
+
+    width: 760, // 4:3
+    height: 570,
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    cephRelease: 'reef',
+	    cephRepo: 'enterprise',
+	    configuration: true,
+	    isInstalled: false,
+	    nodeHasSubscription: true, // avoid warning hint until fully loaded
+	    allHaveSubscription: true, // avoid warning hint until fully loaded
+	},
+	formulas: {
+	    repoHintHidden: get => get('allHaveSubscription') && get('cephRepo') === 'enterprise',
+	    repoHint: function(get) {
+		let repo = get('cephRepo');
+		let nodeSub = get('nodeHasSubscription'), allSub = get('allHaveSubscription');
+
+		if (repo === 'enterprise') {
+                    if (!nodeSub) {
+			return gettext('The enterprise repository is enabled, but there is no active subscription!');
+		    } else if (!allSub) {
+			return gettext('Not all nodes have an active subscription, which is required for cluster-wide enterprise repo access');
+		    }
+		    return ''; // should be hidden
+		} else if (repo === 'no-subscription') {
+		    return allSub
+		        ? gettext("Cluster has active subscriptions and would be elligible for using the enterprise repository.")
+		        : gettext("The no-subscription repository is not the best choice for production setups.");
+		} else {
+		    return gettext('The test repository should only be used for test setups or after consulting the official Proxmox support!');
+		}
+	    },
+	},
+    },
+    cbindData: {
+	nodename: undefined,
+    },
+
+    title: gettext('Setup'),
+    navigateNext: function() {
+	var tp = this.down('#wizcontent');
+	var atab = tp.getActiveTab();
+
+	var next = tp.items.indexOf(atab) + 1;
+	var ntab = tp.items.getAt(next);
+	if (ntab) {
+	    ntab.enable();
+	    tp.setActiveTab(ntab);
+	}
+    },
+    setInitialTab: function(index) {
+	var tp = this.down('#wizcontent');
+	var initialTab = tp.items.getAt(index);
+	initialTab.enable();
+	tp.setActiveTab(initialTab);
+    },
+    onShow: function() {
+	this.callParent(arguments);
+	let viewModel = this.getViewModel();
+	var isInstalled = this.getViewModel().get('isInstalled');
+	if (isInstalled) {
+	    viewModel.set('configuration', false);
+	    this.setInitialTab(2);
+	}
+
+	PVE.Utils.getClusterSubscriptionLevel().then(subcriptionMap => {
+	    viewModel.set('nodeHasSubscription', !!subcriptionMap[this.nodename]);
+
+	    let allHaveSubscription = Object.values(subcriptionMap).every(level => !!level);
+	    viewModel.set('allHaveSubscription', allHaveSubscription);
+	});
+    },
+    items: [
+	{
+	    xtype: 'panel',
+	    title: gettext('Info'),
+	    viewModel: {}, // needed to inherit parent viewModel data
+	    border: false,
+	    bodyBorder: false,
+	    onlineHelp: 'chapter_pveceph',
+	    layout: {
+		type: 'vbox',
+		align: 'stretch',
+	    },
+	    defaults: {
+		border: false,
+		bodyBorder: false,
+	    },
+	    items: [
+		{
+		    xtype: 'pveCephInstallWizardInfo',
+		},
+		{
+		    flex: 1,
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Hint'),
+		    labelClsExtra: 'pmx-hint',
+		    submitValue: false,
+		    labelWidth: 50,
+		    bind: {
+			value: '{repoHint}',
+			hidden: '{repoHintHidden}',
+		    },
+		},
+		{
+		    xtype: 'pveCephHighestVersionDisplay',
+		    labelWidth: 150,
+		    cbind: {
+			nodename: '{nodename}',
+		    },
+		    gotNewestVersion: function(release, maxversiontext, maxversion) {
+			if (release === 'unknown') {
+			    return;
+			}
+			let wizard = this.up('pveCephInstallWizard');
+			wizard.getViewModel().set('cephRelease', release);
+		    },
+		},
+		{
+		    xtype: 'container',
+		    layout: 'hbox',
+		    defaults: {
+			border: false,
+			layout: 'anchor',
+			flex: 1,
+		    },
+		    items: [{
+			xtype: 'pveCephVersionSelector',
+			labelWidth: 150,
+			padding: '0 10 0 0',
+			submitValue: false,
+			bind: {
+			    value: '{cephRelease}',
+			},
+			listeners: {
+			    change: function(field, release) {
+				let wizard = this.up('pveCephInstallWizard');
+				wizard.down('#next').setText(
+				    Ext.String.format(gettext('Start {0} installation'), release),
+				);
+			    },
+			},
+		    },
+		    {
+			xtype: 'proxmoxKVComboBox',
+			fieldLabel: gettext('Repository'),
+			padding: '0 0 0 10',
+			comboItems: [
+			    ['enterprise', gettext('Enterprise (recommended)')],
+			    ['no-subscription', gettext('No-Subscription')],
+			    ['test', gettext('Test')],
+			],
+			labelWidth: 150,
+			submitValue: false,
+			value: 'enterprise',
+			bind: {
+			    value: '{cephRepo}',
+			},
+		    }],
+		},
+	    ],
+	    listeners: {
+		activate: function() {
+		    // notify owning container that it should display a help button
+		    if (this.onlineHelp) {
+			Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp);
+		    }
+		    let wizard = this.up('pveCephInstallWizard');
+		    let release = wizard.getViewModel().get('cephRelease');
+		    wizard.down('#back').hide(true);
+		    wizard.down('#next').setText(
+			Ext.String.format(gettext('Start {0} installation'), release),
+		    );
+		},
+		deactivate: function() {
+		    if (this.onlineHelp) {
+			Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp);
+		    }
+		    this.up('pveCephInstallWizard').down('#next').setText(gettext('Next'));
+		},
+	    },
+	},
+	{
+	    title: gettext('Installation'),
+	    xtype: 'panel',
+	    layout: 'fit',
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	    viewModel: {}, // needed to inherit parent viewModel data
+	    listeners: {
+		afterrender: function() {
+		    var me = this;
+		    if (this.getViewModel().get('isInstalled')) {
+			this.mask("Ceph is already installed, click next to create your configuration.", ['pve-static-mask']);
+		    } else {
+			me.down('pveNoVncConsole').fireEvent('activate');
+		    }
+		},
+		activate: function() {
+		    let me = this;
+		    const nodename = me.nodename;
+		    me.updateStore = Ext.create('Proxmox.data.UpdateStore', {
+			storeid: 'ceph-status-' + nodename,
+			interval: 1000,
+			proxy: {
+			    type: 'proxmox',
+			    url: '/api2/json/nodes/' + nodename + '/ceph/status',
+			},
+			listeners: {
+			    load: function(rec, response, success, operation) {
+				if (success) {
+				    me.updateStore.stopUpdate();
+				    me.down('textfield').setValue('success');
+				} else if (operation.error.statusText.match("not initialized", "i")) {
+				    me.updateStore.stopUpdate();
+				    me.up('pveCephInstallWizard').getViewModel().set('configuration', false);
+				    me.down('textfield').setValue('success');
+				} else if (operation.error.statusText.match("rados_connect failed", "i")) {
+				    me.updateStore.stopUpdate();
+				    me.up('pveCephInstallWizard').getViewModel().set('configuration', true);
+				    me.down('textfield').setValue('success');
+				} else if (!operation.error.statusText.match("not installed", "i")) {
+				    Proxmox.Utils.setErrorMask(me, operation.error.statusText);
+				}
+			    },
+			},
+		    });
+		    me.updateStore.startUpdate();
+		},
+		destroy: function() {
+		    var me = this;
+		    if (me.updateStore) {
+			me.updateStore.stopUpdate();
+		    }
+		},
+	    },
+	    items: [
+		{
+		    xtype: 'pveNoVncConsole',
+		    itemId: 'jsconsole',
+		    consoleType: 'cmd',
+		    xtermjs: true,
+		    cbind: {
+			nodename: '{nodename}',
+		    },
+		    beforeLoad: function() {
+			let me = this;
+			let wizard = me.up('pveCephInstallWizard');
+			let release = wizard.getViewModel().get('cephRelease');
+			let repo = wizard.getViewModel().get('cephRepo');
+			me.cmdOpts = `--version\0${release}\0--repository\0${repo}`;
+		    },
+		    cmd: 'ceph_install',
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'installSuccess',
+		    value: '',
+		    allowBlank: false,
+		    submitValue: false,
+		    hidden: true,
+		},
+	    ],
+	},
+	{
+	    xtype: 'inputpanel',
+	    title: gettext('Configuration'),
+	    onlineHelp: 'chapter_pveceph',
+	    height: 300,
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	    viewModel: {
+		data: {
+		    replicas: undefined,
+		    minreplicas: undefined,
+		},
+	    },
+	    listeners: {
+		activate: function() {
+		    this.up('pveCephInstallWizard').down('#submit').setText(gettext('Next'));
+		},
+		afterrender: function() {
+		    if (this.up('pveCephInstallWizard').getViewModel().get('configuration')) {
+			this.mask("Configuration already initialized", ['pve-static-mask']);
+		    } else {
+			this.unmask();
+		    }
+		},
+		deactivate: function() {
+		    this.up('pveCephInstallWizard').down('#submit').setText(gettext('Finish'));
+		},
+	    },
+	    column1: [
+		{
+		    xtype: 'displayfield',
+		    value: gettext('Ceph cluster configuration') + ':',
+		},
+		{
+		    xtype: 'proxmoxNetworkSelector',
+		    name: 'network',
+		    value: '',
+		    fieldLabel: 'Public Network IP/CIDR',
+		    autoSelect: false,
+		    bind: {
+			allowBlank: '{configuration}',
+		    },
+		    cbind: {
+			nodename: '{nodename}',
+		    },
+		},
+		{
+		    xtype: 'proxmoxNetworkSelector',
+		    name: 'cluster-network',
+		    fieldLabel: 'Cluster Network IP/CIDR',
+		    allowBlank: true,
+		    autoSelect: false,
+		    emptyText: gettext('Same as Public Network'),
+		    cbind: {
+			nodename: '{nodename}',
+		    },
+		},
+		// FIXME: add hint about cluster network and/or reference user to docs??
+	    ],
+	    column2: [
+		{
+		    xtype: 'displayfield',
+		    value: gettext('First Ceph monitor') + ':',
+		},
+		{
+		    xtype: 'displayfield',
+		    fieldLabel: gettext('Monitor node'),
+		    cbind: {
+			value: '{nodename}',
+		    },
+		},
+		{
+		    xtype: 'displayfield',
+		    value: gettext('Additional monitors are recommended. They can be created at any time in the Monitor tab.'),
+		    userCls: 'pmx-hint',
+		},
+	    ],
+	    advancedColumn1: [
+		{
+		    xtype: 'numberfield',
+		    name: 'size',
+		    fieldLabel: 'Number of replicas',
+		    bind: {
+			value: '{replicas}',
+		    },
+		    maxValue: 7,
+		    minValue: 2,
+		    emptyText: '3',
+		},
+		{
+		    xtype: 'numberfield',
+		    name: 'min_size',
+		    fieldLabel: 'Minimum replicas',
+		    bind: {
+			maxValue: '{replicas}',
+			value: '{minreplicas}',
+		    },
+		    minValue: 2,
+		    maxValue: 3,
+		    setMaxValue: function(value) {
+			this.maxValue = Ext.Number.from(value, 2);
+			// allow enough to avoid split brains with max 'size', but more makes simply no sense
+			if (this.maxValue > 4) {
+			    this.maxValue = 4;
+			}
+			this.toggleSpinners();
+			this.validate();
+		    },
+		    emptyText: '2',
+		},
+	    ],
+	    onGetValues: function(values) {
+		['cluster-network', 'size', 'min_size'].forEach(function(field) {
+		    if (!values[field]) {
+			delete values[field];
+		    }
+		});
+		return values;
+	    },
+	    onSubmit: function() {
+		var me = this;
+		if (!this.up('pveCephInstallWizard').getViewModel().get('configuration')) {
+		    var wizard = me.up('window');
+		    var kv = wizard.getValues();
+		    delete kv.delete;
+		    var nodename = me.nodename;
+		    delete kv.nodename;
+		    Proxmox.Utils.API2Request({
+			url: `/nodes/${nodename}/ceph/init`,
+			waitMsgTarget: wizard,
+			method: 'POST',
+			params: kv,
+			success: function() {
+			    Proxmox.Utils.API2Request({
+				url: `/nodes/${nodename}/ceph/mon/${nodename}`,
+				waitMsgTarget: wizard,
+				method: 'POST',
+				success: function() {
+				    me.up('pveCephInstallWizard').navigateNext();
+				},
+				failure: function(response, opts) {
+				    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+				},
+			    });
+			},
+			failure: function(response, opts) {
+			    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			},
+		    });
+		} else {
+		    me.up('pveCephInstallWizard').navigateNext();
+		}
+	    },
+	},
+	{
+	    title: gettext('Success'),
+	    xtype: 'panel',
+	    border: false,
+	    bodyBorder: false,
+	    onlineHelp: 'pve_ceph_install',
+	    html: '<h3>Installation successful!</h3>'+
+	    '<p>The basic installation and configuration is complete. Depending on your setup, some of the following steps are required to start using Ceph:</p>'+
+		'<ol><li>Install Ceph on other nodes</li>'+
+		'<li>Create additional Ceph Monitors</li>'+
+		'<li>Create Ceph OSDs</li>'+
+		'<li>Create Ceph Pools</li></ol>'+
+	    '<p>To learn more, click on the help button below.</p>',
+	    listeners: {
+		activate: function() {
+		    // notify owning container that it should display a help button
+		    if (this.onlineHelp) {
+			Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp);
+		    }
+
+		    var tp = this.up('#wizcontent');
+		    var idx = tp.items.indexOf(this)-1;
+		    for (;idx >= 0; idx--) {
+			var nc = tp.items.getAt(idx);
+			if (nc) {
+			    nc.disable();
+			}
+		    }
+		},
+		deactivate: function() {
+		    if (this.onlineHelp) {
+			Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp);
+		    }
+		},
+	    },
+	    onSubmit: function() {
+		var wizard = this.up('pveCephInstallWizard');
+		wizard.close();
+	    },
+	},
+    ],
+});
+Ext.define('PVE.node.CephConfigDb', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveNodeCephConfigDb',
+
+    border: false,
+    store: {
+	proxy: {
+	    type: 'proxmox',
+	},
+    },
+
+    columns: [
+	{
+	    dataIndex: 'section',
+	    text: 'WHO',
+	    width: 100,
+	},
+	{
+	    dataIndex: 'mask',
+	    text: 'MASK',
+	    hidden: true,
+	    width: 80,
+	},
+	{
+	    dataIndex: 'level',
+	    hidden: true,
+	    text: 'LEVEL',
+	},
+	{
+	    dataIndex: 'name',
+	    flex: 1,
+	    text: 'OPTION',
+	},
+	{
+	    dataIndex: 'value',
+	    flex: 1,
+	    text: 'VALUE',
+	},
+	{
+	    dataIndex: 'can_update_at_runtime',
+	    text: 'Runtime Updatable',
+	    hidden: true,
+	    width: 80,
+	    renderer: Proxmox.Utils.format_boolean,
+	},
+    ],
+
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.store.proxy.url = '/api2/json/nodes/' + nodename + '/ceph/cfg/db';
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore());
+	me.getStore().load();
+    },
+});
+Ext.define('PVE.node.CephConfig', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveNodeCephConfig',
+
+    bodyStyle: 'white-space:pre',
+    bodyPadding: 5,
+    border: false,
+    scrollable: true,
+    load: function() {
+	var me = this;
+
+	Proxmox.Utils.API2Request({
+	    url: me.url,
+	    waitMsgTarget: me,
+	    failure: function(response, opts) {
+		me.update(gettext('Error') + " " + response.htmlStatus);
+		var msg = response.htmlStatus;
+		PVE.Utils.showCephInstallOrMask(me.ownerCt, msg, me.pveSelNode.data.node,
+		    function(win) {
+			me.mon(win, 'cephInstallWindowClosed', function() {
+			    me.load();
+			});
+		    },
+		);
+	    },
+	    success: function(response, opts) {
+		var data = response.result.data;
+		me.update(Ext.htmlEncode(data));
+	    },
+	});
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    url: '/nodes/' + nodename + '/ceph/cfg/raw',
+	    listeners: {
+		activate: function() {
+		    me.load();
+		},
+	    },
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+
+Ext.define('PVE.node.CephConfigCrush', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveNodeCephConfigCrush',
+
+    onlineHelp: 'chapter_pveceph',
+
+    layout: 'border',
+    items: [{
+	    title: gettext('Configuration'),
+	    xtype: 'pveNodeCephConfig',
+	    region: 'center',
+	},
+	{
+	    title: 'Crush Map', // do not localize
+	    xtype: 'pveNodeCephCrushMap',
+	    region: 'east',
+	    split: true,
+	    width: '50%',
+	},
+	{
+	    title: gettext('Configuration Database'),
+	    xtype: 'pveNodeCephConfigDb',
+	    region: 'south',
+	    split: true,
+	    weight: -30,
+	    height: '50%',
+    }],
+
+    initComponent: function() {
+	var me = this;
+	me.defaults = {
+	    pveSelNode: me.pveSelNode,
+	};
+	me.callParent();
+    },
+});
+Ext.define('PVE.node.CephCrushMap', {
+    extend: 'Ext.panel.Panel',
+    alias: ['widget.pveNodeCephCrushMap'],
+    bodyStyle: 'white-space:pre',
+    bodyPadding: 5,
+    border: false,
+    stateful: true,
+    stateId: 'layout-ceph-crush',
+    scrollable: true,
+    load: function() {
+	var me = this;
+
+	Proxmox.Utils.API2Request({
+	    url: me.url,
+	    waitMsgTarget: me,
+	    failure: function(response, opts) {
+		me.update(gettext('Error') + " " + response.htmlStatus);
+		var msg = response.htmlStatus;
+		PVE.Utils.showCephInstallOrMask(
+		    me.ownerCt,
+		    msg,
+		    me.pveSelNode.data.node,
+		    win => me.mon(win, 'cephInstallWindowClosed', () => me.load()),
+		);
+	    },
+	    success: function(response, opts) {
+		var data = response.result.data;
+		me.update(Ext.htmlEncode(data));
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    url: `/nodes/${nodename}/ceph/crush`,
+	    listeners: {
+		activate: () => me.load(),
+	    },
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+Ext.define('PVE.CephCreateFS', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveCephCreateFS',
+
+    showTaskViewer: true,
+    onlineHelp: 'pveceph_fs_create',
+
+    subject: 'Ceph FS',
+    isCreate: true,
+    method: 'POST',
+
+    setFSName: function(fsName) {
+	var me = this;
+
+	if (fsName === '' || fsName === undefined) {
+	    fsName = 'cephfs';
+	}
+
+	me.url = "/nodes/" + me.nodename + "/ceph/fs/" + fsName;
+    },
+
+    items: [
+	{
+	    xtype: 'textfield',
+	    fieldLabel: gettext('Name'),
+	    name: 'name',
+	    value: 'cephfs',
+	    listeners: {
+		change: function(f, value) {
+		    this.up('pveCephCreateFS').setFSName(value);
+		},
+	    },
+	    submitValue: false, // already encoded in apicall URL
+	    emptyText: 'cephfs',
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    fieldLabel: 'Placement Groups',
+	    name: 'pg_num',
+	    value: 128,
+	    emptyText: 128,
+	    minValue: 8,
+	    maxValue: 32768,
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Add as Storage'),
+	    value: true,
+	    name: 'add-storage',
+	    autoEl: {
+		tag: 'div',
+		 'data-qtip': gettext('Add the new CephFS to the cluster storage configuration.'),
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	me.setFSName();
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.NodeCephFSPanel', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveNodeCephFSPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext('CephFS'),
+    onlineHelp: 'pveceph_fs',
+
+    border: false,
+    defaults: {
+	border: false,
+	cbind: {
+	    nodename: '{nodename}',
+	},
+    },
+
+    viewModel: {
+	parent: null,
+	data: {
+	    mdsCount: 0,
+	},
+	formulas: {
+	    canCreateFS: function(get) {
+		return get('mdsCount') > 0;
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'grid',
+	    emptyText: Ext.String.format(gettext('No {0} configured.'), 'CephFS'),
+	    controller: {
+		xclass: 'Ext.app.ViewController',
+
+		init: function(view) {
+		    view.rstore = Ext.create('Proxmox.data.UpdateStore', {
+			autoLoad: true,
+			xtype: 'update',
+			interval: 5 * 1000,
+			autoStart: true,
+			storeid: 'pve-ceph-fs',
+			proxy: {
+			    type: 'proxmox',
+			    url: `/api2/json/nodes/${view.nodename}/ceph/fs`,
+			},
+			model: 'pve-ceph-fs',
+		    });
+		    view.setStore(Ext.create('Proxmox.data.DiffStore', {
+			rstore: view.rstore,
+			sorters: {
+			    property: 'name',
+			    direction: 'ASC',
+			},
+		    }));
+		    // manages the "install ceph?" overlay
+		    PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true);
+		    view.on('destroy', () => view.rstore.stopUpdate());
+		},
+
+		onCreate: function() {
+		    let view = this.getView();
+		    view.rstore.stopUpdate();
+		    Ext.create('PVE.CephCreateFS', {
+			autoShow: true,
+			nodename: view.nodename,
+			listeners: {
+			    destroy: () => view.rstore.startUpdate(),
+			},
+		    });
+		},
+	    },
+	    tbar: [
+		{
+		    text: gettext('Create CephFS'),
+		    reference: 'createButton',
+		    handler: 'onCreate',
+		    bind: {
+			disabled: '{!canCreateFS}',
+		    },
+		},
+	    ],
+	    columns: [
+		{
+		    header: gettext('Name'),
+		    flex: 1,
+		    dataIndex: 'name',
+		},
+		{
+		    header: gettext('Data Pool'),
+		    flex: 1,
+		    dataIndex: 'data_pool',
+		},
+		{
+		    header: gettext('Metadata Pool'),
+		    flex: 1,
+		    dataIndex: 'metadata_pool',
+		},
+	    ],
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	},
+	{
+	    xtype: 'pveNodeCephMDSList',
+	    title: gettext('Metadata Servers'),
+	    stateId: 'grid-ceph-mds',
+	    type: 'mds',
+	    storeLoadCallback: function(store, records, success) {
+		var vm = this.getViewModel();
+		if (!success || !records) {
+		    vm.set('mdsCount', 0);
+		    return;
+		}
+		let count = 0;
+		for (const mds of records) {
+		    if (mds.data.state === 'up:standby') {
+			count++;
+		    }
+		}
+		vm.set('mdsCount', count);
+	    },
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	},
+    ],
+}, function() {
+    Ext.define('pve-ceph-fs', {
+	extend: 'Ext.data.Model',
+	fields: ['name', 'data_pool', 'metadata_pool'],
+	proxy: {
+	    type: 'proxmox',
+	    url: "/api2/json/nodes/localhost/ceph/fs",
+	},
+	idProperty: 'name',
+    });
+});
+Ext.define('PVE.ceph.Log', {
+    extend: 'Proxmox.panel.LogView',
+    xtype: 'cephLogView',
+
+    nodename: undefined,
+
+    failCallback: function(response) {
+	var me = this;
+	var msg = response.htmlStatus;
+	var windowShow = PVE.Utils.showCephInstallOrMask(me, msg, me.nodename,
+	    function(win) {
+		me.mon(win, 'cephInstallWindowClosed', function() {
+		    me.loadTask.delay(200);
+		});
+	    },
+	);
+	if (!windowShow) {
+	    Proxmox.Utils.setErrorMask(me, msg);
+	}
+    },
+});
+Ext.define('PVE.node.CephMonMgrList', {
+    extend: 'Ext.container.Container',
+    xtype: 'pveNodeCephMonMgr',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'chapter_pveceph',
+
+    defaults: {
+	border: false,
+	onlineHelp: 'chapter_pveceph',
+	flex: 1,
+    },
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    items: [
+	{
+	    xtype: 'pveNodeCephServiceList',
+	    cbind: { pveSelNode: '{pveSelNode}' },
+	    type: 'mon',
+	    additionalColumns: [
+		{
+		    header: gettext('Quorum'),
+		    width: 70,
+		    sortable: true,
+		    renderer: Proxmox.Utils.format_boolean,
+		    dataIndex: 'quorum',
+		},
+	    ],
+	    stateId: 'grid-ceph-monitor',
+	    showCephInstallMask: true,
+	    title: gettext('Monitor'),
+	},
+	{
+	    xtype: 'pveNodeCephServiceList',
+	    type: 'mgr',
+	    stateId: 'grid-ceph-manager',
+	    cbind: { pveSelNode: '{pveSelNode}' },
+	    title: gettext('Manager'),
+	},
+    ],
+});
+Ext.define('PVE.CephCreateOsd', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCephCreateOsd',
+
+    subject: 'Ceph OSD',
+
+    showProgress: true,
+
+    onlineHelp: 'pve_ceph_osds',
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.isCreate = true;
+
+	Proxmox.Utils.API2Request({
+	    url: `/nodes/${me.nodename}/ceph/crush`,
+	    method: 'GET',
+	    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    success: function({ result: { data } }) {
+		let classes = [...new Set(
+		    Array.from(
+			data.matchAll(/^device\s[0-9]*\sosd\.[0-9]*\sclass\s(.*)$/gim),
+			m => m[1],
+		    ).filter(v => !['hdd', 'ssd', 'nvme'].includes(v)),
+		)].map(v => [v, v]);
+
+		if (classes.length) {
+		    let kvField = me.down('field[name=crush-device-class]');
+		    kvField.setComboItems([...kvField.comboItems, ...classes]);
+		}
+	    },
+	});
+
+        Ext.applyIf(me, {
+	    url: "/nodes/" + me.nodename + "/ceph/osd",
+	    method: 'POST',
+	    items: [
+		{
+		    xtype: 'inputpanel',
+		    onGetValues: function(values) {
+			Object.keys(values || {}).forEach(function(name) {
+			    if (values[name] === '') {
+				delete values[name];
+			    }
+			});
+
+			return values;
+		    },
+		    column1: [
+			{
+			    xtype: 'pmxDiskSelector',
+			    name: 'dev',
+			    nodename: me.nodename,
+			    diskType: 'unused',
+			    includePartitions: true,
+			    fieldLabel: gettext('Disk'),
+			    allowBlank: false,
+			},
+		    ],
+		    column2: [
+			{
+			    xtype: 'pmxDiskSelector',
+			    name: 'db_dev',
+			    nodename: me.nodename,
+			    diskType: 'journal_disks',
+			    includePartitions: true,
+			    fieldLabel: gettext('DB Disk'),
+			    value: '',
+			    autoSelect: false,
+			    allowBlank: true,
+			    emptyText: gettext('use OSD disk'),
+			    listeners: {
+				change: function(field, val) {
+				    me.down('field[name=db_dev_size]').setDisabled(!val);
+				},
+			    },
+			},
+			{
+			    xtype: 'numberfield',
+			    name: 'db_dev_size',
+			    fieldLabel: `${gettext('DB size')} (${gettext('GiB')})`,
+			    minValue: 1,
+			    maxValue: 128*1024,
+			    decimalPrecision: 2,
+			    allowBlank: true,
+			    disabled: true,
+			    emptyText: gettext('Automatic'),
+			},
+		    ],
+		    advancedColumn1: [
+			{
+			    xtype: 'proxmoxcheckbox',
+			    name: 'encrypted',
+			    fieldLabel: gettext('Encrypt OSD'),
+			},
+			{
+			    xtype: 'proxmoxKVComboBox',
+			    comboItems: [
+				['hdd', 'HDD'],
+				['ssd', 'SSD'],
+				['nvme', 'NVMe'],
+			    ],
+			    name: 'crush-device-class',
+			    nodename: me.nodename,
+			    fieldLabel: gettext('Device Class'),
+			    value: '',
+			    autoSelect: false,
+			    allowBlank: true,
+			    editable: true,
+			    emptyText: gettext('auto detect'),
+			    deleteEmpty: !me.isCreate,
+			},
+		    ],
+		    advancedColumn2: [
+			{
+			    xtype: 'pmxDiskSelector',
+			    name: 'wal_dev',
+			    nodename: me.nodename,
+			    diskType: 'journal_disks',
+			    includePartitions: true,
+			    fieldLabel: gettext('WAL Disk'),
+			    value: '',
+			    autoSelect: false,
+			    allowBlank: true,
+			    emptyText: gettext('use OSD/DB disk'),
+			    listeners: {
+				change: function(field, val) {
+				    me.down('field[name=wal_dev_size]').setDisabled(!val);
+				},
+			    },
+			},
+			{
+			    xtype: 'numberfield',
+			    name: 'wal_dev_size',
+			    fieldLabel: `${gettext('WAL size')} (${gettext('GiB')})`,
+			    minValue: 0.5,
+			    maxValue: 128*1024,
+			    decimalPrecision: 2,
+			    allowBlank: true,
+			    disabled: true,
+			    emptyText: gettext('Automatic'),
+			},
+		    ],
+		},
+		{
+		    xtype: 'displayfield',
+		    padding: '5 0 0 0',
+		    userCls: 'pmx-hint',
+		    value: 'Note: Ceph is not compatible with disks backed by a hardware ' +
+			   'RAID controller. For details see ' +
+			   '<a target="_blank" href="' + Proxmox.Utils.get_help_link('chapter_pveceph') + '">the reference documentation</a>.',
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.CephRemoveOsd', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveCephRemoveOsd'],
+
+    isRemove: true,
+
+    showProgress: true,
+    method: 'DELETE',
+    items: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'cleanup',
+	    checked: true,
+	    labelWidth: 130,
+	    fieldLabel: gettext('Cleanup Disks'),
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'osd-flag-hint',
+	    userCls: 'pmx-hint',
+	    value: gettext('Global flags limiting the self healing of Ceph are enabled.'),
+	    hidden: true,
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'degraded-objects-hint',
+	    userCls: 'pmx-hint',
+	    value: gettext('Objects are degraded. Consider waiting until the cluster is healthy.'),
+	    hidden: true,
+	},
+    ],
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (me.osdid === undefined || me.osdid < 0) {
+	    throw "no osdid specified";
+	}
+
+	me.isCreate = true;
+
+	me.title = gettext('Destroy') + ': Ceph OSD osd.' + me.osdid.toString();
+
+        Ext.applyIf(me, {
+	    url: "/nodes/" + me.nodename + "/ceph/osd/" + me.osdid.toString(),
+        });
+
+        me.callParent();
+
+	if (me.warnings.flags) {
+	    me.down('field[name=osd-flag-hint]').setHidden(false);
+	}
+	if (me.warnings.degraded) {
+	    me.down('field[name=degraded-objects-hint]').setHidden(false);
+	}
+    },
+});
+
+Ext.define('PVE.CephSetFlags', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCephSetFlags',
+
+    showProgress: true,
+
+    width: 720,
+    layout: 'fit',
+
+    onlineHelp: 'pve_ceph_osds',
+    isCreate: true,
+    title: Ext.String.format(gettext('Manage {0}'), 'Global OSD Flags'),
+    submitText: gettext('Apply'),
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    onGetValues: function(values) {
+		let me = this;
+		let val = {};
+		me.down('#flaggrid').getStore().each((rec) => {
+		    val[rec.data.name] = rec.data.value ? 1 : 0;
+		});
+
+		return val;
+	    },
+	    items: [
+		{
+		    xtype: 'grid',
+		    itemId: 'flaggrid',
+		    store: {
+			listeners: {
+			    update: function() {
+				this.commitChanges();
+			    },
+			},
+		    },
+
+		    columns: [
+			{
+			    text: gettext('Enable'),
+			    xtype: 'checkcolumn',
+			    width: 75,
+			    dataIndex: 'value',
+			},
+			{
+			    text: 'Name',
+			    dataIndex: 'name',
+			},
+			{
+			    text: 'Description',
+			    flex: 1,
+			    dataIndex: 'description',
+			},
+		    ],
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+        Ext.applyIf(me, {
+	    url: "/cluster/ceph/flags",
+	    method: 'PUT',
+	});
+
+	me.callParent();
+
+	let grid = me.down('#flaggrid');
+	me.load({
+	    success: function(response, options) {
+		let data = response.result.data;
+		grid.getStore().setData(data);
+		// re-align after store load, else the window is not centered
+		me.alignTo(Ext.getBody(), 'c-c');
+	    },
+	});
+    },
+});
+
+Ext.define('PVE.node.CephOsdTree', {
+    extend: 'Ext.tree.Panel',
+    alias: ['widget.pveNodeCephOsdTree'],
+    onlineHelp: 'chapter_pveceph',
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    flags: [],
+	    maxversion: '0',
+	    mixedversions: false,
+	    versions: {},
+	    isOsd: false,
+	    downOsd: false,
+	    upOsd: false,
+	    inOsd: false,
+	    outOsd: false,
+	    osdid: '',
+	    osdhost: '',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let vm = me.getViewModel();
+	    let nodename = vm.get('nodename');
+	    let sm = view.getSelectionModel();
+	    Proxmox.Utils.API2Request({
+                url: "/nodes/" + nodename + "/ceph/osd",
+		waitMsgTarget: view,
+		method: 'GET',
+		failure: function(response, opts) {
+		    let msg = response.htmlStatus;
+		    PVE.Utils.showCephInstallOrMask(view, msg, nodename, win =>
+			view.mon(win, 'cephInstallWindowClosed', () => { me.reload(); }),
+		    );
+		},
+		success: function(response, opts) {
+		    let data = response.result.data;
+		    let selected = view.getSelection();
+		    let name;
+		    if (selected.length) {
+			name = selected[0].data.name;
+		    }
+		    data.versions = data.versions || {};
+		    vm.set('versions', data.versions);
+		    // extract max version
+		    let maxversion = "0";
+		    let mixedversions = false;
+		    let traverse;
+		    traverse = function(node, fn) {
+			fn(node);
+			if (Array.isArray(node.children)) {
+			    node.children.forEach(c => { traverse(c, fn); });
+			}
+		    };
+		    traverse(data.root, node => {
+			// compatibility for old api call
+			if (node.type === 'host' && !node.version) {
+			    node.version = data.versions[node.name];
+			}
+
+			if (node.version === undefined) {
+			    return;
+			}
+
+			if (PVE.Utils.compare_ceph_versions(node.version, maxversion) !== 0 && maxversion !== "0") {
+			    mixedversions = true;
+			}
+
+			if (PVE.Utils.compare_ceph_versions(node.version, maxversion) > 0) {
+			    maxversion = node.version;
+			}
+		    });
+		    vm.set('maxversion', maxversion);
+		    vm.set('mixedversions', mixedversions);
+		    sm.deselectAll();
+		    view.setRootNode(data.root);
+		    view.expandAll();
+		    if (name) {
+			let node = view.getRootNode().findChild('name', name, true);
+			if (node) {
+			    view.setSelection([node]);
+			}
+		    }
+
+		    let flags = data.flags.split(',');
+		    vm.set('flags', flags);
+		},
+	    });
+	},
+
+	osd_cmd: function(comp) {
+	    let me = this;
+	    let vm = this.getViewModel();
+	    let cmd = comp.cmd;
+	    let params = comp.params || {};
+	    let osdid = vm.get('osdid');
+
+	    let doRequest = function() {
+		let targetnode = vm.get('osdhost');
+		// cmds not node specific and need to work if the OSD node is down
+		if (['in', 'out'].includes(cmd)) {
+		    targetnode = vm.get('nodename');
+		}
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${targetnode}/ceph/osd/${osdid}/${cmd}`,
+		    waitMsgTarget: me.getView(),
+		    method: 'POST',
+		    params: params,
+		    success: () => { me.reload(); },
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    },
+		});
+	    };
+
+	    if (cmd === 'scrub') {
+		Ext.MessageBox.defaultButton = params.deep === 1 ? 2 : 1;
+		Ext.Msg.show({
+		    title: gettext('Confirm'),
+		    icon: params.deep === 1 ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
+		    msg: params.deep !== 1
+		       ? Ext.String.format(gettext("Scrub OSD.{0}"), osdid)
+		       : Ext.String.format(gettext("Deep Scrub OSD.{0}"), osdid) +
+			   "<br>Caution: This can reduce performance while it is running.",
+		    buttons: Ext.Msg.YESNO,
+		    callback: function(btn) {
+			if (btn !== 'yes') {
+			    return;
+			}
+			doRequest();
+		    },
+		});
+	    } else {
+		doRequest();
+	    }
+	},
+
+	create_osd: function() {
+	    let me = this;
+	    let vm = this.getViewModel();
+	    Ext.create('PVE.CephCreateOsd', {
+		nodename: vm.get('nodename'),
+		taskDone: () => { me.reload(); },
+	    }).show();
+	},
+
+	destroy_osd: async function() {
+	    let me = this;
+	    let vm = this.getViewModel();
+
+	    let warnings = {
+		flags: false,
+		degraded: false,
+	    };
+
+	    let flagsPromise = Proxmox.Async.api2({
+		url: `/cluster/ceph/flags`,
+		method: 'GET',
+	    });
+
+	    let statusPromise = Proxmox.Async.api2({
+		url: `/cluster/ceph/status`,
+		method: 'GET',
+	    });
+
+	    me.getView().mask(gettext('Loading...'));
+
+	    try {
+		let result = await Promise.all([flagsPromise, statusPromise]);
+
+		let flagsData = result[0].result.data;
+		let statusData = result[1].result.data;
+
+		let flags = Array.from(
+		    flagsData.filter(v => v.value),
+		    v => v.name,
+		).filter(v => ['norebalance', 'norecover', 'noout'].includes(v));
+
+		if (flags.length) {
+		    warnings.flags = true;
+		}
+		if (Object.keys(statusData.pgmap).includes('degraded_objects')) {
+		    warnings.degraded = true;
+		}
+	    } catch (error) {
+		Ext.Msg.alert(gettext('Error'), error.htmlStatus);
+		me.getView().unmask();
+		return;
+	    }
+
+	    me.getView().unmask();
+	    Ext.create('PVE.CephRemoveOsd', {
+		nodename: vm.get('osdhost'),
+		osdid: vm.get('osdid'),
+		warnings: warnings,
+		taskDone: () => { me.reload(); },
+		autoShow: true,
+	    });
+	},
+
+	set_flags: function() {
+	    let me = this;
+	    let vm = this.getViewModel();
+	    Ext.create('PVE.CephSetFlags', {
+		nodename: vm.get('nodename'),
+		taskDone: () => { me.reload(); },
+	    }).show();
+	},
+
+	service_cmd: function(comp) {
+	    let me = this;
+	    let vm = this.getViewModel();
+	    let cmd = comp.cmd || comp;
+
+	    let doRequest = function() {
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${vm.get('osdhost')}/ceph/${cmd}`,
+		    params: { service: "osd." + vm.get('osdid') },
+		    waitMsgTarget: me.getView(),
+		    method: 'POST',
+		    success: function(response, options) {
+			let upid = response.result.data;
+			let win = Ext.create('Proxmox.window.TaskProgress', {
+			    upid: upid,
+			    taskDone: () => { me.reload(); },
+			});
+			win.show();
+		    },
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    },
+		});
+	    };
+
+	    if (cmd === "stop") {
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${vm.get('osdhost')}/ceph/cmd-safety`,
+		    params: {
+			service: 'osd',
+			id: vm.get('osdid'),
+			action: 'stop',
+		    },
+		    waitMsgTarget: me.getView(),
+		    method: 'GET',
+		    success: function({ result: { data } }) {
+			if (!data.safe) {
+			    Ext.Msg.show({
+				title: gettext('Warning'),
+				message: data.status,
+				icon: Ext.Msg.WARNING,
+				buttons: Ext.Msg.OKCANCEL,
+				buttonText: { ok: gettext('Stop OSD') },
+				fn: function(selection) {
+				    if (selection === 'ok') {
+					doRequest();
+				    }
+				},
+			    });
+			} else {
+			    doRequest();
+			}
+		    },
+		    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		});
+	    } else {
+		doRequest();
+	    }
+	},
+
+	run_details: function(view, rec) {
+	    if (rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0) {
+		this.details();
+	    }
+	},
+
+	details: function() {
+	    let vm = this.getViewModel();
+	    Ext.create('PVE.CephOsdDetails', {
+		nodename: vm.get('osdhost'),
+		osdid: vm.get('osdid'),
+	    }).show();
+	},
+
+	set_selection_status: function(tp, selection) {
+	    if (selection.length < 1) {
+		return;
+	    }
+	    let rec = selection[0];
+	    let vm = this.getViewModel();
+
+	    let isOsd = rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0;
+
+	    vm.set('isOsd', isOsd);
+	    vm.set('downOsd', isOsd && rec.data.status === 'down');
+	    vm.set('upOsd', isOsd && rec.data.status !== 'down');
+	    vm.set('inOsd', isOsd && rec.data.in);
+	    vm.set('outOsd', isOsd && !rec.data.in);
+	    vm.set('osdid', isOsd ? rec.data.id : undefined);
+	    vm.set('osdhost', isOsd ? rec.data.host : undefined);
+	},
+
+	render_status: function(value, metaData, rec) {
+	    if (!value) {
+		return value;
+	    }
+	    let inout = rec.data.in ? 'in' : 'out';
+	    let updownicon = value === 'up' ? 'good fa-arrow-circle-up'
+		: 'critical fa-arrow-circle-down';
+
+	    let inouticon = rec.data.in ? 'good fa-circle'
+		: 'warning fa-circle-o';
+
+	    let text = value + ' <i class="fa ' + updownicon + '"></i> / ' +
+		inout + ' <i class="fa ' + inouticon + '"></i>';
+
+	    return text;
+	},
+
+	render_wal: function(value, metaData, rec) {
+	    if (!value &&
+		rec.data.osdtype === 'bluestore' &&
+		rec.data.type === 'osd') {
+		return 'N/A';
+	    }
+	    return value;
+	},
+
+	render_version: function(value, metadata, rec) {
+	    let vm = this.getViewModel();
+	    let versions = vm.get('versions');
+	    let icon = "";
+	    let version = value || "";
+	    let maxversion = vm.get('maxversion');
+	    if (value && PVE.Utils.compare_ceph_versions(value, maxversion) !== 0) {
+		let host_version = rec.parentNode?.data?.version || versions[rec.data.host] || "";
+		if (rec.data.type === 'host' || PVE.Utils.compare_ceph_versions(host_version, maxversion) !== 0) {
+		    icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE');
+		} else {
+		    icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD');
+		}
+	    } else if (value && vm.get('mixedversions')) {
+		icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK');
+	    }
+
+	    return icon + version;
+	},
+
+	render_osd_val: function(value, metaData, rec) {
+	    return rec.data.type === 'osd' ? value : '';
+	},
+	render_osd_weight: function(value, metaData, rec) {
+	    if (rec.data.type !== 'osd') {
+		return '';
+	    }
+	    return Ext.util.Format.number(value, '0.00###');
+	},
+
+	render_osd_latency: function(value, metaData, rec) {
+	    if (rec.data.type !== 'osd') {
+		return '';
+	    }
+	    let commit_ms = rec.data.commit_latency_ms,
+	        apply_ms = rec.data.apply_latency_ms;
+	    return apply_ms + ' / ' + commit_ms;
+	},
+
+	render_osd_size: function(value, metaData, rec) {
+	    return this.render_osd_val(Proxmox.Utils.render_size(value), metaData, rec);
+	},
+
+	control: {
+	    '#': {
+		selectionchange: 'set_selection_status',
+	    },
+	},
+
+	init: function(view) {
+	    let me = this;
+	    let vm = this.getViewModel();
+
+	    if (!view.pveSelNode.data.node) {
+		throw "no node name specified";
+	    }
+
+	    vm.set('nodename', view.pveSelNode.data.node);
+
+	    me.callParent();
+	    me.reload();
+	},
+    },
+
+    stateful: true,
+    stateId: 'grid-ceph-osd',
+    rootVisible: false,
+    useArrows: true,
+    listeners: {
+	itemdblclick: 'run_details',
+    },
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: 'Name',
+	    dataIndex: 'name',
+	    width: 150,
+	},
+	{
+	    text: 'Type',
+	    dataIndex: 'type',
+	    hidden: true,
+	    align: 'right',
+	    width: 75,
+	},
+	{
+	    text: gettext("Class"),
+	    dataIndex: 'device_class',
+	    align: 'right',
+	    width: 75,
+	},
+	{
+	    text: "OSD Type",
+	    dataIndex: 'osdtype',
+	    align: 'right',
+	    width: 100,
+	},
+	{
+	    text: "Bluestore Device",
+	    dataIndex: 'blfsdev',
+	    align: 'right',
+	    width: 75,
+	    hidden: true,
+	},
+	{
+	    text: "DB Device",
+	    dataIndex: 'dbdev',
+	    align: 'right',
+	    width: 75,
+	    hidden: true,
+	},
+	{
+	    text: "WAL Device",
+	    dataIndex: 'waldev',
+	    align: 'right',
+	    renderer: 'render_wal',
+	    width: 75,
+	    hidden: true,
+	},
+	{
+	    text: 'Status',
+	    dataIndex: 'status',
+	    align: 'right',
+	    renderer: 'render_status',
+	    width: 120,
+	},
+	{
+	    text: gettext('Version'),
+	    dataIndex: 'version',
+	    align: 'right',
+	    renderer: 'render_version',
+	},
+	{
+	    text: 'weight',
+	    dataIndex: 'crush_weight',
+	    align: 'right',
+	    renderer: 'render_osd_weight',
+	    width: 90,
+	},
+	{
+	    text: 'reweight',
+	    dataIndex: 'reweight',
+	    align: 'right',
+	    renderer: 'render_osd_weight',
+	    width: 90,
+	},
+	{
+	    text: gettext('Used') + ' (%)',
+	    dataIndex: 'percent_used',
+	    align: 'right',
+	    renderer: function(value, metaData, rec) {
+		if (rec.data.type !== 'osd') {
+		    return '';
+		}
+		return Ext.util.Format.number(value, '0.00');
+	    },
+	    width: 100,
+	},
+	{
+	    text: gettext('Total'),
+	    dataIndex: 'total_space',
+	    align: 'right',
+	    renderer: 'render_osd_size',
+	    width: 100,
+	},
+	{
+	    text: 'Apply/Commit<br>Latency (ms)',
+	    dataIndex: 'apply_latency_ms',
+	    align: 'right',
+	    renderer: 'render_osd_latency',
+	    width: 120,
+	},
+	{
+	    text: 'PGs',
+	    dataIndex: 'pgs',
+	    align: 'right',
+	    renderer: 'render_osd_val',
+	    width: 90,
+	},
+    ],
+
+
+    tbar: {
+	items: [
+	    {
+		text: gettext('Reload'),
+		iconCls: 'fa fa-refresh',
+		handler: 'reload',
+	    },
+	    '-',
+	    {
+		text: gettext('Create') + ': OSD',
+		handler: 'create_osd',
+	    },
+	    {
+		text: Ext.String.format(gettext('Manage {0}'), 'Global Flags'),
+		handler: 'set_flags',
+	    },
+	    '->',
+	    {
+		xtype: 'tbtext',
+		data: {
+		    osd: undefined,
+		},
+		bind: {
+		    data: {
+			osd: "{osdid}",
+		    },
+		},
+		tpl: [
+		    '<tpl if="osd">',
+		    'osd.{osd}:',
+		    '<tpl else>',
+		    gettext('No OSD selected'),
+		    '</tpl>',
+		],
+	    },
+	    {
+		text: gettext('Details'),
+		iconCls: 'fa fa-info-circle',
+		disabled: true,
+		bind: {
+		    disabled: '{!isOsd}',
+		},
+		handler: 'details',
+	    },
+	    {
+		text: gettext('Start'),
+		iconCls: 'fa fa-play',
+		disabled: true,
+		bind: {
+		    disabled: '{!downOsd}',
+		},
+		cmd: 'start',
+		handler: 'service_cmd',
+	    },
+	    {
+		text: gettext('Stop'),
+		iconCls: 'fa fa-stop',
+		disabled: true,
+		bind: {
+		    disabled: '{!upOsd}',
+		},
+		cmd: 'stop',
+		handler: 'service_cmd',
+	    },
+	    {
+		text: gettext('Restart'),
+		iconCls: 'fa fa-refresh',
+		disabled: true,
+		bind: {
+		    disabled: '{!upOsd}',
+		},
+		cmd: 'restart',
+		handler: 'service_cmd',
+	    },
+	    '-',
+	    {
+		text: 'Out',
+		iconCls: 'fa fa-circle-o',
+		disabled: true,
+		bind: {
+		    disabled: '{!inOsd}',
+		},
+		cmd: 'out',
+		handler: 'osd_cmd',
+	    },
+	    {
+		text: 'In',
+		iconCls: 'fa fa-circle',
+		disabled: true,
+		bind: {
+		    disabled: '{!outOsd}',
+		},
+		cmd: 'in',
+		handler: 'osd_cmd',
+	    },
+	    '-',
+	    {
+		text: gettext('More'),
+		iconCls: 'fa fa-bars',
+		disabled: true,
+		bind: {
+		    disabled: '{!isOsd}',
+		},
+		menu: [
+		    {
+			text: gettext('Scrub'),
+			iconCls: 'fa fa-shower',
+			cmd: 'scrub',
+			handler: 'osd_cmd',
+		    },
+		    {
+			text: gettext('Deep Scrub'),
+			iconCls: 'fa fa-bath',
+			cmd: 'scrub',
+			params: {
+			    deep: 1,
+			},
+			handler: 'osd_cmd',
+		    },
+		    {
+			text: gettext('Destroy'),
+			itemId: 'remove',
+			iconCls: 'fa fa-fw fa-trash-o',
+			bind: {
+			    disabled: '{!downOsd}',
+			},
+			handler: 'destroy_osd',
+		    },
+		],
+	    },
+	],
+    },
+
+    fields: [
+	'name', 'type', 'status', 'host', 'in', 'id',
+	{ type: 'number', name: 'reweight' },
+	{ type: 'number', name: 'percent_used' },
+	{ type: 'integer', name: 'bytes_used' },
+	{ type: 'integer', name: 'total_space' },
+	{ type: 'integer', name: 'apply_latency_ms' },
+	{ type: 'integer', name: 'commit_latency_ms' },
+	{ type: 'string', name: 'device_class' },
+	{ type: 'string', name: 'osdtype' },
+	{ type: 'string', name: 'blfsdev' },
+	{ type: 'string', name: 'dbdev' },
+	{ type: 'string', name: 'waldev' },
+	{
+ type: 'string', name: 'version', calculate: function(data) {
+	    return PVE.Utils.parse_ceph_version(data);
+	},
+},
+	{
+ type: 'string', name: 'iconCls', calculate: function(data) {
+	    let iconMap = {
+		host: 'fa-building',
+		osd: 'fa-hdd-o',
+		root: 'fa-server',
+	    };
+	    return `fa x-fa-tree ${iconMap[data.type] ?? 'fa-folder-o'}`;
+	},
+},
+	{ type: 'number', name: 'crush_weight' },
+    ],
+});
+Ext.define('pve-osd-details-devices', {
+    extend: 'Ext.data.Model',
+    fields: ['device', 'type', 'physical_device', 'size', 'support_discard', 'dev_node'],
+    idProperty: 'device',
+});
+
+Ext.define('PVE.CephOsdDetails', {
+    extend: 'Ext.window.Window',
+    alias: ['widget.pveCephOsdDetails'],
+
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function() {
+	let me = this;
+	me.baseUrl = `/nodes/${me.nodename}/ceph/osd/${me.osdid}`;
+	return {
+	    title: `${gettext('Details')}: OSD ${me.osdid}`,
+	};
+    },
+
+    viewModel: {
+	data: {
+	    device: '',
+	},
+    },
+
+    modal: true,
+    width: 650,
+    minHeight: 250,
+    resizable: true,
+    cbind: {
+	title: '{title}',
+    },
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+    defaults: {
+	layout: 'fit',
+	border: false,
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	reload: function() {
+	    let view = this.getView();
+
+	    Proxmox.Utils.API2Request({
+		url: `${view.baseUrl}/metadata`,
+		waitMsgTarget: view.lookup('detailsTabs'),
+		method: 'GET',
+		failure: function(response, opts) {
+		    Proxmox.Utils.setErrorMask(view.lookup('detailsTabs'), response.htmlStatus);
+		},
+		success: function(response, opts) {
+		    let d = response.result.data;
+		    let osdData = Object.keys(d.osd).sort().map(x => ({ key: x, value: d.osd[x] }));
+		    view.osdStore.loadData(osdData);
+		    let devices = view.lookup('devices');
+		    let deviceStore = devices.getStore();
+		    deviceStore.loadData(d.devices);
+
+		    view.lookup('osdGeneral').rstore.fireEvent('load', view.osdStore, osdData, true);
+		    view.lookup('osdNetwork').rstore.fireEvent('load', view.osdStore, osdData, true);
+
+		    // select 'block' device automatically on first load
+		    if (devices.getSelection().length === 0) {
+			devices.setSelection(deviceStore.findRecord('device', 'block'));
+		    }
+		},
+	    });
+	},
+
+	showDevInfo: function(grid, selected) {
+	    let view = this.getView();
+	    if (selected[0]) {
+		let device = selected[0].data.device;
+		this.getViewModel().set('device', device);
+
+		let detailStore = view.lookup('volumeDetails');
+		detailStore.rstore.getProxy().setUrl(`api2/json${view.baseUrl}/lv-info`);
+		detailStore.rstore.getProxy().setExtraParams({ 'type': device });
+		detailStore.setLoading();
+		detailStore.rstore.load({ callback: () => detailStore.setLoading(false) });
+	    }
+	},
+
+	init: function() {
+	    this.reload();
+	},
+
+	control: {
+	    'grid[reference=devices]': {
+		selectionchange: 'showDevInfo',
+	    },
+	},
+    },
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: 'reload',
+	},
+    ],
+    initComponent: function() {
+        let me = this;
+
+	me.osdStore = Ext.create('Proxmox.data.ObjectStore');
+
+	Ext.applyIf(me, {
+	    items: [
+		{
+		    xtype: 'tabpanel',
+		    reference: 'detailsTabs',
+		    items: [
+			{
+			    xtype: 'proxmoxObjectGrid',
+			    reference: 'osdGeneral',
+			    tooltip: gettext('Various information about the OSD'),
+			    rstore: me.osdStore,
+			    title: gettext('General'),
+			    viewConfig: {
+				enableTextSelection: true,
+			    },
+			    gridRows: [
+				{
+				    xtype: 'text',
+				    name: 'version',
+				    text: gettext('Version'),
+				},
+				{
+				    xtype: 'text',
+				    name: 'hostname',
+				    text: gettext('Hostname'),
+				},
+				{
+				    xtype: 'text',
+				    name: 'osd_data',
+				    text: gettext('OSD data path'),
+				},
+				{
+				    xtype: 'text',
+				    name: 'osd_objectstore',
+				    text: gettext('OSD object store'),
+				},
+				{
+				    xtype: 'text',
+				    name: 'mem_usage',
+				    text: gettext('Memory usage (PSS)'),
+				    renderer: Proxmox.Utils.render_size,
+				},
+				{
+				    xtype: 'text',
+				    name: 'pid',
+				    text: `${gettext('Process ID')} (PID)`,
+				},
+			    ],
+			},
+			{
+			    xtype: 'proxmoxObjectGrid',
+			    reference: 'osdNetwork',
+			    tooltip: gettext('Addresses and ports used by the OSD service'),
+			    rstore: me.osdStore,
+			    title: gettext('Network'),
+			    viewConfig: {
+				enableTextSelection: true,
+			    },
+			    gridRows: [
+				{
+				    xtype: 'text',
+				    name: 'front_addr',
+				    text: `${gettext('Front Address')}<br>(Client & Monitor)`,
+				    renderer: PVE.Utils.render_ceph_osd_addr,
+				},
+				{
+				    xtype: 'text',
+				    name: 'hb_front_addr',
+				    text: gettext('Heartbeat Front Address'),
+				    renderer: PVE.Utils.render_ceph_osd_addr,
+				},
+				{
+				    xtype: 'text',
+				    name: 'back_addr',
+				    text: `${gettext('Back Address')}<br>(OSD)`,
+				    renderer: PVE.Utils.render_ceph_osd_addr,
+				},
+				{
+				    xtype: 'text',
+				    name: 'hb_back_addr',
+				    text: gettext('Heartbeat Back Address'),
+				    renderer: PVE.Utils.render_ceph_osd_addr,
+				},
+			    ],
+			},
+			{
+			    xtype: 'panel',
+			    title: gettext('Devices'),
+			    tooltip: gettext('Physical devices used by the OSD'),
+			    items: [
+				{
+				    xtype: 'grid',
+				    border: false,
+				    reference: 'devices',
+				    store: {
+					model: 'pve-osd-details-devices',
+				    },
+				    columns: {
+					items: [
+					    { text: gettext('Device'), dataIndex: 'device' },
+					    { text: gettext('Type'), dataIndex: 'type' },
+					    {
+						text: gettext('Physical Device'),
+						dataIndex: 'physical_device',
+					    },
+					    {
+						text: gettext('Size'),
+						dataIndex: 'size',
+						renderer: Proxmox.Utils.render_size,
+					    },
+					    {
+						text: 'Discard',
+						dataIndex: 'support_discard',
+						hidden: true,
+					    },
+					    {
+						text: gettext('Device node'),
+						dataIndex: 'dev_node',
+						hidden: true,
+					    },
+					],
+					defaults: {
+					    tdCls: 'pointer',
+					    flex: 1,
+					},
+				    },
+				},
+				{
+				    xtype: 'proxmoxObjectGrid',
+				    reference: 'volumeDetails',
+				    maskOnLoad: true,
+				    viewConfig: {
+					enableTextSelection: true,
+				    },
+				    bind: {
+					title: Ext.String.format(
+						gettext('Volume Details for {0}'),
+						'{device}',
+					),
+				    },
+				    rows: {
+					creation_time: {
+					    header: gettext('Creation time'),
+					},
+					lv_name: {
+					    header: gettext('LV Name'),
+					},
+					lv_path: {
+					    header: gettext('LV Path'),
+					},
+					lv_uuid: {
+					    header: gettext('LV UUID'),
+					},
+					vg_name: {
+					    header: gettext('VG Name'),
+					},
+				    },
+				    url: 'nodes/', //placeholder will be set when device is selected
+				},
+			    ],
+			},
+		    ],
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.CephPoolInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveCephPoolInputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    showProgress: true,
+    onlineHelp: 'pve_ceph_pools',
+
+    subject: 'Ceph Pool',
+
+    defaultSize: undefined,
+    defaultMinSize: undefined,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let vm = this.getViewModel();
+	    vm.set('size', Number(view.defaultSize));
+	    vm.set('minSize', Number(view.defaultMinSize));
+	},
+	sizeChange: function(field, val) {
+	    let vm = this.getViewModel();
+	    let minSize = Math.round(val / 2);
+	    if (minSize > 1) {
+		vm.set('minSize', minSize);
+	    }
+	    vm.set('size', val); // bind does not work in a pmxDisplayEditField, update manually
+	},
+    },
+
+    viewModel: {
+	data: {
+	    minSize: null,
+	    size: null,
+	},
+	formulas: {
+	    minSizeLabel: (get) => {
+		if (get('showMinSizeOneWarning') || get('showMinSizeHalfWarning')) {
+		    return `${gettext('Min. Size')} <i class="fa fa-exclamation-triangle warning"></i>`;
+		}
+		return gettext('Min. Size');
+	    },
+	    showMinSizeOneWarning: (get) => get('minSize') === 1,
+	    showMinSizeHalfWarning: (get) => {
+		let minSize = get('minSize');
+		let size = get('size');
+		if (minSize === 1) {
+		    return false;
+		}
+		return minSize < (size / 2) && minSize !== size;
+	    },
+	},
+    },
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    fieldLabel: gettext('Name'),
+	    cbind: {
+		editable: '{isCreate}',
+		value: '{pool_name}',
+	    },
+	    name: 'name',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'pmxDisplayEditField',
+	    cbind: {
+		editable: '{!isErasure}',
+	    },
+	    fieldLabel: gettext('Size'),
+	    name: 'size',
+	    editConfig: {
+		xtype: 'proxmoxintegerfield',
+		cbind: {
+		    value: (get) => get('defaultSize'),
+		},
+		minValue: 2,
+		maxValue: 7,
+		allowBlank: false,
+		listeners: {
+		    change: 'sizeChange',
+		},
+	    },
+	},
+    ],
+    column2: [
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    fieldLabel: 'PG Autoscale Mode',
+	    name: 'pg_autoscale_mode',
+	    comboItems: [
+		['warn', 'warn'],
+		['on', 'on'],
+		['off', 'off'],
+	    ],
+	    value: 'on', // FIXME: check ceph version and only default to on on octopus and newer
+	    allowBlank: false,
+	    autoSelect: false,
+	    labelWidth: 140,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Add as Storage'),
+	    cbind: {
+		value: '{isCreate}',
+		hidden: '{!isCreate}',
+	    },
+	    name: 'add_storages',
+	    labelWidth: 140,
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Add the new pool to the cluster storage configuration.'),
+	    },
+	},
+    ],
+    advancedColumn1: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    bind: {
+		fieldLabel: '{minSizeLabel}',
+		value: '{minSize}',
+	    },
+	    name: 'min_size',
+	    cbind: {
+		value: (get) => get('defaultMinSize'),
+		minValue: (get) => {
+		    if (Number(get('defaultMinSize')) === 1) {
+			return 1;
+		    } else {
+			return get('isCreate') ? 2 : 1;
+		    }
+		},
+	    },
+	    maxValue: 7,
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'displayfield',
+	    bind: {
+		hidden: '{!showMinSizeHalfWarning}',
+	    },
+	    hidden: true,
+	    userCls: 'pmx-hint',
+	    value: gettext('min_size < size/2 can lead to data loss, incomplete PGs or unfound objects.'),
+	},
+	{
+	    xtype: 'displayfield',
+	    bind: {
+		hidden: '{!showMinSizeOneWarning}',
+	    },
+	    hidden: true,
+	    userCls: 'pmx-hint',
+	    value: gettext('a min_size of 1 is not recommended and can lead to data loss'),
+	},
+	{
+	    xtype: 'pmxDisplayEditField',
+	    cbind: {
+		editable: '{!isErasure}',
+		nodename: '{nodename}',
+		isCreate: '{isCreate}',
+	    },
+	    fieldLabel: 'Crush Rule', // do not localize
+	    name: 'crush_rule',
+	    editConfig: {
+		xtype: 'pveCephRuleSelector',
+		allowBlank: false,
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    fieldLabel: '# of PGs',
+	    name: 'pg_num',
+	    value: 128,
+	    minValue: 1,
+	    maxValue: 32768,
+	    allowBlank: false,
+	    emptyText: 128,
+	},
+    ],
+    advancedColumn2: [
+	{
+	    xtype: 'numberfield',
+	    fieldLabel: gettext('Target Ratio'),
+	    name: 'target_size_ratio',
+	    minValue: 0,
+	    decimalPrecision: 3,
+	    allowBlank: true,
+	    emptyText: '0.0',
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('The ratio of storage amount this pool will consume compared to other pools with ratios. Used for auto-scaling.'),
+	    },
+	},
+	{
+	    xtype: 'pveSizeField',
+	    name: 'target_size',
+	    fieldLabel: gettext('Target Size'),
+	    unit: 'GiB',
+	    minValue: 0,
+	    allowBlank: true,
+	    allowZero: true,
+	    emptyText: '0',
+	    emptyValue: 0,
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('The amount of data eventually stored in this pool. Used for auto-scaling.'),
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    userCls: 'pmx-hint',
+	    value: Ext.String.format(gettext('{0} takes precedence.'), gettext('Target Ratio')), // FIXME: tooltip?
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    fieldLabel: 'Min. # of PGs',
+	    name: 'pg_num_min',
+	    minValue: 0,
+	    allowBlank: true,
+	    emptyText: '0',
+	},
+    ],
+
+    onGetValues: function(values) {
+	Object.keys(values || {}).forEach(function(name) {
+	    if (values[name] === '') {
+		delete values[name];
+	    }
+	});
+
+	return values;
+    },
+});
+
+Ext.define('PVE.Ceph.PoolEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveCephPoolEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: {
+	pool_name: '',
+	isCreate: (cfg) => !cfg.pool_name,
+	defaultSize: undefined,
+	defaultMinSize: undefined,
+    },
+
+    cbind: {
+	autoLoad: get => !get('isCreate'),
+	url: get => get('isCreate')
+	    ? `/nodes/${get('nodename')}/ceph/pool`
+	    : `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}`,
+	loadUrl: get => `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}/status`,
+	method: get => get('isCreate') ? 'POST' : 'PUT',
+    },
+
+    showProgress: true,
+
+    subject: gettext('Ceph Pool'),
+
+    items: [{
+	xtype: 'pveCephPoolInputPanel',
+	cbind: {
+	    nodename: '{nodename}',
+	    pool_name: '{pool_name}',
+	    isErasure: '{isErasure}',
+	    isCreate: '{isCreate}',
+	    defaultSize: '{defaultSize}',
+	    defaultMinSize: '{defaultMinSize}',
+	},
+    }],
+});
+
+Ext.define('PVE.node.Ceph.PoolList', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveNodeCephPoolList',
+
+    onlineHelp: 'chapter_pveceph',
+
+    stateful: true,
+    stateId: 'grid-ceph-pools',
+    bufferedRenderer: false,
+
+    features: [{ ftype: 'summary' }],
+
+    columns: [
+	{
+	    text: gettext('Pool #'),
+	    minWidth: 70,
+	    flex: 1,
+	    align: 'right',
+	    sortable: true,
+	    dataIndex: 'pool',
+	},
+	{
+	    text: gettext('Name'),
+	    minWidth: 120,
+	    flex: 2,
+	    sortable: true,
+	    dataIndex: 'pool_name',
+	},
+	{
+	    text: gettext('Type'),
+	    minWidth: 100,
+	    flex: 1,
+	    dataIndex: 'type',
+	    hidden: true,
+	},
+	{
+	    text: gettext('Size') + '/min',
+	    minWidth: 100,
+	    flex: 1,
+	    align: 'right',
+	    renderer: (v, meta, rec) => `${v}/${rec.data.min_size}`,
+	    dataIndex: 'size',
+	},
+	{
+	    text: '# of Placement Groups',
+	    flex: 1,
+	    minWidth: 100,
+	    align: 'right',
+	    dataIndex: 'pg_num',
+	},
+	{
+	    text: gettext('Optimal # of PGs'),
+	    flex: 1,
+	    minWidth: 100,
+	    align: 'right',
+	    dataIndex: 'pg_num_final',
+	    renderer: function(value, metaData) {
+		if (!value) {
+		    value = '<i class="fa fa-info-circle faded"></i> n/a';
+		    metaData.tdAttr = 'data-qtip="Needs pg_autoscaler module enabled."';
+		}
+		return value;
+	    },
+	},
+	{
+	    text: gettext('Min. # of PGs'),
+	    flex: 1,
+	    minWidth: 100,
+	    align: 'right',
+	    dataIndex: 'pg_num_min',
+	    hidden: true,
+	},
+	{
+	    text: gettext('Target Ratio'),
+	    flex: 1,
+	    minWidth: 100,
+	    align: 'right',
+	    dataIndex: 'target_size_ratio',
+	    renderer: Ext.util.Format.numberRenderer('0.0000'),
+	    hidden: true,
+	},
+	{
+	    text: gettext('Target Size'),
+	    flex: 1,
+	    minWidth: 100,
+	    align: 'right',
+	    dataIndex: 'target_size',
+	    hidden: true,
+	    renderer: function(v, metaData, rec) {
+		let value = Proxmox.Utils.render_size(v);
+		if (rec.data.target_size_ratio > 0) {
+		    value = '<i class="fa fa-info-circle faded"></i> ' + value;
+		    metaData.tdAttr = 'data-qtip="Target Size Ratio takes precedence over Target Size."';
+		}
+		return value;
+	    },
+	},
+	{
+	    text: gettext('Autoscale Mode'),
+	    flex: 1,
+	    minWidth: 100,
+	    align: 'right',
+	    dataIndex: 'pg_autoscale_mode',
+	},
+	{
+	    text: 'CRUSH Rule (ID)',
+	    flex: 1,
+	    align: 'right',
+	    minWidth: 150,
+	    renderer: (v, meta, rec) => `${v} (${rec.data.crush_rule})`,
+	    dataIndex: 'crush_rule_name',
+	},
+	{
+	    text: gettext('Used') + ' (%)',
+	    flex: 1,
+	    minWidth: 150,
+	    sortable: true,
+	    align: 'right',
+	    dataIndex: 'bytes_used',
+	    summaryType: 'sum',
+	    summaryRenderer: Proxmox.Utils.render_size,
+	    renderer: function(v, meta, rec) {
+		let percentage = Ext.util.Format.percent(rec.data.percent_used, '0.00');
+		let used = Proxmox.Utils.render_size(v);
+		return `${used} (${percentage})`;
+	    },
+	},
+    ],
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 3000,
+	    storeid: 'ceph-pool-list' + nodename,
+	    model: 'ceph-pool-list',
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${nodename}/ceph/pool`,
+	    },
+	});
+	let store = Ext.create('Proxmox.data.DiffStore', { rstore: rstore });
+
+	// manages the "install ceph?" overlay
+	PVE.Utils.monitor_ceph_installed(me, rstore, nodename);
+
+	var run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec || !rec.data.pool_name) {
+		return;
+	    }
+	    Ext.create('PVE.Ceph.PoolEdit', {
+		title: gettext('Edit') + ': Ceph Pool',
+		nodename: nodename,
+		pool_name: rec.data.pool_name,
+		isErasure: rec.data.type === 'erasure',
+		autoShow: true,
+		listeners: {
+		    destroy: () => rstore.load(),
+		},
+	    });
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		{
+		    text: gettext('Create'),
+		    handler: function() {
+			let keys = [
+			    'global:osd-pool-default-min-size',
+			    'global:osd-pool-default-size',
+			];
+			let params = {
+			    'config-keys': keys.join(';'),
+			};
+
+			Proxmox.Utils.API2Request({
+			    url: '/nodes/localhost/ceph/cfg/value',
+			    method: 'GET',
+			    params,
+			    waitMsgTarget: me.getView(),
+			    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+			    success: function({ result: { data } }) {
+				let global = data.global;
+				let defaultSize = global?.['osd-pool-default-size'] ?? 3;
+				let defaultMinSize = global?.['osd-pool-default-min-size'] ?? 2;
+
+				Ext.create('PVE.Ceph.PoolEdit', {
+				    title: gettext('Create') + ': Ceph Pool',
+				    isCreate: true,
+				    isErasure: false,
+				    defaultSize,
+				    defaultMinSize,
+				    nodename: nodename,
+				    autoShow: true,
+				    listeners: {
+					destroy: () => rstore.load(),
+				    },
+				});
+			    },
+			});
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Edit'),
+		    selModel: sm,
+		    disabled: true,
+		    handler: run_editor,
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    text: gettext('Destroy'),
+		    selModel: sm,
+		    disabled: true,
+		    handler: function() {
+			let rec = sm.getSelection()[0];
+			if (!rec || !rec.data.pool_name) {
+			    return;
+			}
+			let poolName = rec.data.pool_name;
+			Ext.create('Proxmox.window.SafeDestroy', {
+			    showProgress: true,
+			    url: `/nodes/${nodename}/ceph/pool/${poolName}`,
+			    params: {
+				remove_storages: 1,
+			    },
+			    item: {
+				type: 'CephPool',
+				id: poolName,
+			    },
+			    taskName: 'cephdestroypool',
+			    autoShow: true,
+			    listeners: {
+				destroy: () => rstore.load(),
+			    },
+			});
+		    },
+		},
+	    ],
+	    listeners: {
+		activate: () => rstore.startUpdate(),
+		destroy: () => rstore.stopUpdate(),
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('ceph-pool-list', {
+	extend: 'Ext.data.Model',
+	fields: ['pool_name',
+		  { name: 'pool', type: 'integer' },
+		  { name: 'size', type: 'integer' },
+		  { name: 'min_size', type: 'integer' },
+		  { name: 'pg_num', type: 'integer' },
+		  { name: 'pg_num_min', type: 'integer' },
+		  { name: 'bytes_used', type: 'integer' },
+		  { name: 'percent_used', type: 'number' },
+		  { name: 'crush_rule', type: 'integer' },
+		  { name: 'crush_rule_name', type: 'string' },
+		  { name: 'pg_autoscale_mode', type: 'string' },
+		  { name: 'pg_num_final', type: 'integer' },
+		  { name: 'target_size_ratio', type: 'number' },
+		  { name: 'target_size', type: 'integer' },
+		],
+	idProperty: 'pool_name',
+    });
+});
+
+Ext.define('PVE.form.CephRuleSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveCephRuleSelector',
+
+    allowBlank: false,
+    valueField: 'name',
+    displayField: 'name',
+    editable: false,
+    queryMode: 'local',
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	me.originalAllowBlank = me.allowBlank;
+	me.allowBlank = true;
+
+	Ext.apply(me, {
+	    store: {
+		fields: ['name'],
+		sorters: 'name',
+		proxy: {
+		    type: 'proxmox',
+		    url: `/api2/json/nodes/${me.nodename}/ceph/rules`,
+		},
+		autoLoad: {
+		    callback: (records, op, success) => {
+			if (me.isCreate && success && records.length > 0) {
+			    me.select(records[0]);
+			}
+
+			me.allowBlank = me.originalAllowBlank;
+			delete me.originalAllowBlank;
+			me.validate();
+		    },
+		},
+	    },
+	});
+
+	me.callParent();
+    },
+
+});
+Ext.define('PVE.CephCreateService', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+    xtype: 'pveCephCreateService',
+
+    method: 'POST',
+    isCreate: true,
+    showProgress: true,
+    width: 450,
+
+    setNode: function(node) {
+	let me = this;
+	me.nodename = node;
+	me.updateUrl();
+    },
+    setExtraID: function(extraID) {
+	let me = this;
+	me.extraID = me.type === 'mds' ? `-${extraID}` : '';
+	me.updateUrl();
+    },
+    updateUrl: function() {
+	let me = this;
+
+	let extraID = me.extraID ?? '';
+	let node = me.nodename;
+
+	me.url = `/nodes/${node}/ceph/${me.type}/${node}${extraID}`;
+    },
+
+    defaults: {
+	labelWidth: 75,
+    },
+    items: [
+	{
+	    xtype: 'pveNodeSelector',
+	    fieldLabel: gettext('Host'),
+	    selectCurNode: true,
+	    allowBlank: false,
+	    submitValue: false,
+	    listeners: {
+		change: function(f, value) {
+		    let view = this.up('pveCephCreateService');
+		    view.setNode(value);
+		},
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    fieldLabel: gettext('Extra ID'),
+	    regex: /[a-zA-Z0-9]+/,
+	    regexText: gettext('ID may only consist of alphanumeric characters'),
+	    submitValue: false,
+	    emptyText: Proxmox.Utils.NoneText,
+	    cbind: {
+		disabled: get => get('type') !== 'mds',
+		hidden: get => get('type') !== 'mds',
+	    },
+	    listeners: {
+		change: function(f, value) {
+		    let view = this.up('pveCephCreateService');
+		    view.setExtraID(value);
+		},
+	    },
+	},
+	{
+	    xtype: 'component',
+	    border: false,
+	    padding: '5 2',
+	    style: {
+		fontSize: '12px',
+	    },
+	    userCls: 'pmx-hint',
+	    cbind: {
+		hidden: get => get('type') !== 'mds',
+	    },
+	    html: gettext('The Extra ID allows creating multiple MDS per node, which increases redundancy with more than one CephFS.'),
+	},
+    ],
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	if (!me.type) {
+	    throw "no type specified";
+	}
+	me.setNode(me.nodename);
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.node.CephServiceController', {
+    extend: 'Ext.app.ViewController',
+    alias: 'controller.CephServiceList',
+
+    render_status: (value, metadata, rec) => value,
+
+    render_version: function(value, metadata, rec) {
+	if (value === undefined) {
+	    return '';
+	}
+	let view = this.getView();
+	let host = rec.data.host, nodev = [0];
+	if (view.nodeversions[host] !== undefined) {
+	    nodev = view.nodeversions[host].version.parts;
+	}
+
+	let icon = '';
+	if (PVE.Utils.compare_ceph_versions(view.maxversion, nodev) > 0) {
+	    icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE');
+	} else if (PVE.Utils.compare_ceph_versions(nodev, value) > 0) {
+	    icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD');
+	} else if (view.mixedversions) {
+	    icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK');
+	}
+	return icon + value;
+    },
+
+    getMaxVersions: function(store, records, success) {
+	if (!success || records.length < 1) {
+	    return;
+	}
+	let me = this;
+	let view = me.getView();
+
+	view.nodeversions = records[0].data.node;
+	view.maxversion = [];
+	view.mixedversions = false;
+	for (const [_nodename, data] of Object.entries(view.nodeversions)) {
+	    let res = PVE.Utils.compare_ceph_versions(data.version.parts, view.maxversion);
+	    if (res !== 0 && view.maxversion.length > 0) {
+		view.mixedversions = true;
+	    }
+	    if (res > 0) {
+		view.maxversion = data.version.parts;
+	    }
+	}
+    },
+
+    init: function(view) {
+	if (view.pveSelNode) {
+	    view.nodename = view.pveSelNode.data.node;
+	}
+	if (!view.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!view.type) {
+	    throw "no type specified";
+	}
+
+	view.versionsstore = Ext.create('Proxmox.data.UpdateStore', {
+	    autoStart: true,
+	    interval: 10000,
+	    storeid: `ceph-versions-${view.type}-list${view.nodename}`,
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/ceph/metadata?scope=versions",
+	    },
+	});
+	view.versionsstore.on('load', this.getMaxVersions, this);
+	view.on('destroy', view.versionsstore.stopUpdate);
+
+	view.rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    autoStart: true,
+	    interval: 3000,
+	    storeid: `ceph-${view.type}-list${view.nodename}`,
+	    model: 'ceph-service-list',
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${view.nodename}/ceph/${view.type}`,
+	    },
+	});
+
+	view.setStore(Ext.create('Proxmox.data.DiffStore', {
+	    rstore: view.rstore,
+	    sorters: [{ property: 'name' }],
+	}));
+
+	if (view.storeLoadCallback) {
+	    view.rstore.on('load', view.storeLoadCallback, this);
+	}
+	view.on('destroy', view.rstore.stopUpdate);
+
+	if (view.showCephInstallMask) {
+	    PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true);
+	}
+    },
+
+    service_cmd: function(rec, cmd) {
+	let view = this.getView();
+	if (!rec.data.host) {
+	    Ext.Msg.alert(gettext('Error'), "entry has no host");
+	    return;
+	}
+	let doRequest = function() {
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${rec.data.host}/ceph/${cmd}`,
+		method: 'POST',
+		params: { service: view.type + '.' + rec.data.name },
+		success: function(response, options) {
+		    Ext.create('Proxmox.window.TaskProgress', {
+			autoShow: true,
+			upid: response.result.data,
+			taskDone: () => view.rstore.load(),
+		    });
+		},
+		failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    });
+	};
+	if (cmd === "stop" && ['mon', 'mds'].includes(view.type)) {
+	    Proxmox.Utils.API2Request({
+		url: `/nodes/${rec.data.host}/ceph/cmd-safety`,
+		params: {
+		    service: view.type,
+		    id: rec.data.name,
+		    action: 'stop',
+		},
+		method: 'GET',
+		success: function({ result: { data } }) {
+		    let stopText = {
+			mon: gettext('Stop MON'),
+			mds: gettext('Stop MDS'),
+		    };
+		    if (!data.safe) {
+			Ext.Msg.show({
+			    title: gettext('Warning'),
+			    message: data.status,
+			    icon: Ext.Msg.WARNING,
+			    buttons: Ext.Msg.OKCANCEL,
+			    buttonText: { ok: stopText[view.type] },
+			    fn: function(selection) {
+				if (selection === 'ok') {
+				    doRequest();
+				}
+			    },
+			});
+		    } else {
+			doRequest();
+		    }
+		},
+		failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    });
+	} else {
+	    doRequest();
+	}
+    },
+    onChangeService: function(button) {
+	let me = this;
+	let record = me.getView().getSelection()[0];
+	me.service_cmd(record, button.action);
+    },
+
+    showSyslog: function() {
+	let view = this.getView();
+	let rec = view.getSelection()[0];
+	let service = `ceph-${view.type}@${rec.data.name}`;
+	Ext.create('Ext.window.Window', {
+	    title: `${gettext('Syslog')}: ${service}`,
+	    autoShow: true,
+	    modal: true,
+	    width: 800,
+	    height: 400,
+	    layout: 'fit',
+	    items: [{
+		xtype: 'proxmoxLogView',
+		url: `/api2/extjs/nodes/${rec.data.host}/syslog?service=${encodeURIComponent(service)}`,
+		log_select_timespan: 1,
+	    }],
+	});
+    },
+
+    onCreate: function() {
+	let view = this.getView();
+	Ext.create('PVE.CephCreateService', {
+	    autoShow: true,
+	    nodename: view.nodename,
+	    subject: view.getTitle(),
+	    type: view.type,
+	    taskDone: () => view.rstore.load(),
+	});
+    },
+});
+
+Ext.define('PVE.node.CephServiceList', {
+    extend: 'Ext.grid.GridPanel',
+    xtype: 'pveNodeCephServiceList',
+
+    onlineHelp: 'chapter_pveceph',
+    emptyText: gettext('No such service configured.'),
+
+    stateful: true,
+
+    // will be called when the store loads
+    storeLoadCallback: Ext.emptyFn,
+
+    // if set to true, does shows the ceph install mask if needed
+    showCephInstallMask: false,
+
+    controller: 'CephServiceList',
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Start'),
+	    iconCls: 'fa fa-play',
+	    action: 'start',
+	    disabled: true,
+	    enableFn: rec => rec.data.state === 'stopped' || rec.data.state === 'unknown',
+	    handler: 'onChangeService',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Stop'),
+	    iconCls: 'fa fa-stop',
+	    action: 'stop',
+	    enableFn: rec => rec.data.state !== 'stopped',
+	    disabled: true,
+	    handler: 'onChangeService',
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Restart'),
+	    iconCls: 'fa fa-refresh',
+	    action: 'restart',
+	    disabled: true,
+	    enableFn: rec => rec.data.state !== 'stopped',
+	    handler: 'onChangeService',
+	},
+	'-',
+	{
+	    text: gettext('Create'),
+	    reference: 'createButton',
+	    handler: 'onCreate',
+	},
+	{
+	    text: gettext('Destroy'),
+	    xtype: 'proxmoxStdRemoveButton',
+	    getUrl: function(rec) {
+		let view = this.up('grid');
+		if (!rec.data.host) {
+		    Ext.Msg.alert(gettext('Error'), "entry has no host, cannot build API url");
+		    return '';
+		}
+		return `/nodes/${rec.data.host}/ceph/${view.type}/${rec.data.name}`;
+	    },
+	    callback: function(options, success, response) {
+		let view = this.up('grid');
+		if (!success) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    return;
+		}
+		Ext.create('Proxmox.window.TaskProgress', {
+		    autoShow: true,
+		    upid: response.result.data,
+		    taskDone: () => view.rstore.load(),
+		});
+	    },
+	    handler: function(btn, event, rec) {
+		let me = this;
+		let view = me.up('grid');
+		let doRequest = function() {
+		    Proxmox.button.StdRemoveButton.prototype.handler.call(me, btn, event, rec);
+		};
+		if (view.type === 'mon') {
+		    Proxmox.Utils.API2Request({
+			url: `/nodes/${rec.data.host}/ceph/cmd-safety`,
+			params: {
+			    service: view.type,
+			    id: rec.data.name,
+			    action: 'destroy',
+			},
+			method: 'GET',
+			success: function({ result: { data } }) {
+			    if (!data.safe) {
+				Ext.Msg.show({
+				    title: gettext('Warning'),
+				    message: data.status,
+				    icon: Ext.Msg.WARNING,
+				    buttons: Ext.Msg.OKCANCEL,
+				    buttonText: { ok: gettext('Destroy MON') },
+				    fn: function(selection) {
+					if (selection === 'ok') {
+					    doRequest();
+					}
+				    },
+				});
+			    } else {
+				doRequest();
+			    }
+			},
+			failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		    });
+		} else {
+		    doRequest();
+		}
+	    },
+
+	},
+	'-',
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Syslog'),
+	    disabled: true,
+	    handler: 'showSyslog',
+	},
+    ],
+
+    columns: [
+	{
+	    header: gettext('Name'),
+	    flex: 1,
+	    sortable: true,
+	    renderer: function(v) {
+		return this.type + '.' + v;
+	    },
+	    dataIndex: 'name',
+	},
+	{
+	    header: gettext('Host'),
+	    flex: 1,
+	    sortable: true,
+	    renderer: function(v) {
+		return v || Proxmox.Utils.unknownText;
+	    },
+	    dataIndex: 'host',
+	},
+	{
+	    header: gettext('Status'),
+	    flex: 1,
+	    sortable: false,
+	    renderer: 'render_status',
+	    dataIndex: 'state',
+	},
+	{
+	    header: gettext('Address'),
+	    flex: 3,
+	    sortable: true,
+	    renderer: function(v) {
+		return v || Proxmox.Utils.unknownText;
+	    },
+	    dataIndex: 'addr',
+	},
+	{
+	    header: gettext('Version'),
+	    flex: 3,
+	    sortable: true,
+	    dataIndex: 'version',
+	    renderer: 'render_version',
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (me.additionalColumns) {
+	    me.columns = me.columns.concat(me.additionalColumns);
+	}
+
+	me.callParent();
+    },
+
+}, function() {
+    Ext.define('ceph-service-list', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'addr',
+	    'name',
+	    'fs_name',
+	    'rank',
+	    'host',
+	    'quorum',
+	    'state',
+	    'ceph_version',
+	    'ceph_version_short',
+	    {
+		type: 'string',
+		name: 'version',
+		calculate: data => PVE.Utils.parse_ceph_version(data),
+	    },
+	],
+	idProperty: 'name',
+    });
+});
+
+Ext.define('PVE.node.CephMDSServiceController', {
+    extend: 'PVE.node.CephServiceController',
+    alias: 'controller.CephServiceMDSList',
+
+    render_status: (value, mD, rec) => rec.data.fs_name ? `${value} (${rec.data.fs_name})` : value,
+});
+
+Ext.define('PVE.node.CephMDSList', {
+    extend: 'PVE.node.CephServiceList',
+    xtype: 'pveNodeCephMDSList',
+
+    controller: {
+	type: 'CephServiceMDSList',
+    },
+});
+
+Ext.define('PVE.ceph.Services', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveCephServices',
+
+    layout: {
+	type: 'hbox',
+	align: 'stretch',
+    },
+
+    bodyPadding: '0 5 20',
+    defaults: {
+	xtype: 'box',
+	style: {
+	    'text-align': 'center',
+	},
+    },
+
+    items: [
+	{
+	    flex: 1,
+	    xtype: 'pveCephServiceList',
+	    itemId: 'mons',
+	    title: gettext('Monitors'),
+	},
+	{
+	    flex: 1,
+	    xtype: 'pveCephServiceList',
+	    itemId: 'mgrs',
+	    title: gettext('Managers'),
+	},
+	{
+	    flex: 1,
+	    xtype: 'pveCephServiceList',
+	    itemId: 'mdss',
+	    title: gettext('Meta Data Servers'),
+	},
+    ],
+
+    updateAll: function(metadata, status) {
+	var me = this;
+
+	const healthstates = {
+	    'HEALTH_UNKNOWN': 0,
+	    'HEALTH_ERR': 1,
+	    'HEALTH_WARN': 2,
+	    'HEALTH_UPGRADE': 3,
+	    'HEALTH_OLD': 4,
+	    'HEALTH_OK': 5,
+	};
+	// order guarantee since es2020, but browsers did so before. Note, integers would break it.
+	const healthmap = Object.keys(healthstates);
+	let maxversion = "00.0.00";
+	Object.values(metadata.node || {}).forEach(function(node) {
+	    if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) {
+		maxversion = node?.version?.parts;
+	    }
+	});
+	var quorummap = status && status.quorum_names ? status.quorum_names : [];
+	let monmessages = {}, mgrmessages = {}, mdsmessages = {};
+	if (status) {
+	    if (status.health) {
+		Ext.Object.each(status.health.checks, function(key, value, _obj) {
+		    if (!Ext.String.startsWith(key, "MON_")) {
+			return;
+		    }
+		    for (let i = 0; i < value.detail.length; i++) {
+			let match = value.detail[i].message.match(/mon.([a-zA-Z0-9\-.]+)/);
+			if (!match) {
+			    continue;
+			}
+			let monid = match[1];
+			if (!monmessages[monid]) {
+			    monmessages[monid] = {
+				worstSeverity: healthstates.HEALTH_OK,
+				messages: [],
+			    };
+			}
+
+			let severityIcon = PVE.Utils.get_ceph_icon_html(value.severity, true);
+			let details = value.detail.reduce((acc, v) => `${acc}\n${v.message}`, '');
+			monmessages[monid].messages.push(severityIcon + details);
+
+			if (healthstates[value.severity] < monmessages[monid].worstSeverity) {
+			    monmessages[monid].worstSeverity = healthstates[value.severity];
+			}
+		    }
+		});
+	    }
+
+	    if (status.mgrmap) {
+		mgrmessages[status.mgrmap.active_name] = "active";
+		status.mgrmap.standbys.forEach(function(mgr) {
+		    mgrmessages[mgr.name] = "standby";
+		});
+	    }
+
+	    if (status.fsmap) {
+		status.fsmap.by_rank.forEach(function(mds) {
+		    mdsmessages[mds.name] = 'rank: ' + mds.rank + "; " + mds.status;
+		});
+	    }
+	}
+
+	let checks = {
+	    mon: function(mon) {
+		if (quorummap.indexOf(mon.name) !== -1) {
+		    mon.health = healthstates.HEALTH_OK;
+		} else {
+		    mon.health = healthstates.HEALTH_ERR;
+		}
+		if (monmessages[mon.name]) {
+		    if (monmessages[mon.name].worstSeverity < mon.health) {
+			mon.health = monmessages[mon.name].worstSeverity;
+		    }
+		    Array.prototype.push.apply(mon.messages, monmessages[mon.name].messages);
+		}
+		return mon;
+	    },
+	    mgr: function(mgr) {
+		if (mgrmessages[mgr.name] === 'active') {
+		    mgr.title = '<b>' + mgr.title + '</b>';
+		    mgr.statuses.push(gettext('Status') + ': <b>active</b>');
+		} else if (mgrmessages[mgr.name] === 'standby') {
+		    mgr.statuses.push(gettext('Status') + ': standby');
+		} else if (mgr.health > healthstates.HEALTH_WARN) {
+		    mgr.health = healthstates.HEALTH_WARN;
+		}
+
+		return mgr;
+	    },
+	    mds: function(mds) {
+		if (mdsmessages[mds.name]) {
+		    mds.title = '<b>' + mds.title + '</b>';
+		    mds.statuses.push(gettext('Status') + ': <b>' + mdsmessages[mds.name]+"</b>");
+		} else if (mds.addr !== Proxmox.Utils.unknownText) {
+		    mds.statuses.push(gettext('Status') + ': standby');
+		}
+
+		return mds;
+	    },
+	};
+
+	for (let type of ['mon', 'mgr', 'mds']) {
+	    var ids = Object.keys(metadata[type] || {});
+	    me[type] = {};
+
+	    for (let id of ids) {
+		const [name, host] = id.split('@');
+		let result = {
+		    id: id,
+		    health: healthstates.HEALTH_OK,
+		    statuses: [],
+		    messages: [],
+		    name: name,
+		    title: metadata[type][id].name || name,
+		    host: host,
+		    version: PVE.Utils.parse_ceph_version(metadata[type][id]),
+		    service: metadata[type][id].service,
+		    addr: metadata[type][id].addr || metadata[type][id].addrs || Proxmox.Utils.unknownText,
+		};
+
+		result.statuses = [
+		    gettext('Host') + ": " + host,
+		    gettext('Address') + ": " + result.addr,
+		];
+
+		if (checks[type]) {
+		    result = checks[type](result);
+		}
+
+		if (result.service && !result.version) {
+		    result.messages.push(
+			PVE.Utils.get_ceph_icon_html('HEALTH_UNKNOWN', true) +
+			gettext('Stopped'),
+		    );
+		    result.health = healthstates.HEALTH_UNKNOWN;
+		}
+
+		if (!result.version && result.addr === Proxmox.Utils.unknownText) {
+		    result.health = healthstates.HEALTH_UNKNOWN;
+		}
+
+		if (result.version) {
+		    result.statuses.push(gettext('Version') + ": " + result.version);
+
+		    if (PVE.Utils.compare_ceph_versions(result.version, maxversion) !== 0) {
+			let host_version = metadata.node[host]?.version?.parts || metadata.version?.[host] || "";
+			if (PVE.Utils.compare_ceph_versions(host_version, maxversion) === 0) {
+			    if (result.health > healthstates.HEALTH_OLD) {
+				result.health = healthstates.HEALTH_OLD;
+			    }
+			    result.messages.push(
+				PVE.Utils.get_ceph_icon_html('HEALTH_OLD', true) +
+				gettext('A newer version was installed but old version still running, please restart'),
+			    );
+			} else {
+			    if (result.health > healthstates.HEALTH_UPGRADE) {
+				result.health = healthstates.HEALTH_UPGRADE;
+			    }
+			    result.messages.push(
+				PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE', true) +
+				gettext('Other cluster members use a newer version of this service, please upgrade and restart'),
+			    );
+			}
+		    }
+		}
+
+		result.statuses.push(''); // empty line
+		result.text = result.statuses.concat(result.messages).join('<br>');
+
+		result.health = healthmap[result.health];
+
+		me[type][id] = result;
+	    }
+	}
+
+	me.getComponent('mons').updateAll(Object.values(me.mon));
+	me.getComponent('mgrs').updateAll(Object.values(me.mgr));
+	me.getComponent('mdss').updateAll(Object.values(me.mds));
+    },
+});
+
+Ext.define('PVE.ceph.ServiceList', {
+    extend: 'Ext.container.Container',
+    xtype: 'pveCephServiceList',
+
+    style: {
+	'text-align': 'center',
+    },
+    defaults: {
+	xtype: 'box',
+	style: {
+	    'text-align': 'center',
+	},
+    },
+
+    items: [
+	{
+	    itemId: 'title',
+	    data: {
+		title: '',
+	    },
+	    tpl: '<h3>{title}</h3>',
+	},
+    ],
+
+    updateAll: function(list) {
+	var me = this;
+	me.suspendLayout = true;
+
+	list.sort((a, b) => a.id > b.id ? 1 : a.id < b.id ? -1 : 0);
+	if (!me.ids) {
+	    me.ids = [];
+	}
+	let pendingRemoval = {};
+	me.ids.forEach(id => { pendingRemoval[id] = true; }); // mark all as to-remove first here
+
+	for (let i = 0; i < list.length; i++) {
+	    let service = me.getComponent(list[i].id);
+	    if (!service) {
+		// services and list are sorted, so just insert at i + 1 (first el. is the title)
+		service = me.insert(i + 1, {
+		    xtype: 'pveCephServiceWidget',
+		    itemId: list[i].id,
+		});
+		me.ids.push(list[i].id);
+	    } else {
+		delete pendingRemoval[list[i].id]; // drop exisiting from for-removal
+	    }
+	    service.updateService(list[i].title, list[i].text, list[i].health);
+	}
+	Object.keys(pendingRemoval).forEach(id => me.remove(id)); // GC
+
+	me.suspendLayout = false;
+	me.updateLayout();
+    },
+
+    initComponent: function() {
+	var me = this;
+	me.callParent();
+	me.getComponent('title').update({
+	    title: me.title,
+	});
+    },
+});
+
+Ext.define('PVE.ceph.ServiceWidget', {
+    extend: 'Ext.Component',
+    alias: 'widget.pveCephServiceWidget',
+
+    userCls: 'monitor inline-block',
+    data: {
+	title: '0',
+	health: 'HEALTH_ERR',
+	text: '',
+	iconCls: PVE.Utils.get_health_icon(),
+    },
+
+    tpl: [
+	'{title}: ',
+	'<i class="fa fa-fw {iconCls}"></i>',
+    ],
+
+    updateService: function(title, text, health) {
+	var me = this;
+
+	me.update(Ext.apply(me.data, {
+	    health: health,
+	    text: text,
+	    title: title,
+	    iconCls: PVE.Utils.get_health_icon(PVE.Utils.map_ceph_health[health]),
+	}));
+
+	if (me.tooltip) {
+	    me.tooltip.setHtml(text);
+	}
+    },
+
+    listeners: {
+	destroy: function() {
+	    let me = this;
+	    if (me.tooltip) {
+		me.tooltip.destroy();
+		delete me.tooltip;
+	    }
+	},
+	mouseenter: {
+	    element: 'el',
+	    fn: function(events, element) {
+		let view = this.component;
+		if (!view) {
+		    return;
+		}
+		if (!view.tooltip || view.data.text !== view.tooltip.html) {
+		    view.tooltip = Ext.create('Ext.tip.ToolTip', {
+			target: view.el,
+			trackMouse: true,
+			dismissDelay: 0,
+			renderTo: Ext.getBody(),
+			html: view.data.text,
+		    });
+		}
+		view.tooltip.show();
+	    },
+	},
+	mouseleave: {
+	    element: 'el',
+	    fn: function(events, element) {
+		let view = this.component;
+		if (view.tooltip) {
+		    view.tooltip.destroy();
+		    delete view.tooltip;
+		}
+	    },
+	},
+    },
+});
+Ext.define('pve-ceph-warnings', {
+    extend: 'Ext.data.Model',
+    fields: ['id', 'summary', 'detail', 'severity'],
+    idProperty: 'id',
+});
+
+
+Ext.define('PVE.node.CephStatus', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveNodeCephStatus',
+
+    onlineHelp: 'chapter_pveceph',
+
+    scrollable: true,
+    bodyPadding: 5,
+    layout: {
+	type: 'column',
+    },
+
+    defaults: {
+	padding: 5,
+    },
+
+    items: [
+	{
+	    xtype: 'panel',
+	    title: gettext('Health'),
+	    bodyPadding: 10,
+	    plugins: 'responsive',
+	    responsiveConfig: {
+		'width < 1600': {
+		    minHeight: 230,
+		    columnWidth: 1,
+		},
+		'width >= 1600': {
+		    minHeight: 500,
+		    columnWidth: 0.5,
+		},
+	    },
+	    layout: {
+		type: 'hbox',
+		align: 'stretch',
+	    },
+	    items: [
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+			align: 'stretch',
+		    },
+		    flex: 1,
+		    items: [
+			{
+
+			    xtype: 'pveHealthWidget',
+			    itemId: 'overallhealth',
+			    flex: 1,
+			    title: gettext('Status'),
+			},
+			{
+			    xtype: 'displayfield',
+			    itemId: 'versioninfo',
+			    fieldLabel: gettext('Ceph Version'),
+			    value: "",
+			    autoEl: {
+				tag: 'div',
+				'data-qtip': gettext('The newest version installed in the Cluster.'),
+			    },
+			    padding: '10 0 0 0',
+			    style: {
+				'text-align': 'center',
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'grid',
+		    itemId: 'warnings',
+		    flex: 2,
+		    maxHeight: 430,
+		    stateful: true,
+		    stateId: 'ceph-status-warnings',
+		    viewConfig: {
+			enableTextSelection: true,
+			    listeners: {
+				collapsebody: function(rowNode, record) {
+				    record.set('expanded', false);
+				    record.commit();
+				},
+				expandbody: function(rowNode, record) {
+				    record.set('expanded', true);
+				    record.commit();
+				},
+			    },
+		    },
+		    // we load the store manually, to show an emptyText specify an empty intermediate store
+		    store: {
+			type: 'diff',
+			trackRemoved: false,
+			data: [],
+			rstore: {
+			    storeid: 'pve-ceph-warnings',
+			    type: 'update',
+			    model: 'pve-ceph-warnings',
+			},
+		    },
+		    updateHealth: function(health) {
+			let checks = health.checks || {};
+
+			let checkRecords = Object.keys(checks).sort().map(key => {
+			    let check = checks[key];
+			    let data = {
+				id: key,
+				summary: check.summary.message,
+				detail: check.detail.reduce((acc, v) => `${acc}\n${v.message}`, '').trimStart(),
+				severity: check.severity,
+			    };
+			    data.noDetails = data.detail.length === 0;
+			    data.detailsCls = data.detail.length === 0 ? 'pmx-opacity-75' : '';
+			    if (data.detail.length === 0) {
+				data.detail = "no additional data";
+			    }
+			    return data;
+			});
+
+			let rstore = this.getStore().rstore;
+			rstore.loadData(checkRecords, false);
+			rstore.fireEvent('load', rstore, checkRecords, true);
+		    },
+		    emptyText: gettext('No Warnings/Errors'),
+		    columns: [
+			{
+			    dataIndex: 'severity',
+			    tooltip: gettext('Severity'),
+			    align: 'center',
+			    width: 38,
+			    renderer: function(value) {
+				let health = PVE.Utils.map_ceph_health[value];
+				let icon = PVE.Utils.get_health_icon(health);
+				return `<i class="fa fa-fw ${icon}"></i>`;
+			    },
+			    sorter: {
+				sorterFn: function(a, b) {
+				    let health = ['HEALTH_ERR', 'HEALTH_WARN', 'HEALTH_OK'];
+				    return health.indexOf(b.data.severity) - health.indexOf(a.data.severity);
+				},
+			    },
+			},
+			{
+			    dataIndex: 'summary',
+			    header: gettext('Summary'),
+			    renderer: function(value, metaData, record, rI, cI, store, view) {
+				if (record.get('expanded')) {
+				    metaData.tdCls = 'pmx-column-wrapped';
+				}
+				return value;
+			    },
+			    flex: 1,
+			},
+			{
+			    xtype: 'actioncolumn',
+			    width: 50,
+			    align: 'center',
+			    tooltip: gettext('Actions'),
+			    items: [
+				{
+				    iconCls: 'x-fa fa-clipboard',
+				    tooltip: gettext('Copy to Clipboard'),
+				    handler: function(grid, rowindex, colindex, item, e, { data }) {
+					let detail = data.noDetails ? '': `\n${data.detail}`;
+					navigator.clipboard
+					    .writeText(`${data.severity}: ${data.summary}${detail}`)
+					    .catch(err => Ext.Msg.alert(gettext('Error'), err));
+				    },
+				},
+			    ],
+			},
+		    ],
+		    listeners: {
+			itemdblclick: function(view, record, row, rowIdx, e) {
+			    // inspired by Ext.grid.plugin.RowExpander, but for double click
+			    let rowNode = view.getNode(rowIdx);
+			    let normalRow = Ext.fly(rowNode);
+
+			    let collapsedCls = view.rowBodyFeature.rowCollapsedCls;
+
+			    if (normalRow.hasCls(collapsedCls)) {
+				view.rowBodyFeature.rowExpander.toggleRow(rowIdx, record);
+			    }
+			},
+		    },
+		    plugins: [
+			{
+			    ptype: 'rowexpander',
+			    expandOnDblClick: false,
+			    scrollIntoViewOnExpand: false,
+			    rowBodyTpl: '<pre class="pve-ceph-warning-detail {detailsCls}">{detail}</pre>',
+			},
+		    ],
+		},
+	    ],
+	},
+	{
+	    xtype: 'pveCephStatusDetail',
+	    itemId: 'statusdetail',
+	    plugins: 'responsive',
+	    responsiveConfig: {
+		'width < 1600': {
+		    columnWidth: 1,
+		    minHeight: 250,
+		},
+		'width >= 1600': {
+		    columnWidth: 0.5,
+		    minHeight: 300,
+		},
+	    },
+	    title: gettext('Status'),
+	},
+	{
+	    xtype: 'pveCephServices',
+	    title: gettext('Services'),
+	    itemId: 'services',
+	    plugins: 'responsive',
+	    layout: {
+		type: 'hbox',
+		align: 'stretch',
+	    },
+	    responsiveConfig: {
+		'width < 1600': {
+		    columnWidth: 1,
+		    minHeight: 200,
+		},
+		'width >= 1600': {
+		    columnWidth: 0.5,
+		    minHeight: 200,
+		},
+	    },
+	},
+	{
+	    xtype: 'panel',
+	    title: gettext('Performance'),
+	    columnWidth: 1,
+	    bodyPadding: 5,
+	    layout: {
+		type: 'hbox',
+		align: 'center',
+	    },
+	    items: [
+		{
+		    xtype: 'container',
+		    flex: 1,
+		    items: [
+			{
+			    xtype: 'proxmoxGauge',
+			    itemId: 'space',
+			    title: gettext('Usage'),
+			},
+			{
+			    flex: 1,
+			    border: false,
+			},
+			{
+			    xtype: 'container',
+			    itemId: 'recovery',
+			    hidden: true,
+			    padding: 25,
+			    items: [
+				{
+				    xtype: 'pveRunningChart',
+				    itemId: 'recoverychart',
+				    title: gettext('Recovery') +'/ '+ gettext('Rebalance'),
+				    renderer: PVE.Utils.render_bandwidth,
+				    height: 100,
+				},
+				{
+				    xtype: 'progressbar',
+				    itemId: 'recoveryprogress',
+				},
+			    ],
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    flex: 2,
+		    defaults: {
+			padding: 0,
+			height: 100,
+		    },
+		    items: [
+			{
+			    xtype: 'pveRunningChart',
+			    itemId: 'reads',
+			    title: gettext('Reads'),
+			    renderer: PVE.Utils.render_bandwidth,
+			},
+			{
+			    xtype: 'pveRunningChart',
+			    itemId: 'writes',
+			    title: gettext('Writes'),
+			    renderer: PVE.Utils.render_bandwidth,
+			},
+			{
+			    xtype: 'pveRunningChart',
+			    itemId: 'readiops',
+			    title: 'IOPS: ' + gettext('Reads'),
+			    renderer: Ext.util.Format.numberRenderer('0,000'),
+			},
+			{
+			    xtype: 'pveRunningChart',
+			    itemId: 'writeiops',
+			    title: 'IOPS: ' + gettext('Writes'),
+			    renderer: Ext.util.Format.numberRenderer('0,000'),
+			},
+		    ],
+		},
+	    ],
+	},
+    ],
+
+    updateAll: function(store, records, success) {
+	if (!success || records.length === 0) {
+	    return;
+	}
+
+	var me = this;
+	var rec = records[0];
+	me.status = rec.data;
+
+	// add health panel
+	me.down('#overallhealth').updateHealth(PVE.Utils.render_ceph_health(rec.data.health || {}));
+	me.down('#warnings').updateHealth(rec.data.health || {}); // add errors to gridstore
+
+	me.getComponent('services').updateAll(me.metadata || {}, rec.data);
+
+	me.getComponent('statusdetail').updateAll(me.metadata || {}, rec.data);
+
+	// add performance data
+	let pgmap = rec.data.pgmap;
+	let used = pgmap.bytes_used;
+	let total = pgmap.bytes_total;
+
+	var text = Ext.String.format(gettext('{0} of {1}'),
+	    Proxmox.Utils.render_size(used),
+	    Proxmox.Utils.render_size(total),
+	);
+
+	// update the usage widget
+	const usage = total > 0 ? used / total : 0;
+	me.down('#space').updateValue(usage, text);
+
+	let readiops = pgmap.read_op_per_sec;
+	let writeiops = pgmap.write_op_per_sec;
+	let reads = pgmap.read_bytes_sec || 0;
+	let writes = pgmap.write_bytes_sec || 0;
+
+	// update the graphs
+	me.reads.addDataPoint(reads);
+	me.writes.addDataPoint(writes);
+	me.readiops.addDataPoint(readiops);
+	me.writeiops.addDataPoint(writeiops);
+
+	let degraded = pgmap.degraded_objects || 0;
+	let misplaced = pgmap.misplaced_objects || 0;
+	let unfound = pgmap.unfound_objects || 0;
+	let unhealthy = degraded + unfound + misplaced;
+	// update recovery
+	if (pgmap.recovering_objects_per_sec !== undefined || unhealthy > 0) {
+	    let toRecoverObjects = pgmap.misplaced_total || pgmap.unfound_total || pgmap.degraded_total || 0;
+	    if (toRecoverObjects === 0) {
+		return; // FIXME: unexpected return and leaves things possible visible when it shouldn't?
+	    }
+	    let recovered = toRecoverObjects - unhealthy || 0;
+	    let speed = pgmap.recovering_bytes_per_sec || 0;
+
+	    let recoveryRatio = recovered / toRecoverObjects;
+	    let txt = `${(recoveryRatio * 100).toFixed(2)}%`;
+	    if (speed > 0) {
+		let obj_per_sec = speed / (4 * 1024 * 1024); // 4 MiB per Object
+		let duration = Proxmox.Utils.format_duration_human(unhealthy/obj_per_sec);
+		let speedTxt = PVE.Utils.render_bandwidth(speed);
+		txt += ` (${speedTxt} - ${duration} left)`;
+	    }
+
+	    me.down('#recovery').setVisible(true);
+	    me.down('#recoveryprogress').updateValue(recoveryRatio);
+	    me.down('#recoveryprogress').updateText(txt);
+	    me.down('#recoverychart').addDataPoint(speed);
+	} else {
+	    me.down('#recovery').setVisible(false);
+	    me.down('#recoverychart').addDataPoint(0);
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+
+	me.callParent();
+	var baseurl = '/api2/json' + (nodename ? '/nodes/' + nodename : '/cluster') + '/ceph';
+	me.store = Ext.create('Proxmox.data.UpdateStore', {
+	    storeid: 'ceph-status-' + (nodename || 'cluster'),
+	    interval: 5000,
+	    proxy: {
+		type: 'proxmox',
+		url: baseurl + '/status',
+	    },
+	});
+
+	me.metadatastore = Ext.create('Proxmox.data.UpdateStore', {
+	    storeid: 'ceph-metadata-' + (nodename || 'cluster'),
+	    interval: 15*1000,
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/cluster/ceph/metadata',
+	    },
+	});
+
+	// save references for the updatefunction
+	me.iops = me.down('#iops');
+	me.readiops = me.down('#readiops');
+	me.writeiops = me.down('#writeiops');
+	me.reads = me.down('#reads');
+	me.writes = me.down('#writes');
+
+	// manages the "install ceph?" overlay
+	PVE.Utils.monitor_ceph_installed(me, me.store, nodename);
+
+	me.mon(me.store, 'load', me.updateAll, me);
+	me.mon(me.metadatastore, 'load', function(store, records, success) {
+	    if (!success || records.length < 1) {
+		return;
+	    }
+	    me.metadata = records[0].data;
+
+	    // update services
+	    me.getComponent('services').updateAll(me.metadata, me.status || {});
+
+	    // update detailstatus panel
+	    me.getComponent('statusdetail').updateAll(me.metadata, me.status || {});
+
+	    let maxversion = [];
+	    let maxversiontext = "";
+	    for (const [_nodename, data] of Object.entries(me.metadata.node)) {
+		let version = data.version.parts;
+		if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) {
+		    maxversion = version;
+		    maxversiontext = data.version.str;
+		}
+	    }
+	    me.down('#versioninfo').setValue(maxversiontext);
+	}, me);
+
+	me.on('destroy', me.store.stopUpdate);
+	me.on('destroy', me.metadatastore.stopUpdate);
+	me.store.startUpdate();
+	me.metadatastore.startUpdate();
+    },
+
+});
+Ext.define('PVE.ceph.StatusDetail', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveCephStatusDetail',
+
+    layout: {
+	type: 'hbox',
+	align: 'stretch',
+    },
+
+    bodyPadding: '0 5',
+    defaults: {
+	xtype: 'box',
+	style: {
+	    'text-align': 'center',
+	},
+    },
+
+    items: [{
+	flex: 1,
+	itemId: 'osds',
+	maxHeight: 250,
+	scrollable: true,
+	padding: '0 10 5 10',
+	data: {
+	    total: 0,
+	    upin: 0,
+	    upout: 0,
+	    downin: 0,
+	    downout: 0,
+	    oldOSD: [],
+	    ghostOSD: [],
+	},
+	tpl: [
+	    '<h3>OSDs</h3>',
+	    '<table class="osds">',
+	    '<tr><td></td>',
+	    '<td><i class="fa fa-fw good fa-circle"></i>',
+	    gettext('In'),
+	    '</td>',
+	    '<td><i class="fa fa-fw warning fa-circle-o"></i>',
+	    gettext('Out'),
+	    '</td>',
+	    '</tr>',
+	    '<tr>',
+	    '<td><i class="fa fa-fw good fa-arrow-circle-up"></i>',
+	    gettext('Up'),
+	    '</td>',
+	    '<td>{upin}</td>',
+	    '<td>{upout}</td>',
+	    '</tr>',
+	    '<tr>',
+	    '<td><i class="fa fa-fw critical fa-arrow-circle-down"></i>',
+	    gettext('Down'),
+	    '</td>',
+	    '<td>{downin}</td>',
+	    '<td>{downout}</td>',
+	    '</tr>',
+	    '</table>',
+	    '<br /><div>',
+	    gettext('Total'),
+	    ': {total}',
+	    '</div><br />',
+	    '<tpl if="oldOSD.length &gt; 0">',
+	    '<i class="fa fa-refresh warning"></i> ' + gettext('Outdated OSDs') + "<br>",
+	    '<div class="osds">',
+	    '<tpl for="oldOSD">',
+	    '<div class="left-aligned">osd.{id}:</div>',
+	    '<div class="right-aligned">{version}</div><br />',
+	    '<div style="clear:both"></div>',
+	    '</tpl>',
+	    '</div>',
+	    '</tpl>',
+	    '</div>',
+	    '<tpl if="ghostOSD.length &gt; 0">',
+	    '<br />',
+	    `<i class="fa fa-question-circle warning"></i> ${gettext('Ghost OSDs')}<br>`,
+	    `<div data-qtip="${gettext('OSDs with no metadata, possibly left over from removal')}" class="osds">`,
+	    '<tpl for="ghostOSD">',
+	    '<div class="left-aligned">osd.{id}</div>',
+	    '<div style="clear:both"></div>',
+	    '</tpl>',
+	    '</div>',
+	    '</tpl>',
+	],
+    },
+    {
+	flex: 1,
+	border: false,
+	itemId: 'pgchart',
+	xtype: 'polar',
+	height: 184,
+	innerPadding: 5,
+	insetPadding: 5,
+	colors: [
+	    '#CFCFCF',
+	    '#21BF4B',
+	    '#3892d4',
+	    '#FFCC00',
+	    '#FF6C59',
+	],
+	store: { },
+	series: [
+	    {
+		type: 'pie',
+		donut: 60,
+		angleField: 'count',
+		tooltip: {
+		    trackMouse: true,
+		    renderer: function(tooltip, record, ctx) {
+			var html = record.get('text');
+			html += '<br>';
+			record.get('states').forEach(function(state) {
+			    html += '<br>' +
+				state.state_name + ': ' + state.count.toString();
+			});
+			tooltip.setHtml(html);
+		    },
+		},
+		subStyle: {
+		    strokeStyle: false,
+		},
+	    },
+	],
+    },
+    {
+	flex: 1.6,
+	itemId: 'pgs',
+	padding: '0 10',
+	maxHeight: 250,
+	scrollable: true,
+	data: {
+	    states: [],
+	},
+	tpl: [
+	    '<h3>PGs</h3>',
+	    '<tpl for="states">',
+	    '<div class="left-aligned"><i class ="fa fa-circle {cls}"></i> {state_name}:</div>',
+	    '<div class="right-aligned">{count}</div><br />',
+	    '<div style="clear:both"></div>',
+	    '</tpl>',
+	],
+    }],
+
+    // similar to mgr dashboard
+    pgstates: {
+	// clean
+	clean: 1,
+	active: 1,
+
+	// busy
+	activating: 2,
+	backfill_wait: 2,
+	backfilling: 2,
+	creating: 2,
+	deep: 2,
+	forced_backfill: 2,
+	forced_recovery: 2,
+	peered: 2,
+	peering: 2,
+	recovering: 2,
+	recovery_wait: 2,
+	remapped: 2,
+	repair: 2,
+	scrubbing: 2,
+	snaptrim: 2,
+	snaptrim_wait: 2,
+
+	// warning
+	degraded: 3,
+	undersized: 3,
+
+	// critical
+	backfill_toofull: 4,
+	backfill_unfound: 4,
+	down: 4,
+	incomplete: 4,
+	inconsistent: 4,
+	recovery_toofull: 4,
+	recovery_unfound: 4,
+	snaptrim_error: 4,
+	stale: 4,
+    },
+
+    statecategories: [
+	{
+	    text: gettext('Unknown'),
+	    count: 0,
+	    states: [],
+	    cls: 'faded',
+	},
+	{
+	    text: gettext('Clean'),
+	    cls: 'good',
+	},
+	{
+	    text: gettext('Busy'),
+	    cls: 'pve-ceph-status-busy',
+	},
+	{
+	    text: gettext('Warning'),
+	    cls: 'warning',
+	},
+	{
+	    text: gettext('Critical'),
+	    cls: 'critical',
+	},
+    ],
+
+    checkThemeColors: function() {
+	let me = this;
+	let rootStyle = getComputedStyle(document.documentElement);
+
+	// get color
+	let background = rootStyle.getPropertyValue("--pwt-panel-background").trim() || "#ffffff";
+
+	// set the colors
+	me.chart.setBackground(background);
+	me.chart.redraw();
+    },
+
+    updateAll: function(metadata, status) {
+	let me = this;
+	me.suspendLayout = true;
+
+	let maxversion = "0";
+	Object.values(metadata.node || {}).forEach(function(node) {
+	    if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) {
+		maxversion = node.version.parts;
+	    }
+	});
+
+	let oldOSD = [], ghostOSD = [];
+	metadata.osd?.forEach(osd => {
+	    let version = PVE.Utils.parse_ceph_version(osd);
+	    if (version !== undefined) {
+		if (PVE.Utils.compare_ceph_versions(version, maxversion) !== 0) {
+		    oldOSD.push({
+			id: osd.id,
+			version: version,
+		    });
+		}
+	    } else {
+		if (Object.keys(osd).length > 1) {
+		    console.warn('got OSD entry with no valid version but other keys', osd);
+		}
+		ghostOSD.push({
+		    id: osd.id,
+		});
+	    }
+	});
+
+	// update PGs sorted
+	let pgmap = status.pgmap || {};
+	let pgs_by_state = pgmap.pgs_by_state || [];
+	pgs_by_state.sort(function(a, b) {
+	    return a.state_name < b.state_name?-1:a.state_name === b.state_name?0:1;
+	});
+
+	me.statecategories.forEach(function(cat) {
+	    cat.count = 0;
+	    cat.states = [];
+	});
+
+	pgs_by_state.forEach(function(state) {
+	    let states = state.state_name.split(/[^a-z]+/);
+	    let result = 0;
+	    for (let i = 0; i < states.length; i++) {
+		if (me.pgstates[states[i]] > result) {
+		    result = me.pgstates[states[i]];
+		}
+	    }
+	    // for the list
+	    state.cls = me.statecategories[result].cls;
+
+	    me.statecategories[result].count += state.count;
+	    me.statecategories[result].states.push(state);
+	});
+
+	me.chart.getStore().setData(me.statecategories);
+	me.getComponent('pgs').update({ states: pgs_by_state });
+
+	let health = status.health || {};
+	// we collect monitor/osd information from the checks
+	const downinregex = /(\d+) osds down/;
+	let downin_osds = 0;
+	Ext.Object.each(health.checks, function(key, value, obj) {
+	    var found = null;
+	    if (key === 'OSD_DOWN') {
+		found = value.summary.message.match(downinregex);
+		if (found !== null) {
+		    downin_osds = parseInt(found[1], 10);
+		}
+	    }
+	});
+
+	let osdmap = status.osdmap || {};
+	if (typeof osdmap.osdmap !== "undefined") {
+	    osdmap = osdmap.osdmap;
+	}
+	// update OSDs counts
+	let total_osds = osdmap.num_osds || 0;
+	let in_osds = osdmap.num_in_osds || 0;
+	let up_osds = osdmap.num_up_osds || 0;
+	let down_osds = total_osds - up_osds;
+
+	let downout_osds = down_osds - downin_osds;
+	let upin_osds = in_osds - downin_osds;
+	let upout_osds = up_osds - upin_osds;
+
+	let osds = {
+	    total: total_osds,
+	    upin: upin_osds,
+	    upout: upout_osds,
+	    downin: downin_osds,
+	    downout: downout_osds,
+	    oldOSD: oldOSD,
+	    ghostOSD,
+	};
+	let osdcomponent = me.getComponent('osds');
+	osdcomponent.update(Ext.apply(osdcomponent.data, osds));
+
+	me.suspendLayout = false;
+	me.updateLayout();
+    },
+
+     initComponent: function() {
+	var me = this;
+	me.callParent();
+
+	me.chart = me.getComponent('pgchart');
+	me.checkThemeColors();
+
+	// switch colors on media query changes
+	me.mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
+	me.themeListener = (e) => { me.checkThemeColors(); };
+	me.mediaQueryList.addEventListener("change", me.themeListener);
+    },
+
+    doDestroy: function() {
+	let me = this;
+
+	me.mediaQueryList.removeEventListener("change", me.themeListener);
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.node.ACMEAccountCreate', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 450,
+    title: gettext('Register Account'),
+    isCreate: true,
+    method: 'POST',
+    submitText: gettext('Register'),
+    url: '/cluster/acme/account',
+    showTaskViewer: true,
+    defaultExists: false,
+    referenceHolder: true,
+    onlineHelp: "sysadmin_certs_acme_account",
+
+    viewModel: {
+	data: {
+	    customDirectory: false,
+	    eabRequired: false,
+	},
+	formulas: {
+	    eabEmptyText: function(get) {
+		return get('eabRequired') ? gettext("required") : gettext("optional");
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Account Name'),
+	    name: 'name',
+	    cbind: {
+		emptyText: (get) => get('defaultExists') ? '' : 'default',
+		allowBlank: (get) => !get('defaultExists'),
+	    },
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'contact',
+	    vtype: 'email',
+	    allowBlank: false,
+	    fieldLabel: gettext('E-Mail'),
+	},
+	{
+	    xtype: 'proxmoxComboGrid',
+	    notFoundIsValid: true,
+	    isFormField: false,
+	    allowBlank: false,
+	    valueField: 'url',
+	    displayField: 'name',
+	    fieldLabel: gettext('ACME Directory'),
+	    store: {
+		listeners: {
+		    'load': function() {
+			this.add({ name: gettext("Custom"), url: '' });
+		    },
+		},
+		autoLoad: true,
+		fields: ['name', 'url'],
+		idProperty: ['name'],
+		proxy: {
+		    type: 'proxmox',
+		    url: '/api2/json/cluster/acme/directories',
+		},
+	    },
+	    listConfig: {
+		columns: [
+		    {
+			header: gettext('Name'),
+			dataIndex: 'name',
+			flex: 1,
+		    },
+		    {
+			header: gettext('URL'),
+			dataIndex: 'url',
+			flex: 1,
+		    },
+		],
+	    },
+	    listeners: {
+		change: function(combogrid, value) {
+		    let me = this;
+
+		    let vm = me.up('window').getViewModel();
+		    let dirField = me.up('window').lookupReference('directoryInput');
+		    let tosButton = me.up('window').lookupReference('queryTos');
+
+		    let isCustom = combogrid.getSelection().get('name') === gettext("Custom");
+		    vm.set('customDirectory', isCustom);
+
+		    dirField.setValue(value);
+
+		    if (!isCustom) {
+			tosButton.click();
+		    } else {
+			me.up('window').clearToSFields();
+		    }
+		},
+	    },
+	},
+	{
+	    xtype: 'fieldcontainer',
+	    layout: 'hbox',
+	    fieldLabel: gettext('URL'),
+	    bind: {
+		hidden: '{!customDirectory}',
+	    },
+	    items: [
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'directory',
+		    reference: 'directoryInput',
+		    flex: 1,
+		    allowBlank: false,
+		    listeners: {
+			change: function(textbox, value) {
+			    let me = this;
+			    me.up('window').clearToSFields();
+			},
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    margin: '0 0 0 5',
+		    reference: 'queryTos',
+		    text: gettext('Query URL'),
+		    listeners: {
+			click: function(button) {
+			    let me = this;
+
+			    let w = me.up('window');
+			    let vm = w.getViewModel();
+			    let disp = w.down('#tos_url_display');
+			    let field = w.down('#tos_url');
+			    let checkbox = w.down('#tos_checkbox');
+			    let value = w.lookupReference('directoryInput').getValue();
+			    w.clearToSFields();
+
+			    if (!value) {
+				return;
+			    } else {
+				disp.setValue(gettext("Loading"));
+			    }
+
+			    Proxmox.Utils.API2Request({
+				url: '/cluster/acme/meta',
+				method: 'GET',
+				params: {
+				    directory: value,
+				},
+				success: function(response, opt) {
+				    if (response.result.data && response.result.data.termsOfService) {
+					field.setValue(response.result.data.termsOfService);
+					disp.setValue(response.result.data.termsOfService);
+					checkbox.setHidden(false);
+				    } else {
+					// Needed to pass input verification and enable register button
+					// has no influence on the submitted form
+					checkbox.setValue(true);
+					disp.setValue("No terms of service agreement required");
+				    }
+				    vm.set('eabRequired', !!(response.result.data &&
+					response.result.data.externalAccountRequired));
+				},
+				failure: function(response, opt) {
+				    disp.setValue(undefined);
+				    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+				},
+			    });
+			},
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'displayfield',
+	    itemId: 'tos_url_display',
+	    renderer: PVE.Utils.render_optional_url,
+	    name: 'tos_url_display',
+	},
+	{
+	    xtype: 'hidden',
+	    itemId: 'tos_url',
+	    name: 'tos_url',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    itemId: 'tos_checkbox',
+	    boxLabel: gettext('Accept TOS'),
+	    submitValue: false,
+	    validateValue: function(value) {
+		if (value && this.checked) {
+		    return true;
+		}
+		return false;
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'eab-kid',
+	    fieldLabel: gettext('EAB Key ID'),
+	    bind: {
+		hidden: '{!customDirectory}',
+		allowBlank: '{!eabRequired}',
+		emptyText: '{eabEmptyText}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'eab-hmac-key',
+	    fieldLabel: gettext('EAB Key'),
+	    bind: {
+		hidden: '{!customDirectory}',
+		allowBlank: '{!eabRequired}',
+		emptyText: '{eabEmptyText}',
+	    },
+	},
+    ],
+
+    clearToSFields: function() {
+	let me = this;
+
+	let disp = me.down('#tos_url_display');
+	let field = me.down('#tos_url');
+	let checkbox = me.down('#tos_checkbox');
+
+	disp.setValue("Terms of service not fetched yet");
+	field.setValue(undefined);
+	checkbox.setValue(undefined);
+	checkbox.setHidden(true);
+    },
+
+});
+
+Ext.define('PVE.node.ACMEAccountView', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 600,
+    fieldDefaults: {
+	labelWidth: 140,
+    },
+
+    title: gettext('Account'),
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('E-Mail'),
+	    name: 'email',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Created'),
+	    name: 'createdAt',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Status'),
+	    name: 'status',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Directory'),
+	    renderer: PVE.Utils.render_optional_url,
+	    name: 'directory',
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Terms of Services'),
+	    renderer: PVE.Utils.render_optional_url,
+	    name: 'tos',
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.accountname) {
+	    throw "no account name defined";
+	}
+
+	me.url = '/cluster/acme/account/' + me.accountname;
+
+	me.callParent();
+
+	// hide OK/Reset button, because we just want to show data
+	me.down('toolbar[dock=bottom]').setVisible(false);
+
+	me.load({
+	    success: function(response) {
+		var data = response.result.data;
+		data.email = data.account.contact[0];
+		data.createdAt = data.account.createdAt;
+		data.status = data.account.status;
+		me.setValues(data);
+	    },
+	});
+    },
+});
+
+Ext.define('PVE.node.ACMEDomainEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveACMEDomainEdit',
+
+    subject: gettext('Domain'),
+    isCreate: false,
+    width: 450,
+    onlineHelp: 'sysadmin_certificate_management',
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    onGetValues: function(values) {
+		let me = this;
+		let win = me.up('pveACMEDomainEdit');
+		let nodeconfig = win.nodeconfig;
+		let olddomain = win.domain || {};
+
+		let params = {
+		    digest: nodeconfig.digest,
+		};
+
+		let configkey = olddomain.configkey;
+		let acmeObj = PVE.Parser.parseACME(nodeconfig.acme);
+
+		if (values.type === 'dns') {
+		    if (!olddomain.configkey || olddomain.configkey === 'acme') {
+			// look for first free slot
+			for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
+			    if (nodeconfig[`acmedomain${i}`] === undefined) {
+				configkey = `acmedomain${i}`;
+				break;
+			    }
+			}
+			if (olddomain.domain) {
+			    // we have to remove the domain from the acme domainlist
+			    PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
+			    params.acme = PVE.Parser.printACME(acmeObj);
+			}
+		    }
+
+		    delete values.type;
+		    params[configkey] = PVE.Parser.printPropertyString(values, 'domain');
+		} else {
+		    if (olddomain.configkey && olddomain.configkey !== 'acme') {
+			// delete the old dns entry
+			params.delete = [olddomain.configkey];
+		    }
+
+		    // add new, remove old and make entries unique
+		    PVE.Utils.add_domain_to_acme(acmeObj, values.domain);
+		    PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
+		    params.acme = PVE.Parser.printACME(acmeObj);
+		}
+
+		return params;
+	    },
+	    items: [
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    name: 'type',
+		    fieldLabel: gettext('Challenge Type'),
+		    allowBlank: false,
+		    value: 'standalone',
+		    comboItems: [
+			['standalone', 'HTTP'],
+			['dns', 'DNS'],
+		    ],
+		    validator: function(value) {
+			let me = this;
+			let win = me.up('pveACMEDomainEdit');
+			let oldconfigkey = win.domain ? win.domain.configkey : undefined;
+			let val = me.getValue();
+			if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) {
+			    // we have to check if there is a 'acmedomain' slot left
+			    let found = false;
+			    for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
+				if (!win.nodeconfig[`acmedomain${i}`]) {
+				    found = true;
+				}
+			    }
+			    if (!found) {
+				return gettext('Only 5 Domains with type DNS can be configured');
+			    }
+			}
+
+			return true;
+		    },
+		    listeners: {
+			change: function(cb, value) {
+			    let me = this;
+			    let view = me.up('pveACMEDomainEdit');
+			    let pluginField = view.down('field[name=plugin]');
+			    pluginField.setDisabled(value !== 'dns');
+			    pluginField.setHidden(value !== 'dns');
+			},
+		    },
+		},
+		{
+		    xtype: 'hidden',
+		    name: 'alias',
+		},
+		{
+		    xtype: 'pveACMEPluginSelector',
+		    name: 'plugin',
+		    disabled: true,
+		    hidden: true,
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'domain',
+		    allowBlank: false,
+		    vtype: 'DnsName',
+		    value: '',
+		    fieldLabel: gettext('Domain'),
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw 'no nodename given';
+	}
+
+	if (!me.nodeconfig) {
+	    throw 'no nodeconfig given';
+	}
+
+	me.isCreate = !me.domain;
+	if (me.isCreate) {
+	    me.domain = `${me.nodename}.`; // TODO: FQDN of node
+	}
+
+	me.url = `/api2/extjs/nodes/${me.nodename}/config`;
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.setValues(me.domain);
+	} else {
+	    me.setValues({ domain: me.domain });
+	}
+    },
+});
+
+Ext.define('pve-acme-domains', {
+    extend: 'Ext.data.Model',
+    fields: ['domain', 'type', 'alias', 'plugin', 'configkey'],
+    idProperty: 'domain',
+});
+
+Ext.define('PVE.node.ACME', {
+    extend: 'Ext.grid.Panel',
+    alias: 'widget.pveACMEView',
+
+    margin: '10 0 0 0',
+    title: 'ACME',
+
+    emptyText: gettext('No Domains configured'),
+
+    viewModel: {
+	data: {
+	    domaincount: 0,
+	    account: undefined, // the account we display
+	    configaccount: undefined, // the account set in the config
+	    accountEditable: false,
+	    accountsAvailable: false,
+	},
+
+	formulas: {
+	    canOrder: (get) => !!get('account') && get('domaincount') > 0,
+	    editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'),
+	    editBtnText: (get) => get('accountEditable') ? gettext('Apply') : gettext('Edit'),
+	    accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'),
+	    accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'),
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let accountSelector = this.lookup('accountselector');
+	    accountSelector.store.on('load', this.onAccountsLoad, this);
+	},
+
+	onAccountsLoad: function(store, records, success) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let configaccount = vm.get('configaccount');
+	    vm.set('accountsAvailable', records.length > 0);
+	    if (me.autoChangeAccount && records.length > 0) {
+		me.changeAccount(records[0].data.name, () => {
+		    vm.set('accountEditable', false);
+		    me.reload();
+		});
+		me.autoChangeAccount = false;
+	    } else if (configaccount) {
+		if (store.findExact('name', configaccount) !== -1) {
+		    vm.set('account', configaccount);
+		} else {
+		    vm.set('account', null);
+		}
+	    }
+	},
+
+	addDomain: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    Ext.create('PVE.node.ACMEDomainEdit', {
+		nodename: view.nodename,
+		nodeconfig: view.nodeconfig,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	editDomain: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+
+	    Ext.create('PVE.node.ACMEDomainEdit', {
+		nodename: view.nodename,
+		nodeconfig: view.nodeconfig,
+		domain: selection[0].data,
+		apiCallDone: function() {
+		    me.reload();
+		},
+	    }).show();
+	},
+
+	removeDomain: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+
+	    let rec = selection[0].data;
+	    let params = {};
+	    if (rec.configkey !== 'acme') {
+		params.delete = rec.configkey;
+	    } else {
+		let acme = PVE.Parser.parseACME(view.nodeconfig.acme);
+		PVE.Utils.remove_domain_from_acme(acme, rec.domain);
+		params.acme = PVE.Parser.printACME(acme);
+	    }
+
+	    Proxmox.Utils.API2Request({
+		method: 'PUT',
+		url: `/nodes/${view.nodename}/config`,
+		params,
+		success: function(response, opt) {
+		    me.reload();
+		},
+		failure: function(response, opt) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	},
+
+	toggleEditAccount: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let editable = vm.get('accountEditable');
+	    if (editable) {
+		me.changeAccount(vm.get('account'), function() {
+		    vm.set('accountEditable', false);
+		    me.reload();
+		});
+	    } else {
+		vm.set('accountEditable', true);
+	    }
+	},
+
+	changeAccount: function(account, callback) {
+	    let me = this;
+	    let view = me.getView();
+	    let params = {};
+
+	    let acme = PVE.Parser.parseACME(view.nodeconfig.acme);
+	    acme.account = account;
+	    params.acme = PVE.Parser.printACME(acme);
+
+	    Proxmox.Utils.API2Request({
+		method: 'PUT',
+		waitMsgTarget: view,
+		url: `/nodes/${view.nodename}/config`,
+		params,
+		success: function(response, opt) {
+		    if (Ext.isFunction(callback)) {
+			callback();
+		    }
+		},
+		failure: function(response, opt) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	},
+
+	order: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    Proxmox.Utils.API2Request({
+		method: 'POST',
+		params: {
+		    force: 1,
+		},
+		url: `/nodes/${view.nodename}/certificates/acme/certificate`,
+		success: function(response, opt) {
+		    Ext.create('Proxmox.window.TaskViewer', {
+		        upid: response.result.data,
+		        taskDone: function(success) {
+			    me.orderFinished(success);
+		        },
+		    }).show();
+		},
+		failure: function(response, opt) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	},
+
+	orderFinished: function(success) {
+	    if (!success) return;
+	    // reload only if the Web UI is open on the same node that the cert was ordered for
+	    if (this.getView().nodename !== Proxmox.NodeName) {
+		return;
+	    }
+	    var txt = gettext('pveproxy will be restarted with new certificates, please reload the GUI!');
+	    Ext.getBody().mask(txt, ['pve-static-mask']);
+	    // reload after 10 seconds automatically
+	    Ext.defer(function() {
+		window.location.reload(true);
+	    }, 10000);
+	},
+
+	reload: function() {
+	    let me = this;
+	    let view = me.getView();
+	    view.rstore.load();
+	},
+
+	addAccount: function() {
+	    let me = this;
+	    Ext.create('PVE.node.ACMEAccountCreate', {
+		autoShow: true,
+		taskDone: function() {
+		    me.reload();
+		    let accountSelector = me.lookup('accountselector');
+		    me.autoChangeAccount = true;
+		    accountSelector.store.load();
+		},
+	    });
+	},
+    },
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Add'),
+	    handler: 'addDomain',
+	    selModel: false,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    disabled: true,
+	    handler: 'editDomain',
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    handler: 'removeDomain',
+	},
+	'-',
+	{
+	    xtype: 'button',
+	    reference: 'order',
+	    text: gettext('Order Certificates Now'),
+	    bind: {
+		disabled: '{!canOrder}',
+	    },
+	    handler: 'order',
+	},
+	'-',
+	{
+	    xtype: 'displayfield',
+	    value: gettext('Using Account') + ':',
+	    bind: {
+		hidden: '{!accountsAvailable}',
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    reference: 'accounttext',
+	    renderer: (val) => val || Proxmox.Utils.NoneText,
+	    bind: {
+		value: '{account}',
+		hidden: '{accountTextHidden}',
+	    },
+	},
+	{
+	    xtype: 'pveACMEAccountSelector',
+	    hidden: true,
+	    reference: 'accountselector',
+	    bind: {
+		value: '{account}',
+		hidden: '{accountValueHidden}',
+	    },
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa black fa-pencil',
+	    bind: {
+		iconCls: '{editBtnIcon}',
+		text: '{editBtnText}',
+		hidden: '{!accountsAvailable}',
+	    },
+	    handler: 'toggleEditAccount',
+	},
+	{
+	    xtype: 'displayfield',
+	    value: gettext('No Account available.'),
+	    bind: {
+		hidden: '{accountsAvailable}',
+	    },
+	},
+	{
+	    xtype: 'button',
+	    hidden: true,
+	    reference: 'accountlink',
+	    text: gettext('Add ACME Account'),
+	    bind: {
+		hidden: '{accountsAvailable}',
+	    },
+	    handler: 'addAccount',
+	},
+    ],
+
+    updateStore: function(store, records, success) {
+	let me = this;
+	let data = [];
+	let rec;
+	if (success && records.length > 0) {
+	    rec = records[0];
+	} else {
+	    rec = {
+		data: {},
+	    };
+	}
+
+	me.nodeconfig = rec.data; // save nodeconfig for updates
+
+	let account = 'default';
+
+	if (rec.data.acme) {
+	    let obj = PVE.Parser.parseACME(rec.data.acme);
+	    (obj.domains || []).forEach(domain => {
+		if (domain === '') return;
+		let record = {
+		    domain,
+		    type: 'standalone',
+		    configkey: 'acme',
+		};
+		data.push(record);
+	    });
+
+	    if (obj.account) {
+		account = obj.account;
+	    }
+	}
+
+	let vm = me.getViewModel();
+	let oldaccount = vm.get('account');
+
+	// account changed, and we do not edit currently, load again to verify
+	if (oldaccount !== account && !vm.get('accountEditable')) {
+	    vm.set('configaccount', account);
+	    me.lookup('accountselector').store.load();
+	}
+
+	for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
+	    let acmedomain = rec.data[`acmedomain${i}`];
+	    if (!acmedomain) continue;
+
+	    let record = PVE.Parser.parsePropertyString(acmedomain, 'domain');
+	    record.type = 'dns';
+	    record.configkey = `acmedomain${i}`;
+	    data.push(record);
+	}
+
+	vm.set('domaincount', data.length);
+	me.store.loadData(data, false);
+    },
+
+    listeners: {
+	itemdblclick: 'editDomain',
+    },
+
+    columns: [
+	{
+	    dataIndex: 'domain',
+	    flex: 5,
+	    text: gettext('Domain'),
+	},
+	{
+	    dataIndex: 'type',
+	    flex: 1,
+	    text: gettext('Type'),
+	},
+	{
+	    dataIndex: 'plugin',
+	    flex: 1,
+	    text: gettext('Plugin'),
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	me.rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 10 * 1000,
+	    autoStart: true,
+	    storeid: `pve-node-domains-${me.nodename}`,
+	    proxy: {
+		type: 'proxmox',
+		url: `/api2/json/nodes/${me.nodename}/config`,
+	    },
+	});
+
+	me.store = Ext.create('Ext.data.Store', {
+	    model: 'pve-acme-domains',
+	    sorters: 'domain',
+	});
+
+	me.callParent();
+	me.mon(me.rstore, 'load', 'updateStore', me);
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+	me.on('destroy', me.rstore.stopUpdate, me.rstore);
+    },
+});
+Ext.define('PVE.node.CertificateView', {
+    extend: 'Ext.container.Container',
+    xtype: 'pveCertificatesView',
+
+    onlineHelp: 'sysadmin_certificate_management',
+
+    mixins: ['Proxmox.Mixin.CBind'],
+    scrollable: 'y',
+
+    items: [
+	{
+	    xtype: 'pveCertView',
+	    border: 0,
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	},
+	{
+	    xtype: 'pveACMEView',
+	    border: 0,
+	    cbind: {
+		nodename: '{nodename}',
+	    },
+	},
+    ],
+
+});
+
+Ext.define('PVE.node.CertificateViewer', {
+    extend: 'Proxmox.window.Edit',
+
+    title: gettext('Certificate'),
+
+    fieldDefaults: {
+	labelWidth: 120,
+    },
+    width: 800,
+
+    items: {
+	xtype: 'inputpanel',
+	maxHeight: 900,
+	scrollable: 'y',
+	columnT: [
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Name'),
+		name: 'filename',
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Fingerprint'),
+		name: 'fingerprint',
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Issuer'),
+		name: 'issuer',
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Subject'),
+		name: 'subject',
+	    },
+	],
+	column1: [
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Public Key Type'),
+		name: 'public-key-type',
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Public Key Size'),
+		name: 'public-key-bits',
+	    },
+	],
+	column2: [
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Valid Since'),
+		renderer: Proxmox.Utils.render_timestamp,
+		name: 'notbefore',
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Expires'),
+		renderer: Proxmox.Utils.render_timestamp,
+		name: 'notafter',
+	    },
+	],
+	columnB: [
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Subject Alternative Names'),
+		name: 'san',
+		renderer: PVE.Utils.render_san,
+	    },
+	    {
+		xtype: 'fieldset',
+		title: gettext('Raw Certificate'),
+		collapsible: true,
+		collapsed: true,
+		items: [{
+		    xtype: 'textarea',
+		    name: 'pem',
+		    editable: false,
+		    grow: true,
+		    growMax: 350,
+		    fieldStyle: {
+			'white-space': 'pre-wrap',
+			'font-family': 'monospace',
+		    },
+		}],
+	    },
+	],
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.cert) {
+	    throw "no cert given";
+	}
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	me.url = `/nodes/${me.nodename}/certificates/info`;
+	me.callParent();
+
+	// hide OK/Reset button, because we just want to show data
+	me.down('toolbar[dock=bottom]').setVisible(false);
+
+	me.load({
+	    success: function(response) {
+		if (Ext.isArray(response.result.data)) {
+		    for (const item of response.result.data) {
+			if (item.filename === me.cert) {
+			    me.setValues(item);
+			    return;
+			}
+		    }
+		}
+	    },
+	});
+    },
+});
+
+Ext.define('PVE.node.CertUpload', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCertUpload',
+
+    title: gettext('Upload Custom Certificate'),
+    resizable: false,
+    isCreate: true,
+    submitText: gettext('Upload'),
+    method: 'POST',
+    width: 600,
+
+    apiCallDone: function(success, response, options) {
+	if (!success) {
+	    return;
+	}
+	let txt = gettext('API server will be restarted to use new certificates, please reload web-interface!');
+	Ext.getBody().mask(txt, ['pve-static-mask']);
+	Ext.defer(() => window.location.reload(true), 10000); // reload after 10 seconds automatically
+    },
+
+    items: {
+	xtype: 'inputpanel',
+	onGetValues: function(values) {
+	    values.restart = 1;
+	    values.force = 1;
+	    if (!values.key) {
+		delete values.key;
+	    }
+	    return values;
+	},
+	items: [
+	    {
+		fieldLabel: gettext('Private Key (Optional)'),
+		labelAlign: 'top',
+		emptyText: gettext('No change'),
+		name: 'key',
+		xtype: 'textarea',
+	    },
+	    {
+		xtype: 'filebutton',
+		text: gettext('From File'),
+		listeners: {
+		    change: function(btn, e, value) {
+			let form = this.up('form');
+			for (const file of e.event.target.files) {
+			    PVE.Utils.loadFile(file, res => form.down('field[name=key]').setValue(res));
+			}
+			btn.reset();
+		    },
+		},
+	    },
+	    {
+		fieldLabel: gettext('Certificate Chain'),
+		labelAlign: 'top',
+		allowBlank: false,
+		name: 'certificates',
+		xtype: 'textarea',
+	    },
+	    {
+		xtype: 'filebutton',
+		text: gettext('From File'),
+		listeners: {
+		    change: function(btn, e, value) {
+			let form = this.up('form');
+			for (const file of e.event.target.files) {
+			    PVE.Utils.loadFile(file, res => form.down('field[name=certificates]').setValue(res));
+			}
+			btn.reset();
+		    },
+		},
+	    },
+	],
+    },
+
+    initComponent: function() {
+	let me = this;
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+	me.url = `/nodes/${me.nodename}/certificates/custom`;
+
+	me.callParent();
+    },
+});
+
+Ext.define('pve-certificate', {
+    extend: 'Ext.data.Model',
+    fields: ['filename', 'fingerprint', 'issuer', 'notafter', 'notbefore', 'subject', 'san', 'public-key-bits', 'public-key-type'],
+    idProperty: 'filename',
+});
+
+Ext.define('PVE.node.Certificates', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveCertView',
+
+    tbar: [
+	{
+	    xtype: 'button',
+	    text: gettext('Upload Custom Certificate'),
+	    handler: function() {
+		let view = this.up('grid');
+		Ext.create('PVE.node.CertUpload', {
+		    nodename: view.nodename,
+		    listeners: {
+			destroy: () => view.reload(),
+		    },
+		    autoShow: true,
+		});
+	    },
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    itemId: 'deletebtn',
+	    text: gettext('Delete Custom Certificate'),
+	    dangerous: true,
+	    selModel: false,
+	    getUrl: function(rec) {
+		let view = this.up('grid');
+		return `/nodes/${view.nodename}/certificates/custom?restart=1`;
+	    },
+	    confirmMsg: gettext('Delete custom certificate and switch to generated one?'),
+	    callback: function(options, success, response) {
+		if (success) {
+		    let txt = gettext('API server will be restarted to use new certificates, please reload web-interface!');
+		    Ext.getBody().mask(txt, ['pve-static-mask']);
+		    // reload after 10 seconds automatically
+		    Ext.defer(() => window.location.reload(true), 10000);
+		}
+	    },
+	},
+	'-',
+	{
+	    xtype: 'proxmoxButton',
+	    itemId: 'viewbtn',
+	    disabled: true,
+	    text: gettext('View Certificate'),
+	    handler: function() {
+		this.up('grid').viewCertificate();
+	    },
+	},
+    ],
+
+    columns: [
+	{
+	    header: gettext('File'),
+	    width: 150,
+	    dataIndex: 'filename',
+	},
+	{
+	    header: gettext('Issuer'),
+	    flex: 1,
+	    dataIndex: 'issuer',
+	},
+	{
+	    header: gettext('Subject'),
+	    flex: 1,
+	    dataIndex: 'subject',
+	},
+	{
+	    header: gettext('Public Key Alogrithm'),
+	    flex: 1,
+	    dataIndex: 'public-key-type',
+	    hidden: true,
+	},
+	{
+	    header: gettext('Public Key Size'),
+	    flex: 1,
+	    dataIndex: 'public-key-bits',
+	    hidden: true,
+	},
+	{
+	    header: gettext('Valid Since'),
+	    width: 150,
+	    dataIndex: 'notbefore',
+	    renderer: Proxmox.Utils.render_timestamp,
+	},
+	{
+	    header: gettext('Expires'),
+	    width: 150,
+	    dataIndex: 'notafter',
+	    renderer: Proxmox.Utils.render_timestamp,
+	},
+	{
+	    header: gettext('Subject Alternative Names'),
+	    flex: 1,
+	    dataIndex: 'san',
+	    renderer: PVE.Utils.render_san,
+	},
+	{
+	    header: gettext('Fingerprint'),
+	    dataIndex: 'fingerprint',
+	    hidden: true,
+	},
+	{
+	    header: gettext('PEM'),
+	    dataIndex: 'pem',
+	    hidden: true,
+	},
+    ],
+
+    reload: function() {
+	this.rstore.load();
+    },
+
+    viewCertificate: function() {
+	let me = this;
+	let selection = me.getSelection();
+	if (!selection || selection.length < 1) {
+	    return;
+	}
+	var win = Ext.create('PVE.node.CertificateViewer', {
+	    cert: selection[0].data.filename,
+	    nodename: me.nodename,
+	});
+	win.show();
+    },
+
+    listeners: {
+	itemdblclick: 'viewCertificate',
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no nodename given";
+	}
+
+	me.rstore = Ext.create('Proxmox.data.UpdateStore', {
+	    storeid: 'certs-' + me.nodename,
+	    model: 'pve-certificate',
+	    proxy: {
+		type: 'proxmox',
+		    url: '/api2/json/nodes/' + me.nodename + '/certificates/info',
+	    },
+	});
+
+	me.store = {
+	    type: 'diff',
+	    rstore: me.rstore,
+	};
+
+	me.callParent();
+
+	me.mon(me.rstore, 'load', store => me.down('#deletebtn').setDisabled(!store.getById('pveproxy-ssl.pem')));
+	me.rstore.startUpdate();
+	me.on('destroy', me.rstore.stopUpdate, me.rstore);
+    },
+});
+Ext.define('PVE.node.CmdMenu', {
+    extend: 'Ext.menu.Menu',
+    xtype: 'nodeCmdMenu',
+
+    showSeparator: false,
+
+    items: [
+	{
+	    text: gettext('Create VM'),
+	    itemId: 'createvm',
+	    iconCls: 'fa fa-desktop',
+	    handler: function() {
+		Ext.create('PVE.qemu.CreateWizard', {
+		    nodename: this.up('menu').nodename,
+		    autoShow: true,
+		});
+	    },
+	},
+	{
+	    text: gettext('Create CT'),
+	    itemId: 'createct',
+	    iconCls: 'fa fa-cube',
+	    handler: function() {
+		Ext.create('PVE.lxc.CreateWizard', {
+		    nodename: this.up('menu').nodename,
+		    autoShow: true,
+		});
+	    },
+	},
+	{ xtype: 'menuseparator' },
+	{
+	    text: gettext('Bulk Start'),
+	    itemId: 'bulkstart',
+	    iconCls: 'fa fa-fw fa-play',
+	    handler: function() {
+		Ext.create('PVE.window.BulkAction', {
+		    nodename: this.up('menu').nodename,
+		    title: gettext('Bulk Start'),
+		    btnText: gettext('Start'),
+		    action: 'startall',
+		    autoShow: true,
+		});
+	    },
+	},
+	{
+	    text: gettext('Bulk Shutdown'),
+	    itemId: 'bulkstop',
+	    iconCls: 'fa fa-fw fa-stop',
+	    handler: function() {
+		Ext.create('PVE.window.BulkAction', {
+		    nodename: this.up('menu').nodename,
+		    title: gettext('Bulk Shutdown'),
+		    btnText: gettext('Shutdown'),
+		    action: 'stopall',
+		    autoShow: true,
+		});
+	    },
+	},
+	{
+	    text: gettext('Bulk Suspend'),
+	    itemId: 'bulksuspend',
+	    iconCls: 'fa fa-fw fa-download',
+	    handler: function() {
+		Ext.create('PVE.window.BulkAction', {
+		    nodename: this.up('menu').nodename,
+		    title: gettext('Bulk Suspend'),
+		    btnText: gettext('Suspend'),
+		    action: 'suspendall',
+		    autoShow: true,
+		});
+	    },
+	},
+	{
+	    text: gettext('Bulk Migrate'),
+	    itemId: 'bulkmigrate',
+	    iconCls: 'fa fa-fw fa-send-o',
+	    handler: function() {
+		Ext.create('PVE.window.BulkAction', {
+		    nodename: this.up('menu').nodename,
+		    title: gettext('Bulk Migrate'),
+		    btnText: gettext('Migrate'),
+		    action: 'migrateall',
+		    autoShow: true,
+		});
+	    },
+	},
+	{ xtype: 'menuseparator' },
+	{
+	    text: gettext('Shell'),
+	    itemId: 'shell',
+	    iconCls: 'fa fa-fw fa-terminal',
+	    handler: function() {
+		let nodename = this.up('menu').nodename;
+		PVE.Utils.openDefaultConsoleWindow(true, 'shell', undefined, nodename, undefined);
+	    },
+	},
+	{ xtype: 'menuseparator' },
+	{
+	    text: gettext('Wake-on-LAN'),
+	    itemId: 'wakeonlan',
+	    iconCls: 'fa fa-fw fa-power-off',
+	    handler: function() {
+		let nodename = this.up('menu').nodename;
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${nodename}/wakeonlan`,
+		    method: 'POST',
+		    failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		    success: function(response, opts) {
+			Ext.Msg.show({
+			    title: 'Success',
+			    icon: Ext.Msg.INFO,
+			    msg: Ext.String.format(
+				gettext("Wake on LAN packet send for '{0}': '{1}'"),
+				nodename,
+				response.result.data,
+			    ),
+			});
+		    },
+		});
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw 'no nodename specified';
+	}
+
+	me.title = gettext('Node') + " '" + me.nodename + "'";
+	me.callParent();
+
+	let caps = Ext.state.Manager.get('GuiCap');
+
+	if (!caps.vms['VM.Allocate']) {
+	    me.getComponent('createct').setDisabled(true);
+	    me.getComponent('createvm').setDisabled(true);
+	}
+	if (!caps.vms['VM.Migrate']) {
+	    me.getComponent('bulkmigrate').setDisabled(true);
+	}
+	if (!caps.vms['VM.PowerMgmt']) {
+	    me.getComponent('bulkstart').setDisabled(true);
+	    me.getComponent('bulkstop').setDisabled(true);
+	    me.getComponent('bulksuspend').setDisabled(true);
+	}
+	if (!caps.nodes['Sys.PowerMgmt']) {
+	    me.getComponent('wakeonlan').setDisabled(true);
+	}
+	if (!caps.nodes['Sys.Console']) {
+	    me.getComponent('shell').setDisabled(true);
+	}
+	if (me.pveSelNode.data.running) {
+	    me.getComponent('wakeonlan').setDisabled(true);
+	}
+
+	if (PVE.Utils.isStandaloneNode()) {
+	    me.getComponent('bulkmigrate').setVisible(false);
+	}
+    },
+});
+Ext.define('PVE.node.Config', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.PVE.node.Config',
+
+    onlineHelp: 'chapter_system_administration',
+
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	me.statusStore = Ext.create('Proxmox.data.ObjectStore', {
+	    url: "/api2/json/nodes/" + nodename + "/status",
+	    interval: 5000,
+	});
+
+	var node_command = function(cmd) {
+	    Proxmox.Utils.API2Request({
+		params: { command: cmd },
+		url: '/nodes/' + nodename + '/status',
+		method: 'POST',
+		waitMsgTarget: me,
+		failure: function(response, opts) {
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+	    });
+	};
+
+	var actionBtn = Ext.create('Ext.Button', {
+	    text: gettext('Bulk Actions'),
+	    iconCls: 'fa fa-fw fa-ellipsis-v',
+	    disabled: !caps.vms['VM.PowerMgmt'] && !caps.vms['VM.Migrate'],
+	    menu: new Ext.menu.Menu({
+		items: [
+		    {
+			text: gettext('Bulk Start'),
+			iconCls: 'fa fa-fw fa-play',
+			disabled: !caps.vms['VM.PowerMgmt'],
+			handler: function() {
+			    Ext.create('PVE.window.BulkAction', {
+				autoShow: true,
+				nodename: nodename,
+				title: gettext('Bulk Start'),
+				btnText: gettext('Start'),
+				action: 'startall',
+			    });
+			},
+		    },
+		    {
+			text: gettext('Bulk Shutdown'),
+			iconCls: 'fa fa-fw fa-stop',
+			disabled: !caps.vms['VM.PowerMgmt'],
+			handler: function() {
+			    Ext.create('PVE.window.BulkAction', {
+				autoShow: true,
+				nodename: nodename,
+				title: gettext('Bulk Shutdown'),
+				btnText: gettext('Shutdown'),
+				action: 'stopall',
+			    });
+			},
+		    },
+		    {
+			text: gettext('Bulk Suspend'),
+			iconCls: 'fa fa-fw fa-download',
+			disabled: !caps.vms['VM.PowerMgmt'],
+			handler: function() {
+			    Ext.create('PVE.window.BulkAction', {
+				autoShow: true,
+				nodename: nodename,
+				title: gettext('Bulk Suspend'),
+				btnText: gettext('Suspend'),
+				action: 'suspendall',
+			    });
+			},
+		    },
+		    {
+			text: gettext('Bulk Migrate'),
+			iconCls: 'fa fa-fw fa-send-o',
+			disabled: !caps.vms['VM.Migrate'],
+			hidden: PVE.Utils.isStandaloneNode(),
+			handler: function() {
+			    Ext.create('PVE.window.BulkAction', {
+				autoShow: true,
+				nodename: nodename,
+				title: gettext('Bulk Migrate'),
+				btnText: gettext('Migrate'),
+				action: 'migrateall',
+			    });
+			},
+		    },
+		],
+	    }),
+	});
+
+	let restartBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Reboot'),
+	    disabled: !caps.nodes['Sys.PowerMgmt'],
+	    dangerous: true,
+	    confirmMsg: Ext.String.format(gettext("Reboot node '{0}'?"), nodename),
+	    handler: function() {
+		node_command('reboot');
+	    },
+	    iconCls: 'fa fa-undo',
+	});
+
+	var shutdownBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Shutdown'),
+	    disabled: !caps.nodes['Sys.PowerMgmt'],
+	    dangerous: true,
+	    confirmMsg: Ext.String.format(gettext("Shutdown node '{0}'?"), nodename),
+	    handler: function() {
+		node_command('shutdown');
+	    },
+	    iconCls: 'fa fa-power-off',
+	});
+
+	var shellBtn = Ext.create('PVE.button.ConsoleButton', {
+	    disabled: !caps.nodes['Sys.Console'],
+	    text: gettext('Shell'),
+	    consoleType: 'shell',
+	    nodename: nodename,
+	});
+
+	me.items = [];
+
+	Ext.apply(me, {
+	    title: gettext('Node') + " '" + nodename + "'",
+	    hstateid: 'nodetab',
+	    defaults: {
+		statusStore: me.statusStore,
+	    },
+	    tbar: [restartBtn, shutdownBtn, shellBtn, actionBtn],
+	});
+
+	if (caps.nodes['Sys.Audit']) {
+	    me.items.push(
+		{
+		    xtype: 'pveNodeSummary',
+		    title: gettext('Summary'),
+		    iconCls: 'fa fa-book',
+		    itemId: 'summary',
+		},
+		{
+		    xtype: 'pmxNotesView',
+		    title: gettext('Notes'),
+		    iconCls: 'fa fa-sticky-note-o',
+		    itemId: 'notes',
+		},
+	    );
+	}
+
+	if (caps.nodes['Sys.Console']) {
+	    me.items.push(
+		{
+		    xtype: 'pveNoVncConsole',
+		    title: gettext('Shell'),
+		    iconCls: 'fa fa-terminal',
+		    itemId: 'jsconsole',
+		    consoleType: 'shell',
+		    xtermjs: true,
+		    nodename: nodename,
+		},
+	    );
+	}
+
+	if (caps.nodes['Sys.Audit']) {
+	    me.items.push(
+		{
+		    xtype: 'proxmoxNodeServiceView',
+		    title: gettext('System'),
+		    iconCls: 'fa fa-cogs',
+		    itemId: 'services',
+		    expandedOnInit: true,
+		    restartCommand: 'reload', // avoid disruptions
+		    startOnlyServices: {
+			'pveproxy': true,
+			'pvedaemon': true,
+			'pve-cluster': true,
+		    },
+		    nodename: nodename,
+		    onlineHelp: 'pve_service_daemons',
+		},
+		{
+		    xtype: 'proxmoxNodeNetworkView',
+		    title: gettext('Network'),
+		    iconCls: 'fa fa-exchange',
+		    itemId: 'network',
+		    showApplyBtn: true,
+		    groups: ['services'],
+		    nodename: nodename,
+		    onlineHelp: 'sysadmin_network_configuration',
+		},
+		{
+		    xtype: 'pveCertificatesView',
+		    title: gettext('Certificates'),
+		    iconCls: 'fa fa-certificate',
+		    itemId: 'certificates',
+		    groups: ['services'],
+		    nodename: nodename,
+		},
+		{
+		    xtype: 'proxmoxNodeDNSView',
+		    title: gettext('DNS'),
+		    iconCls: 'fa fa-globe',
+		    groups: ['services'],
+		    itemId: 'dns',
+		    nodename: nodename,
+		    onlineHelp: 'sysadmin_network_configuration',
+		},
+		{
+		    xtype: 'proxmoxNodeHostsView',
+		    title: gettext('Hosts'),
+		    iconCls: 'fa fa-globe',
+		    groups: ['services'],
+		    itemId: 'hosts',
+		    nodename: nodename,
+		    onlineHelp: 'sysadmin_network_configuration',
+		},
+		{
+		    xtype: 'proxmoxNodeOptionsView',
+		    title: gettext('Options'),
+		    iconCls: 'fa fa-gear',
+		    groups: ['services'],
+		    itemId: 'options',
+		    nodename: nodename,
+		    onlineHelp: 'proxmox_node_management',
+		},
+		{
+		    xtype: 'proxmoxNodeTimeView',
+		    title: gettext('Time'),
+		    itemId: 'time',
+		    groups: ['services'],
+		    nodename: nodename,
+		    iconCls: 'fa fa-clock-o',
+		});
+	}
+
+	if (caps.nodes['Sys.Syslog']) {
+	    me.items.push({
+		xtype: 'proxmoxJournalView',
+		title: gettext('System Log'),
+		iconCls: 'fa fa-list',
+		groups: ['services'],
+		disabled: !caps.nodes['Sys.Syslog'],
+		itemId: 'syslog',
+		url: "/api2/extjs/nodes/" + nodename + "/journal",
+	    });
+
+	    if (caps.nodes['Sys.Modify']) {
+		me.items.push({
+		    xtype: 'proxmoxNodeAPT',
+		    title: gettext('Updates'),
+		    iconCls: 'fa fa-refresh',
+		    expandedOnInit: true,
+		    disabled: !caps.nodes['Sys.Console'],
+		    // do we want to link to system updates instead?
+		    itemId: 'apt',
+		    upgradeBtn: {
+			xtype: 'pveConsoleButton',
+			disabled: Proxmox.UserName !== 'root@pam',
+			text: gettext('Upgrade'),
+			consoleType: 'upgrade',
+			nodename: nodename,
+		    },
+		    nodename: nodename,
+		});
+
+		me.items.push({
+		    xtype: 'proxmoxNodeAPTRepositories',
+		    title: gettext('Repositories'),
+		    iconCls: 'fa fa-files-o',
+		    itemId: 'aptrepositories',
+		    nodename: nodename,
+		    onlineHelp: 'sysadmin_package_repositories',
+		    groups: ['apt'],
+		});
+	    }
+	}
+
+	if (caps.nodes['Sys.Audit']) {
+	    me.items.push(
+		{
+		    xtype: 'pveFirewallRules',
+		    iconCls: 'fa fa-shield',
+		    title: gettext('Firewall'),
+		    allow_iface: true,
+		    base_url: '/nodes/' + nodename + '/firewall/rules',
+		    list_refs_url: '/cluster/firewall/refs',
+		    itemId: 'firewall',
+		},
+		{
+		    xtype: 'pveFirewallOptions',
+		    title: gettext('Options'),
+		    iconCls: 'fa fa-gear',
+		    onlineHelp: 'pve_firewall_host_specific_configuration',
+		    groups: ['firewall'],
+		    base_url: '/nodes/' + nodename + '/firewall/options',
+		    fwtype: 'node',
+		    itemId: 'firewall-options',
+		});
+	}
+
+
+	if (caps.nodes['Sys.Audit']) {
+	    me.items.push(
+		{
+		    xtype: 'pmxDiskList',
+		    title: gettext('Disks'),
+		    itemId: 'storage',
+		    expandedOnInit: true,
+		    iconCls: 'fa fa-hdd-o',
+		    nodename: nodename,
+		    includePartitions: true,
+		    supportsWipeDisk: true,
+		},
+		{
+		    xtype: 'pveLVMList',
+		    title: 'LVM',
+		    itemId: 'lvm',
+		    onlineHelp: 'chapter_lvm',
+		    iconCls: 'fa fa-square',
+		    groups: ['storage'],
+		},
+		{
+		    xtype: 'pveLVMThinList',
+		    title: 'LVM-Thin',
+		    itemId: 'lvmthin',
+		    onlineHelp: 'chapter_lvm',
+		    iconCls: 'fa fa-square-o',
+		    groups: ['storage'],
+		},
+		{
+		    xtype: 'pveDirectoryList',
+		    title: Proxmox.Utils.directoryText,
+		    itemId: 'directory',
+		    onlineHelp: 'chapter_storage',
+		    iconCls: 'fa fa-folder',
+		    groups: ['storage'],
+		},
+		{
+		    title: 'ZFS',
+		    itemId: 'zfs',
+		    onlineHelp: 'chapter_zfs',
+		    iconCls: 'fa fa-th-large',
+		    groups: ['storage'],
+		    xtype: 'pveZFSList',
+		},
+		{
+		    xtype: 'pveNodeCephStatus',
+		    title: 'Ceph',
+		    itemId: 'ceph',
+		    iconCls: 'fa fa-ceph',
+		},
+		{
+		    xtype: 'pveNodeCephConfigCrush',
+		    title: gettext('Configuration'),
+		    iconCls: 'fa fa-gear',
+		    groups: ['ceph'],
+		    itemId: 'ceph-config',
+		},
+		{
+		    xtype: 'pveNodeCephMonMgr',
+		    title: gettext('Monitor'),
+		    iconCls: 'fa fa-tv',
+		    groups: ['ceph'],
+		    itemId: 'ceph-monlist',
+		},
+		{
+		    xtype: 'pveNodeCephOsdTree',
+		    title: 'OSD',
+		    iconCls: 'fa fa-hdd-o',
+		    groups: ['ceph'],
+		    itemId: 'ceph-osdtree',
+		},
+		{
+		    xtype: 'pveNodeCephFSPanel',
+		    title: 'CephFS',
+		    iconCls: 'fa fa-folder',
+		    groups: ['ceph'],
+		    nodename: nodename,
+		    itemId: 'ceph-cephfspanel',
+		},
+		{
+		    xtype: 'pveNodeCephPoolList',
+		    title: gettext('Pools'),
+		    iconCls: 'fa fa-sitemap',
+		    groups: ['ceph'],
+		    itemId: 'ceph-pools',
+		},
+		{
+		    xtype: 'pveReplicaView',
+		    iconCls: 'fa fa-retweet',
+		    title: gettext('Replication'),
+		    itemId: 'replication',
+		},
+	    );
+	}
+
+	if (caps.nodes['Sys.Syslog']) {
+	    me.items.push(
+		{
+		    xtype: 'proxmoxLogView',
+		    title: gettext('Log'),
+		    iconCls: 'fa fa-list',
+		    groups: ['firewall'],
+		    onlineHelp: 'chapter_pve_firewall',
+		    url: '/api2/extjs/nodes/' + nodename + '/firewall/log',
+		    itemId: 'firewall-fwlog',
+		    log_select_timespan: true,
+		    submitFormat: 'U',
+		},
+		{
+		    xtype: 'cephLogView',
+		    title: gettext('Log'),
+		    itemId: 'ceph-log',
+		    iconCls: 'fa fa-list',
+		    groups: ['ceph'],
+		    onlineHelp: 'chapter_pveceph',
+		    url: "/api2/extjs/nodes/" + nodename + "/ceph/log",
+		    nodename: nodename,
+		});
+	}
+
+	me.items.push(
+	    {
+		title: gettext('Task History'),
+		iconCls: 'fa fa-list-alt',
+		itemId: 'tasks',
+		nodename: nodename,
+		xtype: 'proxmoxNodeTasks',
+		extraFilter: [
+		    {
+			xtype: 'pveGuestIDSelector',
+			fieldLabel: 'VMID',
+			allowBlank: true,
+			name: 'vmid',
+		    },
+		],
+	    },
+	    {
+		title: gettext('Subscription'),
+		iconCls: 'fa fa-support',
+		itemId: 'support',
+		xtype: 'pveNodeSubscription',
+		nodename: nodename,
+	    },
+	);
+
+	me.callParent();
+
+	me.mon(me.statusStore, 'load', function(store, records, success) {
+	    let uptimerec = store.data.get('uptime');
+	    let powermgmt = caps.nodes['Sys.PowerMgmt'] && uptimerec && uptimerec.data.value;
+
+	    restartBtn.setDisabled(!powermgmt);
+	    shutdownBtn.setDisabled(!powermgmt);
+	    shellBtn.setDisabled(!powermgmt);
+	});
+
+	me.on('afterrender', function() {
+	    me.statusStore.startUpdate();
+	});
+
+	me.on('destroy', function() {
+	    me.statusStore.stopUpdate();
+	});
+    },
+});
+Ext.define('PVE.node.CreateDirectory', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCreateDirectory',
+
+    subject: Proxmox.Utils.directoryText,
+
+    showProgress: true,
+
+    onlineHelp: 'chapter_storage',
+
+    initComponent: function() {
+        var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.isCreate = true;
+
+        Ext.applyIf(me, {
+	    url: "/nodes/" + me.nodename + "/disks/directory",
+	    method: 'POST',
+	    items: [
+		{
+		    xtype: 'pmxDiskSelector',
+		    name: 'device',
+		    nodename: me.nodename,
+		    diskType: 'unused',
+		    includePartitions: true,
+		    fieldLabel: gettext('Disk'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxKVComboBox',
+		    comboItems: [
+			['ext4', 'ext4'],
+			['xfs', 'xfs'],
+		    ],
+		    fieldLabel: gettext('Filesystem'),
+		    name: 'filesystem',
+		    value: '',
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'name',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'add_storage',
+		    fieldLabel: gettext('Add Storage'),
+		    value: '1',
+		},
+            ],
+        });
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.node.Directorylist', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveDirectoryList',
+
+    viewModel: {
+	data: {
+	    path: '',
+	},
+	formulas: {
+	    dirName: (get) => get('path')?.replace('/mnt/pve/', '') || undefined,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	destroyDirectory: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let view = me.getView();
+
+	    const dirName = vm.get('dirName');
+
+	    if (!view.nodename) {
+		throw "no node name specified";
+	    }
+
+	    if (!dirName) {
+		throw "no directory name specified";
+	    }
+
+	    Ext.create('PVE.window.SafeDestroyStorage', {
+		url: `/nodes/${view.nodename}/disks/directory/${dirName}`,
+		item: { id: dirName },
+		taskName: 'dirremove',
+		taskDone: () => { view.reload(); },
+	    }).show();
+	},
+    },
+
+    stateful: true,
+    stateId: 'grid-node-directory',
+    columns: [
+	{
+	    text: gettext('Path'),
+	    dataIndex: 'path',
+	    flex: 1,
+	},
+	{
+	    header: gettext('Device'),
+	    flex: 1,
+	    dataIndex: 'device',
+	},
+	{
+	    header: gettext('Type'),
+	    width: 100,
+	    dataIndex: 'type',
+	},
+	{
+	    header: gettext('Options'),
+	    width: 100,
+	    dataIndex: 'options',
+	},
+	{
+	    header: gettext('Unit File'),
+	    hidden: true,
+	    dataIndex: 'unitfile',
+	},
+    ],
+
+    rootVisible: false,
+    useArrows: true,
+
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: function() {
+		this.up('panel').reload();
+	    },
+	},
+	{
+	    text: `${gettext('Create')}: ${gettext('Directory')}`,
+	    handler: function() {
+		let view = this.up('panel');
+		Ext.create('PVE.node.CreateDirectory', {
+		    nodename: view.nodename,
+		    listeners: {
+			destroy: () => view.reload(),
+		    },
+		    autoShow: true,
+		});
+	    },
+	},
+	'->',
+	{
+	    xtype: 'tbtext',
+	    data: {
+		dirName: undefined,
+	    },
+	    bind: {
+		data: {
+		    dirName: "{dirName}",
+		},
+	    },
+	    tpl: [
+		'<tpl if="dirName">',
+		gettext('Directory') + ' {dirName}:',
+		'<tpl else>',
+		Ext.String.format(gettext('No {0} selected'), gettext('directory')),
+		'</tpl>',
+	    ],
+	},
+	{
+	    text: gettext('More'),
+	    iconCls: 'fa fa-bars',
+	    disabled: true,
+	    bind: {
+		disabled: '{!dirName}',
+	    },
+	    menu: [
+		{
+		    text: gettext('Destroy'),
+		    itemId: 'remove',
+		    iconCls: 'fa fa-fw fa-trash-o',
+		    handler: 'destroyDirectory',
+		    disabled: true,
+		    bind: {
+			disabled: '{!dirName}',
+		    },
+		},
+	    ],
+	},
+    ],
+
+    reload: function() {
+	let me = this;
+	me.store.load();
+	me.store.sort();
+    },
+
+    listeners: {
+	activate: function() {
+	    this.reload();
+	},
+	selectionchange: function(model, selected) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    vm.set('path', selected[0]?.data.path || '');
+	},
+    },
+
+    initComponent: function() {
+        let me = this;
+
+	me.nodename = me.pveSelNode.data.node;
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    store: {
+		fields: ['path', 'device', 'type', 'options', 'unitfile'],
+		proxy: {
+		    type: 'proxmox',
+		    url: `/api2/json/nodes/${me.nodename}/disks/directory`,
+		},
+		sorters: 'path',
+	    },
+	});
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
+	me.reload();
+    },
+});
+
+Ext.define('PVE.node.CreateLVM', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCreateLVM',
+
+    onlineHelp: 'chapter_lvm',
+    subject: 'LVM Volume Group',
+
+    showProgress: true,
+    isCreate: true,
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.isCreate = true;
+
+        Ext.applyIf(me, {
+	    url: `/nodes/${me.nodename}/disks/lvm`,
+	    method: 'POST',
+	    items: [
+		{
+		    xtype: 'pmxDiskSelector',
+		    name: 'device',
+		    nodename: me.nodename,
+		    diskType: 'unused',
+		    includePartitions: true,
+		    fieldLabel: gettext('Disk'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'name',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'add_storage',
+		    fieldLabel: gettext('Add Storage'),
+		    value: '1',
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.node.LVMList', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'pveLVMList',
+
+    viewModel: {
+	data: {
+	    volumeGroup: '',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	destroyVolumeGroup: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let view = me.getView();
+
+	    const volumeGroup = vm.get('volumeGroup');
+
+	    if (!view.nodename) {
+		throw "no node name specified";
+	    }
+
+	    if (!volumeGroup) {
+		throw "no volume group specified";
+	    }
+
+	    Ext.create('PVE.window.SafeDestroyStorage', {
+		url: `/nodes/${view.nodename}/disks/lvm/${volumeGroup}`,
+		item: { id: volumeGroup },
+		taskName: 'lvmremove',
+		taskDone: () => { view.reload(); },
+	    }).show();
+	},
+    },
+
+    emptyText: PVE.Utils.renderNotFound('VGs'),
+
+    stateful: true,
+    stateId: 'grid-node-lvm',
+
+    rootVisible: false,
+    useArrows: true,
+
+    columns: [
+	{
+	    xtype: 'treecolumn',
+	    text: gettext('Name'),
+	    dataIndex: 'name',
+	    flex: 1,
+	},
+	{
+	    text: gettext('Number of LVs'),
+	    dataIndex: 'lvcount',
+	    width: 150,
+	    align: 'right',
+	},
+	{
+	    header: gettext('Assigned to LVs'),
+	    width: 130,
+	    dataIndex: 'usage',
+	    tdCls: 'x-progressbar-default-cell',
+	    xtype: 'widgetcolumn',
+	    widget: {
+		xtype: 'pveProgressBar',
+	    },
+	},
+	{
+	    header: gettext('Size'),
+	    width: 100,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'size',
+	},
+	{
+	    header: gettext('Free'),
+	    width: 100,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'free',
+	},
+    ],
+
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: function() {
+		this.up('panel').reload();
+	    },
+	},
+	{
+	    text: gettext('Create') + ': Volume Group',
+	    handler: function() {
+		let view = this.up('panel');
+		Ext.create('PVE.node.CreateLVM', {
+		    nodename: view.nodename,
+		    taskDone: () => view.reload(),
+		    autoShow: true,
+		});
+	    },
+	},
+	'->',
+	{
+	    xtype: 'tbtext',
+	    data: {
+		volumeGroup: undefined,
+	    },
+	    bind: {
+		data: {
+		    volumeGroup: "{volumeGroup}",
+		},
+	    },
+	    tpl: [
+		'<tpl if="volumeGroup">',
+		'Volume group {volumeGroup}:',
+		'<tpl else>',
+		Ext.String.format(gettext('No {0} selected'), 'volume group'),
+		'</tpl>',
+	    ],
+	},
+	{
+	    text: gettext('More'),
+	    iconCls: 'fa fa-bars',
+	    disabled: true,
+	    bind: {
+		disabled: '{!volumeGroup}',
+	    },
+	    menu: [
+		{
+		    text: gettext('Destroy'),
+		    itemId: 'remove',
+		    iconCls: 'fa fa-fw fa-trash-o',
+		    handler: 'destroyVolumeGroup',
+		    disabled: true,
+		    bind: {
+			disabled: '{!volumeGroup}',
+		    },
+		},
+	    ],
+	},
+    ],
+
+    reload: function() {
+	let me = this;
+	let sm = me.getSelectionModel();
+	Proxmox.Utils.API2Request({
+	    url: `/nodes/${me.nodename}/disks/lvm`,
+	    waitMsgTarget: me,
+	    method: 'GET',
+	    failure: (response, opts) => Proxmox.Utils.setErrorMask(me, response.htmlStatus),
+	    success: function(response, opts) {
+		sm.deselectAll();
+		me.setRootNode(response.result.data);
+		me.expandAll();
+	    },
+	});
+    },
+
+    listeners: {
+	activate: function() {
+	    this.reload();
+	},
+	selectionchange: function(model, selected) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    if (selected.length < 1 || selected[0].data.parentId !== 'root') {
+		vm.set('volumeGroup', '');
+	    } else {
+		vm.set('volumeGroup', selected[0].data.name);
+	    }
+	},
+    },
+
+    selModel: 'treemodel',
+    fields: [
+	'name',
+	'size',
+	'free',
+	{
+	    type: 'string',
+	    name: 'iconCls',
+	    calculate: data => `fa x-fa-tree fa-${data.leaf ? 'hdd-o' : 'object-group'}`,
+	},
+	{
+	    type: 'number',
+	    name: 'usage',
+	    calculate: data => (data.size - data.free) / data.size,
+	},
+    ],
+    sorters: 'name',
+
+    initComponent: function() {
+	let me = this;
+
+	me.nodename = me.pveSelNode.data.node;
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+	me.callParent();
+
+	me.reload();
+    },
+});
+
+Ext.define('PVE.node.CreateLVMThin', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCreateLVMThin',
+
+    onlineHelp: 'chapter_lvm',
+    subject: 'LVM Thinpool',
+
+    showProgress: true,
+    isCreate: true,
+
+    initComponent: function() {
+        let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+        Ext.applyIf(me, {
+	    url: `/nodes/${me.nodename}/disks/lvmthin`,
+	    method: 'POST',
+	    items: [
+		{
+		    xtype: 'pmxDiskSelector',
+		    name: 'device',
+		    nodename: me.nodename,
+		    diskType: 'unused',
+		    includePartitions: true,
+		    fieldLabel: gettext('Disk'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'name',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'add_storage',
+		    fieldLabel: gettext('Add Storage'),
+		    value: '1',
+		},
+            ],
+        });
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.node.LVMThinList', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveLVMThinList',
+
+    viewModel: {
+	data: {
+	    thinPool: '',
+	    volumeGroup: '',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	destroyThinPool: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let view = me.getView();
+
+	    const thinPool = vm.get('thinPool');
+	    const volumeGroup = vm.get('volumeGroup');
+
+	    if (!view.nodename) {
+		throw "no node name specified";
+	    }
+
+	    if (!thinPool) {
+		throw "no thin pool specified";
+	    }
+
+	    if (!volumeGroup) {
+		throw "no volume group specified";
+	    }
+
+	    Ext.create('PVE.window.SafeDestroyStorage', {
+		url: `/nodes/${view.nodename}/disks/lvmthin/${thinPool}`,
+		params: { 'volume-group': volumeGroup },
+		item: { id: `${volumeGroup}/${thinPool}` },
+		taskName: 'lvmthinremove',
+		taskDone: () => { view.reload(); },
+	    }).show();
+	},
+    },
+
+    emptyText: PVE.Utils.renderNotFound('Thin-Pool'),
+
+    stateful: true,
+    stateId: 'grid-node-lvmthin',
+
+    rootVisible: false,
+    useArrows: true,
+
+    columns: [
+	{
+	    text: gettext('Name'),
+	    dataIndex: 'lv',
+	    flex: 1,
+	},
+	{
+	    header: 'Volume Group',
+	    width: 110,
+	    dataIndex: 'vg',
+	},
+	{
+	    header: gettext('Usage'),
+	    width: 110,
+	    dataIndex: 'usage',
+	    tdCls: 'x-progressbar-default-cell',
+	    xtype: 'widgetcolumn',
+	    widget: {
+		xtype: 'pveProgressBar',
+	    },
+	},
+	{
+	    header: gettext('Size'),
+	    width: 100,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'lv_size',
+	},
+	{
+	    header: gettext('Used'),
+	    width: 100,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'used',
+	},
+	{
+	    header: gettext('Metadata Usage'),
+	    width: 120,
+	    dataIndex: 'metadata_usage',
+	    tdCls: 'x-progressbar-default-cell',
+	    xtype: 'widgetcolumn',
+	    widget: {
+		xtype: 'pveProgressBar',
+	    },
+	},
+	{
+	    header: gettext('Metadata Size'),
+	    width: 120,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'metadata_size',
+	},
+	{
+	    header: gettext('Metadata Used'),
+	    width: 125,
+	    align: 'right',
+	    sortable: true,
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'metadata_used',
+	},
+    ],
+
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: function() {
+		this.up('panel').reload();
+	    },
+	},
+	{
+	    text: gettext('Create') + ': Thinpool',
+	    handler: function() {
+		var view = this.up('panel');
+		Ext.create('PVE.node.CreateLVMThin', {
+		    nodename: view.nodename,
+		    taskDone: () => view.reload(),
+		    autoShow: true,
+		});
+	    },
+	},
+	'->',
+	{
+	    xtype: 'tbtext',
+	    data: {
+		thinPool: undefined,
+		volumeGroup: undefined,
+	    },
+	    bind: {
+		data: {
+		    thinPool: "{thinPool}",
+		    volumeGroup: "{volumeGroup}",
+		},
+	    },
+	    tpl: [
+		'<tpl if="thinPool">',
+		'<tpl if="volumeGroup">',
+		'Thinpool {volumeGroup}/{thinPool}:',
+		'<tpl else>', // volumeGroup
+		'Missing volume group (node running old version?)',
+		'</tpl>',
+		'<tpl else>', // thinPool
+		Ext.String.format(gettext('No {0} selected'), 'thinpool'),
+		'</tpl>',
+	    ],
+	},
+	{
+	    text: gettext('More'),
+	    iconCls: 'fa fa-bars',
+	    disabled: true,
+	    bind: {
+		disabled: '{!volumeGroup || !thinPool}',
+	    },
+	    menu: [
+		{
+		    text: gettext('Destroy'),
+		    itemId: 'remove',
+		    iconCls: 'fa fa-fw fa-trash-o',
+		    handler: 'destroyThinPool',
+		    disabled: true,
+		    bind: {
+			disabled: '{!volumeGroup || !thinPool}',
+		    },
+		},
+	    ],
+	},
+    ],
+
+    reload: function() {
+	let me = this;
+	me.store.load();
+	me.store.sort();
+    },
+
+    listeners: {
+	activate: function() {
+	    this.reload();
+	},
+	selectionchange: function(model, selected) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    vm.set('volumeGroup', selected[0]?.data.vg || '');
+	    vm.set('thinPool', selected[0]?.data.lv || '');
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.nodename = me.pveSelNode.data.node;
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    store: {
+		fields: [
+		    'lv',
+		    'lv_size',
+		    'used',
+		    'metadata_size',
+		    'metadata_used',
+		    {
+			type: 'number',
+			name: 'usage',
+			calculate: data => data.used / data.lv_size,
+		    },
+		    {
+			type: 'number',
+			name: 'metadata_usage',
+			calculate: data => data.metadata_used / data.metadata_size,
+		    },
+		],
+		proxy: {
+		    type: 'proxmox',
+		    url: `/api2/json/nodes/${me.nodename}/disks/lvmthin`,
+		},
+		sorters: 'lv',
+	    },
+	});
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
+	me.reload();
+    },
+});
+
+Ext.define('PVE.node.StatusView', {
+    extend: 'Proxmox.panel.StatusView',
+    alias: 'widget.pveNodeStatus',
+
+    height: 390,
+    bodyPadding: '15 5 15 5',
+
+    layout: {
+	type: 'table',
+	columns: 2,
+	tableAttrs: {
+	    style: {
+		width: '100%',
+	    },
+	},
+    },
+
+    defaults: {
+	xtype: 'pmxInfoWidget',
+	padding: '0 10 5 10',
+    },
+
+    items: [
+	{
+	    itemId: 'cpu',
+	    iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon',
+	    title: gettext('CPU usage'),
+	    valueField: 'cpu',
+	    maxField: 'cpuinfo',
+	    renderer: Proxmox.Utils.render_node_cpu_usage,
+	},
+	{
+	    itemId: 'wait',
+	    iconCls: 'fa fa-fw fa-clock-o',
+	    title: gettext('IO delay'),
+	    valueField: 'wait',
+	    rowspan: 2,
+	},
+	{
+	    itemId: 'load',
+	    iconCls: 'fa fa-fw fa-tasks',
+	    title: gettext('Load average'),
+	    printBar: false,
+	    textField: 'loadavg',
+	},
+	{
+	    xtype: 'box',
+	    colspan: 2,
+	    padding: '0 0 20 0',
+	},
+	{
+	    iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon',
+	    itemId: 'memory',
+	    title: gettext('RAM usage'),
+	    valueField: 'memory',
+	    maxField: 'memory',
+	    renderer: Proxmox.Utils.render_node_size_usage,
+	},
+	{
+	    itemId: 'ksm',
+	    printBar: false,
+	    title: gettext('KSM sharing'),
+	    textField: 'ksm',
+	    renderer: function(record) {
+		return Proxmox.Utils.render_size(record.shared);
+	    },
+	    padding: '0 10 10 10',
+	},
+	{
+	    iconCls: 'fa fa-fw fa-hdd-o',
+	    itemId: 'rootfs',
+	    title: '/ ' + gettext('HD space'),
+	    valueField: 'rootfs',
+	    maxField: 'rootfs',
+	    renderer: Proxmox.Utils.render_node_size_usage,
+	},
+	{
+	    iconCls: 'fa fa-fw fa-refresh',
+	    itemId: 'swap',
+	    printSize: true,
+	    title: gettext('SWAP usage'),
+	    valueField: 'swap',
+	    maxField: 'swap',
+	    renderer: Proxmox.Utils.render_node_size_usage,
+	},
+	{
+	    xtype: 'box',
+	    colspan: 2,
+	    padding: '0 0 20 0',
+	},
+	{
+	    itemId: 'cpus',
+	    colspan: 2,
+	    printBar: false,
+	    title: gettext('CPU(s)'),
+	    textField: 'cpuinfo',
+	    renderer: Proxmox.Utils.render_cpu_model,
+	    value: '',
+	},
+	{
+	    colspan: 2,
+	    title: gettext('Kernel Version'),
+	    printBar: false,
+	    // TODO: remove with next major and only use newish current-kernel textfield
+	    multiField: true,
+	    //textField: 'current-kernel',
+	    renderer: ({ data }) => {
+		if (!data['current-kernel']) {
+		    return data.kversion;
+		}
+		let kernel = data['current-kernel'];
+		let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown';
+		return `${kernel.sysname} ${kernel.release} (${buildDate})`;
+	    },
+	    value: '',
+	},
+	{
+	    colspan: 2,
+	    title: gettext('Boot Mode'),
+	    printBar: false,
+	    textField: 'boot-info',
+	    renderer: boot => {
+		if (boot.mode === 'legacy-bios') {
+		    return 'Legacy BIOS';
+		} else if (boot.mode === 'efi') {
+		    return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`;
+		}
+		return Proxmox.Utils.unknownText;
+	    },
+	    value: '',
+	},
+	{
+	    itemId: 'version',
+	    colspan: 2,
+	    printBar: false,
+	    title: gettext('Manager Version'),
+	    textField: 'pveversion',
+	    value: '',
+	},
+	{
+	    itemId: 'thermal',
+	    colspan: 2,
+	    printBar: false,
+	    title: gettext('Thermal'),
+	    textField: 'thermal',
+	    renderer: function (value) {
+			value = JSON.parse(value);
+			const cpu0 = value['coretemp-isa-0000']['Package id 0']['temp1_input'].toFixed(1);
+			const board = value['acpitz-acpi-0']['temp1']['temp1_input'].toFixed(1);
+			const nvme = value['nvme-pci-0c00']['Composite']['temp1_input'].toFixed(1);
+			return `CPU: ${cpu0}\xb0C | Board: ${board}\xb0C | NVME: ${nvme}\xb0C`;
+		},
+	},
+	{
+	    itemId: 'networksp',
+	    colspan: 2,
+	    printBar: false,
+	    title: gettext('Network Speed'),
+	    textField: 'networksp',
+	    renderer: function (sp) {
+			if (!Array.isArray(sp)) {
+				return '';
+			}
+			const sps = sp.map(function (s) { return String(s).match(/(?<=:\s+)(.+)/g)?.[0] });
+			return sps.join(' | ');
+		},
+	},
+    ],
+
+    updateTitle: function() {
+	var me = this;
+	var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime'));
+	me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')');
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let stateProvider = Ext.state.Manager.getProvider();
+	let repoLink = stateProvider.encodeHToken({
+	    view: "server",
+	    rid: `node/${me.pveSelNode.data.node}`,
+	    ltab: "tasks",
+	    nodetab: "aptrepositories",
+	});
+
+	me.items.push({
+	    xtype: 'pmxNodeInfoRepoStatus',
+	    itemId: 'repositoryStatus',
+	    product: 'Proxmox VE',
+	    repoLink: `#${repoLink}`,
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.node.SubscriptionKeyEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    title: gettext('Upload Subscription Key'),
+    width: 350,
+
+    items: {
+	xtype: 'textfield',
+	name: 'key',
+	value: '',
+	fieldLabel: gettext('Subscription Key'),
+	labelWidth: 120,
+	getSubmitValue: function() {
+	    return this.processRawValue(this.getRawValue())?.trim();
+	},
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	me.load();
+    },
+});
+
+Ext.define('PVE.node.Subscription', {
+    extend: 'Proxmox.grid.ObjectGrid',
+
+    alias: ['widget.pveNodeSubscription'],
+
+    onlineHelp: 'getting_help',
+
+    viewConfig: {
+	enableTextSelection: true,
+    },
+
+    showReport: function() {
+	var me = this;
+
+	var getReportFileName = function() {
+	    var now = Ext.Date.format(new Date(), 'D-d-F-Y-G-i');
+	    return `${me.nodename}-pve-report-${now}.txt`;
+	};
+
+	var view = Ext.createWidget('component', {
+	    itemId: 'system-report-view',
+	    scrollable: true,
+	    style: {
+		'white-space': 'pre',
+		'font-family': 'monospace',
+		padding: '5px',
+	    },
+	});
+
+	var reportWindow = Ext.create('Ext.window.Window', {
+	    title: gettext('System Report'),
+	    width: 1024,
+	    height: 600,
+	    layout: 'fit',
+	    modal: true,
+	    buttons: [
+		'->',
+		{
+		    text: gettext('Download'),
+		    handler: function() {
+			var fileContent = Ext.String.htmlDecode(reportWindow.getComponent('system-report-view').html);
+			var fileName = getReportFileName();
+
+			// Internet Explorer
+			if (window.navigator.msSaveOrOpenBlob) {
+			    navigator.msSaveOrOpenBlob(new Blob([fileContent]), fileName);
+			} else {
+			    var element = document.createElement('a');
+			    element.setAttribute('href', 'data:text/plain;charset=utf-8,' +
+			      encodeURIComponent(fileContent));
+			    element.setAttribute('download', fileName);
+			    element.style.display = 'none';
+			    document.body.appendChild(element);
+			    element.click();
+			    document.body.removeChild(element);
+			}
+		    },
+		},
+	    ],
+	    items: view,
+	});
+
+	Proxmox.Utils.API2Request({
+	    url: '/api2/extjs/nodes/' + me.nodename + '/report',
+	    method: 'GET',
+	    waitMsgTarget: me,
+	    failure: function(response) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response) {
+		var report = Ext.htmlEncode(response.result.data);
+		reportWindow.show();
+		view.update(report);
+	    },
+	});
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	let rows = {
+	    productname: {
+		header: gettext('Type'),
+	    },
+	    key: {
+		header: gettext('Subscription Key'),
+	    },
+	    status: {
+		header: gettext('Status'),
+		renderer: v => {
+		    let message = me.getObjectValue('message');
+		    return message ? `${v}: ${message}` : v;
+		},
+	    },
+	    message: {
+		visible: false,
+	    },
+	    serverid: {
+		header: gettext('Server ID'),
+	    },
+	    sockets: {
+		header: gettext('Sockets'),
+	    },
+	    checktime: {
+		header: gettext('Last checked'),
+		renderer: Proxmox.Utils.render_timestamp,
+	    },
+	    nextduedate: {
+		header: gettext('Next due date'),
+	    },
+	    signature: {
+		header: gettext('Signed/Offline'),
+		renderer: v => v ? gettext('Yes') : gettext('No'),
+	    },
+	};
+
+	Ext.apply(me, {
+	    url: `/api2/json/nodes/${me.nodename}/subscription`,
+	    cwidth1: 170,
+	    tbar: [
+		{
+		    text: gettext('Upload Subscription Key'),
+		    handler: () => Ext.create('PVE.node.SubscriptionKeyEdit', {
+			autoShow: true,
+			url: `/api2/extjs/nodes/${me.nodename}/subscription`,
+			listeners: {
+			    destroy: () => me.rstore.load(),
+			},
+		    }),
+		},
+		{
+		    text: gettext('Check'),
+		    handler: () => Proxmox.Utils.API2Request({
+			params: { force: 1 },
+			url: `/nodes/${me.nodename}/subscription`,
+			method: 'POST',
+			waitMsgTarget: me,
+			failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+			callback: () => me.rstore.load(),
+		    }),
+		},
+		{
+		    text: gettext('Remove Subscription'),
+		    xtype: 'proxmoxStdRemoveButton',
+		    confirmMsg: gettext('Are you sure you want to remove the subscription key?'),
+		    baseurl: `/nodes/${me.nodename}/subscription`,
+		    dangerous: true,
+		    selModel: false,
+		    callback: () => me.rstore.load(),
+		},
+		'-',
+		{
+		    text: gettext('System Report'),
+		    handler: function() {
+			Proxmox.Utils.checked_command(function() { me.showReport(); });
+		    },
+		},
+	    ],
+	    rows: rows,
+	    listeners: {
+		activate: () => me.rstore.load(),
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.node.Summary', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveNodeSummary',
+
+    scrollable: true,
+    bodyPadding: 5,
+
+    showVersions: function() {
+	var me = this;
+
+	// Note: we use simply text/html here, because ExtJS grid has problems
+	// with cut&paste
+
+	var nodename = me.pveSelNode.data.node;
+
+	var view = Ext.createWidget('component', {
+	    autoScroll: true,
+	    id: 'pkgversions',
+	    padding: 5,
+	    style: {
+		'white-space': 'pre',
+		'font-family': 'monospace',
+	    },
+	});
+
+	var win = Ext.create('Ext.window.Window', {
+	    title: gettext('Package versions'),
+	    width: 600,
+	    height: 600,
+	    layout: 'fit',
+	    modal: true,
+	    items: [view],
+	    buttons: [
+		{
+		    xtype: 'button',
+		    iconCls: 'fa fa-clipboard',
+		    handler: function(button) {
+			window.getSelection().selectAllChildren(
+			    document.getElementById('pkgversions'),
+			);
+			document.execCommand("copy");
+		    },
+		    text: gettext('Copy'),
+		},
+		{
+		    text: gettext('Ok'),
+		    handler: function() {
+			this.up('window').close();
+		    },
+		},
+	    ],
+	});
+
+	Proxmox.Utils.API2Request({
+	    waitMsgTarget: me,
+	    url: `/nodes/${nodename}/apt/versions`,
+	    method: 'GET',
+	    failure: function(response, opts) {
+		win.close();
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response, opts) {
+		win.show();
+		let text = '';
+		Ext.Array.each(response.result.data, function(rec) {
+		    let version = "not correctly installed";
+		    let pkg = rec.Package;
+		    if (rec.OldVersion && rec.CurrentState === 'Installed') {
+			version = rec.OldVersion;
+		    }
+		    if (rec.RunningKernel) {
+			text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`;
+		    } else if (rec.ManagerVersion) {
+			text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`;
+		    } else {
+			text += `${pkg}: ${version}\n`;
+		    }
+		});
+
+		view.update(Ext.htmlEncode(text));
+	    },
+	});
+    },
+
+    updateRepositoryStatus: function() {
+	let me = this;
+	let repoStatus = me.nodeStatus.down('#repositoryStatus');
+
+	let nodename = me.pveSelNode.data.node;
+
+	Proxmox.Utils.API2Request({
+	    url: `/nodes/${nodename}/apt/repositories`,
+	    method: 'GET',
+	    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    success: response => repoStatus.setRepositoryInfo(response.result.data['standard-repos']),
+	});
+
+	Proxmox.Utils.API2Request({
+	    url: `/nodes/${nodename}/subscription`,
+	    method: 'GET',
+	    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    success: function(response, opts) {
+		const res = response.result;
+		const subscription = res?.data?.status.toLowerCase() === 'active';
+		repoStatus.setSubscriptionStatus(subscription);
+	    },
+	});
+    },
+
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.statusStore) {
+	    throw "no status storage specified";
+	}
+
+	var rstore = me.statusStore;
+
+	var version_btn = new Ext.Button({
+	    text: gettext('Package versions'),
+	    handler: function() {
+		Proxmox.Utils.checked_command(function() { me.showVersions(); });
+	    },
+	});
+
+	var rrdstore = Ext.create('Proxmox.data.RRDStore', {
+	    rrdurl: "/api2/json/nodes/" + nodename + "/rrddata",
+	    model: 'pve-rrd-node',
+	});
+
+	let nodeStatus = Ext.create('PVE.node.StatusView', {
+	    xtype: 'pveNodeStatus',
+	    rstore: rstore,
+	    width: 770,
+	    pveSelNode: me.pveSelNode,
+	});
+
+	Ext.apply(me, {
+	    tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }],
+	    nodeStatus: nodeStatus,
+	    items: [
+		{
+		    xtype: 'container',
+		    itemId: 'itemcontainer',
+		    layout: 'column',
+		    minWidth: 700,
+		    defaults: {
+			minHeight: 390,
+			padding: 5,
+			columnWidth: 1,
+		    },
+		    items: [
+			nodeStatus,
+			{
+			    xtype: 'proxmoxRRDChart',
+			    title: gettext('CPU usage'),
+			    fields: ['cpu', 'iowait'],
+			    fieldTitles: [gettext('CPU usage'), gettext('IO delay')],
+			    unit: 'percent',
+			    store: rrdstore,
+			},
+			{
+			    xtype: 'proxmoxRRDChart',
+			    title: gettext('Server load'),
+			    fields: ['loadavg'],
+			    fieldTitles: [gettext('Load average')],
+			    store: rrdstore,
+			},
+			{
+			    xtype: 'proxmoxRRDChart',
+			    title: gettext('Memory usage'),
+			    fields: ['memtotal', 'memused'],
+			    fieldTitles: [gettext('Total'), gettext('RAM usage')],
+			    unit: 'bytes',
+			    powerOfTwo: true,
+			    store: rrdstore,
+			},
+			{
+			    xtype: 'proxmoxRRDChart',
+			    title: gettext('Network traffic'),
+			    fields: ['netin', 'netout'],
+			    store: rrdstore,
+			},
+		    ],
+		    listeners: {
+			resize: function(panel) {
+			    Proxmox.Utils.updateColumns(panel);
+			},
+		    },
+		},
+	    ],
+	    listeners: {
+		activate: function() {
+		    rstore.setInterval(1000);
+		    rstore.startUpdate(); // just to be sure
+		    rrdstore.startUpdate();
+		},
+		destroy: function() {
+		    rstore.setInterval(5000); // don't stop it, it's not ours!
+		    rrdstore.stopUpdate();
+		},
+	    },
+	});
+
+	me.updateRepositoryStatus();
+
+	me.callParent();
+
+	let sp = Ext.state.Manager.getProvider();
+	me.mon(sp, 'statechange', function(provider, key, value) {
+	    if (key !== 'summarycolumns') {
+		return;
+	    }
+	    Proxmox.Utils.updateColumns(me.getComponent('itemcontainer'));
+	});
+    },
+});
+Ext.define('PVE.node.CreateZFS', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCreateZFS',
+
+    onlineHelp: 'chapter_zfs',
+    subject: 'ZFS',
+
+    showProgress: true,
+    isCreate: true,
+    width: 800,
+
+    viewModel: {
+	data: {
+	    raidLevel: 'single',
+	},
+	formulas: {
+	    isDraid: get => get('raidLevel')?.startsWith("draid"),
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    url: `/nodes/${me.nodename}/disks/zfs`,
+	    method: 'POST',
+	    items: [
+		{
+		    xtype: 'inputpanel',
+		    onGetValues: function(values) {
+			if (values.draidData || values.draidSpares) {
+			    let opt = { data: values.draidData, spares: values.draidSpares };
+			    values['draid-config'] = PVE.Parser.printPropertyString(opt);
+			}
+			delete values.draidData;
+			delete values.draidSpares;
+			return values;
+		    },
+		    column1: [
+			{
+			    xtype: 'proxmoxtextfield',
+			    name: 'name',
+			    fieldLabel: gettext('Name'),
+			    allowBlank: false,
+			    maxLength: 128, // ZFS_MAX_DATASET_NAME_LEN is (256 - some edge case)
+			    validator: v => {
+				// see zpool_name_valid function in libzfs_zpool.c
+				if (v.match(/^(mirror|raidz|draid|spare)/) || v === 'log') {
+				    return gettext('Cannot use reserved pool name');
+				} else if (!v.match(/^[a-zA-Z][a-zA-Z0-9\-_.]*$/)) {
+				    // note: zfs would support also : and whitespace, but we don't
+				    return gettext("Invalid characters in pool name");
+				}
+				return true;
+			    },
+			},
+			{
+			    xtype: 'proxmoxcheckbox',
+			    name: 'add_storage',
+			    fieldLabel: gettext('Add Storage'),
+			    value: '1',
+			},
+		    ],
+		    column2: [
+			{
+			    xtype: 'proxmoxKVComboBox',
+			    fieldLabel: gettext('RAID Level'),
+			    name: 'raidlevel',
+			    value: 'single',
+			    comboItems: [
+				['single', gettext('Single Disk')],
+				['mirror', 'Mirror'],
+				['raid10', 'RAID10'],
+				['raidz', 'RAIDZ'],
+				['raidz2', 'RAIDZ2'],
+				['raidz3', 'RAIDZ3'],
+				['draid', 'dRAID'],
+				['draid2', 'dRAID2'],
+				['draid3', 'dRAID3'],
+			    ],
+			    bind: {
+				value: '{raidLevel}',
+			    },
+			},
+			{
+			    xtype: 'proxmoxKVComboBox',
+			    fieldLabel: gettext('Compression'),
+			    name: 'compression',
+			    value: 'on',
+			    comboItems: [
+				['on', 'on'],
+				['off', 'off'],
+				['gzip', 'gzip'],
+				['lz4', 'lz4'],
+				['lzjb', 'lzjb'],
+				['zle', 'zle'],
+				['zstd', 'zstd'],
+			    ],
+			},
+			{
+			    xtype: 'proxmoxintegerfield',
+			    fieldLabel: gettext('ashift'),
+			    minValue: 9,
+			    maxValue: 16,
+			    value: '12',
+			    name: 'ashift',
+			},
+		    ],
+		    columnB: [
+			{
+			    xtype: 'fieldset',
+			    title: gettext('dRAID Config'),
+			    collapsible: false,
+			    bind: {
+				hidden: '{!isDraid}',
+			    },
+			    layout: 'hbox',
+			    padding: '5px 10px',
+			    defaults: {
+				flex: 1,
+				layout: 'anchor',
+			    },
+			    items: [{
+				xtype: 'proxmoxintegerfield',
+				name: 'draidData',
+				fieldLabel: gettext('Data Devs'),
+				minValue: 1,
+				allowBlank: false,
+				disabled: true,
+				hidden: true,
+				bind: {
+				    disabled: '{!isDraid}',
+				    hidden: '{!isDraid}',
+				},
+				padding: '0 10 0 0',
+			    },
+			    {
+				xtype: 'proxmoxintegerfield',
+				name: 'draidSpares',
+				fieldLabel: gettext('Spares'),
+				minValue: 0,
+				allowBlank: false,
+				disabled: true,
+				hidden: true,
+				bind: {
+				    disabled: '{!isDraid}',
+				    hidden: '{!isDraid}',
+				},
+				padding: '0 0 0 10',
+			    }],
+			},
+			{
+			    xtype: 'pmxMultiDiskSelector',
+			    name: 'devices',
+			    nodename: me.nodename,
+			    diskType: 'unused',
+			    includePartitions: true,
+			    height: 200,
+			    emptyText: gettext('No Disks unused'),
+			    itemId: 'disklist',
+			},
+		    ],
+		},
+		{
+		    xtype: 'displayfield',
+		    padding: '5 0 0 0',
+		    userCls: 'pmx-hint',
+		    value: 'Note: ZFS is not compatible with disks backed by a hardware ' +
+		       'RAID controller. For details see <a target="_blank" href="' +
+		       Proxmox.Utils.get_help_link('chapter_zfs') + '">the reference documentation</a>.',
+		},
+	    ],
+	});
+
+        me.callParent();
+    },
+
+});
+
+Ext.define('PVE.node.ZFSList', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveZFSList',
+
+    viewModel: {
+	data: {
+	    pool: '',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	destroyPool: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let view = me.getView();
+
+	    const pool = vm.get('pool');
+
+	    if (!view.nodename) {
+		throw "no node name specified";
+	    }
+
+	    if (!pool) {
+		throw "no pool specified";
+	    }
+
+	    Ext.create('PVE.window.SafeDestroyStorage', {
+		url: `/nodes/${view.nodename}/disks/zfs/${pool}`,
+		item: { id: pool },
+		taskName: 'zfsremove',
+		taskDone: () => { view.reload(); },
+	    }).show();
+	},
+    },
+
+    stateful: true,
+    stateId: 'grid-node-zfs',
+    columns: [
+	{
+	    text: gettext('Name'),
+	    dataIndex: 'name',
+	    flex: 1,
+	},
+	{
+	    header: gettext('Size'),
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'size',
+	},
+	{
+	    header: gettext('Free'),
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'free',
+	},
+	{
+	    header: gettext('Allocated'),
+	    renderer: Proxmox.Utils.format_size,
+	    dataIndex: 'alloc',
+	},
+	{
+	    header: gettext('Fragmentation'),
+	    renderer: function(value) {
+		return value.toString() + '%';
+	    },
+	    dataIndex: 'frag',
+	},
+	{
+	    header: gettext('Health'),
+	    renderer: PVE.Utils.render_zfs_health,
+	    dataIndex: 'health',
+	},
+	{
+	    header: gettext('Deduplication'),
+	    hidden: true,
+	    renderer: function(value) {
+		return value.toFixed(2).toString() + 'x';
+	    },
+	    dataIndex: 'dedup',
+	},
+    ],
+
+    rootVisible: false,
+    useArrows: true,
+
+    tbar: [
+	{
+	    text: gettext('Reload'),
+	    iconCls: 'fa fa-refresh',
+	    handler: function() {
+		this.up('panel').reload();
+	    },
+	},
+	{
+	    text: gettext('Create') + ': ZFS',
+	    handler: function() {
+		let view = this.up('panel');
+		Ext.create('PVE.node.CreateZFS', {
+		    nodename: view.nodename,
+		    listeners: {
+			destroy: () => view.reload(),
+		    },
+		    autoShow: true,
+		});
+	    },
+	},
+	{
+	    text: gettext('Detail'),
+	    itemId: 'detailbtn',
+	    disabled: true,
+	    handler: function() {
+		let view = this.up('panel');
+		let selection = view.getSelection();
+		if (selection.length) {
+		    view.show_detail(selection[0].get('name'));
+		}
+	    },
+	},
+	'->',
+	{
+	    xtype: 'tbtext',
+	    data: {
+		pool: undefined,
+	    },
+	    bind: {
+		data: {
+		    pool: "{pool}",
+		},
+	    },
+	    tpl: [
+		'<tpl if="pool">',
+		'Pool {pool}:',
+		'<tpl else>',
+		Ext.String.format(gettext('No {0} selected'), 'pool'),
+		'</tpl>',
+	    ],
+	},
+	{
+	    text: gettext('More'),
+	    iconCls: 'fa fa-bars',
+	    disabled: true,
+	    bind: {
+		disabled: '{!pool}',
+	    },
+	    menu: [
+		{
+		    text: gettext('Destroy'),
+		    itemId: 'remove',
+		    iconCls: 'fa fa-fw fa-trash-o',
+		    handler: 'destroyPool',
+		    disabled: true,
+		    bind: {
+			disabled: '{!pool}',
+		    },
+		},
+	    ],
+	},
+    ],
+
+    show_detail: function(zpool) {
+	let me = this;
+
+	Ext.create('Proxmox.window.ZFSDetail', {
+	    zpool,
+	    nodename: me.nodename,
+	}).show();
+    },
+
+    set_button_status: function() {
+	var me = this;
+    },
+
+    reload: function() {
+	var me = this;
+	me.store.load();
+	me.store.sort();
+    },
+
+    listeners: {
+	activate: function() {
+	    this.reload();
+	},
+	selectionchange: function(model, selected) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    me.down('#detailbtn').setDisabled(selected.length === 0);
+	    vm.set('pool', selected[0]?.data.name || '');
+	},
+	itemdblclick: function(grid, record) {
+	    this.show_detail(record.get('name'));
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.nodename = me.pveSelNode.data.node;
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	Ext.apply(me, {
+	    store: {
+		fields: ['name', 'size', 'free', 'alloc', 'dedup', 'frag', 'health'],
+		proxy: {
+		    type: 'proxmox',
+		    url: `/api2/json/nodes/${me.nodename}/disks/zfs`,
+		},
+		sorters: 'name',
+	    },
+	});
+
+	me.callParent();
+
+	Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
+	me.reload();
+    },
+});
+
+Ext.define('Proxmox.node.NodeOptionsView', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.proxmoxNodeOptionsView'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    cbindData: function(_initialconfig) {
+	let me = this;
+
+	let baseUrl = `/nodes/${me.nodename}/config`;
+	me.url = `/api2/json${baseUrl}`;
+	me.editorConfig = {
+	    url: `/api2/extjs/${baseUrl}`,
+	};
+
+	return {};
+    },
+
+    listeners: {
+	itemdblclick: function() { this.run_editor(); },
+	activate: function() { this.rstore.startUpdate(); },
+	destroy: function() { this.rstore.stopUpdate(); },
+	deactivate: function() { this.rstore.stopUpdate(); },
+    },
+
+    tbar: [
+	{
+	    text: gettext('Edit'),
+	    xtype: 'proxmoxButton',
+	    disabled: true,
+	    handler: btn => btn.up('grid').run_editor(),
+	},
+    ],
+
+    gridRows: [
+	{
+	    xtype: 'integer',
+	    name: 'startall-onboot-delay',
+	    text: gettext('Start on boot delay'),
+	    minValue: 0,
+	    maxValue: 300,
+	    labelWidth: 130,
+	    deleteEmpty: true,
+	    renderer: function(value) {
+		if (value === undefined) {
+		    return Proxmox.Utils.defaultText;
+		}
+
+		let secString = value === '1' ? gettext('Second') : gettext('Seconds');
+		return `${value} ${secString}`;
+	    },
+	},
+	{
+	    xtype: 'text',
+	    name: 'wakeonlan',
+	    text: gettext('MAC address for Wake on LAN'),
+	    vtype: 'MacAddress',
+	    labelWidth: 150,
+	    deleteEmpty: true,
+	    renderer: function(value) {
+		if (value === undefined) {
+		    return Proxmox.Utils.NoneText;
+		}
+
+		return value;
+	    },
+	},
+    ],
+});
+Ext.define('PVE.pool.Config', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.pvePoolConfig',
+
+    onlineHelp: 'pveum_pools',
+
+    initComponent: function() {
+        var me = this;
+
+	var pool = me.pveSelNode.data.pool;
+	if (!pool) {
+	    throw "no pool specified";
+	}
+
+	Ext.apply(me, {
+	    title: Ext.String.format(gettext("Resource Pool") + ': ' + pool),
+	    hstateid: 'pooltab',
+	    items: [
+		{
+		    title: gettext('Summary'),
+		    iconCls: 'fa fa-book',
+		    xtype: 'pvePoolSummary',
+		    itemId: 'summary',
+		},
+		{
+		    title: gettext('Members'),
+		    xtype: 'pvePoolMembers',
+		    iconCls: 'fa fa-th',
+		    pool: pool,
+		    itemId: 'members',
+		},
+		{
+		    xtype: 'pveACLView',
+		    title: gettext('Permissions'),
+		    iconCls: 'fa fa-unlock',
+		    itemId: 'permissions',
+		    path: '/pool/' + pool,
+		},
+	    ],
+	});
+
+	me.callParent();
+   },
+});
+Ext.define('PVE.pool.StatusView', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    alias: ['widget.pvePoolStatusView'],
+    disabled: true,
+
+    title: gettext('Status'),
+    cwidth1: 150,
+    interval: 30000,
+    //height: 195,
+    initComponent: function() {
+	var me = this;
+
+	var pool = me.pveSelNode.data.pool;
+	if (!pool) {
+	    throw "no pool specified";
+	}
+
+	var rows = {
+	    comment: {
+		header: gettext('Comment'),
+		renderer: Ext.String.htmlEncode,
+		required: true,
+	    },
+	};
+
+	Ext.apply(me, {
+	    url: "/api2/json/pools/?poolid=" + pool,
+	    rows: rows,
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.pool.Summary', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pvePoolSummary',
+
+    initComponent: function() {
+        var me = this;
+
+	var pool = me.pveSelNode.data.pool;
+	if (!pool) {
+	    throw "no pool specified";
+	}
+
+	var statusview = Ext.create('PVE.pool.StatusView', {
+	    pveSelNode: me.pveSelNode,
+	    style: 'padding-top:0px',
+	});
+
+	var rstore = statusview.rstore;
+
+	Ext.apply(me, {
+	    autoScroll: true,
+	    bodyStyle: 'padding:10px',
+	    defaults: {
+		style: 'padding-top:10px',
+		width: 800,
+	    },
+	    items: [statusview],
+	});
+
+	me.on('activate', rstore.startUpdate);
+	me.on('destroy', rstore.stopUpdate);
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.window.IPInfo', {
+    extend: 'Ext.window.Window',
+    width: 600,
+    title: gettext('Guest Agent Network Information'),
+    height: 300,
+    layout: {
+	type: 'fit',
+    },
+    modal: true,
+    items: [
+	{
+	    xtype: 'grid',
+	    store: {},
+	    emptyText: gettext('No network information'),
+	    viewConfig: {
+		enableTextSelection: true,
+	    },
+	    columns: [
+		{
+		    dataIndex: 'name',
+		    text: gettext('Name'),
+		    flex: 3,
+		},
+		{
+		    dataIndex: 'hardware-address',
+		    text: gettext('MAC address'),
+		    width: 140,
+		},
+		{
+		    dataIndex: 'ip-addresses',
+		    text: gettext('IP address'),
+		    align: 'right',
+		    flex: 4,
+		    renderer: function(val) {
+			if (!Ext.isArray(val)) {
+			    return '';
+			}
+			var ips = [];
+			val.forEach(function(ip) {
+			    var addr = ip['ip-address'];
+			    var pref = ip.prefix;
+			    if (addr && pref) {
+				ips.push(addr + '/' + pref);
+			    }
+			});
+			return ips.join('<br>');
+		    },
+		},
+	    ],
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.AgentIPView', {
+    extend: 'Ext.container.Container',
+    xtype: 'pveAgentIPView',
+
+    layout: {
+	type: 'hbox',
+	align: 'top',
+    },
+
+    nics: [],
+
+    items: [
+	{
+	    xtype: 'box',
+	    html: '<i class="fa fa-exchange"></i> IPs',
+	},
+	{
+	    xtype: 'container',
+	    flex: 1,
+	    layout: {
+		type: 'vbox',
+		align: 'right',
+		pack: 'end',
+	    },
+	    items: [
+		{
+		    xtype: 'label',
+		    flex: 1,
+		    itemId: 'ipBox',
+		    style: {
+			'text-align': 'right',
+		    },
+		},
+		{
+		    xtype: 'button',
+		    itemId: 'moreBtn',
+		    hidden: true,
+		    ui: 'default-toolbar',
+		    handler: function(btn) {
+			let view = this.up('pveAgentIPView');
+
+			var win = Ext.create('PVE.window.IPInfo');
+			win.down('grid').getStore().setData(view.nics);
+			win.show();
+		    },
+		    text: gettext('More'),
+		},
+	    ],
+	},
+    ],
+
+    getDefaultIps: function(nics) {
+	var me = this;
+	var ips = [];
+	nics.forEach(function(nic) {
+	    if (nic['hardware-address'] &&
+		nic['hardware-address'] !== '00:00:00:00:00:00' &&
+		nic['hardware-address'] !== '0:0:0:0:0:0') {
+		var nic_ips = nic['ip-addresses'] || [];
+		nic_ips.forEach(function(ip) {
+		    var p = ip['ip-address'];
+		    // show 2 ips at maximum
+		    if (ips.length < 2) {
+			ips.push(p);
+		    }
+		});
+	    }
+	});
+
+	return ips;
+    },
+
+    startIPStore: function(store, records, success) {
+	var me = this;
+	let agentRec = store.getById('agent');
+	let state = store.getById('status');
+
+	me.agent = agentRec && agentRec.data.value === 1;
+	me.running = state && state.data.value === 'running';
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	if (!caps.vms['VM.Monitor']) {
+	    var errorText = gettext("Requires '{0}' Privileges");
+	    me.updateStatus(false, Ext.String.format(errorText, 'VM.Monitor'));
+	    return;
+	}
+
+	if (me.agent && me.running && me.ipStore.isStopped) {
+	    me.ipStore.startUpdate();
+	} else if (me.ipStore.isStopped) {
+	    me.updateStatus();
+	}
+    },
+
+    updateStatus: function(unsuccessful, defaulttext) {
+	var me = this;
+	var text = defaulttext || gettext('No network information');
+	var more = false;
+	if (unsuccessful) {
+	    text = gettext('Guest Agent not running');
+	} else if (me.agent && me.running) {
+	    if (Ext.isArray(me.nics) && me.nics.length) {
+		more = true;
+		var ips = me.getDefaultIps(me.nics);
+		if (ips.length !== 0) {
+		    text = ips.join('<br>');
+		}
+	    } else if (me.nics && me.nics.error) {
+		text = Ext.String.format(text, me.nics.error.desc);
+	    }
+	} else if (me.agent) {
+	    text = gettext('Guest Agent not running');
+	} else {
+	    text = gettext('No Guest Agent configured');
+	}
+
+	var ipBox = me.down('#ipBox');
+	ipBox.update(text);
+
+	var moreBtn = me.down('#moreBtn');
+	moreBtn.setVisible(more);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.rstore) {
+	    throw 'rstore not given';
+	}
+
+	if (!me.pveSelNode) {
+	    throw 'pveSelNode not given';
+	}
+
+	var nodename = me.pveSelNode.data.node;
+	var vmid = me.pveSelNode.data.vmid;
+
+	me.ipStore = Ext.create('Proxmox.data.UpdateStore', {
+	    interval: 10000,
+	    storeid: 'pve-qemu-agent-' + vmid,
+	    method: 'POST',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes/' + nodename + '/qemu/' + vmid + '/agent/network-get-interfaces',
+	    },
+	});
+
+	me.callParent();
+
+	me.mon(me.ipStore, 'load', function(store, records, success) {
+	    if (records && records.length) {
+		me.nics = records[0].data.result;
+	    } else {
+		me.nics = undefined;
+	    }
+	    me.updateStatus(!success);
+	});
+
+	me.on('destroy', me.ipStore.stopUpdate, me.ipStore);
+
+	// if we already have info about the vm, use it immediately
+	if (me.rstore.getCount()) {
+	    me.startIPStore(me.rstore, me.rstore.getData(), false);
+	}
+
+	// check if the guest agent is there on every statusstore load
+	me.mon(me.rstore, 'load', me.startIPStore, me);
+    },
+});
+Ext.define('PVE.qemu.AudioInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveAudioInputPanel',
+
+    // FIXME: enable once we bumped doc-gen so this ref is included
+    //onlineHelp: 'qm_audio_device',
+
+    onGetValues: function(values) {
+	var ret = PVE.Parser.printPropertyString(values);
+	if (ret === '') {
+	    return {
+		'delete': 'audio0',
+	    };
+	}
+	return {
+	    audio0: ret,
+	};
+    },
+
+    items: [{
+	name: 'device',
+	xtype: 'proxmoxKVComboBox',
+	value: 'ich9-intel-hda',
+	fieldLabel: gettext('Audio Device'),
+	comboItems: [
+	    ['ich9-intel-hda', 'ich9-intel-hda'],
+	    ['intel-hda', 'intel-hda'],
+	    ['AC97', 'AC97'],
+	],
+    }, {
+	name: 'driver',
+	xtype: 'proxmoxKVComboBox',
+	value: 'spice',
+	fieldLabel: gettext('Backend Driver'),
+	comboItems: [
+	    ['spice', 'SPICE'],
+	    ['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`],
+	],
+    }],
+});
+
+Ext.define('PVE.qemu.AudioEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    vmconfig: undefined,
+
+    subject: gettext('Audio Device'),
+
+    items: [{
+	xtype: 'pveAudioInputPanel',
+    }],
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	me.load({
+	    success: function(response) {
+		me.vmconfig = response.result.data;
+
+		var audio0 = me.vmconfig.audio0;
+		if (audio0) {
+		    me.setValues(PVE.Parser.parsePropertyString(audio0));
+		}
+	    },
+	});
+    },
+});
+Ext.define('pve-boot-order-entry', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{ name: 'name', type: 'string' },
+	{ name: 'enabled', type: 'bool' },
+	{ name: 'desc', type: 'string' },
+    ],
+});
+
+Ext.define('PVE.qemu.BootOrderPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuBootOrderPanel',
+
+    onlineHelp: 'qm_bootorder',
+
+    vmconfig: {}, // store loaded vm config
+    store: undefined,
+
+    inUpdate: false,
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let me = this;
+
+	    let grid = me.lookup('grid');
+	    let marker = me.lookup('marker');
+	    let emptyWarning = me.lookup('emptyWarning');
+
+	    marker.originalValue = undefined;
+
+	    view.store = Ext.create('Ext.data.Store', {
+		model: 'pve-boot-order-entry',
+		listeners: {
+		    update: function() {
+			this.commitChanges();
+			let val = view.calculateValue();
+			if (marker.originalValue === undefined) {
+			    marker.originalValue = val;
+			}
+			view.inUpdate = true;
+			marker.setValue(val);
+			view.inUpdate = false;
+			marker.checkDirty();
+			emptyWarning.setHidden(val !== '');
+			grid.getView().refresh();
+		    },
+		},
+	    });
+	    grid.setStore(view.store);
+	},
+    },
+
+    isCloudinit: (v) => v.match(/media=cdrom/) && v.match(/[:/]vm-\d+-cloudinit/),
+
+    isDisk: function(value) {
+	return PVE.Utils.bus_match.test(value);
+    },
+
+    isBootdev: function(dev, value) {
+	return (this.isDisk(dev) && !this.isCloudinit(value)) ||
+	    (/^net\d+/).test(dev) ||
+	    (/^hostpci\d+/).test(dev) ||
+	    ((/^usb\d+/).test(dev) && !(/spice/).test(value));
+    },
+
+    setVMConfig: function(vmconfig) {
+	let me = this;
+	me.vmconfig = vmconfig;
+
+	me.store.removeAll();
+
+	let boot = PVE.Parser.parsePropertyString(me.vmconfig.boot, "legacy");
+
+	let bootorder = [];
+	if (boot.order) {
+	    bootorder = boot.order.split(';').map(dev => ({ name: dev, enabled: true }));
+	} else if (!(/^\s*$/).test(me.vmconfig.boot)) {
+	    // legacy style, transform to new bootorder
+	    let order = boot.legacy || 'cdn';
+	    let bootdisk = me.vmconfig.bootdisk || undefined;
+
+	    // get the first 4 characters (acdn)
+	    // ignore the rest (there should never be more than 4)
+	    let orderList = order.split('').slice(0, 4);
+
+	    // build bootdev list
+	    for (let i = 0; i < orderList.length; i++) {
+		let list = [];
+		if (orderList[i] === 'c') {
+		    if (bootdisk !== undefined && me.vmconfig[bootdisk]) {
+			list.push(bootdisk);
+		    }
+		} else if (orderList[i] === 'd') {
+		    Ext.Object.each(me.vmconfig, function(key, value) {
+			if (me.isDisk(key) && value.match(/media=cdrom/) && !me.isCloudinit(value)) {
+			    list.push(key);
+			}
+		    });
+		} else if (orderList[i] === 'n') {
+		    Ext.Object.each(me.vmconfig, function(key, value) {
+			if ((/^net\d+/).test(key)) {
+			    list.push(key);
+			}
+		    });
+		}
+
+		// Object.each iterates in random order, sort alphabetically
+		list.sort();
+		list.forEach(dev => bootorder.push({ name: dev, enabled: true }));
+	    }
+	}
+
+	// add disabled devices as well
+	let disabled = [];
+	Ext.Object.each(me.vmconfig, function(key, value) {
+	    if (me.isBootdev(key, value) &&
+		!Ext.Array.some(bootorder, x => x.name === key)) {
+		disabled.push(key);
+	    }
+	});
+	disabled.sort();
+	disabled.forEach(dev => bootorder.push({ name: dev, enabled: false }));
+
+	// add descriptions
+	bootorder.forEach(entry => {
+	    entry.desc = me.vmconfig[entry.name];
+	});
+
+	me.store.insert(0, bootorder);
+	me.store.fireEvent("update");
+    },
+
+    calculateValue: function() {
+	let me = this;
+	return me.store.getData().items
+	    .filter(x => x.data.enabled)
+	    .map(x => x.data.name)
+	    .join(';');
+    },
+
+    onGetValues: function() {
+	let me = this;
+	// Note: we allow an empty value, so no 'delete' option
+	let val = { order: me.calculateValue() };
+	let res = { boot: PVE.Parser.printPropertyString(val) };
+	return res;
+    },
+
+    items: [
+	{
+	    xtype: 'grid',
+	    reference: 'grid',
+	    margin: '0 0 5 0',
+	    minHeight: 150,
+	    defaults: {
+		sortable: false,
+		hideable: false,
+		draggable: false,
+	    },
+	    columns: [
+		{
+		    header: '#',
+		    flex: 4,
+		    renderer: (value, metaData, record, rowIndex) => {
+			let dragHandle = "<i class='pve-grid-fa fa fa-fw fa-reorder cursor-move'></i>";
+			let idx = (rowIndex + 1).toString();
+			if (record.get('enabled')) {
+			    return dragHandle + idx;
+			} else {
+			    return dragHandle + "<span class='faded'>" + idx + "</span>";
+			}
+		    },
+		},
+		{
+		    xtype: 'checkcolumn',
+		    header: gettext('Enabled'),
+		    dataIndex: 'enabled',
+		    flex: 4,
+		},
+		{
+		    header: gettext('Device'),
+		    dataIndex: 'name',
+		    flex: 6,
+		    renderer: (value, metaData, record, rowIndex) => {
+			let desc = record.get('desc');
+
+			let icon = '', iconCls;
+			if (value.match(/^net\d+$/)) {
+			    iconCls = 'exchange';
+			} else if (desc.match(/media=cdrom/)) {
+			    metaData.tdCls = 'pve-itype-icon-cdrom';
+			} else {
+			    iconCls = 'hdd-o';
+			}
+			if (iconCls !== undefined) {
+			    metaData.tdCls += 'pve-itype-fa';
+			    icon = `<i class="pve-grid-fa fa fa-fw fa-${iconCls}"></i>`;
+			}
+
+			return icon + value;
+		    },
+		},
+		{
+		    header: gettext('Description'),
+		    dataIndex: 'desc',
+		    flex: 20,
+		},
+	    ],
+	    viewConfig: {
+		plugins: {
+		    ptype: 'gridviewdragdrop',
+		    dragText: gettext('Drag and drop to reorder'),
+		},
+	    },
+	    listeners: {
+		drop: function() {
+		    // doesn't fire automatically on reorder
+		    this.getStore().fireEvent("update");
+		},
+	    },
+	},
+	{
+	    xtype: 'component',
+	    html: gettext('Drag and drop to reorder'),
+	},
+	{
+	    xtype: 'displayfield',
+	    reference: 'emptyWarning',
+	    userCls: 'pmx-hint',
+	    value: gettext('Warning: No devices selected, the VM will probably not boot!'),
+	},
+	{
+	    // for dirty marking and 'reset' function
+	    xtype: 'field',
+	    reference: 'marker',
+	    hidden: true,
+	    setValue: function(val) {
+		let me = this;
+		let panel = me.up('pveQemuBootOrderPanel');
+
+		// on form reset, go back to original state
+		if (!panel.inUpdate) {
+		    panel.setVMConfig(panel.vmconfig);
+		}
+
+		// not a subclass, so no callParent; just do it manually
+		me.setRawValue(me.valueToRaw(val));
+		return me.mixins.field.setValue.call(me, val);
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.BootOrderEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    items: [{
+	xtype: 'pveQemuBootOrderPanel',
+	itemId: 'inputpanel',
+    }],
+
+    subject: gettext('Boot Order'),
+    width: 640,
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	me.load({
+	    success: ({ result }) => me.down('#inputpanel').setVMConfig(result.data),
+	});
+    },
+});
+Ext.define('PVE.qemu.CDInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuCDInputPanel',
+
+    insideWizard: false,
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var confid = me.confid || values.controller + values.deviceid;
+
+	me.drive.media = 'cdrom';
+	if (values.mediaType === 'iso') {
+	    me.drive.file = values.cdimage;
+	} else if (values.mediaType === 'cdrom') {
+	    me.drive.file = 'cdrom';
+	} else {
+	    me.drive.file = 'none';
+	}
+
+	var params = {};
+
+	params[confid] = PVE.Parser.printQemuDrive(me.drive);
+
+	return params;
+    },
+
+    setVMConfig: function(vmconfig) {
+	var me = this;
+
+	if (me.bussel) {
+	    me.bussel.setVMConfig(vmconfig, 'cdrom');
+	}
+    },
+
+    setDrive: function(drive) {
+	var me = this;
+
+	var values = {};
+	if (drive.file === 'cdrom') {
+	    values.mediaType = 'cdrom';
+	} else if (drive.file === 'none') {
+	    values.mediaType = 'none';
+	} else {
+	    values.mediaType = 'iso';
+	    values.cdimage = drive.file;
+	}
+
+	me.drive = drive;
+
+	me.setValues(values);
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	me.isosel.setNodename(nodename);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.drive = {};
+
+	var items = [];
+
+	if (!me.confid) {
+	    me.bussel = Ext.create('PVE.form.ControllerSelector', {
+		withVirtIO: false,
+	    });
+	    items.push(me.bussel);
+	}
+
+	items.push({
+	    xtype: 'radiofield',
+	    name: 'mediaType',
+	    inputValue: 'iso',
+	    boxLabel: gettext('Use CD/DVD disc image file (iso)'),
+	    checked: true,
+	    listeners: {
+		change: function(f, value) {
+		    if (!me.rendered) {
+			return;
+		    }
+		    var cdImageField = me.down('pveIsoSelector');
+		    cdImageField.setDisabled(!value);
+		    if (value) {
+			cdImageField.validate();
+		    } else {
+			cdImageField.reset();
+		    }
+		},
+	    },
+	});
+
+
+	me.isosel = Ext.create('PVE.form.IsoSelector', {
+	    nodename: me.nodename,
+	    insideWizard: me.insideWizard,
+	    name: 'cdimage',
+	});
+
+	items.push(me.isosel);
+
+	items.push({
+	    xtype: 'radiofield',
+	    name: 'mediaType',
+	    inputValue: 'cdrom',
+	    boxLabel: gettext('Use physical CD/DVD Drive'),
+	});
+
+	items.push({
+	    xtype: 'radiofield',
+	    name: 'mediaType',
+	    inputValue: 'none',
+	    boxLabel: gettext('Do not use any media'),
+	});
+
+	me.items = items;
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.CDEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 400,
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.isCreate = !me.confid;
+
+	var ipanel = Ext.create('PVE.qemu.CDInputPanel', {
+	    confid: me.confid,
+	    nodename: nodename,
+	});
+
+	Ext.applyIf(me, {
+	    subject: 'CD/DVD Drive',
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		ipanel.setVMConfig(response.result.data);
+		if (me.confid) {
+		    var value = response.result.data[me.confid];
+		    var drive = PVE.Parser.parseQemuDrive(me.confid, value);
+		    if (!drive) {
+			Ext.Msg.alert('Error', 'Unable to parse drive options');
+			me.close();
+			return;
+		    }
+		    ipanel.setDrive(drive);
+		}
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.CIDriveInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveCIDriveInputPanel',
+
+    insideWizard: false,
+
+    vmconfig: {}, // used to select usused disks
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var drive = {};
+	var params = {};
+	drive.file = values.hdstorage + ":cloudinit";
+	drive.format = values.diskformat;
+	params[values.controller + values.deviceid] = PVE.Parser.printQemuDrive(drive);
+	return params;
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+	me.down('#hdstorage').setNodename(nodename);
+	me.down('#hdimage').setStorage(undefined, nodename);
+    },
+
+    setVMConfig: function(config) {
+	var me = this;
+	me.down('#drive').setVMConfig(config, 'cdrom');
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.drive = {};
+
+	me.items = [
+	    {
+		xtype: 'pveControllerSelector',
+		withVirtIO: false,
+		itemId: 'drive',
+		fieldLabel: gettext('CloudInit Drive'),
+		name: 'drive',
+	    },
+	    {
+		xtype: 'pveDiskStorageSelector',
+		itemId: 'storselector',
+		storageContent: 'images',
+		nodename: me.nodename,
+		hideSize: true,
+	    },
+	];
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.CIDriveEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveCIDriveEdit',
+
+    isCreate: true,
+    subject: gettext('CloudInit Drive'),
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.items = [{
+	    xtype: 'pveCIDriveInputPanel',
+	    itemId: 'cipanel',
+	    nodename: nodename,
+	}];
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, opts) {
+		me.down('#cipanel').setVMConfig(response.result.data);
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.CloudInit', {
+    extend: 'Proxmox.grid.PendingObjectGrid',
+    xtype: 'pveCiPanel',
+
+    onlineHelp: 'qm_cloud_init',
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    disabled: true,
+	    dangerous: true,
+	    confirmMsg: function(rec) {
+		let view = this.up('grid');
+		var warn = gettext('Are you sure you want to remove entry {0}');
+
+		var entry = rec.data.key;
+		var msg = Ext.String.format(warn, "'"
+		    + view.renderKey(entry, {}, rec) + "'");
+
+		return msg;
+	    },
+	    enableFn: function(record) {
+		let view = this.up('grid');
+		var caps = Ext.state.Manager.get('GuiCap');
+		let caps_ci = caps.vms['VM.Config.Network'] || caps.vms['VM.Config.Cloudinit'];
+		if (view.rows[record.data.key].never_delete || !caps_ci) {
+		    return false;
+		}
+
+		if (record.data.key === 'cipassword' && !record.data.value) {
+		    return false;
+		}
+		return true;
+	    },
+	    handler: function() {
+		let view = this.up('grid');
+		let records = view.getSelection();
+		if (!records || !records.length) {
+		    return;
+		}
+
+		var id = records[0].data.key;
+		var match = id.match(/^net(\d+)$/);
+		if (match) {
+		    id = 'ipconfig' + match[1];
+		}
+
+		var params = {};
+		params.delete = id;
+		Proxmox.Utils.API2Request({
+		    url: view.baseurl + '/config',
+		    waitMsgTarget: view,
+		    method: 'PUT',
+		    params: params,
+		    failure: function(response, opts) {
+			Ext.Msg.alert('Error', response.htmlStatus);
+		    },
+		    callback: function() {
+			view.reload();
+		    },
+		});
+	    },
+	    text: gettext('Remove'),
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    disabled: true,
+	    enableFn: function(rec) {
+		let view = this.up('pveCiPanel');
+		return !!view.rows[rec.data.key].editor;
+	    },
+	    handler: function() {
+		let view = this.up('grid');
+		view.run_editor();
+	    },
+	    text: gettext('Edit'),
+	},
+	'-',
+	{
+	    xtype: 'button',
+	    itemId: 'savebtn',
+	    text: gettext('Regenerate Image'),
+	    handler: function() {
+		let view = this.up('grid');
+		var eject_params = {};
+		var insert_params = {};
+		let disk = PVE.Parser.parseQemuDrive(view.ciDriveId, view.ciDrive);
+		var storage = '';
+		var stormatch = disk.file.match(/^([^:]+):/);
+		if (stormatch) {
+		    storage = stormatch[1];
+		}
+		eject_params[view.ciDriveId] = 'none,media=cdrom';
+		insert_params[view.ciDriveId] = storage + ':cloudinit';
+
+		var failure = function(response, opts) {
+		    Ext.Msg.alert('Error', response.htmlStatus);
+		};
+
+		Proxmox.Utils.API2Request({
+		    url: view.baseurl + '/config',
+		    waitMsgTarget: view,
+		    method: 'PUT',
+		    params: eject_params,
+		    failure: failure,
+		    callback: function() {
+			Proxmox.Utils.API2Request({
+			    url: view.baseurl + '/config',
+			    waitMsgTarget: view,
+			    method: 'PUT',
+			    params: insert_params,
+			    failure: failure,
+			    callback: function() {
+				view.reload();
+			    },
+			});
+		    },
+		});
+	    },
+	},
+    ],
+
+    border: false,
+
+    set_button_status: function(rstore, records, success) {
+	if (!success || records.length < 1) {
+	    return;
+	}
+	var me = this;
+	var found;
+	records.forEach(function(record) {
+	    if (found) {
+		return;
+	    }
+	    var id = record.data.key;
+	    var value = record.data.value;
+	    var ciregex = new RegExp("vm-" + me.pveSelNode.data.vmid + "-cloudinit");
+		if (id.match(/^(ide|scsi|sata)\d+$/) && ciregex.test(value)) {
+		    found = id;
+		    me.ciDriveId = found;
+		    me.ciDrive = value;
+		}
+	});
+
+	me.down('#savebtn').setDisabled(!found);
+	me.setDisabled(!found);
+	if (!found) {
+	    me.getView().mask(gettext('No CloudInit Drive found'), ['pve-static-mask']);
+	} else {
+	    me.getView().unmask();
+	}
+    },
+
+    renderKey: function(key, metaData, rec, rowIndex, colIndex, store) {
+	var me = this;
+	var rows = me.rows;
+	var rowdef = rows[key] || {};
+
+	var icon = "";
+	if (rowdef.iconCls) {
+	    icon = '<i class="' + rowdef.iconCls + '"></i> ';
+	}
+	return icon + (rowdef.header || key);
+    },
+
+    listeners: {
+	activate: function() {
+	    var me = this;
+	    me.rstore.startUpdate();
+	},
+	itemdblclick: function() {
+	    var me = this;
+	    me.run_editor();
+	},
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+	var caps = Ext.state.Manager.get('GuiCap');
+	me.baseurl = '/api2/extjs/nodes/' + nodename + '/qemu/' + vmid;
+	me.url = me.baseurl + '/pending';
+	me.editorConfig.url = me.baseurl + '/config';
+	me.editorConfig.pveSelNode = me.pveSelNode;
+
+	let caps_ci = caps.vms['VM.Config.Cloudinit'] || caps.vms['VM.Config.Network'];
+	/* editor is string and object */
+	me.rows = {
+	    ciuser: {
+		header: gettext('User'),
+		iconCls: 'fa fa-user',
+		never_delete: true,
+		defaultValue: '',
+		editor: caps_ci ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('User'),
+		    items: [
+			{
+			    xtype: 'proxmoxtextfield',
+			    deleteEmpty: true,
+			    emptyText: Proxmox.Utils.defaultText,
+			    fieldLabel: gettext('User'),
+			    name: 'ciuser',
+			},
+		    ],
+		} : undefined,
+		renderer: function(value) {
+		    return Ext.String.htmlEncode(value || Proxmox.Utils.defaultText);
+		},
+	    },
+	    cipassword: {
+		header: gettext('Password'),
+		iconCls: 'fa fa-unlock',
+		defaultValue: '',
+		editor: caps_ci ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Password'),
+		    items: [
+			{
+			    xtype: 'proxmoxtextfield',
+			    inputType: 'password',
+			    deleteEmpty: true,
+			    emptyText: Proxmox.Utils.noneText,
+			    fieldLabel: gettext('Password'),
+			    name: 'cipassword',
+			},
+		    ],
+		} : undefined,
+		renderer: function(value) {
+		    return Ext.String.htmlEncode(value || Proxmox.Utils.noneText);
+		},
+	    },
+	    searchdomain: {
+		header: gettext('DNS domain'),
+		iconCls: 'fa fa-globe',
+		editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined,
+		never_delete: true,
+		defaultValue: gettext('use host settings'),
+	    },
+	    nameserver: {
+		header: gettext('DNS servers'),
+		iconCls: 'fa fa-globe',
+		editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined,
+		never_delete: true,
+		defaultValue: gettext('use host settings'),
+	    },
+	    sshkeys: {
+		header: gettext('SSH public key'),
+		iconCls: 'fa fa-key',
+		editor: caps_ci ? 'PVE.qemu.SSHKeyEdit' : undefined,
+		never_delete: true,
+		renderer: function(value) {
+		    value = decodeURIComponent(value);
+		    var keys = value.split('\n');
+		    var text = [];
+		    keys.forEach(function(key) {
+			if (key.length) {
+			    let res = PVE.Parser.parseSSHKey(key);
+			    if (res) {
+				key = Ext.String.htmlEncode(res.comment);
+				if (res.options) {
+				    key += ' <span style="color:gray">(' + gettext('with options') + ')</span>';
+				}
+				text.push(key);
+				return;
+			    }
+			    // Most likely invalid at this point, so just stick to
+			    // the old value.
+			    text.push(Ext.String.htmlEncode(key));
+			}
+		    });
+		    if (text.length) {
+			return text.join('<br>');
+		    } else {
+			return Proxmox.Utils.noneText;
+		    }
+		},
+		defaultValue: '',
+	    },
+	    ciupgrade: {
+		header: gettext('Upgrade packages'),
+		iconCls: 'fa fa-archive',
+		renderer: Proxmox.Utils.format_boolean,
+		defaultValue: 1,
+		editor: {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Upgrade packages on boot'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'ciupgrade',
+			uncheckedValue: 0,
+			value: 1, // serves as default value, using defaultValue is not enough
+			fieldLabel: gettext('Upgrade packages'),
+			labelWidth: 140,
+		    },
+		},
+	    },
+	};
+	var i;
+	var ipconfig_renderer = function(value, md, record, ri, ci, store, pending) {
+	    var id = record.data.key;
+	    var match = id.match(/^net(\d+)$/);
+	    var val = '';
+	    if (match) {
+		val = me.getObjectValue('ipconfig'+match[1], '', pending);
+	    }
+	    return val;
+	};
+	for (i = 0; i < 32; i++) {
+	    // we want to show an entry for every network device
+	    // even if it is empty
+	    me.rows['net' + i.toString()] = {
+		multiKey: ['ipconfig' + i.toString(), 'net' + i.toString()],
+		header: gettext('IP Config') + ' (net' + i.toString() +')',
+		editor: caps_ci ? 'PVE.qemu.IPConfigEdit' : undefined,
+		iconCls: 'fa fa-exchange',
+		renderer: ipconfig_renderer,
+	    };
+	    me.rows['ipconfig' + i.toString()] = {
+		visible: false,
+	    };
+	}
+
+	PVE.Utils.forEachBus(['ide', 'scsi', 'sata'], function(type, id) {
+	    me.rows[type+id] = {
+		visible: false,
+	    };
+	});
+	me.callParent();
+	me.mon(me.rstore, 'load', me.set_button_status, me);
+    },
+});
+Ext.define('PVE.qemu.CmdMenu', {
+    extend: 'Ext.menu.Menu',
+
+    showSeparator: false,
+    initComponent: function() {
+	let me = this;
+
+	let info = me.pveSelNode.data;
+	if (!info.node) {
+	    throw "no node name specified";
+	}
+	if (!info.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	let vm_command = function(cmd, params) {
+	    Proxmox.Utils.API2Request({
+		params: params,
+		url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`,
+		method: 'POST',
+		failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+	    });
+	};
+	let confirmedVMCommand = (cmd, params, confirmTask) => {
+	    let task = confirmTask || `qm${cmd}`;
+	    let msg = Proxmox.Utils.format_task_description(task, info.vmid);
+	    Ext.Msg.confirm(gettext('Confirm'), msg, btn => {
+		if (btn === 'yes') {
+		    vm_command(cmd, params);
+		}
+	    });
+	};
+
+	let caps = Ext.state.Manager.get('GuiCap');
+	let standalone = PVE.Utils.isStandaloneNode();
+
+	let running = false, stopped = true, suspended = false;
+	switch (info.status) {
+	    case 'running':
+		running = true;
+		stopped = false;
+		break;
+	    case 'suspended':
+		stopped = false;
+		suspended = true;
+		break;
+	    case 'paused':
+		stopped = false;
+		suspended = true;
+		break;
+	    default: break;
+	}
+
+	me.title = "VM " + info.vmid;
+
+	me.items = [
+	    {
+		text: gettext('Start'),
+		iconCls: 'fa fa-fw fa-play',
+		hidden: running || suspended,
+		disabled: running || suspended,
+		handler: () => vm_command('start'),
+	    },
+	    {
+		text: gettext('Pause'),
+		iconCls: 'fa fa-fw fa-pause',
+		hidden: stopped || suspended,
+		disabled: stopped || suspended,
+		handler: () => confirmedVMCommand('suspend', undefined, 'qmpause'),
+	    },
+	    {
+		text: gettext('Hibernate'),
+		iconCls: 'fa fa-fw fa-download',
+		hidden: stopped || suspended,
+		disabled: stopped || suspended,
+		tooltip: gettext('Suspend to disk'),
+		handler: () => confirmedVMCommand('suspend', { todisk: 1 }),
+	    },
+	    {
+		text: gettext('Resume'),
+		iconCls: 'fa fa-fw fa-play',
+		hidden: !suspended,
+		handler: () => vm_command('resume'),
+	    },
+	    {
+		text: gettext('Shutdown'),
+		iconCls: 'fa fa-fw fa-power-off',
+		disabled: stopped || suspended,
+		handler: () => confirmedVMCommand('shutdown'),
+	    },
+	    {
+		text: gettext('Stop'),
+		iconCls: 'fa fa-fw fa-stop',
+		disabled: stopped,
+		tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'),
+		handler: () => {
+		    Ext.create('PVE.GuestStop', {
+			nodename: info.node,
+			vm: info,
+			autoShow: true,
+		    });
+		},
+	    },
+	    {
+		text: gettext('Reboot'),
+		iconCls: 'fa fa-fw fa-refresh',
+		disabled: stopped,
+		tooltip: Ext.String.format(gettext('Reboot {0}'), 'VM'),
+		handler: () => confirmedVMCommand('reboot'),
+	    },
+	    {
+		xtype: 'menuseparator',
+		hidden: (standalone || !caps.vms['VM.Migrate']) && !caps.vms['VM.Allocate'] && !caps.vms['VM.Clone'],
+	    },
+	    {
+		text: gettext('Migrate'),
+		iconCls: 'fa fa-fw fa-send-o',
+		hidden: standalone || !caps.vms['VM.Migrate'],
+		handler: function() {
+		    Ext.create('PVE.window.Migrate', {
+			vmtype: 'qemu',
+			nodename: info.node,
+			vmid: info.vmid,
+			autoShow: true,
+		    });
+		},
+	    },
+	    {
+		text: gettext('Clone'),
+		iconCls: 'fa fa-fw fa-clone',
+		hidden: !caps.vms['VM.Clone'],
+		handler: () => PVE.window.Clone.wrap(info.node, info.vmid, me.isTemplate, 'qemu'),
+	    },
+	    {
+		text: gettext('Convert to template'),
+		iconCls: 'fa fa-fw fa-file-o',
+		hidden: !caps.vms['VM.Allocate'],
+		handler: function() {
+		    let msg = Proxmox.Utils.format_task_description('qmtemplate', info.vmid);
+		    Ext.Msg.confirm(gettext('Confirm'), msg, btn => {
+			if (btn === 'yes') {
+			    Proxmox.Utils.API2Request({
+				url: `/nodes/${info.node}/qemu/${info.vmid}/template`,
+				method: 'POST',
+				failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus),
+			    });
+			}
+		    });
+		},
+	    },
+	    { xtype: 'menuseparator' },
+	    {
+		text: gettext('Console'),
+		iconCls: 'fa fa-fw fa-terminal',
+		handler: function() {
+		    Proxmox.Utils.API2Request({
+			url: `/nodes/${info.node}/qemu/${info.vmid}/status/current`,
+			failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus),
+			success: function({ result: { data } }, opts) {
+			    PVE.Utils.openDefaultConsoleWindow(
+				{
+				    spice: data.spice,
+				    xtermjs: data.serial,
+				},
+				'kvm',
+				info.vmid,
+				info.node,
+				info.name,
+			    );
+			},
+		    });
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.qemu.Config', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.PVE.qemu.Config',
+
+    onlineHelp: 'chapter_virtual_machines',
+    userCls: 'proxmox-tags-full',
+
+    initComponent: function() {
+        var me = this;
+	var vm = me.pveSelNode.data;
+
+	var nodename = vm.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = vm.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var template = !!vm.template;
+
+	var running = !!vm.uptime;
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var base_url = '/nodes/' + nodename + "/qemu/" + vmid;
+
+	me.statusStore = Ext.create('Proxmox.data.ObjectStore', {
+	    url: '/api2/json' + base_url + '/status/current',
+	    interval: 1000,
+	});
+
+	var vm_command = function(cmd, params) {
+	    Proxmox.Utils.API2Request({
+		params: params,
+		url: base_url + '/status/' + cmd,
+		waitMsgTarget: me,
+		method: 'POST',
+		failure: function(response, opts) {
+		    Ext.Msg.alert('Error', response.htmlStatus);
+		},
+	    });
+	};
+
+	var resumeBtn = Ext.create('Ext.Button', {
+	    text: gettext('Resume'),
+	    disabled: !caps.vms['VM.PowerMgmt'],
+	    hidden: true,
+	    handler: function() {
+		vm_command('resume');
+	    },
+	    iconCls: 'fa fa-play',
+	});
+
+	var startBtn = Ext.create('Ext.Button', {
+	    text: gettext('Start'),
+	    disabled: !caps.vms['VM.PowerMgmt'] || running,
+	    hidden: template,
+	    handler: function() {
+		vm_command('start');
+	    },
+	    iconCls: 'fa fa-play',
+	});
+
+	var migrateBtn = Ext.create('Ext.Button', {
+	    text: gettext('Migrate'),
+	    disabled: !caps.vms['VM.Migrate'],
+	    hidden: PVE.Utils.isStandaloneNode(),
+	    handler: function() {
+		var win = Ext.create('PVE.window.Migrate', {
+		    vmtype: 'qemu',
+		    nodename: nodename,
+		    vmid: vmid,
+		});
+		win.show();
+	    },
+	    iconCls: 'fa fa-send-o',
+	});
+
+	var moreBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('More'),
+	    menu: {
+ items: [
+		{
+		    text: gettext('Clone'),
+		    iconCls: 'fa fa-fw fa-clone',
+		    hidden: !caps.vms['VM.Clone'],
+		    handler: function() {
+			PVE.window.Clone.wrap(nodename, vmid, template, 'qemu');
+		    },
+		},
+		{
+		    text: gettext('Convert to template'),
+		    disabled: template,
+		    xtype: 'pveMenuItem',
+		    iconCls: 'fa fa-fw fa-file-o',
+		    hidden: !caps.vms['VM.Allocate'],
+		    confirmMsg: Proxmox.Utils.format_task_description('qmtemplate', vmid),
+		    handler: function() {
+			Proxmox.Utils.API2Request({
+			    url: base_url + '/template',
+			    waitMsgTarget: me,
+			    method: 'POST',
+			    failure: function(response, opts) {
+				Ext.Msg.alert('Error', response.htmlStatus);
+			    },
+			});
+		    },
+		},
+		{
+		    iconCls: 'fa fa-heartbeat ',
+		    hidden: !caps.nodes['Sys.Console'],
+		    text: gettext('Manage HA'),
+		    handler: function() {
+			var ha = vm.hastate;
+			Ext.create('PVE.ha.VMResourceEdit', {
+			    vmid: vmid,
+			    isCreate: !ha || ha === 'unmanaged',
+			}).show();
+		    },
+		},
+		{
+		    text: gettext('Remove'),
+		    itemId: 'removeBtn',
+		    disabled: !caps.vms['VM.Allocate'],
+		    handler: function() {
+			Ext.create('PVE.window.SafeDestroyGuest', {
+			    url: base_url,
+			    item: { type: 'VM', id: vmid },
+			    taskName: 'qmdestroy',
+			}).show();
+		    },
+		    iconCls: 'fa fa-trash-o',
+		},
+	    ],
+},
+	});
+
+	var shutdownBtn = Ext.create('PVE.button.Split', {
+	    text: gettext('Shutdown'),
+	    disabled: !caps.vms['VM.PowerMgmt'] || !running,
+	    hidden: template,
+	    confirmMsg: Proxmox.Utils.format_task_description('qmshutdown', vmid),
+	    handler: function() {
+		vm_command('shutdown');
+	    },
+	    menu: {
+		items: [{
+		    text: gettext('Reboot'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    tooltip: Ext.String.format(gettext('Shutdown, apply pending changes and reboot {0}'), 'VM'),
+		    confirmMsg: Proxmox.Utils.format_task_description('qmreboot', vmid),
+		    handler: function() {
+			vm_command("reboot");
+		    },
+		    iconCls: 'fa fa-refresh',
+		}, {
+		    text: gettext('Pause'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    confirmMsg: Proxmox.Utils.format_task_description('qmpause', vmid),
+		    handler: function() {
+			vm_command("suspend");
+		    },
+		    iconCls: 'fa fa-pause',
+		}, {
+		    text: gettext('Hibernate'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    confirmMsg: Proxmox.Utils.format_task_description('qmsuspend', vmid),
+		    tooltip: gettext('Suspend to disk'),
+		    handler: function() {
+			vm_command("suspend", { todisk: 1 });
+		    },
+		    iconCls: 'fa fa-download',
+		}, {
+		    text: gettext('Stop'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'),
+		    handler: function() {
+			Ext.create('PVE.GuestStop', {
+			    nodename: nodename,
+			    vm: vm,
+			    autoShow: true,
+			});
+		    },
+		    iconCls: 'fa fa-stop',
+		}, {
+		    text: gettext('Reset'),
+		    disabled: !caps.vms['VM.PowerMgmt'],
+		    tooltip: Ext.String.format(gettext('Reset {0} immediately'), 'VM'),
+		    confirmMsg: Proxmox.Utils.format_task_description('qmreset', vmid),
+		    handler: function() {
+			vm_command("reset");
+		    },
+		    iconCls: 'fa fa-bolt',
+		}],
+	    },
+	    iconCls: 'fa fa-power-off',
+	});
+
+	var consoleBtn = Ext.create('PVE.button.ConsoleButton', {
+	    disabled: !caps.vms['VM.Console'],
+	    hidden: template,
+	    consoleType: 'kvm',
+	    // disable spice/xterm for default action until status api call succeeded
+	    enableSpice: false,
+	    enableXtermjs: false,
+	    consoleName: vm.name,
+	    nodename: nodename,
+	    vmid: vmid,
+	});
+
+	var statusTxt = Ext.create('Ext.toolbar.TextItem', {
+	    data: {
+		lock: undefined,
+	    },
+	    tpl: [
+		'<tpl if="lock">',
+		'<i class="fa fa-lg fa-lock"></i> ({lock})',
+		'</tpl>',
+	    ],
+	});
+
+	let tagsContainer = Ext.create('PVE.panel.TagEditContainer', {
+	    tags: vm.tags,
+	    canEdit: !!caps.vms['VM.Config.Options'],
+	    listeners: {
+		change: function(tags) {
+		    Proxmox.Utils.API2Request({
+			url: base_url + '/config',
+			method: 'PUT',
+			params: {
+			    tags,
+			},
+			success: function() {
+			    me.statusStore.load();
+			},
+			failure: function(response) {
+			    Ext.Msg.alert('Error', response.htmlStatus);
+			    me.statusStore.load();
+			},
+		    });
+		},
+	    },
+	});
+
+	let vm_text = `${vm.vmid} (${vm.name})`;
+
+	Ext.apply(me, {
+	    title: Ext.String.format(gettext("Virtual Machine {0} on node '{1}'"), vm_text, nodename),
+	    hstateid: 'kvmtab',
+	    tbarSpacing: false,
+	    tbar: [statusTxt, tagsContainer, '->', resumeBtn, startBtn, shutdownBtn, migrateBtn, consoleBtn, moreBtn],
+	    defaults: { statusStore: me.statusStore },
+	    items: [
+		{
+		    title: gettext('Summary'),
+		    xtype: 'pveGuestSummary',
+		    iconCls: 'fa fa-book',
+		    itemId: 'summary',
+		},
+	    ],
+	});
+
+	if (caps.vms['VM.Console'] && !template) {
+	    me.items.push({
+		title: gettext('Console'),
+		itemId: 'console',
+		iconCls: 'fa fa-terminal',
+		xtype: 'pveNoVncConsole',
+		vmid: vmid,
+		consoleType: 'kvm',
+		nodename: nodename,
+	    });
+	}
+
+	me.items.push(
+	    {
+		title: gettext('Hardware'),
+		itemId: 'hardware',
+		iconCls: 'fa fa-desktop',
+		xtype: 'PVE.qemu.HardwareView',
+	    },
+	    {
+		title: 'Cloud-Init',
+		itemId: 'cloudinit',
+		iconCls: 'fa fa-cloud',
+		xtype: 'pveCiPanel',
+	    },
+	    {
+		title: gettext('Options'),
+		iconCls: 'fa fa-gear',
+		itemId: 'options',
+		xtype: 'PVE.qemu.Options',
+	    },
+	    {
+		title: gettext('Task History'),
+		itemId: 'tasks',
+		xtype: 'proxmoxNodeTasks',
+		iconCls: 'fa fa-list-alt',
+		nodename: nodename,
+		preFilter: {
+		    vmid,
+		},
+	    },
+	);
+
+	if (caps.vms['VM.Monitor'] && !template) {
+	    me.items.push({
+		title: gettext('Monitor'),
+		iconCls: 'fa fa-eye',
+		itemId: 'monitor',
+		xtype: 'pveQemuMonitor',
+	    });
+	}
+
+	if (caps.vms['VM.Backup']) {
+	    me.items.push({
+		title: gettext('Backup'),
+		iconCls: 'fa fa-floppy-o',
+		xtype: 'pveBackupView',
+		itemId: 'backup',
+	    },
+	    {
+		title: gettext('Replication'),
+		iconCls: 'fa fa-retweet',
+		xtype: 'pveReplicaView',
+		itemId: 'replication',
+	    });
+	}
+
+	if ((caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] ||
+	    caps.vms['VM.Audit']) && !template) {
+	    me.items.push({
+		title: gettext('Snapshots'),
+		iconCls: 'fa fa-history',
+		type: 'qemu',
+		xtype: 'pveGuestSnapshotTree',
+		itemId: 'snapshot',
+	    });
+	}
+
+	if (caps.vms['VM.Audit']) {
+	    me.items.push(
+		{
+		    xtype: 'pveFirewallRules',
+		    title: gettext('Firewall'),
+		    iconCls: 'fa fa-shield',
+		    allow_iface: true,
+		    base_url: base_url + '/firewall/rules',
+		    list_refs_url: base_url + '/firewall/refs',
+		    itemId: 'firewall',
+		},
+		{
+		    xtype: 'pveFirewallOptions',
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-gear',
+		    onlineHelp: 'pve_firewall_vm_container_configuration',
+		    title: gettext('Options'),
+		    base_url: base_url + '/firewall/options',
+		    fwtype: 'vm',
+		    itemId: 'firewall-options',
+		},
+		{
+		    xtype: 'pveFirewallAliases',
+		    title: gettext('Alias'),
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-external-link',
+		    base_url: base_url + '/firewall/aliases',
+		    itemId: 'firewall-aliases',
+		},
+		{
+		    xtype: 'pveIPSet',
+		    title: gettext('IPSet'),
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-list-ol',
+		    base_url: base_url + '/firewall/ipset',
+		    list_refs_url: base_url + '/firewall/refs',
+		    itemId: 'firewall-ipset',
+		},
+	    );
+	}
+
+	if (caps.vms['VM.Console']) {
+            me.items.push(
+                {
+		    title: gettext('Log'),
+		    groups: ['firewall'],
+		    iconCls: 'fa fa-list',
+		    onlineHelp: 'chapter_pve_firewall',
+		    itemId: 'firewall-fwlog',
+		    xtype: 'proxmoxLogView',
+		    url: '/api2/extjs' + base_url + '/firewall/log',
+		    log_select_timespan: true,
+		    submitFormat: 'U',
+		},
+	    );
+	}
+
+	if (caps.vms['Permissions.Modify']) {
+	    me.items.push({
+		xtype: 'pveACLView',
+		title: gettext('Permissions'),
+		iconCls: 'fa fa-unlock',
+		itemId: 'permissions',
+		path: '/vms/' + vmid,
+	    });
+	}
+
+	me.callParent();
+
+	var prevQMPStatus = 'unknown';
+        me.mon(me.statusStore, 'load', function(s, records, success) {
+	    var status;
+	    var qmpstatus;
+	    var spice = false;
+	    var xtermjs = false;
+	    var lock;
+	    var rec;
+
+	    if (!success) {
+		status = qmpstatus = 'unknown';
+	    } else {
+		rec = s.data.get('status');
+		status = rec ? rec.data.value : 'unknown';
+		rec = s.data.get('qmpstatus');
+		qmpstatus = rec ? rec.data.value : 'unknown';
+		rec = s.data.get('template');
+		template = rec ? rec.data.value : false;
+		rec = s.data.get('lock');
+		lock = rec ? rec.data.value : undefined;
+
+		spice = !!s.data.get('spice');
+		xtermjs = !!s.data.get('serial');
+	    }
+
+	    rec = s.data.get('tags');
+	    tagsContainer.loadTags(rec?.data?.value);
+
+	    if (template) {
+		return;
+	    }
+
+	    var resume = ['prelaunch', 'paused', 'suspended'].indexOf(qmpstatus) !== -1;
+
+	    if (resume || lock === 'suspended') {
+		startBtn.setVisible(false);
+		resumeBtn.setVisible(true);
+	    } else {
+		startBtn.setVisible(true);
+		resumeBtn.setVisible(false);
+	    }
+
+	    consoleBtn.setEnableSpice(spice);
+	    consoleBtn.setEnableXtermJS(xtermjs);
+
+	    statusTxt.update({ lock: lock });
+
+	    let guest_running = status === 'running' &&
+		!(qmpstatus === "shutdown" || qmpstatus === "prelaunch");
+	    startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || template || guest_running);
+
+	    shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running');
+	    me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped');
+	    consoleBtn.setDisabled(template);
+
+	    let wasStopped = ['prelaunch', 'stopped', 'suspended'].indexOf(prevQMPStatus) !== -1;
+	    if (wasStopped && qmpstatus === 'running') {
+		let con = me.down('#console');
+		if (con) {
+		    con.reload();
+		}
+	    }
+
+	    prevQMPStatus = qmpstatus;
+	});
+
+	me.on('afterrender', function() {
+	    me.statusStore.startUpdate();
+	});
+
+	me.on('destroy', function() {
+	    me.statusStore.stopUpdate();
+	});
+   },
+});
+Ext.define('PVE.qemu.CreateWizard', {
+    extend: 'PVE.window.Wizard',
+    alias: 'widget.pveQemuCreateWizard',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    current: {
+		scsihw: '',
+	    },
+	},
+	formulas: {
+	    cgroupMode: function(get) {
+		const nodeInfo = PVE.data.ResourceStore.getNodes().find(
+		    node => node.node === get('nodename'),
+		);
+		return nodeInfo ? nodeInfo['cgroup-mode'] : 2;
+	    },
+	},
+    },
+
+    cbindData: {
+	nodename: undefined,
+    },
+
+    subject: gettext('Virtual Machine'),
+
+    // fot the special case that we have 2 cdrom drives
+    //
+    // emulates part of the backend bootorder logic, but includes all
+    // cdrom drives since we don't know which one the user put in a bootable iso
+    // and hardcodes the known values (ide0/2, net0)
+    calculateBootOrder: function(values) {
+	// user selected windows + second cdrom
+	if (values.ide0 && values.ide0.match(/media=cdrom/)) {
+	    let disk;
+	    PVE.Utils.forEachBus(['ide', 'scsi', 'virtio', 'sata'], (type, id) => {
+		let confId = type + id;
+		if (!values[confId]) {
+		    return undefined;
+		}
+		if (values[confId].match(/media=cdrom/)) {
+		    return undefined;
+		}
+		disk = confId;
+		return false; // abort loop
+	    });
+
+	    let order = [];
+	    if (disk) {
+		order.push(disk);
+	    }
+	    order.push('ide0', 'ide2');
+	    if (values.net0) {
+		order.push('net0');
+	    }
+
+	    return `order=${order.join(';')}`;
+	}
+	return undefined;
+    },
+
+    items: [
+	{
+	    xtype: 'inputpanel',
+	    title: gettext('General'),
+	    onlineHelp: 'qm_general_settings',
+	    column1: [
+		{
+		    xtype: 'pveNodeSelector',
+		    name: 'nodename',
+		    cbind: {
+			selectCurNode: '{!nodename}',
+			preferredValue: '{nodename}',
+		    },
+		    bind: {
+			value: '{nodename}',
+		    },
+		    fieldLabel: gettext('Node'),
+		    allowBlank: false,
+		    onlineValidator: true,
+		},
+		{
+		    xtype: 'pveGuestIDSelector',
+		    name: 'vmid',
+		    guestType: 'qemu',
+		    value: '',
+		    loadNextFreeID: true,
+		    validateExists: false,
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'name',
+		    vtype: 'DnsName',
+		    value: '',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: true,
+		},
+	    ],
+	    column2: [
+		{
+		    xtype: 'pvePoolSelector',
+		    fieldLabel: gettext('Resource Pool'),
+		    name: 'pool',
+		    value: '',
+		    allowBlank: true,
+		},
+	    ],
+	    advancedColumn1: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'onboot',
+		    uncheckedValue: 0,
+		    defaultValue: 0,
+		    deleteDefaultValue: true,
+		    fieldLabel: gettext('Start at boot'),
+		},
+	    ],
+	    advancedColumn2: [
+		{
+		    xtype: 'textfield',
+		    name: 'order',
+		    defaultValue: '',
+		    emptyText: 'any',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Start/Shutdown order'),
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'up',
+		    defaultValue: '',
+		    emptyText: 'default',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Startup delay'),
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'down',
+		    defaultValue: '',
+		    emptyText: 'default',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Shutdown timeout'),
+		},
+	    ],
+
+	    advancedColumnB: [
+		{
+		    xtype: 'pveTagFieldSet',
+		    name: 'tags',
+		    maxHeight: 150,
+		},
+	    ],
+
+	    onGetValues: function(values) {
+		['name', 'pool', 'onboot', 'agent'].forEach(function(field) {
+		    if (!values[field]) {
+			delete values[field];
+		    }
+		});
+
+		var res = PVE.Parser.printStartup({
+		    order: values.order,
+		    up: values.up,
+		    down: values.down,
+		});
+
+		if (res) {
+		    values.startup = res;
+		}
+
+		delete values.order;
+		delete values.up;
+		delete values.down;
+
+		return values;
+	    },
+	},
+	{
+	    xtype: 'container',
+	    layout: 'hbox',
+	    defaults: {
+		flex: 1,
+		padding: '0 10',
+	    },
+	    title: gettext('OS'),
+	    items: [
+		{
+		    xtype: 'pveQemuCDInputPanel',
+		    bind: {
+			nodename: '{nodename}',
+		    },
+		    confid: 'ide2',
+		    insideWizard: true,
+		},
+		{
+		    xtype: 'pveQemuOSTypePanel',
+		    insideWizard: true,
+		    bind: {
+			nodename: '{nodename}',
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'pveQemuSystemPanel',
+	    title: gettext('System'),
+	    isCreate: true,
+	    insideWizard: true,
+	},
+	{
+	    xtype: 'pveMultiHDPanel',
+	    bind: {
+		nodename: '{nodename}',
+	    },
+	    title: gettext('Disks'),
+	},
+	{
+	    xtype: 'pveQemuProcessorPanel',
+	    insideWizard: true,
+	    title: gettext('CPU'),
+	},
+	{
+	    xtype: 'pveQemuMemoryPanel',
+	    insideWizard: true,
+	    title: gettext('Memory'),
+	},
+	{
+	    xtype: 'pveQemuNetworkInputPanel',
+	    bind: {
+		nodename: '{nodename}',
+	    },
+	    title: gettext('Network'),
+	    insideWizard: true,
+	},
+	{
+	    title: gettext('Confirm'),
+	    layout: 'fit',
+	    items: [
+		{
+		    xtype: 'grid',
+		    store: {
+			model: 'KeyValue',
+			sorters: [{
+			    property: 'key',
+			    direction: 'ASC',
+			}],
+		    },
+		    columns: [
+			{ header: 'Key', width: 150, dataIndex: 'key' },
+			{ header: 'Value', flex: 1, dataIndex: 'value' },
+		    ],
+		},
+	    ],
+	    dockedItems: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'start',
+		    dock: 'bottom',
+		    margin: '5 0 0 0',
+		    boxLabel: gettext('Start after created'),
+		},
+	    ],
+	    listeners: {
+		show: function(panel) {
+		    let wizard = this.up('window');
+		    var kv = wizard.getValues();
+		    var data = [];
+
+		    let boot = wizard.calculateBootOrder(kv);
+		    if (boot) {
+			kv.boot = boot;
+		    }
+
+		    Ext.Object.each(kv, function(key, value) {
+			if (key === 'delete') { // ignore
+			    return;
+			}
+			data.push({ key: key, value: value });
+		    });
+
+		    var summarystore = panel.down('grid').getStore();
+		    summarystore.suspendEvents();
+		    summarystore.removeAll();
+		    summarystore.add(data);
+		    summarystore.sort();
+		    summarystore.resumeEvents();
+		    summarystore.fireEvent('refresh');
+		},
+	    },
+	    onSubmit: function() {
+		var wizard = this.up('window');
+		var kv = wizard.getValues();
+		delete kv.delete;
+
+		var nodename = kv.nodename;
+		delete kv.nodename;
+
+		let boot = wizard.calculateBootOrder(kv);
+		if (boot) {
+		    kv.boot = boot;
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: '/nodes/' + nodename + '/qemu',
+		    waitMsgTarget: wizard,
+		    method: 'POST',
+		    params: kv,
+		    success: function(response) {
+			wizard.close();
+		    },
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    },
+		});
+	    },
+	},
+    ],
+});
+
+
+Ext.define('PVE.qemu.DisplayInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveDisplayInputPanel',
+    onlineHelp: 'qm_display',
+
+    onGetValues: function(values) {
+	let ret = PVE.Parser.printPropertyString(values, 'type');
+	if (ret === '') {
+	    return { 'delete': 'vga' };
+	}
+	return { vga: ret };
+    },
+
+    viewModel: {
+	data: {
+	    type: '__default__',
+	    clipboard: '__default__',
+	},
+	formulas: {
+	    matchNonGUIOption: function(get) {
+		return get('type').match(/^(serial\d|none)$/);
+	    },
+	    memoryEmptyText: function(get) {
+		let val = get('type');
+		if (val === "cirrus") {
+		    return "4";
+		} else if (val === "std" || val.match(/^qxl\d?$/) || val === "vmware") {
+		    return "16";
+		} else if (val.match(/^virtio/)) {
+		    return "256";
+		} else if (get('matchNonGUIOption')) {
+		    return "N/A";
+		} else {
+		    console.debug("unexpected display type", val);
+		    return Proxmox.Utils.defaultText;
+		}
+	    },
+	    isVNC: get => get('clipboard') === 'vnc',
+	    hideDefaultHint: get => get('isVNC') || get('matchNonGUIOption'),
+	    hideVNCHint: get => !get('isVNC') || get('matchNonGUIOption'),
+	},
+    },
+
+    items: [{
+	name: 'type',
+	xtype: 'proxmoxKVComboBox',
+	value: '__default__',
+	deleteEmpty: false,
+	fieldLabel: gettext('Graphic card'),
+	comboItems: Object.entries(PVE.Utils.kvm_vga_drivers),
+	validator: function(v) {
+	    let cfg = this.up('proxmoxWindowEdit').vmconfig || {};
+
+	    if (v.match(/^serial\d+$/) && (!cfg[v] || cfg[v] !== 'socket')) {
+		let fmt = gettext("Serial interface '{0}' is not correctly configured.");
+		return Ext.String.format(fmt, v);
+	    }
+	    return true;
+	},
+	bind: {
+	    value: '{type}',
+	},
+    },
+    {
+	xtype: 'proxmoxintegerfield',
+	emptyText: Proxmox.Utils.defaultText,
+	fieldLabel: gettext('Memory') + ' (MiB)',
+	minValue: 4,
+	maxValue: 512,
+	step: 4,
+	name: 'memory',
+	bind: {
+	    emptyText: '{memoryEmptyText}',
+	    disabled: '{matchNonGUIOption}',
+	},
+    }],
+
+    advancedItems: [
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'clipboard',
+	    deleteEmpty: false,
+	    value: '__default__',
+	    fieldLabel: gettext('Clipboard'),
+	    comboItems: [
+		['__default__', Proxmox.Utils.defaultText],
+		['vnc', 'VNC'],
+	    ],
+	    bind: {
+		value: '{clipboard}',
+		disabled: '{matchNonGUIOption}',
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'vncHint',
+	    userCls: 'pmx-hint',
+	    value: gettext('You cannot use the default SPICE clipboard if the VNC Clipboard is selected.') + ' ' +
+		gettext('VNC Clipboard requires spice-tools installed in the Guest-VM.'),
+	    bind: {
+		hidden: '{hideVNCHint}',
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'defaultHint',
+	    userCls: 'pmx-hint',
+	    value: gettext('This option depends on your display type.') + ' ' +
+		gettext('If the display type uses SPICE you are able to use the default SPICE Clipboard.'),
+	    bind: {
+		hidden: '{hideDefaultHint}',
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.DisplayEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    vmconfig: undefined,
+
+    subject: gettext('Display'),
+    width: 350,
+
+    items: [{
+	xtype: 'pveDisplayInputPanel',
+    }],
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	me.load({
+	    success: function(response) {
+		me.vmconfig = response.result.data;
+		let vga = me.vmconfig.vga || '__default__';
+		me.setValues(PVE.Parser.parsePropertyString(vga, 'type'));
+	    },
+	});
+    },
+});
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.HDInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuHDInputPanel',
+    onlineHelp: 'qm_hard_disk',
+
+    insideWizard: false,
+
+    unused: false, // ADD usused disk imaged
+
+    vmconfig: {}, // used to select usused disks
+
+    viewModel: {
+	data: {
+	    isSCSI: false,
+	    isVirtIO: false,
+	    isSCSISingle: false,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	onControllerChange: function(field) {
+	    let me = this;
+	    let vm = this.getViewModel();
+
+	    let value = field.getValue();
+	    vm.set('isSCSI', value.match(/^scsi/));
+	    vm.set('isVirtIO', value.match(/^virtio/));
+
+	    me.fireIdChange();
+	},
+
+	fireIdChange: function() {
+	    let view = this.getView();
+	    view.fireEvent('diskidchange', view, view.bussel.getConfId());
+	},
+
+	control: {
+	    'field[name=controller]': {
+		change: 'onControllerChange',
+		afterrender: 'onControllerChange',
+	    },
+	    'field[name=deviceid]': {
+		change: 'fireIdChange',
+	    },
+	    'field[name=scsiController]': {
+		change: function(f, value) {
+		    let vm = this.getViewModel();
+		    vm.set('isSCSISingle', value === 'virtio-scsi-single');
+		},
+	    },
+	},
+
+	init: function(view) {
+	    var vm = this.getViewModel();
+	    if (view.isCreate) {
+		vm.set('isIncludedInBackup', true);
+	    }
+	    if (view.confid) {
+		vm.set('isSCSI', view.confid.match(/^scsi/));
+		vm.set('isVirtIO', view.confid.match(/^virtio/));
+	    }
+	},
+    },
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var params = {};
+	var confid = me.confid || values.controller + values.deviceid;
+
+	if (me.unused) {
+	    me.drive.file = me.vmconfig[values.unusedId];
+	    confid = values.controller + values.deviceid;
+	} else if (me.isCreate) {
+	    if (values.hdimage) {
+		me.drive.file = values.hdimage;
+	    } else {
+		me.drive.file = values.hdstorage + ":" + values.disksize;
+	    }
+	    me.drive.format = values.diskformat;
+	}
+
+	PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
+	PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
+	PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
+	PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on');
+	PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
+	PVE.Utils.propertyStringSet(me.drive, values.readOnly, 'ro', 'on');
+	PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
+	PVE.Utils.propertyStringSet(me.drive, values.aio, 'aio');
+
+	['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'].forEach(name => {
+	    let burst_name = `${name}_max`;
+	    PVE.Utils.propertyStringSet(me.drive, values[name], name);
+	    PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
+	});
+
+	params[confid] = PVE.Parser.printQemuDrive(me.drive);
+
+	return params;
+    },
+
+    updateVMConfig: function(vmconfig) {
+	var me = this;
+	me.vmconfig = vmconfig;
+	me.bussel?.updateVMConfig(vmconfig);
+    },
+
+    setVMConfig: function(vmconfig) {
+	var me = this;
+
+	me.vmconfig = vmconfig;
+
+	if (me.bussel) {
+	    me.bussel.setVMConfig(vmconfig);
+	    me.scsiController.setValue(vmconfig.scsihw);
+	}
+	if (me.unusedDisks) {
+	    var disklist = [];
+	    Ext.Object.each(vmconfig, function(key, value) {
+		if (key.match(/^unused\d+$/)) {
+		    disklist.push([key, value]);
+		}
+	    });
+	    me.unusedDisks.store.loadData(disklist);
+	    me.unusedDisks.setValue(me.confid);
+	}
+    },
+
+    setDrive: function(drive) {
+	var me = this;
+
+	me.drive = drive;
+
+	var values = {};
+	var match = drive.file.match(/^([^:]+):/);
+	if (match) {
+	    values.hdstorage = match[1];
+	}
+
+	values.hdimage = drive.file;
+	values.backup = PVE.Parser.parseBoolean(drive.backup, 1);
+	values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1);
+	values.diskformat = drive.format || 'raw';
+	values.cache = drive.cache || '__default__';
+	values.discard = drive.discard === 'on';
+	values.ssd = PVE.Parser.parseBoolean(drive.ssd);
+	values.iothread = PVE.Parser.parseBoolean(drive.iothread);
+	values.readOnly = PVE.Parser.parseBoolean(drive.ro);
+	values.aio = drive.aio || '__default__';
+
+	values.mbps_rd = drive.mbps_rd;
+	values.mbps_wr = drive.mbps_wr;
+	values.iops_rd = drive.iops_rd;
+	values.iops_wr = drive.iops_wr;
+	values.mbps_rd_max = drive.mbps_rd_max;
+	values.mbps_wr_max = drive.mbps_wr_max;
+	values.iops_rd_max = drive.iops_rd_max;
+	values.iops_wr_max = drive.iops_wr_max;
+
+	me.setValues(values);
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+	me.down('#hdstorage').setNodename(nodename);
+	me.down('#hdimage').setStorage(undefined, nodename);
+    },
+
+    hasAdvanced: true,
+
+    initComponent: function() {
+	var me = this;
+
+	me.drive = {};
+
+	let column1 = [];
+	let column2 = [];
+
+	let advancedColumn1 = [];
+	let advancedColumn2 = [];
+
+	if (!me.confid || me.unused) {
+	    me.bussel = Ext.create('PVE.form.ControllerSelector', {
+		vmconfig: me.vmconfig,
+		selectFree: true,
+	    });
+	    column1.push(me.bussel);
+
+	    me.scsiController = Ext.create('Ext.form.field.Display', {
+		fieldLabel: gettext('SCSI Controller'),
+		reference: 'scsiController',
+		name: 'scsiController',
+		bind: me.insideWizard ? {
+		    value: '{current.scsihw}',
+		    visible: '{isSCSI}',
+		} : {
+		    visible: '{isSCSI}',
+		},
+		renderer: PVE.Utils.render_scsihw,
+		submitValue: false,
+		hidden: true,
+	    });
+	    column1.push(me.scsiController);
+	}
+
+	if (me.unused) {
+	    me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', {
+		name: 'unusedId',
+		fieldLabel: gettext('Disk image'),
+		matchFieldWidth: false,
+		listConfig: {
+		    width: 350,
+		},
+		data: [],
+		allowBlank: false,
+	    });
+	    column1.push(me.unusedDisks);
+	} else if (me.isCreate) {
+	    column1.push({
+		xtype: 'pveDiskStorageSelector',
+		storageContent: 'images',
+		name: 'disk',
+		nodename: me.nodename,
+		autoSelect: me.insideWizard,
+	    });
+	} else {
+	    column1.push({
+		xtype: 'textfield',
+		disabled: true,
+		submitValue: false,
+		fieldLabel: gettext('Disk image'),
+                name: 'hdimage',
+	    });
+	}
+
+	column2.push(
+	    {
+		xtype: 'CacheTypeSelector',
+		name: 'cache',
+		value: '__default__',
+		fieldLabel: gettext('Cache'),
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Discard'),
+		reference: 'discard',
+		name: 'discard',
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'iothread',
+		fieldLabel: 'IO thread',
+		clearOnDisable: true,
+		bind: me.insideWizard || me.isCreate ? {
+		    disabled: '{!isVirtIO && !isSCSI}',
+		    // Checkbox.setValue handles Arrays in a different way, therefore cast to bool
+		    value: '{!!isVirtIO || (isSCSI && isSCSISingle)}',
+		} : {
+		    disabled: '{!isVirtIO && !isSCSI}',
+		},
+	    },
+	);
+
+	advancedColumn1.push(
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('SSD emulation'),
+		name: 'ssd',
+		clearOnDisable: true,
+		bind: {
+		    disabled: '{isVirtIO}',
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'readOnly', // `ro` in the config, we map in get/set values
+		defaultValue: 0,
+		fieldLabel: gettext('Read-only'),
+		clearOnDisable: true,
+		bind: {
+		    disabled: '{!isVirtIO && !isSCSI}',
+		},
+	    },
+	);
+
+	advancedColumn2.push(
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Backup'),
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Include volume in backup job'),
+		},
+		name: 'backup',
+		bind: {
+		    value: '{isIncludedInBackup}',
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Skip replication'),
+		name: 'noreplicate',
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'aio',
+		fieldLabel: gettext('Async IO'),
+		allowBlank: false,
+		value: '__default__',
+		comboItems: [
+		    ['__default__', Proxmox.Utils.defaultText + ' (io_uring)'],
+		    ['io_uring', 'io_uring'],
+		    ['native', 'native'],
+		    ['threads', 'threads'],
+		],
+	    },
+	);
+
+	let labelWidth = 140;
+
+	let bwColumn1 = [
+	    {
+		xtype: 'numberfield',
+		name: 'mbps_rd',
+		minValue: 1,
+		step: 1,
+		fieldLabel: gettext('Read limit') + ' (MB/s)',
+		labelWidth: labelWidth,
+		emptyText: gettext('unlimited'),
+	    },
+	    {
+		xtype: 'numberfield',
+		name: 'mbps_wr',
+		minValue: 1,
+		step: 1,
+		fieldLabel: gettext('Write limit') + ' (MB/s)',
+		labelWidth: labelWidth,
+		emptyText: gettext('unlimited'),
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'iops_rd',
+		minValue: 10,
+		step: 10,
+		fieldLabel: gettext('Read limit') + ' (ops/s)',
+		labelWidth: labelWidth,
+		emptyText: gettext('unlimited'),
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'iops_wr',
+		minValue: 10,
+		step: 10,
+		fieldLabel: gettext('Write limit') + ' (ops/s)',
+		labelWidth: labelWidth,
+		emptyText: gettext('unlimited'),
+	    },
+	];
+
+	let bwColumn2 = [
+	    {
+		xtype: 'numberfield',
+		name: 'mbps_rd_max',
+		minValue: 1,
+		step: 1,
+		fieldLabel: gettext('Read max burst') + ' (MB)',
+		labelWidth: labelWidth,
+		emptyText: gettext('default'),
+	    },
+	    {
+		xtype: 'numberfield',
+		name: 'mbps_wr_max',
+		minValue: 1,
+		step: 1,
+		fieldLabel: gettext('Write max burst') + ' (MB)',
+		labelWidth: labelWidth,
+		emptyText: gettext('default'),
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'iops_rd_max',
+		minValue: 10,
+		step: 10,
+		fieldLabel: gettext('Read max burst') + ' (ops)',
+		labelWidth: labelWidth,
+		emptyText: gettext('default'),
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'iops_wr_max',
+		minValue: 10,
+		step: 10,
+		fieldLabel: gettext('Write max burst') + ' (ops)',
+		labelWidth: labelWidth,
+		emptyText: gettext('default'),
+	    },
+	];
+
+	me.items = [
+	    {
+		xtype: 'tabpanel',
+		plain: true,
+		bodyPadding: 10,
+		border: 0,
+		items: [
+		    {
+			title: gettext('Disk'),
+			xtype: 'inputpanel',
+			reference: 'diskpanel',
+			column1,
+			column2,
+			advancedColumn1,
+			advancedColumn2,
+			showAdvanced: me.showAdvanced,
+			getValues: () => ({}),
+		    },
+		    {
+			title: gettext('Bandwidth'),
+			xtype: 'inputpanel',
+			reference: 'bwpanel',
+			column1: bwColumn1,
+			column2: bwColumn2,
+			showAdvanced: me.showAdvanced,
+			getValues: () => ({}),
+		    },
+		],
+	    },
+	];
+
+	me.callParent();
+    },
+
+    setAdvancedVisible: function(visible) {
+	this.lookup('diskpanel').setAdvancedVisible(visible);
+	this.lookup('bwpanel').setAdvancedVisible(visible);
+    },
+});
+
+Ext.define('PVE.qemu.HDEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+
+    backgroundDelay: 5,
+
+    width: 600,
+    bodyPadding: 0,
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var unused = me.confid && me.confid.match(/^unused\d+$/);
+
+	me.isCreate = me.confid ? unused : true;
+
+	var ipanel = Ext.create('PVE.qemu.HDInputPanel', {
+	    confid: me.confid,
+	    nodename: nodename,
+	    unused: unused,
+	    isCreate: me.isCreate,
+	});
+
+	if (unused) {
+	    me.subject = gettext('Unused Disk');
+	} else if (me.isCreate) {
+            me.subject = gettext('Hard Disk');
+	} else {
+           me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
+	}
+
+	me.items = [ipanel];
+
+	me.callParent();
+	/* 'data' is assigned an empty array in same file, and here we
+	 * use it like an object
+	 */
+	me.load({
+	    success: function(response, options) {
+		ipanel.setVMConfig(response.result.data);
+		if (me.confid) {
+		    var value = response.result.data[me.confid];
+		    var drive = PVE.Parser.parseQemuDrive(me.confid, value);
+		    if (!drive) {
+			Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options');
+			me.close();
+			return;
+		    }
+		    ipanel.setDrive(drive);
+		    me.isValid(); // trigger validation
+		}
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.EFIDiskInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveEFIDiskInputPanel',
+
+    insideWizard: false,
+
+    unused: false, // ADD usused disk imaged
+
+    vmconfig: {}, // used to select usused disks
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.disabled) {
+	    return {};
+	}
+
+	var confid = 'efidisk0';
+
+	if (values.hdimage) {
+	    me.drive.file = values.hdimage;
+	} else {
+	    // we use 1 here, because for efi the size gets overridden from the backend
+	    me.drive.file = values.hdstorage + ":1";
+	}
+
+	// always default to newer 4m type with secure boot support, if we're
+	// adding a new EFI disk there can't be any old state anyway
+	me.drive.efitype = '4m';
+	me.drive['pre-enrolled-keys'] = values.preEnrolledKeys;
+	delete values.preEnrolledKeys;
+
+	me.drive.format = values.diskformat;
+	let params = {};
+	params[confid] = PVE.Parser.printQemuDrive(me.drive);
+	return params;
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+	me.down('#hdstorage').setNodename(nodename);
+	me.down('#hdimage').setStorage(undefined, nodename);
+    },
+
+    setDisabled: function(disabled) {
+	let me = this;
+	me.down('pveDiskStorageSelector').setDisabled(disabled);
+	me.down('proxmoxcheckbox[name=preEnrolledKeys]').setDisabled(disabled);
+	me.callParent(arguments);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.drive = {};
+
+	me.items = [
+	    {
+		xtype: 'pveDiskStorageSelector',
+		name: 'efidisk0',
+		storageLabel: gettext('EFI Storage'),
+		storageContent: 'images',
+		nodename: me.nodename,
+		disabled: me.disabled,
+		hideSize: true,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'preEnrolledKeys',
+		checked: true,
+		fieldLabel: gettext("Pre-Enroll keys"),
+		disabled: me.disabled,
+		//boxLabel: '(e.g., Microsoft secure-boot keys')',
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Use EFIvars image with standard distribution and Microsoft secure boot keys enrolled.'),
+		},
+	    },
+	    {
+		xtype: 'label',
+		text: gettext("Warning: The VM currently does not uses 'OVMF (UEFI)' as BIOS."),
+		userCls: 'pmx-hint',
+		hidden: me.usesEFI,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.EFIDiskEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+    subject: gettext('EFI Disk'),
+
+    width: 450,
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.items = [{
+	    xtype: 'pveEFIDiskInputPanel',
+	    onlineHelp: 'qm_bios_and_uefi',
+	    confid: me.confid,
+	    nodename: nodename,
+	    usesEFI: me.usesEFI,
+	    isCreate: true,
+	}];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.qemu.TPMDiskInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveTPMDiskInputPanel',
+
+    unused: false,
+    vmconfig: {},
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.disabled) {
+	    return {};
+	}
+
+	var confid = 'tpmstate0';
+
+	if (values.hdimage) {
+	    me.drive.file = values.hdimage;
+	} else {
+	    // size is constant, so just use 1
+	    me.drive.file = values.hdstorage + ":1";
+	}
+
+	me.drive.version = values.version;
+	var params = {};
+	params[confid] = PVE.Parser.printQemuDrive(me.drive);
+	return params;
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+	me.down('#hdstorage').setNodename(nodename);
+	me.down('#hdimage').setStorage(undefined, nodename);
+    },
+
+    setDisabled: function(disabled) {
+	let me = this;
+	me.down('pveDiskStorageSelector').setDisabled(disabled);
+	me.down('proxmoxKVComboBox[name=version]').setDisabled(disabled);
+	me.callParent(arguments);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.drive = {};
+
+	me.items = [
+	    {
+		xtype: 'pveDiskStorageSelector',
+		name: me.disktype + '0',
+		storageLabel: gettext('TPM Storage'),
+		storageContent: 'images',
+		nodename: me.nodename,
+		disabled: me.disabled,
+		hideSize: true,
+		hideFormat: true,
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'version',
+		value: 'v2.0',
+		fieldLabel: gettext('Version'),
+		deleteEmpty: false,
+		disabled: me.disabled,
+		comboItems: [
+		    ['v1.2', 'v1.2'],
+		    ['v2.0', 'v2.0'],
+		],
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.TPMDiskEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+    subject: gettext('TPM State'),
+
+    width: 450,
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.items = [{
+	    xtype: 'pveTPMDiskInputPanel',
+	    //onlineHelp: 'qm_tpm', FIXME: add once available
+	    confid: me.confid,
+	    nodename: nodename,
+	    isCreate: true,
+	}];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.window.HDMove', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    resizable: false,
+    modal: true,
+    width: 350,
+    border: false,
+    layout: 'fit',
+    showReset: false,
+    showTaskViewer: true,
+    method: 'POST',
+
+    cbindData: function() {
+	let me = this;
+	return {
+	    disk: me.disk,
+	    isQemu: me.type === 'qemu',
+	    nodename: me.nodename,
+	    url: () => {
+		let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume';
+		return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`;
+	    },
+	};
+    },
+
+    cbind: {
+	title: get => get('isQemu') ? gettext("Move disk") : gettext('Move Volume'),
+	submitText: get => get('title'),
+	qemu: '{isQemu}',
+	url: '{url}',
+    },
+
+    getValues: function() {
+	let me = this;
+	let values = me.formPanel.getForm().getValues();
+
+	let params = {
+	    storage: values.hdstorage,
+	};
+	params[me.qemu ? 'disk' : 'volume'] = me.disk;
+
+	if (values.diskformat && me.qemu) {
+	    params.format = values.diskformat;
+	}
+
+	if (values.deleteDisk) {
+	    params.delete = 1;
+	}
+	return params;
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'moveFormPanel',
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'displayfield',
+		    cbind: {
+			name: get => get('isQemu') ? 'disk' : 'volume',
+			fieldLabel: get => get('isQemu') ? gettext('Disk') : gettext('Mount Point'),
+			value: '{disk}',
+		    },
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'pveDiskStorageSelector',
+		    storageLabel: gettext('Target Storage'),
+		    cbind: {
+			nodename: '{nodename}',
+			storageContent: get => get('isQemu') ? 'images' : 'rootdir',
+			hideFormat: get => get('disk') === 'tpmstate0',
+		    },
+		    hideSize: true,
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Delete source'),
+		    name: 'deleteDisk',
+		    uncheckedValue: 0,
+		    checked: false,
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	if (!me.type) {
+	    throw "no type specified";
+	}
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.window.HDResize', {
+    extend: 'Ext.window.Window',
+
+    resizable: false,
+
+    resize_disk: function(disk, size) {
+	var me = this;
+        var params = { disk: disk, size: '+' + size + 'G' };
+
+	Proxmox.Utils.API2Request({
+	    params: params,
+	    url: '/nodes/' + me.nodename + '/qemu/' + me.vmid + '/resize',
+	    waitMsgTarget: me,
+	    method: 'PUT',
+	    failure: function(response, opts) {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+	    },
+	    success: function(response, options) {
+		Ext.create('Proxmox.window.TaskProgress', {
+		    autoShow: true,
+		    upid: response.result.data,
+		});
+		me.close();
+	    },
+	});
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var items = [
+	    {
+		xtype: 'displayfield',
+		name: 'disk',
+		value: me.disk,
+		fieldLabel: gettext('Disk'),
+		vtype: 'StorageId',
+		allowBlank: false,
+	    },
+	];
+
+	me.hdsizesel = Ext.createWidget('numberfield', {
+	    name: 'size',
+	    minValue: 0,
+	    maxValue: 128*1024,
+	    decimalPrecision: 3,
+	    value: '0',
+	    fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`,
+	    allowBlank: false,
+	});
+
+	items.push(me.hdsizesel);
+
+	me.formPanel = Ext.create('Ext.form.Panel', {
+	    bodyPadding: 10,
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 140,
+		anchor: '100%',
+	    },
+	    items: items,
+	});
+
+	var form = me.formPanel.getForm();
+
+	var submitBtn;
+
+	me.title = gettext('Resize disk');
+	submitBtn = Ext.create('Ext.Button', {
+	    text: gettext('Resize disk'),
+	    handler: function() {
+		if (form.isValid()) {
+		    var values = form.getValues();
+		    me.resize_disk(me.disk, values.size);
+		}
+	    },
+	});
+
+	Ext.apply(me, {
+	    modal: true,
+	    width: 250,
+	    height: 150,
+	    border: false,
+	    layout: 'fit',
+	    buttons: [submitBtn],
+	    items: [me.formPanel],
+	});
+
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.qemu.HardwareView', {
+    extend: 'Proxmox.grid.PendingObjectGrid',
+    alias: ['widget.PVE.qemu.HardwareView'],
+
+    onlineHelp: 'qm_virtual_machines_settings',
+
+    renderKey: function(key, metaData, rec, rowIndex, colIndex, store) {
+	var me = this;
+	var rows = me.rows;
+	var rowdef = rows[key] || {};
+	var iconCls = rowdef.iconCls;
+	var icon = '';
+	var txt = rowdef.header || key;
+
+	metaData.tdAttr = "valign=middle";
+
+	if (rowdef.isOnStorageBus) {
+	    var value = me.getObjectValue(key, '', false);
+	    if (value === '') {
+		value = me.getObjectValue(key, '', true);
+	    }
+	    if (value.match(/vm-.*-cloudinit/)) {
+		iconCls = 'cloud';
+		txt = rowdef.cloudheader;
+	    } else if (value.match(/media=cdrom/)) {
+		metaData.tdCls = 'pve-itype-icon-cdrom';
+		return rowdef.cdheader;
+	    }
+	}
+
+	if (rowdef.tdCls) {
+	    metaData.tdCls = rowdef.tdCls;
+	} else if (iconCls) {
+	    icon = "<i class='pve-grid-fa fa fa-fw fa-" + iconCls + "'></i>";
+	    metaData.tdCls += " pve-itype-fa";
+	}
+
+	// only return icons in grid but not remove dialog
+	if (rowIndex !== undefined) {
+	    return icon + txt;
+	} else {
+	    return txt;
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	const { node: nodename, vmid } = me.pveSelNode.data;
+	if (!nodename) {
+	    throw "no node name specified";
+	} else if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	const caps = Ext.state.Manager.get('GuiCap');
+	const diskCap = caps.vms['VM.Config.Disk'];
+	const cdromCap = caps.vms['VM.Config.CDROM'];
+
+	let isCloudInitKey = v => v && v.toString().match(/vm-.*-cloudinit/);
+
+	const nodeInfo = PVE.data.ResourceStore.getNodes().find(node => node.node === nodename);
+	let processorEditor = {
+	    xtype: 'pveQemuProcessorEdit',
+	    cgroupMode: nodeInfo['cgroup-mode'],
+	};
+
+	let rows = {
+	    memory: {
+		header: gettext('Memory'),
+		editor: caps.vms['VM.Config.Memory'] ? 'PVE.qemu.MemoryEdit' : undefined,
+		never_delete: true,
+		defaultValue: '512',
+		tdCls: 'pve-itype-icon-memory',
+		group: 2,
+		multiKey: ['memory', 'balloon', 'shares'],
+		renderer: function(value, metaData, record, ri, ci, store, pending) {
+		    var res = '';
+
+		    var max = me.getObjectValue('memory', 512, pending);
+		    var balloon = me.getObjectValue('balloon', undefined, pending);
+		    var shares = me.getObjectValue('shares', undefined, pending);
+
+		    res = Proxmox.Utils.format_size(max*1024*1024);
+
+		    if (balloon !== undefined && balloon > 0) {
+			res = Proxmox.Utils.format_size(balloon*1024*1024) + "/" + res;
+
+			if (shares) {
+			    res += ' [shares=' + shares +']';
+			}
+		    } else if (balloon === 0) {
+			res += ' [balloon=0]';
+		    }
+		    return res;
+		},
+	    },
+	    sockets: {
+		header: gettext('Processors'),
+		never_delete: true,
+		editor: caps.vms['VM.Config.CPU'] || caps.vms['VM.Config.HWType']
+		    ? processorEditor : undefined,
+		tdCls: 'pve-itype-icon-cpu',
+		group: 3,
+		defaultValue: '1',
+		multiKey: ['sockets', 'cpu', 'cores', 'numa', 'vcpus', 'cpulimit', 'cpuunits'],
+		renderer: function(value, metaData, record, rowIndex, colIndex, store, pending) {
+		    var sockets = me.getObjectValue('sockets', 1, pending);
+		    var model = me.getObjectValue('cpu', undefined, pending);
+		    var cores = me.getObjectValue('cores', 1, pending);
+		    var numa = me.getObjectValue('numa', undefined, pending);
+		    var vcpus = me.getObjectValue('vcpus', undefined, pending);
+		    var cpulimit = me.getObjectValue('cpulimit', undefined, pending);
+		    var cpuunits = me.getObjectValue('cpuunits', undefined, pending);
+
+		    let res = Ext.String.format(
+		        '{0} ({1} sockets, {2} cores)', sockets * cores, sockets, cores);
+
+		    if (model) {
+			res += ' [' + model + ']';
+		    }
+		    if (numa) {
+			res += ' [numa=' + numa +']';
+		    }
+		    if (vcpus) {
+			res += ' [vcpus=' + vcpus +']';
+		    }
+		    if (cpulimit) {
+			res += ' [cpulimit=' + cpulimit +']';
+		    }
+		    if (cpuunits) {
+			res += ' [cpuunits=' + cpuunits +']';
+		    }
+
+		    return res;
+		},
+	    },
+	    bios: {
+		header: 'BIOS',
+		group: 4,
+		never_delete: true,
+		editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.BiosEdit' : undefined,
+		defaultValue: '',
+		iconCls: 'microchip',
+		renderer: PVE.Utils.render_qemu_bios,
+	    },
+	    vga: {
+		header: gettext('Display'),
+		editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.DisplayEdit' : undefined,
+		never_delete: true,
+		iconCls: 'desktop',
+		group: 5,
+		defaultValue: '',
+		renderer: PVE.Utils.render_kvm_vga_driver,
+	    },
+	    machine: {
+		header: gettext('Machine'),
+		editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.MachineEdit' : undefined,
+		iconCls: 'cogs',
+		never_delete: true,
+		group: 6,
+		defaultValue: '',
+		renderer: function(value, metaData, record, rowIndex, colIndex, store, pending) {
+		    let ostype = me.getObjectValue('ostype', undefined, pending);
+		    if (PVE.Utils.is_windows(ostype) &&
+			(!value || value === 'pc' || value === 'q35')) {
+			return value === 'q35' ? 'pc-q35-5.1' : 'pc-i440fx-5.1';
+		    }
+		    return PVE.Utils.render_qemu_machine(value);
+		},
+	    },
+	    scsihw: {
+		header: gettext('SCSI Controller'),
+		iconCls: 'database',
+		editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.ScsiHwEdit' : undefined,
+		renderer: PVE.Utils.render_scsihw,
+		group: 7,
+		never_delete: true,
+		defaultValue: '',
+	    },
+	    vmstate: {
+		header: gettext('Hibernation VM State'),
+		iconCls: 'download',
+		del_extra_msg: gettext('The saved VM state will be permanently lost.'),
+		group: 100,
+	    },
+	    cores: {
+		visible: false,
+	    },
+	    cpu: {
+		visible: false,
+	    },
+	    numa: {
+		visible: false,
+	    },
+	    balloon: {
+		visible: false,
+	    },
+	    hotplug: {
+		visible: false,
+	    },
+	    vcpus: {
+		visible: false,
+	    },
+	    cpuunits: {
+		visible: false,
+	    },
+	    cpulimit: {
+		visible: false,
+	    },
+	    shares: {
+		visible: false,
+	    },
+	    ostype: {
+		visible: false,
+	    },
+	};
+
+	PVE.Utils.forEachBus(undefined, function(type, id) {
+	    let confid = type + id;
+	    rows[confid] = {
+		group: 10,
+		iconCls: 'hdd-o',
+		editor: 'PVE.qemu.HDEdit',
+		isOnStorageBus: true,
+		header: gettext('Hard Disk') + ' (' + confid +')',
+		cdheader: gettext('CD/DVD Drive') + ' (' + confid +')',
+		cloudheader: gettext('CloudInit Drive') + ' (' + confid + ')',
+	    };
+	});
+	for (let i = 0; i < PVE.Utils.hardware_counts.net; i++) {
+	    let confid = "net" + i.toString();
+	    rows[confid] = {
+		group: 15,
+		order: i,
+		iconCls: 'exchange',
+		editor: caps.vms['VM.Config.Network'] ? 'PVE.qemu.NetworkEdit' : undefined,
+		never_delete: !caps.vms['VM.Config.Network'],
+		header: gettext('Network Device') + ' (' + confid +')',
+	    };
+	}
+	rows.efidisk0 = {
+	    group: 20,
+	    iconCls: 'hdd-o',
+	    editor: null,
+	    never_delete: !caps.vms['VM.Config.Disk'],
+	    header: gettext('EFI Disk'),
+	};
+	rows.tpmstate0 = {
+	    group: 22,
+	    iconCls: 'hdd-o',
+	    editor: null,
+	    never_delete: !caps.vms['VM.Config.Disk'],
+	    header: gettext('TPM State'),
+	};
+	for (let i = 0; i < PVE.Utils.hardware_counts.usb; i++) {
+	    let confid = "usb" + i.toString();
+	    rows[confid] = {
+		group: 25,
+		order: i,
+		iconCls: 'usb',
+		editor: caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] ? 'PVE.qemu.USBEdit' : undefined,
+		never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
+		header: gettext('USB Device') + ' (' + confid + ')',
+	    };
+	}
+	for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) {
+	    let confid = "hostpci" + i.toString();
+	    rows[confid] = {
+		group: 30,
+		order: i,
+		tdCls: 'pve-itype-icon-pci',
+		never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
+		editor: caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] ? 'PVE.qemu.PCIEdit' : undefined,
+		header: gettext('PCI Device') + ' (' + confid + ')',
+	    };
+	}
+	for (let i = 0; i < PVE.Utils.hardware_counts.serial; i++) {
+	    let confid = "serial" + i.toString();
+	    rows[confid] = {
+		group: 35,
+		order: i,
+		tdCls: 'pve-itype-icon-serial',
+		never_delete: !caps.nodes['Sys.Console'],
+		header: gettext('Serial Port') + ' (' + confid + ')',
+	    };
+	}
+	rows.audio0 = {
+	    group: 40,
+	    iconCls: 'volume-up',
+	    editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.AudioEdit' : undefined,
+	    never_delete: !caps.vms['VM.Config.HWType'],
+	    header: gettext('Audio Device'),
+	};
+	for (let i = 0; i < 256; i++) {
+	    rows["unused" + i.toString()] = {
+		group: 99,
+		order: i,
+		iconCls: 'hdd-o',
+		del_extra_msg: gettext('This will permanently erase all data.'),
+		editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.HDEdit' : undefined,
+		header: gettext('Unused Disk') + ' ' + i.toString(),
+	    };
+	}
+	rows.rng0 = {
+	    group: 45,
+	    tdCls: 'pve-itype-icon-die',
+	    editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.RNGEdit' : undefined,
+	    never_delete: !caps.nodes['Sys.Console'],
+	    header: gettext("VirtIO RNG"),
+	};
+
+	var sorterFn = function(rec1, rec2) {
+	    var v1 = rec1.data.key;
+	    var v2 = rec2.data.key;
+	    var g1 = rows[v1].group || 0;
+	    var g2 = rows[v2].group || 0;
+	    var order1 = rows[v1].order || 0;
+	    var order2 = rows[v2].order || 0;
+
+	    if (g1 - g2 !== 0) {
+		return g1 - g2;
+	    }
+
+	    if (order1 - order2 !== 0) {
+		return order1 - order2;
+	    }
+
+	    if (v1 > v2) {
+		return 1;
+	    } else if (v1 < v2) {
+	        return -1;
+	    } else {
+		return 0;
+	    }
+	};
+
+	let baseurl = `nodes/${nodename}/qemu/${vmid}/config`;
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec || !rows[rec.data.key]?.editor) {
+		return;
+	    }
+	    let rowdef = rows[rec.data.key];
+	    let editor = rowdef.editor;
+
+	    if (rowdef.isOnStorageBus) {
+		let value = me.getObjectValue(rec.data.key, '', true);
+		if (isCloudInitKey(value)) {
+		    return;
+		} else if (value.match(/media=cdrom/)) {
+		    editor = 'PVE.qemu.CDEdit';
+		} else if (!diskCap) {
+		    return;
+		}
+	    }
+
+	    let commonOpts = {
+		autoShow: true,
+		pveSelNode: me.pveSelNode,
+		confid: rec.data.key,
+		url: `/api2/extjs/${baseurl}`,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+	    };
+
+	    if (Ext.isString(editor)) {
+		Ext.create(editor, commonOpts);
+	    } else {
+		let win = Ext.createWidget(rowdef.editor.xtype, Ext.apply(commonOpts, rowdef.editor));
+		win.load();
+	    }
+	};
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    selModel: sm,
+	    disabled: true,
+	    handler: run_editor,
+	});
+
+	let move_menuitem = new Ext.menu.Item({
+	    text: gettext('Move Storage'),
+	    tooltip: gettext('Move disk to another storage'),
+	    iconCls: 'fa fa-database',
+	    selModel: sm,
+	    handler: () => {
+		let rec = sm.getSelection()[0];
+		if (!rec) {
+		    return;
+		}
+		Ext.create('PVE.window.HDMove', {
+		    autoShow: true,
+		    disk: rec.data.key,
+		    nodename: nodename,
+		    vmid: vmid,
+		    type: 'qemu',
+		    listeners: {
+			destroy: () => me.reload(),
+		    },
+		});
+	    },
+	});
+
+	let reassign_menuitem = new Ext.menu.Item({
+	    text: gettext('Reassign Owner'),
+	    tooltip: gettext('Reassign disk to another VM'),
+	    iconCls: 'fa fa-desktop',
+	    selModel: sm,
+	    handler: () => {
+		let rec = sm.getSelection()[0];
+		if (!rec) {
+		    return;
+		}
+
+		Ext.create('PVE.window.GuestDiskReassign', {
+		    autoShow: true,
+		    disk: rec.data.key,
+		    nodename: nodename,
+		    vmid: vmid,
+		    type: 'qemu',
+		    listeners: {
+			destroy: () => me.reload(),
+		    },
+		});
+	    },
+	});
+
+	let resize_menuitem = new Ext.menu.Item({
+	    text: gettext('Resize'),
+	    iconCls: 'fa fa-plus',
+	    selModel: sm,
+	    handler: () => {
+		let rec = sm.getSelection()[0];
+		if (!rec) {
+		    return;
+		}
+		Ext.create('PVE.window.HDResize', {
+		    autoShow: true,
+		    disk: rec.data.key,
+		    nodename: nodename,
+		    vmid: vmid,
+		    listeners: {
+			destroy: () => me.reload(),
+		    },
+		});
+	    },
+	});
+
+	let diskaction_btn = new Proxmox.button.Button({
+	    text: gettext('Disk Action'),
+	    disabled: true,
+	    menu: {
+		items: [
+		    move_menuitem,
+		    reassign_menuitem,
+		    resize_menuitem,
+		],
+	    },
+	});
+
+
+	let remove_btn = new Proxmox.button.Button({
+	    text: gettext('Remove'),
+	    defaultText: gettext('Remove'),
+	    altText: gettext('Detach'),
+	    selModel: sm,
+	    disabled: true,
+	    dangerous: true,
+	    RESTMethod: 'PUT',
+	    confirmMsg: function(rec) {
+		let warn = gettext('Are you sure you want to remove entry {0}');
+		if (this.text === this.altText) {
+		    warn = gettext('Are you sure you want to detach entry {0}');
+		}
+		let rendered = me.renderKey(rec.data.key, {}, rec);
+		let msg = Ext.String.format(warn, `'${rendered}'`);
+
+		if (rows[rec.data.key].del_extra_msg) {
+		    msg += '<br>' + rows[rec.data.key].del_extra_msg;
+		}
+		return msg;
+	    },
+	    handler: function(btn, e, rec) {
+		Proxmox.Utils.API2Request({
+		    url: '/api2/extjs/' + baseurl,
+		    waitMsgTarget: me,
+		    method: btn.RESTMethod,
+		    params: {
+			'delete': rec.data.key,
+		    },
+		    callback: () => me.reload(),
+		    failure: response => Ext.Msg.alert('Error', response.htmlStatus),
+		    success: function(response, options) {
+			if (btn.RESTMethod === 'POST') {
+			    Ext.create('Proxmox.window.TaskProgress', {
+				autoShow: true,
+				upid: response.result.data,
+				listeners: {
+				    destroy: () => me.reload(),
+				},
+			    });
+			}
+		    },
+		});
+	    },
+	    listeners: {
+		render: function(btn) {
+		    // hack: calculate the max button width on first display to prevent the whole
+		    // toolbar to move when we switch between the "Remove" and "Detach" labels
+		    var def = btn.getSize().width;
+
+		    btn.setText(btn.altText);
+		    var alt = btn.getSize().width;
+
+		    btn.setText(btn.defaultText);
+
+		    var optimal = alt > def ? alt : def;
+		    btn.setSize({ width: optimal });
+		},
+	    },
+	});
+
+	let revert_btn = new PVE.button.PendingRevert({
+	    apiurl: '/api2/extjs/' + baseurl,
+	});
+
+	let efidisk_menuitem = Ext.create('Ext.menu.Item', {
+	    text: gettext('EFI Disk'),
+	    iconCls: 'fa fa-fw fa-hdd-o black',
+	    disabled: !caps.vms['VM.Config.Disk'],
+	    handler: function() {
+		let { data: bios } = me.rstore.getData().map.bios || {};
+
+		Ext.create('PVE.qemu.EFIDiskEdit', {
+		    autoShow: true,
+		    url: '/api2/extjs/' + baseurl,
+		    pveSelNode: me.pveSelNode,
+		    usesEFI: bios?.value === 'ovmf' || bios?.pending === 'ovmf',
+		    listeners: {
+			destroy: () => me.reload(),
+		    },
+		});
+	    },
+	});
+
+	let counts = {};
+	let isAtLimit = (type) => counts[type] >= PVE.Utils.hardware_counts[type];
+	let isAtUsbLimit = () => {
+	    let ostype = me.getObjectValue('ostype');
+	    let machine = me.getObjectValue('machine');
+	    return counts.usb >= PVE.Utils.get_max_usb_count(ostype, machine);
+	};
+
+	let set_button_status = function() {
+	    let selection_model = me.getSelectionModel();
+	    let rec = selection_model.getSelection()[0];
+
+	    counts = {}; // en/disable hardwarebuttons
+	    let hasCloudInit = false;
+	    me.rstore.getData().items.forEach(function({ id, data }) {
+		if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) {
+		    hasCloudInit = true;
+		    return;
+		}
+
+		let match = id.match(/^([^\d]+)\d+$/);
+		if (match && PVE.Utils.hardware_counts[match[1]] !== undefined) {
+		    let type = match[1];
+		    counts[type] = (counts[type] || 0) + 1;
+		}
+	    });
+
+	    // heuristic only for disabling some stuff, the backend has the final word.
+	    const noSysConsolePerm = !caps.nodes['Sys.Console'];
+	    const noHWPerm = !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'];
+	    const noVMConfigHWTypePerm = !caps.vms['VM.Config.HWType'];
+	    const noVMConfigNetPerm = !caps.vms['VM.Config.Network'];
+	    const noVMConfigDiskPerm = !caps.vms['VM.Config.Disk'];
+	    const noVMConfigCDROMPerm = !caps.vms['VM.Config.CDROM'];
+	    const noVMConfigCloudinitPerm = !caps.vms['VM.Config.Cloudinit'];
+
+	    me.down('#addUsb').setDisabled(noHWPerm || isAtUsbLimit());
+	    me.down('#addPci').setDisabled(noHWPerm || isAtLimit('hostpci'));
+	    me.down('#addAudio').setDisabled(noVMConfigHWTypePerm || isAtLimit('audio'));
+	    me.down('#addSerial').setDisabled(noVMConfigHWTypePerm || isAtLimit('serial'));
+	    me.down('#addNet').setDisabled(noVMConfigNetPerm || isAtLimit('net'));
+	    me.down('#addRng').setDisabled(noSysConsolePerm || isAtLimit('rng'));
+	    efidisk_menuitem.setDisabled(noVMConfigDiskPerm || isAtLimit('efidisk'));
+	    me.down('#addTpmState').setDisabled(noSysConsolePerm || isAtLimit('tpmstate'));
+	    me.down('#addCloudinitDrive').setDisabled(noVMConfigCDROMPerm || noVMConfigCloudinitPerm || hasCloudInit);
+
+	    if (!rec) {
+		remove_btn.disable();
+		edit_btn.disable();
+		diskaction_btn.disable();
+		revert_btn.disable();
+		return;
+	    }
+	    const { key, value } = rec.data;
+	    const row = rows[key];
+
+	    const deleted = !!rec.data.delete;
+	    const pending = deleted || me.hasPendingChanges(key);
+
+	    const isCloudInit = isCloudInitKey(value);
+	    const isCDRom = value && !!value.toString().match(/media=cdrom/);
+
+	    const isUnusedDisk = key.match(/^unused\d+/);
+	    const isUsedDisk = !isUnusedDisk && row.isOnStorageBus && !isCDRom;
+	    const isDisk = isUnusedDisk || isUsedDisk;
+	    const isEfi = key === 'efidisk0';
+	    const tpmMoveable = key === 'tpmstate0' && !me.pveSelNode.data.running;
+
+	    let cannotDelete = deleted || row.never_delete;
+	    cannotDelete ||= isCDRom && !cdromCap;
+	    cannotDelete ||= isDisk && !diskCap;
+	    cannotDelete ||= isCloudInit && noVMConfigCloudinitPerm;
+	    remove_btn.setDisabled(cannotDelete);
+
+	    remove_btn.setText(isUsedDisk && !isCloudInit ? remove_btn.altText : remove_btn.defaultText);
+	    remove_btn.RESTMethod = isUnusedDisk ? 'POST':'PUT';
+
+	    edit_btn.setDisabled(
+	        deleted || !row.editor || isCloudInit || (isCDRom && !cdromCap) || (isDisk && !diskCap));
+
+	    diskaction_btn.setDisabled(
+		pending ||
+		!diskCap ||
+		isCloudInit ||
+		!(isDisk || isEfi || tpmMoveable),
+	    );
+	    reassign_menuitem.setDisabled(pending || (isEfi || tpmMoveable));
+	    resize_menuitem.setDisabled(pending || !isUsedDisk);
+
+	    revert_btn.setDisabled(!pending);
+	};
+
+	let editorFactory = (classPath, extraOptions) => {
+	    extraOptions = extraOptions || {};
+	    return () => Ext.create(`PVE.qemu.${classPath}`, {
+		autoShow: true,
+		url: `/api2/extjs/${baseurl}`,
+		pveSelNode: me.pveSelNode,
+		listeners: {
+		    destroy: () => me.reload(),
+		},
+		isAdd: true,
+		isCreate: true,
+		...extraOptions,
+	    });
+	};
+
+	Ext.apply(me, {
+	    url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`,
+	    interval: 5000,
+	    selModel: sm,
+	    run_editor: run_editor,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			cls: 'pve-add-hw-menu',
+			items: [
+			    {
+				text: gettext('Hard Disk'),
+				iconCls: 'fa fa-fw fa-hdd-o black',
+				disabled: !caps.vms['VM.Config.Disk'],
+				handler: editorFactory('HDEdit'),
+			    },
+			    {
+				text: gettext('CD/DVD Drive'),
+				iconCls: 'pve-itype-icon-cdrom',
+				disabled: !caps.vms['VM.Config.CDROM'],
+				handler: editorFactory('CDEdit'),
+			    },
+			    {
+				text: gettext('Network Device'),
+				itemId: 'addNet',
+				iconCls: 'fa fa-fw fa-exchange black',
+				disabled: !caps.vms['VM.Config.Network'],
+				handler: editorFactory('NetworkEdit'),
+			    },
+			    efidisk_menuitem,
+			    {
+				text: gettext('TPM State'),
+				itemId: 'addTpmState',
+				iconCls: 'fa fa-fw fa-hdd-o black',
+				disabled: !caps.vms['VM.Config.Disk'],
+				handler: editorFactory('TPMDiskEdit'),
+			    },
+			    {
+				text: gettext('USB Device'),
+				itemId: 'addUsb',
+				iconCls: 'fa fa-fw fa-usb black',
+				disabled: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
+				handler: editorFactory('USBEdit'),
+			    },
+			    {
+				text: gettext('PCI Device'),
+				itemId: 'addPci',
+				iconCls: 'pve-itype-icon-pci',
+				disabled: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
+				handler: editorFactory('PCIEdit'),
+			    },
+			    {
+				text: gettext('Serial Port'),
+				itemId: 'addSerial',
+				iconCls: 'pve-itype-icon-serial',
+				disabled: !caps.vms['VM.Config.Options'],
+				handler: editorFactory('SerialEdit'),
+			    },
+			    {
+				text: gettext('CloudInit Drive'),
+				itemId: 'addCloudinitDrive',
+				iconCls: 'fa fa-fw fa-cloud black',
+				disabled: !caps.vms['VM.Config.CDROM'] || !caps.vms['VM.Config.Cloudinit'],
+				handler: editorFactory('CIDriveEdit'),
+			    },
+			    {
+				text: gettext('Audio Device'),
+				itemId: 'addAudio',
+				iconCls: 'fa fa-fw fa-volume-up black',
+				disabled: !caps.vms['VM.Config.HWType'],
+				handler: editorFactory('AudioEdit'),
+			    },
+			    {
+				text: gettext("VirtIO RNG"),
+				itemId: 'addRng',
+				iconCls: 'pve-itype-icon-die',
+				disabled: !caps.nodes['Sys.Console'],
+				handler: editorFactory('RNGEdit'),
+			    },
+			],
+		    }),
+		},
+		remove_btn,
+		edit_btn,
+		diskaction_btn,
+		revert_btn,
+	    ],
+	    rows: rows,
+	    sorterFn: sorterFn,
+	    listeners: {
+		itemdblclick: run_editor,
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate, me.rstore);
+	me.on('destroy', me.rstore.stopUpdate, me.rstore);
+
+	me.mon(me.getStore(), 'datachanged', set_button_status, me);
+    },
+});
+Ext.define('PVE.qemu.IPConfigPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveIPConfigPanel',
+
+    insideWizard: false,
+
+    vmconfig: {},
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (values.ipv4mode !== 'static') {
+	    values.ip = values.ipv4mode;
+	}
+
+	if (values.ipv6mode !== 'static') {
+	    values.ip6 = values.ipv6mode;
+	}
+
+	var params = {};
+
+	var cfg = PVE.Parser.printIPConfig(values);
+	if (cfg === '') {
+	    params.delete = [me.confid];
+	} else {
+	    params[me.confid] = cfg;
+	}
+	return params;
+    },
+
+    setVMConfig: function(config) {
+	var me = this;
+	me.vmconfig = config;
+    },
+
+    setIPConfig: function(confid, data) {
+	var me = this;
+
+	me.confid = confid;
+
+	if (data.ip === 'dhcp') {
+	    data.ipv4mode = data.ip;
+	    data.ip = '';
+	} else {
+	    data.ipv4mode = 'static';
+	}
+	if (data.ip6 === 'dhcp' || data.ip6 === 'auto') {
+	    data.ipv6mode = data.ip6;
+	    data.ip6 = '';
+	} else {
+	    data.ipv6mode = 'static';
+	}
+
+	me.ipconfig = data;
+	me.setValues(me.ipconfig);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.ipconfig = {};
+
+	me.column1 = [
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Network Device'),
+		value: me.netid,
+	    },
+	    {
+		layout: {
+		    type: 'hbox',
+		    align: 'middle',
+		},
+		border: false,
+		margin: '0 0 5 0',
+		items: [
+		    {
+			xtype: 'label',
+			text: gettext('IPv4') + ':',
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('Static'),
+			name: 'ipv4mode',
+			inputValue: 'static',
+			checked: false,
+			margin: '0 0 0 10',
+			listeners: {
+			    change: function(cb, value) {
+				me.down('field[name=ip]').setDisabled(!value);
+				me.down('field[name=gw]').setDisabled(!value);
+			    },
+			},
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('DHCP'),
+			name: 'ipv4mode',
+			inputValue: 'dhcp',
+			checked: false,
+			margin: '0 0 0 10',
+		    },
+		],
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'ip',
+		vtype: 'IPCIDRAddress',
+		value: '',
+		disabled: true,
+		fieldLabel: gettext('IPv4/CIDR'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'gw',
+		value: '',
+		vtype: 'IPAddress',
+		disabled: true,
+		fieldLabel: gettext('Gateway') + ' (' + gettext('IPv4') +')',
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'displayfield',
+	    },
+	    {
+		layout: {
+		    type: 'hbox',
+		    align: 'middle',
+		},
+		border: false,
+		margin: '0 0 5 0',
+		items: [
+		    {
+			xtype: 'label',
+			text: gettext('IPv6') + ':',
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('Static'),
+			name: 'ipv6mode',
+			inputValue: 'static',
+			checked: false,
+			margin: '0 0 0 10',
+			listeners: {
+			    change: function(cb, value) {
+				me.down('field[name=ip6]').setDisabled(!value);
+				me.down('field[name=gw6]').setDisabled(!value);
+			    },
+			},
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('DHCP'),
+			name: 'ipv6mode',
+			inputValue: 'dhcp',
+			checked: false,
+			margin: '0 0 0 10',
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('SLAAC'),
+			name: 'ipv6mode',
+			inputValue: 'auto',
+			checked: false,
+			margin: '0 0 0 10',
+		    },
+		],
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'ip6',
+		value: '',
+		vtype: 'IP6CIDRAddress',
+		disabled: true,
+		fieldLabel: gettext('IPv6/CIDR'),
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'gw6',
+		vtype: 'IP6Address',
+		value: '',
+		disabled: true,
+		fieldLabel: gettext('Gateway') + ' (' + gettext('IPv6') +')',
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.IPConfigEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+
+    initComponent: function() {
+	var me = this;
+
+	// convert confid from netX to ipconfigX
+	var match = me.confid.match(/^net(\d+)$/);
+	if (match) {
+	    me.netid = me.confid;
+	    me.confid = 'ipconfig' + match[1];
+	}
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.isCreate = !me.confid;
+
+	var ipanel = Ext.create('PVE.qemu.IPConfigPanel', {
+	    confid: me.confid,
+	    netid: me.netid,
+	    nodename: nodename,
+	});
+
+	Ext.applyIf(me, {
+	    subject: gettext('Network Config'),
+	    items: ipanel,
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		me.vmconfig = response.result.data;
+		var ipconfig = {};
+		var value = me.vmconfig[me.confid];
+		if (value) {
+		    ipconfig = PVE.Parser.parseIPConfig(me.confid, value);
+		    if (!ipconfig) {
+			Ext.Msg.alert(gettext('Error'), gettext('Unable to parse network configuration'));
+			me.close();
+			return;
+		    }
+		}
+		ipanel.setIPConfig(me.confid, ipconfig);
+		ipanel.setVMConfig(me.vmconfig);
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.KeyboardEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.applyIf(me, {
+	    subject: gettext('Keyboard Layout'),
+	    items: {
+		xtype: 'VNCKeyboardSelector',
+		name: 'keyboard',
+		value: '__default__',
+		fieldLabel: gettext('Keyboard Layout'),
+	    },
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+Ext.define('PVE.qemu.MachineInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveMachineInputPanel',
+    onlineHelp: 'qm_machine_type',
+
+    viewModel: {
+	data: {
+	    type: '__default__',
+	},
+	formulas: {
+	    q35: get => get('type') === 'q35',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'combobox[name=machine]': {
+		change: 'onMachineChange',
+	    },
+	},
+	onMachineChange: function(field, value) {
+	    let me = this;
+	    let version = me.lookup('version');
+	    let store = version.getStore();
+	    let oldRec = store.findRecord('id', version.getValue(), 0, false, false, true);
+	    let type = value === 'q35' ? 'q35' : 'i440fx';
+	    store.clearFilter();
+	    store.addFilter(val => val.data.id === 'latest' || val.data.type === type);
+	    if (!me.getView().isWindows) {
+		version.setValue('latest');
+	    } else {
+		store.isWindows = true;
+		if (!oldRec) {
+		    return;
+		}
+		let oldVers = oldRec.data.version;
+		// we already filtered by correct type, so just check version property
+		let rec = store.findRecord('version', oldVers, 0, false, false, true);
+		if (rec) {
+		    version.select(rec);
+		}
+	    }
+	},
+    },
+
+    onGetValues: function(values) {
+	if (values.delete === 'machine' && values.viommu) {
+	    delete values.delete;
+	    values.machine = 'pc';
+	}
+	if (values.version && values.version !== 'latest') {
+	    values.machine = values.version;
+	    delete values.delete;
+	}
+	delete values.version;
+	if (values.delete === 'machine' && !values.viommu) {
+	    return values;
+	}
+	let ret = {};
+	ret.machine = PVE.Parser.printPropertyString(values, 'machine');
+	return ret;
+    },
+
+    setValues: function(values) {
+	let me = this;
+
+	let machineConf = PVE.Parser.parsePropertyString(values.machine, 'type');
+	values.machine = machineConf.type;
+
+	me.isWindows = values.isWindows;
+	if (values.machine === 'pc') {
+	    values.machine = '__default__';
+	}
+
+	if (me.isWindows) {
+	    if (values.machine === '__default__') {
+		values.version = 'pc-i440fx-5.1';
+	    } else if (values.machine === 'q35') {
+		values.version = 'pc-q35-5.1';
+	    }
+	}
+
+	values.viommu = machineConf.viommu || '__default__';
+
+	if (values.machine !== '__default__' && values.machine !== 'q35') {
+	    values.version = values.machine;
+	    values.machine = values.version.match(/q35/) ? 'q35' : '__default__';
+
+	    // avoid hiding a pinned version
+	    me.setAdvancedVisible(true);
+	}
+
+	this.callParent(arguments);
+    },
+
+    items: {
+	xtype: 'proxmoxKVComboBox',
+	name: 'machine',
+	reference: 'machine',
+	fieldLabel: gettext('Machine'),
+	comboItems: [
+	    ['__default__', PVE.Utils.render_qemu_machine('')],
+	    ['q35', 'q35'],
+	],
+	bind: {
+	    value: '{type}',
+	},
+    },
+
+    advancedItems: [
+	{
+	    xtype: 'combobox',
+	    name: 'version',
+	    reference: 'version',
+	    fieldLabel: gettext('Version'),
+	    emptyText: gettext('Latest'),
+	    value: 'latest',
+	    editable: false,
+	    valueField: 'id',
+	    displayField: 'version',
+	    queryParam: false,
+	    store: {
+		autoLoad: true,
+		fields: ['id', 'type', 'version'],
+		proxy: {
+		    type: 'proxmox',
+		    url: "/api2/json/nodes/localhost/capabilities/qemu/machines",
+		},
+		listeners: {
+		    load: function(records) {
+			if (!this.isWindows) {
+			    this.insert(0, { id: 'latest', type: 'any', version: gettext('Latest') });
+			}
+		    },
+		},
+	    },
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Note'),
+	    value: gettext('Machine version change may affect hardware layout and settings in the guest OS.'),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'viommu',
+	    fieldLabel: gettext('vIOMMU'),
+	    reference: 'viommu-q35',
+	    deleteEmpty: false,
+	    value: '__default__',
+	    comboItems: [
+		['__default__', Proxmox.Utils.defaultText + ' (None)'],
+		['intel', gettext('Intel (AMD Compatible)')],
+		['virtio', 'VirtIO'],
+	    ],
+	    bind: {
+		hidden: '{!q35}',
+		disabled: '{!q35}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'viommu',
+	    fieldLabel: gettext('vIOMMU'),
+	    reference: 'viommu-i440fx',
+	    deleteEmpty: false,
+	    value: '__default__',
+	    comboItems: [
+		['__default__', Proxmox.Utils.defaultText + ' (None)'],
+		['virtio', 'VirtIO'],
+	    ],
+	    bind: {
+		hidden: '{q35}',
+		disabled: '{q35}',
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.MachineEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: gettext('Machine'),
+
+    items: {
+	xtype: 'pveMachineInputPanel',
+    },
+
+    width: 400,
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	me.load({
+	    success: function(response) {
+		let conf = response.result.data;
+		let values = {
+		    machine: conf.machine || '__default__',
+		};
+		values.isWindows = PVE.Utils.is_windows(conf.ostype);
+		me.setValues(values);
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.MemoryInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuMemoryPanel',
+    onlineHelp: 'qm_memory',
+
+    insideWizard: false,
+
+    viewModel: {}, // inherit data from createWizard if insideWizard
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    '#': {
+		afterrender: 'setMemory',
+	    },
+	},
+
+	setMemory: function() {
+	    let me = this;
+	    let view = me.getView(), viewModel = me.getViewModel();
+	    if (view.insideWizard) {
+		let memory = view.down('pveMemoryField[name=memory]');
+		// NOTE: we only set memory but that then sets balloon in its change handler
+		if (viewModel.get('current.ostype') === 'win11') {
+		    memory.setValue('4096');
+		} else {
+		    memory.setValue('2048');
+		}
+	    }
+	},
+    },
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var res = {};
+
+	res.memory = values.memory;
+	res.balloon = values.balloon;
+
+	if (!values.ballooning) {
+	    res.balloon = 0;
+	    res.delete = 'shares';
+	} else if (values.memory === values.balloon) {
+	    delete res.balloon;
+	    res.delete = 'balloon,shares';
+	} else if (Ext.isDefined(values.shares) && values.shares !== "") {
+	    res.shares = values.shares;
+	} else {
+	    res.delete = "shares";
+	}
+
+	return res;
+    },
+
+    initComponent: function() {
+	var me = this;
+	var labelWidth = 160;
+
+	me.items= [
+	    {
+		xtype: 'pveMemoryField',
+		labelWidth: labelWidth,
+		fieldLabel: gettext('Memory') + ' (MiB)',
+		name: 'memory',
+		value: '512', // better defaults get set via the view controllers afterrender
+		minValue: 1,
+		step: 32,
+		hotplug: me.hotplug,
+		listeners: {
+		    change: function(f, value, old) {
+			var bf = me.down('field[name=balloon]');
+			var balloon = bf.getValue();
+			bf.setMaxValue(value);
+			if (balloon === old) {
+			    bf.setValue(value);
+			}
+			bf.validate();
+		    },
+		},
+	    },
+	];
+
+	me.advancedItems= [
+	    {
+		xtype: 'pveMemoryField',
+		name: 'balloon',
+		minValue: 1,
+		maxValue: me.insideWizard ? 2048 : 512,
+		value: '512', // better defaults get set (indirectly) via the view controllers afterrender
+		step: 32,
+		fieldLabel: gettext('Minimum memory') + ' (MiB)',
+		hotplug: me.hotplug,
+		labelWidth: labelWidth,
+		allowBlank: false,
+		listeners: {
+		    change: function(f, value) {
+			var memory = me.down('field[name=memory]').getValue();
+			var shares = me.down('field[name=shares]');
+			shares.setDisabled(value === memory);
+		    },
+		},
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'shares',
+		disabled: true,
+		minValue: 0,
+		maxValue: 50000,
+		value: '',
+		step: 10,
+		fieldLabel: gettext('Shares'),
+		labelWidth: labelWidth,
+		allowBlank: true,
+		emptyText: Proxmox.Utils.defaultText + ' (1000)',
+		submitEmptyText: false,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		labelWidth: labelWidth,
+		value: '1',
+		name: 'ballooning',
+		fieldLabel: gettext('Ballooning Device'),
+		listeners: {
+		    change: function(f, value) {
+			var bf = me.down('field[name=balloon]');
+			var shares = me.down('field[name=shares]');
+			var memory = me.down('field[name=memory]');
+			bf.setDisabled(!value);
+			shares.setDisabled(!value || bf.getValue() === memory.getValue());
+		    },
+		},
+	    },
+	];
+
+	if (me.insideWizard) {
+	    me.column1 = me.items;
+	    me.items = undefined;
+	    me.advancedColumn1 = me.advancedItems;
+	    me.advancedItems = undefined;
+	}
+	me.callParent();
+    },
+
+});
+
+Ext.define('PVE.qemu.MemoryEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	var memoryhotplug;
+	if (me.hotplug) {
+	    Ext.each(me.hotplug.split(','), function(el) {
+		if (el === 'memory') {
+		    memoryhotplug = 1;
+	        }
+	    });
+	}
+
+	var ipanel = Ext.create('PVE.qemu.MemoryInputPanel', {
+	    hotplug: memoryhotplug,
+	});
+
+	Ext.apply(me, {
+	    subject: gettext('Memory'),
+	    items: [ipanel],
+	    // uncomment the following to use the async configiguration API
+	    // backgroundDelay: 5,
+	    width: 400,
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		var data = response.result.data;
+
+		var values = {
+		    ballooning: data.balloon === 0 ? '0' : '1',
+		    shares: data.shares,
+		    memory: data.memory || '512',
+		    balloon: data.balloon > 0 ? data.balloon : data.memory || '512',
+		};
+
+		ipanel.setValues(values);
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.Monitor', {
+    extend: 'Ext.panel.Panel',
+
+    alias: 'widget.pveQemuMonitor',
+
+    // start to trim saved command output once there are *both*, more than `commandLimit` commands
+    // executed and the total of saved in+output is over `lineLimit` lines; repeat by dropping one
+    // full command output until either condition is false again
+    commandLimit: 10,
+    lineLimit: 5000,
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var history = [];
+	var histNum = -1;
+	let commands = [];
+
+	var textbox = Ext.createWidget('panel', {
+	    region: 'center',
+	    xtype: 'panel',
+	    autoScroll: true,
+	    border: true,
+	    margins: '5 5 5 5',
+	    bodyStyle: 'font-family: monospace;',
+	});
+
+	var scrollToEnd = function() {
+	    var el = textbox.getTargetEl();
+	    var dom = Ext.getDom(el);
+
+	    var clientHeight = dom.clientHeight;
+	    // BrowserBug: clientHeight reports 0 in IE9 StrictMode
+            // Instead we are using offsetHeight and hardcoding borders
+            if (Ext.isIE9 && Ext.isStrict) {
+		clientHeight = dom.offsetHeight + 2;
+            }
+	    dom.scrollTop = dom.scrollHeight - clientHeight;
+	};
+
+	var refresh = function() {
+	    textbox.update(`<pre>${commands.flat(2).join('\n')}</pre>`);
+	    scrollToEnd();
+	};
+
+	let recordInput = line => {
+	    commands.push([line]);
+
+	    // drop oldest commands and their output until we're not over both limits anymore
+	    while (commands.length > me.commandLimit && commands.flat(2).length > me.lineLimit) {
+		commands.shift();
+	    }
+	};
+
+	let addResponse = lines => commands[commands.length - 1].push(lines);
+
+	var executeCmd = function(cmd) {
+	    recordInput("# " + Ext.htmlEncode(cmd), true);
+	    if (cmd) {
+		history.unshift(cmd);
+		if (history.length > 20) {
+		    history.splice(20);
+		}
+	    }
+	    histNum = -1;
+
+	    refresh();
+	    Proxmox.Utils.API2Request({
+		params: { command: cmd },
+		url: '/nodes/' + nodename + '/qemu/' + vmid + "/monitor",
+		method: 'POST',
+		waitMsgTarget: me,
+		success: function(response, opts) {
+		    var res = response.result.data;
+		    addResponse(res.split('\n').map(line => Ext.htmlEncode(line)));
+		    refresh();
+		},
+		failure: function(response, opts) {
+		    Ext.Msg.alert('Error', response.htmlStatus);
+		},
+	    });
+	};
+
+	Ext.apply(me, {
+	    layout: { type: 'border' },
+	    border: false,
+	    items: [
+		textbox,
+		{
+		    region: 'south',
+		    margins: '0 5 5 5',
+		    border: false,
+		    xtype: 'textfield',
+		    name: 'cmd',
+		    value: '',
+		    fieldStyle: 'font-family: monospace;',
+		    allowBlank: true,
+		    listeners: {
+			afterrender: function(f) {
+			    f.focus(false);
+			    recordInput("Type 'help' for help.");
+			    refresh();
+			},
+			specialkey: function(f, e) {
+			    var key = e.getKey();
+			    switch (key) {
+				case e.ENTER:
+				    var cmd = f.getValue();
+				    f.setValue('');
+				    executeCmd(cmd);
+				    break;
+				case e.PAGE_UP:
+				    textbox.scrollBy(0, -0.9*textbox.getHeight(), false);
+				    break;
+				case e.PAGE_DOWN:
+				    textbox.scrollBy(0, 0.9*textbox.getHeight(), false);
+				    break;
+				case e.UP:
+				    if (histNum + 1 < history.length) {
+					f.setValue(history[++histNum]);
+				    }
+				    e.preventDefault();
+				    break;
+				case e.DOWN:
+				    if (histNum > 0) {
+					f.setValue(history[--histNum]);
+				    }
+				    e.preventDefault();
+				    break;
+				default:
+				    break;
+			    }
+			},
+		    },
+		},
+	    ],
+	    listeners: {
+		show: function() {
+		    var field = me.query('textfield[name="cmd"]')[0];
+		    field.focus(false, true);
+		},
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.qemu.MultiHDPanel', {
+    extend: 'PVE.panel.MultiDiskPanel',
+    alias: 'widget.pveMultiHDPanel',
+
+    onlineHelp: 'qm_hard_disk',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	// maxCount is the sum of all controller ids - 1 (ide2 is fixed in the wizard)
+	maxCount: Object.values(PVE.Utils.diskControllerMaxIDs)
+		.reduce((previous, current) => previous+current, 0) - 1,
+
+	getNextFreeDisk: function(vmconfig) {
+	    let clist = PVE.Utils.sortByPreviousUsage(vmconfig);
+	    return PVE.Utils.nextFreeDisk(clist, vmconfig);
+	},
+
+	addPanel: function(itemId, vmconfig, nextFreeDisk) {
+	    let me = this;
+	    return me.getView().add({
+		vmconfig,
+		border: false,
+		showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'),
+		xtype: 'pveQemuHDInputPanel',
+		bind: {
+		    nodename: '{nodename}',
+		},
+		padding: '0 0 0 5',
+		itemId,
+		isCreate: true,
+		insideWizard: true,
+	    });
+	},
+
+	getBaseVMConfig: function() {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    let res = {
+		ide2: 'media=cdrom',
+		scsihw: vm.get('current.scsihw'),
+		ostype: vm.get('current.ostype'),
+	    };
+
+	    if (vm.get('current.ide0') === "some") {
+		res.ide0 = "media=cdrom";
+	    }
+
+	    return res;
+	},
+
+	diskSorter: {
+	    sorterFn: function(rec1, rec2) {
+		let [, name1, id1] = PVE.Utils.bus_match.exec(rec1.data.name);
+		let [, name2, id2] = PVE.Utils.bus_match.exec(rec2.data.name);
+
+		if (name1 === name2) {
+		    return parseInt(id1, 10) - parseInt(id2, 10);
+		}
+
+		return name1 < name2 ? -1 : 1;
+	    },
+	},
+
+	deleteDisabled: () => false,
+    },
+});
+Ext.define('PVE.qemu.NetworkInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuNetworkInputPanel',
+    onlineHelp: 'qm_network_device',
+
+    insideWizard: false,
+
+    onGetValues: function(values) {
+	var me = this;
+
+	me.network.model = values.model;
+	if (values.nonetwork) {
+	    return {};
+	} else {
+	    me.network.bridge = values.bridge;
+	    me.network.tag = values.tag;
+	    me.network.firewall = values.firewall;
+	}
+	me.network.macaddr = values.macaddr;
+	me.network.disconnect = values.disconnect;
+	me.network.queues = values.queues;
+	me.network.mtu = values.mtu;
+
+	if (values.rate) {
+	    me.network.rate = values.rate;
+	} else {
+	    delete me.network.rate;
+	}
+
+	var params = {};
+
+	params[me.confid] = PVE.Parser.printQemuNetwork(me.network);
+
+	return params;
+    },
+
+    viewModel: {
+	data: {
+	    networkModel: undefined,
+	    mtu: '',
+	},
+	formulas: {
+	    isVirtio: get => get('networkModel') === 'virtio',
+	    showMtuHint: get => get('mtu') === 1,
+	},
+    },
+
+    setNetwork: function(confid, data) {
+	var me = this;
+
+	me.confid = confid;
+
+	if (data) {
+	    data.networkmode = data.bridge ? 'bridge' : 'nat';
+	} else {
+	    data = {};
+	    data.networkmode = 'bridge';
+	}
+	me.network = data;
+
+	me.setValues(me.network);
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+
+	me.bridgesel.setNodename(nodename);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.network = {};
+	me.confid = 'net0';
+
+	me.column1 = [];
+	me.column2 = [];
+
+	me.bridgesel = Ext.create('PVE.form.BridgeSelector', {
+	    name: 'bridge',
+	    fieldLabel: gettext('Bridge'),
+	    nodename: me.nodename,
+	    autoSelect: true,
+	    allowBlank: false,
+	});
+
+	me.column1 = [
+	    me.bridgesel,
+	    {
+		xtype: 'pveVlanField',
+		name: 'tag',
+		value: '',
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Firewall'),
+		name: 'firewall',
+		checked: me.insideWizard || me.isCreate,
+	    },
+	];
+
+	me.advancedColumn1 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Disconnect'),
+		name: 'disconnect',
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'mtu',
+		fieldLabel: 'MTU',
+		bind: {
+		    disabled: '{!isVirtio}',
+		    value: '{mtu}',
+		},
+		emptyText: '1500 (1 = bridge MTU)',
+		minValue: 1,
+		maxValue: 65520,
+		allowBlank: true,
+		validator: val => val === '' || val >= 576 || val === '1'
+		    ? true
+		    : gettext('MTU needs to be >= 576 or 1 to inherit the MTU from the underlying bridge.'),
+	    },
+	];
+
+	if (me.insideWizard) {
+	    me.column1.unshift({
+		xtype: 'checkbox',
+		name: 'nonetwork',
+		inputValue: 'none',
+		boxLabel: gettext('No network device'),
+		listeners: {
+		    change: function(cb, value) {
+			var fields = [
+			    'disconnect',
+			    'bridge',
+			    'tag',
+			    'firewall',
+			    'model',
+			    'macaddr',
+			    'rate',
+			    'queues',
+			    'mtu',
+			];
+			fields.forEach(function(fieldname) {
+			    me.down('field[name='+fieldname+']').setDisabled(value);
+			});
+			me.down('field[name=bridge]').validate();
+		    },
+		},
+	    });
+	    me.column2.unshift({
+		xtype: 'displayfield',
+	    });
+	}
+
+	me.column2.push(
+	    {
+		xtype: 'pveNetworkCardSelector',
+		name: 'model',
+		fieldLabel: gettext('Model'),
+		bind: '{networkModel}',
+		value: PVE.qemu.OSDefaults.generic.networkCard,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'macaddr',
+		fieldLabel: gettext('MAC address'),
+		vtype: 'MacAddress',
+		allowBlank: true,
+		emptyText: 'auto',
+	    });
+	me.advancedColumn2 = [
+	    {
+		xtype: 'numberfield',
+		name: 'rate',
+		fieldLabel: gettext('Rate limit') + ' (MB/s)',
+		minValue: 0,
+		maxValue: 10*1024,
+		value: '',
+		emptyText: 'unlimited',
+		allowBlank: true,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'queues',
+		fieldLabel: 'Multiqueue',
+		minValue: 1,
+		maxValue: 64,
+		value: '',
+		allowBlank: true,
+	    },
+	];
+	me.advancedColumnB = [
+	    {
+		xtype: 'displayfield',
+		userCls: 'pmx-hint',
+		value: gettext("Use the special value '1' to inherit the MTU value from the underlying bridge"),
+		bind: {
+		    hidden: '{!showMtuHint}',
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.NetworkEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    isAdd: true,
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	me.isCreate = !me.confid;
+
+	var ipanel = Ext.create('PVE.qemu.NetworkInputPanel', {
+	    confid: me.confid,
+	    nodename: nodename,
+	    isCreate: me.isCreate,
+	});
+
+	Ext.applyIf(me, {
+	    subject: gettext('Network Device'),
+	    items: ipanel,
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		var i, confid;
+		me.vmconfig = response.result.data;
+		if (!me.isCreate) {
+		    var value = me.vmconfig[me.confid];
+		    var network = PVE.Parser.parseQemuNetwork(me.confid, value);
+		    if (!network) {
+			Ext.Msg.alert(gettext('Error'), 'Unable to parse network options');
+			me.close();
+			return;
+		    }
+		    ipanel.setNetwork(me.confid, network);
+		} else {
+		    for (i = 0; i < 100; i++) {
+			confid = 'net' + i.toString();
+			if (!Ext.isDefined(me.vmconfig[confid])) {
+			    me.confid = confid;
+			    break;
+			}
+		    }
+
+		    let ostype = me.vmconfig.ostype;
+		    let defaults = PVE.qemu.OSDefaults.getDefaults(ostype);
+		    let data = {
+			model: defaults.networkCard,
+		    };
+
+		    ipanel.setNetwork(me.confid, data);
+		}
+	    },
+	});
+    },
+});
+/*
+ * This class holds performance *recommended* settings for the PVE Qemu wizards
+ * the *mandatory* settings are set in the PVE::QemuServer
+ * config_to_command sub
+ * We store this here until we get the data from the API server
+*/
+
+// this is how you would add an hypothetic FreeBSD > 10 entry
+//
+//virtio-blk is stable but virtIO net still
+//   problematic as of 10.3
+// see https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=165059
+//	addOS({
+//	    parent: 'generic', // inherits defaults
+//	    pveOS: 'freebsd10', // must match a radiofield in OSTypeEdit.js
+//	    busType: 'virtio' // must match a pveBusController value
+//			    // networkCard muss match a pveNetworkCardSelector
+
+
+Ext.define('PVE.qemu.OSDefaults', {
+    singleton: true, // will also force creation when loaded
+
+    constructor: function() {
+	let me = this;
+
+	let addOS = function(settings) {
+		if (Object.prototype.hasOwnProperty.call(settings, 'parent')) {
+		    var child = Ext.clone(me[settings.parent]);
+		    me[settings.pveOS] = Ext.apply(child, settings);
+		} else {
+		    throw "Could not find your genitor";
+		}
+	    };
+
+	// default values
+	me.generic = {
+	    busType: 'ide',
+	    networkCard: 'e1000',
+	    busPriority: {
+		    ide: 4,
+		    sata: 3,
+		    scsi: 2,
+		    virtio: 1,
+	    },
+	    scsihw: 'virtio-scsi-single',
+	    cputype: 'x86-64-v2-AES',
+	};
+
+       // virtio-net is in kernel since 2.6.25
+       // virtio-scsi since 3.2 but backported in RHEL with 2.6 kernel
+	addOS({
+	    pveOS: 'l26',
+	    parent: 'generic',
+	    busType: 'scsi',
+	    busPriority: {
+		    scsi: 4,
+		    virtio: 3,
+		    sata: 2,
+		    ide: 1,
+	    },
+	    networkCard: 'virtio',
+	});
+
+	// recommandation from http://wiki.qemu.org/Windows2000
+	addOS({
+	    pveOS: 'w2k',
+	    parent: 'generic',
+	    networkCard: 'rtl8139',
+	    scsihw: '',
+	});
+	// https://pve.proxmox.com/wiki/Windows_XP_Guest_Notes
+	addOS({
+	    pveOS: 'wxp',
+	    parent: 'w2k',
+	});
+
+	me.getDefaults = function(ostype) {
+	    if (PVE.qemu.OSDefaults[ostype]) {
+		return PVE.qemu.OSDefaults[ostype];
+	    } else {
+		return PVE.qemu.OSDefaults.generic;
+	    }
+	};
+    },
+});
+Ext.define('PVE.qemu.OSTypeInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuOSTypePanel',
+    onlineHelp: 'qm_os_settings',
+    insideWizard: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'combobox[name=osbase]': {
+		change: 'onOSBaseChange',
+	    },
+	    'combobox[name=ostype]': {
+		afterrender: 'onOSTypeChange',
+		change: 'onOSTypeChange',
+	    },
+	    'checkbox[reference=enableSecondCD]': {
+		change: 'onSecondCDChange',
+	    },
+	},
+	onOSBaseChange: function(field, value) {
+	    let me = this;
+	    me.lookup('ostype').getStore().setData(PVE.Utils.kvm_ostypes[value]);
+	    if (me.getView().insideWizard) {
+		let isWindows = value === 'Microsoft Windows';
+		let enableSecondCD = me.lookup('enableSecondCD');
+		enableSecondCD.setVisible(isWindows);
+		if (!isWindows) {
+		    enableSecondCD.setValue(false);
+		}
+	    }
+	},
+	onOSTypeChange: function(field) {
+	    var me = this, ostype = field.getValue();
+	    if (!me.getView().insideWizard) {
+		return;
+	    }
+	    var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype);
+
+	    me.setWidget('pveBusSelector', targetValues.busType);
+	    me.setWidget('pveNetworkCardSelector', targetValues.networkCard);
+	    me.setWidget('CPUModelSelector', targetValues.cputype);
+	    var scsihw = targetValues.scsihw || '__default__';
+	    this.getViewModel().set('current.scsihw', scsihw);
+	    this.getViewModel().set('current.ostype', ostype);
+	},
+	setWidget: function(widget, newValue) {
+	    // changing a widget is safe only if ComponentQuery.query returns us
+	    // a single value array
+	    var widgets = Ext.ComponentQuery.query('pveQemuCreateWizard ' + widget);
+	    if (widgets.length === 1) {
+		widgets[0].setValue(newValue);
+	    } else {
+		// ignore multiple disks, we only want to set the type if there is a single disk
+	    }
+	},
+	onSecondCDChange: function(widget, value, lastValue) {
+	    let me = this;
+	    let vm = me.getViewModel();
+	    let updateVMConfig = function() {
+		let widgets = Ext.ComponentQuery.query('pveMultiHDPanel');
+		if (widgets.length === 1) {
+		    widgets[0].getController().updateVMConfig();
+		}
+	    };
+	    if (value) {
+		// only for windows
+		vm.set('current.ide0', "some");
+		vm.notify();
+		updateVMConfig();
+		me.setWidget('pveBusSelector', 'scsi');
+		me.setWidget('pveNetworkCardSelector', 'virtio');
+	    } else {
+		vm.set('current.ide0', "");
+		vm.notify();
+		updateVMConfig();
+		me.setWidget('pveBusSelector', 'scsi');
+		let ostype = me.lookup('ostype').getValue();
+		var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype);
+		me.setWidget('pveBusSelector', targetValues.busType);
+	    }
+	},
+    },
+
+    setNodename: function(nodename) {
+	var me = this;
+	me.lookup('isoSelector').setNodename(nodename);
+    },
+
+    onGetValues: function(values) {
+	if (values.ide0) {
+	    let drive = {
+		media: 'cdrom',
+		file: values.ide0,
+	    };
+	    values.ide0 = PVE.Parser.printQemuDrive(drive);
+	}
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'displayfield',
+		value: gettext('Guest OS') + ':',
+		hidden: !me.insideWizard,
+	    },
+	    {
+		xtype: 'combobox',
+		submitValue: false,
+		name: 'osbase',
+		fieldLabel: gettext('Type'),
+		editable: false,
+		queryMode: 'local',
+		value: 'Linux',
+		store: Object.keys(PVE.Utils.kvm_ostypes),
+	    },
+	    {
+		xtype: 'combobox',
+		name: 'ostype',
+		reference: 'ostype',
+		fieldLabel: gettext('Version'),
+		value: 'l26',
+		allowBlank: false,
+		editable: false,
+		queryMode: 'local',
+		valueField: 'val',
+		displayField: 'desc',
+		store: {
+		    fields: ['desc', 'val'],
+		    data: PVE.Utils.kvm_ostypes.Linux,
+		    listeners: {
+			datachanged: function(store) {
+			    var ostype = me.lookup('ostype');
+			    var old_val = ostype.getValue();
+			    if (!me.insideWizard && old_val && store.find('val', old_val) !== -1) {
+				ostype.setValue(old_val);
+			    } else {
+				ostype.setValue(store.getAt(0));
+			    }
+			},
+		    },
+		},
+	    },
+	];
+
+	if (me.insideWizard) {
+	    me.items.push(
+		{
+		    xtype: 'proxmoxcheckbox',
+		    reference: 'enableSecondCD',
+		    isFormField: false,
+		    hidden: true,
+		    checked: false,
+		    boxLabel: gettext('Add additional drive for VirtIO drivers'),
+		    listeners: {
+			change: function(cb, value) {
+			    me.lookup('isoSelector').setDisabled(!value);
+			    me.lookup('isoSelector').setHidden(!value);
+			},
+		    },
+		},
+		{
+		    xtype: 'pveIsoSelector',
+		    reference: 'isoSelector',
+		    name: 'ide0',
+		    nodename: me.nodename,
+		    insideWizard: true,
+		    hidden: true,
+		    disabled: true,
+		},
+	    );
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.OSTypeEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: 'OS Type',
+
+    items: [{ xtype: 'pveQemuOSTypePanel' }],
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		var value = response.result.data.ostype || 'other';
+		var osinfo = PVE.Utils.get_kvm_osinfo(value);
+		me.setValues({ ostype: value, osbase: osinfo.base });
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.Options', {
+    extend: 'Proxmox.grid.PendingObjectGrid',
+    alias: ['widget.PVE.qemu.Options'],
+
+    onlineHelp: 'qm_options',
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var vmid = me.pveSelNode.data.vmid;
+	if (!vmid) {
+	    throw "no VM ID specified";
+	}
+
+	var caps = Ext.state.Manager.get('GuiCap');
+
+	var rows = {
+	    name: {
+		required: true,
+		defaultValue: me.pveSelNode.data.name,
+		header: gettext('Name'),
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Name'),
+		    items: {
+			xtype: 'inputpanel',
+			items: {
+			    xtype: 'textfield',
+			    name: 'name',
+			    vtype: 'DnsName',
+			    value: '',
+			    fieldLabel: gettext('Name'),
+			    allowBlank: true,
+			},
+			onGetValues: function(values) {
+			    var params = values;
+			    if (values.name === undefined ||
+				values.name === null ||
+				values.name === '') {
+				params = { 'delete': 'name' };
+			    }
+			    return params;
+			},
+		    },
+		} : undefined,
+	    },
+	    onboot: {
+		header: gettext('Start at boot'),
+		defaultValue: '',
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Start at boot'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'onboot',
+			uncheckedValue: 0,
+			defaultValue: 0,
+			deleteDefaultValue: true,
+			fieldLabel: gettext('Start at boot'),
+		    },
+		} : undefined,
+	    },
+	    startup: {
+		header: gettext('Start/Shutdown order'),
+		defaultValue: '',
+		renderer: PVE.Utils.render_kvm_startup,
+		editor: caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify']
+		    ? {
+			xtype: 'pveWindowStartupEdit',
+			onlineHelp: 'qm_startup_and_shutdown',
+		    } : undefined,
+	    },
+	    ostype: {
+		header: gettext('OS Type'),
+		editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.OSTypeEdit' : undefined,
+		renderer: PVE.Utils.render_kvm_ostype,
+		defaultValue: 'other',
+	    },
+	    bootdisk: {
+		visible: false,
+	    },
+	    boot: {
+		header: gettext('Boot Order'),
+		defaultValue: 'cdn',
+		editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.BootOrderEdit' : undefined,
+		multiKey: ['boot', 'bootdisk'],
+		renderer: function(order, metaData, record, rowIndex, colIndex, store, pending) {
+		    if (/^\s*$/.test(order)) {
+			return gettext('(No boot device selected)');
+		    }
+		    let boot = PVE.Parser.parsePropertyString(order, "legacy");
+		    if (boot.order) {
+			let list = boot.order.split(';');
+			let ret = '';
+			list.forEach(dev => {
+			    if (ret) {
+				ret += ', ';
+			    }
+			    ret += dev;
+			});
+			return ret;
+		    }
+
+		    // legacy style and fallback
+		    let i;
+		    var text = '';
+		    var bootdisk = me.getObjectValue('bootdisk', undefined, pending);
+		    order = boot.legacy || 'cdn';
+		    for (i = 0; i < order.length; i++) {
+			if (text) {
+			    text += ', ';
+			}
+			var sel = order.substring(i, i + 1);
+			if (sel === 'c') {
+			    if (bootdisk) {
+				text += bootdisk;
+			    } else {
+				text += gettext('first disk');
+			    }
+			} else if (sel === 'n') {
+			    text += gettext('any net');
+			} else if (sel === 'a') {
+			    text += gettext('Floppy');
+			} else if (sel === 'd') {
+			    text += gettext('any CD-ROM');
+			} else {
+			    text += sel;
+			}
+		    }
+		    return text;
+		},
+	    },
+	    tablet: {
+		header: gettext('Use tablet for pointer'),
+		defaultValue: true,
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.HWType'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Use tablet for pointer'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'tablet',
+			checked: true,
+			uncheckedValue: 0,
+			defaultValue: 1,
+			deleteDefaultValue: true,
+			fieldLabel: gettext('Enabled'),
+		    },
+		} : undefined,
+	    },
+	    hotplug: {
+		header: gettext('Hotplug'),
+		defaultValue: 'disk,network,usb',
+		renderer: PVE.Utils.render_hotplug_features,
+		editor: caps.vms['VM.Config.HWType'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Hotplug'),
+		    items: {
+			xtype: 'pveHotplugFeatureSelector',
+			name: 'hotplug',
+			value: '',
+			multiSelect: true,
+			fieldLabel: gettext('Hotplug'),
+			allowBlank: true,
+		    },
+		} : undefined,
+	    },
+	    acpi: {
+		header: gettext('ACPI support'),
+		defaultValue: true,
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.HWType'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('ACPI support'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'acpi',
+			checked: true,
+			uncheckedValue: 0,
+			defaultValue: 1,
+			deleteDefaultValue: true,
+			fieldLabel: gettext('Enabled'),
+		    },
+		} : undefined,
+	    },
+	    kvm: {
+		header: gettext('KVM hardware virtualization'),
+		defaultValue: true,
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.HWType'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('KVM hardware virtualization'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'kvm',
+			checked: true,
+			uncheckedValue: 0,
+			defaultValue: 1,
+			deleteDefaultValue: true,
+			fieldLabel: gettext('Enabled'),
+		    },
+		} : undefined,
+	    },
+	    freeze: {
+		header: gettext('Freeze CPU at startup'),
+		defaultValue: false,
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.PowerMgmt'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Freeze CPU at startup'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'freeze',
+			uncheckedValue: 0,
+			defaultValue: 0,
+			deleteDefaultValue: true,
+			labelWidth: 140,
+			fieldLabel: gettext('Freeze CPU at startup'),
+		    },
+		} : undefined,
+	    },
+	    localtime: {
+		header: gettext('Use local time for RTC'),
+		defaultValue: '__default__',
+		renderer: PVE.Utils.render_localtime,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Use local time for RTC'),
+		    width: 400,
+		    items: {
+			xtype: 'proxmoxKVComboBox',
+			name: 'localtime',
+			value: '__default__',
+			comboItems: [
+			    ['__default__', PVE.Utils.render_localtime('__default__')],
+			    [1, PVE.Utils.render_localtime(1)],
+			    [0, PVE.Utils.render_localtime(0)],
+			],
+			labelWidth: 140,
+			fieldLabel: gettext('Use local time for RTC'),
+		    },
+		} : undefined,
+	    },
+	    startdate: {
+		header: gettext('RTC start date'),
+		defaultValue: 'now',
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('RTC start date'),
+		    items: {
+			xtype: 'proxmoxtextfield',
+			name: 'startdate',
+			deleteEmpty: true,
+			value: 'now',
+			fieldLabel: gettext('RTC start date'),
+			vtype: 'QemuStartDate',
+			allowBlank: true,
+		    },
+		} : undefined,
+	    },
+	    smbios1: {
+		header: gettext('SMBIOS settings (type1)'),
+		defaultValue: '',
+		renderer: Ext.String.htmlEncode,
+		editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.Smbios1Edit' : undefined,
+	    },
+	    agent: {
+		header: 'QEMU Guest Agent',
+		defaultValue: false,
+		renderer: PVE.Utils.render_qga_features,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Qemu Agent'),
+		    width: 350,
+		    onlineHelp: 'qm_qemu_agent',
+		    items: {
+			xtype: 'pveAgentFeatureSelector',
+			name: 'agent',
+		    },
+		} : undefined,
+	    },
+	    protection: {
+		header: gettext('Protection'),
+		defaultValue: false,
+		renderer: Proxmox.Utils.format_boolean,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Protection'),
+		    items: {
+			xtype: 'proxmoxcheckbox',
+			name: 'protection',
+			uncheckedValue: 0,
+			defaultValue: 0,
+			deleteDefaultValue: true,
+			fieldLabel: gettext('Enabled'),
+		    },
+		} : undefined,
+	    },
+	    spice_enhancements: {
+		header: gettext('Spice Enhancements'),
+		defaultValue: false,
+		renderer: PVE.Utils.render_spice_enhancements,
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('Spice Enhancements'),
+		    onlineHelp: 'qm_spice_enhancements',
+		    items: {
+			xtype: 'pveSpiceEnhancementSelector',
+			name: 'spice_enhancements',
+		    },
+		} : undefined,
+	    },
+	    vmstatestorage: {
+		header: gettext('VM State storage'),
+		defaultValue: '',
+		renderer: val => val || gettext('Automatic'),
+		editor: caps.vms['VM.Config.Options'] ? {
+		    xtype: 'proxmoxWindowEdit',
+		    subject: gettext('VM State storage'),
+		    onlineHelp: 'chapter_virtual_machines', // FIXME: use 'qm_vmstatestorage' once available
+		    width: 350,
+		    items: {
+			xtype: 'pveStorageSelector',
+			storageContent: 'images',
+			allowBlank: true,
+			emptyText: gettext("Automatic (Storage used by the VM, or 'local')"),
+			autoSelect: false,
+			deleteEmpty: true,
+			skipEmptyText: true,
+			nodename: nodename,
+			name: 'vmstatestorage',
+		    },
+		} : undefined,
+	    },
+	    hookscript: {
+		header: gettext('Hookscript'),
+	    },
+	};
+
+	var baseurl = 'nodes/' + nodename + '/qemu/' + vmid + '/config';
+
+	var edit_btn = new Ext.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    handler: function() { me.run_editor(); },
+	});
+
+	var revert_btn = new PVE.button.PendingRevert();
+
+	var set_button_status = function() {
+	    var sm = me.getSelectionModel();
+	    var rec = sm.getSelection()[0];
+
+	    if (!rec) {
+		edit_btn.disable();
+		return;
+	    }
+
+	    var key = rec.data.key;
+	    var pending = rec.data.delete || me.hasPendingChanges(key);
+	    var rowdef = rows[key];
+
+	    edit_btn.setDisabled(!rowdef.editor);
+	    revert_btn.setDisabled(!pending);
+	};
+
+	Ext.apply(me, {
+	    url: "/api2/json/nodes/" + nodename + "/qemu/" + vmid + "/pending",
+	    interval: 5000,
+	    cwidth1: 250,
+	    tbar: [edit_btn, revert_btn],
+	    rows: rows,
+	    editorConfig: {
+		url: "/api2/extjs/" + baseurl,
+	    },
+	    listeners: {
+		itemdblclick: me.run_editor,
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+
+	me.on('activate', () => me.rstore.startUpdate());
+	me.on('destroy', () => me.rstore.stopUpdate());
+	me.on('deactivate', () => me.rstore.stopUpdate());
+
+	me.mon(me.getStore(), 'datachanged', function() {
+	    set_button_status();
+	});
+    },
+});
+
+Ext.define('PVE.qemu.PCIInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    onlineHelp: 'qm_pci_passthrough_vm_config',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	setVMConfig: function(vmconfig) {
+	    let me = this;
+	    let view = me.getView();
+	    me.vmconfig = vmconfig;
+
+	    let hostpci = me.vmconfig[view.confid] || '';
+
+	    let values = PVE.Parser.parsePropertyString(hostpci, 'host');
+	    if (values.host) {
+		if (!values.host.match(/^[0-9a-f]{4}:/i)) { // add optional domain
+		    values.host = "0000:" + values.host;
+		}
+		if (values.host.length < 11) { // 0000:00:00 format not 0000:00:00.0
+		    values.host += ".0";
+		    values.multifunction = true;
+		}
+		values.type = 'raw';
+	    } else if (values.mapping) {
+		values.type = 'mapped';
+	    }
+
+	    values['x-vga'] = PVE.Parser.parseBoolean(values['x-vga'], 0);
+	    values.pcie = PVE.Parser.parseBoolean(values.pcie, 0);
+	    values.rombar = PVE.Parser.parseBoolean(values.rombar, 1);
+
+	    view.setValues(values);
+	    if (!me.vmconfig.machine || me.vmconfig.machine.indexOf('q35') === -1) {
+		// machine is not set to some variant of q35, so we disable pcie
+		let pcie = me.lookup('pcie');
+		pcie.setDisabled(true);
+		pcie.setBoxLabel(gettext('Q35 only'));
+	    }
+
+	    if (values.romfile) {
+		me.lookup('romfile').setVisible(true);
+	    }
+	},
+
+	selectorEnable: function(selector) {
+	    let me = this;
+	    me.pciDevChange(selector, selector.getValue());
+	},
+
+	pciDevChange: function(pcisel, value) {
+	    let me = this;
+	    let mdevfield = me.lookup('mdev');
+	    if (!value) {
+		if (!pcisel.isDisabled()) {
+		    mdevfield.setDisabled(true);
+		}
+		return;
+	    }
+	    let pciDev = pcisel.getStore().getById(value);
+
+	    mdevfield.setDisabled(!pciDev || !pciDev.data.mdev);
+	    if (!pciDev) {
+		return;
+	    }
+
+	    let path = value;
+	    if (pciDev.data.map) {
+		// find local mapping
+		for (const entry of pciDev.data.map) {
+		    let mapping = PVE.Parser.parsePropertyString(entry);
+		    if (mapping.node === pcisel.up('inputpanel').nodename) {
+			path = mapping.path.split(';')[0];
+			break;
+		    }
+		}
+		if (path.indexOf('.') === -1) {
+		    path += '.0';
+		}
+	    }
+
+	    if (pciDev.data.mdev) {
+		mdevfield.setPciID(path);
+	    }
+	    if (pcisel.reference === 'selector') {
+		let iommu = pciDev.data.iommugroup;
+		if (iommu === -1) {
+		    return;
+		}
+		// try to find out if there are more devices in that iommu group
+		let id = path.substring(0, 5); // 00:00
+		let count = 0;
+		pcisel.getStore().each(({ data }) => {
+		    if (data.iommugroup === iommu && data.id.substring(0, 5) !== id) {
+			count++;
+			return false;
+		    }
+		    return true;
+		});
+		me.lookup('group_warning').setVisible(count > 0);
+	    }
+	},
+
+	onGetValues: function(values) {
+	    let me = this;
+	    let view = me.getView();
+	    if (!view.confid) {
+		for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) {
+		    if (!me.vmconfig['hostpci' + i.toString()]) {
+			view.confid = 'hostpci' + i.toString();
+			break;
+		    }
+		}
+		// FIXME: what if no confid was found??
+	    }
+
+	    values.host?.replace(/^0000:/, ''); // remove optional '0000' domain
+
+	    if (values.multifunction && values.host) {
+		values.host = values.host.substring(0, values.host.indexOf('.')); // skip the '.X'
+		delete values.multifunction;
+	    }
+
+	    if (values.rombar) {
+		delete values.rombar;
+	    } else {
+		values.rombar = 0;
+	    }
+
+	    if (!values.romfile) {
+		delete values.romfile;
+	    }
+
+	    delete values.type;
+
+	    let ret = {};
+	    ret[view.confid] = PVE.Parser.printPropertyString(values, 'host');
+	    return ret;
+	},
+    },
+
+    viewModel: {
+	data: {
+	    isMapped: true,
+	},
+    },
+
+    setVMConfig: function(vmconfig) {
+	return this.getController().setVMConfig(vmconfig);
+    },
+
+    onGetValues: function(values) {
+	return this.getController().onGetValues(values);
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.nodename = me.pveSelNode.data.node;
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	me.columnT = [
+	    {
+		xtype: 'displayfield',
+		reference: 'iommu_warning',
+		hidden: true,
+		columnWidth: 1,
+		padding: '0 0 10 0',
+		value: 'No IOMMU detected, please activate it.' +
+		'See Documentation for further information.',
+		userCls: 'pmx-hint',
+	    },
+	    {
+		xtype: 'displayfield',
+		reference: 'group_warning',
+		hidden: true,
+		columnWidth: 1,
+		padding: '0 0 10 0',
+		itemId: 'iommuwarning',
+		value: 'The selected Device is not in a seperate IOMMU group, make sure this is intended.',
+		userCls: 'pmx-hint',
+	    },
+	];
+
+	me.column1 = [
+	    {
+		xtype: 'radiofield',
+		name: 'type',
+		inputValue: 'mapped',
+		boxLabel: gettext('Mapped Device'),
+		bind: {
+		    value: '{isMapped}',
+		},
+	    },
+	    {
+		xtype: 'pvePCIMapSelector',
+		fieldLabel: gettext('Device'),
+		reference: 'mapped_selector',
+		name: 'mapping',
+		labelAlign: 'right',
+		nodename: me.nodename,
+		allowBlank: false,
+		bind: {
+		    disabled: '{!isMapped}',
+		},
+		listeners: {
+		    change: 'pciDevChange',
+		    enable: 'selectorEnable',
+		},
+	    },
+	    {
+		xtype: 'radiofield',
+		name: 'type',
+		inputValue: 'raw',
+		checked: true,
+		boxLabel: gettext('Raw Device'),
+	    },
+	    {
+		xtype: 'pvePCISelector',
+		fieldLabel: gettext('Device'),
+		name: 'host',
+		reference: 'selector',
+		nodename: me.nodename,
+		labelAlign: 'right',
+		allowBlank: false,
+		disabled: true,
+		bind: {
+		    disabled: '{isMapped}',
+		},
+		onLoadCallBack: function(store, records, success) {
+		    if (!success || !records.length) {
+			return;
+		    }
+		    me.lookup('iommu_warning').setVisible(
+			records.every((val) => val.data.iommugroup === -1),
+		    );
+		},
+		listeners: {
+		    change: 'pciDevChange',
+		    enable: 'selectorEnable',
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('All Functions'),
+		reference: 'all_functions',
+		disabled: true,
+		labelAlign: 'right',
+		name: 'multifunction',
+		bind: {
+		    disabled: '{isMapped}',
+		},
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'pveMDevSelector',
+		name: 'mdev',
+		reference: 'mdev',
+		disabled: true,
+		fieldLabel: gettext('MDev Type'),
+		nodename: me.nodename,
+		listeners: {
+		    change: function(field, value) {
+			let multiFunction = me.down('field[name=multifunction]');
+			if (value) {
+			    multiFunction.setValue(false);
+			}
+			multiFunction.setDisabled(!!value);
+		    },
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: gettext('Primary GPU'),
+		name: 'x-vga',
+	    },
+	];
+
+	me.advancedColumn1 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: 'ROM-Bar',
+		name: 'rombar',
+	    },
+	    {
+		xtype: 'displayfield',
+		submitValue: true,
+		hidden: true,
+		fieldLabel: 'ROM-File',
+		reference: 'romfile',
+		name: 'romfile',
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'vendor-id',
+		fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Vendor')),
+		emptyText: gettext('From Device'),
+		vtype: 'PciId',
+		allowBlank: true,
+		submitEmpty: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'device-id',
+		fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Device')),
+		emptyText: gettext('From Device'),
+		vtype: 'PciId',
+		allowBlank: true,
+		submitEmpty: false,
+	    },
+	];
+
+	me.advancedColumn2 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		fieldLabel: 'PCI-Express',
+		reference: 'pcie',
+		name: 'pcie',
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'sub-vendor-id',
+		fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Sub-Vendor')),
+		emptyText: gettext('From Device'),
+		vtype: 'PciId',
+		allowBlank: true,
+		submitEmpty: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'sub-device-id',
+		fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Sub-Device')),
+		emptyText: gettext('From Device'),
+		vtype: 'PciId',
+		allowBlank: true,
+		submitEmpty: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.qemu.PCIEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: gettext('PCI Device'),
+
+    vmconfig: undefined,
+    isAdd: true,
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = !me.confid;
+
+	let ipanel = Ext.create('PVE.qemu.PCIInputPanel', {
+	    confid: me.confid,
+	    pveSelNode: me.pveSelNode,
+	});
+
+	Ext.apply(me, {
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: ({ result }) => ipanel.setVMConfig(result.data),
+	});
+    },
+});
+// The view model of the parent shoul contain a 'cgroupMode' variable (or params for v2 are used).
+Ext.define('PVE.qemu.ProcessorInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuProcessorPanel',
+    onlineHelp: 'qm_cpu',
+
+    insideWizard: false,
+
+    viewModel: {
+	data: {
+	    socketCount: 1,
+	    coreCount: 1,
+	    showCustomModelPermWarning: false,
+	    userIsRoot: false,
+	},
+	formulas: {
+	    totalCoreCount: get => get('socketCount') * get('coreCount'),
+	    cpuunitsDefault: (get) => get('cgroupMode') === 1 ? 1024 : 100,
+	    cpuunitsMin: (get) => get('cgroupMode') === 1 ? 2 : 1,
+	    cpuunitsMax: (get) => get('cgroupMode') === 1 ? 262144 : 10000,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	init: function() {
+	    let me = this;
+	    let viewModel = me.getViewModel();
+
+	    viewModel.set('userIsRoot', Proxmox.UserName === 'root@pam');
+	},
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+	let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault');
+
+	if (Array.isArray(values.delete)) {
+	    values.delete = values.delete.join(',');
+	}
+
+	PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard);
+	PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard);
+
+	// build the cpu options:
+	me.cpu.cputype = values.cputype;
+
+	if (values.flags) {
+	    me.cpu.flags = values.flags;
+	} else {
+	    delete me.cpu.flags;
+	}
+
+	delete values.cputype;
+	delete values.flags;
+	var cpustring = PVE.Parser.printQemuCpu(me.cpu);
+
+	// remove cputype delete request:
+	var del = values.delete;
+	delete values.delete;
+	if (del) {
+	    del = del.split(',');
+	    Ext.Array.remove(del, 'cputype');
+	} else {
+	    del = [];
+	}
+
+	if (cpustring) {
+	    values.cpu = cpustring;
+	} else {
+	    del.push('cpu');
+	}
+
+	var delarr = del.join(',');
+	if (delarr) {
+	    values.delete = delarr;
+	}
+
+	return values;
+    },
+
+    setValues: function(values) {
+	let me = this;
+
+	let type = values.cputype;
+	let typeSelector = me.lookupReference('cputype');
+	let typeStore = typeSelector.getStore();
+	typeStore.on('load', (store, records, success) => {
+	    if (!success || !type || records.some(x => x.data.name === type)) {
+		return;
+	    }
+
+	    // if we get here, a custom CPU model is selected for the VM but we
+	    // don't have permission to configure it - it will not be in the
+	    // list retrieved from the API, so add it manually to allow changing
+	    // other processor options
+	    typeStore.add({
+		name: type,
+		displayname: type.replace(/^custom-/, ''),
+		custom: 1,
+		vendor: gettext("Unknown"),
+	    });
+	    typeSelector.select(type);
+	});
+
+	me.callParent([values]);
+    },
+
+    cpu: {},
+
+    column1: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'sockets',
+	    minValue: 1,
+	    maxValue: 4,
+	    value: '1',
+	    fieldLabel: gettext('Sockets'),
+	    allowBlank: false,
+	    bind: {
+		value: '{socketCount}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'cores',
+	    minValue: 1,
+	    maxValue: 256,
+	    value: '1',
+	    fieldLabel: gettext('Cores'),
+	    allowBlank: false,
+	    bind: {
+		value: '{coreCount}',
+	    },
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'CPUModelSelector',
+	    name: 'cputype',
+	    reference: 'cputype',
+	    fieldLabel: gettext('Type'),
+	},
+	{
+	    xtype: 'displayfield',
+	    fieldLabel: gettext('Total cores'),
+	    name: 'totalcores',
+	    isFormField: false,
+	    bind: {
+		value: '{totalCoreCount}',
+	    },
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'displayfield',
+	    userCls: 'pmx-hint',
+	    value: gettext('WARNING: You do not have permission to configure custom CPU types, if you change the type you will not be able to go back!'),
+	    hidden: true,
+	    bind: {
+		hidden: '{!showCustomModelPermWarning}',
+	    },
+	},
+    ],
+
+    advancedColumn1: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'vcpus',
+	    minValue: 1,
+	    maxValue: 1,
+	    value: '',
+	    fieldLabel: gettext('VCPUs'),
+	    deleteEmpty: true,
+	    allowBlank: true,
+	    emptyText: '1',
+	    bind: {
+		emptyText: '{totalCoreCount}',
+		maxValue: '{totalCoreCount}',
+	    },
+	},
+	{
+	    xtype: 'numberfield',
+	    name: 'cpulimit',
+	    minValue: 0,
+	    maxValue: 128, // api maximum
+	    value: '',
+	    step: 1,
+	    fieldLabel: gettext('CPU limit'),
+	    allowBlank: true,
+	    emptyText: gettext('unlimited'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'affinity',
+	    vtype: 'CpuSet',
+	    value: '',
+	    fieldLabel: gettext('CPU Affinity'),
+	    allowBlank: true,
+	    emptyText: gettext("All Cores"),
+	    deleteEmpty: true,
+	    bind: {
+		disabled: '{!userIsRoot}',
+	    },
+	},
+    ],
+
+    advancedColumn2: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'cpuunits',
+	    fieldLabel: gettext('CPU units'),
+	    minValue: '1',
+	    maxValue: '10000',
+	    value: '',
+	    emptyText: '100',
+	    bind: {
+		minValue: '{cpuunitsMin}',
+		maxValue: '{cpuunitsMax}',
+		emptyText: '{cpuunitsDefault}',
+	    },
+	    deleteEmpty: true,
+	    allowBlank: true,
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Enable NUMA'),
+	    name: 'numa',
+	    uncheckedValue: 0,
+	},
+    ],
+    advancedColumnB: [
+	{
+	    xtype: 'label',
+	    text: 'Extra CPU Flags:',
+	},
+	{
+	    xtype: 'vmcpuflagselector',
+	    name: 'flags',
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.ProcessorEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveQemuProcessorEdit',
+
+    width: 700,
+
+    viewModel: {
+	data: {
+	    cgroupMode: 2,
+	},
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.getViewModel().set('cgroupMode', me.cgroupMode);
+
+	var ipanel = Ext.create('PVE.qemu.ProcessorInputPanel');
+
+	Ext.apply(me, {
+	    subject: gettext('Processors'),
+	    items: ipanel,
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		var data = response.result.data;
+		var value = data.cpu;
+		if (value) {
+		    var cpu = PVE.Parser.parseQemuCpu(value);
+		    ipanel.cpu = cpu;
+		    data.cputype = cpu.cputype;
+		    if (cpu.flags) {
+			data.flags = cpu.flags;
+		    }
+
+		    let caps = Ext.state.Manager.get('GuiCap');
+		    if (data.cputype.indexOf('custom-') === 0 &&
+			!caps.nodes['Sys.Audit']) {
+			let vm = ipanel.getViewModel();
+			vm.set("showCustomModelPermWarning", true);
+		    }
+		}
+		me.setValues(data);
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.BiosEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveQemuBiosEdit',
+
+    onlineHelp: 'qm_bios_and_uefi',
+    subject: 'BIOS',
+    autoLoad: true,
+
+    viewModel: {
+	data: {
+	    bios: '__default__',
+	    efidisk0: false,
+	},
+	formulas: {
+	    showEFIDiskHint: (get) => get('bios') === 'ovmf' && !get('efidisk0'),
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'pveQemuBiosSelector',
+	    onlineHelp: 'qm_bios_and_uefi',
+	    name: 'bios',
+	    value: '__default__',
+	    bind: '{bios}',
+	    fieldLabel: 'BIOS',
+	},
+	{
+	    xtype: 'displayfield',
+	    name: 'efidisk0',
+	    bind: '{efidisk0}',
+	    hidden: true,
+	},
+	{
+	    xtype: 'displayfield',
+	    userCls: 'pmx-hint',
+	    value: gettext('You need to add an EFI disk for storing the EFI settings. See the online help for details.'),
+	    bind: {
+		hidden: '{!showEFIDiskHint}',
+	    },
+	},
+    ],
+});
+Ext.define('PVE.qemu.RNGInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveRNGInputPanel',
+
+    onlineHelp: 'qm_virtio_rng',
+
+    onGetValues: function(values) {
+	if (values.max_bytes === "") {
+	    values.max_bytes = "0";
+	} else if (values.max_bytes === "1024" && values.period === "") {
+	    delete values.max_bytes;
+	}
+
+	var ret = PVE.Parser.printPropertyString(values);
+
+	return {
+	    rng0: ret,
+	};
+    },
+
+    setValues: function(values) {
+	if (values.max_bytes === 0) {
+	    values.max_bytes = null;
+	}
+
+	this.callParent(arguments);
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    '#max_bytes': {
+		change: function(el, newVal) {
+		    let limitWarning = this.lookupReference('limitWarning');
+		    limitWarning.setHidden(!!newVal);
+		},
+	    },
+	    '#source': {
+		change: function(el, newVal) {
+		    let limitWarning = this.lookupReference('sourceWarning');
+		    limitWarning.setHidden(newVal !== '/dev/random');
+		},
+	    },
+	},
+    },
+
+    items: [{
+	itemId: 'source',
+	name: 'source',
+	xtype: 'proxmoxKVComboBox',
+	value: '/dev/urandom',
+	fieldLabel: gettext('Entropy source'),
+	labelWidth: 130,
+	comboItems: [
+	    ['/dev/urandom', '/dev/urandom'],
+	    ['/dev/random', '/dev/random'],
+	    ['/dev/hwrng', '/dev/hwrng'],
+	],
+    },
+    {
+	xtype: 'numberfield',
+	itemId: 'max_bytes',
+	name: 'max_bytes',
+	minValue: 0,
+	step: 1,
+	value: 1024,
+	fieldLabel: gettext('Limit (Bytes/Period)'),
+	labelWidth: 130,
+	emptyText: gettext('unlimited'),
+    },
+    {
+	xtype: 'numberfield',
+	name: 'period',
+	minValue: 1,
+	step: 1,
+	fieldLabel: gettext('Period') + ' (ms)',
+	labelWidth: 130,
+	emptyText: '1000',
+    },
+    {
+	xtype: 'displayfield',
+	reference: 'sourceWarning',
+	value: gettext('Using /dev/random as entropy source is discouraged, as it can lead to host entropy starvation. /dev/urandom is preferred, and does not lead to a decrease in security in practice.'),
+	userCls: 'pmx-hint',
+	hidden: true,
+    },
+    {
+	xtype: 'displayfield',
+	reference: 'limitWarning',
+	value: gettext('Disabling the limiter can potentially allow a guest to overload the host. Proceed with caution.'),
+	userCls: 'pmx-hint',
+	hidden: true,
+    }],
+});
+
+Ext.define('PVE.qemu.RNGEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: gettext('VirtIO RNG'),
+
+    items: [{
+	xtype: 'pveRNGInputPanel',
+    }],
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response) {
+		    me.vmconfig = response.result.data;
+
+		    var rng0 = me.vmconfig.rng0;
+		    if (rng0) {
+			me.setValues(PVE.Parser.parsePropertyString(rng0));
+		    }
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.qemu.SSHKeyInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveQemuSSHKeyInputPanel',
+
+    insideWizard: false,
+
+    onGetValues: function(values) {
+	var me = this;
+	if (values.sshkeys) {
+	    values.sshkeys.trim();
+	}
+	if (!values.sshkeys.length) {
+	    values = {};
+	    values.delete = 'sshkeys';
+	    return values;
+	} else {
+	    values.sshkeys = encodeURIComponent(values.sshkeys);
+	}
+	return values;
+    },
+
+    items: [
+	{
+	    xtype: 'textarea',
+	    itemId: 'sshkeys',
+	    name: 'sshkeys',
+	    height: 250,
+	},
+	{
+	    xtype: 'filebutton',
+	    itemId: 'filebutton',
+	    name: 'file',
+	    text: gettext('Load SSH Key File'),
+	    fieldLabel: 'test',
+	    listeners: {
+		change: function(btn, e, value) {
+		    let view = this.up('inputpanel');
+		    e = e.event;
+		    Ext.Array.each(e.target.files, function(file) {
+			PVE.Utils.loadSSHKeyFromFile(file, function(res) {
+			    let keysField = view.down('#sshkeys');
+			    var old = keysField.getValue();
+			    keysField.setValue(old + res);
+			});
+		    });
+		    btn.reset();
+		},
+	    },
+	},
+    ],
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+	if (!window.FileReader) {
+	    me.down('#filebutton').setVisible(false);
+	}
+    },
+});
+
+Ext.define('PVE.qemu.SSHKeyEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 800,
+
+    initComponent: function() {
+	var me = this;
+
+	var ipanel = Ext.create('PVE.qemu.SSHKeyInputPanel');
+
+	Ext.apply(me, {
+	    subject: gettext('SSH Keys'),
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.create) {
+	    me.load({
+		success: function(response, options) {
+		    var data = response.result.data;
+		    if (data.sshkeys) {
+			data.sshkeys = decodeURIComponent(data.sshkeys);
+			ipanel.setValues(data);
+		    }
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.qemu.ScsiHwEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.applyIf(me, {
+	    subject: gettext('SCSI Controller Type'),
+	    items: {
+		xtype: 'pveScsiHwSelector',
+		name: 'scsihw',
+		value: '__default__',
+		fieldLabel: gettext('Type'),
+	    },
+	});
+
+	me.callParent();
+
+	me.load();
+    },
+});
+Ext.define('PVE.qemu.SerialnputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    autoComplete: false,
+
+    setVMConfig: function(vmconfig) {
+	var me = this, i;
+	me.vmconfig = vmconfig;
+
+	for (i = 0; i < 4; i++) {
+	    var port = 'serial' + i.toString();
+	    if (!me.vmconfig[port]) {
+		me.down('field[name=serialid]').setValue(i);
+		break;
+	    }
+	}
+    },
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var id = 'serial' + values.serialid;
+	delete values.serialid;
+	values[id] = 'socket';
+	return values;
+    },
+
+    items: [
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'serialid',
+	    fieldLabel: gettext('Serial Port'),
+	    minValue: 0,
+	    maxValue: 3,
+	    allowBlank: false,
+	    validator: function(id) {
+		if (!this.rendered) {
+		    return true;
+		}
+		let view = this.up('panel');
+		if (view.vmconfig !== undefined && Ext.isDefined(view.vmconfig['serial' + id])) {
+			return "This device is already in use.";
+		}
+		return true;
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.SerialEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    vmconfig: undefined,
+
+    isAdd: true,
+
+    subject: gettext('Serial Port'),
+
+    initComponent: function() {
+	var me = this;
+
+	// for now create of (socket) serial port only
+	me.isCreate = true;
+
+	var ipanel = Ext.create('PVE.qemu.SerialnputPanel', {});
+
+	Ext.apply(me, {
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		ipanel.setVMConfig(response.result.data);
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.Smbios1InputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.PVE.qemu.Smbios1InputPanel',
+
+    insideWizard: false,
+
+    smbios1: {},
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var params = {
+	    smbios1: PVE.Parser.printQemuSmbios1(values),
+	};
+
+	return params;
+    },
+
+    setSmbios1: function(data) {
+	var me = this;
+
+	me.smbios1 = data;
+
+	me.setValues(me.smbios1);
+    },
+
+    items: [
+	{
+	    xtype: 'textfield',
+	    fieldLabel: 'UUID',
+	    regex: /^[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$/,
+	    name: 'uuid',
+	},
+	{
+	    xtype: 'textareafield',
+	    fieldLabel: gettext('Manufacturer'),
+	    fieldStyle: {
+		height: '2em',
+		minHeight: '2em',
+	    },
+	    name: 'manufacturer',
+	},
+	{
+	    xtype: 'textareafield',
+	    fieldLabel: gettext('Product'),
+	    fieldStyle: {
+		height: '2em',
+		minHeight: '2em',
+	    },
+	    name: 'product',
+	},
+	{
+	    xtype: 'textareafield',
+	    fieldLabel: gettext('Version'),
+	    fieldStyle: {
+		height: '2em',
+		minHeight: '2em',
+	    },
+	    name: 'version',
+	},
+	{
+	    xtype: 'textareafield',
+	    fieldLabel: gettext('Serial'),
+	    fieldStyle: {
+		height: '2em',
+		minHeight: '2em',
+	    },
+	    name: 'serial',
+	},
+	{
+	    xtype: 'textareafield',
+	    fieldLabel: 'SKU',
+	    fieldStyle: {
+		height: '2em',
+		minHeight: '2em',
+	    },
+	    name: 'sku',
+	},
+	{
+	    xtype: 'textareafield',
+	    fieldLabel: gettext('Family'),
+	    fieldStyle: {
+		height: '2em',
+		minHeight: '2em',
+	    },
+	    name: 'family',
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.Smbios1Edit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	var ipanel = Ext.create('PVE.qemu.Smbios1InputPanel', {});
+
+	Ext.applyIf(me, {
+	    subject: gettext('SMBIOS settings (type1)'),
+	    width: 450,
+	    items: ipanel,
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		me.vmconfig = response.result.data;
+		var value = me.vmconfig.smbios1;
+		if (value) {
+		    var data = PVE.Parser.parseQemuSmbios1(value);
+		    if (!data) {
+			Ext.Msg.alert(gettext('Error'), 'Unable to parse smbios options');
+			me.close();
+			return;
+		    }
+		    ipanel.setSmbios1(data);
+		}
+	    },
+	});
+    },
+});
+Ext.define('PVE.qemu.SystemInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveQemuSystemPanel',
+
+    onlineHelp: 'qm_system_settings',
+
+    viewModel: {
+	data: {
+	    efi: false,
+	    addefi: true,
+	},
+
+	formulas: {
+	    efidisk: function(get) {
+		return get('efi') && get('addefi');
+	    },
+	},
+    },
+
+    onGetValues: function(values) {
+	if (values.vga && values.vga.substr(0, 6) === 'serial') {
+	    values['serial' + values.vga.substr(6, 1)] = 'socket';
+	}
+
+	delete values.hdimage;
+	delete values.hdstorage;
+	delete values.diskformat;
+
+	delete values.preEnrolledKeys; // efidisk
+	delete values.version; // tpmstate
+
+	return values;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	scsihwChange: function(field, value) {
+	    var me = this;
+	    if (me.getView().insideWizard) {
+		me.getViewModel().set('current.scsihw', value);
+	    }
+	},
+
+	biosChange: function(field, value) {
+	    var me = this;
+	    if (me.getView().insideWizard) {
+		me.getViewModel().set('efi', value === 'ovmf');
+	    }
+	},
+
+	control: {
+	    'pveScsiHwSelector': {
+		change: 'scsihwChange',
+	    },
+	    'pveQemuBiosSelector': {
+		change: 'biosChange',
+	    },
+	    '#': {
+		afterrender: 'setMachine',
+	    },
+	},
+
+	setMachine: function() {
+	    let me = this;
+	    let vm = this.getViewModel();
+	    let ostype = vm.get('current.ostype');
+	    if (ostype === 'win11') {
+		me.lookup('machine').setValue('q35');
+		me.lookup('bios').setValue('ovmf');
+		me.lookup('addtpmbox').setValue(true);
+	    }
+	},
+    },
+
+    column1: [
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    value: '__default__',
+	    deleteEmpty: false,
+	    fieldLabel: gettext('Graphic card'),
+	    name: 'vga',
+	    comboItems: Object.entries(PVE.Utils.kvm_vga_drivers),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'machine',
+	    reference: 'machine',
+	    value: '__default__',
+	    fieldLabel: gettext('Machine'),
+	    comboItems: [
+		['__default__', PVE.Utils.render_qemu_machine('')],
+		['q35', 'q35'],
+	    ],
+	},
+	{
+	    xtype: 'displayfield',
+	    value: gettext('Firmware'),
+	},
+	{
+	    xtype: 'pveQemuBiosSelector',
+	    name: 'bios',
+	    reference: 'bios',
+	    value: '__default__',
+	    fieldLabel: 'BIOS',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    bind: {
+		value: '{addefi}',
+		hidden: '{!efi}',
+		disabled: '{!efi}',
+	    },
+	    hidden: true,
+	    submitValue: false,
+	    disabled: true,
+	    fieldLabel: gettext('Add EFI Disk'),
+	},
+	{
+	    xtype: 'pveEFIDiskInputPanel',
+	    name: 'efidisk0',
+	    storageContent: 'images',
+	    bind: {
+		nodename: '{nodename}',
+		hidden: '{!efi}',
+		disabled: '{!efidisk}',
+	    },
+	    autoSelect: false,
+	    disabled: true,
+	    hidden: true,
+	    hideSize: true,
+	    usesEFI: true,
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'pveScsiHwSelector',
+	    name: 'scsihw',
+	    value: '__default__',
+	    bind: {
+		value: '{current.scsihw}',
+	    },
+	    fieldLabel: gettext('SCSI Controller'),
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'agent',
+	    uncheckedValue: 0,
+	    defaultValue: 0,
+	    deleteDefaultValue: true,
+	    fieldLabel: gettext('Qemu Agent'),
+	},
+	{
+	    // fake for spacing
+	    xtype: 'displayfield',
+	    value: ' ',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    reference: 'addtpmbox',
+	    bind: {
+		value: '{addtpm}',
+	    },
+	    submitValue: false,
+	    fieldLabel: gettext('Add TPM'),
+	},
+	{
+	    xtype: 'pveTPMDiskInputPanel',
+	    name: 'tpmstate0',
+	    storageContent: 'images',
+	    bind: {
+		nodename: '{nodename}',
+		hidden: '{!addtpm}',
+		disabled: '{!addtpm}',
+	    },
+	    disabled: true,
+	    hidden: true,
+	},
+    ],
+
+});
+Ext.define('PVE.qemu.USBInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    autoComplete: false,
+    onlineHelp: 'qm_usb_passthrough',
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	if (!me.pveSelNode) {
+	    throw "no pveSelNode given";
+	}
+
+	return { nodename: me.pveSelNode.data.node };
+    },
+
+    viewModel: {
+	data: {},
+    },
+
+    setVMConfig: function(vmconfig) {
+	var me = this;
+	me.vmconfig = vmconfig;
+	let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine);
+	if (max_usb > PVE.Utils.hardware_counts.usb_old) {
+	    me.down('field[name=usb3]').setDisabled(true);
+	}
+    },
+
+    onGetValues: function(values) {
+	var me = this;
+	if (!me.confid) {
+	    let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine);
+	    for (let i = 0; i < max_usb; i++) {
+		let id = 'usb' + i.toString();
+		if (!me.vmconfig[id]) {
+		    me.confid = id;
+		    break;
+		}
+	    }
+	}
+	var val = "";
+	var type = me.down('radiofield').getGroupValue();
+	switch (type) {
+	    case 'spice':
+		val = 'spice';
+		break;
+	    case 'mapped':
+		val = `mapping=${values[type]}`;
+		delete values.mapped;
+		break;
+	    case 'hostdevice':
+	    case 'port':
+		val = 'host=' + values[type];
+		delete values[type];
+		break;
+	    default:
+		throw "invalid type selected";
+	}
+
+	if (values.usb3) {
+	    delete values.usb3;
+	    val += ',usb3=1';
+	}
+	values[me.confid] = val;
+	return values;
+    },
+
+    items: [
+	{
+	    xtype: 'fieldcontainer',
+	    defaultType: 'radiofield',
+	    layout: 'fit',
+	    items: [
+		{
+		    name: 'usb',
+		    inputValue: 'spice',
+		    boxLabel: gettext('Spice Port'),
+		    submitValue: false,
+		    checked: true,
+		},
+		{
+		    name: 'usb',
+		    inputValue: 'mapped',
+		    boxLabel: gettext('Use mapped Device'),
+		    reference: 'mapped',
+		    submitValue: false,
+		},
+		{
+		    xtype: 'pveUSBMapSelector',
+		    disabled: true,
+		    name: 'mapped',
+		    cbind: { nodename: '{nodename}' },
+		    bind: { disabled: '{!mapped.checked}' },
+		    allowBlank: false,
+		    fieldLabel: gettext('Choose Device'),
+		    labelAlign: 'right',
+		},
+		{
+		    name: 'usb',
+		    inputValue: 'hostdevice',
+		    boxLabel: gettext('Use USB Vendor/Device ID'),
+		    reference: 'hostdevice',
+		    submitValue: false,
+		},
+		{
+		    xtype: 'pveUSBSelector',
+		    disabled: true,
+		    type: 'device',
+		    name: 'hostdevice',
+		    cbind: { pveSelNode: '{pveSelNode}' },
+		    bind: { disabled: '{!hostdevice.checked}' },
+		    editable: true,
+		    allowBlank: false,
+		    fieldLabel: gettext('Choose Device'),
+		    labelAlign: 'right',
+		},
+		{
+		    name: 'usb',
+		    inputValue: 'port',
+		    boxLabel: gettext('Use USB Port'),
+		    reference: 'port',
+		    submitValue: false,
+		},
+		{
+		    xtype: 'pveUSBSelector',
+		    disabled: true,
+		    name: 'port',
+		    cbind: { pveSelNode: '{pveSelNode}' },
+		    bind: { disabled: '{!port.checked}' },
+		    editable: true,
+		    type: 'port',
+		    allowBlank: false,
+		    fieldLabel: gettext('Choose Port'),
+		    labelAlign: 'right',
+		},
+		{
+		    xtype: 'checkbox',
+		    name: 'usb3',
+		    inputValue: true,
+		    checked: true,
+		    reference: 'usb3',
+		    fieldLabel: gettext('Use USB3'),
+		},
+	    ],
+	},
+    ],
+});
+
+Ext.define('PVE.qemu.USBEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    vmconfig: undefined,
+
+    isAdd: true,
+    width: 400,
+    subject: gettext('USB Device'),
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.confid;
+
+	var ipanel = Ext.create('PVE.qemu.USBInputPanel', {
+	    confid: me.confid,
+	    pveSelNode: me.pveSelNode,
+	});
+
+	Ext.apply(me, {
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	me.load({
+	    success: function(response, options) {
+		ipanel.setVMConfig(response.result.data);
+		if (me.isCreate) {
+		    return;
+		}
+
+		let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'host');
+		let port, hostdevice, mapped, usb3 = false;
+		let usb;
+
+		if (data.host) {
+		    if (/^(0x)?[a-zA-Z0-9]{4}:(0x)?[a-zA-Z0-9]{4}$/.test(data.host)) {
+			hostdevice = data.host.replace('0x', '');
+			usb = 'hostdevice';
+		    } else if (/^(\d+)-(\d+(\.\d+)*)$/.test(data.host)) {
+			port = data.host;
+			usb = 'port';
+		    } else if (/^spice$/i.test(data.host)) {
+			usb = 'spice';
+		    }
+		} else if (data.mapping) {
+		    mapped = data.mapping;
+		    usb = 'mapped';
+		}
+
+		usb3 = data.usb3 ?? false;
+
+		var values = {
+		    usb,
+		    hostdevice,
+		    port,
+		    usb3,
+		    mapped,
+		};
+
+		ipanel.setValues(values);
+	    },
+	});
+    },
+});
+Ext.define('PVE.sdn.Browser', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.PVE.sdn.Browser',
+
+    onlineHelp: 'chapter_pvesdn',
+
+    initComponent: function() {
+	let me = this;
+
+	let nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+	let sdnId = me.pveSelNode.data.sdn;
+	if (!sdnId) {
+	    throw "no sdn ID specified";
+	}
+
+	me.items = [];
+
+	Ext.apply(me, {
+	    title: Ext.String.format(gettext("Zone {0} on node {1}"), `'${sdnId}'`, `'${nodename}'`),
+	    hstateid: 'sdntab',
+	});
+
+	const caps = Ext.state.Manager.get('GuiCap');
+
+	me.items.push({
+	    nodename: nodename,
+	    zone: sdnId,
+	    xtype: 'pveSDNZoneContentPanel',
+	    title: gettext('Content'),
+	    iconCls: 'fa fa-th',
+	    itemId: 'content',
+	});
+
+	if (caps.sdn['Permissions.Modify']) {
+	    me.items.push({
+		xtype: 'pveACLView',
+		title: gettext('Permissions'),
+		iconCls: 'fa fa-unlock',
+		itemId: 'permissions',
+		path: `/sdn/zones/${sdnId}`,
+	    });
+	}
+
+	me.callParent();
+   },
+});
+Ext.define('PVE.sdn.ControllerView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveSDNControllerView'],
+
+    onlineHelp: 'pvesdn_config_controllers',
+
+    stateful: true,
+    stateId: 'grid-sdn-controller',
+
+    createSDNControllerEditWindow: function(type, sid) {
+	var schema = PVE.Utils.sdncontrollerSchema[type];
+	if (!schema || !schema.ipanel) {
+	    throw "no editor registered for controller type: " + type;
+	}
+
+	Ext.create('PVE.sdn.controllers.BaseEdit', {
+	    paneltype: 'PVE.sdn.controllers.' + schema.ipanel,
+	    type: type,
+	    controllerid: sid,
+	    autoShow: true,
+	    listeners: {
+		destroy: this.reloadStore,
+	    },
+	});
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	var store = new Ext.data.Store({
+	    model: 'pve-sdn-controller',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/sdn/controllers?pending=1",
+	    },
+	    sorters: {
+		property: 'controller',
+		direction: 'ASC',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    let type = rec.data.type, controller = rec.data.controller;
+	    me.createSDNControllerEditWindow(type, controller);
+	};
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/sdn/controllers/',
+	    callback: () => store.load(),
+	});
+
+	// else we cannot dynamically generate the add menu handlers
+	let addHandleGenerator = function(type) {
+	    return function() { me.createSDNControllerEditWindow(type); };
+	};
+	let addMenuItems = [];
+	for (const [type, controller] of Object.entries(PVE.Utils.sdncontrollerSchema)) {
+	    if (controller.hideAdd) {
+		continue;
+	    }
+	    addMenuItems.push({
+		text: PVE.Utils.format_sdncontroller_type(type),
+		iconCls: 'fa fa-fw fa-' + controller.faIcon,
+		handler: addHandleGenerator(type),
+	    });
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: () => store.load(),
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: addMenuItems,
+		    }),
+		},
+		remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    flex: 2,
+		    sortable: true,
+		    dataIndex: 'controller',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'controller', 1);
+		    },
+		},
+		{
+		    header: gettext('Type'),
+		    flex: 1,
+		    sortable: true,
+		    dataIndex: 'type',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'type', 1);
+		    },
+		},
+		{
+		    header: gettext('Node'),
+		    flex: 1,
+		    sortable: true,
+		    dataIndex: 'node',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'node', 1);
+		    },
+		},
+		{
+		    header: gettext('State'),
+		    width: 100,
+		    dataIndex: 'state',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending_state(rec, value);
+		    },
+		},
+	    ],
+	    listeners: {
+		activate: () => store.load(),
+		itemdblclick: run_editor,
+	    },
+	});
+	store.load();
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.Status', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNStatus',
+
+    onlineHelp: 'chapter_pvesdn',
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.rstore = Ext.create('Proxmox.data.ObjectStore', {
+	    interval: me.interval,
+	    model: 'pve-sdn-status',
+	    storeid: 'pve-store-' + ++Ext.idSeed,
+	    groupField: 'type',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/cluster/resources',
+	    },
+	});
+
+	me.items = [{
+	    xtype: 'pveSDNStatusView',
+	    title: gettext('Status'),
+	    rstore: me.rstore,
+	    border: 0,
+	    collapsible: true,
+	    padding: '0 0 20 0',
+	}];
+
+	me.callParent();
+	me.on('activate', me.rstore.startUpdate);
+    },
+});
+Ext.define('PVE.sdn.StatusView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveSDNStatusView',
+
+    sortPriority: {
+	sdn: 1,
+	node: 2,
+	status: 3,
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.rstore) {
+	    throw "no rstore given";
+	}
+
+	Proxmox.Utils.monStoreErrors(me, me.rstore);
+
+	var store = Ext.create('Proxmox.data.DiffStore', {
+	    rstore: me.rstore,
+	    sortAfterUpdate: true,
+	    sorters: [{
+		sorterFn: function(rec1, rec2) {
+		    var p1 = me.sortPriority[rec1.data.type];
+		    var p2 = me.sortPriority[rec2.data.type];
+		    return p1 !== p2 ? p1 > p2 ? 1 : -1 : 0;
+		},
+	    }],
+	    filters: {
+		property: 'type',
+		value: 'sdn',
+		operator: '==',
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	    stateful: false,
+	    tbar: [
+		{
+		    text: gettext('Apply'),
+		    handler: function() {
+			Proxmox.Utils.API2Request({
+			    url: '/cluster/sdn/',
+			    method: 'PUT',
+			    waitMsgTarget: me,
+			    failure: function(response, opts) {
+				Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			    },
+			});
+		    },
+		},
+	    ],
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: [
+		{
+		    header: 'SDN',
+		    width: 80,
+		    dataIndex: 'sdn',
+		},
+		{
+		    header: gettext('Node'),
+		    width: 80,
+		    dataIndex: 'node',
+		},
+		{
+		    header: gettext('Status'),
+		    width: 80,
+		    flex: 1,
+		    dataIndex: 'status',
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	me.on('activate', me.rstore.startUpdate);
+	me.on('destroy', me.rstore.stopUpdate);
+    },
+}, function() {
+    Ext.define('pve-sdn-status', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'id', 'type', 'node', 'status', 'sdn',
+	],
+	idProperty: 'id',
+    });
+});
+Ext.define('PVE.sdn.VnetInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (me.isCreate) {
+	    values.type = 'vnet';
+	}
+
+	return values;
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'vnet',
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+	    maxLength: 8,
+	    flex: 1,
+	    allowBlank: false,
+	    fieldLabel: gettext('Name'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'alias',
+	    fieldLabel: gettext('Alias'),
+	    allowBlank: true,
+	    skipEmptyText: true,
+	    cbind: {
+		deleteEmpty: "{!isCreate}",
+	    },
+	},
+	{
+	    xtype: 'pveSDNZoneSelector',
+	    fieldLabel: gettext('Zone'),
+	    name: 'zone',
+	    value: '',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'tag',
+	    minValue: 1,
+	    maxValue: 16777216,
+	    fieldLabel: gettext('Tag'),
+	    allowBlank: true,
+	    cbind: {
+		deleteEmpty: "{!isCreate}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'vlanaware',
+	    uncheckedValue: null,
+	    checked: false,
+	    fieldLabel: gettext('VLAN Aware'),
+	    cbind: {
+		deleteEmpty: "{!isCreate}",
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.sdn.VnetEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: gettext('VNet'),
+
+    vnet: undefined,
+
+    width: 350,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = me.vnet === undefined;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/cluster/sdn/vnets';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/cluster/sdn/vnets/' + me.vnet;
+	    me.method = 'PUT';
+	}
+
+	let ipanel = Ext.create('PVE.sdn.VnetInputPanel', {
+	    isCreate: me.isCreate,
+	});
+
+	Ext.apply(me, {
+	    items: [
+		ipanel,
+	    ],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    let values = response.result.data;
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.sdn.VnetView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveSDNVnetView',
+
+    onlineHelp: 'pvesdn_config_vnet',
+
+    stateful: true,
+    stateId: 'grid-sdn-vnet',
+
+    subnetview_panel: undefined,
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-sdn-vnet',
+	    proxy: {
+                type: 'proxmox',
+		url: "/api2/json/cluster/sdn/vnets?pending=1",
+	    },
+	    sorters: {
+		property: 'vnet',
+		direction: 'ASC',
+	    },
+	});
+
+	let reload = () => store.load();
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+        let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+
+	    let win = Ext.create('PVE.sdn.VnetEdit', {
+		autoShow: true,
+		onlineHelp: 'pvesdn_config_vnet',
+		vnet: rec.data.vnet,
+	    });
+	    win.on('destroy', reload);
+        };
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/sdn/vnets/',
+	    callback: reload,
+	});
+
+	let set_button_status = function() {
+	    var rec = me.selModel.getSelection()[0];
+
+	    if (!rec || rec.data.state === 'deleted') {
+		edit_btn.disable();
+		remove_btn.disable();
+	    }
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: reload,
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Create'),
+		    handler: function() {
+			let win = Ext.create('PVE.sdn.VnetEdit', {
+			    autoShow: true,
+			    onlineHelp: 'pvesdn_config_vnet',
+			    type: 'vnet',
+			});
+			win.on('destroy', reload);
+		    },
+		},
+		remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    flex: 2,
+		    dataIndex: 'vnet',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'vnet', 1);
+		    },
+		},
+		{
+		    header: gettext('Alias'),
+		    flex: 1,
+		    dataIndex: 'alias',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'alias');
+		    },
+		},
+		{
+		    header: gettext('Zone'),
+		    flex: 1,
+		    dataIndex: 'zone',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'zone');
+		    },
+		},
+		{
+		    header: gettext('Tag'),
+		    flex: 1,
+		    dataIndex: 'tag',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'tag');
+		    },
+		},
+		{
+		    header: gettext('VLAN Aware'),
+		    flex: 1,
+		    dataIndex: 'vlanaware',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'vlanaware');
+		    },
+		},
+		{
+		    header: gettext('State'),
+		    width: 100,
+		    dataIndex: 'state',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending_state(rec, value);
+		    },
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+		selectionchange: set_button_status,
+		show: reload,
+		select: function(_sm, rec) {
+		    let url = `/cluster/sdn/vnets/${rec.data.vnet}/subnets`;
+		    me.subnetview_panel.setBaseUrl(url);
+		},
+		deselect: function() {
+		    me.subnetview_panel.setBaseUrl(undefined);
+		},
+	    },
+	});
+	store.load();
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.VnetACLAdd', {
+    extend: 'Proxmox.window.Edit',
+    alias: ['widget.pveSDNVnetACLAdd'],
+
+    url: '/access/acl',
+    method: 'PUT',
+    isAdd: true,
+    isCreate: true,
+
+    width: 400,
+    initComponent: function() {
+        let me = this;
+
+	let items = [
+	    {
+		xtype: 'hiddenfield',
+		name: 'path',
+		value: me.path,
+		allowBlank: false,
+		fieldLabel: gettext('Path'),
+	    },
+	];
+
+	if (me.aclType === 'group') {
+	    me.subject = gettext("Group Permission");
+	    items.push({
+		xtype: 'pveGroupSelector',
+		name: 'groups',
+		fieldLabel: gettext('Group'),
+	    });
+	} else if (me.aclType === 'user') {
+	    me.subject = gettext("User Permission");
+	    items.push({
+		xtype: 'pmxUserSelector',
+		name: 'users',
+		fieldLabel: gettext('User'),
+	    });
+	} else if (me.aclType === 'token') {
+	    me.subject = gettext("API Token Permission");
+	    items.push({
+		xtype: 'pveTokenSelector',
+		name: 'tokens',
+		fieldLabel: gettext('API Token'),
+	    });
+	} else {
+	    throw "unknown ACL type";
+	}
+
+	items.push({
+	    xtype: 'pmxRoleSelector',
+	    name: 'roles',
+	    value: 'NoAccess',
+	    fieldLabel: gettext('Role'),
+	});
+
+	items.push({
+	    xtype: 'proxmoxintegerfield',
+	    name: 'vlan',
+	    minValue: 1,
+	    maxValue: 4096,
+            allowBlank: true,
+	    fieldLabel: 'VLAN',
+	    emptyText: gettext('All'),
+	});
+
+	let ipanel = Ext.create('Proxmox.panel.InputPanel', {
+	    items: items,
+	    onlineHelp: 'pveum_permission_management',
+	    onGetValues: function(values) {
+		if (values.vlan) {
+		    values.path = values.path + "/" + values.vlan;
+		    delete values.vlan;
+		}
+		return values;
+	    },
+	});
+
+	Ext.apply(me, {
+	    items: [ipanel],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.VnetACLView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: ['widget.pveSDNVnetACLView'],
+
+    onlineHelp: 'chapter_user_management',
+
+    stateful: true,
+    stateId: 'grid-acls',
+
+    // use fixed path
+    path: undefined,
+
+    setPath: function(path) {
+        let me = this;
+
+        me.path = path;
+
+        if (path === undefined) {
+	    me.down('#groupmenu').setDisabled(true);
+	    me.down('#usermenu').setDisabled(true);
+	    me.down('#tokenmenu').setDisabled(true);
+        } else {
+	    me.down('#groupmenu').setDisabled(false);
+	    me.down('#usermenu').setDisabled(false);
+	    me.down('#tokenmenu').setDisabled(false);
+            me.store.load();
+        }
+    },
+    initComponent: function() {
+	let me = this;
+
+	let store = Ext.create('Ext.data.Store', {
+	    model: 'pve-acl',
+	    proxy: {
+                type: 'proxmox',
+		url: "/api2/json/access/acl",
+	    },
+	    sorters: {
+		property: 'path',
+		direction: 'ASC',
+	    },
+	});
+
+	store.addFilter(Ext.create('Ext.util.Filter', {
+	    filterFn: item => item.data.path.replace(/(\/sdn\/zones\/(.*)\/(.*))\/[0-9]*$/, '$1') === me.path,
+	}));
+
+	let render_ugid = function(ugid, metaData, record) {
+	    if (record.data.type === 'group') {
+		return '@' + ugid;
+	    }
+
+	    return Ext.String.htmlEncode(ugid);
+	};
+
+	let render_vlan = function(path, metaData, record) {
+	    let vlan = 'any';
+	    const match = path.match(/(\/sdn\/zones\/)(.*)\/(.*)\/([0-9]*)$/);
+	    if (match) {
+		vlan = match[4];
+	    }
+
+	    return Ext.String.htmlEncode(vlan);
+	};
+
+	let columns = [
+	    {
+		header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'),
+		flex: 1,
+		sortable: true,
+		renderer: render_ugid,
+		dataIndex: 'ugid',
+	    },
+	    {
+		header: gettext('Role'),
+		flex: 1,
+		sortable: true,
+		dataIndex: 'roleid',
+	    },
+	    {
+		header: gettext('VLAN'),
+		flex: 1,
+		sortable: true,
+		renderer: render_vlan,
+		dataIndex: 'path',
+	    },
+	];
+
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let remove_btn = new Proxmox.button.Button({
+	    text: gettext('Remove'),
+	    disabled: true,
+	    selModel: sm,
+	    confirmMsg: gettext('Are you sure you want to remove this entry'),
+	    handler: function(btn, event, rec) {
+		var params = {
+		    'delete': 1,
+		    path: rec.data.path,
+		    roles: rec.data.roleid,
+		};
+		if (rec.data.type === 'group') {
+		    params.groups = rec.data.ugid;
+		} else if (rec.data.type === 'user') {
+		    params.users = rec.data.ugid;
+		} else if (rec.data.type === 'token') {
+		    params.tokens = rec.data.ugid;
+		} else {
+		    throw 'unknown data type';
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: '/access/acl',
+		    params: params,
+		    method: 'PUT',
+		    waitMsgTarget: me,
+		    callback: () => store.load(),
+		    failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+		});
+	    },
+	});
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: {
+			xtype: 'menu',
+			items: [
+			    {
+				text: gettext('Group Permission'),
+				disabled: !me.path,
+				itemId: 'groupmenu',
+				iconCls: 'fa fa-fw fa-group',
+				handler: function() {
+				    var win = Ext.create('PVE.sdn.VnetACLAdd', {
+					aclType: 'group',
+					path: me.path,
+				    });
+				    win.on('destroy', () => store.load());
+				    win.show();
+				},
+			    },
+			    {
+				text: gettext('User Permission'),
+				disabled: !me.path,
+				itemId: 'usermenu',
+				iconCls: 'fa fa-fw fa-user',
+				handler: function() {
+				    var win = Ext.create('PVE.sdn.VnetACLAdd', {
+					aclType: 'user',
+					path: me.path,
+				    });
+				    win.on('destroy', () => store.load());
+				    win.show();
+				},
+			    },
+			    {
+				text: gettext('API Token Permission'),
+				disabled: !me.path,
+				itemId: 'tokenmenu',
+				iconCls: 'fa fa-fw fa-user-o',
+				handler: function() {
+				    let win = Ext.create('PVE.sdn.VnetACLAdd', {
+					aclType: 'token',
+					path: me.path,
+				    });
+				    win.on('destroy', () => store.load());
+				    win.show();
+				},
+			    },
+			],
+		    },
+		},
+		remove_btn,
+	    ],
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    columns: columns,
+	    listeners: {
+	    },
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-acl-vnet', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'path', 'type', 'ugid', 'roleid',
+	    {
+		name: 'propagate',
+		type: 'boolean',
+	    },
+	],
+    });
+});
+Ext.define('PVE.sdn.Vnet', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNVnet',
+
+    title: 'VNet',
+
+    onlineHelp: 'pvesdn_config_vnet',
+
+    initComponent: function() {
+	var me = this;
+
+	var subnetview_panel = Ext.createWidget('pveSDNSubnetView', {
+	    title: gettext('Subnets'),
+	    region: 'center',
+	    border: false,
+	});
+
+	var vnetview_panel = Ext.createWidget('pveSDNVnetView', {
+	    title: 'VNets',
+	    region: 'west',
+	    subnetview_panel: subnetview_panel,
+	    width: '50%',
+	    border: false,
+	    split: true,
+	});
+
+	Ext.apply(me, {
+	    layout: 'border',
+	    items: [vnetview_panel, subnetview_panel],
+	    listeners: {
+		show: function() {
+		    subnetview_panel.fireEvent('show', subnetview_panel);
+		},
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.SubnetInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (me.isCreate) {
+	    values.type = 'subnet';
+	    values.subnet = values.cidr;
+	    delete values.cidr;
+	}
+
+	return values;
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'cidr',
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+	    flex: 1,
+	    allowBlank: false,
+	    fieldLabel: gettext('Subnet'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'gateway',
+	    vtype: 'IP64Address',
+	    fieldLabel: gettext('Gateway'),
+	    allowBlank: true,
+	    skipEmptyText: true,
+	    cbind: {
+		deleteEmpty: "{!isCreate}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'snat',
+	    uncheckedValue: null,
+	    checked: false,
+	    fieldLabel: 'SNAT',
+	    cbind: {
+		deleteEmpty: "{!isCreate}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'dnszoneprefix',
+	    skipEmptyText: true,
+	    fieldLabel: gettext('DNS Zone Prefix'),
+	    allowBlank: true,
+	    cbind: {
+		deleteEmpty: "{!isCreate}",
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
+    extend: 'Ext.form.FieldContainer',
+    mixins: ['Ext.form.field.Field'],
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+	me.initField();
+    },
+
+    // since value is an array of objects we need to override isEquals here
+    isEqual: function(value1, value2) {
+	return JSON.stringify(value1) === JSON.stringify(value2);
+    },
+
+    getValue: function() {
+	let me = this;
+	let store = me.lookup('grid').getStore();
+
+	let value = [];
+
+	store.getData()
+	    .each((item) => {
+		// needs a deep copy otherwise we run in to ExtJS reference
+		// shenaningans
+		value.push({
+		    'start-address': item.data['start-address'],
+		    'end-address': item.data['end-address'],
+		});
+	    });
+
+	return value;
+    },
+
+    getSubmitData: function() {
+	let me = this;
+
+	let data = {};
+
+	let value = me.getValue()
+	    .map((item) => `start-address=${item['start-address']},end-address=${item['end-address']}`);
+
+	if (value.length) {
+	    data[me.getName()] = value;
+	} else if (!me.isCreate) {
+	    data.delete = me.getName();
+	}
+
+	return data;
+    },
+
+    setValue: function(dhcpRanges) {
+	let me = this;
+	let store = me.lookup('grid').getStore();
+
+	let data = [];
+
+	dhcpRanges.forEach((item) => {
+	    // needs a deep copy otherwise we run in to ExtJS reference
+	    // shenaningans
+	    data.push({
+		'start-address': item['start-address'],
+		'end-address': item['end-address'],
+	    });
+	});
+
+	store.setData(data);
+    },
+
+    getErrors: function() {
+	let me = this;
+        let errors = [];
+
+	return errors;
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addRange: function() {
+	    let me = this;
+	    me.lookup('grid').getStore().add({});
+
+	    me.getView().checkChange();
+	},
+
+	removeRange: function(field) {
+	    let me = this;
+	    let record = field.getWidgetRecord();
+
+	    me.lookup('grid').getStore().remove(record);
+
+	    me.getView().checkChange();
+	},
+
+	onValueChange: function(field, value) {
+	    let me = this;
+	    let record = field.getWidgetRecord();
+	    let column = field.getWidgetColumn();
+
+	    record.set(column.dataIndex, value);
+	    record.commit();
+
+	    me.getView().checkChange();
+	},
+
+	control: {
+	    'grid button': {
+		click: 'removeRange',
+	    },
+	    'field': {
+		change: 'onValueChange',
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'grid',
+	    reference: 'grid',
+	    scrollable: true,
+	    store: {
+		fields: ['start-address', 'end-address'],
+	    },
+	    columns: [
+		{
+		    text: gettext('Start Address'),
+		    xtype: 'widgetcolumn',
+		    dataIndex: 'start-address',
+		    flex: 1,
+		    widget: {
+			xtype: 'textfield',
+			vtype: 'IP64Address',
+		    },
+		},
+		{
+		    text: gettext('End Address'),
+		    xtype: 'widgetcolumn',
+		    dataIndex: 'end-address',
+		    flex: 1,
+		    widget: {
+			xtype: 'textfield',
+			vtype: 'IP64Address',
+		    },
+		},
+		{
+		    xtype: 'widgetcolumn',
+		    width: 40,
+		    widget: {
+			xtype: 'button',
+			iconCls: 'fa fa-trash-o',
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'container',
+	    layout: {
+		type: 'hbox',
+	    },
+	    items: [
+		{
+		    xtype: 'button',
+		    text: gettext('Add'),
+		    iconCls: 'fa fa-plus-circle',
+		    handler: 'addRange',
+		},
+	    ],
+	},
+    ],
+});
+
+Ext.define('PVE.sdn.SubnetEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: gettext('Subnet'),
+
+    subnet: undefined,
+
+    width: 350,
+
+    base_url: undefined,
+
+    bodyPadding: 0,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = me.subnet === undefined;
+
+	if (me.isCreate) {
+	    me.url = me.base_url;
+	    me.method = 'POST';
+	} else {
+	    me.url = me.base_url + '/' + me.subnet;
+	    me.method = 'PUT';
+	}
+
+	let ipanel = Ext.create('PVE.sdn.SubnetInputPanel', {
+	    isCreate: me.isCreate,
+	    title: gettext('General'),
+	});
+
+	let dhcpPanel = Ext.create('PVE.sdn.SubnetDhcpRangePanel', {
+	    isCreate: me.isCreate,
+	    title: gettext('DHCP Ranges'),
+	    name: 'dhcp-range',
+	});
+
+	Ext.apply(me, {
+	    items: [
+		{
+		    xtype: 'tabpanel',
+		    bodyPadding: 10,
+		    items: [ipanel, dhcpPanel],
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    me.setValues(response.result.data);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.sdn.SubnetView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveSDNSubnetView',
+
+    stateful: true,
+    stateId: 'grid-sdn-subnet',
+
+    base_url: undefined,
+
+    remove_btn: undefined,
+
+    setBaseUrl: function(url) {
+	let me = this;
+
+	me.base_url = url;
+
+	if (url === undefined) {
+	    me.store.removeAll();
+	    me.create_btn.disable();
+	} else {
+	    me.remove_btn.baseurl = url + '/';
+	    me.store.setProxy({
+		type: 'proxmox',
+		url: '/api2/json/' + url + '?pending=1',
+	    });
+	    me.create_btn.enable();
+	    me.store.load();
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-sdn-subnet',
+	});
+
+	let reload = function() {
+	    store.load();
+	};
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+        let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+
+	    let win = Ext.create('PVE.sdn.SubnetEdit', {
+		autoShow: true,
+		subnet: rec.data.subnet,
+		base_url: me.base_url,
+	    });
+	    win.on('destroy', reload);
+        };
+
+	me.create_btn = new Proxmox.button.Button({
+	    text: gettext('Create'),
+	    disabled: true,
+	    handler: function() {
+		let win = Ext.create('PVE.sdn.SubnetEdit', {
+		    autoShow: true,
+		    base_url: me.base_url,
+		    type: 'subnet',
+		});
+		win.on('destroy', reload);
+	    },
+	});
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	me.remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: me.base_url + '/',
+	    callback: () => store.load(),
+	});
+
+	let set_button_status = function() {
+	    var rec = me.selModel.getSelection()[0];
+
+	    if (!rec || rec.data.state === 'deleted') {
+		edit_btn.disable();
+		me.remove_btn.disable();
+	    }
+	};
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: reload,
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		me.create_btn,
+		me.remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: gettext('Subnet'),
+		    flex: 2,
+		    dataIndex: 'cidr',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'cidr', 1);
+		    },
+		},
+		{
+		    header: gettext('Gateway'),
+		    flex: 1,
+		    dataIndex: 'gateway',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'gateway');
+		    },
+		},
+		{
+		    header: 'SNAT',
+		    flex: 1,
+		    dataIndex: 'snat',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'snat');
+		    },
+		},
+		{
+		    header: gettext('DNS Prefix'),
+		    flex: 1,
+		    dataIndex: 'dnszoneprefix',
+                    renderer: function(value, metaData, rec) {
+                        return PVE.Utils.render_sdn_pending(rec, value, 'dnszoneprefix');
+		    },
+                },
+		{
+		    header: gettext('State'),
+		    width: 100,
+		    dataIndex: 'state',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending_state(rec, value);
+		    },
+		},
+
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+
+        if (me.base_url) {
+            me.setBaseUrl(me.base_url); // load
+        }
+    },
+}, function() {
+    Ext.define('pve-sdn-subnet', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'cidr',
+	    'gateway',
+	    'snat',
+	],
+	idProperty: 'subnet',
+    });
+});
+Ext.define('PVE.sdn.ZoneContentView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveSDNZoneContentView',
+
+    stateful: true,
+    stateId: 'grid-sdnzone-content',
+    viewConfig: {
+	trackOver: false,
+	loadMask: false,
+    },
+    features: [
+	{
+	    ftype: 'grouping',
+	    groupHeaderTpl: '{name} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})',
+	},
+    ],
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	if (!me.zone) {
+	    throw "no zone ID specified";
+	}
+
+	var baseurl = "/nodes/" + me.nodename + "/sdn/zones/" + me.zone + "/content";
+	if (me.zone === 'localnetwork') {
+	    baseurl = "/nodes/" + me.nodename + "/network?type=any_local_bridge";
+	}
+	var store = Ext.create('Ext.data.Store', {
+	    model: 'pve-sdnzone-content',
+	    groupField: 'content',
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json' + baseurl,
+	    },
+	    sorters: {
+		property: 'vnet',
+		direction: 'ASC',
+	    },
+	});
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var reload = function() {
+	    store.load();
+	};
+
+	Proxmox.Utils.monStoreErrors(me, store);
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+	    ],
+	    columns: [
+		{
+		    header: 'VNet',
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'vnet',
+		},
+		{
+		    header: 'Alias',
+		    width: 300,
+		    sortable: true,
+		    dataIndex: 'alias',
+		},
+		{
+		    header: gettext('Status'),
+		    width: 100,
+		    sortable: true,
+		    dataIndex: 'status',
+		},
+		{
+		    header: gettext('Details'),
+		    flex: 1,
+		    dataIndex: 'statusmsg',
+		},
+	    ],
+            listeners: {
+                activate: reload,
+                show: reload,
+                select: function(_sm, rec) {
+                    let path = `/sdn/zones/${me.zone}/${rec.data.vnet}`;
+                    me.permissions_panel.setPath(path);
+                },
+                deselect: function() {
+                    me.permissions_panel.setPath(undefined);
+                },
+            },
+	});
+	store.load();
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-sdnzone-content', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    {
+		name: 'iface',
+		convert: function(value, record) {
+		    //map local vmbr to vnet
+		    if (record.data.iface) {
+			record.data.vnet = record.data.iface;
+		    }
+		    return value;
+		},
+	    },
+	    {
+		name: 'comments',
+		convert: function(value, record) {
+		    //map local vmbr comments to vnet alias
+		    if (record.data.comments) {
+			record.data.alias = record.data.comments;
+		    }
+		    return value;
+		},
+	    },
+	    'vnet',
+	    'status',
+	    'statusmsg',
+	    {
+		name: 'text',
+		convert: function(value, record) {
+		    // check for volid, because if you click on a grouping header,
+		    // it calls convert (but with an empty volid)
+		    if (value || record.data.vnet === null) {
+			return value;
+		    }
+		    return PVE.Utils.format_sdnvnet_type(value, {}, record);
+		},
+	    },
+	],
+	idProperty: 'vnet',
+    });
+});
+Ext.define('PVE.sdn.ZoneContentPanel', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNZoneContentPanel',
+
+    title: 'VNet',
+
+    onlineHelp: 'pvesdn_config_vnet',
+
+    initComponent: function() {
+	var me = this;
+
+	var permissions_panel = Ext.createWidget('pveSDNVnetACLView', {
+	    title: gettext('VNet Permissions'),
+	    region: 'center',
+	    border: false,
+	});
+
+	var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', {
+	    title: 'VNets',
+	    region: 'west',
+	    permissions_panel: permissions_panel,
+	    nodename: me.nodename,
+	    zone: me.zone,
+	    width: '50%',
+	    border: false,
+	    split: true,
+	});
+
+	Ext.apply(me, {
+	    layout: 'border',
+	    items: [vnetview_panel, permissions_panel],
+	    listeners: {
+		show: function() {
+		    permissions_panel.fireEvent('show', permissions_panel);
+		},
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.ZoneView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveSDNZoneView'],
+
+    onlineHelp: 'pvesdn_config_zone',
+
+    stateful: true,
+    stateId: 'grid-sdn-zone',
+
+    createSDNEditWindow: function(type, sid) {
+	let schema = PVE.Utils.sdnzoneSchema[type];
+	if (!schema || !schema.ipanel) {
+	    throw "no editor registered for zone type: " + type;
+	}
+
+	Ext.create('PVE.sdn.zones.BaseEdit', {
+	    paneltype: 'PVE.sdn.zones.' + schema.ipanel,
+	    type: type,
+	    zone: sid,
+	    autoShow: true,
+	    listeners: {
+		destroy: this.reloadStore,
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-sdn-zone',
+	    proxy: {
+                type: 'proxmox',
+		url: "/api2/json/cluster/sdn/zones?pending=1",
+	    },
+	    sorters: {
+		property: 'zone',
+		direction: 'ASC',
+	    },
+	});
+
+	let reload = function() {
+	    store.load();
+	};
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    let type = rec.data.type,
+		zone = rec.data.zone;
+
+	    me.createSDNEditWindow(type, zone);
+	};
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/sdn/zones/',
+	    callback: reload,
+	});
+
+	let set_button_status = function() {
+	    var rec = me.selModel.getSelection()[0];
+
+	    if (!rec || rec.data.state === 'deleted') {
+		edit_btn.disable();
+		remove_btn.disable();
+	    }
+	};
+
+	// else we cannot dynamically generate the add menu handlers
+	let addHandleGenerator = function(type) {
+	    return function() { me.createSDNEditWindow(type); };
+	};
+	let addMenuItems = [];
+	for (const [type, zone] of Object.entries(PVE.Utils.sdnzoneSchema)) {
+	    if (zone.hideAdd) {
+		continue;
+	    }
+	    addMenuItems.push({
+		text: PVE.Utils.format_sdnzone_type(type),
+		iconCls: 'fa fa-fw fa-' + zone.faIcon,
+		handler: addHandleGenerator(type),
+	    });
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: reload,
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: addMenuItems,
+		    }),
+		},
+		remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    width: 100,
+		    dataIndex: 'zone',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'zone', 1);
+		    },
+		},
+		{
+		    header: gettext('Type'),
+		    width: 100,
+		    dataIndex: 'type',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'type', 1);
+		    },
+		},
+		{
+		    header: 'MTU',
+		    width: 50,
+		    dataIndex: 'mtu',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'mtu');
+		    },
+		},
+		{
+		    header: 'IPAM',
+		    flex: 3,
+		    dataIndex: 'ipam',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'ipam');
+		    },
+		},
+		{
+		    header: gettext('Domain'),
+		    flex: 3,
+		    dataIndex: 'dnszone',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'dnszone');
+		    },
+		},
+		{
+		    header: gettext('DNS'),
+		    flex: 3,
+		    dataIndex: 'dns',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'dns');
+		    },
+		},
+		{
+		    header: gettext('Reverse DNS'),
+		    flex: 3,
+		    dataIndex: 'reversedns',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'reversedns');
+		    },
+		},
+		{
+		    header: gettext('Nodes'),
+		    flex: 3,
+		    dataIndex: 'nodes',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending(rec, value, 'nodes');
+		    },
+		},
+		{
+		    header: gettext('State'),
+		    width: 100,
+		    dataIndex: 'state',
+		    renderer: function(value, metaData, rec) {
+			return PVE.Utils.render_sdn_pending_state(rec, value);
+		    },
+		},
+	    ],
+	    listeners: {
+		activate: reload,
+		itemdblclick: run_editor,
+		selectionchange: set_button_status,
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.IpamEditInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    isCreate: false,
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (!values.vmid) {
+	    delete values.vmid;
+	}
+
+	return values;
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'vmid',
+	    fieldLabel: 'VMID',
+	    allowBlank: false,
+	    editable: false,
+	    cbind: {
+		hidden: '{isCreate}',
+	    },
+	},
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'mac',
+	    fieldLabel: 'MAC',
+	    allowBlank: false,
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'ip',
+	    fieldLabel: gettext('IP Address'),
+	    allowBlank: false,
+	},
+    ],
+});
+
+Ext.define('PVE.sdn.IpamEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    subject: gettext('DHCP Mapping'),
+    width: 350,
+
+    isCreate: false,
+    mapping: {},
+
+    url: '/cluster/sdn/vnets',
+
+    submitUrl: function(url, values) {
+	return `${url}/${values.vnet}/ips`;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.method = me.isCreate ? 'POST' : 'PUT';
+
+	let ipanel = Ext.create('PVE.sdn.IpamEditInputPanel', {
+	    isCreate: me.isCreate,
+	});
+
+	Ext.apply(me, {
+	    items: [
+		ipanel,
+	    ],
+	});
+
+	me.callParent();
+
+	ipanel.setValues(me.mapping);
+    },
+});
+Ext.define('PVE.sdn.Options', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNOptions',
+
+    title: 'Options',
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    onlineHelp: 'pvesdn_config_controllers',
+
+    items: [
+	{
+	    xtype: 'pveSDNControllerView',
+	    title: gettext('Controllers'),
+	    flex: 1,
+	    padding: '0 0 20 0',
+	    border: 0,
+	},
+	{
+	    xtype: 'pveSDNIpamView',
+	    title: 'IPAM',
+	    flex: 1,
+	    padding: '0 0 20 0',
+	    border: 0,
+	}, {
+	    xtype: 'pveSDNDnsView',
+	    title: 'DNS',
+	    flex: 1,
+	    border: 0,
+	},
+    ],
+});
+Ext.define('PVE.panel.SDNControllerBase', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    type: '',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.controller;
+	}
+
+	return values;
+    },
+});
+
+Ext.define('PVE.sdn.controllers.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.controllerid;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/cluster/sdn/controllers';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/cluster/sdn/controllers/' + me.controllerid;
+	    me.method = 'PUT';
+	}
+
+	var ipanel = Ext.create(me.paneltype, {
+	    type: me.type,
+	    isCreate: me.isCreate,
+	    controllerid: me.controllerid,
+	});
+
+	Ext.apply(me, {
+	    subject: PVE.Utils.format_sdncontroller_type(me.type),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    var ctypes = values.content || '';
+
+		    values.content = ctypes.split(',');
+
+		    if (values.nodes) {
+			values.nodes = values.nodes.split(',');
+		    }
+		    values.enable = values.disable ? 0 : 1;
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.sdn.controllers.EvpnInputPanel', {
+    extend: 'PVE.panel.SDNControllerBase',
+
+    onlineHelp: 'pvesdn_controller_plugin_evpn',
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'controller',
+		maxLength: 8,
+		value: me.controllerid || '',
+		fieldLabel: 'ID',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'asn',
+		minValue: 1,
+		maxValue: 4294967295,
+		value: 65000,
+		fieldLabel: 'ASN #',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'peers',
+		fieldLabel: gettext('Peers'),
+		allowBlank: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.controllers.BgpInputPanel', {
+    extend: 'PVE.panel.SDNControllerBase',
+
+    onlineHelp: 'pvesdn_controller_plugin_evpn',
+
+    onGetValues: function(values) {
+        var me = this;
+
+        if (me.isCreate) {
+            values.type = me.type;
+	    values.controller = 'bgp' + values.node;
+        } else {
+            delete values.controller;
+        }
+
+        return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'pveNodeSelector',
+		name: 'node',
+		fieldLabel: gettext('Node'),
+		multiSelect: false,
+		autoSelect: false,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'asn',
+		minValue: 1,
+		maxValue: 4294967295,
+		value: 65000,
+		fieldLabel: 'ASN #',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'peers',
+		fieldLabel: gettext('Peers'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'ebgp',
+		uncheckedValue: 0,
+		checked: false,
+		fieldLabel: 'EBGP',
+	    },
+
+	];
+
+	me.advancedItems = [
+
+	    {
+		xtype: 'textfield',
+		name: 'loopback',
+		fieldLabel: gettext('Loopback Interface'),
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'ebgp-multihop',
+		minValue: 1,
+		maxValue: 100,
+		fieldLabel: 'ebgp-multihop',
+		allowBlank: true,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'bgp-multipath-as-path-relax',
+		uncheckedValue: 0,
+		checked: false,
+		fieldLabel: 'bgp-multipath-as-path-relax',
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.controllers.IsisInputPanel', {
+    extend: 'PVE.panel.SDNControllerBase',
+
+    onlineHelp: 'pvesdn_controller_plugin_evpn',
+
+    onGetValues: function(values) {
+        var me = this;
+
+        if (me.isCreate) {
+            values.type = me.type;
+	    values.controller = 'isis' + values.node;
+        } else {
+            delete values.controller;
+        }
+
+        return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'pveNodeSelector',
+		name: 'node',
+		fieldLabel: gettext('Node'),
+		multiSelect: false,
+		autoSelect: false,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'isis-domain',
+		fieldLabel: 'Domain',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'isis-net',
+		fieldLabel: 'Network entity title',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'isis-ifaces',
+		fieldLabel: gettext('Interfaces'),
+		allowBlank: false,
+	    },
+	];
+
+	me.advancedItems = [
+	    {
+		xtype: 'textfield',
+		name: 'loopback',
+		fieldLabel: gettext('Loopback Interface'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.IpamView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveSDNIpamView'],
+
+    stateful: true,
+    stateId: 'grid-sdn-ipam',
+
+    createSDNEditWindow: function(type, sid) {
+	let schema = PVE.Utils.sdnipamSchema[type];
+	if (!schema || !schema.ipanel) {
+	    throw "no editor registered for ipam type: " + type;
+	}
+
+	Ext.create('PVE.sdn.ipams.BaseEdit', {
+	    paneltype: 'PVE.sdn.ipams.' + schema.ipanel,
+	    type: type,
+	    ipam: sid,
+	    autoShow: true,
+	    listeners: {
+		destroy: this.reloadStore,
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-sdn-ipam',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/sdn/ipams",
+	    },
+	    sorters: {
+		property: 'ipam',
+		direction: 'ASC',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    let type = rec.data.type, ipam = rec.data.ipam;
+	    me.createSDNEditWindow(type, ipam);
+	};
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/sdn/ipams/',
+	    callback: () => store.load(),
+	});
+
+	// else we cannot dynamically generate the add menu handlers
+	let addHandleGenerator = function(type) {
+	    return function() { me.createSDNEditWindow(type); };
+	};
+	let addMenuItems = [];
+	for (const [type, ipam] of Object.entries(PVE.Utils.sdnipamSchema)) {
+	    if (ipam.hideAdd) {
+		continue;
+	    }
+	    addMenuItems.push({
+		text: PVE.Utils.format_sdnipam_type(type),
+		iconCls: 'fa fa-fw fa-' + ipam.faIcon,
+		handler: addHandleGenerator(type),
+	    });
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: () => store.load(),
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: addMenuItems,
+		    }),
+		},
+		remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    flex: 2,
+		    dataIndex: 'ipam',
+		},
+		{
+		    header: gettext('Type'),
+		    flex: 1,
+		    dataIndex: 'type',
+		    renderer: PVE.Utils.format_sdnipam_type,
+		},
+		{
+		    header: 'url',
+		    flex: 1,
+		    dataIndex: 'url',
+		},
+	    ],
+	    listeners: {
+		activate: () => store.load(),
+		itemdblclick: run_editor,
+	    },
+	});
+
+	store.load();
+	me.callParent();
+    },
+});
+Ext.define('PVE.panel.SDNIpamBase', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    type: '',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.ipam;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.ipams.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.ipam;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/cluster/sdn/ipams';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/cluster/sdn/ipams/' + me.ipam;
+	    me.method = 'PUT';
+	}
+
+	var ipanel = Ext.create(me.paneltype, {
+	    type: me.type,
+	    isCreate: me.isCreate,
+	    ipam: me.ipam,
+	});
+
+	Ext.apply(me, {
+	    subject: PVE.Utils.format_sdnipam_type(me.type),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    var ctypes = values.content || '';
+
+		    values.content = ctypes.split(',');
+
+		    if (values.nodes) {
+			values.nodes = values.nodes.split(',');
+		    }
+		    values.enable = values.disable ? 0 : 1;
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.sdn.ipams.NetboxInputPanel', {
+    extend: 'PVE.panel.SDNIpamBase',
+
+    onlineHelp: 'pvesdn_ipam_plugin_netbox',
+
+    onGetValues: function(values) {
+        var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.ipam;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'ipam',
+		maxLength: 10,
+		value: me.zone || '',
+		fieldLabel: 'ID',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'url',
+		fieldLabel: gettext('URL'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'token',
+		fieldLabel: gettext('Token'),
+		allowBlank: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.ipams.PVEIpamInputPanel', {
+    extend: 'PVE.panel.SDNIpamBase',
+
+    onlineHelp: 'pvesdn_ipam_plugin_pveipam',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.ipam;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'ipam',
+		maxLength: 10,
+		value: me.zone || '',
+		fieldLabel: 'ID',
+		allowBlank: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.ipams.PhpIpamInputPanel', {
+    extend: 'PVE.panel.SDNIpamBase',
+
+    onlineHelp: 'pvesdn_ipam_plugin_phpipam',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.ipam;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'ipam',
+		maxLength: 10,
+		value: me.zone || '',
+		fieldLabel: 'ID',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'url',
+		fieldLabel: gettext('URL'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'token',
+		fieldLabel: gettext('Token'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'section',
+		fieldLabel: gettext('Section'),
+		allowBlank: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.DnsView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: ['widget.pveSDNDnsView'],
+
+    stateful: true,
+    stateId: 'grid-sdn-dns',
+
+    createSDNEditWindow: function(type, sid) {
+	let schema = PVE.Utils.sdndnsSchema[type];
+	if (!schema || !schema.ipanel) {
+	    throw "no editor registered for dns type: " + type;
+	}
+
+	Ext.create('PVE.sdn.dns.BaseEdit', {
+	    paneltype: 'PVE.sdn.dns.' + schema.ipanel,
+	    type: type,
+	    dns: sid,
+	    autoShow: true,
+	    listeners: {
+		destroy: this.reloadStore,
+	    },
+	});
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	let store = new Ext.data.Store({
+	    model: 'pve-sdn-dns',
+	    proxy: {
+		type: 'proxmox',
+		url: "/api2/json/cluster/sdn/dns",
+	    },
+		sorters: {
+		property: 'dns',
+		direction: 'ASC',
+	    },
+	});
+
+	let sm = Ext.create('Ext.selection.RowModel', {});
+
+	let run_editor = function() {
+	    let rec = sm.getSelection()[0];
+	    if (!rec) {
+		return;
+	    }
+	    let type = rec.data.type,
+		dns = rec.data.dns;
+
+	    me.createSDNEditWindow(type, dns);
+	};
+
+	let edit_btn = new Proxmox.button.Button({
+	    text: gettext('Edit'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: run_editor,
+	});
+
+	let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+	    selModel: sm,
+	    baseurl: '/cluster/sdn/dns/',
+	    callback: () => store.load(),
+	});
+
+	// else we cannot dynamically generate the add menu handlers
+	let addHandleGenerator = function(type) {
+	    return function() { me.createSDNEditWindow(type); };
+	};
+	let addMenuItems = [];
+	for (const [type, dns] of Object.entries(PVE.Utils.sdndnsSchema)) {
+	    if (dns.hideAdd) {
+		continue;
+	    }
+	    addMenuItems.push({
+		text: PVE.Utils.format_sdndns_type(type),
+		iconCls: 'fa fa-fw fa-' + dns.faIcon,
+		handler: addHandleGenerator(type),
+	    });
+	}
+
+	Ext.apply(me, {
+	    store: store,
+	    reloadStore: () => store.load(),
+	    selModel: sm,
+	    viewConfig: {
+		trackOver: false,
+	    },
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    menu: new Ext.menu.Menu({
+			items: addMenuItems,
+		    }),
+		},
+		remove_btn,
+		edit_btn,
+	    ],
+	    columns: [
+		{
+		    header: 'ID',
+		    flex: 2,
+		    dataIndex: 'dns',
+		},
+		{
+		    header: gettext('Type'),
+		    flex: 1,
+		    dataIndex: 'type',
+		    renderer: PVE.Utils.format_sdndns_type,
+		},
+		{
+		    header: 'url',
+		    flex: 1,
+		    dataIndex: 'url',
+		},
+	    ],
+	    listeners: {
+		activate: () => store.load(),
+		itemdblclick: run_editor,
+	    },
+	});
+
+	store.load();
+	me.callParent();
+    },
+});
+Ext.define('PVE.panel.SDNDnsBase', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    type: '',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.dns;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.dns.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.dns;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/cluster/sdn/dns';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/cluster/sdn/dns/' + me.dns;
+	    me.method = 'PUT';
+	}
+
+	var ipanel = Ext.create(me.paneltype, {
+	    type: me.type,
+	    isCreate: me.isCreate,
+	    dns: me.dns,
+	});
+
+	Ext.apply(me, {
+            subject: PVE.Utils.format_sdndns_type(me.type),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    var ctypes = values.content || '';
+
+		    values.content = ctypes.split(',');
+
+		    if (values.nodes) {
+			values.nodes = values.nodes.split(',');
+		    }
+		    values.enable = values.disable ? 0 : 1;
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.sdn.dns.PowerdnsInputPanel', {
+    extend: 'PVE.panel.SDNDnsBase',
+
+    onlineHelp: 'pvesdn_dns_plugin_powerdns',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.dns;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'dns',
+		maxLength: 10,
+		value: me.dns || '',
+		fieldLabel: 'ID',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'url',
+		fieldLabel: 'URL',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'key',
+		fieldLabel: gettext('API Key'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'ttl',
+		fieldLabel: 'TTL',
+		allowBlank: true,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.panel.SDNZoneBase', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    type: '',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.zone;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items.unshift({
+	    xtype: me.isCreate ? 'textfield' : 'displayfield',
+	    name: 'zone',
+	    maxLength: 8,
+	    value: me.zone || '',
+	    fieldLabel: 'ID',
+	    allowBlank: false,
+	});
+
+        me.items.push(
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'mtu',
+		minValue: 100,
+		maxValue: 65000,
+		fieldLabel: 'MTU',
+		allowBlank: true,
+		emptyText: 'auto',
+		deleteEmpty: !me.isCreate,
+	    },
+	    {
+		xtype: 'pveNodeSelector',
+		name: 'nodes',
+		fieldLabel: gettext('Nodes'),
+		emptyText: gettext('All') + ' (' + gettext('No restrictions') +')',
+		multiSelect: true,
+		autoSelect: false,
+	    },
+	    {
+		xtype: 'pveSDNIpamSelector',
+		fieldLabel: gettext('IPAM'),
+		name: 'ipam',
+		value: me.ipam || 'pve',
+		allowBlank: false,
+	    },
+	);
+
+	me.advancedItems = me.advancedItems ?? [];
+
+	me.advancedItems.unshift(
+	    {
+		xtype: 'pveSDNDnsSelector',
+		fieldLabel: gettext('DNS Server'),
+		name: 'dns',
+		value: '',
+		allowBlank: true,
+	    },
+	    {
+		xtype: 'pveSDNDnsSelector',
+		fieldLabel: gettext('Reverse DNS Server'),
+		name: 'reversedns',
+		value: '',
+		allowBlank: true,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'dnszone',
+		skipEmptyText: true,
+		fieldLabel: gettext('DNS Zone'),
+		allowBlank: true,
+		deleteEmpty: !me.isCreate,
+	    },
+	);
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.zones.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 400,
+
+    initComponent: function() {
+	var me = this;
+
+	me.isCreate = !me.zone;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/cluster/sdn/zones';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/cluster/sdn/zones/' + me.zone;
+	    me.method = 'PUT';
+	}
+
+	var ipanel = Ext.create(me.paneltype, {
+	    type: me.type,
+	    isCreate: me.isCreate,
+	    zone: me.zone,
+	});
+
+	Ext.apply(me, {
+	    subject: PVE.Utils.format_sdnzone_type(me.type),
+	    isAdd: true,
+	    items: [ipanel],
+	});
+
+	me.callParent();
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    var values = response.result.data;
+		    var ctypes = values.content || '';
+
+		    values.content = ctypes.split(',');
+
+		    if (values.nodes) {
+			values.nodes = values.nodes.split(',');
+		    }
+
+		    if (values.exitnodes) {
+			values.exitnodes = values.exitnodes.split(',');
+		    }
+
+		    values.enable = values.disable ? 0 : 1;
+
+		    ipanel.setValues(values);
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.sdn.zones.EvpnInputPanel', {
+    extend: 'PVE.panel.SDNZoneBase',
+
+    onlineHelp: 'pvesdn_zone_plugin_evpn',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'pveSDNControllerSelector',
+		fieldLabel: gettext('Controller'),
+		name: 'controller',
+		value: '',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'vrf-vxlan',
+		minValue: 1,
+		maxValue: 16000000,
+		fieldLabel: 'VRF-VXLAN Tag',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'mac',
+		fieldLabel: gettext('VNet MAC Address'),
+		vtype: 'MacAddress',
+		allowBlank: true,
+		emptyText: 'auto',
+		deleteEmpty: !me.isCreate,
+	    },
+	    {
+		xtype: 'pveNodeSelector',
+		name: 'exitnodes',
+		fieldLabel: gettext('Exit Nodes'),
+		multiSelect: true,
+		autoSelect: false,
+	    },
+	    {
+		xtype: 'pveNodeSelector',
+		name: 'exitnodes-primary',
+		fieldLabel: gettext('Primary Exit Node'),
+		multiSelect: false,
+		autoSelect: false,
+		skipEmptyText: true,
+		deleteEmpty: !me.isCreate,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'exitnodes-local-routing',
+		uncheckedValue: null,
+		checked: false,
+		fieldLabel: gettext('Exit Nodes Local Routing'),
+		deleteEmpty: !me.isCreate,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'advertise-subnets',
+		uncheckedValue: null,
+		checked: false,
+		fieldLabel: gettext('Advertise Subnets'),
+		deleteEmpty: !me.isCreate,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'disable-arp-nd-suppression',
+		uncheckedValue: null,
+		checked: false,
+		fieldLabel: gettext('Disable ARP-nd Suppression'),
+		deleteEmpty: !me.isCreate,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'rt-import',
+		fieldLabel: gettext('Route Target Import'),
+		allowBlank: true,
+		deleteEmpty: !me.isCreate,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.zones.QinQInputPanel', {
+    extend: 'PVE.panel.SDNZoneBase',
+
+    onlineHelp: 'pvesdn_zone_plugin_qinq',
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.sdn;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.items = [
+	    {
+		xtype: 'textfield',
+		name: 'bridge',
+		fieldLabel: 'Bridge',
+		allowBlank: false,
+		vtype: 'BridgeName',
+		minLength: 1,
+		maxLength: 10,
+	    },
+	    {
+		xtype: 'proxmoxintegerfield',
+		name: 'tag',
+		minValue: 0,
+		maxValue: 4096,
+		fieldLabel: gettext('Service VLAN'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxKVComboBox',
+		name: 'vlan-protocol',
+		fieldLabel: gettext('Service VLAN Protocol'),
+		allowBlank: true,
+		value: '802.1q',
+		comboItems: [
+		    ['802.1q', '802.1q'],
+		    ['802.1ad', '802.1ad'],
+		],
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.zones.SimpleInputPanel', {
+    extend: 'PVE.panel.SDNZoneBase',
+
+    onlineHelp: 'pvesdn_zone_plugin_simple',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.zone;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+        me.items = [];
+	me.advancedItems = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'dhcp',
+		inputValue: 'dnsmasq',
+		uncheckedValue: null,
+		checked: false,
+		fieldLabel: gettext('automatic DHCP'),
+		deleteEmpty: !me.isCreate,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.zones.VlanInputPanel', {
+    extend: 'PVE.panel.SDNZoneBase',
+
+    onlineHelp: 'pvesdn_zone_plugin_vlan',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.zone;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+        me.items = [
+          {
+	    xtype: 'textfield',
+	    name: 'bridge',
+	    fieldLabel: 'Bridge',
+	    allowBlank: false,
+	    vtype: 'BridgeName',
+	    minLength: 1,
+	    maxLength: 10,
+          },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.sdn.zones.VxlanInputPanel', {
+    extend: 'PVE.panel.SDNZoneBase',
+
+    onlineHelp: 'pvesdn_zone_plugin_vxlan',
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.zone;
+	}
+
+	delete values.mode;
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.items = [
+	    {
+		xtype: 'textfield',
+		name: 'peers',
+		fieldLabel: gettext('Peer Address List'),
+		allowBlank: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.ContentView', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: 'widget.pveStorageContentView',
+
+    itemdblclick: Ext.emptyFn,
+
+    viewConfig: {
+	trackOver: false,
+	loadMask: false,
+    },
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    me.nodename = me.pveSelNode.data.node;
+	    if (!me.nodename) {
+		throw "no node name specified";
+	    }
+	}
+	const nodename = me.nodename;
+
+	if (!me.storage) {
+	    me.storage = me.pveSelNode.data.storage;
+	    if (!me.storage) {
+		throw "no storage ID specified";
+	    }
+	}
+	const storage = me.storage;
+
+	var content = me.content;
+	if (!content) {
+	    throw "no content type specified";
+	}
+
+	const baseurl = `/nodes/${nodename}/storage/${storage}/content`;
+	let store = me.store = Ext.create('Ext.data.Store', {
+	    model: 'pve-storage-content',
+	    proxy: {
+                type: 'proxmox',
+		url: '/api2/json' + baseurl,
+		extraParams: {
+		    content: content,
+		},
+	    },
+	    sorters: {
+		property: 'volid',
+		direction: 'ASC',
+	    },
+	});
+
+	if (!me.sm) {
+	    me.sm = Ext.create('Ext.selection.RowModel', {});
+	}
+	let sm = me.sm;
+
+	let reload = () => store.load();
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	let tbar = me.tbar ? [...me.tbar] : [];
+	if (me.useUploadButton) {
+	    tbar.unshift(
+		{
+		    xtype: 'button',
+		    text: gettext('Upload'),
+		    disabled: !me.enableUploadButton,
+		    handler: function() {
+			Ext.create('PVE.window.UploadToStorage', {
+			    nodename: nodename,
+			    storage: storage,
+			    content: content,
+			    autoShow: true,
+			    taskDone: () => reload(),
+			});
+		    },
+		},
+		{
+		    xtype: 'button',
+		    text: gettext('Download from URL'),
+		    disabled: !me.enableDownloadUrlButton,
+		    handler: function() {
+			Ext.create('PVE.window.DownloadUrlToStorage', {
+			    nodename: nodename,
+			    storage: storage,
+			    content: content,
+			    autoShow: true,
+			    taskDone: () => reload(),
+			});
+		    },
+		},
+		'-',
+	    );
+	}
+	if (!me.useCustomRemoveButton) {
+	    tbar.push({
+		xtype: 'proxmoxStdRemoveButton',
+		selModel: sm,
+		enableFn: rec => !rec?.data?.protected,
+		delay: 5,
+		callback: () => reload(),
+		baseurl: baseurl + '/',
+	    });
+	}
+	tbar.push(
+	    '->',
+	    gettext('Search') + ':',
+	    ' ',
+	    {
+		xtype: 'textfield',
+		width: 200,
+		enableKeyEvents: true,
+		emptyText: content === 'backup' ? gettext('Name, Format, Notes') : gettext('Name, Format'),
+		listeners: {
+		    keyup: {
+			buffer: 500,
+			fn: function(field) {
+			    let needle = field.getValue().toLocaleLowerCase();
+			    store.clearFilter(true);
+			    store.filter([
+				{
+				    filterFn: ({ data }) =>
+				        data.text?.toLocaleLowerCase().includes(needle) ||
+				        data.notes?.toLocaleLowerCase().includes(needle),
+				},
+			    ]);
+			},
+		    },
+		    change: function(field, newValue, oldValue) {
+			if (newValue !== this.originalValue) {
+			    this.triggers.clear.setVisible(true);
+			}
+		    },
+		},
+		triggers: {
+		    clear: {
+			cls: 'pmx-clear-trigger',
+			weight: -1,
+			hidden: true,
+			handler: function() {
+			    this.triggers.clear.setVisible(false);
+			    this.setValue(this.originalValue);
+			    store.clearFilter();
+			},
+		    },
+		},
+	    },
+	);
+
+	let availableColumns = {
+	    'name': {
+		header: gettext('Name'),
+		flex: 2,
+		sortable: true,
+		renderer: PVE.Utils.render_storage_content,
+		dataIndex: 'text',
+	    },
+	    'notes': {
+		header: gettext('Notes'),
+		flex: 1,
+		renderer: Ext.htmlEncode,
+		dataIndex: 'notes',
+	    },
+	    'protected': {
+		header: `<i class="fa fa-shield"></i>`,
+		tooltip: gettext('Protected'),
+		width: 30,
+		renderer: v => v ? `<i data-qtip="${gettext('Protected')}" class="fa fa-shield"></i>` : '',
+		sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0),
+		dataIndex: 'protected',
+	    },
+	    'date': {
+		header: gettext('Date'),
+		width: 150,
+		dataIndex: 'vdate',
+	    },
+	    'format': {
+		header: gettext('Format'),
+		width: 100,
+		dataIndex: 'format',
+	    },
+	    'size': {
+		header: gettext('Size'),
+		width: 100,
+		renderer: Proxmox.Utils.format_size,
+		dataIndex: 'size',
+	    },
+	};
+
+	let showColumns = me.showColumns || ['name', 'date', 'format', 'size'];
+
+	Object.keys(availableColumns).forEach(function(key) {
+	    if (!showColumns.includes(key)) {
+		delete availableColumns[key];
+	    }
+	});
+
+	if (me.extraColumns && typeof me.extraColumns === 'object') {
+	    Object.assign(availableColumns, me.extraColumns);
+	}
+	const columns = Object.values(availableColumns);
+
+	Ext.apply(me, {
+	    store,
+	    selModel: sm,
+	    tbar,
+	    columns,
+	    listeners: {
+		activate: reload,
+		itemdblclick: (view, record) => me.itemdblclick(view, record),
+	    },
+	});
+
+	me.callParent();
+    },
+}, function() {
+    Ext.define('pve-storage-content', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'volid', 'content', 'format', 'size', 'used', 'vmid',
+	    'channel', 'id', 'lun', 'notes', 'verification',
+	    {
+		name: 'text',
+		convert: function(value, record) {
+		    // check for volid, because if you click on a grouping header,
+		    // it calls convert (but with an empty volid)
+		    if (value || record.data.volid === null) {
+			return value;
+		    }
+		    return PVE.Utils.render_storage_content(value, {}, record);
+		},
+	    },
+	    {
+		name: 'vdate',
+		convert: function(value, record) {
+		    // check for volid, because if you click on a grouping header,
+		    // it calls convert (but with an empty volid)
+		    if (value || record.data.volid === null) {
+			return value;
+		    }
+		    let t = record.data.content;
+		    if (t === "backup") {
+			let v = record.data.volid;
+			let match = v.match(/(\d{4}_\d{2}_\d{2})-(\d{2}_\d{2}_\d{2})/);
+			if (match) {
+			    let date = match[1].replace(/_/g, '-');
+			    let time = match[2].replace(/_/g, ':');
+			    return date + " " + time;
+			}
+		    }
+		    if (record.data.ctime) {
+			let ctime = new Date(record.data.ctime * 1000);
+			return Ext.Date.format(ctime, 'Y-m-d H:i:s');
+		    }
+		    return '';
+		},
+	    },
+	],
+	idProperty: 'volid',
+    });
+});
+Ext.define('PVE.storage.BackupView', {
+    extend: 'PVE.storage.ContentView',
+
+    alias: 'widget.pveStorageBackupView',
+
+    showColumns: ['name', 'notes', 'protected', 'date', 'format', 'size'],
+
+    initComponent: function() {
+	let me = this;
+
+	let nodename = me.nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	let storage = me.storage = me.pveSelNode.data.storage;
+	if (!storage) {
+	    throw "no storage ID specified";
+	}
+
+	me.content = 'backup';
+
+	let sm = me.sm = Ext.create('Ext.selection.RowModel', {});
+
+	let pruneButton = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Prune group'),
+	    disabled: true,
+	    selModel: sm,
+	    setBackupGroup: function(backup) {
+		if (backup) {
+		    let name = backup.text;
+		    let vmid = backup.vmid;
+		    let format = backup.format;
+
+		    let vmtype;
+		    if (name.startsWith('vzdump-lxc-') || format === "pbs-ct") {
+			vmtype = 'lxc';
+		    } else if (name.startsWith('vzdump-qemu-') || format === "pbs-vm") {
+			vmtype = 'qemu';
+		    }
+
+		    if (vmid && vmtype) {
+			this.setText(gettext('Prune group') + ` ${vmtype}/${vmid}`);
+			this.vmid = vmid;
+			this.vmtype = vmtype;
+			this.setDisabled(false);
+			return;
+		    }
+		}
+		this.setText(gettext('Prune group'));
+		this.vmid = null;
+		this.vmtype = null;
+		this.setDisabled(true);
+	    },
+	    handler: function(b, e, rec) {
+		Ext.create('PVE.window.Prune', {
+		    autoShow: true,
+		    nodename,
+		    storage,
+		    backup_id: this.vmid,
+		    backup_type: this.vmtype,
+		    listeners: {
+			destroy: () => me.store.load(),
+		    },
+		});
+	    },
+	});
+
+	me.on('selectionchange', function(model, srecords, eOpts) {
+	    if (srecords.length === 1) {
+		pruneButton.setBackupGroup(srecords[0].data);
+	    } else {
+		pruneButton.setBackupGroup(null);
+	    }
+	});
+
+	let isPBS = me.pluginType === 'pbs';
+
+	me.tbar = [
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Restore'),
+		selModel: sm,
+		disabled: true,
+		handler: function(b, e, rec) {
+		    let vmtype;
+		    if (PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format)) {
+			vmtype = 'qemu';
+		    } else if (PVE.Utils.volume_is_lxc_backup(rec.data.volid, rec.data.format)) {
+			vmtype = 'lxc';
+		    } else {
+			return;
+		    }
+
+		    Ext.create('PVE.window.Restore', {
+			autoShow: true,
+			nodename,
+			volid: rec.data.volid,
+			volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec),
+			vmtype,
+			isPBS,
+			listeners: {
+			    destroy: () => me.store.load(),
+			},
+		    });
+		},
+	    },
+	];
+	if (isPBS) {
+	    me.tbar.push({
+		xtype: 'proxmoxButton',
+		text: gettext('File Restore'),
+		disabled: true,
+		selModel: sm,
+		handler: function(b, e, rec) {
+		    let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format);
+		    Ext.create('Proxmox.window.FileBrowser', {
+			title: gettext('File Restore') + " - " + rec.data.text,
+			listURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/list`,
+			downloadURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/download`,
+			extraParams: {
+			    volume: rec.data.volid,
+			},
+			archive: isVMArchive ? 'all' : undefined,
+			autoShow: true,
+		    });
+		},
+	    });
+	}
+	me.tbar.push(
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Show Configuration'),
+		disabled: true,
+		selModel: sm,
+		handler: function(b, e, rec) {
+		    Ext.create('PVE.window.BackupConfig', {
+			autoShow: true,
+			volume: rec.data.volid,
+			pveSelNode: me.pveSelNode,
+		    });
+		},
+	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Edit Notes'),
+		disabled: true,
+		selModel: sm,
+		handler: function(b, e, rec) {
+		    let volid = rec.data.volid;
+		    Ext.create('Proxmox.window.Edit', {
+			autoShow: true,
+			autoLoad: true,
+			width: 600,
+			height: 400,
+			resizable: true,
+			title: gettext('Notes'),
+			url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`,
+			layout: 'fit',
+			items: [
+			    {
+				xtype: 'textarea',
+				layout: 'fit',
+				name: 'notes',
+				height: '100%',
+			    },
+			],
+			listeners: {
+			    destroy: () => me.store.load(),
+			},
+		    });
+		},
+	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Change Protection'),
+		disabled: true,
+		handler: function(button, event, record) {
+		    const volid = record.data.volid;
+		    Proxmox.Utils.API2Request({
+			url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`,
+			method: 'PUT',
+			waitMsgTarget: me,
+			params: { 'protected': record.data.protected ? 0 : 1 },
+			failure: response => Ext.Msg.alert('Error', response.htmlStatus),
+			success: () => {
+			    me.store.load({
+				callback: () => sm.fireEvent('selectionchange', sm, [record]),
+			    });
+			},
+		    });
+		},
+	    },
+	    '-',
+	    pruneButton,
+	);
+
+	me.extraColumns = {};
+
+	if (isPBS) {
+	    me.extraColumns.encrypted = {
+		header: gettext('Encrypted'),
+		dataIndex: 'encrypted',
+		renderer: PVE.Utils.render_backup_encryption,
+		sorter: {
+		    property: 'encrypted',
+		    transform: encrypted => encrypted ? 1 : 0,
+		},
+	    };
+	    me.extraColumns.verification = {
+		header: gettext('Verify State'),
+		dataIndex: 'verification',
+		renderer: PVE.Utils.render_backup_verification,
+		sorter: {
+		    property: 'verification',
+		    transform: value => {
+			let state = value?.state ?? 'none';
+			let order = PVE.Utils.verificationStateOrder;
+			return order[state] ?? order.__default__;
+		    },
+		},
+	    };
+	}
+
+	me.extraColumns.vmid = {
+	    header: 'VMID',
+	    dataIndex: 'vmid',
+	    hidden: true,
+	    sorter: (a, b) => (a.data.vmid ?? 0) - (b.data.vmid ?? 0),
+	};
+
+	me.callParent();
+
+	me.store.getSorters().clear();
+	me.store.setSorters([
+	    {
+		property: 'vdate',
+		direction: 'DESC',
+	    },
+	]);
+    },
+});
+Ext.define('PVE.panel.StorageBase', {
+    extend: 'Proxmox.panel.InputPanel',
+    controller: 'storageEdit',
+
+    type: '',
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.storage;
+	}
+
+	values.disable = values.enable ? 0 : 1;
+	delete values.enable;
+
+	return values;
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.column1.unshift({
+	    xtype: me.isCreate ? 'textfield' : 'displayfield',
+	    name: 'storage',
+	    value: me.storageId || '',
+	    fieldLabel: 'ID',
+	    vtype: 'StorageId',
+	    allowBlank: false,
+	});
+
+	me.column2 = me.column2 || [];
+	me.column2.unshift(
+	    {
+		xtype: 'pveNodeSelector',
+		name: 'nodes',
+		reference: 'storageNodeRestriction',
+		disabled: me.storageId === 'local',
+		fieldLabel: gettext('Nodes'),
+		emptyText: gettext('All') + ' (' + gettext('No restrictions') +')',
+		multiSelect: true,
+		autoSelect: false,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'enable',
+		checked: true,
+		uncheckedValue: 0,
+		fieldLabel: gettext('Enable'),
+	    },
+	);
+
+	const qemuImgStorageTypes = ['dir', 'btrfs', 'nfs', 'cifs', 'glusterfs'];
+
+	if (qemuImgStorageTypes.includes(me.type)) {
+	    const preallocSelector = {
+		xtype: 'pvePreallocationSelector',
+		name: 'preallocation',
+		fieldLabel: gettext('Preallocation'),
+		allowBlank: false,
+		deleteEmpty: !me.isCreate,
+		value: '__default__',
+	    };
+
+	    me.advancedColumn1 = me.advancedColumn1 || [];
+	    me.advancedColumn2 = me.advancedColumn2 || [];
+	    if (me.advancedColumn2.length < me.advancedColumn1.length) {
+		me.advancedColumn2.unshift(preallocSelector);
+	    } else {
+		me.advancedColumn1.unshift(preallocSelector);
+	    }
+	}
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    apiCallDone: function(success, response, options) {
+	let me = this;
+	if (typeof me.ipanel.apiCallDone === "function") {
+	    me.ipanel.apiCallDone(success, response, options);
+	}
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	me.isCreate = !me.storageId;
+
+	if (me.isCreate) {
+	    me.url = '/api2/extjs/storage';
+	    me.method = 'POST';
+	} else {
+	    me.url = '/api2/extjs/storage/' + me.storageId;
+	    me.method = 'PUT';
+	}
+
+	me.ipanel = Ext.create(me.paneltype, {
+	    title: gettext('General'),
+	    type: me.type,
+	    isCreate: me.isCreate,
+	    storageId: me.storageId,
+	});
+
+	Ext.apply(me, {
+            subject: PVE.Utils.format_storage_type(me.type),
+	    isAdd: true,
+	    bodyPadding: 0,
+	    items: {
+		xtype: 'tabpanel',
+		region: 'center',
+		layout: 'fit',
+		bodyPadding: 10,
+		items: [
+		    me.ipanel,
+		    {
+			xtype: 'pveBackupJobPrunePanel',
+			title: gettext('Backup Retention'),
+			hasMaxProtected: true,
+			isCreate: me.isCreate,
+			keepAllDefaultForCreate: true,
+			showPBSHint: me.ipanel.isPBS,
+			fallbackHintHtml: gettext('Without any keep option, the node\'s vzdump.conf or `keep-all` is used as fallback for backup jobs'),
+		    },
+		],
+	    },
+	});
+
+	if (me.ipanel.extraTabs) {
+	    me.ipanel.extraTabs.forEach(panel => {
+		panel.isCreate = me.isCreate;
+		me.items.items.push(panel);
+	    });
+	}
+
+	me.callParent();
+
+	if (!me.canDoBackups) {
+	    // cannot mask now, not fully rendered until activated
+	    me.down('pmxPruneInputPanel').needMask = true;
+	}
+
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response, options) {
+		    let values = response.result.data;
+		    let ctypes = values.content || '';
+
+		    values.content = ctypes.split(',');
+
+		    if (values.nodes) {
+			values.nodes = values.nodes.split(',');
+		    }
+		    values.enable = values.disable ? 0 : 1;
+		    if (values['prune-backups']) {
+			let retention = PVE.Parser.parsePropertyString(values['prune-backups']);
+			delete values['prune-backups'];
+			Object.assign(values, retention);
+		    } else if (values.maxfiles !== undefined) {
+			if (values.maxfiles > 0) {
+			    values['keep-last'] = values.maxfiles;
+			}
+			delete values.maxfiles;
+		    }
+
+		    me.query('inputpanel').forEach(panel => {
+			panel.setValues(values);
+		    });
+		},
+	    });
+	}
+    },
+});
+Ext.define('PVE.storage.Browser', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.PVE.storage.Browser',
+
+    onlineHelp: 'chapter_storage',
+
+    initComponent: function() {
+        let me = this;
+
+	let nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	let storeid = me.pveSelNode.data.storage;
+	if (!storeid) {
+	    throw "no storage ID specified";
+	}
+
+	let storageInfo = PVE.data.ResourceStore.findRecord(
+	    'id',
+	    `storage/${nodename}/${storeid}`,
+	    0, // startIndex
+	    false, // anyMatch
+	    true, // caseSensitive
+	    true, // exactMatch
+	);
+	let res = storageInfo.data;
+	let plugin = res.plugintype;
+
+	me.items = plugin !== 'esxi' ? [
+	    {
+		title: gettext('Summary'),
+		xtype: 'pveStorageSummary',
+		iconCls: 'fa fa-book',
+		itemId: 'summary',
+	    },
+	] : [];
+
+	let caps = Ext.state.Manager.get('GuiCap');
+
+	Ext.apply(me, {
+	    title: Ext.String.format(gettext("Storage {0} on node {1}"), `'${storeid}'`, `'${nodename}'`),
+	    hstateid: 'storagetab',
+	});
+
+	if (
+	    caps.storage['Datastore.Allocate'] ||
+	    caps.storage['Datastore.AllocateSpace'] ||
+	    caps.storage['Datastore.Audit']
+	) {
+	    let contents = res.content.split(',');
+
+	    let enableUpload = !!caps.storage['Datastore.AllocateTemplate'];
+	    let enableDownloadUrl = enableUpload && (
+		!!(caps.nodes['Sys.Audit'] && caps.nodes['Sys.Modify']) || // for backward compat
+		!!caps.nodes['Sys.AccessNetwork'] // new explicit priv for querying (local) networks
+	    );
+
+	    if (contents.includes('backup')) {
+		me.items.push({
+		    xtype: 'pveStorageBackupView',
+		    title: gettext('Backups'),
+		    iconCls: 'fa fa-floppy-o',
+		    itemId: 'contentBackup',
+		    pluginType: plugin,
+		});
+	    }
+	    if (contents.includes('images')) {
+		me.items.push({
+		    xtype: 'pveStorageImageView',
+		    title: gettext('VM Disks'),
+		    iconCls: 'fa fa-hdd-o',
+		    itemId: 'contentImages',
+		    content: 'images',
+		    pluginType: plugin,
+		});
+	    }
+	    if (contents.includes('rootdir')) {
+		me.items.push({
+		    xtype: 'pveStorageImageView',
+		    title: gettext('CT Volumes'),
+		    iconCls: 'fa fa-hdd-o lxc',
+		    itemId: 'contentRootdir',
+		    content: 'rootdir',
+		    pluginType: plugin,
+		});
+	    }
+	    if (contents.includes('iso')) {
+		me.items.push({
+		    xtype: 'pveStorageContentView',
+		    title: gettext('ISO Images'),
+		    iconCls: 'pve-itype-treelist-item-icon-cdrom',
+		    itemId: 'contentIso',
+		    content: 'iso',
+		    pluginType: plugin,
+		    enableUploadButton: enableUpload,
+		    enableDownloadUrlButton: enableDownloadUrl,
+		    useUploadButton: true,
+		});
+	    }
+	    if (contents.includes('vztmpl')) {
+		me.items.push({
+		    xtype: 'pveStorageTemplateView',
+		    title: gettext('CT Templates'),
+		    iconCls: 'fa fa-file-o lxc',
+		    itemId: 'contentVztmpl',
+		    pluginType: plugin,
+		    enableUploadButton: enableUpload,
+		    enableDownloadUrlButton: enableDownloadUrl,
+		    useUploadButton: true,
+		});
+	    }
+	    if (contents.includes('snippets')) {
+		me.items.push({
+		    xtype: 'pveStorageContentView',
+		    title: gettext('Snippets'),
+		    iconCls: 'fa fa-file-code-o',
+		    itemId: 'contentSnippets',
+		    content: 'snippets',
+		    pluginType: plugin,
+		});
+	    }
+	    if (contents.includes('import')) {
+		let createGuestImportWindow = (selection) => {
+		    if (!selection) {
+			return;
+		    }
+
+		    let volumeName = selection.data.volid.replace(/^.*?:/, '');
+
+		    Ext.create('PVE.window.GuestImport', {
+			storage: storeid,
+			volumeName,
+			nodename,
+			autoShow: true,
+		    });
+		};
+		me.items.push({
+		    xtype: 'pveStorageContentView',
+		    title: gettext('Virtual Guests'),
+		    iconCls: 'fa fa-desktop',
+		    itemId: 'contentImport',
+		    content: 'import',
+		    useCustomRemoveButton: true, // hide default remove button
+		    showColumns: ['name', 'format'],
+		    itemdblclick: (view, record) => createGuestImportWindow(record),
+		    tbar: [
+			{
+			    xtype: 'proxmoxButton',
+			    disabled: true,
+			    text: gettext('Import'),
+			    iconCls: 'fa fa-cloud-download',
+			    handler: function() {
+				let grid = this.up('pveStorageContentView');
+				let selection = grid.getSelection()?.[0];
+
+				createGuestImportWindow(selection);
+			    },
+			},
+		    ],
+		    pluginType: plugin,
+		});
+	    }
+	}
+
+	if (caps.storage['Permissions.Modify']) {
+	    me.items.push({
+		xtype: 'pveACLView',
+		title: gettext('Permissions'),
+		iconCls: 'fa fa-unlock',
+		itemId: 'permissions',
+		path: `/storage/${storeid}`,
+	    });
+	}
+
+	me.callParent();
+   },
+});
+Ext.define('PVE.storage.CIFSScan', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveCIFSScan',
+
+    queryParam: 'server',
+
+    valueField: 'share',
+    displayField: 'share',
+    matchFieldWidth: false,
+    listConfig: {
+	loadingText: gettext('Scanning...'),
+	width: 350,
+    },
+    doRawQuery: Ext.emptyFn,
+
+    onTriggerClick: function() {
+	var me = this;
+
+	if (!me.queryCaching || me.lastQuery !== me.cifsServer) {
+	    me.store.removeAll();
+	}
+
+	var params = {};
+	if (me.cifsUsername) {
+	    params.username = me.cifsUsername;
+	}
+	if (me.cifsPassword) {
+	    params.password = me.cifsPassword;
+	}
+	if (me.cifsDomain) {
+	    params.domain = me.cifsDomain;
+	}
+
+	me.store.getProxy().setExtraParams(params);
+	me.allQuery = me.cifsServer;
+
+	me.callParent();
+    },
+
+    resetProxy: function() {
+	let me = this;
+	me.lastQuery = null;
+	if (!me.readOnly && !me.disabled) {
+	    if (me.isExpanded) {
+		me.collapse();
+	    }
+	}
+    },
+
+    setServer: function(server) {
+	if (this.cifsServer !== server) {
+	    this.cifsServer = server;
+	    this.resetProxy();
+	}
+    },
+    setUsername: function(username) {
+	if (this.cifsUsername !== username) {
+	    this.cifsUsername = username;
+	    this.resetProxy();
+	}
+    },
+    setPassword: function(password) {
+	if (this.cifsPassword !== password) {
+	    this.cifsPassword = password;
+	    this.resetProxy();
+	}
+    },
+    setDomain: function(domain) {
+	if (this.cifsDomain !== domain) {
+	    this.cifsDomain = domain;
+	    this.resetProxy();
+	}
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    fields: ['description', 'share'],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes/' + me.nodename + '/scan/cifs',
+	    },
+	});
+	store.sort('share', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+
+	let picker = me.getPicker();
+	// don't use monStoreErrors directly, it doesn't copes well with comboboxes
+	picker.mon(store, 'beforeload', function(s, operation, eOpts) {
+	    picker.unmask();
+	    delete picker.minHeight;
+	});
+	picker.mon(store.proxy, 'afterload', function(proxy, request, success) {
+	    if (success) {
+		Proxmox.Utils.setErrorMask(picker, false);
+		return;
+	    }
+	    let error = request._operation.getError();
+	    let msg = Proxmox.Utils.getResponseErrorMessage(error);
+	    if (msg) {
+		picker.minHeight = 100;
+	    }
+	    Proxmox.Utils.setErrorMask(picker, msg);
+	});
+    },
+});
+
+Ext.define('PVE.storage.CIFSInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    onlineHelp: 'storage_cifs',
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (values.password?.length === 0) {
+	    delete values.password;
+	}
+	if (values.username?.length === 0) {
+	    delete values.username;
+	}
+	if (values.subdir?.length === 0) {
+	    delete values.subdir;
+	}
+
+	return me.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'server',
+		value: '',
+		fieldLabel: gettext('Server'),
+		allowBlank: false,
+		listeners: {
+		    change: function(f, value) {
+			if (me.isCreate) {
+			    var exportField = me.down('field[name=share]');
+			    exportField.setServer(value);
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'username',
+		value: '',
+		fieldLabel: gettext('Username'),
+		emptyText: gettext('Guest user'),
+		listeners: {
+		    change: function(f, value) {
+			if (!me.isCreate) {
+			    return;
+			}
+			var exportField = me.down('field[name=share]');
+			exportField.setUsername(value);
+		    },
+		},
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		inputType: 'password',
+		name: 'password',
+		value: me.isCreate ? '' : '********',
+		emptyText: me.isCreate ? gettext('None') : '',
+		fieldLabel: gettext('Password'),
+		minLength: 1,
+		listeners: {
+		    change: function(f, value) {
+			let exportField = me.down('field[name=share]');
+			exportField.setPassword(value);
+		    },
+		},
+	    },
+	    {
+		xtype: me.isCreate ? 'pveCIFSScan' : 'displayfield',
+		name: 'share',
+		value: '',
+		fieldLabel: 'Share',
+		allowBlank: false,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'pveContentTypeSelector',
+		name: 'content',
+		value: 'images',
+		multiSelect: true,
+		fieldLabel: gettext('Content'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'domain',
+		value: me.isCreate ? '' : undefined,
+		fieldLabel: gettext('Domain'),
+		allowBlank: true,
+		listeners: {
+		    change: function(f, value) {
+			if (me.isCreate) {
+			    let exportField = me.down('field[name=share]');
+			    exportField.setDomain(value);
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: 'pmxDisplayEditField',
+		editable: me.isCreate,
+		name: 'subdir',
+		fieldLabel: gettext('Subdirectory'),
+		allowBlank: true,
+		emptyText: gettext('/some/path'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.CephFSInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+    controller: 'cephstorage',
+
+    onlineHelp: 'storage_cephfs',
+
+    viewModel: {
+	type: 'cephstorage',
+    },
+
+    setValues: function(values) {
+	if (values.monhost) {
+	    this.viewModel.set('pveceph', false);
+	    this.lookupReference('pvecephRef').setValue(false);
+	    this.lookupReference('pvecephRef').resetOriginalValue();
+	}
+	this.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+	me.type = 'cephfs';
+
+	me.column1 = [];
+
+	me.column1.push(
+	    {
+		xtype: 'textfield',
+		name: 'monhost',
+		vtype: 'HostList',
+		value: '',
+		bind: {
+		    disabled: '{pveceph}',
+		    submitValue: '{!pveceph}',
+		    hidden: '{pveceph}',
+		},
+		fieldLabel: 'Monitor(s)',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'displayfield',
+		reference: 'monhost',
+		bind: {
+		    disabled: '{!pveceph}',
+		    hidden: '{!pveceph}',
+		},
+		value: '',
+		fieldLabel: 'Monitor(s)',
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'username',
+		value: 'admin',
+		bind: {
+		    disabled: '{pveceph}',
+		    submitValue: '{!pveceph}',
+		},
+		fieldLabel: gettext('User name'),
+		allowBlank: true,
+	    },
+	);
+
+	if (me.isCreate) {
+	    me.column1.push({
+		xtype: 'pveCephFSSelector',
+		nodename: me.nodename,
+		name: 'fs-name',
+		bind: {
+		    disabled: '{!pveceph}',
+		    submitValue: '{pveceph}',
+		    hidden: '{!pveceph}',
+		},
+		fieldLabel: gettext('FS Name'),
+		allowBlank: false,
+	    }, {
+		xtype: 'textfield',
+		nodename: me.nodename,
+		name: 'fs-name',
+		bind: {
+		    disabled: '{pveceph}',
+		    submitValue: '{!pveceph}',
+		    hidden: '{pveceph}',
+		},
+		fieldLabel: gettext('FS Name'),
+	    });
+	}
+
+	me.column2 = [
+	    {
+		xtype: 'pveContentTypeSelector',
+		cts: ['backup', 'iso', 'vztmpl', 'snippets'],
+		fieldLabel: gettext('Content'),
+		name: 'content',
+		value: 'backup',
+		multiSelect: true,
+		allowBlank: false,
+	    },
+	];
+
+	me.columnB = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'keyring',
+		fieldLabel: gettext('Secret Key'),
+		value: me.isCreate ? '' : '***********',
+		allowBlank: false,
+		bind: {
+		    hidden: '{pveceph}',
+		    disabled: '{pveceph}',
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'pveceph',
+		reference: 'pvecephRef',
+		bind: {
+		    disabled: '{!pvecephPossible}',
+		    value: '{pveceph}',
+		},
+		checked: true,
+		uncheckedValue: 0,
+		submitValue: false,
+		hidden: !me.isCreate,
+		boxLabel: gettext('Use Proxmox VE managed hyper-converged cephFS'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.DirInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    onlineHelp: 'storage_directory',
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'path',
+		value: '',
+		fieldLabel: gettext('Directory'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'pveContentTypeSelector',
+		name: 'content',
+		value: 'images',
+		multiSelect: true,
+		fieldLabel: gettext('Content'),
+		allowBlank: false,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'shared',
+		uncheckedValue: 0,
+		fieldLabel: gettext('Shared'),
+		autoEl: {
+		    tag: 'div',
+		    'data-qtip': gettext('Enable if the underlying file system is already shared between nodes.'),
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.GlusterFsScan', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveGlusterFsScan',
+
+    queryParam: 'server',
+
+    valueField: 'volname',
+    displayField: 'volname',
+    matchFieldWidth: false,
+    listConfig: {
+	loadingText: 'Scanning...',
+	width: 350,
+    },
+    doRawQuery: function() {
+	// nothing
+    },
+
+    onTriggerClick: function() {
+	var me = this;
+
+	if (!me.queryCaching || me.lastQuery !== me.glusterServer) {
+	    me.store.removeAll();
+	}
+
+	me.allQuery = me.glusterServer;
+
+	me.callParent();
+    },
+
+    setServer: function(server) {
+	var me = this;
+
+	me.glusterServer = server;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    fields: ['volname'],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes/' + me.nodename + '/scan/glusterfs',
+	    },
+	});
+
+	store.sort('volname', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.GlusterFsInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    onlineHelp: 'storage_glusterfs',
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'server',
+		value: '',
+		fieldLabel: gettext('Server'),
+		allowBlank: false,
+		listeners: {
+		    change: function(f, value) {
+			if (me.isCreate) {
+			    var volumeField = me.down('field[name=volume]');
+			    volumeField.setServer(value);
+			    volumeField.setValue('');
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
+		name: 'server2',
+		value: '',
+		fieldLabel: gettext('Second Server'),
+		allowBlank: true,
+	    },
+	    {
+		xtype: me.isCreate ? 'pveGlusterFsScan' : 'displayfield',
+		name: 'volume',
+		value: '',
+		fieldLabel: 'Volume name',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'pveContentTypeSelector',
+		cts: ['images', 'iso', 'backup', 'vztmpl', 'snippets'],
+		name: 'content',
+		value: 'images',
+		multiSelect: true,
+		fieldLabel: gettext('Content'),
+		allowBlank: false,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.ImageView', {
+    extend: 'PVE.storage.ContentView',
+
+    alias: 'widget.pveStorageImageView',
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename = me.pveSelNode.data.node;
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	var storage = me.storage = me.pveSelNode.data.storage;
+	if (!me.storage) {
+	    throw "no storage ID specified";
+	}
+
+	if (!me.content || (me.content !== 'images' && me.content !== 'rootdir')) {
+	    throw "content needs to be either 'images' or 'rootdir'";
+	}
+
+	var sm = me.sm = Ext.create('Ext.selection.RowModel', {});
+
+	var reload = function() {
+	    me.store.load();
+	};
+
+	me.tbar = [
+	    {
+		xtype: 'proxmoxButton',
+		selModel: sm,
+		text: gettext('Remove'),
+		disabled: true,
+		handler: function(btn, event, rec) {
+		    let url = `/nodes/${nodename}/storage/${storage}/content/${rec.data.volid}`;
+		    var vmid = rec.data.vmid;
+
+		    var store = PVE.data.ResourceStore;
+
+		    if (vmid && store.findVMID(vmid)) {
+			var guest_node = store.guestNode(vmid);
+			var storage_path = 'storage/' + nodename + '/' + storage;
+
+			// allow to delete local backed images if a VMID exists on another node.
+			if (store.storageIsShared(storage_path) || guest_node === nodename) {
+			    var msg = Ext.String.format(
+				gettext("Cannot remove image, a guest with VMID '{0}' exists!"), vmid);
+			    msg += '<br />' + gettext("You can delete the image from the guest's hardware pane");
+
+			    Ext.Msg.show({
+				title: gettext('Cannot remove disk image.'),
+				icon: Ext.Msg.ERROR,
+				msg: msg,
+			    });
+			    return;
+			}
+		    }
+		    var win = Ext.create('Proxmox.window.SafeDestroy', {
+			title: Ext.String.format(gettext("Destroy '{0}'"), rec.data.volid),
+			showProgress: true,
+			url: url,
+			item: { type: 'Image', id: vmid },
+			taskName: 'unknownimgdel',
+		    }).show();
+		    win.on('destroy', reload);
+		},
+	    },
+	];
+	me.useCustomRemoveButton = true;
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.IScsiScan', {
+    extend: 'PVE.form.ComboBoxSetStoreNode',
+    alias: 'widget.pveIScsiScan',
+
+    queryParam: 'portal',
+    valueField: 'target',
+    displayField: 'target',
+    matchFieldWidth: false,
+    allowBlank: false,
+
+    listConfig: {
+	width: 350,
+	columns: [
+	    {
+		dataIndex: 'target',
+		flex: 1,
+	    },
+	],
+	emptyText: PVE.Utils.renderNotFound(gettext('iSCSI Target')),
+    },
+
+    config: {
+	apiSuffix: '/scan/iscsi',
+    },
+
+    showNodeSelector: true,
+
+    reload: function() {
+	let me = this;
+	if (!me.isDisabled()) {
+	    me.getStore().load();
+	}
+    },
+
+    setPortal: function(portal) {
+	let me = this;
+	me.portal = portal;
+	me.getStore().getProxy().setExtraParams({ portal });
+	me.reload();
+    },
+
+    setNodeName: function(value) {
+	let me = this;
+	me.callParent([value]);
+	me.reload();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    fields: ['target', 'portal'],
+	    proxy: {
+		type: 'proxmox',
+		url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
+	    },
+	});
+	store.sort('target', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.IScsiInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage_open_iscsi',
+
+    onGetValues: function(values) {
+	let me = this;
+
+	values.content = values.luns ? 'images' : 'none';
+	delete values.luns;
+
+	return me.callParent([values]);
+    },
+
+    setValues: function(values) {
+	values.luns = values.content.indexOf('images') !== -1;
+	this.callParent([values]);
+    },
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+
+	    name: 'portal',
+	    value: '',
+	    fieldLabel: 'Portal',
+	    allowBlank: false,
+
+	    editConfig: {
+		listeners: {
+		    change: {
+			fn: function(f, value) {
+			    let panel = this.up('inputpanel');
+			    let exportField = panel.lookup('iScsiTargetScan');
+			    if (exportField) {
+				exportField.setDisabled(!value);
+				exportField.setPortal(value);
+				exportField.setValue('');
+			    }
+			},
+			buffer: 500,
+		    },
+		},
+	    },
+	},
+	{
+	    cbind: {
+		xtype: (get) => get('isCreate') ? 'pveIScsiScan' : 'displayfield',
+		readOnly: '{!isCreate}',
+		disabled: '{isCreate}',
+	    },
+
+	    name: 'target',
+	    value: '',
+	    fieldLabel: gettext('Target'),
+	    allowBlank: false,
+	    reference: 'iScsiTargetScan',
+	    listeners: {
+		nodechanged: function(value) {
+		    this.up('inputpanel').lookup('storageNodeRestriction').setValue(value);
+		},
+	    },
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'checkbox',
+	    name: 'luns',
+	    checked: true,
+	    fieldLabel: gettext('Use LUNs directly'),
+	},
+    ],
+});
+Ext.define('PVE.storage.VgSelector', {
+    extend: 'PVE.form.ComboBoxSetStoreNode',
+    alias: 'widget.pveVgSelector',
+    valueField: 'vg',
+    displayField: 'vg',
+    queryMode: 'local',
+    editable: false,
+
+    listConfig: {
+	columns: [
+	    {
+		dataIndex: 'vg',
+		flex: 1,
+	    },
+	],
+	emptyText: PVE.Utils.renderNotFound('VGs'),
+    },
+
+    config: {
+	apiSuffix: '/scan/lvm',
+    },
+
+    showNodeSelector: true,
+
+    setNodeName: function(value) {
+	let me = this;
+	me.callParent([value]);
+	me.getStore().load();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    autoLoad: {}, // true,
+	    fields: ['vg', 'size', 'free'],
+	    proxy: {
+		type: 'proxmox',
+		url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
+	    },
+	});
+
+	store.sort('vg', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.BaseStorageSelector', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveBaseStorageSelector',
+
+    existingGroupsText: gettext("Existing volume groups"),
+    queryMode: 'local',
+    editable: false,
+    value: '',
+    valueField: 'storage',
+    displayField: 'text',
+    initComponent: function() {
+	let me = this;
+
+	let store = Ext.create('Ext.data.Store', {
+	    autoLoad: {
+		addRecords: true,
+		params: {
+		    type: 'iscsi',
+		},
+	    },
+	    fields: ['storage', 'type', 'content',
+		      {
+			  name: 'text',
+			  convert: function(value, record) {
+			      if (record.data.storage) {
+				  return record.data.storage + " (iSCSI)";
+			      } else {
+				  return me.existingGroupsText;
+			      }
+			  },
+		      }],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/storage/',
+	    },
+	});
+
+	store.loadData([{ storage: '' }], true);
+
+	store.sort('storage', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.LunSelector', {
+    extend: 'PVE.form.FileSelector',
+    alias: 'widget.pveStorageLunSelector',
+
+    nodename: 'localhost',
+    storageContent: 'images',
+    allowBlank: false,
+
+    initComponent: function() {
+	let me = this;
+
+	if (!PVE.Utils.isStandaloneNode()) {
+	    me.errorHeight = 140;
+	    Ext.apply(me.listConfig ?? {}, {
+		tbar: {
+		    xtype: 'toolbar',
+		    items: [
+			{
+			    xtype: "pveStorageScanNodeSelector",
+			    autoSelect: false,
+			    fieldLabel: gettext('Node to scan'),
+			    listeners: {
+				change: (_field, value) => me.setNodename(value),
+			    },
+			},
+		    ],
+		},
+		emptyText: me.listConfig?.emptyText ?? PVE.Utils.renderNotFound(gettext('Volume')),
+	    });
+	}
+
+	me.callParent();
+    },
+
+});
+
+Ext.define('PVE.storage.LVMInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage_lvm',
+
+    column1: [
+	{
+	    xtype: 'pveBaseStorageSelector',
+	    name: 'basesel',
+	    fieldLabel: gettext('Base storage'),
+	    cbind: {
+		disabled: '{!isCreate}',
+		hidden: '{!isCreate}',
+	    },
+	    submitValue: false,
+	    listeners: {
+		change: function(f, value) {
+		    let me = this;
+		    let vgField = me.up('inputpanel').lookup('volumeGroupSelector');
+		    let vgNameField = me.up('inputpanel').lookup('vgName');
+		    let baseField = me.up('inputpanel').lookup('lunSelector');
+
+		    vgField.setVisible(!value);
+		    vgField.setDisabled(!!value);
+
+		    baseField.setVisible(!!value);
+		    baseField.setDisabled(!value);
+		    baseField.setStorage(value);
+
+		    vgNameField.setVisible(!!value);
+		    vgNameField.setDisabled(!value);
+		},
+	    },
+	},
+	{
+	    xtype: 'pveStorageLunSelector',
+	    name: 'base',
+	    fieldLabel: gettext('Base volume'),
+	    reference: 'lunSelector',
+	    hidden: true,
+	    disabled: true,
+	},
+	{
+	    xtype: 'pveVgSelector',
+	    name: 'vgname',
+	    fieldLabel: gettext('Volume group'),
+	    reference: 'volumeGroupSelector',
+	    cbind: {
+		disabled: '{!isCreate}',
+		hidden: '{!isCreate}',
+	    },
+	    allowBlank: false,
+	    listeners: {
+		nodechanged: function(value) {
+		    this.up('inputpanel').lookup('storageNodeRestriction').setValue(value);
+		},
+	    },
+	},
+	{
+	    name: 'vgname',
+	    fieldLabel: gettext('Volume group'),
+	    reference: 'vgName',
+	    cbind: {
+		xtype: (get) => get('isCreate') ? 'textfield' : 'displayfield',
+		hidden: '{isCreate}',
+		disabled: '{isCreate}',
+	    },
+	    value: '',
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'pveContentTypeSelector',
+	    cts: ['images', 'rootdir'],
+	    fieldLabel: gettext('Content'),
+	    name: 'content',
+	    value: ['images', 'rootdir'],
+	    multiSelect: true,
+	    allowBlank: false,
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'shared',
+	    uncheckedValue: 0,
+	    fieldLabel: gettext('Shared'),
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Enable if the LVM is located on a shared LUN.'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'saferemove',
+	    uncheckedValue: 0,
+	    fieldLabel: gettext('Wipe Removed Volumes'),
+	},
+    ],
+});
+Ext.define('PVE.storage.TPoolSelector', {
+    extend: 'PVE.form.ComboBoxSetStoreNode',
+    alias: 'widget.pveTPSelector',
+
+    queryParam: 'vg',
+    valueField: 'lv',
+    displayField: 'lv',
+    editable: false,
+    allowBlank: false,
+
+    listConfig: {
+	emptyText: PVE.Utils.renderNotFound('Thin-Pool'),
+	columns: [
+	    {
+		dataIndex: 'lv',
+		flex: 1,
+	    },
+	],
+    },
+
+    config: {
+	apiSuffix: '/scan/lvmthin',
+    },
+
+    reload: function() {
+	let me = this;
+	if (!me.isDisabled()) {
+	    me.getStore().load();
+	}
+    },
+
+    setVG: function(myvg) {
+	let me = this;
+	me.vg = myvg;
+	me.getStore().getProxy().setExtraParams({ vg: myvg });
+	me.reload();
+    },
+
+    setNodeName: function(value) {
+	let me = this;
+	me.callParent([value]);
+	me.reload();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    fields: ['lv'],
+	    proxy: {
+		type: 'proxmox',
+		url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
+	    },
+	});
+
+	store.sort('lv', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.BaseVGSelector', {
+    extend: 'PVE.form.ComboBoxSetStoreNode',
+    alias: 'widget.pveBaseVGSelector',
+
+    valueField: 'vg',
+    displayField: 'vg',
+    queryMode: 'local',
+    editable: false,
+    allowBlank: false,
+
+    listConfig: {
+	columns: [
+	    {
+		dataIndex: 'vg',
+		flex: 1,
+	    },
+	],
+    },
+
+    showNodeSelector: true,
+
+    config: {
+	apiSuffix: '/scan/lvm',
+    },
+
+    setNodeName: function(value) {
+	let me = this;
+	me.callParent([value]);
+	me.getStore().load();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    autoLoad: {},
+	    fields: ['vg', 'size', 'free'],
+	    proxy: {
+		type: 'proxmox',
+		url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
+	    },
+	});
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.LvmThinInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage_lvmthin',
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+
+	    name: 'vgname',
+	    fieldLabel: gettext('Volume group'),
+
+	    editConfig: {
+		xtype: 'pveBaseVGSelector',
+		listeners: {
+		    nodechanged: function(value) {
+			let panel = this.up('inputpanel');
+			panel.lookup('thinPoolSelector').setNodeName(value);
+			panel.lookup('storageNodeRestriction').setValue(value);
+		    },
+		    change: function(f, value) {
+			let vgField = this.up('inputpanel').lookup('thinPoolSelector');
+			if (vgField && !f.isDisabled()) {
+			    vgField.setDisabled(!value);
+			    vgField.setVG(value);
+			    vgField.setValue('');
+			}
+		    },
+		},
+	    },
+	},
+	{
+	    xtype: 'pmxDisplayEditField',
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+
+	    name: 'thinpool',
+	    fieldLabel: gettext('Thin Pool'),
+	    allowBlank: false,
+
+	    editConfig: {
+		xtype: 'pveTPSelector',
+		reference: 'thinPoolSelector',
+		disabled: true,
+	    },
+	},
+	{
+	    xtype: 'pveContentTypeSelector',
+	    cts: ['images', 'rootdir'],
+	    fieldLabel: gettext('Content'),
+	    name: 'content',
+	    value: ['images', 'rootdir'],
+	    multiSelect: true,
+	    allowBlank: false,
+	},
+    ],
+});
+Ext.define('PVE.storage.BTRFSInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    onlineHelp: 'storage_btrfs',
+
+    initComponent: function() {
+	let me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'path',
+		value: '',
+		fieldLabel: gettext('Path'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'pveContentTypeSelector',
+		name: 'content',
+		value: ['images', 'rootdir'],
+		multiSelect: true,
+		fieldLabel: gettext('Content'),
+		allowBlank: false,
+	    },
+	];
+
+	me.columnB = [
+	    {
+		xtype: 'displayfield',
+		userCls: 'pmx-hint',
+		value: `BTRFS integration is currently a technology preview.`,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.NFSScan', {
+    extend: 'Ext.form.field.ComboBox',
+    alias: 'widget.pveNFSScan',
+
+    queryParam: 'server',
+
+    valueField: 'path',
+    displayField: 'path',
+    matchFieldWidth: false,
+    listConfig: {
+	loadingText: gettext('Scanning...'),
+	width: 350,
+    },
+    doRawQuery: function() {
+	// do nothing
+    },
+
+    onTriggerClick: function() {
+	var me = this;
+
+	if (!me.queryCaching || me.lastQuery !== me.nfsServer) {
+	    me.store.removeAll();
+	}
+
+	me.allQuery = me.nfsServer;
+
+	me.callParent();
+    },
+
+    setServer: function(server) {
+	var me = this;
+
+	me.nfsServer = server;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	var store = Ext.create('Ext.data.Store', {
+	    fields: ['path', 'options'],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/nodes/' + me.nodename + '/scan/nfs',
+	    },
+	});
+
+	store.sort('path', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.NFSInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    onlineHelp: 'storage_nfs',
+
+    options: [],
+
+    onGetValues: function(values) {
+	var me = this;
+
+	var i;
+	var res = [];
+	for (i = 0; i < me.options.length; i++) {
+	    var item = me.options[i];
+	    if (!item.match(/^vers=(.*)$/)) {
+		res.push(item);
+	    }
+	}
+	if (values.nfsversion && values.nfsversion !== '__default__') {
+	    res.push('vers=' + values.nfsversion);
+	}
+	delete values.nfsversion;
+	values.options = res.join(',');
+	if (values.options === '') {
+	    delete values.options;
+	    if (!me.isCreate) {
+		values.delete = "options";
+	    }
+	}
+
+	return me.callParent([values]);
+    },
+
+    setValues: function(values) {
+	var me = this;
+	if (values.options) {
+	    me.options = values.options.split(',');
+	    me.options.forEach(function(item) {
+		var match = item.match(/^vers=(.*)$/);
+		if (match) {
+		    values.nfsversion = match[1];
+		}
+	    });
+	}
+	return me.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'server',
+		value: '',
+		fieldLabel: gettext('Server'),
+		allowBlank: false,
+		listeners: {
+		    change: function(f, value) {
+			if (me.isCreate) {
+			    var exportField = me.down('field[name=export]');
+			    exportField.setServer(value);
+			    exportField.setValue('');
+			}
+		    },
+		},
+	    },
+	    {
+		xtype: me.isCreate ? 'pveNFSScan' : 'displayfield',
+		name: 'export',
+		value: '',
+		fieldLabel: 'Export',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'pveContentTypeSelector',
+		name: 'content',
+		value: 'images',
+		multiSelect: true,
+		fieldLabel: gettext('Content'),
+		allowBlank: false,
+	    },
+	];
+
+	me.advancedColumn2 = [
+	    {
+		xtype: 'proxmoxKVComboBox',
+		fieldLabel: gettext('NFS Version'),
+		name: 'nfsversion',
+		value: '__default__',
+		deleteEmpty: false,
+		comboItems: [
+			['__default__', Proxmox.Utils.defaultText],
+			['3', '3'],
+			['4', '4'],
+			['4.1', '4.1'],
+			['4.2', '4.2'],
+		],
+	    },
+	];
+
+	me.callParent();
+    },
+});
+/*global QRCode*/
+Ext.define('PVE.Storage.PBSKeyShow', {
+    extend: 'Ext.window.Window',
+    xtype: 'pvePBSKeyShow',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 600,
+    modal: true,
+    resizable: false,
+    title: gettext('Important: Save your Encryption Key'),
+
+    // avoid that esc closes this by mistake, force user to more manual action
+    onEsc: Ext.emptyFn,
+    closable: false,
+
+    items: [
+	{
+	    xtype: 'form',
+	    layout: {
+		type: 'vbox',
+		align: 'stretch',
+	    },
+	    bodyPadding: 10,
+	    border: false,
+	    defaults: {
+		anchor: '100%',
+		border: false,
+		padding: '10 0 0 0',
+            },
+	    items: [
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Key'),
+		    labelWidth: 80,
+		    inputId: 'encryption-key-value',
+		    cbind: {
+			value: '{key}',
+		    },
+		    editable: false,
+		},
+		{
+		    xtype: 'component',
+		    html: gettext('Keep your encryption key safe, but easily accessible for disaster recovery.')
+		        + '<br>' + gettext('We recommend the following safe-keeping strategy:'),
+		},
+		{
+		    xtyp: 'container',
+		    layout: 'hbox',
+		    items: [
+			{
+			    xtype: 'component',
+			    html: '1. ' + gettext('Save the key in your password manager.'),
+			    flex: 1,
+			},
+			{
+			    xtype: 'button',
+			    text: gettext('Copy Key'),
+			    iconCls: 'fa fa-clipboard x-btn-icon-el-default-toolbar-small',
+			    cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+			    width: 110,
+			    handler: function(b) {
+				document.getElementById('encryption-key-value').select();
+				document.execCommand("copy");
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    layout: 'hbox',
+		    items: [
+			{
+			    xtype: 'component',
+			    html: '2. ' + gettext('Download the key to a USB (pen) drive, placed in secure vault.'),
+			    flex: 1,
+			},
+			{
+			    xtype: 'button',
+			    text: gettext('Download'),
+			    iconCls: 'fa fa-download x-btn-icon-el-default-toolbar-small',
+			    cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+			    width: 110,
+			    handler: function(b) {
+				let win = this.up('window');
+
+				let pveID = PVE.ClusterName || window.location.hostname;
+				let name = `pve-${pveID}-storage-${win.sid}.enc`;
+
+				let hiddenElement = document.createElement('a');
+				hiddenElement.href = 'data:attachment/text,' + encodeURI(win.key);
+				hiddenElement.target = '_blank';
+				hiddenElement.download = name;
+				hiddenElement.click();
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    layout: 'hbox',
+		    items: [
+			{
+			    xtype: 'component',
+			    html: '3. ' + gettext('Print as paperkey, laminated and placed in secure vault.'),
+			    flex: 1,
+			},
+			{
+			    xtype: 'button',
+			    text: gettext('Print Key'),
+			    iconCls: 'fa fa-print x-btn-icon-el-default-toolbar-small',
+			    cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+			    width: 110,
+			    handler: function(b) {
+				let win = this.up('window');
+				win.paperkey(win.key);
+			    },
+			},
+		    ],
+		},
+	    ],
+	},
+	{
+	    xtype: 'component',
+	    border: false,
+	    padding: '10 10 10 10',
+	    userCls: 'pmx-hint',
+	    html: gettext('Please save the encryption key - losing it will render any backup created with it unusable'),
+	},
+    ],
+    buttons: [
+	{
+	    text: gettext('Close'),
+	    handler: function(b) {
+		let win = this.up('window');
+		win.close();
+	    },
+	},
+    ],
+    paperkey: function(keyString) {
+	let me = this;
+
+	const key = JSON.parse(keyString);
+
+	const qrwidth = 500;
+	let qrdiv = document.createElement('div');
+	let qrcode = new QRCode(qrdiv, {
+	    width: qrwidth,
+	    height: qrwidth,
+	    correctLevel: QRCode.CorrectLevel.H,
+	});
+	qrcode.makeCode(keyString);
+
+	let shortKeyFP = '';
+	if (key.fingerprint) {
+	    shortKeyFP = PVE.Utils.render_pbs_fingerprint(key.fingerprint);
+	}
+
+	let printFrame = document.createElement("iframe");
+	Object.assign(printFrame.style, {
+	    position: "fixed",
+	    right: "0",
+	    bottom: "0",
+	    width: "0",
+	    height: "0",
+	    border: "0",
+	});
+	const prettifiedKey = JSON.stringify(key, null, 2);
+	const keyQrBase64 = qrdiv.children[0].toDataURL("image/png");
+	const html = `<html><head><script>
+	    window.addEventListener('DOMContentLoaded', (ev) => window.print());
+	</script><style>@media print and (max-height: 150mm) {
+	  h4, p { margin: 0; font-size: 1em; }
+	}</style></head><body style="padding: 5px;">
+	<h4>Encryption Key - Storage '${me.sid}' (${shortKeyFP})</h4>
+<p style="font-size:1.2em;font-family:monospace;white-space:pre-wrap;overflow-wrap:break-word;">
+-----BEGIN PROXMOX BACKUP KEY-----
+${prettifiedKey}
+-----END PROXMOX BACKUP KEY-----</p>
+	<center><img style="width: 100%; max-width: ${qrwidth}px;" src="${keyQrBase64}"></center>
+	</body></html>`;
+
+	printFrame.src = "data:text/html;base64," + btoa(html);
+	document.body.appendChild(printFrame);
+	me.on('destroy', () => document.body.removeChild(printFrame));
+    },
+});
+
+Ext.define('PVE.panel.PBSEncryptionKeyTab', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pvePBSEncryptionKeyTab',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage_pbs_encryption',
+
+    onGetValues: function(form) {
+	let values = {};
+	if (form.cryptMode === 'upload') {
+	    values['encryption-key'] = form['crypt-key-upload'];
+	} else if (form.cryptMode === 'autogenerate') {
+	    values['encryption-key'] = 'autogen';
+	} else if (form.cryptMode === 'none') {
+	    if (!this.isCreate) {
+		values.delete = ['encryption-key'];
+	    }
+	}
+	return values;
+    },
+
+    setValues: function(values) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	let cryptKeyInfo = values['encryption-key'];
+	if (cryptKeyInfo) {
+	    let icon = '<span class="fa fa-lock good"></span> ';
+	    if (cryptKeyInfo.match(/^[a-fA-F0-9]{2}:/)) { // new style fingerprint
+		let shortKeyFP = PVE.Utils.render_pbs_fingerprint(cryptKeyInfo);
+		values['crypt-key-fp'] = icon + `${gettext('Active')} - ${gettext('Fingerprint')} ${shortKeyFP}`;
+	    } else {
+		// old key without FP
+		values['crypt-key-fp'] = icon + gettext('Active');
+	    }
+	} else {
+	    values['crypt-key-fp'] = gettext('None');
+	    let cryptModeNone = me.down('radiofield[inputValue=none]');
+	    cryptModeNone.setBoxLabel(gettext('Do not encrypt backups'));
+	    cryptModeNone.setValue(true);
+	}
+	vm.set('keepCryptVisible', !!cryptKeyInfo);
+	vm.set('allowEdit', !cryptKeyInfo);
+
+	me.callParent([values]);
+    },
+
+    viewModel: {
+	data: {
+	    allowEdit: true,
+	    keepCryptVisible: false,
+	},
+	formulas: {
+	    showDangerousHint: get => {
+		let allowEdit = get('allowEdit');
+		return get('keepCryptVisible') && allowEdit;
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    name: 'crypt-key-fp',
+	    fieldLabel: gettext('Encryption Key'),
+	    padding: '2 0',
+	},
+	{
+	    xtype: 'checkbox',
+	    name: 'crypt-allow-edit',
+	    boxLabel: gettext('Edit existing encryption key (dangerous!)'),
+	    hidden: true,
+	    submitValue: false,
+	    isDirty: () => false,
+	    bind: {
+		hidden: '{!keepCryptVisible}',
+		value: '{allowEdit}',
+	    },
+	},
+	{
+	    xtype: 'radiofield',
+	    name: 'cryptMode',
+	    inputValue: 'keep',
+	    boxLabel: gettext('Keep encryption key'),
+	    padding: '0 0 0 25',
+	    cbind: {
+		hidden: '{isCreate}',
+		checked: '{!isCreate}',
+	    },
+	    bind: {
+		hidden: '{!keepCryptVisible}',
+		disabled: '{!allowEdit}',
+	    },
+	},
+	{
+	    xtype: 'radiofield',
+	    name: 'cryptMode',
+	    inputValue: 'none',
+	    checked: true,
+	    padding: '0 0 0 25',
+	    cbind: {
+		disabled: '{!isCreate}',
+		checked: '{isCreate}',
+		boxLabel: get => get('isCreate')
+		    ? gettext('Do not encrypt backups')
+		    : gettext('Delete existing encryption key'),
+	    },
+	    bind: {
+		disabled: '{!allowEdit}',
+	    },
+	},
+	{
+	    xtype: 'radiofield',
+	    name: 'cryptMode',
+	    inputValue: 'autogenerate',
+	    boxLabel: gettext('Auto-generate a client encryption key'),
+	    padding: '0 0 0 25',
+	    cbind: {
+		disabled: '{!isCreate}',
+	    },
+	    bind: {
+		disabled: '{!allowEdit}',
+	    },
+	},
+	{
+	    xtype: 'radiofield',
+	    name: 'cryptMode',
+	    inputValue: 'upload',
+	    boxLabel: gettext('Upload an existing client encryption key'),
+	    padding: '0 0 0 25',
+	    cbind: {
+		disabled: '{!isCreate}',
+	    },
+	    bind: {
+		disabled: '{!allowEdit}',
+	    },
+	    listeners: {
+		change: function(f, value) {
+		    let panel = this.up('inputpanel');
+		    if (!panel.rendered) {
+			return;
+		    }
+		    let uploadKeyField = panel.down('field[name=crypt-key-upload]');
+		    uploadKeyField.setDisabled(!value);
+		    uploadKeyField.setHidden(!value);
+
+		    let uploadKeyButton = panel.down('filebutton[name=crypt-upload-button]');
+		    uploadKeyButton.setDisabled(!value);
+		    uploadKeyButton.setHidden(!value);
+
+		    if (value) {
+			uploadKeyField.validate();
+		    } else {
+			uploadKeyField.reset();
+		    }
+		},
+	    },
+	},
+	{
+	    xtype: 'fieldcontainer',
+	    layout: 'hbox',
+	    items: [
+		{
+		    xtype: 'proxmoxtextfield',
+		    name: 'crypt-key-upload',
+		    fieldLabel: gettext('Key'),
+		    value: '',
+		    disabled: true,
+		    hidden: true,
+		    allowBlank: false,
+		    labelAlign: 'right',
+		    flex: 1,
+		    emptyText: gettext('You can drag-and-drop a key file here.'),
+		    validator: function(value) {
+			if (value.length) {
+			    let key;
+			    try {
+				key = JSON.parse(value);
+			    } catch (e) {
+				return "Failed to parse key - " + e;
+			    }
+			    if (key.data === undefined) {
+				return "Does not seems like a valid Proxmox Backup key!";
+			    }
+			}
+			return true;
+		    },
+		    afterRender: function() {
+			if (!window.FileReader) {
+			    // No FileReader support in this browser
+			    return;
+			}
+			let cancel = function(ev) {
+			    ev = ev.event;
+			    if (ev.preventDefault) {
+				ev.preventDefault();
+			    }
+			};
+			this.inputEl.on('dragover', cancel);
+			this.inputEl.on('dragenter', cancel);
+			this.inputEl.on('drop', ev => {
+			    cancel(ev);
+			    let files = ev.event.dataTransfer.files;
+			    PVE.Utils.loadTextFromFile(files[0], v => this.setValue(v));
+			});
+		    },
+		},
+		{
+		    xtype: 'filebutton',
+		    name: 'crypt-upload-button',
+		    iconCls: 'fa fa-fw fa-folder-open-o x-btn-icon-el-default-toolbar-small',
+		    cls: 'x-btn-default-toolbar-small proxmox-inline-button',
+		    margin: '0 0 0 4',
+		    disabled: true,
+		    hidden: true,
+		    listeners: {
+			change: function(btn, e, value) {
+			    let ev = e.event;
+			    let field = btn.up().down('proxmoxtextfield[name=crypt-key-upload]');
+			    PVE.Utils.loadTextFromFile(ev.target.files[0], v => field.setValue(v));
+			    btn.reset();
+			},
+		    },
+		},
+	    ],
+	},
+	{
+	    xtype: 'component',
+	    border: false,
+	    padding: '5 2',
+	    userCls: 'pmx-hint',
+	    html: // `<b style="color:red;font-weight:600;">${gettext('Warning')}</b>: ` +
+	      `<span class="fa fa-exclamation-triangle" style="color:red;font-size:14px;"></span> ` +
+	      gettext('Deleting or replacing the encryption key will break restoring backups created with it!'),
+	    hidden: true,
+	    bind: {
+		hidden: '{!showDangerousHint}',
+	    },
+	},
+    ],
+});
+
+Ext.define('PVE.storage.PBSInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    onlineHelp: 'storage_pbs',
+
+    apiCallDone: function(success, response, options) {
+	let res = response.result.data;
+	if (!(res && res.config && res.config['encryption-key'])) {
+	    return;
+	}
+	let key = res.config['encryption-key'];
+	Ext.create('PVE.Storage.PBSKeyShow', {
+	    autoShow: true,
+	    sid: res.storage,
+	    key: key,
+	});
+    },
+
+    isPBS: true, // HACK
+
+    extraTabs: [
+	{
+	    xtype: 'pvePBSEncryptionKeyTab',
+	    title: gettext('Encryption'),
+	},
+    ],
+
+    setValues: function(values) {
+	let me = this;
+
+	let server = values.server;
+	if (values.port !== undefined) {
+	    if (Proxmox.Utils.IP6_match.test(server)) {
+		server = `[${server}]`;
+	    }
+	    server += `:${values.port}`;
+	}
+	values.hostport = server;
+
+	return me.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
+		fieldLabel: gettext('Server'),
+		allowBlank: false,
+		name: 'hostport',
+		submitValue: false,
+		vtype: 'HostPort',
+		listeners: {
+		    change: function(field, newvalue) {
+			let server = newvalue;
+			let port;
+
+			let match = Proxmox.Utils.HostPort_match.exec(newvalue);
+			if (match === null) {
+			    match = Proxmox.Utils.HostPortBrackets_match.exec(newvalue);
+			    if (match === null) {
+				match = Proxmox.Utils.IP6_dotnotation_match.exec(newvalue);
+			    }
+			}
+
+			if (match !== null) {
+			    server = match[1];
+			    if (match[2] !== undefined) {
+				port = match[2];
+			    }
+			}
+
+			field.up('inputpanel').down('field[name=server]').setValue(server);
+			field.up('inputpanel').down('field[name=port]').setValue(port);
+		    },
+		},
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		hidden: true,
+		name: 'server',
+		submitValue: me.isCreate, // it is fixed
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		hidden: true,
+		deleteEmpty: !me.isCreate,
+		name: 'port',
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'username',
+		value: '',
+		emptyText: gettext('Example') + ': admin@pbs',
+		fieldLabel: gettext('Username'),
+		regex: /\S+@\w+/,
+		regexText: gettext('Example') + ': admin@pbs',
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		inputType: 'password',
+		name: 'password',
+		value: me.isCreate ? '' : '********',
+		emptyText: me.isCreate ? gettext('None') : '',
+		fieldLabel: gettext('Password'),
+		allowBlank: false,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'displayfield',
+		name: 'content',
+		value: 'backup',
+		submitValue: true,
+		fieldLabel: gettext('Content'),
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'datastore',
+		value: '',
+		fieldLabel: 'Datastore',
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'namespace',
+		value: '',
+		emptyText: gettext('Root'),
+		fieldLabel: gettext('Namespace'),
+		allowBlank: true,
+	    },
+	];
+
+	me.columnB = [
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'fingerprint',
+		value: me.isCreate ? null : undefined,
+		fieldLabel: gettext('Fingerprint'),
+		emptyText: gettext('Server certificate SHA-256 fingerprint, required for self-signed certificates'),
+		regex: /[A-Fa-f0-9]{2}(:[A-Fa-f0-9]{2}){31}/,
+		regexText: gettext('Example') + ': AB:CD:EF:...',
+		deleteEmpty: !me.isCreate,
+		allowBlank: true,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.Ceph.Model', {
+    extend: 'Ext.app.ViewModel',
+    alias: 'viewmodel.cephstorage',
+
+    data: {
+	pveceph: true,
+	pvecephPossible: true,
+	namespacePresent: false,
+    },
+});
+
+Ext.define('PVE.storage.Ceph.Controller', {
+    extend: 'PVE.controller.StorageEdit',
+    alias: 'controller.cephstorage',
+
+    control: {
+	'#': {
+	    afterrender: 'queryMonitors',
+	},
+	'textfield[name=username]': {
+	    disable: 'resetField',
+	},
+	'displayfield[name=monhost]': {
+	    enable: 'queryMonitors',
+	},
+	'textfield[name=monhost]': {
+	    disable: 'resetField',
+	    enable: 'resetField',
+	},
+	'textfield[name=namespace]': {
+	    change: 'updateNamespaceHint',
+	},
+    },
+    resetField: function(field) {
+	field.reset();
+    },
+    updateNamespaceHint: function(field, newVal, oldVal) {
+	this.getViewModel().set('namespacePresent', newVal);
+    },
+    queryMonitors: function(field, newVal, oldVal) {
+	// we get called with two signatures, the above one for a field
+	// change event and the afterrender from the view, this check only
+	// can be true for the field change one and omit the API request if
+	// pveceph got unchecked - as it's not needed there.
+	if (field && !newVal && oldVal) {
+	    return;
+	}
+	var view = this.getView();
+	var vm = this.getViewModel();
+	if (!(view.isCreate || vm.get('pveceph'))) {
+	    return; // only query on create or if editing a pveceph store
+	}
+
+	var monhostField = this.lookupReference('monhost');
+
+	Proxmox.Utils.API2Request({
+	    url: '/api2/json/nodes/localhost/ceph/mon',
+	    method: 'GET',
+	    scope: this,
+	    callback: function(options, success, response) {
+		var data = response.result.data;
+		if (response.status === 200) {
+		    if (data.length > 0) {
+			var monhost = Ext.Array.pluck(data, 'name').sort().join(',');
+			monhostField.setValue(monhost);
+			monhostField.resetOriginalValue();
+			if (view.isCreate) {
+			    vm.set('pvecephPossible', true);
+			}
+		    } else {
+			vm.set('pveceph', false);
+		    }
+		} else {
+		    vm.set('pveceph', false);
+		    vm.set('pvecephPossible', false);
+		}
+	    },
+	});
+    },
+});
+
+Ext.define('PVE.storage.RBDInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+    controller: 'cephstorage',
+
+    onlineHelp: 'ceph_rados_block_devices',
+
+    viewModel: {
+	type: 'cephstorage',
+    },
+
+    setValues: function(values) {
+	if (values.monhost) {
+	    this.viewModel.set('pveceph', false);
+	    this.lookupReference('pvecephRef').setValue(false);
+	    this.lookupReference('pvecephRef').resetOriginalValue();
+	}
+	if (values.namespace) {
+	    this.getViewModel().set('namespacePresent', true);
+	}
+	this.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+	me.type = 'rbd';
+
+	me.column1 = [];
+
+	if (me.isCreate) {
+	    me.column1.push({
+		xtype: 'pveCephPoolSelector',
+		nodename: me.nodename,
+		name: 'pool',
+		bind: {
+		    disabled: '{!pveceph}',
+		    submitValue: '{pveceph}',
+		    hidden: '{!pveceph}',
+		},
+		fieldLabel: gettext('Pool'),
+		allowBlank: false,
+	    }, {
+		xtype: 'textfield',
+		name: 'pool',
+		value: 'rbd',
+		bind: {
+		    disabled: '{pveceph}',
+		    submitValue: '{!pveceph}',
+		    hidden: '{pveceph}',
+		},
+		fieldLabel: gettext('Pool'),
+		allowBlank: false,
+	    });
+	} else {
+	    me.column1.push({
+		xtype: 'displayfield',
+		nodename: me.nodename,
+		name: 'pool',
+		fieldLabel: gettext('Pool'),
+		allowBlank: false,
+	    });
+	}
+
+	me.column1.push(
+	    {
+		xtype: 'textfield',
+		name: 'monhost',
+		vtype: 'HostList',
+		bind: {
+		    disabled: '{pveceph}',
+		    submitValue: '{!pveceph}',
+		    hidden: '{pveceph}',
+		},
+		value: '',
+		fieldLabel: 'Monitor(s)',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'displayfield',
+		reference: 'monhost',
+		bind: {
+		    disabled: '{!pveceph}',
+		    hidden: '{!pveceph}',
+		},
+		value: '',
+		fieldLabel: 'Monitor(s)',
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'username',
+		bind: {
+		    disabled: '{pveceph}',
+		    submitValue: '{!pveceph}',
+		},
+		value: 'admin',
+		fieldLabel: gettext('User name'),
+		allowBlank: true,
+	    },
+	);
+
+	me.column2 = [
+	    {
+		xtype: 'pveContentTypeSelector',
+		cts: ['images', 'rootdir'],
+		fieldLabel: gettext('Content'),
+		name: 'content',
+		value: ['images'],
+		multiSelect: true,
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'krbd',
+		uncheckedValue: 0,
+		fieldLabel: 'KRBD',
+	    },
+	];
+
+	me.columnB = [
+	    {
+		xtype: me.isCreate ? 'textarea' : 'displayfield',
+		name: 'keyring',
+		fieldLabel: 'Keyring',
+		value: me.isCreate ? '' : '***********',
+		allowBlank: false,
+		bind: {
+		    hidden: '{pveceph}',
+		    disabled: '{pveceph}',
+		},
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'pveceph',
+		reference: 'pvecephRef',
+		bind: {
+		    disabled: '{!pvecephPossible}',
+		    value: '{pveceph}',
+		},
+		checked: true,
+		uncheckedValue: 0,
+		submitValue: false,
+		hidden: !me.isCreate,
+		boxLabel: gettext('Use Proxmox VE managed hyper-converged ceph pool'),
+	    },
+	];
+
+	me.advancedColumn1 = [
+	    {
+		xtype: 'pmxDisplayEditField',
+		editable: me.isCreate,
+		name: 'namespace',
+		value: '',
+		fieldLabel: gettext('Namespace'),
+		allowBlank: true,
+	    },
+	];
+	me.advancedColumn2 = [
+	    {
+		xtype: 'displayfield',
+		name: 'namespace-hint',
+		userCls: 'pmx-hint',
+		value: gettext('RBD namespaces must be created manually!'),
+		bind: {
+		    hidden: '{!namespacePresent}',
+		},
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.StatusView', {
+    extend: 'Proxmox.panel.StatusView',
+    alias: 'widget.pveStorageStatusView',
+
+    height: 230,
+    title: gettext('Status'),
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    defaults: {
+	xtype: 'pmxInfoWidget',
+	padding: '0 30 5 30',
+    },
+    items: [
+	{
+	    xtype: 'box',
+	    height: 30,
+	},
+	{
+	    itemId: 'enabled',
+	    title: gettext('Enabled'),
+	    printBar: false,
+	    textField: 'disabled',
+	    renderer: Proxmox.Utils.format_neg_boolean,
+	},
+	{
+	    itemId: 'active',
+	    title: gettext('Active'),
+	    printBar: false,
+	    textField: 'active',
+	    renderer: Proxmox.Utils.format_boolean,
+	},
+	{
+	    itemId: 'content',
+	    title: gettext('Content'),
+	    printBar: false,
+	    textField: 'content',
+	    renderer: PVE.Utils.format_content_types,
+	},
+	{
+	    itemId: 'type',
+	    title: gettext('Type'),
+	    printBar: false,
+	    textField: 'type',
+	    renderer: PVE.Utils.format_storage_type,
+	},
+	{
+	    xtype: 'box',
+	    height: 10,
+	},
+	{
+	    itemId: 'usage',
+	    title: gettext('Usage'),
+	    valueField: 'used',
+	    maxField: 'total',
+	    renderer: (val, max) => {
+		if (max === undefined) {
+		    return val;
+		}
+		return Proxmox.Utils.render_size_usage(val, max, true);
+	    },
+	},
+    ],
+
+    updateTitle: function() {
+	// nothing
+    },
+});
+Ext.define('PVE.storage.Summary', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveStorageSummary',
+    scrollable: true,
+    bodyPadding: 5,
+    tbar: [
+	'->',
+	{
+	    xtype: 'proxmoxRRDTypeSelector',
+	},
+    ],
+    layout: {
+	type: 'column',
+    },
+    defaults: {
+	padding: 5,
+	columnWidth: 1,
+    },
+    initComponent: function() {
+        var me = this;
+
+	var nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var storage = me.pveSelNode.data.storage;
+	if (!storage) {
+	    throw "no storage ID specified";
+	}
+
+	var rstore = Ext.create('Proxmox.data.ObjectStore', {
+	    url: "/api2/json/nodes/" + nodename + "/storage/" + storage + "/status",
+	    interval: 1000,
+	});
+
+	var rrdstore = Ext.create('Proxmox.data.RRDStore', {
+	    rrdurl: "/api2/json/nodes/" + nodename + "/storage/" + storage + "/rrddata",
+	    model: 'pve-rrd-storage',
+	});
+
+	Ext.apply(me, {
+	    items: [
+		{
+		    xtype: 'pveStorageStatusView',
+		    pveSelNode: me.pveSelNode,
+		    rstore: rstore,
+		},
+		{
+		    xtype: 'proxmoxRRDChart',
+		    title: gettext('Usage'),
+		    fields: ['total', 'used'],
+		    fieldTitles: ['Total Size', 'Used Size'],
+		    store: rrdstore,
+		},
+	    ],
+	    listeners: {
+		activate: function() { rstore.startUpdate(); rrdstore.startUpdate(); },
+		destroy: function() { rstore.stopUpdate(); rrdstore.stopUpdate(); },
+	    },
+	});
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.grid.TemplateSelector', {
+    extend: 'Ext.grid.GridPanel',
+
+    alias: 'widget.pveTemplateSelector',
+
+    stateful: true,
+    stateId: 'grid-template-selector',
+    viewConfig: {
+	trackOver: false,
+    },
+    initComponent: function() {
+	var me = this;
+
+	if (!me.nodename) {
+	    throw "no node name specified";
+	}
+
+	var baseurl = "/nodes/" + me.nodename + "/aplinfo";
+	var store = new Ext.data.Store({
+	    model: 'pve-aplinfo',
+	    groupField: 'section',
+	    proxy: {
+                type: 'proxmox',
+		url: '/api2/json' + baseurl,
+	    },
+	});
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
+            groupHeaderTpl: '{[ "Section: " + values.name ]} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})',
+	});
+
+	var reload = function() {
+	    store.load();
+	};
+
+	Proxmox.Utils.monStoreErrors(me, store);
+
+	Ext.apply(me, {
+	    store: store,
+	    selModel: sm,
+	    tbar: [
+		'->',
+		gettext('Search'),
+		{
+		    xtype: 'textfield',
+		    width: 200,
+		    enableKeyEvents: true,
+		    listeners: {
+			buffer: 500,
+			keyup: function(field) {
+			    var value = field.getValue().toLowerCase();
+			    store.clearFilter(true);
+			    store.filterBy(function(rec) {
+				return rec.data.package.toLowerCase().indexOf(value) !== -1 ||
+				rec.data.headline.toLowerCase().indexOf(value) !== -1;
+			    });
+			},
+		    },
+		},
+	    ],
+	    features: [groupingFeature],
+	    columns: [
+		{
+		    header: gettext('Type'),
+		    width: 80,
+		    dataIndex: 'type',
+		},
+		{
+		    header: gettext('Package'),
+		    flex: 1,
+		    dataIndex: 'package',
+		},
+		{
+		    header: gettext('Version'),
+		    width: 80,
+		    dataIndex: 'version',
+		},
+		{
+		    header: gettext('Description'),
+		    flex: 1.5,
+		    renderer: Ext.String.htmlEncode,
+		    dataIndex: 'headline',
+		},
+	    ],
+	    listeners: {
+		afterRender: reload,
+	    },
+	});
+
+	me.callParent();
+    },
+
+}, function() {
+    Ext.define('pve-aplinfo', {
+	extend: 'Ext.data.Model',
+	fields: [
+	    'template', 'type', 'package', 'version', 'headline', 'infopage',
+	    'description', 'os', 'section',
+	],
+	idProperty: 'template',
+    });
+});
+
+Ext.define('PVE.storage.TemplateDownload', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pveTemplateDownload',
+
+    modal: true,
+    title: gettext('Templates'),
+    layout: 'fit',
+    width: 900,
+    height: 600,
+    initComponent: function() {
+        var me = this;
+
+	var grid = Ext.create('PVE.grid.TemplateSelector', {
+	    border: false,
+	    scrollable: true,
+	    nodename: me.nodename,
+	});
+
+	var sm = grid.getSelectionModel();
+
+	var submitBtn = Ext.create('Proxmox.button.Button', {
+	    text: gettext('Download'),
+	    disabled: true,
+	    selModel: sm,
+	    handler: function(button, event, rec) {
+		Proxmox.Utils.API2Request({
+		    url: '/nodes/' + me.nodename + '/aplinfo',
+		    params: {
+			storage: me.storage,
+			template: rec.data.template,
+		    },
+		    method: 'POST',
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    },
+		    success: function(response, options) {
+			var upid = response.result.data;
+
+			Ext.create('Proxmox.window.TaskViewer', {
+			    upid: upid,
+			    listeners: {
+				destroy: me.reloadGrid,
+			    },
+			}).show();
+
+			me.close();
+		    },
+		});
+	    },
+	});
+
+        Ext.apply(me, {
+	    items: grid,
+	    buttons: [submitBtn],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.TemplateView', {
+    extend: 'PVE.storage.ContentView',
+
+    alias: 'widget.pveStorageTemplateView',
+
+    initComponent: function() {
+	var me = this;
+
+	var nodename = me.nodename = me.pveSelNode.data.node;
+	if (!nodename) {
+	    throw "no node name specified";
+	}
+
+	var storage = me.storage = me.pveSelNode.data.storage;
+	if (!storage) {
+	    throw "no storage ID specified";
+	}
+
+	me.content = 'vztmpl';
+
+	var reload = function() {
+	    me.store.load();
+	};
+
+	var templateButton = Ext.create('Proxmox.button.Button', {
+	    itemId: 'tmpl-btn',
+	    text: gettext('Templates'),
+	    handler: function() {
+		var win = Ext.create('PVE.storage.TemplateDownload', {
+		    nodename: nodename,
+		    storage: storage,
+		    reloadGrid: reload,
+		});
+		win.show();
+	    },
+	});
+
+	me.tbar = [templateButton];
+	me.useUploadButton = true;
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.ZFSInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    viewModel: {
+	parent: null,
+	data: {
+	    isLIO: false,
+	    isComstar: true,
+	    hasWriteCacheOption: true,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'field[name=iscsiprovider]': {
+		change: 'changeISCSIProvider',
+	    },
+	},
+	changeISCSIProvider: function(f, newVal, oldVal) {
+	    var vm = this.getViewModel();
+	    vm.set('isLIO', newVal === 'LIO');
+	    vm.set('isComstar', newVal === 'comstar');
+	    vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'istgt');
+	},
+    },
+
+    onGetValues: function(values) {
+	var me = this;
+
+	if (me.isCreate) {
+	    values.content = 'images';
+	}
+
+	values.nowritecache = values.writecache ? 0 : 1;
+	delete values.writecache;
+
+	return me.callParent([values]);
+    },
+
+    setValues: function(values) {
+	values.writecache = values.nowritecache ? 0 : 1;
+	this.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'portal',
+		value: '',
+		fieldLabel: gettext('Portal'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'pool',
+		value: '',
+		fieldLabel: gettext('Pool'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'blocksize',
+		value: '4k',
+		fieldLabel: gettext('Block Size'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'target',
+		value: '',
+		fieldLabel: gettext('Target'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'comstar_tg',
+		value: '',
+		fieldLabel: gettext('Target group'),
+		bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' },
+		allowBlank: true,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: me.isCreate ? 'pveiScsiProviderSelector' : 'displayfield',
+		name: 'iscsiprovider',
+		value: 'comstar',
+		fieldLabel: gettext('iSCSI Provider'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'sparse',
+		checked: false,
+		uncheckedValue: 0,
+		fieldLabel: gettext('Thin provision'),
+	    },
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'writecache',
+		checked: true,
+		bind: me.isCreate ? { disabled: '{!hasWriteCacheOption}' } : { hidden: '{!hasWriteCacheOption}' },
+		uncheckedValue: 0,
+		fieldLabel: gettext('Write cache'),
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'comstar_hg',
+		value: '',
+		bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' },
+		fieldLabel: gettext('Host group'),
+		allowBlank: true,
+	    },
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'lio_tpg',
+		value: '',
+		bind: me.isCreate ? { disabled: '{!isLIO}' } : { hidden: '{!isLIO}' },
+		allowBlank: false,
+		fieldLabel: gettext('Target portal group'),
+	    },
+	];
+
+	me.callParent();
+    },
+});
+Ext.define('PVE.storage.ZFSPoolSelector', {
+    extend: 'PVE.form.ComboBoxSetStoreNode',
+    alias: 'widget.pveZFSPoolSelector',
+    valueField: 'pool',
+    displayField: 'pool',
+    queryMode: 'local',
+    editable: false,
+    allowBlank: false,
+
+    listConfig: {
+	columns: [
+	    {
+		dataIndex: 'pool',
+		flex: 1,
+	    },
+	],
+	emptyText: PVE.Utils.renderNotFound(gettext('ZFS Pool')),
+    },
+
+    config: {
+	apiSuffix: '/scan/zfs',
+    },
+
+    showNodeSelector: true,
+
+    setNodeName: function(value) {
+	let me = this;
+	me.callParent([value]);
+	me.getStore().load();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	if (!me.nodename) {
+	    me.nodename = 'localhost';
+	}
+
+	let store = Ext.create('Ext.data.Store', {
+	    autoLoad: {}, // true,
+	    fields: ['pool', 'size', 'free'],
+	    proxy: {
+		type: 'proxmox',
+		url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
+	    },
+	});
+	store.sort('pool', 'ASC');
+
+	Ext.apply(me, {
+	    store: store,
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.ZFSPoolInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage_zfspool',
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    cbind: {
+		editable: '{isCreate}',
+	    },
+
+	    name: 'pool',
+	    fieldLabel: gettext('ZFS Pool'),
+	    allowBlank: false,
+
+	    editConfig: {
+		xtype: 'pveZFSPoolSelector',
+		reference: 'zfsPoolSelector',
+		listeners: {
+		    nodechanged: function(value) {
+			this.up('inputpanel').lookup('storageNodeRestriction').setValue(value);
+		    },
+		},
+	    },
+	},
+	{
+	    xtype: 'pveContentTypeSelector',
+	    cts: ['images', 'rootdir'],
+	    fieldLabel: gettext('Content'),
+	    name: 'content',
+	    value: ['images', 'rootdir'],
+	    multiSelect: true,
+	    allowBlank: false,
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxcheckbox',
+	    name: 'sparse',
+	    checked: false,
+	    uncheckedValue: 0,
+	    fieldLabel: gettext('Thin provision'),
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'blocksize',
+	    emptyText: '16k',
+	    fieldLabel: gettext('Block Size'),
+	    allowBlank: true,
+	},
+    ],
+});
+Ext.define('PVE.storage.ESXIInputPanel', {
+    extend: 'PVE.panel.StorageBase',
+
+    setValues: function(values) {
+	let me = this;
+
+	let server = values.server;
+	if (values.port !== undefined) {
+	    if (Proxmox.Utils.IP6_match.test(server)) {
+		server = `[${server}]`;
+	    }
+	    server += `:${values.port}`;
+	}
+	values.server = server;
+
+	return me.callParent([values]);
+    },
+
+    onGetValues: function(values) {
+	let me = this;
+
+	if (values.password?.length === 0) {
+	    delete values.password;
+	}
+	if (values.username?.length === 0) {
+	    delete values.username;
+	}
+
+	if (me.isCreate) {
+	    let serverPortMatch = Proxmox.Utils.HostPort_match.exec(values.server);
+	    if (serverPortMatch === null) {
+		serverPortMatch = Proxmox.Utils.HostPortBrackets_match.exec(values.server);
+		if (serverPortMatch === null) {
+		    serverPortMatch = Proxmox.Utils.IP6_dotnotation_match.exec(values.server);
+		}
+	    }
+
+	    if (serverPortMatch !== null) {
+		values.server = serverPortMatch[1];
+		if (serverPortMatch[2] !== undefined) {
+		    values.port = serverPortMatch[2];
+		}
+	    }
+	}
+
+	return me.callParent([values]);
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: 'pmxDisplayEditField',
+		name: 'server',
+		fieldLabel: gettext('Server'),
+		editable: me.isCreate,
+		emptyText: gettext('IP address or hostname'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'username',
+		fieldLabel: gettext('Username'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'password',
+		fieldLabel: gettext('Password'),
+		inputType: 'password',
+		emptyText: gettext('Unchanged'),
+		minLength: 1,
+		allowBlank: !me.isCreate,
+	    },
+	];
+
+	me.column2 = [
+	    {
+		xtype: 'proxmoxcheckbox',
+		name: 'skip-cert-verification',
+		fieldLabel: gettext('Skip Certificate Verification'),
+		value: false,
+		uncheckedValue: 0,
+		defaultValue: 0,
+		deleteDefaultValue: !me.isCreate,
+	    },
+	];
+
+	me.callParent();
+    },
+});
+/*
+ * Workspace base class
+ *
+ * popup login window when auth fails (call onLogin handler)
+ * update (re-login) ticket every 15 minutes
+ *
+ */
+
+Ext.define('PVE.Workspace', {
+    extend: 'Ext.container.Viewport',
+
+    title: 'Proxmox Virtual Environment',
+
+    loginData: null, // Data from last login call
+
+    onLogin: function(loginData) {
+	// override me
+    },
+
+    // private
+    updateLoginData: function(loginData) {
+	let me = this;
+	me.loginData = loginData;
+	Proxmox.Utils.setAuthData(loginData);
+
+	let rt = me.down('pveResourceTree');
+	rt.setDatacenterText(loginData.clustername);
+	PVE.ClusterName = loginData.clustername;
+
+	if (loginData.cap) {
+	    Ext.state.Manager.set('GuiCap', loginData.cap);
+	}
+	me.response401count = 0;
+
+	me.onLogin(loginData);
+    },
+
+    // private
+    showLogin: function() {
+	let me = this;
+
+	Proxmox.Utils.authClear();
+	Ext.state.Manager.clear('GuiCap');
+	Proxmox.UserName = null;
+	me.loginData = null;
+
+	if (!me.login) {
+	    me.login = Ext.create('PVE.window.LoginWindow', {
+		handler: function(data) {
+		    me.login = null;
+		    me.updateLoginData(data);
+		    Proxmox.Utils.checked_command(Ext.emptyFn); // display subscription status
+		},
+	    });
+	}
+	me.onLogin(null);
+        me.login.show();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	Ext.tip.QuickTipManager.init();
+
+	// fixme: what about other errors
+	Ext.Ajax.on('requestexception', function(conn, response, options) {
+	    if ((response.status === 401 || response.status === '401') && !PVE.Utils.silenceAuthFailures) { // auth failure
+		// don't immediately show as logged out to cope better with some big
+		// upgrades, which may temporarily produce a false positive 401 err
+		me.response401count++;
+		if (me.response401count > 5) {
+		    me.showLogin();
+		}
+	    }
+	});
+
+	me.callParent();
+
+        if (!Proxmox.Utils.authOK()) {
+	    me.showLogin();
+	} else if (me.loginData) {
+	    me.onLogin(me.loginData);
+	}
+
+	Ext.TaskManager.start({
+	    run: function() {
+		let ticket = Proxmox.Utils.authOK();
+		if (!ticket || !Proxmox.UserName) {
+		    return;
+		}
+
+		Ext.Ajax.request({
+		    params: {
+			username: Proxmox.UserName,
+			password: ticket,
+		    },
+		    url: '/api2/json/access/ticket',
+		    method: 'POST',
+		    success: function(response, opts) {
+			let obj = Ext.decode(response.responseText);
+			me.updateLoginData(obj.data);
+		    },
+		});
+	    },
+	    interval: 15 * 60 * 1000,
+	});
+    },
+});
+
+Ext.define('PVE.StdWorkspace', {
+    extend: 'PVE.Workspace',
+
+    alias: ['widget.pveStdWorkspace'],
+
+    // private
+    setContent: function(comp) {
+	let me = this;
+
+	let view = me.child('#content');
+	let layout = view.getLayout();
+	let current = layout.getActiveItem();
+
+	if (comp) {
+	    Proxmox.Utils.setErrorMask(view, false);
+	    comp.border = false;
+	    view.add(comp);
+	    if (current !== null && layout.getNext()) {
+		layout.next();
+		let task = Ext.create('Ext.util.DelayedTask', function() {
+		    view.remove(current);
+		});
+		task.delay(10);
+	    }
+	} else {
+	    view.removeAll(); // helper for cleaning the content when logging out
+	}
+    },
+
+    selectById: function(nodeid) {
+	let me = this;
+	me.down('pveResourceTree').selectById(nodeid);
+    },
+
+    onLogin: function(loginData) {
+	let me = this;
+
+	me.updateUserInfo();
+
+	if (loginData) {
+	    PVE.data.ResourceStore.startUpdate();
+
+	    Proxmox.Utils.API2Request({
+		url: '/version',
+		method: 'GET',
+		success: function(response) {
+		    PVE.VersionInfo = response.result.data;
+		    me.updateVersionInfo();
+		},
+	    });
+
+	    PVE.UIOptions.update();
+
+	    Proxmox.Utils.API2Request({
+		url: '/cluster/sdn',
+		method: 'GET',
+		success: function(response) {
+		    PVE.SDNInfo = response.result.data;
+		},
+		failure: function(response) {
+		    PVE.SDNInfo = null;
+		    let ui = Ext.ComponentQuery.query('treelistitem[text="SDN"]')[0];
+		    if (ui) {
+			ui.addCls('x-hidden-display');
+		    }
+		},
+	    });
+
+	    Proxmox.Utils.API2Request({
+		url: '/access/domains',
+		method: 'GET',
+		success: function(response) {
+		    let [_username, realm] = Proxmox.Utils.parse_userid(Proxmox.UserName);
+		    response.result.data.forEach((domain) => {
+			if (domain.realm === realm) {
+			    let schema = PVE.Utils.authSchema[domain.type];
+			    if (schema) {
+				me.query('#tfaitem')[0].setHidden(!schema.tfa);
+				me.query('#passworditem')[0].setHidden(!schema.pwchange);
+			    }
+			}
+		    });
+		},
+	    });
+	}
+    },
+
+    updateUserInfo: function() {
+	let me = this;
+	let ui = me.query('#userinfo')[0];
+	ui.setText(Ext.String.htmlEncode(Proxmox.UserName || ''));
+	ui.updateLayout();
+    },
+
+    updateVersionInfo: function() {
+	let me = this;
+
+	let ui = me.query('#versioninfo')[0];
+
+	if (PVE.VersionInfo) {
+	    let version = PVE.VersionInfo.version;
+	    ui.update('Virtual Environment ' + version);
+	} else {
+	    ui.update('Virtual Environment');
+	}
+	ui.updateLayout();
+    },
+
+    initComponent: function() {
+	let me = this;
+
+	Ext.History.init();
+
+	let appState = Ext.create('PVE.StateProvider');
+	Ext.state.Manager.setProvider(appState);
+
+	let selview = Ext.create('PVE.form.ViewSelector', {
+	    flex: 1,
+	    padding: '0 5 0 0',
+	});
+
+	let rtree = Ext.createWidget('pveResourceTree', {
+	    viewFilter: selview.getViewFilter(),
+	    flex: 1,
+	    selModel: {
+		selType: 'treemodel',
+		listeners: {
+		    selectionchange: function(sm, selected) {
+			if (selected.length <= 0) {
+			    return;
+			}
+			let treeNode = selected[0];
+			let treeTypeToClass = {
+			    root: 'PVE.dc.Config',
+			    node: 'PVE.node.Config',
+			    qemu: 'PVE.qemu.Config',
+			    lxc: 'pveLXCConfig',
+			    storage: 'PVE.storage.Browser',
+			    sdn: 'PVE.sdn.Browser',
+			    pool: 'pvePoolConfig',
+			};
+			PVE.curSelectedNode = treeNode;
+			me.setContent({
+			    xtype: treeTypeToClass[treeNode.data.type || 'root'] || 'pvePanelConfig',
+			    showSearch: treeNode.data.id === 'root' || Ext.isDefined(treeNode.data.groupbyid),
+			    pveSelNode: treeNode,
+			    workspace: me,
+			    viewFilter: selview.getViewFilter(),
+			});
+		    },
+		},
+	    },
+	});
+
+	selview.on('select', function(combo, records) {
+	    if (records) {
+		let view = combo.getViewFilter();
+		rtree.setViewFilter(view);
+	    }
+	});
+
+	let caps = appState.get('GuiCap');
+
+	let createVM = Ext.createWidget('button', {
+	    pack: 'end',
+	    margin: '3 5 0 0',
+	    baseCls: 'x-btn',
+	    iconCls: 'fa fa-desktop',
+	    text: gettext("Create VM"),
+	    disabled: !caps.vms['VM.Allocate'],
+	    handler: function() {
+		let wiz = Ext.create('PVE.qemu.CreateWizard', {});
+		wiz.show();
+	    },
+	});
+
+	let createCT = Ext.createWidget('button', {
+	    pack: 'end',
+	    margin: '3 5 0 0',
+	    baseCls: 'x-btn',
+	    iconCls: 'fa fa-cube',
+	    text: gettext("Create CT"),
+	    disabled: !caps.vms['VM.Allocate'],
+	    handler: function() {
+		let wiz = Ext.create('PVE.lxc.CreateWizard', {});
+		wiz.show();
+	    },
+	});
+
+	appState.on('statechange', function(sp, key, value) {
+	    if (key === 'GuiCap' && value) {
+		caps = value;
+		createVM.setDisabled(!caps.vms['VM.Allocate']);
+		createCT.setDisabled(!caps.vms['VM.Allocate']);
+	    }
+	});
+
+	Ext.apply(me, {
+	    layout: { type: 'border' },
+	    border: false,
+	    items: [
+		{
+		    region: 'north',
+		    title: gettext('Header'), // for ARIA
+		    header: false, // avoid rendering the title
+		    layout: {
+			type: 'hbox',
+			align: 'middle',
+		    },
+		    baseCls: 'x-plain',
+		    defaults: {
+			baseCls: 'x-plain',
+		    },
+		    border: false,
+		    margin: '2 0 2 5',
+		    items: [
+			{
+			    xtype: 'proxmoxlogo',
+			},
+			{
+			    minWidth: 150,
+			    id: 'versioninfo',
+			    html: 'Virtual Environment',
+			    style: {
+				'font-size': '14px',
+				'line-height': '18px',
+			    },
+			},
+			{
+			    xtype: 'pveGlobalSearchField',
+			    tree: rtree,
+			},
+			{
+			    flex: 1,
+			},
+			{
+			    xtype: 'proxmoxHelpButton',
+			    hidden: false,
+			    baseCls: 'x-btn',
+			    iconCls: 'fa fa-book x-btn-icon-el-default-toolbar-small ',
+			    listenToGlobalEvent: false,
+			    onlineHelp: 'pve_documentation_index',
+			    text: gettext('Documentation'),
+			    margin: '0 5 0 0',
+			},
+			createVM,
+			createCT,
+			{
+			    pack: 'end',
+			    margin: '0 5 0 0',
+			    id: 'userinfo',
+			    xtype: 'button',
+			    baseCls: 'x-btn',
+			    style: {
+				// proxmox dark grey p light grey as border
+				backgroundColor: '#464d4d',
+				borderColor: '#ABBABA',
+			    },
+			    iconCls: 'fa fa-user',
+			    menu: [
+				{
+				    iconCls: 'fa fa-gear',
+				    text: gettext('My Settings'),
+				    handler: function() {
+					var win = Ext.create('PVE.window.Settings');
+					win.show();
+				    },
+				},
+				{
+				    text: gettext('Password'),
+				    itemId: 'passworditem',
+				    iconCls: 'fa fa-fw fa-key',
+				    handler: function() {
+					var win = Ext.create('Proxmox.window.PasswordEdit', {
+					    userid: Proxmox.UserName,
+					    confirmCurrentPassword: Proxmox.UserName !== 'root@pam',
+					});
+					win.show();
+				    },
+				},
+				{
+				    text: 'TFA',
+				    itemId: 'tfaitem',
+				    iconCls: 'fa fa-fw fa-lock',
+				    handler: function(btn, event, rec) {
+					Ext.state.Manager.getProvider().set('dctab', { value: 'tfa' }, true);
+					me.selectById('root');
+				    },
+				},
+				{
+				    iconCls: 'fa fa-paint-brush',
+				    text: gettext('Color Theme'),
+				    handler: function() {
+					Ext.create('Proxmox.window.ThemeEditWindow')
+					    .show();
+				    },
+				},
+				{
+				    iconCls: 'fa fa-language',
+				    text: gettext('Language'),
+				    handler: function() {
+					Ext.create('Proxmox.window.LanguageEditWindow')
+					    .show();
+				    },
+				},
+				'-',
+				{
+				    iconCls: 'fa fa-fw fa-sign-out',
+				    text: gettext("Logout"),
+				    handler: function() {
+					PVE.data.ResourceStore.loadData([], false);
+					me.showLogin();
+					me.setContent(null);
+					var rt = me.down('pveResourceTree');
+					rt.setDatacenterText(undefined);
+					rt.clearTree();
+
+					// empty the stores of the StatusPanel child items
+					var statusPanels = Ext.ComponentQuery.query('pveStatusPanel grid');
+					Ext.Array.forEach(statusPanels, function(comp) {
+					    if (comp.getStore()) {
+						comp.getStore().loadData([], false);
+					    }
+					});
+				    },
+				},
+			    ],
+			},
+		    ],
+		},
+		{
+		    region: 'center',
+		    stateful: true,
+		    stateId: 'pvecenter',
+		    minWidth: 100,
+		    minHeight: 100,
+		    id: 'content',
+		    xtype: 'container',
+		    layout: { type: 'card' },
+		    border: false,
+		    margin: '0 5 0 0',
+		    items: [],
+		},
+		{
+		    region: 'west',
+		    stateful: true,
+		    stateId: 'pvewest',
+		    itemId: 'west',
+		    xtype: 'container',
+		    border: false,
+		    layout: { type: 'vbox', align: 'stretch' },
+		    margin: '0 0 0 5',
+		    split: true,
+		    width: 300,
+		    items: [
+			{
+			    xtype: 'container',
+			    layout: 'hbox',
+			    padding: '0 0 5 0',
+			    items: [
+				selview,
+				{
+				    xtype: 'button',
+				    cls: 'x-btn-default-toolbar-small',
+				    iconCls: 'fa fa-fw fa-gear x-btn-icon-el-default-toolbar-small',
+				    handler: () => {
+					Ext.create('PVE.window.TreeSettingsEdit', {
+					    autoShow: true,
+					    apiCallDone: () => PVE.UIOptions.fireUIConfigChanged(),
+					});
+				    },
+				},
+			    ],
+			},
+			rtree,
+		    ],
+		    listeners: {
+			resize: function(panel, width, height) {
+			    var viewWidth = me.getSize().width;
+			    if (width > viewWidth - 100 && viewWidth > 150) {
+				panel.setWidth(viewWidth - 100);
+			    }
+			},
+		    },
+		},
+		{
+		    xtype: 'pveStatusPanel',
+		    stateful: true,
+		    stateId: 'pvesouth',
+		    itemId: 'south',
+		    region: 'south',
+		    margin: '0 5 5 5',
+		    title: gettext('Logs'),
+		    collapsible: true,
+		    header: false,
+		    height: 200,
+		    split: true,
+		    listeners: {
+			resize: function(panel, width, height) {
+			    var viewHeight = me.getSize().height;
+			    if (height > viewHeight - 150 && viewHeight > 200) {
+				panel.setHeight(viewHeight - 150);
+			    }
+			},
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+
+	me.updateUserInfo();
+
+	// on resize, center all modal windows
+	Ext.on('resize', function() {
+	    let modalWindows = Ext.ComponentQuery.query('window[modal]');
+	    if (modalWindows.length > 0) {
+		modalWindows.forEach(win => win.alignTo(me, 'c-c'));
+	    }
+	});
+
+	let tagSelectors = [];
+	['circle', 'dense'].forEach((style) => {
+	    ['dark', 'light'].forEach((variant) => {
+		tagSelectors.push(`.proxmox-tags-${style} .proxmox-tag-${variant}`);
+	    });
+	});
+
+	Ext.create('Ext.tip.ToolTip', {
+	    target: me.el,
+	    delegate: tagSelectors.join(', '),
+	    trackMouse: true,
+	    renderTo: Ext.getBody(),
+	    border: 0,
+	    minWidth: 0,
+	    padding: 0,
+	    bodyBorder: 0,
+	    bodyPadding: 0,
+	    dismissDelay: 0,
+	    userCls: 'pmx-tag-tooltip',
+	    shadow: false,
+	    listeners: {
+		beforeshow: function(tip) {
+		    let tag = Ext.htmlEncode(tip.triggerElement.innerHTML);
+		    let tagEl = Proxmox.Utils.getTagElement(tag, PVE.UIOptions.tagOverrides);
+		    tip.update(`<span class="proxmox-tags-full">${tagEl}</span>`);
+		},
+	    },
+	});
+    },
+});
+