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 11/16] ui: sdn: add microsegmentation
Date: Tue,  9 Jun 2026 15:25:17 +0200	[thread overview]
Message-ID: <20260609132522.235917-12-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260609132522.235917-1-h.laimer@proxmox.com>

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/manager6/Makefile                         |   9 +
 www/manager6/Utils.js                         |  23 +
 www/manager6/dc/Config.js                     |   8 +
 www/manager6/form/MicrosegGroupSelector.js    |  64 +++
 www/manager6/form/MicrosegGuestNicSelector.js | 107 +++++
 www/manager6/form/MicrosegGuestSelector.js    |  83 ++++
 www/manager6/sdn/MicrosegView.js              | 408 ++++++++++++++++++
 www/manager6/sdn/microseg/AssignmentEdit.js   |  63 +++
 www/manager6/sdn/microseg/Base.js             |  88 ++++
 www/manager6/sdn/microseg/GroupEdit.js        |  61 +++
 www/manager6/sdn/microseg/PolicyView.js       | 221 ++++++++++
 www/manager6/sdn/microseg/RuleEdit.js         |  49 +++
 12 files changed, 1184 insertions(+)
 create mode 100644 www/manager6/form/MicrosegGroupSelector.js
 create mode 100644 www/manager6/form/MicrosegGuestNicSelector.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

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d4dd3f35..f300a6a4 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -71,6 +71,9 @@ JSSRC= 							\
 	form/SDNControllerSelector.js			\
 	form/SDNZoneSelector.js				\
 	form/SDNVnetSelector.js				\
+	form/MicrosegGroupSelector.js			\
+	form/MicrosegGuestSelector.js			\
+	form/MicrosegGuestNicSelector.js		\
 	form/SDNIpamSelector.js				\
 	form/SDNDnsSelector.js				\
 	form/ScsiHwSelector.js				\
@@ -338,6 +341,12 @@ JSSRC= 							\
 	sdn/zones/VxlanEdit.js				\
 	sdn/FabricsView.js				\
 	sdn/FabricsContentView.js			\
+	sdn/MicrosegView.js				\
+	sdn/microseg/Base.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 2ed4e65d..2ac5de06 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -966,6 +966,29 @@ Ext.define('PVE.Utils', {
             },
         },
 
+        sdnmicrosegSchema: {
+            group: {
+                name: gettext('Group'),
+                ipanel: 'PVE.sdn.microseg.GroupInputPanel',
+            },
+            rule: {
+                name: gettext('Rule'),
+                ipanel: 'PVE.sdn.microseg.RuleInputPanel',
+            },
+            assignment: {
+                name: gettext('Assignment'),
+                ipanel: 'PVE.sdn.microseg.AssignmentInputPanel',
+            },
+        },
+
+        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..4954d2e3
--- /dev/null
+++ b/www/manager6/form/MicrosegGroupSelector.js
@@ -0,0 +1,64 @@
+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',
+                },
+            });
+
+            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/MicrosegGuestNicSelector.js b/www/manager6/form/MicrosegGuestNicSelector.js
new file mode 100644
index 00000000..da8dc953
--- /dev/null
+++ b/www/manager6/form/MicrosegGuestNicSelector.js
@@ -0,0 +1,107 @@
+Ext.define('PVE.form.MicrosegGuestNicSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveMicrosegGuestNicSelector'],
+
+    valueField: 'iface',
+    displayField: 'name',
+    queryMode: 'local',
+    disabled: true,
+    emptyText: gettext('Select a guest first'),
+
+    currentGuest: undefined,
+
+    initComponent: function () {
+        let me = this;
+
+        me.store = Ext.create('Ext.data.Store', {
+            fields: ['iface', 'name', 'detail'],
+            data: [],
+        });
+
+        Ext.apply(me, {
+            autoSelect: false,
+            listConfig: {
+                columns: [
+                    {
+                        header: gettext('Interface'),
+                        dataIndex: 'name',
+                        width: 90,
+                    },
+                    {
+                        header: gettext('Configuration'),
+                        dataIndex: 'detail',
+                        flex: 1,
+                        renderer: Ext.String.htmlEncode,
+                    },
+                ],
+            },
+        });
+
+        me.callParent();
+    },
+
+    setValue: function (value) {
+        let me = this;
+        let store = me.getStore();
+        if (
+            value !== undefined &&
+            value !== null &&
+            store &&
+            store.findBy((r) => String(r.get('iface')) === String(value)) === -1
+        ) {
+            me.pendingValue = value;
+        }
+        return me.callParent([value]);
+    },
+
+    setGuest: function (vmid, node, type) {
+        let me = this;
+
+        vmid = vmid !== undefined && vmid !== null && vmid !== '' ? String(vmid) : undefined;
+
+        if (!vmid || !node) {
+            return;
+        }
+
+        if (me.currentGuest === vmid) {
+            return;
+        }
+
+        if (me.currentGuest !== undefined && me.currentGuest !== vmid) {
+            me.setValue(undefined);
+        }
+        me.currentGuest = vmid;
+
+        let path = type === 'lxc' ? 'lxc' : 'qemu';
+        Proxmox.Utils.API2Request({
+            url: `/nodes/${node}/${path}/${vmid}/config`,
+            method: 'GET',
+            success: function (response) {
+                let conf = response.result.data ?? {};
+                let nics = [];
+                Ext.Object.each(conf, function (key, val) {
+                    let m = key.match(/^net(\d+)$/);
+                    if (m) {
+                        nics.push({
+                            iface: parseInt(m[1], 10),
+                            name: key,
+                            detail: Ext.isString(val) ? val : '',
+                        });
+                    }
+                });
+                nics.sort((a, b) => a.iface - b.iface);
+                me.getStore().loadData(nics);
+                if (me.pendingValue !== undefined) {
+                    let v = me.pendingValue;
+                    me.pendingValue = undefined;
+                    me.setValue(v);
+                }
+            },
+            failure: function (response) {
+                me.getStore().removeAll();
+                me.currentGuest = undefined;
+                Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+            },
+        });
+    },
+});
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..d9948fb9
--- /dev/null
+++ b/www/manager6/sdn/MicrosegView.js
@@ -0,0 +1,408 @@
+Ext.define('pve-sdn-microseg', {
+    extend: 'Ext.data.Model',
+    fields: [
+        'id',
+        'type',
+        'comment',
+        'digest',
+        'state',
+        'pending',
+        'mark',
+        'parent',
+        'src',
+        'dst',
+        'allow',
+        'vmid',
+        'iface',
+        'group',
+    ],
+    idProperty: 'id',
+});
+
+Ext.define('PVE.sdn.MicrosegTreeModel', {
+    extend: 'Ext.data.TreeModel',
+    idProperty: 'tree_id',
+});
+
+Ext.define('PVE.sdn.MicrosegTree', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'pveSDNMicrosegTree',
+
+    title: gettext('Groups'),
+    emptyText: gettext('No microseg groups configured.'),
+
+    rootVisible: false,
+    animate: false,
+
+    detailPanel: undefined,
+
+    store: {
+        sorters: ['tree_id'],
+        model: 'PVE.sdn.MicrosegTreeModel',
+    },
+
+    columns: [
+        {
+            xtype: 'treecolumn',
+            text: gettext('Name'),
+            dataIndex: 'id',
+            flex: 2,
+            renderer: function (value, metaData, rec) {
+                if (rec.data.type !== 'assignment') {
+                    return PVE.Utils.render_sdn_pending(rec, value, 'id', 1);
+                }
+
+                let pending = rec.data.pending || {};
+                let vmid = pending.vmid ?? rec.data.vmid;
+                let iface = pending.iface ?? rec.data.iface;
+                let guest = Ext.String.htmlEncode(`${vmid}`);
+                let nic = Ext.String.htmlEncode(`net${iface}`);
+                let label = `${guest} <span style="opacity: 0.6;">${nic}</span>`;
+                if (rec.data.state === 'deleted') {
+                    return `<span style="text-decoration: line-through;">${label}</span>`;
+                }
+                return label;
+            },
+        },
+        {
+            text: gettext('Mark'),
+            dataIndex: 'mark',
+            width: 100,
+            renderer: (value, metaData, rec) =>
+                rec.data.type === 'group' ? PVE.Utils.render_sdn_pending(rec, value, 'mark') : '',
+        },
+        {
+            text: gettext('Comment'),
+            dataIndex: 'comment',
+            flex: 1,
+            renderer: (value, metaData, rec) =>
+                rec.data.type === 'group'
+                    ? PVE.Utils.render_sdn_pending(rec, value, 'comment')
+                    : '',
+        },
+        {
+            text: gettext('Action'),
+            xtype: 'actioncolumn',
+            width: 110,
+            items: [
+                {
+                    tooltip: gettext('Add Subgroup'),
+                    handler: 'addSubgroupAction',
+                    getClass: (_v, _m, { data }) =>
+                        data.type === 'group' && data.state !== 'deleted'
+                            ? 'fa fa-sitemap'
+                            : 'pmx-hidden',
+                    isActionDisabled: (_v, _r, _c, _i, { data }) => data.type !== 'group',
+                },
+                {
+                    tooltip: gettext('Add Assignment'),
+                    handler: 'addAssignmentAction',
+                    getClass: (_v, _m, { data }) =>
+                        data.type === 'group' && data.state !== 'deleted'
+                            ? 'fa fa-plus-circle'
+                            : 'pmx-hidden',
+                    isActionDisabled: (_v, _r, _c, _i, { data }) => data.type !== 'group',
+                },
+                {
+                    tooltip: gettext('Edit'),
+                    handler: 'editAction',
+                    getClass: (_v, _m, { data }) =>
+                        data.type && data.state !== 'deleted' ? 'fa fa-pencil fa-fw' : 'pmx-hidden',
+                    isActionDisabled: (_v, _r, _c, _i, { data }) =>
+                        !data.type || data.state === 'deleted',
+                },
+                {
+                    tooltip: gettext('Delete'),
+                    handler: 'deleteAction',
+                    getClass: (_v, _m, { data }) =>
+                        data.type && data.state !== 'deleted'
+                            ? 'fa critical fa-trash-o'
+                            : 'pmx-hidden',
+                    isActionDisabled: (_v, _r, _c, _i, { data }) =>
+                        !data.type || data.state === 'deleted',
+                },
+            ],
+        },
+        {
+            text: gettext('State'),
+            width: 100,
+            dataIndex: 'state',
+            renderer: (value, metaData, rec) => PVE.Utils.render_sdn_pending_state(rec, value),
+        },
+    ],
+
+    tbar: [
+        {
+            text: gettext('Add Group'),
+            handler: 'addGroup',
+        },
+        {
+            text: gettext('Add Assignment'),
+            itemId: 'addAssignmentBtn',
+            disabled: true,
+            handler: 'addAssignmentTbar',
+        },
+        {
+            xtype: 'proxmoxButton',
+            text: gettext('Reload'),
+            handler: 'reload',
+        },
+    ],
+
+    listeners: {
+        selectionchange: 'onSelectionChange',
+        itemdblclick: 'onItemDblClick',
+    },
+
+    controller: {
+        xclass: 'Ext.app.ViewController',
+
+        contextChain: function () {
+            let rec = this.getView().getSelection()?.[0];
+            if (!rec) {
+                return [];
+            }
+            let node = rec.data.type === 'assignment' ? rec.parentNode : rec;
+            let chain = [];
+            while (node && node.data && node.data.type === 'group') {
+                chain.push(node.data.id);
+                node = node.parentNode;
+            }
+            return chain;
+        },
+
+        reload: function () {
+            let me = this;
+            let view = me.getView();
+            let previous = view.getSelection()?.[0]?.data?.tree_id;
+
+            Proxmox.Utils.API2Request({
+                url: '/cluster/sdn/microseg/all?pending=1',
+                method: 'GET',
+                waitMsgTarget: view,
+                failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+                success: function (response) {
+                    let entries = response.result.data;
+
+                    let groups = {};
+                    for (const entry of entries) {
+                        if (entry.type !== 'group') {
+                            continue;
+                        }
+                        groups[entry.id] = {
+                            type: 'group',
+                            expanded: true,
+                            iconCls: 'fa fa-tag x-fa-treepanel',
+                            tree_id: entry.id,
+                            children: [],
+                            ...entry,
+                            ...entry.pending,
+                        };
+                    }
+
+                    let parentOf = {};
+                    for (const [name, node] of Object.entries(groups)) {
+                        let parent = node.parent;
+                        parentOf[name] =
+                            parent && parent !== 'deleted' && groups[parent] ? parent : undefined;
+                    }
+                    let acyclic = (name) => {
+                        let seen = {};
+                        let cur = name;
+                        while (cur !== undefined) {
+                            if (seen[cur]) {
+                                return false;
+                            }
+                            seen[cur] = true;
+                            cur = parentOf[cur];
+                        }
+                        return true;
+                    };
+
+                    let roots = [];
+                    for (const [name, node] of Object.entries(groups)) {
+                        let parent = parentOf[name];
+                        if (parent && acyclic(name)) {
+                            groups[parent].children.push(node);
+                        } else {
+                            roots.push(node);
+                        }
+                    }
+
+                    for (const entry of entries) {
+                        if (entry.type !== 'assignment') {
+                            continue;
+                        }
+                        let member = {
+                            type: 'assignment',
+                            leaf: true,
+                            tree_id: entry.id,
+                            ...entry,
+                            ...entry.pending,
+                        };
+                        let resources = PVE.data.ResourceStore;
+                        let idx = resources.findBy(
+                            (r) => String(r.get('vmid')) === String(member.vmid),
+                        );
+                        let guest = idx >= 0 ? resources.getAt(idx) : undefined;
+                        member.iconCls =
+                            (guest?.get('type') === 'lxc' ? 'fa fa-cube' : 'fa fa-desktop') +
+                            ' x-fa-treepanel';
+                        let parent = groups[member.group];
+                        if (parent) {
+                            parent.children.push(member);
+                        } else {
+                            console.warn(
+                                `microseg assignment ${member.id} references unknown group ${member.group}`,
+                            );
+                        }
+                    }
+
+                    view.setRootNode({ expanded: true, children: roots });
+
+                    let node = previous && view.getStore().getNodeById(previous);
+                    if (node) {
+                        view.setSelection(node);
+                    }
+                    me.onSelectionChange();
+                },
+            });
+        },
+
+        onSelectionChange: function () {
+            let me = this;
+            let view = me.getView();
+            let chain = me.contextChain();
+            view.down('#addAssignmentBtn')?.setDisabled(chain.length === 0);
+            view.detailPanel?.setGroups(chain);
+        },
+
+        onItemDblClick: function (_view, rec) {
+            this.editAction(null, null, null, null, null, rec);
+        },
+
+        addGroup: function () {
+            this.openGroupAdd();
+        },
+
+        addSubgroupAction: function (_t, _r, _c, _i, _e, rec) {
+            if (rec.data.type === 'group') {
+                this.openGroupAdd(rec.data.id);
+            }
+        },
+
+        openGroupAdd: function (presetParent) {
+            let me = this;
+            Ext.create('PVE.sdn.microseg.BaseEdit', {
+                type: 'group',
+                autoShow: true,
+                panelConfig: presetParent ? { presetParent } : undefined,
+                listeners: { destroy: () => me.reload() },
+            });
+        },
+
+        addAssignmentTbar: function () {
+            let chain = this.contextChain();
+            if (chain.length) {
+                this.openAssignmentAdd(chain[0]);
+            }
+        },
+
+        addAssignmentAction: function (_t, _r, _c, _i, _e, rec) {
+            if (rec.data.type === 'group') {
+                this.openAssignmentAdd(rec.data.id);
+            }
+        },
+
+        openAssignmentAdd: function (group) {
+            let me = this;
+            Ext.create('PVE.sdn.microseg.BaseEdit', {
+                type: 'assignment',
+                autoShow: true,
+                panelConfig: { presetGroup: group },
+                listeners: { destroy: () => me.reload() },
+            });
+        },
+
+        editAction: function (_t, _r, _c, _i, _e, rec) {
+            let me = this;
+            Ext.create('PVE.sdn.microseg.BaseEdit', {
+                type: rec.data.type === 'group' ? 'group' : 'assignment',
+                microsegId: rec.data.id,
+                autoShow: true,
+                listeners: { destroy: () => me.reload() },
+            });
+        },
+
+        deleteAction: function (_t, _r, _c, _i, _e, rec) {
+            let me = this;
+            let name = rec.data.id;
+            let message =
+                rec.data.type === 'group'
+                    ? Ext.String.format(
+                          gettext('Are you sure you want to remove the group "{0}"?'),
+                          name,
+                      )
+                    : Ext.String.format(
+                          gettext('Are you sure you want to remove the assignment "{0}"?'),
+                          name,
+                      );
+
+            Ext.Msg.show({
+                title: gettext('Confirm'),
+                icon: Ext.Msg.WARNING,
+                message: message,
+                buttons: Ext.Msg.YESNO,
+                defaultFocus: 'no',
+                callback: function (btn) {
+                    if (btn !== 'yes') {
+                        return;
+                    }
+                    Proxmox.Utils.API2Request({
+                        url: `/cluster/sdn/microseg/${rec.data.type}/${encodeURIComponent(name)}`,
+                        method: 'DELETE',
+                        waitMsgTarget: me.getView(),
+                        failure: (response) =>
+                            Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+                        callback: () => me.reload(),
+                    });
+                },
+            });
+        },
+    },
+});
+
+Ext.define('PVE.sdn.MicrosegView', {
+    extend: 'Ext.panel.Panel',
+    alias: 'widget.pveSDNMicroseg',
+
+    onlineHelp: 'pvesdn_config_microseg',
+
+    layout: 'border',
+
+    initComponent: function () {
+        let me = this;
+
+        let policies = Ext.createWidget('pveSDNMicrosegPolicyView', {
+            region: 'center',
+            border: false,
+        });
+
+        let tree = Ext.createWidget('pveSDNMicrosegTree', {
+            region: 'west',
+            width: '45%',
+            split: true,
+            border: false,
+            detailPanel: policies,
+        });
+
+        Ext.apply(me, {
+            items: [tree, policies],
+            listeners: {
+                activate: () => tree.getController().reload(),
+            },
+        });
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
new file mode 100644
index 00000000..b75c8a1c
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -0,0 +1,63 @@
+Ext.define('PVE.sdn.microseg.AssignmentInputPanel', {
+    extend: 'PVE.panel.SDNMicrosegBase',
+
+    onlineHelp: 'pvesdn_microseg_assignment',
+
+    autoId: true,
+
+    syncNicSelector: function () {
+        let me = this;
+
+        let vmid = me.guestSelector.getValue();
+        let rec;
+        if (vmid !== undefined && vmid !== null && vmid !== '') {
+            let store = me.guestSelector.getStore();
+            let idx = store.findBy((r) => String(r.get('vmid')) === String(vmid));
+            rec = idx >= 0 ? store.getAt(idx) : undefined;
+        }
+
+        if (rec) {
+            me.nicSelector.setGuest(rec.get('vmid'), rec.get('node'), rec.get('type'));
+        } else {
+            me.nicSelector.setGuest();
+        }
+        me.nicSelector.setDisabled(!me.isCreate || !rec);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.guestSelector = Ext.create({
+            xtype: 'pveMicrosegGuestSelector',
+            name: 'vmid',
+            fieldLabel: gettext('Guest'),
+            allowBlank: false,
+            disabled: !me.isCreate,
+            listeners: {
+                change: () => me.syncNicSelector(),
+            },
+        });
+        me.guestSelector.getStore().on('load', () => me.syncNicSelector());
+
+        me.nicSelector = Ext.create({
+            xtype: 'pveMicrosegGuestNicSelector',
+            name: 'iface',
+            fieldLabel: gettext('Network interface'),
+            allowBlank: false,
+        });
+
+        me.items = [
+            me.guestSelector,
+            me.nicSelector,
+            {
+                xtype: 'pveMicrosegGroupSelector',
+                name: 'group',
+                fieldLabel: gettext('Group'),
+                allowBlank: false,
+                value: me.presetGroup,
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
new file mode 100644
index 00000000..e604a1f2
--- /dev/null
+++ b/www/manager6/sdn/microseg/Base.js
@@ -0,0 +1,88 @@
+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',
+                ),
+            });
+        }
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.sdn.microseg.BaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 400,
+
+    initComponent: function () {
+        let me = this;
+
+        me.isCreate = !me.microsegId;
+
+        if (me.isCreate) {
+            me.url = '/api2/extjs/cluster/sdn/microseg/' + me.type;
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs/cluster/sdn/microseg/' + me.type + '/' + me.microsegId;
+            me.method = 'PUT';
+        }
+
+        let ipanel = Ext.create(
+            PVE.Utils.sdnmicrosegSchema[me.type].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..6d4f27ab
--- /dev/null
+++ b/www/manager6/sdn/microseg/GroupEdit.js
@@ -0,0 +1,61 @@
+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) {
+            if (!values.mark) {
+                delete values.mark;
+            }
+            if (!values.parent) {
+                delete values.parent;
+            }
+        } else if (!values.parent) {
+            delete values.parent;
+            values.delete = values.delete ? [].concat(values.delete, 'parent') : ['parent'];
+        }
+        return me.callParent([values]);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: 'pveMicrosegGroupSelector',
+                name: 'parent',
+                fieldLabel: gettext('Parent group'),
+                emptyText: gettext('none (top-level group)'),
+                allowBlank: true,
+                value: me.presetParent,
+            },
+            {
+                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..0bd39a12
--- /dev/null
+++ b/www/manager6/sdn/microseg/PolicyView.js
@@ -0,0 +1,221 @@
+Ext.define('PVE.sdn.microseg.PolicyView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pveSDNMicrosegPolicyView',
+
+    title: gettext('Policies'),
+
+    chain: undefined,
+    ownGroup: undefined,
+    chainSet: undefined,
+
+    setGroups: function (chain) {
+        let me = this;
+
+        me.chain = chain || [];
+        me.ownGroup = me.chain[0];
+        me.chainSet = {};
+        for (const name of me.chain) {
+            me.chainSet[name] = true;
+        }
+
+        me.store.removeAll();
+
+        if (me.ownGroup === undefined || me.ownGroup === null) {
+            me.setTitle(gettext('Policies'));
+            me.addBtn.disable();
+            return;
+        }
+
+        me.setTitle(Ext.String.format(gettext('Policies for {0}'), me.ownGroup));
+        me.addBtn.enable();
+        me.store.load();
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let eff = (rec, key) => {
+            let pending = rec.data.pending?.[key];
+            if (pending === undefined || pending === null || pending === 'deleted') {
+                return rec.data[key];
+            }
+            return pending;
+        };
+
+        let matched = (rec) => {
+            let src = eff(rec, 'src');
+            let dst = eff(rec, 'dst');
+            if (src === me.ownGroup || dst === me.ownGroup) {
+                return me.ownGroup;
+            }
+            return (me.chain || []).find((name) => name === src || name === dst);
+        };
+
+        let isInherited = (rec) => {
+            let group = matched(rec);
+            return group !== undefined && group !== me.ownGroup;
+        };
+
+        let store = new Ext.data.Store({
+            model: 'pve-sdn-microseg',
+            proxy: {
+                type: 'proxmox',
+                url: '/api2/json/cluster/sdn/microseg/rule?pending=1',
+            },
+            filters: [
+                {
+                    filterFn: (rec) => {
+                        if (!me.chainSet) {
+                            return false;
+                        }
+                        return Boolean(
+                            me.chainSet[eff(rec, 'src')] || me.chainSet[eff(rec, 'dst')],
+                        );
+                    },
+                },
+            ],
+            sorters: { property: 'id', direction: 'ASC' },
+        });
+
+        let reload = () => {
+            if (me.ownGroup !== undefined && me.ownGroup !== null) {
+                store.load();
+            }
+        };
+
+        let sm = Ext.create('Ext.selection.RowModel', {});
+
+        let run_editor = function () {
+            let rec = sm.getSelection()[0];
+            if (!rec || isInherited(rec)) {
+                return;
+            }
+            let win = Ext.create('PVE.sdn.microseg.BaseEdit', {
+                type: 'rule',
+                microsegId: rec.data.id,
+                autoShow: true,
+            });
+            win.on('destroy', reload);
+        };
+
+        me.addBtn = new Proxmox.button.Button({
+            text: gettext('Add'),
+            disabled: true,
+            handler: function () {
+                let win = Ext.create('PVE.sdn.microseg.BaseEdit', {
+                    type: 'rule',
+                    autoShow: true,
+                    panelConfig: { presetDst: me.ownGroup },
+                });
+                win.on('destroy', reload);
+            },
+        });
+
+        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/rule/',
+            callback: reload,
+        });
+
+        let set_button_status = function () {
+            let rec = sm.getSelection()[0];
+            if (!rec || rec.data.state === 'deleted' || isInherited(rec)) {
+                edit_btn.disable();
+                remove_btn.disable();
+            }
+        };
+
+        Ext.apply(me, {
+            store: store,
+            selModel: sm,
+            viewConfig: {
+                trackOver: false,
+                getRowClass: (rec) => (isInherited(rec) ? 'proxmox-disabled-row' : ''),
+            },
+            tbar: [me.addBtn, remove_btn, edit_btn],
+            columns: [
+                {
+                    header: '',
+                    width: 44,
+                    align: 'center',
+                    renderer: (value, md, rec) => {
+                        let group = matched(rec);
+                        let inbound = eff(rec, 'dst') === group;
+                        let outbound = eff(rec, 'src') === group;
+                        let dir;
+                        if (inbound && outbound) {
+                            dir = { icon: 'fa-exchange', tip: gettext('Internal') };
+                        } else if (inbound) {
+                            dir = { icon: 'fa-sign-in', tip: gettext('Inbound') };
+                        } else if (outbound) {
+                            dir = { icon: 'fa-sign-out', tip: gettext('Outbound') };
+                        } else {
+                            return '';
+                        }
+                        return `<i class="fa fa-fw ${dir.icon}" data-qtip="${Ext.String.htmlEncode(dir.tip)}"></i>`;
+                    },
+                },
+                {
+                    header: gettext('Source'),
+                    flex: 1,
+                    dataIndex: 'src',
+                    renderer: (value, md, rec) => {
+                        if (!eff(rec, 'src')) {
+                            return '<i>' + gettext('unstamped') + '</i>';
+                        }
+                        return PVE.Utils.render_sdn_pending(rec, value, 'src');
+                    },
+                },
+                {
+                    header: gettext('Destination'),
+                    flex: 1,
+                    dataIndex: 'dst',
+                    renderer: (value, md, rec) => PVE.Utils.render_sdn_pending(rec, value, 'dst'),
+                },
+                {
+                    header: gettext('Action'),
+                    width: 100,
+                    dataIndex: 'allow',
+                    renderer: (value, md, rec) =>
+                        Number(eff(rec, 'allow'))
+                            ? '<i class="fa fa-check good"></i> ' + gettext('allow')
+                            : '<i class="fa fa-ban critical"></i> ' + gettext('deny'),
+                },
+                {
+                    header: gettext('Inherited'),
+                    width: 150,
+                    renderer: (value, md, rec) => {
+                        let group = matched(rec);
+                        if (!group || group === me.ownGroup) {
+                            return '';
+                        }
+                        return (
+                            '<span style="color: gray;"><i>' +
+                            Ext.String.format(gettext('from {0}'), Ext.String.htmlEncode(group)) +
+                            '</i></span>'
+                        );
+                    },
+                },
+                {
+                    header: gettext('State'),
+                    width: 100,
+                    dataIndex: 'state',
+                    renderer: (value, md, rec) => PVE.Utils.render_sdn_pending_state(rec, value),
+                },
+            ],
+            listeners: {
+                itemdblclick: run_editor,
+                selectionchange: set_button_status,
+            },
+        });
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js
new file mode 100644
index 00000000..4e0a1342
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleEdit.js
@@ -0,0 +1,49 @@
+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) {
+            delete values.src;
+        }
+        return me.callParent([values]);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: 'pveMicrosegGroupSelector',
+                name: 'src',
+                fieldLabel: gettext('Source group'),
+                emptyText: gettext('leave empty for unstamped traffic'),
+                allowBlank: true,
+                disabled: !me.isCreate,
+            },
+            {
+                xtype: 'pveMicrosegGroupSelector',
+                name: 'dst',
+                fieldLabel: gettext('Destination group'),
+                allowBlank: false,
+                disabled: !me.isCreate,
+                value: me.presetDst,
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                name: 'allow',
+                fieldLabel: gettext('Action'),
+                boxLabel: gettext('Allow (unchecked = deny)'),
+                checked: true,
+                uncheckedValue: 0,
+                inputValue: 1,
+            },
+        ];
+
+        me.callParent();
+    },
+});
-- 
2.47.3





  parent reply	other threads:[~2026-06-09 13:26 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-06-09 13:25 [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ebpf,-ve-rs,-perl-rs} 00/16] sdn: add microsegmentation support Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 01/16] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 02/16] bpf: add bridge subsystem Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 03/16] debian: add packaging and boot-time oneshot unit Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ve-rs 04/16] ve-config: sdn: add microseg config types Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-perl-rs 05/16] sdn: add microseg config binding Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-cluster 06/16] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 07/16] sdn: microseg: add config and API Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 08/16] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 09/16] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 10/16] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
2026-06-09 13:25 ` Hannes Laimer [this message]
2026-06-09 13:25 ` [PATCH pve-manager 12/16] network: apply microseg state on reload Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-manager 13/16] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-docs 14/16] sdn: add microsegmentation section Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-docs 15/16] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
2026-06-09 13:25 ` [PATCH ifupdown2 16/16] d/patches: add support for VXLAN-GBP flag 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=20260609132522.235917-12-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