From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 9E0211FF164 for ; Fri, 8 Nov 2024 15:43:27 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7C2A810836; Fri, 8 Nov 2024 15:42:15 +0100 (CET) From: Lukas Wagner To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Date: Fri, 8 Nov 2024 15:41:17 +0100 Message-Id: <20241108144124.273550-8-l.wagner@proxmox.com> X-Mailer: git-send-email 2.39.5 In-Reply-To: <20241108144124.273550-1-l.wagner@proxmox.com> References: <20241108144124.273550-1-l.wagner@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.009 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH widget-toolkit v3 07/14] notification: add UI for adding/updating webhook targets X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox VE development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pve-devel-bounces@lists.proxmox.com Sender: "pve-devel" The widgets for editing the headers/secrets were adapted from the 'Tag Edit' dialog from PVE's datacenter options. Apart from that, the new dialog is rather standard. I've decided to put the http method and url in a single row, mostly to save space and also to make it analogous to how an actual http request is structured (VERB URL, followed by headers, followed by the body). The secrets are a mechanism to store tokens/passwords in the protected notification config. Secrets are accessible via templating in the URL, headers and body via {{ secrets.NAME }}. Secrets can only be set/updated, but not retrieved/displayed. Signed-off-by: Lukas Wagner Tested-By: Stefan Hanreich --- src/Makefile | 1 + src/Schema.js | 5 + src/Utils.js | 20 ++ src/panel/WebhookEditPanel.js | 424 ++++++++++++++++++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 src/panel/WebhookEditPanel.js diff --git a/src/Makefile b/src/Makefile index 0478251..cfaffd7 100644 --- a/src/Makefile +++ b/src/Makefile @@ -78,6 +78,7 @@ JSSRC= \ panel/StatusView.js \ panel/TfaView.js \ panel/NotesView.js \ + panel/WebhookEditPanel.js \ window/Edit.js \ window/PasswordEdit.js \ window/SafeDestroy.js \ diff --git a/src/Schema.js b/src/Schema.js index 42541e0..cd1c306 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -65,6 +65,11 @@ Ext.define('Proxmox.Schema', { // a singleton ipanel: 'pmxGotifyEditPanel', iconCls: 'fa-bell-o', }, + webhook: { + name: 'Webhook', + ipanel: 'pmxWebhookEditPanel', + iconCls: 'fa-bell-o', + }, }, // to add or change existing for product specific ones diff --git a/src/Utils.js b/src/Utils.js index 4ff95af..52375d2 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1524,6 +1524,26 @@ utilities: { me.IP6_dotnotation_match = new RegExp("^(" + IPV6_REGEXP + ")(?:\\.(\\d+))?$"); me.Vlan_match = /^vlan(\d+)/; me.VlanInterface_match = /(\w+)\.(\d+)/; + + + // Taken from proxmox-schema and ported to JS + let PORT_REGEX_STR = "(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])"; + let IPRE_BRACKET_STR = "(?:" + IPV4_REGEXP + "|\\[(?:" + IPV6_REGEXP + ")\\])"; + let DNS_NAME_STR = "(?:(?:" + DnsName_REGEXP + "\\.)*" + DnsName_REGEXP + ")"; + let HTTP_URL_REGEX = "^https?://(?:(?:(?:" + + DNS_NAME_STR + + "|" + + IPRE_BRACKET_STR + + ")(?::" + + PORT_REGEX_STR + + ")?)|" + + IPV6_REGEXP + + ")(?:/[^\x00-\x1F\x7F]*)?$"; + + me.httpUrlRegex = new RegExp(HTTP_URL_REGEX); + + // Same as SAFE_ID_REGEX in proxmox-schema + me.safeIdRegex = /^(?:[A-Za-z0-9_][A-Za-z0-9._\\-]*)$/; }, }); diff --git a/src/panel/WebhookEditPanel.js b/src/panel/WebhookEditPanel.js new file mode 100644 index 0000000..0a39f3c --- /dev/null +++ b/src/panel/WebhookEditPanel.js @@ -0,0 +1,424 @@ +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: [ + { + layout: 'hbox', + border: false, + margin: '0 0 5 0', + items: [ + { + xtype: 'displayfield', + value: gettext('Method/URL:'), + width: 125, + }, + { + 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'), + maskValues: false, + cbind: { + isCreate: '{isCreate}', + }, + }, + { + xtype: 'textarea', + fieldLabel: gettext('Body'), + name: 'body', + allowBlank: true, + minHeight: '150', + fieldStyle: { + 'font-family': 'monospace', + }, + margin: '15 0 0 0', + }, + { + xtype: 'pmxWebhookKeyValueList', + name: 'secret', + fieldLabel: gettext('Secrets'), + maskValues: true, + cbind: { + isCreate: '{isCreate}', + }, + }, + { + 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.container.Container', + alias: 'widget.pmxWebhookKeyValueList', + + mixins: [ + 'Ext.form.field.Field', + ], + + // override for column header + fieldTitle: gettext('Item'), + + // will be applied to the textfields + maskRe: undefined, + + allowBlank: true, + selectAll: false, + isFormField: true, + deleteEmpty: false, + config: { + deleteEmpty: false, + 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: '', + 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', + }, + }, + }, + + margin: '10 0 5 0', + + items: [ + { + layout: 'hbox', + border: false, + items: [ + { + xtype: 'displayfield', + width: 125, + }, + { + xtype: 'button', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: 'addLine', + margin: '0 5 5 0', + }, + ], + }, + { + xtype: 'grid', + reference: 'grid', + minHeight: 100, + maxHeight: 100, + scrollable: 'vertical', + margin: '0 0 0 125', + + viewConfig: { + deferEmptyText: false, + }, + + store: { + listeners: { + update: function() { + this.commitChanges(); + }, + }, + }, + }, + ], + + initComponent: function() { + let me = this; + + for (const [key, value] of Object.entries(me.gridConfig ?? {})) { + me.items[1][key] = value; + } + + me.items[0].items[0].value = me.fieldLabel + ':'; + + me.items[1].columns = [ + { + header: me.fieldTtitle, + dataIndex: 'headerName', + xtype: 'widgetcolumn', + widget: { + xtype: 'textfield', + isFormField: false, + maskRe: me.maskRe, + allowBlank: false, + queryMode: 'local', + listeners: { + change: 'itemChange', + }, + }, + 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}', + }, + }, + flex: 1, + }, + { + xtype: 'widgetcolumn', + width: 40, + widget: { + xtype: 'button', + iconCls: 'fa fa-trash-o', + }, + }, + ]; + + me.callParent(); + me.initField(); + }, +}); -- 2.39.5 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel