public inbox for pve-devel@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal