From 151153922c036328809876d914231962fe6bbc70 Mon Sep 17 00:00:00 2001 From: Tsanie Date: Wed, 26 Jun 2024 14:49:30 +0800 Subject: [PATCH] initial commit --- .../proxmox-widget-toolkit/proxmoxlib.js | 23690 ++++++ usr/share/perl5/PVE/API2/Nodes.pm | 2612 + usr/share/pve-manager/js/pvemanagerlib.js | 60994 ++++++++++++++++ 3 files changed, 87296 insertions(+) create mode 100644 usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js create mode 100644 usr/share/perl5/PVE/API2/Nodes.pm create mode 100644 usr/share/pve-manager/js/pvemanagerlib.js 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 => ``, + + 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('www.proxmox.com', 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('
'); + }, + + 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 += "
"; + Ext.Object.each(result.errors, (prop, desc) => { + msg += `
${Ext.htmlEncode(prop)}: ${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 ' ' + 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 '' + value + ''; + } + 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('
'); + } + 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) => `${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 `${string}`; + }, + + // 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
' + 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
' + 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') + ': bondN, where 0 <= N <= 9999', + + InterfaceName: function(v) { + return (/^[a-z][a-z0-9_]{1,20}$/).test(v); + }, + InterfaceNameText: gettext("Allowed characters") + ": 'a-z', '0-9', '_'
" + + gettext("Minimum characters") + ": 2
" + + gettext("Maximum characters") + ": 21
" + + 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', '-', '_', '.'
" + + gettext("Minimum characters") + ": 2
" + + gettext("Must start with") + ": 'A-Z', 'a-z'
" + + gettext("Must end with") + ": 'A-Z', 'a-z', '0-9'
", + + ConfigId: function(v) { + return (/^[a-z][a-z0-9_-]+$/i).test(v); + }, + ConfigIdText: gettext("Allowed characters") + ": 'A-Z', 'a-z', '0-9', '_'
" + + gettext("Minimum characters") + ": 2
" + + gettext("Must start with") + ": " + gettext("letter"), + + HttpProxy: function(v) { + return (/^http:\/\/.*$/).test(v); + }, + HttpProxyText: gettext('Example') + ": http://username:password@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); + // + // tell the spec runner to ignore this element when checking if the dom is clean + el.dom.setAttribute('data-sticky', true); + // + } + + 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 + '', + ], + + 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(`${input}`, 'text/html'); + doc.normalize(); + + _sanitize(doc.body); + + return doc.body.innerHTML; + }, + + parse: function(markdown) { + /*global marked*/ + let unsafeHTML = marked.parse(markdown); + + return `
${this.sanitizeHTML(unsafeHTML)}
`; + }, + +}); +/* + * 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 = '
'+ current +'
'; + } + } + + if (pending) { + return current + '
' + pending + '
'; + } 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 += `
${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}
`; + additionalInfo += `${gettext('Usage')}: ${usage}
`; + additionalInfo += `${gettext('Size')}: ${size}
`; + additionalInfo += `${gettext('Serial')}: ${serial}`; + + return `${mainMessage}

${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 = ` + ${message} + `; + + 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: [ + '
', + '', + ' ', + '', + '{title}
 
{usage}
', + ], + }, + { + 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('
')); + + 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 = ` + + `; + + 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('
'); + + 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 + '
' + view.content : text; + } else if (!top && num) { + view.content = view.content ? view.content + '
' + 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}
${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: '

{title}

', + }, + { + 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: 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(`${me.getNote()}`); + 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.') +``, + reference: 'webAuthnWaiting', + hidden: true, + }, + { + xtype: 'box', + data: { + error: '', + }, + tpl: ' {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: '' + + Ext.String.format( + gettext('No second factor left! Please contact an administrator!'), + 4, + ), + }, + { + xtype: 'box', + reference: 'recoveryEmpty', + hidden: true, + html: '' + + Ext.String.format( + gettext('No more recovery keys left! Please generate a new set!'), + 4, + ), + }, + { + xtype: 'box', + reference: 'recoveryLow', + hidden: true, + html: '' + + 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.') +``, + reference: 'u2fWaiting', + hidden: true, + }, + { + xtype: 'box', + data: { + error: '', + }, + tpl: ' {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 = ` +

Recovery Keys for '${userid}' - ${title} (${host})

+

+${keyString} +

+ `; + + 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.') + + `
${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: `${gettext('Tip:')} ` + + 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: `
${Ext.htmlEncode(data.Description)}
`, + 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: `

${gettext('No updates available.')}

`, + }, + 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) => ``, + 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('
'))}"`; + if (record.data.Enabled) { + metaData.tdCls = 'proxmox-invalid-row'; + err = ' '; + } else { + metaData.tdCls = 'proxmox-warning-row'; + err = ' '; + } + } + 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 = (Option) + if (components[0].match(/\w+(-no-subscription|test)\s*$/i)) { + metaData.tdCls = 'proxmox-warning-row'; + err = ' '; + + 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}
`; + } 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 ` ${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: [ + '
', + '', + '{text}', + '
', + ], + }, + { + 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("
" + Ext.htmlEncode(changes) + "
"); + 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('
') || ''; + }; + }; + + 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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + 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 && /^/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)) { + 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(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]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag + + '|(?=[ \\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 = /|$)/; + 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', ')|<(?: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', ')|<(?: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', ')|<(?: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]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\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: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\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' + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^', + // 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]*?(?:(?=[\\?@\\[\\]`^{|}~'; + inline.punctuation = edit(inline.punctuation).replace(/punctuation/g, inline._punctuation).getRegex(); + + // sequences em should skip over [title](link), `code`, + 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]*?(?:(?=[\\ 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 '
' + (escaped ? _code : escape(_code, true)) + '
\n'; + } + return '
' + (escaped ? _code : escape(_code, true)) + '
\n'; + } + + /** + * @param {string} quote + */; + _proto.blockquote = function blockquote(quote) { + return "
\n" + quote + "
\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 "" + text + "\n"; + } + + // ignore IDs + return "" + text + "\n"; + }; + _proto.hr = function hr() { + return this.options.xhtml ? '
\n' : '
\n'; + }; + _proto.list = function list(body, ordered, start) { + var type = ordered ? 'ol' : 'ul', + startatt = ordered && start !== 1 ? ' start="' + start + '"' : ''; + return '<' + type + startatt + '>\n' + body + '\n'; + } + + /** + * @param {string} text + */; + _proto.listitem = function listitem(text) { + return "
  • " + text + "
  • \n"; + }; + _proto.checkbox = function checkbox(checked) { + return ' '; + } + + /** + * @param {string} text + */; + _proto.paragraph = function paragraph(text) { + return "

    " + text + "

    \n"; + } + + /** + * @param {string} header + * @param {string} body + */; + _proto.table = function table(header, body) { + if (body) body = "" + body + ""; + return '\n' + '\n' + header + '\n' + body + '
    \n'; + } + + /** + * @param {string} content + */; + _proto.tablerow = function tablerow(content) { + return "\n" + content + "\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 + ("\n"); + } + + /** + * span level renderer + * @param {string} text + */; + _proto.strong = function strong(text) { + return "" + text + ""; + } + + /** + * @param {string} text + */; + _proto.em = function em(text) { + return "" + text + ""; + } + + /** + * @param {string} text + */; + _proto.codespan = function codespan(text) { + return "" + text + ""; + }; + _proto.br = function br() { + return this.options.xhtml ? '
    ' : '
    '; + } + + /** + * @param {string} text + */; + _proto.del = function del(text) { + return "" + text + ""; + } + + /** + * @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 = '
    '; + 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 = "\""' : '>'; + 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 '

    An error occurred:

    ' + escape(e.message + '', true) + '
    '; + } + 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 '

    An error occurred:

    ' + escape(e.message + '', true) + '
    '; + } + 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/' 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/' 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/' 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/' 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 ' + +'
    ' + +'www.proxmox.com 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 " "; + }, + + 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 '
    '+ value +'
    '; + } + } 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 = ``; + + if (value === 'deleted') { + return '' + icon + value + ''; + } + + let tip = gettext('Pending Changes') + ':
    '; + + 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}
    `; + } + } + return ''+ icon + value + ''; + }, + + 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 ' ' + 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 = ``; + return `${icon} ${gettext('Encrypted')}`; + }, + + render_backup_verification: function(v, meta, record) { + let i = (cls, txt) => ` ${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 ` ${i(iconCls, txt)} `; + }, + + 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 ` ${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 = ' '; + 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 '' + value + ''; + } + 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('
    '); + } + 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]}
    `; + } + 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: [ + '
      ', + '
    • {text}
    • ', + '
    ', + ], + + displayTpl: [ + '', + '{value}', + '', + ], + +}); +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('
    '); + }, + }, + ], + }, + + 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 ` ${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 ` ${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 = ``; + if (iconCls !== undefined) { + checks.push(`${icon} ${message}`); + } + }); + + return checks.join('
    '); + }, + 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 ` ${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 = ``; + if (iconCls !== undefined) { + errors.push(`${icon} ${message}`); + } + }); + + return errors.join('
    '); + }, + }, + { + 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: [ + '', + '{tag}', + '', + ], + + 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: '', + 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: ``, + 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: [ + 'accept="{accept}"', + 'tabindex="{tabIndex}"', + '>', + ], + + 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: ``, + tooltip: gettext('Protected'), + width: 30, + renderer: v => v ? `` : '', + 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 = '

    ' + Ext.htmlEncode(errors[name]) + '

    '; + 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 = ""; + 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 = `${comment}`; + } + 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(''); + states.push(gettext("Removal Scheduled")); + } + if (record.data.error) { + icons.push(''); + states.push(record.data.error); + } + if (icons.length === 0) { + icons.push(''); + 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: `${gettext('Note')}: ${ + 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: [ + '

    {title}

    ', + '', + '

    ', + '{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: ['IPSet:', 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 = '

    ' + Ext.htmlEncode(msg) + '

    '; + metaData.tdAttr = 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + + html.replace(/"/g, '"') + '"'; + } + } + return value; + }; + + Ext.apply(me, { + tbar: ['IP/CIDR:', 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 '! ' + 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: '

    {title}:

    ', + }, + { + 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}
    ${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 = `
    ${me.getRecordValue('name') + text}
    `; + + 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 += `
    +  ${distro}
    `; + } + + 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 = ' '; + } + 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 = '
    '; + status += `
    `; + status += `
    `; + status += '
    '; + } + } + if (Ext.isNumeric(info.vmid) && info.vmid > 0) { + if (PVE.UIOptions.getTreeSortingValue('sort-field') !== 'vmid') { + info.text = `${info.name} (${String(info.vmid)})`; + } + } + info.text = `${status}${info.text}`; + 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 ` ${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 => `{{${v}}}`).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 `

    + ${Ext.String.format(gettext('{0} is not initialized.'), 'Ceph')} + ${gettext('You need to create an initial config once.')}

    `; + } else { + return '

    ' + + Ext.String.format(gettext('{0} is not installed on this node.'), 'Ceph') + '
    ' + + gettext('Would you like to install it now?') + '

    '; + } + }, + }, + }, + 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: '
    /
    ', + }, + { + 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.') + `
    ${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') + `
    ${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 ' '; + case 'error': + return ''; + 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 '
    '+ text +'
    '; + } 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 += `
    ${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 = ` `; + } + 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 => '
      ' + + get('warnings').map(w => `
    • ${w}
    • `).join('') + '
    ', + 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})'), + 'OVMF/UEFI Boot Entries', + ), + }; + 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(''); + 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 = `

    ${Ext.htmlEncode(data.errors.group)}

    `; + 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.') + + '
    ' + + Ext.String.format( + gettext('Possible template variables are: {0}'), + PVE.Utils.notesTemplateVars.map(v => `{{${v}}}`).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:
    ' + errors.join('
    ')); + } + } + }; + + 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: [ + '

    ' + gettext("Virtual Machines") + '

    ', + '
    ', + '
    ', + ' ', + gettext('Running'), + '
    ', + '
    {running}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Paused'), + '
    ', + '
    {paused}
    ', + '
    ', + '
    ', + '
    ', + '
    ', + ' ', + gettext('Stopped'), + '
    ', + '
    {stopped}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Templates'), + '
    ', + '
    {template}
    ', + '
    ', + '
    ', + ], + }, + { + itemId: 'lxc', + data: { + running: 0, + paused: 0, + stopped: 0, + template: 0, + }, + cls: 'centered-flex-column', + tpl: [ + '

    ' + gettext("LXC Container") + '

    ', + '
    ', + '
    ', + ' ', + gettext('Running'), + '
    ', + '
    {running}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Paused'), + '
    ', + '
    {paused}
    ', + '
    ', + '
    ', + '
    ', + '
    ', + ' ', + gettext('Stopped'), + '
    ', + '
    {stopped}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Templates'), + '
    ', + '
    {template}
    ', + '
    ', + '
    ', + ], + }, + { + itemId: 'error', + colspan: 2, + data: { + num: 0, + }, + columnWidth: 1, + padding: '10 250 0 250', + tpl: [ + '', + '
    ', + ' ', + gettext('Error'), + '
    ', + '
    {num}
    ', + '
    ', + ], + }, + ], + + 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: [ + '

    ' + gettext('Nodes') + '


    ', + '
    ', + '
    ', + ' ', + gettext('Online'), + '
    ', + '
    {online}
    ', + '

    ', + '
    ', + ' ', + gettext('Offline'), + '
    ', + '
    {offline}
    ', + '
    ', + ], + }, + { + 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 ''; + }, + }, + { + 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: `${gettext('Note:')} ` + + 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: `${gettext('Note:')} ` + + gettext('WebAuthn requires using a trusted certificate.'), + }, + { + xtype: 'box', + id: 'idChangeWarning', + hidden: true, + padding: '5 0 0 0', + html: ' ' + + 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 `
    ${tags}
    `; + }, + 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: ['' + gettext('Group') + ':', 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: '' + gettext('Rules') + ':', + 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: '

    No valid subscription

    ' + PVE.Utils.noSubKeyHtml, + + communityHtml: 'Please use the public community forum for any questions.', + + activeHtml: 'Please use our support portal for any questions. You can also use the public community forum to get additional information.', + + bugzillaHtml: '

    Bug Tracking

    Our bug tracking system is available here.', + + docuHtml: function() { + var me = this; + var guideUrl = window.location.origin + me.pveGuidePath; + var text = Ext.String.format('

    Documentation

    ' + + 'The official Proxmox VE Administration Guide' + + ' is included with this installation and can be browsed at ' + + '{0}', guideUrl); + return text; + }, + + updateActive: function(data) { + var me = this; + + var html = '

    ' + data.productname + '

    ' + me.activeHtml; + html += '

    ' + me.docuHtml(); + html += '

    ' + me.bugzillaHtml; + + me.update(html); + }, + + updateCommunity: function(data) { + var me = this; + + var html = '

    ' + data.productname + '

    ' + me.communityHtml; + html += '

    ' + me.docuHtml(); + html += '

    ' + 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} ${realm}`; + }, + 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: `
    ${gettext('No LDAP/AD Realm found')}
    `, + }, + 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('
    ')); + 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('
    ')); + 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: [ + '', + ' ({lock})', + '', + ], + }); + + 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 + "
    " + 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 + "
    " + 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 = ``; + 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: `

    Ceph?

    +

    "Ceph is a unified, + distributed storage system, designed for excellent performance, reliability, + and scalability."

    +

    + Ceph is currently not installed 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. +

    +

    + 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 ceph.com. +

    `, +}); + +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: '

    Installation successful!

    '+ + '

    The basic installation and configuration is complete. Depending on your setup, some of the following steps are required to start using Ceph:

    '+ + '
    1. Install Ceph on other nodes
    2. '+ + '
    3. Create additional Ceph Monitors
    4. '+ + '
    5. Create Ceph OSDs
    6. '+ + '
    7. Create Ceph Pools
    '+ + '

    To learn more, click on the help button below.

    ', + 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 ' + + 'the reference documentation.', + }, + ], + }); + + 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) + + "
    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 + ' / ' + + inout + ' '; + + 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
    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: [ + '', + 'osd.{osd}:', + '', + gettext('No OSD selected'), + '', + ], + }, + { + 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')}
    (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')}
    (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')} `; + } + 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 = ' 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 = ' ' + 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 = '' + mgr.title + ''; + mgr.statuses.push(gettext('Status') + ': active'); + } 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 = '' + mds.title + ''; + mds.statuses.push(gettext('Status') + ': ' + mdsmessages[mds.name]+""); + } 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('
    '); + + 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: '

    {title}

    ', + }, + ], + + 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}: ', + '', + ], + + 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 ``; + }, + 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: '
    {detail}
    ', + }, + ], + }, + ], + }, + { + 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: [ + '

    OSDs

    ', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    ', + gettext('In'), + '', + gettext('Out'), + '
    ', + gettext('Up'), + '{upin}{upout}
    ', + gettext('Down'), + '{downin}{downout}
    ', + '
    ', + gettext('Total'), + ': {total}', + '

    ', + '', + ' ' + gettext('Outdated OSDs') + "
    ", + '
    ', + '', + '
    osd.{id}:
    ', + '
    {version}

    ', + '
    ', + '
    ', + '
    ', + '
    ', + '', + '', + '
    ', + ` ${gettext('Ghost OSDs')}
    `, + `
    `, + '', + '
    osd.{id}
    ', + '
    ', + '
    ', + '
    ', + '
    ', + ], + }, + { + 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 += '
    '; + record.get('states').forEach(function(state) { + html += '
    ' + + 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: [ + '

    PGs

    ', + '', + '
    {state_name}:
    ', + '
    {count}

    ', + '
    ', + '
    ', + ], + }], + + // 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: [ + '', + gettext('Directory') + ' {dirName}:', + '', + Ext.String.format(gettext('No {0} selected'), gettext('directory')), + '', + ], + }, + { + 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: [ + '', + 'Volume group {volumeGroup}:', + '', + Ext.String.format(gettext('No {0} selected'), 'volume group'), + '', + ], + }, + { + 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: [ + '', + '', + 'Thinpool {volumeGroup}/{thinPool}:', + '', // volumeGroup + 'Missing volume group (node running old version?)', + '', + '', // thinPool + Ext.String.format(gettext('No {0} selected'), 'thinpool'), + '', + ], + }, + { + 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 the reference documentation.', + }, + ], + }); + + 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: [ + '', + 'Pool {pool}:', + '', + Ext.String.format(gettext('No {0} selected'), 'pool'), + '', + ], + }, + { + 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('
    '); + }, + }, + ], + }, + ], +}); + +Ext.define('PVE.qemu.AgentIPView', { + extend: 'Ext.container.Container', + xtype: 'pveAgentIPView', + + layout: { + type: 'hbox', + align: 'top', + }, + + nics: [], + + items: [ + { + xtype: 'box', + html: ' 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('
    '); + } + } 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 = ""; + let idx = (rowIndex + 1).toString(); + if (record.get('enabled')) { + return dragHandle + idx; + } else { + return dragHandle + "" + idx + ""; + } + }, + }, + { + 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 = ``; + } + + 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 = ' '; + } + 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 += ' (' + gettext('with options') + ')'; + } + 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('
    '); + } 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: [ + '', + ' ({lock})', + '', + ], + }); + + 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 = ""; + 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 += '
    ' + 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(`
    ${commands.flat(2).join('\n')}
    `); + 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: ``, + tooltip: gettext('Protected'), + width: 30, + renderer: v => v ? `` : '', + 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 += '
    ' + 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.') + + '
    ' + 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 = ` +

    Encryption Key - Storage '${me.sid}' (${shortKeyFP})

    +

    +-----BEGIN PROXMOX BACKUP KEY----- +${prettifiedKey} +-----END PROXMOX BACKUP KEY-----

    +
    + `; + + 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 = ' '; + 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: // `${gettext('Warning')}: ` + + ` ` + + 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(`${tagEl}`); + }, + }, + }); + }, +}); +