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
next prev 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