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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.