From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id A87551FF14F for ; Fri, 08 May 2026 18:41:04 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 83FCB201D7; Fri, 8 May 2026 18:41:04 +0200 (CEST) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v6 12/24] ui: sdn: add panel for managing route map entries Date: Fri, 8 May 2026 18:31:21 +0200 Message-ID: <20260508163134.481912-13-s.hanreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260508163134.481912-1-s.hanreich@proxmox.com> References: <20260508163134.481912-1-s.hanreich@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1778257795079 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.631 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 Message-ID-Hash: TXYFSDIDYSLBTGFCCPJ7VRYY55A2MQBS X-Message-ID-Hash: TXYFSDIDYSLBTGFCCPJ7VRYY55A2MQBS X-MailFrom: s.hanreich@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Thomas Lamprecht X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This panel allows users to perform CRUD operations on route map entries and shows an overview of all existing route map entries. Signed-off-by: Stefan Hanreich Link: https://lore.proxmox.com/20260505153720.412180-43-s.hanreich@proxmox.com Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 1 + www/manager6/dc/Config.js | 8 + www/manager6/sdn/RouteMapPanel.js | 977 ++++++++++++++++++++++++++++++ 3 files changed, 986 insertions(+) create mode 100644 www/manager6/sdn/RouteMapPanel.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index b123a331d..597769bb9 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -309,6 +309,7 @@ JSSRC= \ sdn/ZoneView.js \ sdn/IpamEdit.js \ sdn/OptionsPanel.js \ + sdn/RouteMapPanel.js \ sdn/RouteMapSelector.js \ sdn/PrefixListPanel.js \ sdn/PrefixListSelector.js \ diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 8784e357c..fd3a68a79 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -312,6 +312,14 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-road', itemId: 'sdnfabrics', }, + { + xtype: 'pveSDNRouteMaps', + groups: ['sdn'], + title: gettext('Route Maps'), + hidden: true, + iconCls: 'fa fa-map', + itemId: 'sdnroutemaps', + }, { xtype: 'pveSDNPrefixLists', groups: ['sdn'], diff --git a/www/manager6/sdn/RouteMapPanel.js b/www/manager6/sdn/RouteMapPanel.js new file mode 100644 index 000000000..5fa0475c3 --- /dev/null +++ b/www/manager6/sdn/RouteMapPanel.js @@ -0,0 +1,977 @@ +Ext.define('PVE.sdn.RouteMapEntry', { + extend: 'Ext.data.Model', + fields: ['route-map-id', 'order', 'action', 'match', 'set', 'exit-action', 'pending'], + + getRouteMapId: function () { + let me = this; + return me.data.pending?.['route-map-id'] ?? me.data['route-map-id']; + }, + + getOrder: function () { + let me = this; + return me.data.pending?.order ?? me.data.order; + }, +}); + +Ext.define('PVE.sdn.RouteMapExitAction', { + extend: 'Ext.data.Model', + fields: ['key', 'value'], +}); + +Ext.define('PVE.sdn.RouteMapSet', { + extend: 'Ext.data.Model', + fields: ['key', 'value'], +}); + +Ext.define('PVE.sdn.RouteMapSetValueField', { + extend: 'Ext.container.Container', + mixins: ['Ext.form.field.Field'], + + alias: ['widget.pveSdnRouteMapSetValueField'], + + layout: 'vbox', + + config: { + record: null, + }, + + publishes: { + record: true, + }, + + defaults: { + width: '100%', + }, + + viewModel: { + data: { + selectedKey: null, + }, + }, + + items: [], + + getWidgetForKey: function (key) { + const widgets = { + 'ip-next-hop-peer-address': { + xtype: 'displayfield', + }, + 'ip-next-hop': { + xtype: 'proxmoxtextfield', + vtype: 'IPAddress', + bind: { + value: '{record.value}', + }, + }, + 'ip-next-hop-unchanged': { + xtype: 'displayfield', + }, + 'ip6-next-hop-peer-address': { + xtype: 'displayfield', + }, + 'ip6-next-hop-prefer-global': { + xtype: 'displayfield', + }, + 'ip6-next-hop': { + xtype: 'proxmoxtextfield', + vtype: 'IP6Address', + bind: { + value: '{record.value}', + }, + }, + 'local-preference': { + xtype: 'proxmoxintegerfield', + minValue: 1, + maxValue: 2 ** 32 - 1, + step: 1, + bind: { + value: '{record.value}', + }, + }, + tag: { + xtype: 'proxmoxintegerfield', + minValue: 1, + maxValue: 2 ** 32 - 1, + step: 1, + bind: { + value: '{record.value}', + }, + }, + weight: { + xtype: 'proxmoxintegerfield', + minValue: 1, + maxValue: 2 ** 32 - 1, + step: 1, + bind: { + value: '{record.value}', + }, + }, + metric: { + xtype: 'proxmoxintegerfield', + minValue: 1, + maxValue: 2 ** 32 - 1, + step: 1, + bind: { + value: '{record.value}', + }, + }, + src: { + xtype: 'proxmoxtextfield', + vtype: 'IP64Address', + bind: { + value: '{record.value}', + }, + }, + }; + + return ( + widgets[key] ?? { + xtype: 'displayfield', + } + ); + }, + + applyRecord: function (record) { + let me = this; + + if (record.data.key === me.getViewModel().get('selectedKey')) { + return; + } + me.getViewModel().set('selectedKey', record.data.key); + + me.removeAll(); + + let widget = me.getWidgetForKey(record.data.key); + + if (widget.xtype === 'displayfield') { + me.getRecord()?.set('value', null); + } + + me.add(widget); + + return record; + }, +}); + +const ROUTE_MAP_SET_ACTION_LABELS = { + 'ip-next-hop': gettext('IPv4 next-hop'), + 'ip-next-hop-peer-address': gettext('IPv4 next-hop to peer address'), + 'ip-next-hop-unchanged': gettext('IPv4 next-hop unchanged'), + 'ip6-next-hop': gettext('IPv6 next-hop'), + 'ip6-next-hop-peer-address': gettext('IPv6 next-hop to peer address'), + 'ip6-next-hop-prefer-global': gettext('IPv6 next-hop to global address'), + 'local-preference': gettext('Local Preference'), + tag: gettext('Tag'), + weight: gettext('Weight'), + metric: gettext('Metric'), + src: gettext('Source'), +}; + +Ext.define('PVE.sdn.RouteMapSetField', { + extend: 'Ext.grid.Panel', + mixins: ['Ext.form.field.Field'], + alias: 'widget.pveSdnRouteMapSetField', + + emptyText: gettext('No set actions configured.'), + + isCreate: false, + + store: { + model: 'PVE.sdn.RouteMapSet', + }, + + columns: [ + { + header: gettext('Property'), + xtype: 'widgetcolumn', + flex: 1, + widget: { + xtype: 'proxmoxKVComboBox', + comboItems: Object.entries(ROUTE_MAP_SET_ACTION_LABELS), + allowBlank: false, + deleteEmpty: false, + bind: { + value: '{record.key}', + }, + listeners: { + select: function (_this, newValue) { + let me = this; + me.getWidgetRecord().set('key', newValue.id); + }, + }, + }, + }, + { + header: gettext('Value'), + flex: 1, + xtype: 'widgetcolumn', + widget: { + xtype: 'pveSdnRouteMapSetValueField', + bind: { + record: { + bindTo: '{record}', + deep: true, + }, + }, + }, + }, + { + width: 20, + xtype: 'actioncolumn', + items: [ + { + tooltip: gettext('Delete'), + handler: 'deleteSet', + iconCls: 'fa critical fa-trash-o', + }, + ], + }, + ], + + initComponent: function () { + let me = this; + me.callParent(); + + me.getStore().on('datachanged', function () { + me.fireEvent('dirtychange'); + }); + }, + + getValue: function () { + let me = this; + + return me + .getStore() + .getData() + .items.map((item) => { + let data = item.data; + delete data.id; + + if (!data.value) { + delete data.value; + } + + return PVE.Parser.printPropertyString(data); + }); + }, + + setValue: function (value) { + let me = this; + me.getStore().setData(value.map(PVE.Parser.parsePropertyString)); + }, + + getSubmitData: function () { + let me = this; + let value = me.getValue(); + + if (value.length === 0) { + return { + delete: [me.getName()], + }; + } + + return { + [me.getName()]: value, + }; + }, + + tbar: [ + { + xtype: 'button', + text: gettext('Add'), + handler: 'addEntry', + }, + ], + + controller: { + addEntry: function () { + let me = this; + me.getView().getStore().add({ + key: null, + value: null, + }); + }, + deleteSet: function (_table, _rI, _cI, _item, _e, record) { + let me = this; + me.getView().getStore().remove(record); + }, + }, +}); + +Ext.define('PVE.sdn.RouteMapMatch', { + extend: 'Ext.data.Model', + fields: ['key', 'value'], +}); + +Ext.define('PVE.sdn.RouteMapMatchValueField', { + extend: 'Ext.container.Container', + mixins: ['Ext.form.field.Field'], + + alias: ['widget.pveSdnRouteMapMatchValueField'], + + layout: 'vbox', + + config: { + key: null, + record: null, + }, + + publishes: { + record: true, + }, + + defaults: { + name: 'value', + width: '100%', + bind: { + value: '{record.value}', + }, + }, + + items: [], + + getWidgetForKey: function (key) { + const widgets = { + 'route-type': { + xtype: 'proxmoxKVComboBox', + comboItems: [ + ['ead', gettext('Ethernet Auto-Discovery (Type 1)')], + ['macip', gettext('MAC/IP Advertisement (Type 2)')], + ['multicast', gettext('Inclusive Multicast (Type 3)')], + ['es', gettext('Ethernet Segment (Type 4)')], + ['prefix', gettext('IP Prefix (Type 5)')], + ], + allowBlank: false, + deleteEmpty: false, + }, + vni: { + xtype: 'proxmoxintegerfield', + flex: 1, + minValue: 1, + maxValue: 2 ** 24 - 1, + step: 1, + }, + 'ip-address-prefix-list': { + xtype: 'pveSDNPrefixListSelector', + }, + 'ip6-address-prefix-list': { + xtype: 'pveSDNPrefixListSelector', + }, + 'ip-next-hop-prefix-list': { + xtype: 'pveSDNPrefixListSelector', + }, + 'ip6-next-hop-prefix-list': { + xtype: 'pveSDNPrefixListSelector', + }, + 'ip-next-hop-address': { + xtype: 'proxmoxtextfield', + vtype: 'IPAddress', + }, + 'ip6-next-hop-address': { + xtype: 'proxmoxtextfield', + vtype: 'IP6Address', + }, + metric: { + xtype: 'proxmoxintegerfield', + minValue: 1, + maxValue: 2 ** 32 - 1, + step: 1, + }, + 'local-preference': { + xtype: 'proxmoxintegerfield', + minValue: 1, + maxValue: 2 ** 32 - 1, + step: 1, + }, + peer: { + xtype: 'proxmoxtextfield', + }, + }; + + return ( + widgets[key] ?? { + xtype: 'displayfield', + } + ); + }, + + updateKey: function (key) { + let me = this; + + me.removeAll(); + me.add(me.getWidgetForKey(key)); + + return key; + }, +}); + +const ROUTE_MAP_MATCH_ACTION_LABELS = { + 'route-type': gettext('Route Type'), + vni: gettext('VNI'), + 'ip-address-prefix-list': gettext('IPv4 (prefix-list)'), + 'ip6-address-prefix-list': gettext('IPv6 (prefix-list)'), + 'ip-next-hop-prefix-list': gettext('IPv4 next-hop (prefix-list)'), + 'ip6-next-hop-prefix-list': gettext('IPv6 next-hop (prefix-list)'), + 'ip-next-hop-address': gettext('IPv4 next-hop'), + 'ip6-next-hop-address': gettext('IPv6 next-hop'), + metric: gettext('Metric'), + 'local-preference': gettext('Local Preference'), + peer: gettext('Peer'), +}; + +Ext.define('PVE.sdn.RouteMapMatchField', { + extend: 'Ext.grid.Panel', + mixins: ['Ext.form.field.Field'], + alias: 'widget.pveSdnRouteMapMatchField', + + emptyText: gettext('No match actions configured.'), + + isCreate: false, + + store: { + model: 'PVE.sdn.RouteMapMatch', + }, + + columns: [ + { + header: gettext('Property'), + xtype: 'widgetcolumn', + flex: 1, + widget: { + xtype: 'proxmoxKVComboBox', + comboItems: Object.entries(ROUTE_MAP_MATCH_ACTION_LABELS), + allowBlank: false, + deleteEmpty: false, + bind: { + value: '{record.key}', + }, + listeners: { + select: function (_this, newValue) { + let me = this; + me.getWidgetRecord().set('key', newValue.id); + }, + }, + }, + }, + { + header: gettext('Value'), + flex: 1, + xtype: 'widgetcolumn', + widget: { + xtype: 'pveSdnRouteMapMatchValueField', + bind: { + key: '{record.key}', + record: '{record}', + }, + }, + }, + { + width: 20, + xtype: 'actioncolumn', + items: [ + { + tooltip: gettext('Delete'), + handler: 'deleteMatch', + iconCls: 'fa critical fa-trash-o', + }, + ], + }, + ], + + initComponent: function () { + let me = this; + me.callParent(); + + me.getStore().on('datachanged', function () { + me.fireEvent('validitychange'); + me.fireEvent('dirtychange'); + }); + }, + + getValue: function () { + let me = this; + + return me + .getStore() + .getData() + .items.map((item) => { + let data = item.data; + delete data.id; + + if (!data.value) { + delete data.value; + } + + return PVE.Parser.printPropertyString(data); + }); + }, + + setValue: function (value) { + let me = this; + me.getStore().setData(value.map(PVE.Parser.parsePropertyString)); + }, + + getSubmitData: function () { + let me = this; + + let value = me.getValue(); + if (value.length === 0) { + return { + delete: [me.getName()], + }; + } + + return { + [me.getName()]: value, + }; + }, + + tbar: [ + { + xtype: 'button', + text: gettext('Add'), + handler: 'addEntry', + }, + ], + + controller: { + addEntry: function () { + let me = this; + me.getView().getStore().add({ + key: null, + value: null, + }); + }, + deleteMatch: function (_table, _rI, _cI, _item, _e, record) { + let me = this; + me.getView().getStore().remove(record); + }, + }, +}); + +Ext.define('PVE.sdn.RouteMapExitActionField', { + extend: 'Ext.container.Container', + mixins: ['Ext.form.field.Field'], + alias: 'widget.pveSdnRouteMapExitActionField', + + layout: 'hbox', + + viewModel: { + data: { + exitAction: null, + }, + }, + + items: [ + { + xtype: 'proxmoxKVComboBox', + flex: 1, + fieldLabel: gettext('Exit Policy'), + bind: { + value: '{exitAction.key}', + }, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + gettext('(exit)')], + ['on-match-next', gettext('On match next')], + ], + deleteEmpty: false, + editable: false, + isFormField: false, + listeners: { + select: 'onSelect', + }, + }, + ], + + controller: { + onSelect: function () { + let me = this; + me.getView().fireEvent('dirtychange'); + }, + }, + + getValue: function () { + let me = this; + + let exitAction = me.getViewModel().get('exitAction'); + + if (!exitAction?.key || exitAction.key === '__default__') { + return null; + } + + return PVE.Parser.printPropertyString(exitAction); + }, + + setValue: function (value) { + let me = this; + + let exitAction = PVE.Parser.parsePropertyString(value); + + me.getViewModel().set('exitAction', exitAction); + me.resetOriginalValue(); + }, + + getSubmitData: function () { + let me = this; + + let value = me.getValue(); + + if (!value) { + return { + delete: [me.getName()], + }; + } + + return { + [me.getName()]: value, + }; + }, +}); + +Ext.define('PVE.sdn.EditRouteMapEntryWindow', { + extend: 'Proxmox.window.Edit', + subject: gettext('Route Map Entry'), + + initComponent: function () { + let me = this; + me.method = me.isCreate ? 'POST' : 'PUT'; + + me.callParent(); + }, + + loadUrl: function () { + let me = this; + return `/api2/extjs/cluster/sdn/route-maps/${me.getRouteMapId()}/${me.getOrder()}`; + }, + + submitUrl: function () { + let me = this; + + if (me.isCreate) { + return '/api2/extjs/cluster/sdn/route-maps'; + } else { + return `/api2/extjs/cluster/sdn/route-maps/${me.getRouteMapId()}/${me.getOrder()}`; + } + }, + + width: 600, + + viewModel: { + formulas: { + routeMapId: function (get) { + let me = this; + return me.getView().getRouteMapId(); + }, + order: function (get) { + let me = this; + return me.getView().getOrder(); + }, + }, + }, + + config: { + routeMapId: null, + order: null, + }, + + isCreate: false, + + items: [ + { + xtype: 'pveSDNRouteMapSelector', + name: 'route-map-id', + fieldLabel: gettext('Route Map ID'), + editable: true, + notFoundIsValid: true, + bind: { + disabled: '{routeMapId}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'order', + fieldLabel: gettext('Order'), + bind: { + disabled: '{order}', + }, + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Action'), + name: 'action', + comboItems: [ + ['permit', gettext('Permit')], + ['deny', gettext('Deny')], + ], + allowBlank: false, + }, + { + xtype: 'fieldcontainer', + fieldLabel: gettext('Match'), + items: [ + { + xtype: 'pveSdnRouteMapMatchField', + name: 'match', + }, + ], + }, + { + xtype: 'fieldcontainer', + fieldLabel: gettext('Set'), + items: [ + { + xtype: 'pveSdnRouteMapSetField', + name: 'set', + }, + ], + }, + { + xtype: 'pveSDNRouteMapSelector', + fieldLabel: gettext('Call'), + name: 'call', + deleteEmpty: true, + skipEmptyText: true, + }, + { + xtype: 'pveSdnRouteMapExitActionField', + fieldLabel: gettext('Exit Policy'), + name: 'exit-action', + }, + ], +}); + +Ext.define('PVE.sdn.RouteMapPanel', { + extend: 'Ext.grid.Panel', + alias: ['widget.pveSDNRouteMaps'], + + emptyText: gettext('No route maps configured.'), + + store: { + autoLoad: true, + model: 'PVE.sdn.RouteMapEntry', + proxy: { + type: 'proxmox', + url: '/api2/extjs/cluster/sdn/route-maps?pending=1', + }, + sorters: [ + { + property: 'route-map-id', + direction: 'ASC', + }, + { + property: 'order', + direction: 'ASC', + }, + ], + }, + + viewModel: { + formulas: { + selection: function (get) { + let me = this; + + let selection = me.getView().getSelection(); + return selection.length > 0 ? selection[0] : null; + }, + }, + }, + + listeners: { + itemdblclick: 'editRouteMapEntry', + }, + + controller: { + reload: function () { + let me = this; + me.getView().getStore().load(); + }, + addRouteMapEntry: function () { + let me = this; + + Ext.create('PVE.sdn.EditRouteMapEntryWindow', { + autoShow: true, + isCreate: true, + listeners: { + close: function () { + me.reload(); + }, + }, + }); + }, + removeRouteMapEntry: function () { + let me = this; + + let entry = me.getView().getSelection()[0]; + + if (!entry) { + console.warn('no route map entry selected!'); + return; + } + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: gettext('Remove route map entry?'), + buttons: Ext.Msg.YESNO, + defaultFocus: 'no', + callback: function (btn) { + if (btn !== 'yes') { + return; + } + + Proxmox.Async.api2({ + url: `/api2/extjs/cluster/sdn/route-maps/${entry.getRouteMapId()}/${entry.getOrder()}`, + method: 'DELETE', + }) + .catch(Proxmox.Utils.alertResponseFailure) + .finally(() => { + me.reload(); + }); + }, + }); + }, + editRouteMapEntry: function () { + let me = this; + + let entry = me.getView().getSelection()[0]; + + if (!entry) { + console.warn('no route map entry selected!'); + return; + } + + Ext.create('PVE.sdn.EditRouteMapEntryWindow', { + autoShow: true, + autoLoad: true, + isCreate: false, + routeMapId: entry.getRouteMapId(), + order: entry.getOrder(), + listeners: { + close: function () { + me.reload(); + }, + }, + }); + }, + }, + + tbar: [ + { + text: gettext('Add'), + xtype: 'button', + handler: 'addRouteMapEntry', + }, + { + text: gettext('Edit'), + xtype: 'proxmoxButton', + handler: 'editRouteMapEntry', + bind: { + disabled: '{!selection}', + }, + }, + { + text: gettext('Remove'), + xtype: 'proxmoxButton', + handler: 'removeRouteMapEntry', + bind: { + disabled: '{!selection}', + }, + }, + { + text: gettext('Reload'), + xtype: 'button', + handler: 'reload', + }, + ], + + columns: [ + { + text: gettext('Name'), + dataIndex: 'route-map-id', + flex: 1, + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'route-map-id', 1); + }, + }, + { + text: gettext('Order'), + dataIndex: 'order', + width: 50, + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'order', 1); + }, + }, + { + text: gettext('Action'), + dataIndex: 'action', + width: 80, + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'action', 1); + }, + }, + { + text: gettext('Match'), + dataIndex: 'match', + flex: 1, + renderer: function (value, metaData, rec) { + let actions = rec.data.pending?.match ?? rec.data.match ?? []; + + return actions + .map(PVE.Parser.parsePropertyString) + .map((match) => { + let label = ROUTE_MAP_MATCH_ACTION_LABELS[match.key] ?? match.key; + let value = match.value ? `: ${match.value}` : ''; + return Ext.htmlEncode(`${label}${value}`); + }) + .join('
'); + }, + }, + { + text: gettext('Set'), + dataIndex: 'set', + flex: 1, + renderer: function (value, metaData, rec) { + let actions = rec.data.pending?.set ?? rec.data.set ?? []; + + return actions + .map(PVE.Parser.parsePropertyString) + .map((match) => { + let label = ROUTE_MAP_SET_ACTION_LABELS[match.key] ?? match.key; + let value = match.value ? `: ${match.value}` : ''; + return Ext.htmlEncode(`${label}${value}`); + }) + .join('
'); + }, + }, + { + text: gettext('Call'), + dataIndex: 'call', + flex: 1, + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'call', 1); + }, + }, + { + header: gettext('Exit Policy'), + width: 100, + dataIndex: 'exit-action', + renderer: function (value, metaData, rec) { + let exitAction = rec.data.pending?.['exit-action'] ?? rec.data['exit-action']; + + if (exitAction) { + let parsedExitAction = PVE.Parser.parsePropertyString(exitAction); + return Ext.htmlEncode(`${parsedExitAction.key}`); + } + }, + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], +}); -- 2.47.3