From 060e0c6fa962cb566306cf6819229905f024d91c Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 3 Oct 2026 01:29:24 +0200 Subject: [PATCH] Upstream proxmox-git/proxmox-widget-toolkit state of the touched files before ef098ff9ef --- src/Toolkit.js | 921 ++++++++++++++++ src/panel/GotifyEditPanel.js | 75 ++ src/panel/SendmailEditPanel.js | 103 ++ src/panel/SmtpEditPanel.js | 218 ++++ src/panel/WebhookEditPanel.js | 427 ++++++++ src/window/NotificationMatcherEdit.js | 1390 +++++++++++++++++++++++++ 6 files changed, 3134 insertions(+) create mode 100644 src/Toolkit.js create mode 100644 src/panel/GotifyEditPanel.js create mode 100644 src/panel/SendmailEditPanel.js create mode 100644 src/panel/SmtpEditPanel.js create mode 100644 src/panel/WebhookEditPanel.js create mode 100644 src/window/NotificationMatcherEdit.js 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(); + }, +});