all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-manager v3 34/45] ui: sdn: add microsegmentation panel
Date: Tue, 27 Oct 2026 09:55:16 +0100	[thread overview]
Message-ID: <20261027085527.702738-35-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com>

Add a Microsegmentation panel under Datacenter > SDN to manage groups,
rules and guest assignments, with a tree of the groups and their
guests and NICs. Rules are grouped by priority, highest first.

A NIC belongs to the first assignment that matches it, so the
assignment list keeps the match order and can be reordered by drag and
drop or a position field. An assignment that wins no NIC is dimmed.
Selecting a group shows the rules that list it. Selecting a guest or
NIC shows the rules the API reports for its groups, so the panel does
not evaluate rules itself.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/css/ext6-pve.css                          |  23 ++
 www/manager6/Makefile                         |   9 +
 www/manager6/Utils.js                         |  74 ++++++
 www/manager6/data/PermPathStore.js            |   1 +
 www/manager6/dc/Config.js                     |   8 +
 www/manager6/form/SDNMicrosegGroupSelector.js |  83 ++++++
 www/manager6/sdn/MicrosegView.js              |  92 +++++++
 www/manager6/sdn/microseg/AssignmentEdit.js   |  80 ++++++
 www/manager6/sdn/microseg/AssignmentView.js   | 193 ++++++++++++++
 www/manager6/sdn/microseg/Base.js             | 215 +++++++++++++++
 www/manager6/sdn/microseg/GroupEdit.js        |  30 +++
 www/manager6/sdn/microseg/RuleEdit.js         | 158 +++++++++++
 www/manager6/sdn/microseg/RuleView.js         | 242 +++++++++++++++++
 www/manager6/sdn/microseg/Tree.js             | 249 ++++++++++++++++++
 14 files changed, 1457 insertions(+)
 create mode 100644 www/manager6/form/SDNMicrosegGroupSelector.js
 create mode 100644 www/manager6/sdn/MicrosegView.js
 create mode 100644 www/manager6/sdn/microseg/AssignmentEdit.js
 create mode 100644 www/manager6/sdn/microseg/AssignmentView.js
 create mode 100644 www/manager6/sdn/microseg/Base.js
 create mode 100644 www/manager6/sdn/microseg/GroupEdit.js
 create mode 100644 www/manager6/sdn/microseg/RuleEdit.js
 create mode 100644 www/manager6/sdn/microseg/RuleView.js
 create mode 100644 www/manager6/sdn/microseg/Tree.js

diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css
index b1e0b4f81002..07f2c65d33ef 100644
--- a/www/css/ext6-pve.css
+++ b/www/css/ext6-pve.css
@@ -708,3 +708,26 @@ table.osds td:first-of-type {
 .pmx-column-wrapped > div {
     white-space: pre-wrap;
 }
+
+.pve-microseg-group {
+    display: inline-block;
+    border: 1px solid #999;
+    border-radius: 3px;
+    padding: 0px 5px;
+    margin: 0px 1px;
+}
+
+.pve-microseg-muted {
+    opacity: 0.5;
+}
+
+.pve-microseg-id-space {
+    font-size: smaller;
+    font-weight: normal;
+}
+
+.pve-microseg-deleted td:not(.pve-microseg-state) .x-grid-cell-inner,
+.pve-microseg-deleted td:not(.pve-microseg-state) .pve-microseg-group,
+.pve-microseg-deleted td:not(.pve-microseg-state) .fa {
+    text-decoration: line-through;
+}
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d2ea786be00b..7c48b0622aa7 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -71,6 +71,7 @@ JSSRC= 							\
 	form/SDNControllerSelector.js			\
 	form/SDNZoneSelector.js				\
 	form/SDNVnetSelector.js				\
+	form/SDNMicrosegGroupSelector.js		\
 	form/SDNIpamSelector.js				\
 	form/SDNDnsSelector.js				\
 	form/ScsiHwSelector.js				\
@@ -357,6 +358,14 @@ JSSRC= 							\
 	sdn/fabrics/bgp/InterfacePanel.js		\
 	sdn/fabrics/bgp/NodeEdit.js			\
 	sdn/fabrics/bgp/FabricEdit.js			\
+	sdn/MicrosegView.js				\
+	sdn/microseg/Base.js				\
+	sdn/microseg/Tree.js				\
+	sdn/microseg/GroupEdit.js			\
+	sdn/microseg/RuleEdit.js			\
+	sdn/microseg/AssignmentEdit.js			\
+	sdn/microseg/RuleView.js			\
+	sdn/microseg/AssignmentView.js			\
 	storage/ContentView.js				\
 	storage/BackupView.js				\
 	storage/Base.js					\
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 8b99371d3447..8eb4b68c51b4 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -231,6 +231,22 @@ Ext.define('PVE.Utils', {
             return `<span data-qtip="${Ext.htmlEncode(tip)}">${icon}${Ext.htmlEncode(value)}</span>`;
         },
 
+        sdnmicroseg_row_cls: function (rec) {
+            return rec.data.state === 'deleted' ? 'pve-microseg-deleted' : '';
+        },
+
+        render_sdnmicroseg_state: function (value) {
+            return value
+                ? '<i class="fa fa-fw fa-refresh warning" data-qtip="' +
+                      Ext.String.htmlEncode(value) +
+                      '"></i>'
+                : '';
+        },
+
+        render_sdnmicroseg_group: function (group) {
+            return `<span class="pve-microseg-group">${Ext.String.htmlEncode(group)}</span>`;
+        },
+
         render_ceph_health: function (healthObj) {
             var state = {
                 iconCls: PVE.Utils.get_health_icon(),
@@ -966,6 +982,64 @@ Ext.define('PVE.Utils', {
             },
         },
 
+        sdnmicrosegSchema: {
+            group: {
+                name: gettext('Group'),
+                ipanel: 'GroupInputPanel',
+                width: 400,
+            },
+            rule: {
+                name: gettext('Rule'),
+                ipanel: 'RuleInputPanel',
+            },
+            'guest-assignment': {
+                name: gettext('Guest Assignment'),
+                ipanel: 'GuestAssignmentInputPanel',
+                width: 400,
+                faIcon: 'link',
+                assignment: true,
+            },
+        },
+
+        sdnmicroseg_is_assignment: function (type) {
+            return !!PVE.Utils.sdnmicrosegSchema[type]?.assignment;
+        },
+
+        format_sdnmicroseg_type: function (value) {
+            let schema = PVE.Utils.sdnmicrosegSchema[value];
+            if (schema) {
+                return schema.name;
+            }
+            return Proxmox.Utils.unknownText;
+        },
+
+        sdnmicroseg_merge_pending: function (obj) {
+            let merged = { ...obj };
+            for (const [key, value] of Object.entries(obj.pending ?? {})) {
+                if (value === 'deleted') {
+                    delete merged[key];
+                } else if (value !== undefined && value !== null) {
+                    merged[key] = value;
+                }
+            }
+            return merged;
+        },
+
+        format_sdnmicroseg_predicate: function (match, groups) {
+            if (match === 'every') {
+                return match;
+            }
+            return `${match || 'any'}{${(groups || []).join(', ')}}`;
+        },
+
+        format_sdnmicroseg_matcher: function (assignment) {
+            if (!PVE.Utils.sdnmicrosegSchema[assignment.type]) {
+                return Proxmox.Utils.unknownText;
+            }
+            let iface = (assignment.iface ?? '') === '' ? '' : ` net${assignment.iface}`;
+            return `vm${assignment.vmid}${iface}`;
+        },
+
         sdnipamSchema: {
             ipam: {
                 name: 'ipam',
diff --git a/www/manager6/data/PermPathStore.js b/www/manager6/data/PermPathStore.js
index 7210e0cd48af..65c8d17c850f 100644
--- a/www/manager6/data/PermPathStore.js
+++ b/www/manager6/data/PermPathStore.js
@@ -17,6 +17,7 @@ Ext.define('PVE.data.PermPathStore', {
         { value: '/nodes' },
         { value: '/pool' },
         { value: '/sdn/fabrics' },
+        { value: '/sdn/microseg' },
         { value: '/sdn/zones' },
         { value: '/storage' },
         { value: '/vms' },
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 83c48c3d633c..fce20937f3ba 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -309,6 +309,14 @@ Ext.define('PVE.dc.Config', {
                     iconCls: 'fa fa-road',
                     itemId: 'sdnfabrics',
                 },
+                {
+                    xtype: 'pveSDNMicroseg',
+                    groups: ['sdn'],
+                    title: gettext('Microsegmentation'),
+                    hidden: true,
+                    iconCls: 'fa fa-tags',
+                    itemId: 'sdnmicroseg',
+                },
                 {
                     xtype: 'pveSDNRouteMaps',
                     groups: ['sdn'],
diff --git a/www/manager6/form/SDNMicrosegGroupSelector.js b/www/manager6/form/SDNMicrosegGroupSelector.js
new file mode 100644
index 000000000000..1b0c39a1cfba
--- /dev/null
+++ b/www/manager6/form/SDNMicrosegGroupSelector.js
@@ -0,0 +1,83 @@
+Ext.define(
+    'PVE.form.SDNMicrosegGroupSelector',
+    {
+        extend: 'Proxmox.form.ComboGrid',
+        alias: ['widget.pveSDNMicrosegGroupSelector'],
+
+        valueField: 'id',
+        displayField: 'id',
+
+        syncUntagged: function () {
+            let me = this;
+            let store = me.getStore();
+            let record = store.getById('untagged');
+            if (me.includeUntagged && !record) {
+                store.add({
+                    id: 'untagged',
+                    comment: gettext('No group (untagged)'),
+                });
+            } else if (!me.includeUntagged && record) {
+                store.remove(record);
+            }
+        },
+
+        setIncludeUntagged: function (include) {
+            let me = this;
+            me.includeUntagged = include;
+            let value = me.getValue();
+            if (!include && Ext.isArray(value)) {
+                me.setValue(value.filter((id) => id !== 'untagged'));
+            }
+            me.syncUntagged();
+        },
+
+        initComponent: function () {
+            let me = this;
+
+            let store = new Ext.data.Store({
+                model: 'pve-sdn-microseg-group',
+                sorters: {
+                    property: 'id',
+                    direction: 'ASC',
+                },
+            });
+
+            me.mon(store, 'load', () => me.syncUntagged());
+
+            Ext.apply(me, {
+                store: store,
+                autoSelect: false,
+                listConfig: {
+                    columns: [
+                        {
+                            header: gettext('Group'),
+                            sortable: true,
+                            dataIndex: 'id',
+                            flex: 1,
+                        },
+                        {
+                            header: gettext('Comment'),
+                            dataIndex: 'comment',
+                            flex: 2,
+                            renderer: Ext.String.htmlEncode,
+                        },
+                    ],
+                },
+            });
+
+            me.callParent();
+
+            store.load();
+        },
+    },
+    function () {
+        Ext.define('pve-sdn-microseg-group', {
+            extend: 'Ext.data.Model',
+            fields: ['id', '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
new file mode 100644
index 000000000000..805b5b7ebdd9
--- /dev/null
+++ b/www/manager6/sdn/MicrosegView.js
@@ -0,0 +1,92 @@
+Ext.define('PVE.sdn.MicrosegView', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNMicroseg',
+
+    onlineHelp: 'pvesdn_config_microseg',
+
+    layout: 'border',
+
+    controller: {
+        xclass: 'Ext.app.ViewController',
+
+        reload: function () {
+            let me = this;
+            PVE.sdn.microseg.Membership.load(me.getView(), (parts) => {
+                me.lookup('rules').setData(parts.rules);
+                me.lookup('assignments').setData(parts.assignments);
+                me.lookup('tree').setData(
+                    parts.groups,
+                    parts.assignments,
+                    parts.groupSets,
+                    parts.idSpace,
+                );
+            });
+        },
+
+        onTreeSelect: function (tree, selection) {
+            let me = this;
+            let sel = selection[0]?.data.microsegSelection ?? null;
+            me.lookup('rules').setTreeSelection(sel);
+            me.lookup('assignments').setTreeSelection(sel);
+        },
+
+        selectedGroup: function () {
+            let node = this.lookup('tree').getSelection()[0];
+            return node && PVE.sdn.microseg.Tree.editable(node) ? node.data.microsegId : undefined;
+        },
+
+        addGroup: function () {
+            this.lookup('tree').openEdit('group');
+        },
+
+        editGroup: function () {
+            let me = this;
+            let group = me.selectedGroup();
+            if (group) {
+                me.lookup('tree').openEdit('group', group);
+            }
+        },
+    },
+
+    items: [
+        {
+            xtype: 'pveSDNMicrosegTree',
+            reference: 'tree',
+            region: 'west',
+            title: gettext('Groups'),
+            width: 400,
+            minWidth: 250,
+            split: true,
+            listeners: {
+                microsegreload: 'reload',
+                selectionchange: 'onTreeSelect',
+                itemdblclick: 'editGroup',
+            },
+        },
+        {
+            xtype: 'tabpanel',
+            region: 'center',
+            border: false,
+            items: [
+                {
+                    xtype: 'pveSDNMicrosegRules',
+                    reference: 'rules',
+                    listeners: {
+                        microsegreload: 'reload',
+                    },
+                },
+                {
+                    xtype: 'pveSDNMicrosegAssignments',
+                    reference: 'assignments',
+                    listeners: {
+                        microsegreload: 'reload',
+                    },
+                },
+            ],
+        },
+    ],
+
+    listeners: {
+        activate: 'reload',
+    },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
new file mode 100644
index 000000000000..71cabaeeea52
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -0,0 +1,80 @@
+Ext.define('PVE.sdn.microseg.AssignmentInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    onlineHelp: 'pvesdn_microseg_assignment',
+
+    onGetValues: function (values) {
+        let me = this;
+        if (me.isCreate) {
+            values.type = me.type;
+        }
+        return values;
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            ...me.matcherItems,
+            {
+                xtype: 'proxmoxintegerfield',
+                name: 'iface',
+                fieldLabel: gettext('Network Interface'),
+                minValue: 0,
+                maxValue: 31,
+                allowBlank: true,
+                emptyText: gettext('All NICs'),
+                disabled: !me.isCreate,
+            },
+            {
+                xtype: 'pveSDNMicrosegGroupSelector',
+                name: 'groups',
+                fieldLabel: gettext('Groups'),
+                multiSelect: true,
+                allowBlank: false,
+            },
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'comment',
+                fieldLabel: gettext('Comment'),
+                allowBlank: true,
+                maxLength: 256,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.advancedItems = [
+            {
+                xtype: 'proxmoxintegerfield',
+                name: 'position',
+                fieldLabel: gettext('Position'),
+                value: me.position,
+                minValue: 0,
+                allowBlank: true,
+                emptyText: me.isCreate ? '0' : gettext('Unchanged'),
+            },
+        ];
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', {
+    extend: 'PVE.sdn.microseg.AssignmentInputPanel',
+
+    initComponent: function () {
+        let me = this;
+
+        me.matcherItems = [
+            {
+                xtype: 'vmComboSelector',
+                name: 'vmid',
+                fieldLabel: gettext('Guest'),
+                allowBlank: false,
+                disabled: !me.isCreate,
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentView.js b/www/manager6/sdn/microseg/AssignmentView.js
new file mode 100644
index 000000000000..4744380461a7
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentView.js
@@ -0,0 +1,193 @@
+Ext.define(
+    'PVE.sdn.microseg.AssignmentView',
+    {
+        extend: 'PVE.sdn.microseg.BaseGrid',
+        alias: 'widget.pveSDNMicrosegAssignments',
+
+        title: gettext('Assignments'),
+
+        baseurl: '/cluster/sdn/microseg/assignment/',
+
+        getRecordName: (rec) => PVE.Utils.format_sdnmicroseg_matcher(rec.data),
+
+        editPanelConfig: (rec) => ({ position: rec.data.rank }),
+
+        setData: function (assignments) {
+            let me = this;
+            let rows = assignments.map((a) => ({
+                ...a,
+                rank: me.rankOf(a),
+                memberCount: (a.members || []).reduce((n, m) => n + m.nics.length, 0),
+            }));
+            me.getStore().loadData(rows);
+        },
+
+        setTreeSelection: function (sel) {
+            let me = this;
+            me.treeSelection = sel;
+            me.getView().refresh();
+        },
+
+        initComponent: function () {
+            let me = this;
+
+            let store = Ext.create('Ext.data.Store', {
+                model: 'pve-sdn-microseg-assignment',
+                sorters: [
+                    { property: 'rank', direction: 'ASC' },
+                    { property: 'id', direction: 'ASC' },
+                ],
+            });
+
+            let move = (rec, index) =>
+                Proxmox.Utils.API2Request({
+                    url: `${me.baseurl}${rec.data.id}`,
+                    method: 'PUT',
+                    params: { position: index },
+                    waitMsgTarget: me,
+                    failure: (response) =>
+                        Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+                    callback: () => me.reload(),
+                });
+
+            let movable = (rec) => rec.data.state !== 'deleted';
+
+            let winsNic = (rec, vmid, nic) =>
+                (rec.data.members || []).some(
+                    (m) => m.vmid === vmid && (nic === undefined || m.nics.includes(nic)),
+                );
+
+            let dimmed = (rec) => {
+                if (!rec.data.memberCount) {
+                    return true;
+                }
+                let sel = me.treeSelection;
+                if (!sel) {
+                    return false;
+                }
+                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);
+                }
+                return false;
+            };
+
+            let renderMembers = (value, rec) => {
+                if (!value) {
+                    return '<span class="pve-microseg-muted">0</span>';
+                }
+                let nics = [];
+                for (const member of rec.data.members || []) {
+                    for (const nic of member.nics) {
+                        nics.push(`vm${member.vmid} net${nic}`);
+                    }
+                }
+                return `<span data-qtip="${Ext.String.htmlEncode(nics.join(', '))}">${value}</span>`;
+            };
+
+            me.columns = [
+                {
+                    text: '#',
+                    dataIndex: 'rank',
+                    width: 50,
+                    renderer: (v, m, rec) => (rec.data.state === 'deleted' ? '' : v),
+                },
+                {
+                    text: gettext('Matcher'),
+                    dataIndex: 'type',
+                    flex: 2,
+                    renderer: (v, m, rec) => {
+                        let text = Ext.String.htmlEncode(
+                            PVE.Utils.format_sdnmicroseg_matcher(rec.data),
+                        );
+                        let icon = PVE.Utils.sdnmicrosegSchema[v]?.faIcon;
+                        return icon ? `<i class="fa fa-fw fa-${icon}"></i> ${text}` : text;
+                    },
+                },
+                {
+                    text: gettext('Groups'),
+                    dataIndex: 'groups',
+                    flex: 2,
+                    renderer: (v) => (v ?? []).map(PVE.Utils.render_sdnmicroseg_group).join(''),
+                },
+                {
+                    text: gettext('NICs'),
+                    dataIndex: 'memberCount',
+                    width: 90,
+                    renderer: (v, m, rec) => renderMembers(v, rec),
+                },
+                { xtype: 'pveSDNMicrosegStateColumn' },
+                {
+                    text: gettext('Comment'),
+                    dataIndex: 'comment',
+                    flex: 1,
+                    renderer: Ext.String.htmlEncode,
+                },
+            ];
+
+            Ext.apply(me, {
+                store: store,
+                viewConfig: {
+                    plugins: [
+                        {
+                            ptype: 'gridviewdragdrop',
+                            dragGroup: 'pveSDNMicrosegAssignmentDD',
+                            dropGroup: 'pveSDNMicrosegAssignmentDD',
+                        },
+                    ],
+                    getRowClass: (rec) =>
+                        [
+                            PVE.Utils.sdnmicroseg_row_cls(rec),
+                            dimmed(rec) ? 'pve-microseg-muted' : '',
+                        ].join(' '),
+                    listeners: {
+                        beforedrop: (node, data, dropRec, dropPosition) => {
+                            let rec = data.records[0];
+                            if (!dropRec || rec === dropRec || !movable(rec) || !movable(dropRec)) {
+                                return false;
+                            }
+                            let list = store
+                                .getRange()
+                                .filter((r) => r !== rec && r.data.state !== 'deleted');
+                            move(rec, list.indexOf(dropRec) + (dropPosition === 'after' ? 1 : 0));
+                            return false;
+                        },
+                    },
+                },
+                addButton: {
+                    text: gettext('Add'),
+                    menu: {
+                        items: Object.keys(PVE.Utils.sdnmicrosegSchema)
+                            .filter((type) => PVE.Utils.sdnmicroseg_is_assignment(type))
+                            .map((type) => ({
+                                text: PVE.Utils.format_sdnmicroseg_type(type),
+                                handler: () => me.openEdit(type),
+                            })),
+                    },
+                },
+            });
+
+            me.callParent();
+        },
+    },
+    function () {
+        Ext.define('pve-sdn-microseg-assignment', {
+            extend: 'Ext.data.Model',
+            fields: [
+                'id',
+                'type',
+                'vmid',
+                'iface',
+                'groups',
+                'comment',
+                'state',
+                'members',
+                'rank',
+                'memberCount',
+            ],
+        });
+    },
+);
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
new file mode 100644
index 000000000000..6751da1c7e88
--- /dev/null
+++ b/www/manager6/sdn/microseg/Base.js
@@ -0,0 +1,215 @@
+Ext.define('PVE.sdn.microseg.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    initComponent: function () {
+        let me = this;
+
+        me.isCreate = !me.microsegId;
+
+        let schema = PVE.Utils.sdnmicrosegSchema[me.type];
+        if (!schema || !schema.ipanel) {
+            throw 'no editor registered for microseg type: ' + me.type;
+        }
+
+        if (me.isCreate) {
+            me.url = '/api2/extjs' + me.baseurl;
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs' + me.baseurl + me.microsegId;
+            me.method = 'PUT';
+        }
+
+        let ipanel = Ext.create(
+            'PVE.sdn.microseg.' + schema.ipanel,
+            Ext.apply(
+                {
+                    type: me.type,
+                    isCreate: me.isCreate,
+                    microsegId: me.microsegId,
+                },
+                me.panelConfig,
+            ),
+        );
+
+        Ext.apply(me, {
+            subject: PVE.Utils.format_sdnmicroseg_type(me.type),
+            isAdd: true,
+            width: schema.width,
+            items: [ipanel],
+        });
+
+        me.callParent();
+
+        if (!me.isCreate) {
+            me.load();
+        }
+    },
+});
+
+Ext.define('PVE.sdn.microseg.Membership', {
+    singleton: true,
+
+    load: function (view, callback) {
+        Proxmox.Utils.API2Request({
+            url: '/cluster/sdn/microseg/all?pending=1',
+            method: 'GET',
+            failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+            success: (response) => {
+                if (view.destroyed) {
+                    return;
+                }
+                let data = response.result.data;
+                let objects = data.objects.map(PVE.Utils.sdnmicroseg_merge_pending);
+                callback({
+                    groups: objects.filter((o) => o.type === 'group'),
+                    rules: objects.filter((o) => o.type === 'rule'),
+                    assignments: objects.filter((o) => PVE.Utils.sdnmicroseg_is_assignment(o.type)),
+                    groupSets: data['group-sets'],
+                    idSpace: data['id-space'],
+                });
+            },
+        });
+    },
+
+    rulesFor: function (groupSets) {
+        let classRules = {};
+        groupSets.forEach((cls) => {
+            classRules[cls.groups.join(',')] = cls;
+        });
+        return (sets) => {
+            let outbound = new Set();
+            let inbound = new Set();
+            sets.forEach((groupSet) => {
+                let cls = classRules[groupSet.join(',')];
+                cls?.outbound.forEach((r) => outbound.add(r));
+                cls?.inbound.forEach((r) => inbound.add(r));
+            });
+            return { outbound: outbound, inbound: inbound };
+        };
+    },
+
+    nicGroupSets: function (assignments, rulesFor) {
+        let nicGroupSet = {};
+        assignments.forEach((a) => {
+            let sortedGroups = (a.groups || []).slice().sort();
+            let groupSet = {
+                groups: sortedGroups,
+                rules: rulesFor([sortedGroups]),
+                note: PVE.Utils.format_sdnmicroseg_matcher(a),
+            };
+            (a.members || []).forEach((m) => {
+                m.nics.forEach((nic) => {
+                    nicGroupSet[`${m.vmid}/${nic}`] = groupSet;
+                });
+            });
+        });
+        return nicGroupSet;
+    },
+
+    guestSelection: function (vmid, nicGroupSet, rulesFor) {
+        let keys = Object.keys(nicGroupSet).filter((key) => key.startsWith(`${vmid}/`));
+        let rules = rulesFor(keys.map((key) => nicGroupSet[key].groups));
+        return {
+            scope: `vm${vmid}`,
+            mode: 'group-set',
+            outbound: rules.outbound,
+            inbound: rules.inbound,
+            vmid: vmid,
+        };
+    },
+});
+
+Ext.define('PVE.sdn.microseg.StateColumn', {
+    extend: 'Ext.grid.column.Column',
+    xtype: 'pveSDNMicrosegStateColumn',
+
+    text: '',
+    dataIndex: 'state',
+    width: 40,
+    resizable: false,
+    tdCls: 'pve-microseg-state',
+    renderer: PVE.Utils.render_sdnmicroseg_state,
+});
+
+Ext.define('PVE.sdn.microseg.ViewMixin', {
+    extend: 'Ext.Mixin',
+
+    reload: function () {
+        this.fireEvent('microsegreload');
+    },
+
+    rankOf: function (data) {
+        return data.rank ?? Number.MAX_SAFE_INTEGER;
+    },
+
+    openEdit: function (type, microsegId, panelConfig) {
+        let me = this;
+        Ext.create('PVE.sdn.microseg.BaseEdit', {
+            type: type,
+            baseurl: me.baseurl,
+            microsegId: microsegId,
+            panelConfig: panelConfig,
+            autoShow: true,
+            listeners: { destroy: () => me.reload() },
+        });
+    },
+});
+
+Ext.define('PVE.sdn.microseg.BaseGrid', {
+    extend: 'Ext.grid.GridPanel',
+    mixins: ['PVE.sdn.microseg.ViewMixin'],
+
+    border: false,
+    sortableColumns: false,
+
+    initComponent: function () {
+        let me = this;
+
+        if (!me.baseurl) {
+            throw 'no baseurl given';
+        }
+
+        let sm = Ext.create('Ext.selection.RowModel', {});
+
+        let editable = (rec) => rec.data.state !== 'deleted';
+
+        let runEditor = () => {
+            let rec = sm.getSelection()[0];
+            if (!rec || !editable(rec)) {
+                return;
+            }
+            me.openEdit(rec.data.type, rec.data.id, me.editPanelConfig?.(rec));
+        };
+
+        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(),
+                }),
+                {
+                    text: gettext('Reload'),
+                    handler: () => me.reload(),
+                },
+                ...(me.tbar ?? []),
+            ],
+            listeners: {
+                itemdblclick: runEditor,
+            },
+        });
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js
new file mode 100644
index 000000000000..82639c344264
--- /dev/null
+++ b/www/manager6/sdn/microseg/GroupEdit.js
@@ -0,0 +1,30 @@
+Ext.define('PVE.sdn.microseg.GroupInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    onlineHelp: 'pvesdn_microseg_group',
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: me.isCreate ? 'textfield' : 'displayfield',
+                name: 'id',
+                value: me.microsegId || '',
+                fieldLabel: gettext('Name'),
+                allowBlank: false,
+                maxLength: 32,
+            },
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'comment',
+                fieldLabel: gettext('Comment'),
+                allowBlank: true,
+                maxLength: 256,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js
new file mode 100644
index 000000000000..4ec5467f61be
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleEdit.js
@@ -0,0 +1,158 @@
+Ext.define('PVE.sdn.microseg.RuleInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    onlineHelp: 'pvesdn_microseg_rule',
+
+    initComponent: function () {
+        let me = this;
+
+        let matchItems = [
+            ['any', gettext('Any (shares a group)')],
+            ['all', gettext('All (has every group)')],
+            ['exact', gettext('Exact (same groups)')],
+            ['every', gettext('Every (has a group)')],
+        ];
+        let setEvery = (selector, every) => {
+            selector.setDisabled(every || !me.isCreate);
+            if (every) {
+                selector.setValue([]);
+            }
+        };
+
+        me.srcSelector = Ext.create({
+            xtype: 'pveSDNMicrosegGroupSelector',
+            name: 'src',
+            fieldLabel: gettext('Source Groups'),
+            multiSelect: true,
+            allowBlank: false,
+            includeUntagged: true,
+            disabled: !me.isCreate,
+            validator: () => {
+                let groups = me.srcSelector.getValue() || [];
+                let match = me.down('field[name=src-match]')?.getValue() ?? 'any';
+                if (
+                    match !== 'any' &&
+                    groups.includes('untagged') &&
+                    groups.some((group) => group !== 'untagged')
+                ) {
+                    return gettext(
+                        "With 'all' or 'exact', 'untagged' cannot be combined with other groups",
+                    );
+                }
+                return true;
+            },
+        });
+
+        me.dstSelector = Ext.create({
+            xtype: 'pveSDNMicrosegGroupSelector',
+            name: 'dst',
+            fieldLabel: gettext('Dest. Groups'),
+            multiSelect: true,
+            allowBlank: false,
+            includeUntagged: true,
+            disabled: !me.isCreate,
+            validator: () => {
+                let groups = me.dstSelector.getValue() || [];
+                if (groups.length === 1 && groups[0] === 'untagged') {
+                    return gettext("'untagged' cannot be the only destination group");
+                }
+                return true;
+            },
+        });
+
+        me.column1 = [
+            me.srcSelector,
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'src-match',
+                fieldLabel: gettext('Source Match'),
+                value: 'any',
+                disabled: !me.isCreate,
+                comboItems: matchItems,
+                listeners: {
+                    change: function (f, value) {
+                        setEvery(me.srcSelector, value === 'every');
+                        me.srcSelector.validate();
+                    },
+                },
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                name: 'bidirectional',
+                fieldLabel: gettext('Both Directions'),
+                uncheckedValue: null,
+                disabled: !me.isCreate,
+                autoEl: {
+                    tag: 'div',
+                    'data-qtip': gettext(
+                        'Also apply the rule with source and destination swapped.',
+                    ),
+                },
+            },
+        ];
+
+        me.column2 = [
+            me.dstSelector,
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'dst-match',
+                fieldLabel: gettext('Dest. Match'),
+                value: 'any',
+                disabled: !me.isCreate,
+                comboItems: matchItems,
+                listeners: {
+                    change: function (f, value) {
+                        setEvery(me.dstSelector, value === 'every');
+                        me.dstSelector.setIncludeUntagged(value === 'any');
+                        me.dstSelector.validate();
+                    },
+                },
+            },
+            {
+                xtype: 'proxmoxintegerfield',
+                name: 'prio',
+                fieldLabel: gettext('Priority'),
+                minValue: 0,
+                maxValue: 65535,
+                value: 0,
+                allowBlank: false,
+                autoEl: {
+                    tag: 'div',
+                    'data-qtip': gettext(
+                        'The highest priority wins. At a tie, the most specific rules decide and a deny wins among equals.',
+                    ),
+                },
+            },
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'allow',
+                fieldLabel: gettext('Action'),
+                value: 1,
+                comboItems: [
+                    [1, gettext('Allow')],
+                    [0, gettext('Deny')],
+                ],
+            },
+        ];
+
+        me.columnB = [
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'comment',
+                fieldLabel: gettext('Comment'),
+                allowBlank: true,
+                maxLength: 256,
+                deleteEmpty: !me.isCreate,
+            },
+            {
+                xtype: 'displayfield',
+                userCls: 'pmx-hint',
+                value: gettext(
+                    'Rules are stateless. A one-way rule needs a second rule for the replies.',
+                ),
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/RuleView.js b/www/manager6/sdn/microseg/RuleView.js
new file mode 100644
index 000000000000..57ca0eddc1c9
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleView.js
@@ -0,0 +1,242 @@
+Ext.define(
+    'PVE.sdn.microseg.RuleView',
+    {
+        extend: 'PVE.sdn.microseg.BaseGrid',
+        alias: 'widget.pveSDNMicrosegRules',
+
+        title: gettext('Rules'),
+
+        baseurl: '/cluster/sdn/microseg/rule/',
+
+        features: [
+            {
+                ftype: 'grouping',
+                groupHeaderTpl: Ext.String.format(gettext('Priority {0}'), '{name:htmlEncode}'),
+                enableGroupingMenu: false,
+            },
+        ],
+
+        deriveRow: function (data) {
+            let me = this;
+            let sel = me.treeSelection;
+            let src = data.src || [];
+            let dst = data.dst || [];
+            let out = false;
+            let inbound = false;
+            if (sel?.mode === 'membership') {
+                let every = (match) => match === 'every' && !sel.untagged;
+                out = every(data['src-match']) || src.some((g) => sel.groups.has(g));
+                inbound = every(data['dst-match']) || dst.some((g) => sel.groups.has(g));
+                if (data.bidirectional && (out || inbound)) {
+                    out = true;
+                    inbound = true;
+                }
+            } else if (sel?.mode === 'group-set') {
+                out = sel.outbound.has(data.id);
+                inbound = sel.inbound.has(data.id);
+            }
+            return {
+                direction: out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '',
+            };
+        },
+
+        loadRows: function () {
+            let me = this;
+            let rows = (me.rules || []).map((data) => ({ ...data, ...me.deriveRow(data) }));
+            if (me.treeSelection) {
+                rows = rows.filter((row) => row.direction !== '');
+            }
+            me.getStore().loadData(rows);
+        },
+
+        setData: function (rules) {
+            let me = this;
+            me.rules = rules;
+            me.loadRows();
+        },
+
+        setTreeSelection: function (sel) {
+            let me = this;
+
+            let mode = sel?.mode;
+            me.treeSelection = mode
+                ? {
+                      mode: mode,
+                      untagged: sel.untagged,
+                      groups: new Set(sel.groups),
+                      outbound: sel.outbound,
+                      inbound: sel.inbound,
+                  }
+                : null;
+
+            let text = '';
+            if (sel?.scope) {
+                text = Ext.String.format(
+                    gettext('Rules for {0}'),
+                    Ext.String.htmlEncode(sel.scope),
+                );
+            }
+            me.down('#microsegScopeText').setText(text);
+
+            me.loadRows();
+        },
+
+        initComponent: function () {
+            let me = this;
+
+            let store = Ext.create('Ext.data.Store', {
+                model: 'pve-sdn-microseg-rule',
+                grouper: {
+                    property: 'prio',
+                    sortProperty: 'prio',
+                    direction: 'DESC',
+                },
+                sorters: [{ property: 'id', direction: 'ASC' }],
+            });
+
+            let matchWords = {
+                any: gettext('any of'),
+                all: gettext('all of'),
+                exact: gettext('exactly'),
+                every: gettext('every'),
+            };
+
+            let renderPredicate = (match, groups) => {
+                if (match === 'every') {
+                    return matchWords.every;
+                }
+                let list = groups || [];
+                let chips = list.map(PVE.Utils.render_sdnmicroseg_group).join('');
+                let kind = match || 'any';
+                if (kind !== 'any' || list.length > 1) {
+                    return `${matchWords[kind]} ${chips}`;
+                }
+                return chips;
+            };
+
+            let allowIcon =
+                '<i class="fa fa-fw fa-check good" data-qtip="' + gettext('Allow') + '"></i>';
+            let denyIcon =
+                '<i class="fa fa-fw fa-times critical" data-qtip="' + gettext('Deny') + '"></i>';
+
+            let outIcon =
+                '<i class="fa fa-fw fa-sign-out" data-qtip="' +
+                gettext('Outbound, the selection is the source') +
+                '"></i>';
+            let inIcon =
+                '<i class="fa fa-fw fa-sign-in" data-qtip="' +
+                gettext('Inbound, the selection is the destination') +
+                '"></i>';
+            let bothIcon =
+                '<i class="fa fa-fw fa-exchange" data-qtip="' +
+                gettext('Both, the selection is the source and the destination') +
+                '"></i>';
+            let arrowIcon = '<i class="fa fa-fw fa-long-arrow-right"></i>';
+            let bothWaysIcon =
+                '<i class="fa fa-fw fa-arrows-h" data-qtip="' +
+                gettext('Both directions') +
+                '"></i>';
+            let renderDirection = (value, rec) => {
+                if (value === 'both') {
+                    return bothIcon;
+                }
+                if (value === 'out') {
+                    return outIcon;
+                }
+                if (value === 'in') {
+                    return inIcon;
+                }
+                return rec.data.bidirectional ? bothWaysIcon : arrowIcon;
+            };
+
+            let ruleName = (rec) => {
+                let arrow = rec.data.bidirectional ? '<->' : '->';
+                let src = PVE.Utils.format_sdnmicroseg_predicate(
+                    rec.data['src-match'],
+                    rec.data.src,
+                );
+                let dst = PVE.Utils.format_sdnmicroseg_predicate(
+                    rec.data['dst-match'],
+                    rec.data.dst,
+                );
+                return `${src} ${arrow} ${dst}`;
+            };
+
+            me.columns = [
+                {
+                    text: '',
+                    dataIndex: 'allow',
+                    width: 40,
+                    resizable: false,
+                    renderer: (v) => (v ? allowIcon : denyIcon),
+                },
+                {
+                    text: gettext('Source'),
+                    dataIndex: 'src',
+                    flex: 2,
+                    renderer: (v, m, rec) => renderPredicate(rec.data['src-match'], v),
+                },
+                {
+                    text: '',
+                    dataIndex: 'direction',
+                    width: 40,
+                    resizable: false,
+                    renderer: (v, m, rec) => renderDirection(v, rec),
+                },
+                {
+                    text: gettext('Destination'),
+                    dataIndex: 'dst',
+                    flex: 2,
+                    renderer: (v, m, rec) => renderPredicate(rec.data['dst-match'], v),
+                },
+                { xtype: 'pveSDNMicrosegStateColumn' },
+                {
+                    text: gettext('Comment'),
+                    dataIndex: 'comment',
+                    flex: 1,
+                    renderer: Ext.String.htmlEncode,
+                },
+            ];
+
+            Ext.apply(me, {
+                store: store,
+                viewConfig: {
+                    getRowClass: PVE.Utils.sdnmicroseg_row_cls,
+                },
+                getRecordName: ruleName,
+                addButton: {
+                    text: gettext('Add Rule'),
+                    handler: () => me.openEdit('rule'),
+                },
+                tbar: [
+                    '->',
+                    {
+                        xtype: 'tbtext',
+                        itemId: 'microsegScopeText',
+                    },
+                ],
+            });
+
+            me.callParent();
+        },
+    },
+    function () {
+        Ext.define('pve-sdn-microseg-rule', {
+            extend: 'Ext.data.Model',
+            fields: [
+                'id',
+                'type',
+                'comment',
+                'state',
+                'src',
+                'src-match',
+                'dst',
+                'dst-match',
+                'prio',
+                'allow',
+                'bidirectional',
+                'direction',
+            ],
+        });
+    },
+);
diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js
new file mode 100644
index 000000000000..e261e4944e81
--- /dev/null
+++ b/www/manager6/sdn/microseg/Tree.js
@@ -0,0 +1,249 @@
+Ext.define(
+    'PVE.sdn.microseg.Tree',
+    {
+        extend: 'Ext.tree.Panel',
+        alias: 'widget.pveSDNMicrosegTree',
+        mixins: ['PVE.sdn.microseg.ViewMixin'],
+
+        baseurl: '/cluster/sdn/microseg/group/',
+
+        rootVisible: false,
+        animate: false,
+        border: false,
+
+        buildNodes: function (groups, assignments, groupSets) {
+            let guestDisplay = (vmid) => {
+                let store = PVE.data.ResourceStore;
+                let rec = store.getById(`qemu/${vmid}`) || store.getById(`lxc/${vmid}`);
+                if (!rec) {
+                    return { text: `vm${vmid}`, iconCls: 'fa fa-fw fa-desktop' };
+                }
+                return {
+                    text: PVE.Utils.getFormattedGuestIdentifier(vmid, rec.data.name),
+                    iconCls: `fa-fw ${PVE.Utils.get_object_icon_class(rec.data.type, rec.data)}`,
+                };
+            };
+
+            groups.sort((a, b) => a.id.localeCompare(b.id));
+
+            let membership = PVE.sdn.microseg.Membership;
+            let rulesFor = membership.rulesFor(groupSets);
+            let nicGroupSet = membership.nicGroupSets(assignments, rulesFor);
+
+            let groupNics = {};
+            for (const [key, groupSet] of Object.entries(nicGroupSet)) {
+                let [vmid, nic] = key.split('/').map(Number);
+                groupSet.groups.forEach((g) => {
+                    (groupNics[g] = groupNics[g] || []).push({ vmid: vmid, nic: nic });
+                });
+            }
+
+            let guests = {};
+            let guestInfo = (vmid) => {
+                if (!guests[vmid]) {
+                    guests[vmid] = {
+                        ...guestDisplay(vmid),
+                        selection: membership.guestSelection(vmid, nicGroupSet, rulesFor),
+                    };
+                }
+                return guests[vmid];
+            };
+
+            let nodes = [
+                {
+                    id: 'all',
+                    text: gettext('All Rules'),
+                    iconCls: 'fa fa-fw fa-list',
+                    leaf: true,
+                },
+                {
+                    id: 'untagged',
+                    text: 'untagged',
+                    iconCls: 'fa fa-fw fa-ban',
+                    microsegSelection: {
+                        scope: 'untagged',
+                        mode: 'membership',
+                        untagged: true,
+                        groups: ['untagged'],
+                    },
+                    leaf: true,
+                },
+            ];
+
+            for (const group of groups) {
+                let groupId = `g/${group.id}`;
+                let byVmid = {};
+                for (const { vmid, nic } of groupNics[group.id] || []) {
+                    (byVmid[vmid] = byVmid[vmid] || []).push(nic);
+                }
+                let guestNodes = Object.keys(byVmid)
+                    .map(Number)
+                    .sort((a, b) => a - b)
+                    .map((vmid) => {
+                        let guestId = `${groupId}/m/${vmid}`;
+                        let nics = byVmid[vmid].sort((a, b) => a - b);
+                        let guest = guestInfo(vmid);
+                        return {
+                            id: guestId,
+                            text: guest.text,
+                            iconCls: guest.iconCls,
+                            microsegSelection: guest.selection,
+                            children: nics.map((nic) => {
+                                let groupSet = nicGroupSet[`${vmid}/${nic}`];
+                                return {
+                                    id: `${guestId}/n/${nic}`,
+                                    text: `net${nic}`,
+                                    microsegNote: groupSet.note,
+                                    iconCls: 'fa fa-fw fa-plug',
+                                    microsegSelection: {
+                                        scope: `vm${vmid}/net${nic} {${groupSet.groups.join(', ')}}`,
+                                        mode: 'group-set',
+                                        outbound: groupSet.rules.outbound,
+                                        inbound: groupSet.rules.inbound,
+                                        vmid: vmid,
+                                        nic: nic,
+                                    },
+                                    leaf: true,
+                                };
+                            }),
+                        };
+                    });
+
+                let count = (groupNics[group.id] || []).length;
+                nodes.push({
+                    id: groupId,
+                    text: group.id,
+                    microsegNote: count ? `(${count})` : undefined,
+                    iconCls: 'fa fa-fw fa-object-group',
+                    microsegId: group.id,
+                    microsegSelection: {
+                        scope: group.id,
+                        mode: 'membership',
+                        groups: [group.id],
+                    },
+                    state: group.state,
+                    leaf: !guestNodes.length,
+                    children: guestNodes,
+                });
+            }
+            return nodes;
+        },
+
+        updateIdSpace: function (ids) {
+            let me = this;
+            let text = Ext.String.format(gettext('{0} of {1} IDs in use'), ids.used, ids.total);
+            let tip = ids.retired ? Ext.String.format(gettext('{0} retired IDs'), ids.retired) : '';
+            me.down('#microsegIdSpace').update(
+                `<span data-qtip="${Ext.String.htmlEncode(tip)}">${Ext.String.htmlEncode(text)}</span>`,
+            );
+        },
+
+        setData: function (groups, assignments, groupSets, idSpace) {
+            let me = this;
+            let expanded = [];
+            let oldRoot = me.getRootNode();
+            if (oldRoot) {
+                oldRoot.cascadeBy((node) => {
+                    if (!node.isRoot() && node.isExpanded()) {
+                        expanded.push(node.getId());
+                    }
+                });
+            }
+            let selectedId = me.getSelection()[0]?.getId();
+
+            me.updateIdSpace(idSpace);
+            me.setRootNode({
+                expanded: true,
+                children: me.buildNodes(groups, assignments, groupSets),
+            });
+            let store = me.getStore();
+            for (const id of expanded) {
+                store.getNodeById(id)?.expand();
+            }
+            if (selectedId) {
+                let node = store.getNodeById(selectedId);
+                if (node) {
+                    me.setSelection(node);
+                }
+            }
+        },
+
+        statics: {
+            editable: (node) => !!node.data.microsegId && node.data.state !== 'deleted',
+        },
+
+        header: {
+            items: [
+                {
+                    xtype: 'component',
+                    itemId: 'microsegIdSpace',
+                    cls: 'pve-microseg-muted pve-microseg-id-space',
+                },
+            ],
+        },
+
+        viewConfig: {
+            getRowClass: PVE.Utils.sdnmicroseg_row_cls,
+            toggleOnDblClick: false,
+        },
+
+        store: {
+            type: 'tree',
+            model: 'pve-sdn-microseg-tree',
+            root: { expanded: true, children: [] },
+        },
+
+        columns: [
+            {
+                xtype: 'treecolumn',
+                text: gettext('Name'),
+                dataIndex: 'text',
+                flex: 1,
+                renderer: (v, m, rec) => {
+                    let html = Ext.String.htmlEncode(v);
+                    if (rec.data.microsegNote) {
+                        html += ` <span class="pve-microseg-muted">${Ext.String.htmlEncode(rec.data.microsegNote)}</span>`;
+                    }
+                    return html;
+                },
+            },
+            { xtype: 'pveSDNMicrosegStateColumn' },
+        ],
+
+        tbar: [
+            {
+                text: gettext('Add Group'),
+                handler: 'addGroup',
+            },
+            '-',
+            {
+                xtype: 'proxmoxButton',
+                text: gettext('Edit'),
+                parentXType: 'treepanel',
+                disabled: true,
+                enableFn: (node) => PVE.sdn.microseg.Tree.editable(node),
+                handler: 'editGroup',
+            },
+            {
+                xtype: 'proxmoxStdRemoveButton',
+                parentXType: 'treepanel',
+                enableFn: (node) => PVE.sdn.microseg.Tree.editable(node),
+                getUrl: function (node) {
+                    return this.up('treepanel').baseurl + node.data.microsegId;
+                },
+                getRecordName: (node) => node.data.microsegId,
+                callback: 'reload',
+            },
+            {
+                text: gettext('Reload'),
+                handler: 'reload',
+            },
+        ],
+    },
+    function () {
+        Ext.define('pve-sdn-microseg-tree', {
+            extend: 'Ext.data.TreeModel',
+            fields: ['microsegId', 'microsegNote', 'microsegSelection', 'state'],
+        });
+    },
+);
-- 
2.47.3





  parent reply	other threads:[~2026-10-27  9:03 UTC|newest]

Thread overview: 46+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-27  8:54 [PATCH access-control/cluster/container/docs/manager/network/proxmox{-ebpf,-ve-rs,-perl-rs}/qemu-server v3 00/45] sdn: add microsegmentation support Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 01/45] build: keep the build directory out of the BPF objects Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 02/45] policy: add the enforcement subsystem for guest interfaces Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 03/45] policy: add a check for non-first fragments Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 04/45] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 05/45] ve-config: sdn: microseg: add identity allocation for group sets Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 06/45] ve-config: sdn: microseg: add config types Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 07/45] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 08/45] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 09/45] ve-config: sdn: microseg: narrow rules by protocol and ports Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 10/45] ve-config: sdn: microseg: add group hierarchy Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 11/45] ve-config: firewall: expose protocol, icmp type and code numbers Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 12/45] ve-config: sdn: microseg: add the desired state Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 13/45] microseg: add a crate that applies the policy to a node's guest NICs Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 14/45] microseg: add a rule option that matches non-first fragments Hannes Laimer
2026-10-27  8:54 ` [PATCH pve-cluster v3 15/45] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-10-27  8:54 ` [PATCH pve-access-control v3 16/45] permissions: add ACL path for SDN microsegmentation Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-perl-rs v3 17/45] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 18/45] pve-rs: sdn: microseg: bind the data plane Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 19/45] pve-rs: sdn: microseg: check the data plane limits on render Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 20/45] pve-rs: debian: fire the pve-microseg-update trigger on upgrade Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 21/45] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 22/45] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 23/45] sdn: dry-run: show pending microseg changes Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 24/45] sdn: microseg: enforce the policy on guest NICs Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 25/45] sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 26/45] sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 27/45] sdn: microseg: add an update for a running NIC Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 28/45] sdn: microseg: expose protocol, port and macro rule options Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 29/45] sdn: microseg: allow nesting groups under a parent Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 30/45] sdn: microseg: expose the fragments rule option Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 31/45] network: apply the microseg policy on reload Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 32/45] pve-sdn-commit: apply the microseg policy at boot Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 33/45] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-10-27  8:55 ` Hannes Laimer [this message]
2026-10-27  8:55 ` [PATCH pve-manager v3 35/45] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 36/45] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 37/45] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 38/45] ui: sdn: microseg: add narrowing to the rule form Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 39/45] ui: sdn: microseg: show and edit the group hierarchy Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 40/45] ui: sdn: microseg: add fragments option to the rule form Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 41/45] ui: sdn: microseg: add a guest tab with its groups and rules Hannes Laimer
2026-10-27  8:55 ` [PATCH qemu-server v3 42/45] network: update the microseg enforcement on a tag or name change Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-container v3 43/45] lxc: update the microseg enforcement on a tag or hostname change Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-docs v3 44/45] sdn: zones: document the VXLAN-GBP option Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-docs v3 45/45] sdn: add microsegmentation section Hannes Laimer

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261027085527.702738-35-h.laimer@proxmox.com \
    --to=h.laimer@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal