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 4BEB31FF0E3 for ; Tue, 27 Oct 2026 10:03:04 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 27B7621BB6; Tue, 27 Oct 2026 09:57:18 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v3 39/45] ui: sdn: microseg: show and edit the group hierarchy Date: Tue, 27 Oct 2026 09:55:21 +0100 Message-ID: <20261027085527.702738-40-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com> References: <20261027085527.702738-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: 1793091376027 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.353 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) 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: FBWENFLSYUG5LAOEPTZWOC2ICM5ZZMGT X-Message-ID-Hash: FBWENFLSYUG5LAOEPTZWOC2ICM5ZZMGT 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: Selecting a group filters the rules to those naming it or a group above it, so no rule its members inherit is left out. Signed-off-by: Hannes Laimer --- www/manager6/form/SDNMicrosegGroupSelector.js | 26 +++++++++- www/manager6/sdn/MicrosegView.js | 8 ++- www/manager6/sdn/microseg/AssignmentView.js | 5 +- www/manager6/sdn/microseg/Base.js | 48 ++++++++++++++++-- www/manager6/sdn/microseg/GroupEdit.js | 10 ++++ www/manager6/sdn/microseg/RuleView.js | 26 ++++++++++ www/manager6/sdn/microseg/Tree.js | 49 +++++++++++++++---- 7 files changed, 153 insertions(+), 19 deletions(-) diff --git a/www/manager6/form/SDNMicrosegGroupSelector.js b/www/manager6/form/SDNMicrosegGroupSelector.js index 1b0c39a1cfba..e7a4077e97df 100644 --- a/www/manager6/form/SDNMicrosegGroupSelector.js +++ b/www/manager6/form/SDNMicrosegGroupSelector.js @@ -34,12 +34,28 @@ Ext.define( initComponent: function () { let me = this; - let store = new Ext.data.Store({ + let store; + let isExcluded = (rec) => { + let data = store.getData(); + let all = data.getSource() || data; + let seen = new Set(); + while (rec && !seen.has(rec.getId())) { + if (rec.getId() === me.excludeId) { + return true; + } + seen.add(rec.getId()); + rec = all.get(rec.get('parent')); + } + return false; + }; + + store = new Ext.data.Store({ model: 'pve-sdn-microseg-group', sorters: { property: 'id', direction: 'ASC', }, + filters: me.excludeId ? [(rec) => !isExcluded(rec)] : [], }); me.mon(store, 'load', () => me.syncUntagged()); @@ -55,6 +71,12 @@ Ext.define( dataIndex: 'id', flex: 1, }, + { + header: gettext('Parent'), + sortable: true, + dataIndex: 'parent', + flex: 1, + }, { header: gettext('Comment'), dataIndex: 'comment', @@ -73,7 +95,7 @@ Ext.define( function () { Ext.define('pve-sdn-microseg-group', { extend: 'Ext.data.Model', - fields: ['id', 'comment'], + fields: ['id', 'parent', 'comment'], proxy: { type: 'proxmox', url: '/api2/json/cluster/sdn/microseg/group', diff --git a/www/manager6/sdn/MicrosegView.js b/www/manager6/sdn/MicrosegView.js index 805b5b7ebdd9..028253920081 100644 --- a/www/manager6/sdn/MicrosegView.js +++ b/www/manager6/sdn/MicrosegView.js @@ -36,7 +36,13 @@ Ext.define('PVE.sdn.MicrosegView', { }, addGroup: function () { - this.lookup('tree').openEdit('group'); + let me = this; + let parent = me.selectedGroup(); + me.lookup('tree').openEdit( + 'group', + undefined, + parent ? { defaultParent: parent } : undefined, + ); }, editGroup: function () { diff --git a/www/manager6/sdn/microseg/AssignmentView.js b/www/manager6/sdn/microseg/AssignmentView.js index ea6c447bf710..373efe02eccd 100644 --- a/www/manager6/sdn/microseg/AssignmentView.js +++ b/www/manager6/sdn/microseg/AssignmentView.js @@ -68,9 +68,8 @@ Ext.define( if (sel.vmid !== undefined) { return !winsNic(rec, sel.vmid, sel.nic); } - let group = sel.mode === 'membership' ? sel.groups[0] : undefined; - if (group && group !== 'untagged') { - return !(rec.data.groups ?? []).includes(group); + if (sel.mode === 'membership' && !sel.untagged) { + return !(rec.data.groups ?? []).some((g) => sel.subtree.includes(g)); } return false; }; diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js index 6751da1c7e88..02c176b04f5c 100644 --- a/www/manager6/sdn/microseg/Base.js +++ b/www/manager6/sdn/microseg/Base.js @@ -71,6 +71,43 @@ Ext.define('PVE.sdn.microseg.Membership', { }); }, + hierarchy: function (groups) { + let byId = {}; + for (const group of groups) { + byId[group.id] = group; + } + let childrenOf = {}; + for (const group of groups) { + let parent = group.parent; + let key = parent && byId[parent] ? parent : ''; + (childrenOf[key] = childrenOf[key] || []).push(group); + } + let ancestors = (group) => { + let out = []; + let seen = new Set(); + let parent = group.parent; + while (parent && byId[parent] && !seen.has(parent)) { + seen.add(parent); + out.push(parent); + parent = byId[parent].parent; + } + return out; + }; + let close = (list) => { + let out = new Set(); + for (const g of list) { + out.add(g); + if (byId[g]) { + for (const a of ancestors(byId[g])) { + out.add(a); + } + } + } + return [...out].sort(); + }; + return { byId, childrenOf, ancestors, close }; + }, + rulesFor: function (groupSets) { let classRules = {}; groupSets.forEach((cls) => { @@ -88,12 +125,13 @@ Ext.define('PVE.sdn.microseg.Membership', { }; }, - nicGroupSets: function (assignments, rulesFor) { + nicGroupSets: function (assignments, hierarchy, rulesFor) { let nicGroupSet = {}; assignments.forEach((a) => { let sortedGroups = (a.groups || []).slice().sort(); let groupSet = { groups: sortedGroups, + closedGroups: hierarchy.close(sortedGroups), rules: rulesFor([sortedGroups]), note: PVE.Utils.format_sdnmicroseg_matcher(a), }; @@ -106,12 +144,16 @@ Ext.define('PVE.sdn.microseg.Membership', { return nicGroupSet; }, - guestSelection: function (vmid, nicGroupSet, rulesFor) { + guestSelection: function (vmid, nicGroupSet, hierarchy, rulesFor) { let keys = Object.keys(nicGroupSet).filter((key) => key.startsWith(`${vmid}/`)); - let rules = rulesFor(keys.map((key) => nicGroupSet[key].groups)); + let sets = keys.map((key) => nicGroupSet[key].groups); + let ownGroups = sets.flat(); + let rules = rulesFor(sets); return { scope: `vm${vmid}`, mode: 'group-set', + groups: hierarchy.close(ownGroups), + ownGroups: ownGroups, outbound: rules.outbound, inbound: rules.inbound, vmid: vmid, diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js index 82639c344264..94f108ad46f8 100644 --- a/www/manager6/sdn/microseg/GroupEdit.js +++ b/www/manager6/sdn/microseg/GroupEdit.js @@ -15,6 +15,16 @@ Ext.define('PVE.sdn.microseg.GroupInputPanel', { allowBlank: false, maxLength: 32, }, + { + xtype: 'pveSDNMicrosegGroupSelector', + name: 'parent', + fieldLabel: gettext('Parent'), + allowBlank: true, + skipEmptyText: true, + deleteEmpty: !me.isCreate, + excludeId: me.microsegId, + value: me.defaultParent, + }, { xtype: 'proxmoxtextfield', name: 'comment', diff --git a/www/manager6/sdn/microseg/RuleView.js b/www/manager6/sdn/microseg/RuleView.js index 016e1dc4455e..58d81221402c 100644 --- a/www/manager6/sdn/microseg/RuleView.js +++ b/www/manager6/sdn/microseg/RuleView.js @@ -35,8 +35,15 @@ Ext.define( out = sel.outbound.has(data.id); inbound = sel.inbound.has(data.id); } + let matched = sel + ? [...new Set([...src, ...dst])].filter((g) => sel.groups.has(g)) + : []; return { rank: me.rankOf(data), + specificity: data.specificity ?? 0, + matched: matched + .sort() + .map((g) => (sel.ownGroups.has(g) ? g : `${g} (${gettext('inherited')})`)), direction: out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '', }; }, @@ -65,6 +72,7 @@ Ext.define( mode: mode, untagged: sel.untagged, groups: new Set(sel.groups), + ownGroups: new Set(sel.ownGroups), outbound: sel.outbound, inbound: sel.inbound, } @@ -79,6 +87,8 @@ Ext.define( } me.down('#microsegScopeText').setText(text); + me.down('#microsegMatched').setVisible(!!mode); + me.loadRows(); }, @@ -221,6 +231,20 @@ Ext.define( flex: 1, renderer: (v, m, rec) => Ext.String.htmlEncode(matchText(rec)), }, + { + text: gettext('Specificity'), + dataIndex: 'specificity', + width: 100, + hidden: true, + }, + { + text: gettext('Matched By'), + itemId: 'microsegMatched', + dataIndex: 'matched', + flex: 1, + hidden: true, + renderer: (v) => Ext.String.htmlEncode((v || []).join(', ')), + }, { xtype: 'pveSDNMicrosegStateColumn' }, { text: gettext('Comment'), @@ -274,6 +298,8 @@ Ext.define( 'macro', 'direction', 'rank', + 'specificity', + 'matched', ], }); }, diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js index e261e4944e81..1a3c0905d0aa 100644 --- a/www/manager6/sdn/microseg/Tree.js +++ b/www/manager6/sdn/microseg/Tree.js @@ -27,8 +27,11 @@ Ext.define( groups.sort((a, b) => a.id.localeCompare(b.id)); let membership = PVE.sdn.microseg.Membership; + let hierarchy = membership.hierarchy(groups); + let childrenOf = hierarchy.childrenOf; + let ancestorsOf = hierarchy.ancestors; let rulesFor = membership.rulesFor(groupSets); - let nicGroupSet = membership.nicGroupSets(assignments, rulesFor); + let nicGroupSet = membership.nicGroupSets(assignments, hierarchy, rulesFor); let groupNics = {}; for (const [key, groupSet] of Object.entries(nicGroupSet)) { @@ -43,7 +46,12 @@ Ext.define( if (!guests[vmid]) { guests[vmid] = { ...guestDisplay(vmid), - selection: membership.guestSelection(vmid, nicGroupSet, rulesFor), + selection: membership.guestSelection( + vmid, + nicGroupSet, + hierarchy, + rulesFor, + ), }; } return guests[vmid]; @@ -65,16 +73,20 @@ Ext.define( mode: 'membership', untagged: true, groups: ['untagged'], + ownGroups: ['untagged'], + subtree: ['untagged'], }, leaf: true, }, ]; - for (const group of groups) { + let groupNode = (group) => { let groupId = `g/${group.id}`; let byVmid = {}; + let nicKeys = new Set(); for (const { vmid, nic } of groupNics[group.id] || []) { (byVmid[vmid] = byVmid[vmid] || []).push(nic); + nicKeys.add(`${vmid}/${nic}`); } let guestNodes = Object.keys(byVmid) .map(Number) @@ -98,6 +110,8 @@ Ext.define( microsegSelection: { scope: `vm${vmid}/net${nic} {${groupSet.groups.join(', ')}}`, mode: 'group-set', + groups: groupSet.closedGroups, + ownGroups: groupSet.groups, outbound: groupSet.rules.outbound, inbound: groupSet.rules.inbound, vmid: vmid, @@ -109,22 +123,37 @@ Ext.define( }; }); - let count = (groupNics[group.id] || []).length; - nodes.push({ + let subgroupNodes = []; + let subtree = [group.id]; + for (const sub of childrenOf[group.id] || []) { + let subgroup = groupNode(sub); + subgroupNodes.push(subgroup.node); + subgroup.nicKeys.forEach((key) => nicKeys.add(key)); + subtree.push(...subgroup.subtree); + } + let children = [...subgroupNodes, ...guestNodes]; + let node = { id: groupId, text: group.id, - microsegNote: count ? `(${count})` : undefined, + microsegNote: nicKeys.size ? `(${nicKeys.size})` : undefined, iconCls: 'fa fa-fw fa-object-group', microsegId: group.id, microsegSelection: { scope: group.id, mode: 'membership', - groups: [group.id], + groups: [group.id, ...ancestorsOf(group)], + ownGroups: [group.id], + subtree: subtree, }, state: group.state, - leaf: !guestNodes.length, - children: guestNodes, - }); + leaf: !children.length, + children: children, + }; + return { node: node, nicKeys: nicKeys, subtree: subtree }; + }; + + for (const group of childrenOf[''] || []) { + nodes.push(groupNode(group).node); } return nodes; }, -- 2.47.3