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 v2 20/27] ui: sdn: add microsegmentation panel
Date: Thu,  9 Jul 2026 11:18:45 +0200	[thread overview]
Message-ID: <20260709091852.538885-21-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com>

Add a Microsegmentation panel under Datacenter > SDN. A policy grid of
the rules sits next to a membership tree (group -> assignment -> guest
-> NIC). Selecting a node in the tree filters the grid to the rules that
govern that NIC as source or destination. The backend computes the
matching, so the UI doesn't re-implement predicate semantics.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/manager6/Makefile                       |   9 +
 www/manager6/Utils.js                       |  24 ++
 www/manager6/dc/Config.js                   |   8 +
 www/manager6/form/MicrosegGroupSelector.js  |  77 +++++
 www/manager6/form/MicrosegGuestSelector.js  |  83 +++++
 www/manager6/sdn/MicrosegView.js            | 143 +++++++++
 www/manager6/sdn/microseg/AssignmentEdit.js |  59 ++++
 www/manager6/sdn/microseg/Base.js           | 134 ++++++++
 www/manager6/sdn/microseg/GroupEdit.js      |  45 +++
 www/manager6/sdn/microseg/PolicyView.js     | 181 +++++++++++
 www/manager6/sdn/microseg/RuleEdit.js       | 104 ++++++
 www/manager6/sdn/microseg/Tree.js           | 339 ++++++++++++++++++++
 12 files changed, 1206 insertions(+)
 create mode 100644 www/manager6/form/MicrosegGroupSelector.js
 create mode 100644 www/manager6/form/MicrosegGuestSelector.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/Base.js
 create mode 100644 www/manager6/sdn/microseg/GroupEdit.js
 create mode 100644 www/manager6/sdn/microseg/PolicyView.js
 create mode 100644 www/manager6/sdn/microseg/RuleEdit.js
 create mode 100644 www/manager6/sdn/microseg/Tree.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d4dd3f35..ba0fbc19 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -71,6 +71,8 @@ JSSRC= 							\
 	form/SDNControllerSelector.js			\
 	form/SDNZoneSelector.js				\
 	form/SDNVnetSelector.js				\
+	form/MicrosegGroupSelector.js			\
+	form/MicrosegGuestSelector.js			\
 	form/SDNIpamSelector.js				\
 	form/SDNDnsSelector.js				\
 	form/ScsiHwSelector.js				\
@@ -338,6 +340,13 @@ JSSRC= 							\
 	sdn/zones/VxlanEdit.js				\
 	sdn/FabricsView.js				\
 	sdn/FabricsContentView.js			\
+	sdn/microseg/Base.js				\
+	sdn/MicrosegView.js				\
+	sdn/microseg/Tree.js				\
+	sdn/microseg/GroupEdit.js			\
+	sdn/microseg/RuleEdit.js			\
+	sdn/microseg/AssignmentEdit.js			\
+	sdn/microseg/PolicyView.js			\
 	sdn/fabrics/Common.js				\
 	sdn/fabrics/InterfacePanel.js			\
 	sdn/fabrics/NodeEdit.js				\
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 040b5ae0..6d7ac5e3 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -966,6 +966,30 @@ Ext.define('PVE.Utils', {
             },
         },
 
+        sdnmicrosegSchema: {
+            group: {
+                name: gettext('Group'),
+                ipanel: 'GroupInputPanel',
+            },
+            rule: {
+                name: gettext('Rule'),
+                ipanel: 'RuleInputPanel',
+            },
+            guestassignment: {
+                name: gettext('Guest Assignment'),
+                ipanel: 'GuestAssignmentInputPanel',
+                urlType: 'assignment',
+            },
+        },
+
+        format_sdnmicroseg_type: function (value, md, record) {
+            var schema = PVE.Utils.sdnmicrosegSchema[value];
+            if (schema) {
+                return schema.name;
+            }
+            return Proxmox.Utils.unknownText;
+        },
+
         sdnipamSchema: {
             ipam: {
                 name: 'ipam',
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index e1706636..e473f937 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('Microseg'),
+                    hidden: true,
+                    iconCls: 'fa fa-tags',
+                    itemId: 'sdnmicroseg',
+                },
                 {
                     xtype: 'pveSDNRouteMaps',
                     groups: ['sdn'],
diff --git a/www/manager6/form/MicrosegGroupSelector.js b/www/manager6/form/MicrosegGroupSelector.js
new file mode 100644
index 00000000..ca150457
--- /dev/null
+++ b/www/manager6/form/MicrosegGroupSelector.js
@@ -0,0 +1,77 @@
+Ext.define(
+    'PVE.form.MicrosegGroupSelector',
+    {
+        extend: 'Proxmox.form.ComboGrid',
+        alias: ['widget.pveMicrosegGroupSelector'],
+
+        valueField: 'id',
+        displayField: 'id',
+
+        initComponent: function () {
+            let me = this;
+
+            let store = new Ext.data.Store({
+                model: 'pve-sdn-microseg-group',
+                sorters: {
+                    property: 'id',
+                    direction: 'ASC',
+                },
+            });
+
+            if (me.includeUntagged) {
+                store.on('load', function (loaded) {
+                    if (!loaded.getById('untagged')) {
+                        loaded.add({
+                            id: 'untagged',
+                            type: 'group',
+                            mark: 0,
+                            comment: gettext('no group / untagged'),
+                        });
+                    }
+                });
+            }
+
+            Ext.apply(me, {
+                store: store,
+                autoSelect: false,
+                listConfig: {
+                    columns: [
+                        {
+                            header: gettext('Group'),
+                            sortable: true,
+                            dataIndex: 'id',
+                            flex: 1,
+                        },
+                        {
+                            header: gettext('Mark'),
+                            sortable: true,
+                            dataIndex: 'mark',
+                            width: 70,
+                        },
+                        {
+                            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', 'type', 'mark', 'comment'],
+            proxy: {
+                type: 'proxmox',
+                url: '/api2/json/cluster/sdn/microseg/group',
+            },
+            idProperty: 'id',
+        });
+    },
+);
diff --git a/www/manager6/form/MicrosegGuestSelector.js b/www/manager6/form/MicrosegGuestSelector.js
new file mode 100644
index 00000000..19c4bcc9
--- /dev/null
+++ b/www/manager6/form/MicrosegGuestSelector.js
@@ -0,0 +1,83 @@
+Ext.define(
+    'PVE.form.MicrosegGuestSelector',
+    {
+        extend: 'Proxmox.form.ComboGrid',
+        alias: ['widget.pveMicrosegGuestSelector'],
+
+        valueField: 'vmid',
+        displayField: 'display',
+
+        initComponent: function () {
+            let me = this;
+
+            let store = new Ext.data.Store({
+                model: 'pve-microseg-guest',
+                sorters: {
+                    property: 'vmid',
+                    direction: 'ASC',
+                },
+            });
+
+            Ext.apply(me, {
+                store: store,
+                autoSelect: false,
+                listConfig: {
+                    columns: [
+                        {
+                            header: 'VMID',
+                            sortable: true,
+                            dataIndex: 'vmid',
+                            width: 80,
+                        },
+                        {
+                            header: gettext('Name'),
+                            sortable: true,
+                            dataIndex: 'name',
+                            flex: 1,
+                            renderer: Ext.String.htmlEncode,
+                        },
+                        {
+                            header: gettext('Type'),
+                            dataIndex: 'type',
+                            width: 70,
+                        },
+                        {
+                            header: gettext('Node'),
+                            dataIndex: 'node',
+                            width: 100,
+                        },
+                    ],
+                },
+            });
+
+            me.callParent();
+
+            store.load();
+        },
+    },
+    function () {
+        Ext.define('pve-microseg-guest', {
+            extend: 'Ext.data.Model',
+            fields: [
+                'vmid',
+                'name',
+                'node',
+                'type',
+                'status',
+                {
+                    name: 'display',
+                    convert: function (value, rec) {
+                        let name = rec.get('name');
+                        let vmid = rec.get('vmid');
+                        return name ? `${vmid} (${name})` : `${vmid}`;
+                    },
+                },
+            ],
+            proxy: {
+                type: 'proxmox',
+                url: '/api2/json/cluster/resources?type=vm',
+            },
+            idProperty: 'vmid',
+        });
+    },
+);
diff --git a/www/manager6/sdn/MicrosegView.js b/www/manager6/sdn/MicrosegView.js
new file mode 100644
index 00000000..e0ac5b76
--- /dev/null
+++ b/www/manager6/sdn/MicrosegView.js
@@ -0,0 +1,143 @@
+Ext.define('PVE.sdn.MicrosegGrid', {
+    extend: 'Ext.grid.GridPanel',
+    xtype: 'pveSDNMicrosegGrid',
+
+    border: false,
+
+    initComponent: function () {
+        let me = this;
+
+        if (!me.microsegType) {
+            throw 'no microsegType given';
+        }
+
+        let store = Ext.create('Ext.data.Store', {
+            model: 'pve-sdn-microseg',
+            proxy: {
+                type: 'proxmox',
+                url: `/api2/json/cluster/sdn/microseg/${me.microsegType}`,
+                extraParams: { pending: 1 },
+            },
+            sorters: me.storeSorters ?? 'id',
+        });
+
+        let reload = () => store.load();
+
+        let sm = Ext.create('Ext.selection.RowModel', {});
+
+        let openEdit = (type, microsegId) =>
+            Ext.create('PVE.sdn.microseg.BaseEdit', {
+                type: type,
+                microsegId: microsegId,
+                autoShow: true,
+                listeners: { destroy: reload },
+            });
+
+        let run_editor = () => {
+            let rec = sm.getSelection()[0];
+            if (!rec) {
+                return;
+            }
+            openEdit(rec.data.type, rec.data.id);
+        };
+
+        let edit_btn = new Proxmox.button.Button({
+            text: gettext('Edit'),
+            disabled: true,
+            selModel: sm,
+            handler: run_editor,
+        });
+
+        let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+            selModel: sm,
+            baseurl: `/cluster/sdn/microseg/${me.microsegType}/`,
+            callback: reload,
+        });
+
+        let set_button_status = () => {
+            let rec = sm.getSelection()[0];
+            if (!rec || rec.data.state === 'deleted') {
+                edit_btn.disable();
+                remove_btn.disable();
+            }
+        };
+
+        let addButton = me.addMenu
+            ? {
+                  text: me.addText || gettext('Add'),
+                  menu: {
+                      items: me.addMenu.map((type) => ({
+                          text: PVE.Utils.format_sdnmicroseg_type(type),
+                          handler: () => openEdit(type),
+                      })),
+                  },
+              }
+            : {
+                  text: me.addText || gettext('Add'),
+                  handler: () => openEdit(me.microsegType),
+              };
+
+        Ext.apply(me, {
+            store: store,
+            selModel: sm,
+            tbar: [
+                addButton,
+                edit_btn,
+                remove_btn,
+                {
+                    xtype: 'proxmoxButton',
+                    text: gettext('Reload'),
+                    selModel: false,
+                    handler: reload,
+                },
+            ],
+            listeners: {
+                itemdblclick: run_editor,
+                selectionchange: set_button_status,
+                activate: reload,
+            },
+        });
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.MicrosegView', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNMicroseg',
+
+    onlineHelp: 'pvesdn_config_microseg',
+
+    layout: 'border',
+
+    initComponent: function () {
+        let me = this;
+
+        let policyGrid = Ext.create('PVE.sdn.microseg.PolicyView', {
+            region: 'center',
+        });
+
+        let tree = Ext.create('PVE.sdn.MicrosegTree', {
+            region: 'west',
+            title: gettext('Groups'),
+            width: 400,
+            minWidth: 250,
+            split: true,
+            listeners: {
+                microsegselect: (sel) => policyGrid.setSelection(sel),
+            },
+        });
+
+        Ext.apply(me, {
+            items: [tree, policyGrid],
+            listeners: {
+                activate: () => {
+                    tree.reloadTree();
+                    policyGrid.getStore().load();
+                },
+            },
+        });
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
new file mode 100644
index 00000000..2a9c5cce
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -0,0 +1,59 @@
+Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', {
+    extend: 'PVE.panel.SDNMicrosegBase',
+
+    onlineHelp: 'pvesdn_microseg_assignment',
+
+    autoId: true,
+
+    onGetValues: function (values) {
+        let me = this;
+        if (me.isCreate) {
+            values.type = me.type;
+        }
+        if (values.iface === '' || values.iface === null || values.iface === undefined) {
+            delete values.iface;
+        }
+        return me.callParent([values]);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: 'pveMicrosegGuestSelector',
+                name: 'vmid',
+                fieldLabel: gettext('Guest'),
+                allowBlank: false,
+                disabled: !me.isCreate,
+            },
+            {
+                xtype: 'proxmoxintegerfield',
+                name: 'iface',
+                fieldLabel: gettext('Network interface'),
+                minValue: 0,
+                maxValue: 31,
+                allowBlank: true,
+                emptyText: gettext('All NICs'),
+                disabled: !me.isCreate,
+            },
+            {
+                xtype: 'pveMicrosegGroupSelector',
+                name: 'groups',
+                fieldLabel: gettext('Groups'),
+                multiSelect: true,
+                allowBlank: false,
+            },
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'comment',
+                fieldLabel: gettext('Comment'),
+                allowBlank: true,
+                maxLength: 256,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
new file mode 100644
index 00000000..6e226ce2
--- /dev/null
+++ b/www/manager6/sdn/microseg/Base.js
@@ -0,0 +1,134 @@
+Ext.define('pve-sdn-microseg', {
+    extend: 'Ext.data.Model',
+    fields: [
+        'id',
+        'type',
+        'comment',
+        'digest',
+        'state',
+        'pending',
+        'mark',
+        'src',
+        'src_match',
+        'dst',
+        'dst_match',
+        'prio',
+        'allow',
+        'vmid',
+        'iface',
+        'groups',
+        'direction',
+    ],
+    idProperty: 'id',
+});
+
+Ext.define('pve-sdn-microseg-tree', {
+    extend: 'Ext.data.TreeModel',
+    fields: [
+        'microsegEditType',
+        'microsegId',
+        'microsegGroups',
+        'microsegScope',
+        'microsegMode',
+        'microsegOutbound',
+        'microsegInbound',
+        'state',
+        'pending',
+    ],
+});
+
+Ext.define('PVE.panel.SDNMicrosegBase', {
+    extend: 'Proxmox.panel.InputPanel',
+
+    type: '',
+
+    autoId: false,
+    idLabel: 'ID',
+
+    onGetValues: function (values) {
+        let me = this;
+
+        if (!me.isCreate) {
+            delete values.id;
+        }
+
+        return values;
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = me.items ?? [];
+        if (!me.autoId) {
+            me.items.unshift({
+                xtype: me.isCreate ? 'textfield' : 'displayfield',
+                name: 'id',
+                value: me.microsegId || '',
+                fieldLabel: me.idLabel,
+                allowBlank: false,
+                regex: /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,30}[a-zA-Z0-9]$/,
+                regexText: gettext(
+                    'must be 2-32 chars, start/end alphanumeric, may contain dashes and underscores',
+                ),
+            });
+        }
+
+        if (!me.isCreate) {
+            me.items.push({ xtype: 'hiddenfield', name: 'digest' });
+        }
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.microseg.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 400,
+
+    initComponent: function () {
+        let me = this;
+
+        me.isCreate = !me.microsegId;
+
+        let schema = PVE.Utils.sdnmicrosegSchema[me.type];
+        let urlType = schema.urlType || me.type;
+
+        if (me.isCreate) {
+            me.url = '/api2/extjs/cluster/sdn/microseg/' + urlType;
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs/cluster/sdn/microseg/' + urlType + '/' + 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,
+            items: [ipanel],
+        });
+
+        me.callParent();
+
+        if (!me.isCreate) {
+            me.load({
+                success: function (response) {
+                    let values = response.result.data;
+                    ipanel.setValues(values);
+                },
+            });
+        }
+    },
+});
diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js
new file mode 100644
index 00000000..e4f5ed9a
--- /dev/null
+++ b/www/manager6/sdn/microseg/GroupEdit.js
@@ -0,0 +1,45 @@
+Ext.define('PVE.sdn.microseg.GroupInputPanel', {
+    extend: 'PVE.panel.SDNMicrosegBase',
+
+    onlineHelp: 'pvesdn_microseg_group',
+
+    idLabel: gettext('Name'),
+
+    onGetValues: function (values) {
+        let me = this;
+        if (me.isCreate && !values.mark) {
+            delete values.mark;
+        }
+        return me.callParent([values]);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'comment',
+                fieldLabel: gettext('Comment'),
+                allowBlank: true,
+                maxLength: 256,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.advancedItems = [
+            {
+                xtype: 'proxmoxintegerfield',
+                name: 'mark',
+                fieldLabel: gettext('Mark'),
+                minValue: 1,
+                maxValue: 65535,
+                allowBlank: true,
+                disabled: !me.isCreate,
+                emptyText: gettext('auto'),
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/PolicyView.js b/www/manager6/sdn/microseg/PolicyView.js
new file mode 100644
index 00000000..e9775ba1
--- /dev/null
+++ b/www/manager6/sdn/microseg/PolicyView.js
@@ -0,0 +1,181 @@
+Ext.define('PVE.sdn.microseg.PolicyView', {
+    extend: 'PVE.sdn.MicrosegGrid',
+    xtype: 'pveSDNMicrosegPolicyView',
+
+    title: gettext('Policies'),
+    microsegType: 'rule',
+    addText: gettext('Add Rule'),
+
+    storeSorters: [
+        { property: 'prio', direction: 'DESC' },
+        { property: 'id', direction: 'ASC' },
+    ],
+
+    setSelection: function (sel) {
+        let me = this;
+
+        let scope = sel && sel.scope;
+        let mode = sel && sel.mode;
+        let title = gettext('Policies');
+        if (scope) {
+            let fmt =
+                mode === 'identity'
+                    ? gettext('Policies governing {0}')
+                    : gettext('Policies referencing {0}');
+            title = Ext.String.format(fmt, Ext.String.htmlEncode(scope));
+        }
+        me.setTitle(title);
+
+        let col = me.down('#microsegDirection');
+        if (col) {
+            col.setVisible(mode === 'membership' || mode === 'identity');
+        }
+
+        if (mode === 'membership') {
+            let set = {};
+            (sel.groups || []).forEach((g) => {
+                set[g] = true;
+            });
+            me.filterByDirection((groups) => (groups || []).some((g) => set[g]));
+        } else if (mode === 'identity') {
+            let outSet = {};
+            let inSet = {};
+            (sel.outbound || []).forEach((r) => {
+                outSet[r] = true;
+            });
+            (sel.inbound || []).forEach((r) => {
+                inSet[r] = true;
+            });
+            me.filterByRuleId(outSet, inSet);
+        } else {
+            me.getStore().clearFilter();
+        }
+    },
+
+    filterByDirection: function (matches) {
+        let store = this.getStore();
+        store.clearFilter(true);
+        store.filterBy((rec) => {
+            let data = rec.data;
+            let out = matches(data.pending?.src ?? data.src ?? []);
+            let inbound = matches(data.pending?.dst ?? data.dst ?? []);
+            data.direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '';
+            return out || inbound;
+        });
+    },
+
+    filterByRuleId: function (outSet, inSet) {
+        let store = this.getStore();
+        store.clearFilter(true);
+        store.filterBy((rec) => {
+            let out = !!outSet[rec.data.id];
+            let inbound = !!inSet[rec.data.id];
+            rec.data.direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '';
+            return out || inbound;
+        });
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let renderPredicate = (match, groups) => {
+            let kind = match || 'any';
+            let list = (groups || []).join(', ');
+            return Ext.String.htmlEncode(`${kind}(${list})`);
+        };
+
+        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 renderDirection = (value) => {
+            if (value === 'both') {
+                return outIcon + inIcon;
+            }
+            if (value === 'out') {
+                return outIcon;
+            }
+            if (value === 'in') {
+                return inIcon;
+            }
+            return '';
+        };
+
+        let strikeIfDeleted = (rec, html) =>
+            rec.data.state === 'deleted'
+                ? `<span style="text-decoration: line-through;">${html}</span>`
+                : html;
+
+        me.columns = [
+            {
+                text: '',
+                itemId: 'microsegDirection',
+                dataIndex: 'direction',
+                width: 50,
+                resizable: false,
+                hidden: true,
+                renderer: renderDirection,
+            },
+            {
+                text: gettext('Source'),
+                dataIndex: 'src',
+                flex: 2,
+                renderer: (v, m, rec) =>
+                    strikeIfDeleted(
+                        rec,
+                        renderPredicate(
+                            rec.data.pending?.src_match ?? rec.data.src_match,
+                            rec.data.pending?.src ?? v,
+                        ),
+                    ),
+            },
+            {
+                text: gettext('Destination'),
+                dataIndex: 'dst',
+                flex: 2,
+                renderer: (v, m, rec) =>
+                    strikeIfDeleted(
+                        rec,
+                        renderPredicate(
+                            rec.data.pending?.dst_match ?? rec.data.dst_match,
+                            rec.data.pending?.dst ?? v,
+                        ),
+                    ),
+            },
+            {
+                text: gettext('Action'),
+                dataIndex: 'allow',
+                width: 90,
+                renderer: (v, m, rec) =>
+                    strikeIfDeleted(
+                        rec,
+                        (rec.data.pending?.allow ?? v) ? gettext('Allow') : gettext('Deny'),
+                    ),
+            },
+            {
+                text: gettext('Priority'),
+                dataIndex: 'prio',
+                width: 90,
+                renderer: (v, m, rec) => strikeIfDeleted(rec, rec.data.pending?.prio ?? v ?? 0),
+            },
+            {
+                text: gettext('Comment'),
+                dataIndex: 'comment',
+                flex: 1,
+                renderer: (v, m, rec) => PVE.Utils.render_sdn_pending(rec, v ?? '', 'comment'),
+            },
+            {
+                text: gettext('State'),
+                dataIndex: 'state',
+                width: 100,
+                renderer: (v, m, rec) => PVE.Utils.render_sdn_pending_state(rec, v),
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js
new file mode 100644
index 00000000..8b3749e8
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleEdit.js
@@ -0,0 +1,104 @@
+Ext.define('PVE.sdn.microseg.RuleInputPanel', {
+    extend: 'PVE.panel.SDNMicrosegBase',
+
+    onlineHelp: 'pvesdn_microseg_rule',
+
+    autoId: true,
+
+    onGetValues: function (values) {
+        let me = this;
+        if (me.isCreate) {
+            values.src = me.srcSelector.getValue() || [];
+            values.dst = me.dstSelector.getValue() || [];
+        } else {
+            delete values.src;
+            delete values.dst;
+            delete values.src_match;
+            delete values.dst_match;
+        }
+        return me.callParent([values]);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let matchItems = [
+            ['any', gettext('Any (intersects)')],
+            ['all', gettext('All (subset of)')],
+            ['exact', gettext('Exact (equals)')],
+        ];
+
+        me.srcSelector = Ext.create({
+            xtype: 'pveMicrosegGroupSelector',
+            name: 'src',
+            fieldLabel: gettext('Source groups'),
+            multiSelect: true,
+            allowBlank: false,
+            includeUntagged: true,
+            disabled: !me.isCreate,
+        });
+
+        me.dstSelector = Ext.create({
+            xtype: 'pveMicrosegGroupSelector',
+            name: 'dst',
+            fieldLabel: gettext('Destination groups'),
+            multiSelect: true,
+            allowBlank: false,
+            includeUntagged: true,
+            disabled: !me.isCreate,
+        });
+
+        me.items = [
+            me.srcSelector,
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'src_match',
+                fieldLabel: gettext('Source match'),
+                value: 'any',
+                disabled: !me.isCreate,
+                comboItems: matchItems,
+            },
+            me.dstSelector,
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'dst_match',
+                fieldLabel: gettext('Destination match'),
+                value: 'any',
+                disabled: !me.isCreate,
+                comboItems: matchItems,
+            },
+            {
+                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, a deny overrides an allow.'),
+                },
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                name: 'allow',
+                fieldLabel: gettext('Action'),
+                boxLabel: gettext('Allow (unchecked = deny)'),
+                checked: true,
+                uncheckedValue: 0,
+                inputValue: 1,
+            },
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'comment',
+                fieldLabel: gettext('Comment'),
+                allowBlank: true,
+                maxLength: 256,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js
new file mode 100644
index 00000000..9bfa7608
--- /dev/null
+++ b/www/manager6/sdn/microseg/Tree.js
@@ -0,0 +1,339 @@
+Ext.define('PVE.sdn.MicrosegTree', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'pveSDNMicrosegTree',
+
+    rootVisible: false,
+    animate: false,
+    border: false,
+
+    initComponent: function () {
+        let me = this;
+
+        // pending objects carry their staged values under `pending`; fall back to the applied value
+        let pv = (o, k) => o.pending?.[k] ?? o[k];
+
+        let assignmentLabel = (a) => {
+            let ifaceVal = pv(a, 'iface');
+            let iface =
+                ifaceVal === undefined || ifaceVal === null || ifaceVal === ''
+                    ? ''
+                    : ` net${ifaceVal}`;
+            return `vm${pv(a, 'vmid')}${iface}`;
+        };
+
+        let buildNodes = (objects, identities) => {
+            let groups = (objects || [])
+                .filter((o) => o.type === 'group')
+                .sort((a, b) => a.id.localeCompare(b.id));
+            let assignments = (objects || []).filter(
+                (o) => PVE.Utils.sdnmicrosegSchema[o.type]?.urlType === 'assignment',
+            );
+
+            let nicIdentity = {};
+            assignments.forEach((a) => {
+                let aGroups = pv(a, 'groups') || [];
+                (a.realized || []).forEach((m) => {
+                    (m.nics || []).forEach((nic) => {
+                        let key = `${m.vmid}/${nic}`;
+                        if (!nicIdentity[key]) {
+                            nicIdentity[key] = {};
+                        }
+                        aGroups.forEach((g) => {
+                            nicIdentity[key][g] = true;
+                        });
+                    });
+                });
+            });
+            let identityGroups = (vmid, nic) =>
+                Object.keys(nicIdentity[`${vmid}/${nic}`] || {}).sort();
+            let guestIdentities = (vmid) =>
+                Object.keys(nicIdentity)
+                    .filter((key) => key.startsWith(`${vmid}/`))
+                    .map((key) => Object.keys(nicIdentity[key]).sort());
+
+            let classRules = {};
+            (identities || []).forEach((cls) => {
+                classRules[(cls.groups || []).slice().sort().join(',')] = cls;
+            });
+            let rulesFor = (identityList) => {
+                let out = {};
+                let inb = {};
+                identityList.forEach((groupSet) => {
+                    let cls = classRules[groupSet.join(',')] || {};
+                    (cls.outbound || []).forEach((r) => {
+                        out[r] = true;
+                    });
+                    (cls.inbound || []).forEach((r) => {
+                        inb[r] = true;
+                    });
+                });
+                return { outbound: Object.keys(out), inbound: Object.keys(inb) };
+            };
+
+            let nodes = [
+                {
+                    id: 'all',
+                    text: gettext('All Policies'),
+                    iconCls: 'fa fa-fw fa-list',
+                    microsegMode: null,
+                    microsegScope: null,
+                    leaf: true,
+                },
+                {
+                    id: 'untagged',
+                    text: 'untagged',
+                    iconCls: 'fa fa-fw fa-ban',
+                    microsegMode: 'membership',
+                    microsegGroups: ['untagged'],
+                    microsegScope: 'untagged',
+                    leaf: true,
+                },
+            ];
+
+            for (const group of groups) {
+                let groupId = `g/${group.id}`;
+                let assignNodes = assignments
+                    .filter((a) => (pv(a, 'groups') || []).includes(group.id))
+                    .map((a) => {
+                        let aGroups = pv(a, 'groups') || [];
+                        let assignId = `${groupId}/a/${a.id}`;
+                        let realized = a.realized || [];
+                        let anyPending = realized.some((m) => (m.pending || []).length);
+                        return {
+                            id: assignId,
+                            text: assignmentLabel(a),
+                            iconCls: 'fa fa-fw fa-link',
+                            microsegEditType: a.type,
+                            microsegId: a.id,
+                            microsegMode: 'membership',
+                            microsegGroups: aGroups,
+                            microsegScope: assignmentLabel(a),
+                            state: a.state || (anyPending ? 'changed' : undefined),
+                            pending: a.pending,
+                            leaf: !realized.length,
+                            children: realized.map((member) => {
+                                let pendingNics = member.pending || [];
+                                let guestRules = rulesFor(guestIdentities(member.vmid));
+                                return {
+                                    id: `${assignId}/m/${member.vmid}`,
+                                    text: `vm${member.vmid}`,
+                                    iconCls: 'fa fa-fw fa-desktop',
+                                    microsegMode: 'identity',
+                                    microsegOutbound: guestRules.outbound,
+                                    microsegInbound: guestRules.inbound,
+                                    microsegScope: `vm${member.vmid}`,
+                                    state: pendingNics.length ? 'new' : undefined,
+                                    leaf: !(member.nics || []).length,
+                                    children: (member.nics || []).map((nic) => {
+                                        let ig = identityGroups(member.vmid, nic);
+                                        let nicRules = rulesFor([ig]);
+                                        return {
+                                            id: `${assignId}/m/${member.vmid}/n/${nic}`,
+                                            text: `net${nic}`,
+                                            iconCls: 'fa fa-fw fa-plug',
+                                            microsegMode: 'identity',
+                                            microsegOutbound: nicRules.outbound,
+                                            microsegInbound: nicRules.inbound,
+                                            microsegScope: ig.length
+                                                ? `vm${member.vmid}/net${nic} {${ig.join(', ')}}`
+                                                : `vm${member.vmid}/net${nic}`,
+                                            state: pendingNics.includes(nic) ? 'new' : undefined,
+                                            leaf: true,
+                                        };
+                                    }),
+                                };
+                            }),
+                        };
+                    });
+
+                nodes.push({
+                    id: groupId,
+                    text: group.id,
+                    iconCls: 'fa fa-fw fa-object-group',
+                    microsegEditType: 'group',
+                    microsegId: group.id,
+                    microsegMode: 'membership',
+                    microsegGroups: [group.id],
+                    microsegScope: group.id,
+                    state: group.state,
+                    pending: group.pending,
+                    leaf: !assignNodes.length,
+                    children: assignNodes,
+                });
+            }
+            return nodes;
+        };
+
+        let reload = () => {
+            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();
+
+            Proxmox.Utils.API2Request({
+                url: '/cluster/sdn/microseg/all?pending=1',
+                method: 'GET',
+                failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+                success: (response) => {
+                    let data = response.result.data;
+                    me.setRootNode({
+                        expanded: true,
+                        children: buildNodes(data.objects, data.identities),
+                    });
+                    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);
+                        }
+                    }
+                },
+            });
+        };
+
+        let openEdit = (type, microsegId) =>
+            Ext.create('PVE.sdn.microseg.BaseEdit', {
+                type: type,
+                microsegId: microsegId,
+                autoShow: true,
+                listeners: { destroy: reload },
+            });
+
+        let selected = () => me.getSelection()?.[0];
+
+        let editSelected = () => {
+            let node = selected();
+            if (!node || !node.data.microsegEditType) {
+                return;
+            }
+            openEdit(node.data.microsegEditType, node.data.microsegId);
+        };
+
+        let removeSelected = () => {
+            let node = selected();
+            if (!node || !node.data.microsegEditType) {
+                return;
+            }
+            let type = node.data.microsegEditType;
+            let id = node.data.microsegId;
+            let schema = PVE.Utils.sdnmicrosegSchema[type];
+            let urlType = (schema && schema.urlType) || type;
+            Ext.Msg.confirm(
+                gettext('Confirm'),
+                Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${id}'`),
+                (btn) => {
+                    if (btn !== 'yes') {
+                        return;
+                    }
+                    Proxmox.Utils.API2Request({
+                        url: `/cluster/sdn/microseg/${urlType}/${encodeURIComponent(id)}`,
+                        method: 'DELETE',
+                        waitMsgTarget: me,
+                        failure: (response) =>
+                            Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+                        callback: reload,
+                    });
+                },
+            );
+        };
+
+        let editButton = new Proxmox.button.Button({
+            text: gettext('Edit'),
+            disabled: true,
+            handler: editSelected,
+        });
+
+        let removeButton = new Proxmox.button.Button({
+            text: gettext('Remove'),
+            disabled: true,
+            handler: removeSelected,
+        });
+
+        Ext.apply(me, {
+            store: Ext.create('Ext.data.TreeStore', {
+                model: 'pve-sdn-microseg-tree',
+                root: { expanded: true, children: [] },
+            }),
+            columns: [
+                {
+                    xtype: 'treecolumn',
+                    text: gettext('Group / Assignment'),
+                    dataIndex: 'text',
+                    flex: 1,
+                    renderer: Ext.String.htmlEncode,
+                },
+                {
+                    text: gettext('State'),
+                    dataIndex: 'state',
+                    width: 100,
+                    renderer: (value, meta, rec) => PVE.Utils.render_sdn_pending_state(rec, value),
+                },
+            ],
+            tbar: [
+                {
+                    text: gettext('Add Group'),
+                    handler: () => openEdit('group'),
+                },
+                {
+                    text: gettext('Add Assignment'),
+                    menu: {
+                        items: Object.keys(PVE.Utils.sdnmicrosegSchema)
+                            .filter(
+                                (type) =>
+                                    PVE.Utils.sdnmicrosegSchema[type].urlType === 'assignment',
+                            )
+                            .map((type) => ({
+                                text: PVE.Utils.format_sdnmicroseg_type(type),
+                                handler: () => openEdit(type),
+                            })),
+                    },
+                },
+                '-',
+                editButton,
+                removeButton,
+                {
+                    text: gettext('Reload'),
+                    handler: reload,
+                },
+            ],
+            listeners: {
+                itemdblclick: editSelected,
+                selectionchange: () => {
+                    let node = selected();
+                    let editable = !!(
+                        node &&
+                        node.data.microsegEditType &&
+                        node.data.state !== 'deleted'
+                    );
+                    editButton.setDisabled(!editable);
+                    removeButton.setDisabled(!editable);
+                    me.fireEvent(
+                        'microsegselect',
+                        node
+                            ? {
+                                  scope: node.data.microsegScope,
+                                  mode: node.data.microsegMode,
+                                  groups: node.data.microsegGroups,
+                                  outbound: node.data.microsegOutbound,
+                                  inbound: node.data.microsegInbound,
+                              }
+                            : null,
+                    );
+                },
+            },
+        });
+
+        me.reloadTree = reload;
+
+        me.callParent();
+    },
+});
-- 
2.47.3





  parent reply	other threads:[~2026-07-09  9:23 UTC|newest]

Thread overview: 28+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-09  9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-07-09  9:18 ` [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-07-09  9:18 ` [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API Hannes Laimer
2026-07-09  9:18 ` Hannes Laimer [this message]
2026-07-09  9:18 ` [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-manager v2 22/27] network: apply microseg state on reload Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-docs v2 26/27] sdn: add microsegmentation section Hannes Laimer
2026-07-09  9:18 ` [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections 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=20260709091852.538885-21-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