all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-manager 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal