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 85F5D1FF136 for ; Mon, 04 May 2026 18:09:35 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A956548BA; Mon, 4 May 2026 18:05:39 +0200 (CEST) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v4 43/47] ui: sdn: add panel for managing route map entries Date: Mon, 4 May 2026 18:03:40 +0200 Message-ID: <20260504160350.395470-44-s.hanreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260504160350.395470-1-s.hanreich@proxmox.com> References: <20260504160350.395470-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: 1777910535481 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.670 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: YY6P5QN72J5JQQSLJTIYMRI7YLBEGXHY X-Message-ID-Hash: YY6P5QN72J5JQQSLJTIYMRI7YLBEGXHY 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 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 --- www/manager6/Makefile | 1 + www/manager6/dc/Config.js | 8 + www/manager6/sdn/RouteMapPanel.js | 945 ++++++++++++++++++++++++++++++ 3 files changed, 954 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..ce21eb75f --- /dev/null +++ b/www/manager6/sdn/RouteMapPanel.js @@ -0,0 +1,945 @@ +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, + 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}' + }, + comboItems: [ + ['on-match-next', gettext('On match next')], + ], + allowBlank: true, + }, + ], + + getValue: function() { + let me = this; + + let exitAction = me.getViewModel().get('exitAction'); + + if (!exitAction.key) { + return null; + } + + return PVE.Parser.printPropertyString(exitAction); + }, + + setValue: function(value) { + let me = this; + me.getViewModel().set('exitAction', PVE.Parser.parsePropertyString(value)); + }, + + getSubmitData: function() { + let me = this; + + let value = me.getValue(); + if (value.length === 0) { + 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.PendingRouteMap', { + extend: 'Ext.data.Model', + fields: ['id', 'pending'], +}); + +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: `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: 'action', + 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) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], +}); -- 2.47.3