diff --git a/src/Toolkit.js b/src/Toolkit.js
new file mode 100644
index 0000000..bd31b3c
--- /dev/null
+++ b/src/Toolkit.js
@@ -0,0 +1,921 @@
+// 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('; ');
+ },
+});
+
+// Prevents html entities to be interpreted by the browser in grid columns by
+// default. Custom renderer have to take care of this by themselves.
+//
+// Reset the renderer for the 'check', 'boolean', 'number' and 'date' columns
+// back to undefined, since they rely on the renderer being undefined or
+// returning raw HTML.
+Ext.define('Proxmox.grid.column.Column', {
+ override: 'Ext.grid.column.Column',
+
+ renderer: Ext.htmlEncode,
+});
+
+Ext.define('Proxmox.grid.column.Check', {
+ override: 'Ext.grid.column.Check',
+
+ renderer: undefined,
+});
+
+Ext.define('Proxmox.grid.column.Boolean', {
+ override: 'Ext.grid.column.Boolean',
+
+ renderer: undefined,
+});
+
+Ext.define('Proxmox.grid.column.Number', {
+ override: 'Ext.grid.column.Number',
+
+ renderer: undefined,
+});
+
+Ext.define('Proxmox.grid.column.Date', {
+ override: 'Ext.grid.column.Date',
+
+ renderer: undefined,
+});
+
+// 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) {
+ 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: [
+ '',
+ ],
+
+ 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;
+ }
+ },
+});
diff --git a/src/panel/GotifyEditPanel.js b/src/panel/GotifyEditPanel.js
new file mode 100644
index 0000000..5e78e73
--- /dev/null
+++ b/src/panel/GotifyEditPanel.js
@@ -0,0 +1,75 @@
+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;
+ },
+});
diff --git a/src/panel/SendmailEditPanel.js b/src/panel/SendmailEditPanel.js
new file mode 100644
index 0000000..a4de50c
--- /dev/null
+++ b/src/panel/SendmailEditPanel.js
@@ -0,0 +1,103 @@
+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;
+ },
+});
diff --git a/src/panel/SmtpEditPanel.js b/src/panel/SmtpEditPanel.js
new file mode 100644
index 0000000..37e4d51
--- /dev/null
+++ b/src/panel/SmtpEditPanel.js
@@ -0,0 +1,218 @@
+Ext.define('Proxmox.panel.SmtpEditPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+ xtype: 'pmxSmtpEditPanel',
+ mixins: ['Proxmox.Mixin.CBind'],
+ onlineHelp: 'notification_targets_smtp',
+
+ type: 'smtp',
+
+ viewModel: {
+ xtype: 'viewmodel',
+ data: {
+ mode: 'tls',
+ authentication: true,
+ originalAuthentication: 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.getView().isCreate;
+
+ let auth = get('authentication');
+ let origAuth = get('originalAuthentication');
+ let shouldShowUnchanged = !isCreate && auth && origAuth;
+
+ return shouldShowUnchanged ? 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', gettext('None (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',
+ cbind: {
+ allowBlank: '{!isCreate}',
+ },
+ 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) {
+ let me = this;
+
+ values.authentication = !!values.username;
+ values.enable = !values.disable;
+ delete values.disable;
+
+ // Fix race condition in chromium-based browsers. Without this, the
+ // 'Authenticate' remains ticked (the default value) if loading an
+ // SMTP target without authentication.
+ me.getViewModel().set('authentication', values.authentication);
+ me.getViewModel().set('originalAuthentication', values.authentication);
+
+ return values;
+ },
+});
diff --git a/src/panel/WebhookEditPanel.js b/src/panel/WebhookEditPanel.js
new file mode 100644
index 0000000..7980d8b
--- /dev/null
+++ b/src/panel/WebhookEditPanel.js
@@ -0,0 +1,427 @@
+Ext.define('Proxmox.panel.WebhookEditPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+ xtype: 'pmxWebhookEditPanel',
+ mixins: ['Proxmox.Mixin.CBind'],
+ onlineHelp: 'notification_targets_webhook',
+
+ type: 'webhook',
+
+ columnT: [],
+
+ column1: [
+ {
+ xtype: 'pmxDisplayEditField',
+ name: 'name',
+ cbind: {
+ value: '{name}',
+ editable: '{isCreate}',
+ },
+ fieldLabel: gettext('Endpoint Name'),
+ regex: Proxmox.Utils.safeIdRegex,
+ allowBlank: false,
+ },
+ ],
+
+ column2: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'enable',
+ fieldLabel: gettext('Enable'),
+ allowBlank: false,
+ checked: true,
+ },
+ ],
+
+ columnB: [
+ {
+ xtype: 'fieldcontainer',
+ fieldLabel: gettext('Method/URL'),
+ layout: 'hbox',
+ border: false,
+ margin: '0 0 5 0',
+ items: [
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'method',
+ editable: false,
+ value: 'post',
+ comboItems: [
+ ['post', 'POST'],
+ ['put', 'PUT'],
+ ['get', 'GET'],
+ ],
+ width: 80,
+ margin: '0 5 0 0',
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'url',
+ allowBlank: false,
+ emptyText: 'https://example.com/hook',
+ regex: Proxmox.Utils.httpUrlRegex,
+ regexText: gettext('Must be a valid URL'),
+ flex: 4,
+ },
+ ],
+ },
+ {
+ xtype: 'pmxWebhookKeyValueList',
+ name: 'header',
+ fieldLabel: gettext('Headers'),
+ addLabel: gettext('Add Header'),
+ maskValues: false,
+ cbind: {
+ isCreate: '{isCreate}',
+ },
+ margin: '0 0 10 0',
+ },
+ {
+ xtype: 'textarea',
+ fieldLabel: gettext('Body'),
+ name: 'body',
+ allowBlank: true,
+ minHeight: '150',
+ fieldStyle: {
+ 'font-family': 'monospace',
+ },
+ margin: '0 0 5 0',
+ },
+ {
+ xtype: 'pmxWebhookKeyValueList',
+ name: 'secret',
+ fieldLabel: gettext('Secrets'),
+ addLabel: gettext('Add Secret'),
+ maskValues: true,
+ cbind: {
+ isCreate: '{isCreate}',
+ },
+ margin: '0 0 10 0',
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ ],
+
+ onSetValues: (values) => {
+ values.enable = !values.disable;
+
+ if (values.body) {
+ values.body = Proxmox.Utils.base64ToUtf8(values.body);
+ }
+
+ 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;
+ }
+
+ if (values.body) {
+ values.body = Proxmox.Utils.utf8ToBase64(values.body);
+ } else {
+ delete values.body;
+ if (!me.isCreate) {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'body' });
+ }
+ }
+
+ if (Ext.isArray(values.header) && !values.header.length) {
+ delete values.header;
+ if (!me.isCreate) {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'header' });
+ }
+ }
+
+ if (Ext.isArray(values.secret) && !values.secret.length) {
+ delete values.secret;
+ if (!me.isCreate) {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'secret' });
+ }
+ }
+ delete values.enable;
+
+ return values;
+ },
+});
+
+Ext.define('Proxmox.form.WebhookKeyValueList', {
+ extend: 'Ext.form.FieldContainer',
+ alias: 'widget.pmxWebhookKeyValueList',
+
+ mixins: ['Ext.form.field.Field'],
+
+ // override for column header
+ fieldTitle: gettext('Item'),
+
+ // label displayed in the "Add" button
+ addLabel: undefined,
+
+ // will be applied to the textfields
+ maskRe: undefined,
+
+ allowBlank: true,
+ selectAll: false,
+ isFormField: true,
+ deleteEmpty: false,
+ config: {
+ deleteEmpty: false,
+ maskValues: 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) => {
+ let properties = Proxmox.Utils.parsePropertyString(item);
+
+ // decode base64
+ let value = me.maskValues ? '' : Proxmox.Utils.base64ToUtf8(properties.value);
+
+ let obj = {
+ headerName: properties.name,
+ headerValue: value,
+ };
+
+ if (!me.isCreate && me.maskValues) {
+ obj.emptyText = gettext('Unchanged');
+ }
+
+ return obj;
+ }),
+ );
+ } else {
+ store.removeAll();
+ }
+ me.checkChange();
+ return me;
+ },
+
+ getValue: function () {
+ let me = this;
+ let values = [];
+ me.lookup('grid')
+ .getStore()
+ .each((rec) => {
+ if (rec.data.headerName) {
+ let obj = {
+ name: rec.data.headerName,
+ value: Proxmox.Utils.utf8ToBase64(rec.data.headerValue),
+ };
+
+ values.push(Proxmox.Utils.printPropertyString(obj));
+ }
+ });
+
+ return values;
+ },
+
+ getErrors: function (value) {
+ let me = this;
+ let empty = false;
+
+ me.lookup('grid')
+ .getStore()
+ .each((rec) => {
+ if (!rec.data.headerName) {
+ empty = true;
+ }
+
+ if (!rec.data.headerValue && rec.data.newValue) {
+ empty = true;
+ }
+
+ if (!rec.data.headerValue && !me.maskValues) {
+ empty = true;
+ }
+ });
+ if (empty) {
+ return [gettext('Name/value 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({
+ headerName: '',
+ headerValue: '',
+ emptyText: gettext('Value'),
+ newValue: true,
+ });
+ },
+
+ 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('pmxWebhookKeyValueList');
+ list.checkChange();
+ list.validate();
+ },
+
+ control: {
+ 'grid button': {
+ click: 'removeSelection',
+ },
+ },
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let items = [
+ {
+ xtype: 'grid',
+ reference: 'grid',
+ minHeight: 100,
+ maxHeight: 100,
+ scrollable: 'vertical',
+
+ viewConfig: {
+ deferEmptyText: false,
+ },
+
+ store: {
+ listeners: {
+ update: function () {
+ this.commitChanges();
+ },
+ },
+ },
+ margin: '5 0 5 0',
+ columns: [
+ {
+ header: me.fieldTtitle,
+ dataIndex: 'headerName',
+ xtype: 'widgetcolumn',
+ widget: {
+ xtype: 'textfield',
+ isFormField: false,
+ maskRe: me.maskRe,
+ allowBlank: false,
+ queryMode: 'local',
+ emptyText: gettext('Key'),
+ listeners: {
+ change: 'itemChange',
+ },
+ },
+ onWidgetAttach: function (_col, widget) {
+ widget.isValid();
+ },
+ flex: 1,
+ },
+ {
+ header: me.fieldTtitle,
+ dataIndex: 'headerValue',
+ xtype: 'widgetcolumn',
+ widget: {
+ xtype: 'proxmoxtextfield',
+ inputType: me.maskValues ? 'password' : 'text',
+ isFormField: false,
+ maskRe: me.maskRe,
+ queryMode: 'local',
+ listeners: {
+ change: 'itemChange',
+ },
+ allowBlank: !me.isCreate && me.maskValues,
+
+ bind: {
+ emptyText: '{record.emptyText}',
+ },
+ },
+ onWidgetAttach: function (_col, widget) {
+ widget.isValid();
+ },
+ flex: 1,
+ },
+ {
+ xtype: 'widgetcolumn',
+ width: 40,
+ widget: {
+ xtype: 'button',
+ iconCls: 'fa fa-trash-o',
+ },
+ },
+ ],
+ },
+ {
+ xtype: 'button',
+ text: me.addLabel ? me.addLabel : gettext('Add'),
+ iconCls: 'fa fa-plus-circle',
+ handler: 'addLine',
+ },
+ ];
+
+ for (const [key, value] of Object.entries(me.gridConfig ?? {})) {
+ items[0][key] = value;
+ }
+
+ Ext.apply(me, {
+ items,
+ });
+
+ me.callParent();
+ me.initField();
+ },
+});
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
new file mode 100644
index 0000000..a1d173c
--- /dev/null
+++ b/src/window/NotificationMatcherEdit.js
@@ -0,0 +1,1390 @@
+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: 800,
+
+ 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();
+ },
+
+ 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',
+ cbind: {
+ baseUrl: '{baseUrl}',
+ },
+ },
+ ],
+
+ 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 || (Ext.isArray(value) && !value.length)) {
+ 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') {
+ if (data.invert) {
+ text = gettext('At least one rule does not match');
+ } else {
+ text = gettext('All rules match');
+ }
+ } else if (data.value === 'any') {
+ if (data.invert) {
+ text = gettext('No rule matches');
+ } else {
+ text = gettext('Any rule matches');
+ }
+ }
+ 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';
+ }
+
+ if (type === 'exact') {
+ matchedValue = matchedValue.split(',');
+ }
+
+ 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',
+ mixins: ['Proxmox.Mixin.CBind'],
+ border: false,
+ layout: 'anchor',
+
+ 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')],
+ ],
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ hidden: '{!showMatchingMode}',
+ disabled: '{!showMatchingMode}',
+ value: '{rootMode}',
+ },
+ },
+ {
+ xtype: 'proxmoxKVComboBox',
+ fieldLabel: gettext('Node type'),
+ isFormField: false,
+ allowBlank: false,
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ value: '{nodeType}',
+ hidden: '{!showMatcherType}',
+ disabled: '{!showMatcherType}',
+ },
+
+ comboItems: [
+ ['match-field', gettext('Match Field')],
+ ['match-severity', gettext('Match Severity')],
+ ['match-calendar', gettext('Match Calendar')],
+ ],
+ },
+ {
+ xtype: 'pmxNotificationMatchFieldSettings',
+ cbind: {
+ baseUrl: '{baseUrl}',
+ },
+ },
+ {
+ xtype: 'pmxNotificationMatchSeveritySettings',
+ },
+ {
+ xtype: 'pmxNotificationMatchCalendarSettings',
+ },
+ ],
+});
+
+Ext.define('Proxmox.panel.MatchCalendarSettings', {
+ extend: 'Ext.panel.Panel',
+ xtype: 'pmxNotificationMatchCalendarSettings',
+ border: false,
+ layout: 'anchor',
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ hidden: '{!typeIsMatchCalendar}',
+ },
+ viewModel: {
+ // parent is set in `initComponents`
+ formulas: {
+ typeIsMatchCalendar: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ get: function (record) {
+ return record?.get('type') === 'match-calendar';
+ },
+ },
+
+ 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;
+ },
+ },
+ },
+ },
+ items: [
+ {
+ xtype: 'proxmoxKVComboBox',
+ fieldLabel: gettext('Timespan to match'),
+ isFormField: false,
+ allowBlank: false,
+ editable: true,
+ displayField: 'key',
+ field: 'value',
+ bind: {
+ value: '{matchCalendarValue}',
+ disabled: '{!typeIsMatchCalender}',
+ },
+
+ comboItems: [
+ ['mon 8-12', ''],
+ ['tue..fri,sun 0:00-23:59', ''],
+ ],
+ },
+ ],
+
+ initComponent: function () {
+ let me = this;
+ Ext.apply(me.viewModel, {
+ parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+ });
+ me.callParent();
+ },
+});
+
+Ext.define('Proxmox.panel.MatchSeveritySettings', {
+ extend: 'Ext.panel.Panel',
+ xtype: 'pmxNotificationMatchSeveritySettings',
+ border: false,
+ layout: 'anchor',
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ hidden: '{!typeIsMatchSeverity}',
+ },
+ viewModel: {
+ // parent is set in `initComponents`
+ formulas: {
+ typeIsMatchSeverity: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ get: function (record) {
+ return record?.get('type') === 'match-severity';
+ },
+ },
+ matchSeverityValue: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ set: function (value) {
+ let record = this.get('selectedRecord');
+ let currentData = record.get('data');
+ record.set({
+ data: {
+ ...currentData,
+ value: value,
+ },
+ });
+ },
+ get: function (record) {
+ return record?.get('data')?.value;
+ },
+ },
+ },
+ },
+ items: [
+ {
+ xtype: 'proxmoxKVComboBox',
+ fieldLabel: gettext('Severities to match'),
+ isFormField: false,
+ allowBlank: true,
+ multiSelect: true,
+ field: 'value',
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ value: '{matchSeverityValue}',
+ hidden: '{!typeIsMatchSeverity}',
+ disabled: '{!typeIsMatchSeverity}',
+ },
+
+ comboItems: [
+ ['info', gettext('Info')],
+ ['notice', gettext('Notice')],
+ ['warning', ngettext('Warning', 'Warnings', 1)],
+ ['error', gettext('Error')],
+ ['unknown', gettext('Unknown')],
+ ],
+ },
+ ],
+
+ initComponent: function () {
+ let me = this;
+ Ext.apply(me.viewModel, {
+ parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+ });
+ me.callParent();
+ },
+});
+
+Ext.define('Proxmox.panel.MatchFieldSettings', {
+ extend: 'Ext.panel.Panel',
+ xtype: 'pmxNotificationMatchFieldSettings',
+ border: false,
+ layout: 'anchor',
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ hidden: '{!typeIsMatchField}',
+ },
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ control: {
+ 'field[reference=fieldSelector]': {
+ change: function (field) {
+ let view = this.getView();
+ let valueField = view.down('field[reference=valueSelector]');
+ let store = valueField.getStore();
+ let val = field.getValue();
+
+ if (val) {
+ store.setFilters([
+ {
+ property: 'field',
+ value: val,
+ },
+ ]);
+ }
+ },
+ },
+ },
+ },
+ viewModel: {
+ // parent is set in `initComponents`
+ formulas: {
+ typeIsMatchField: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ get: function (record) {
+ return record?.get('type') === 'match-field';
+ },
+ },
+ isRegex: function (get) {
+ return get('matchFieldType') === 'regex';
+ },
+ matchFieldType: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ set: function (value) {
+ let record = this.get('selectedRecord');
+ let currentData = record.get('data');
+
+ let newValue = [];
+
+ // Build equivalent regular expression if switching
+ // to 'regex' mode
+ if (value === 'regex') {
+ let regexVal = '^';
+ if (currentData.value && currentData.value.length) {
+ regexVal += `(${currentData.value.join('|')})`;
+ }
+ regexVal += '$';
+ newValue.push(regexVal);
+ }
+
+ record.set({
+ data: {
+ ...currentData,
+ type: value,
+ value: newValue,
+ },
+ });
+ },
+ get: function (record) {
+ return record?.get('data')?.type;
+ },
+ },
+ matchFieldField: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ set: function (value) {
+ let record = this.get('selectedRecord');
+ let currentData = record.get('data');
+
+ record.set({
+ data: {
+ ...currentData,
+ field: value,
+ // Reset value if field changes
+ value: [],
+ },
+ });
+ },
+ get: function (record) {
+ return record?.get('data')?.field;
+ },
+ },
+ matchFieldValue: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ set: function (value) {
+ let record = this.get('selectedRecord');
+ let currentData = record.get('data');
+ record.set({
+ data: {
+ ...currentData,
+ value: value,
+ },
+ });
+ },
+ get: function (record) {
+ return record?.get('data')?.value;
+ },
+ },
+ },
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let store = Ext.create('Ext.data.Store', {
+ model: 'proxmox-notification-fields',
+ autoLoad: true,
+ proxy: {
+ type: 'proxmox',
+ url: `/api2/json/${me.baseUrl}/matcher-fields`,
+ },
+ listeners: {
+ load: function () {
+ this.each(function (record) {
+ record.set({
+ description: Proxmox.Utils.formatNotificationFieldName(
+ record.get('name'),
+ ),
+ });
+ });
+
+ // Commit changes so that the description field is not marked
+ // as dirty
+ this.commitChanges();
+ },
+ },
+ });
+
+ let valueStore = Ext.create('Ext.data.Store', {
+ model: 'proxmox-notification-field-values',
+ autoLoad: true,
+ proxy: {
+ type: 'proxmox',
+
+ url: `/api2/json/${me.baseUrl}/matcher-field-values`,
+ },
+ listeners: {
+ load: function () {
+ this.each(
+ function (record) {
+ if (record.get('field') === 'type') {
+ record.set({
+ comment: Proxmox.Utils.formatNotificationFieldValue(
+ record.get('value'),
+ ),
+ });
+ }
+ },
+ this,
+ true,
+ );
+
+ // Commit changes so that the description field is not marked
+ // as dirty
+ this.commitChanges();
+ },
+ },
+ });
+
+ Ext.apply(me.viewModel, {
+ parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+ });
+ Ext.apply(me, {
+ items: [
+ {
+ fieldLabel: gettext('Match Type'),
+ xtype: 'proxmoxKVComboBox',
+ reference: 'type',
+ isFormField: false,
+ allowBlank: false,
+ submitValue: false,
+ field: 'type',
+
+ bind: {
+ value: '{matchFieldType}',
+ },
+
+ comboItems: [
+ ['exact', gettext('Exact')],
+ ['regex', gettext('Regex')],
+ ],
+ },
+ {
+ fieldLabel: gettext('Field'),
+ reference: 'fieldSelector',
+ xtype: 'proxmoxComboGrid',
+ isFormField: false,
+ submitValue: false,
+ allowBlank: false,
+ editable: false,
+ store: store,
+ queryMode: 'local',
+ valueField: 'name',
+ displayField: 'description',
+ field: 'field',
+ bind: {
+ value: '{matchFieldField}',
+ },
+ listConfig: {
+ columns: [
+ {
+ header: gettext('Description'),
+ dataIndex: 'description',
+ flex: 2,
+ },
+ {
+ header: gettext('Field Name'),
+ dataIndex: 'name',
+ flex: 1,
+ },
+ ],
+ },
+ },
+ {
+ fieldLabel: gettext('Value'),
+ reference: 'valueSelector',
+ xtype: 'proxmoxComboGrid',
+ autoSelect: false,
+ editable: false,
+ isFormField: false,
+ submitValue: false,
+ allowBlank: false,
+ showClearTrigger: true,
+ field: 'value',
+ store: valueStore,
+ valueField: 'value',
+ displayField: 'value',
+ notFoundIsValid: false,
+ multiSelect: true,
+ bind: {
+ value: '{matchFieldValue}',
+ hidden: '{isRegex}',
+ },
+ listConfig: {
+ columns: [
+ {
+ header: gettext('Value'),
+ dataIndex: 'value',
+ flex: 1,
+ },
+ {
+ header: gettext('Comment'),
+ dataIndex: 'comment',
+ flex: 2,
+ },
+ ],
+ },
+ },
+ {
+ fieldLabel: gettext('Regex'),
+ xtype: 'proxmoxtextfield',
+ editable: true,
+ isFormField: false,
+ submitValue: false,
+ allowBlank: false,
+ field: 'value',
+ bind: {
+ value: '{matchFieldValue}',
+ hidden: '{!isRegex}',
+ },
+ },
+ ],
+ });
+ me.callParent();
+ },
+});