From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 5DAB41FF0DF for ; Thu, 05 Nov 2026 12:19:47 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 7425F21AA7; Thu, 05 Nov 2026 12:15:51 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v4 41/45] ui: sdn: microseg: add a guest tab with its groups and rules Date: Thu, 5 Nov 2026 12:13:20 +0100 Message-ID: <20261105111324.706922-42-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105111324.706922-1-h.laimer@proxmox.com> References: <20261105111324.706922-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793877251989 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.240 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) PROLO_LEO2 0.1 Meta Catches all Leo drug variations so far RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: KXEDBCJ7JTFEFNNSSPVKYZKK4TRLPEZY X-Message-ID-Hash: KXEDBCJ7JTFEFNNSSPVKYZKK4TRLPEZY X-MailFrom: h.laimer@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: The tab lists each assigned NIC with its assignment and groups, and below that the rules that apply to the guest. They are grouped by direction instead of by priority as in the SDN panel, since for a single guest the direction matters more. Editing stays in the SDN panel. Signed-off-by: Hannes Laimer --- www/manager6/Makefile | 1 + www/manager6/lxc/Config.js | 10 ++ www/manager6/qemu/Config.js | 10 ++ www/manager6/sdn/microseg/Base.js | 41 +++++---- www/manager6/sdn/microseg/GuestPanel.js | 117 ++++++++++++++++++++++++ www/manager6/sdn/microseg/RuleView.js | 40 ++++++-- 6 files changed, 195 insertions(+), 24 deletions(-) create mode 100644 www/manager6/sdn/microseg/GuestPanel.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 7c48b0622aa7..1f429fb20bdf 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -366,6 +366,7 @@ JSSRC= \ sdn/microseg/AssignmentEdit.js \ sdn/microseg/RuleView.js \ sdn/microseg/AssignmentView.js \ + sdn/microseg/GuestPanel.js \ storage/ContentView.js \ storage/BackupView.js \ storage/Base.js \ diff --git a/www/manager6/lxc/Config.js b/www/manager6/lxc/Config.js index 3a99d6a71dec..d14bfa244958 100644 --- a/www/manager6/lxc/Config.js +++ b/www/manager6/lxc/Config.js @@ -387,6 +387,16 @@ Ext.define('PVE.lxc.Config', { }); } + if (caps.vms['VM.Audit'] && caps.sdn['SDN.Audit']) { + me.items.push({ + xtype: 'pveSDNMicrosegGuest', + title: gettext('Microsegmentation'), + iconCls: 'fa fa-tags', + itemId: 'microseg', + vmid: vmid, + }); + } + if (caps.vms['Permissions.Modify']) { me.items.push({ xtype: 'pveACLView', diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js index 842d35de1202..7d7b17bfa870 100644 --- a/www/manager6/qemu/Config.js +++ b/www/manager6/qemu/Config.js @@ -440,6 +440,16 @@ Ext.define('PVE.qemu.Config', { }); } + if (caps.vms['VM.Audit'] && caps.sdn['SDN.Audit']) { + me.items.push({ + xtype: 'pveSDNMicrosegGuest', + title: gettext('Microsegmentation'), + iconCls: 'fa fa-tags', + itemId: 'microseg', + vmid: vmid, + }); + } + if (caps.vms['Permissions.Modify']) { me.items.push({ xtype: 'pveACLView', diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js index 38c3db37444a..f8be696dc83d 100644 --- a/www/manager6/sdn/microseg/Base.js +++ b/www/manager6/sdn/microseg/Base.js @@ -134,6 +134,7 @@ Ext.define('PVE.sdn.microseg.Membership', { closedGroups: hierarchy.close(sortedGroups), rules: rulesFor([sortedGroups]), note: PVE.Utils.format_sdnmicroseg_matcher(a), + assignment: a, }; (a.members || []).forEach((m) => { m.nics.forEach((nic) => { @@ -223,33 +224,37 @@ Ext.define('PVE.sdn.microseg.BaseGrid', { me.openEdit(rec.data.type, rec.data.id, me.editPanelConfig?.(rec)); }; + let editButtons = me.readOnly + ? [] + : [ + me.addButton, + new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + enableFn: editable, + handler: runEditor, + }), + Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + enableFn: editable, + baseurl: me.baseurl, + getRecordName: me.getRecordName, + callback: () => me.reload(), + }), + ]; + Ext.apply(me, { selModel: sm, tbar: [ - me.addButton, - new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - enableFn: editable, - handler: runEditor, - }), - Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - enableFn: editable, - baseurl: me.baseurl, - getRecordName: me.getRecordName, - callback: () => me.reload(), - }), + ...editButtons, { text: gettext('Reload'), handler: () => me.reload(), }, ...(me.tbar ?? []), ], - listeners: { - itemdblclick: runEditor, - }, + listeners: me.readOnly ? {} : { itemdblclick: runEditor }, }); me.callParent(); diff --git a/www/manager6/sdn/microseg/GuestPanel.js b/www/manager6/sdn/microseg/GuestPanel.js new file mode 100644 index 000000000000..657fa4d1fbc7 --- /dev/null +++ b/www/manager6/sdn/microseg/GuestPanel.js @@ -0,0 +1,117 @@ +Ext.define('PVE.sdn.microseg.GuestPanel', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNMicrosegGuest', + + onlineHelp: 'pvesdn_config_microseg', + + layout: 'border', + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + let me = this; + let view = me.getView(); + let membership = PVE.sdn.microseg.Membership; + membership.load(view, (parts) => { + let hierarchy = membership.hierarchy(parts.groups); + let rulesFor = membership.rulesFor(parts.groupSets); + let nicGroupSet = membership.nicGroupSets(parts.assignments, hierarchy, rulesFor); + let prefix = `${view.vmid}/`; + let nics = Object.entries(nicGroupSet) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, groupSet]) => ({ + nic: Number(key.slice(prefix.length)), + groups: groupSet.groups, + matcher: groupSet.note, + comment: groupSet.assignment.comment, + state: groupSet.assignment.state, + })) + .sort((a, b) => a.nic - b.nic); + me.lookup('nics').getStore().loadData(nics); + + let rulesView = me.lookup('rules'); + rulesView.setData(parts.rules); + rulesView.setTreeSelection( + membership.guestSelection(view.vmid, nicGroupSet, hierarchy, rulesFor), + ); + }); + }, + }, + + initComponent: function () { + let me = this; + + if (!me.vmid) { + throw 'no vmid given'; + } + + me.items = [ + { + xtype: 'grid', + reference: 'nics', + region: 'north', + height: 150, + split: true, + border: false, + store: { + fields: ['nic', 'groups', 'matcher', 'comment', 'state'], + sorters: [{ property: 'nic', direction: 'ASC' }], + }, + viewConfig: { + getRowClass: PVE.Utils.sdnmicroseg_row_cls, + }, + emptyText: gettext('No assignment covers this guest'), + tbar: [ + { + text: gettext('Reload'), + handler: 'reload', + }, + ], + columns: [ + { + text: gettext('Interface'), + dataIndex: 'nic', + width: 100, + renderer: (v) => `net${v}`, + }, + { + text: gettext('Groups'), + dataIndex: 'groups', + flex: 2, + renderer: (v) => (v || []).map(PVE.Utils.render_sdnmicroseg_group).join(''), + }, + { + text: gettext('Assignment'), + dataIndex: 'matcher', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + { xtype: 'pveSDNMicrosegStateColumn' }, + { + text: gettext('Comment'), + dataIndex: 'comment', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + ], + }, + { + xtype: 'pveSDNMicrosegRules', + reference: 'rules', + region: 'center', + readOnly: true, + groupByDirection: true, + listeners: { + microsegreload: 'reload', + }, + }, + ]; + + me.callParent(); + }, + + listeners: { + activate: 'reload', + }, +}); diff --git a/www/manager6/sdn/microseg/RuleView.js b/www/manager6/sdn/microseg/RuleView.js index f2a4fbc93321..43ff73f651a4 100644 --- a/www/manager6/sdn/microseg/RuleView.js +++ b/www/manager6/sdn/microseg/RuleView.js @@ -38,6 +38,7 @@ Ext.define( let matched = sel ? [...new Set([...src, ...dst])].filter((g) => sel.groups.has(g)) : []; + let direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : ''; return { rank: me.rankOf(data), specificity: data.specificity ?? 0, @@ -46,7 +47,8 @@ Ext.define( .map((g) => sel.ownGroups.has(g) ? g : Ext.String.format(gettext('{0} (inherited)'), g), ), - direction: out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '', + direction: direction, + directionRank: { in: 0, out: 1, both: 2 }[direction] ?? 3, }; }, @@ -97,13 +99,32 @@ Ext.define( initComponent: function () { let me = this; + if (me.groupByDirection) { + let directionNames = { + in: gettext('Inbound'), + out: gettext('Outbound'), + both: gettext('Both'), + }; + me.features = [ + { + ftype: 'grouping', + groupHeaderTpl: [ + '{groupValue:this.directionName}', + { + directionName: (name) => + Ext.String.htmlEncode(directionNames[name] ?? name), + }, + ], + enableGroupingMenu: false, + }, + ]; + } + let store = Ext.create('Ext.data.Store', { model: 'pve-sdn-microseg-rule', - grouper: { - property: 'prio', - sortProperty: 'prio', - direction: 'DESC', - }, + grouper: me.groupByDirection + ? { property: 'direction', sortProperty: 'directionRank', direction: 'ASC' } + : { property: 'prio', sortProperty: 'prio', direction: 'DESC' }, sorters: [ { property: 'rank', direction: 'ASC' }, { property: 'id', direction: 'ASC' }, @@ -238,6 +259,12 @@ Ext.define( flex: 1, renderer: (v, m, rec) => Ext.String.htmlEncode(matchText(rec)), }, + { + text: gettext('Priority'), + dataIndex: 'prio', + width: 80, + hidden: !me.groupByDirection, + }, { text: gettext('Specificity'), dataIndex: 'specificity', @@ -305,6 +332,7 @@ Ext.define( 'macro', 'fragments', 'direction', + 'directionRank', 'rank', 'specificity', 'matched', -- 2.47.3