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
next prev parent reply other threads:[~2026-07-09 9:23 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-07-09 9:18 ` [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API Hannes Laimer
2026-07-09 9:18 ` Hannes Laimer [this message]
2026-07-09 9:18 ` [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 22/27] network: apply microseg state on reload Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 26/27] sdn: add microsegmentation section Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260709091852.538885-21-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox