From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-manager v3 34/45] ui: sdn: add microsegmentation panel
Date: Tue, 27 Oct 2026 09:55:16 +0100 [thread overview]
Message-ID: <20261027085527.702738-35-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com>
Add a Microsegmentation panel under Datacenter > SDN to manage groups,
rules and guest assignments, with a tree of the groups and their
guests and NICs. Rules are grouped by priority, highest first.
A NIC belongs to the first assignment that matches it, so the
assignment list keeps the match order and can be reordered by drag and
drop or a position field. An assignment that wins no NIC is dimmed.
Selecting a group shows the rules that list it. Selecting a guest or
NIC shows the rules the API reports for its groups, so the panel does
not evaluate rules itself.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/css/ext6-pve.css | 23 ++
www/manager6/Makefile | 9 +
www/manager6/Utils.js | 74 ++++++
www/manager6/data/PermPathStore.js | 1 +
www/manager6/dc/Config.js | 8 +
www/manager6/form/SDNMicrosegGroupSelector.js | 83 ++++++
www/manager6/sdn/MicrosegView.js | 92 +++++++
www/manager6/sdn/microseg/AssignmentEdit.js | 80 ++++++
www/manager6/sdn/microseg/AssignmentView.js | 193 ++++++++++++++
www/manager6/sdn/microseg/Base.js | 215 +++++++++++++++
www/manager6/sdn/microseg/GroupEdit.js | 30 +++
www/manager6/sdn/microseg/RuleEdit.js | 158 +++++++++++
www/manager6/sdn/microseg/RuleView.js | 242 +++++++++++++++++
www/manager6/sdn/microseg/Tree.js | 249 ++++++++++++++++++
14 files changed, 1457 insertions(+)
create mode 100644 www/manager6/form/SDNMicrosegGroupSelector.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/AssignmentView.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/RuleEdit.js
create mode 100644 www/manager6/sdn/microseg/RuleView.js
create mode 100644 www/manager6/sdn/microseg/Tree.js
diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css
index b1e0b4f81002..07f2c65d33ef 100644
--- a/www/css/ext6-pve.css
+++ b/www/css/ext6-pve.css
@@ -708,3 +708,26 @@ table.osds td:first-of-type {
.pmx-column-wrapped > div {
white-space: pre-wrap;
}
+
+.pve-microseg-group {
+ display: inline-block;
+ border: 1px solid #999;
+ border-radius: 3px;
+ padding: 0px 5px;
+ margin: 0px 1px;
+}
+
+.pve-microseg-muted {
+ opacity: 0.5;
+}
+
+.pve-microseg-id-space {
+ font-size: smaller;
+ font-weight: normal;
+}
+
+.pve-microseg-deleted td:not(.pve-microseg-state) .x-grid-cell-inner,
+.pve-microseg-deleted td:not(.pve-microseg-state) .pve-microseg-group,
+.pve-microseg-deleted td:not(.pve-microseg-state) .fa {
+ text-decoration: line-through;
+}
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d2ea786be00b..7c48b0622aa7 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -71,6 +71,7 @@ JSSRC= \
form/SDNControllerSelector.js \
form/SDNZoneSelector.js \
form/SDNVnetSelector.js \
+ form/SDNMicrosegGroupSelector.js \
form/SDNIpamSelector.js \
form/SDNDnsSelector.js \
form/ScsiHwSelector.js \
@@ -357,6 +358,14 @@ JSSRC= \
sdn/fabrics/bgp/InterfacePanel.js \
sdn/fabrics/bgp/NodeEdit.js \
sdn/fabrics/bgp/FabricEdit.js \
+ sdn/MicrosegView.js \
+ sdn/microseg/Base.js \
+ sdn/microseg/Tree.js \
+ sdn/microseg/GroupEdit.js \
+ sdn/microseg/RuleEdit.js \
+ sdn/microseg/AssignmentEdit.js \
+ sdn/microseg/RuleView.js \
+ sdn/microseg/AssignmentView.js \
storage/ContentView.js \
storage/BackupView.js \
storage/Base.js \
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 8b99371d3447..8eb4b68c51b4 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -231,6 +231,22 @@ Ext.define('PVE.Utils', {
return `<span data-qtip="${Ext.htmlEncode(tip)}">${icon}${Ext.htmlEncode(value)}</span>`;
},
+ sdnmicroseg_row_cls: function (rec) {
+ return rec.data.state === 'deleted' ? 'pve-microseg-deleted' : '';
+ },
+
+ render_sdnmicroseg_state: function (value) {
+ return value
+ ? '<i class="fa fa-fw fa-refresh warning" data-qtip="' +
+ Ext.String.htmlEncode(value) +
+ '"></i>'
+ : '';
+ },
+
+ render_sdnmicroseg_group: function (group) {
+ return `<span class="pve-microseg-group">${Ext.String.htmlEncode(group)}</span>`;
+ },
+
render_ceph_health: function (healthObj) {
var state = {
iconCls: PVE.Utils.get_health_icon(),
@@ -966,6 +982,64 @@ Ext.define('PVE.Utils', {
},
},
+ sdnmicrosegSchema: {
+ group: {
+ name: gettext('Group'),
+ ipanel: 'GroupInputPanel',
+ width: 400,
+ },
+ rule: {
+ name: gettext('Rule'),
+ ipanel: 'RuleInputPanel',
+ },
+ 'guest-assignment': {
+ name: gettext('Guest Assignment'),
+ ipanel: 'GuestAssignmentInputPanel',
+ width: 400,
+ faIcon: 'link',
+ assignment: true,
+ },
+ },
+
+ sdnmicroseg_is_assignment: function (type) {
+ return !!PVE.Utils.sdnmicrosegSchema[type]?.assignment;
+ },
+
+ format_sdnmicroseg_type: function (value) {
+ let schema = PVE.Utils.sdnmicrosegSchema[value];
+ if (schema) {
+ return schema.name;
+ }
+ return Proxmox.Utils.unknownText;
+ },
+
+ sdnmicroseg_merge_pending: function (obj) {
+ let merged = { ...obj };
+ for (const [key, value] of Object.entries(obj.pending ?? {})) {
+ if (value === 'deleted') {
+ delete merged[key];
+ } else if (value !== undefined && value !== null) {
+ merged[key] = value;
+ }
+ }
+ return merged;
+ },
+
+ format_sdnmicroseg_predicate: function (match, groups) {
+ if (match === 'every') {
+ return match;
+ }
+ return `${match || 'any'}{${(groups || []).join(', ')}}`;
+ },
+
+ format_sdnmicroseg_matcher: function (assignment) {
+ if (!PVE.Utils.sdnmicrosegSchema[assignment.type]) {
+ return Proxmox.Utils.unknownText;
+ }
+ let iface = (assignment.iface ?? '') === '' ? '' : ` net${assignment.iface}`;
+ return `vm${assignment.vmid}${iface}`;
+ },
+
sdnipamSchema: {
ipam: {
name: 'ipam',
diff --git a/www/manager6/data/PermPathStore.js b/www/manager6/data/PermPathStore.js
index 7210e0cd48af..65c8d17c850f 100644
--- a/www/manager6/data/PermPathStore.js
+++ b/www/manager6/data/PermPathStore.js
@@ -17,6 +17,7 @@ Ext.define('PVE.data.PermPathStore', {
{ value: '/nodes' },
{ value: '/pool' },
{ value: '/sdn/fabrics' },
+ { value: '/sdn/microseg' },
{ value: '/sdn/zones' },
{ value: '/storage' },
{ value: '/vms' },
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 83c48c3d633c..fce20937f3ba 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('Microsegmentation'),
+ hidden: true,
+ iconCls: 'fa fa-tags',
+ itemId: 'sdnmicroseg',
+ },
{
xtype: 'pveSDNRouteMaps',
groups: ['sdn'],
diff --git a/www/manager6/form/SDNMicrosegGroupSelector.js b/www/manager6/form/SDNMicrosegGroupSelector.js
new file mode 100644
index 000000000000..1b0c39a1cfba
--- /dev/null
+++ b/www/manager6/form/SDNMicrosegGroupSelector.js
@@ -0,0 +1,83 @@
+Ext.define(
+ 'PVE.form.SDNMicrosegGroupSelector',
+ {
+ extend: 'Proxmox.form.ComboGrid',
+ alias: ['widget.pveSDNMicrosegGroupSelector'],
+
+ valueField: 'id',
+ displayField: 'id',
+
+ syncUntagged: function () {
+ let me = this;
+ let store = me.getStore();
+ let record = store.getById('untagged');
+ if (me.includeUntagged && !record) {
+ store.add({
+ id: 'untagged',
+ comment: gettext('No group (untagged)'),
+ });
+ } else if (!me.includeUntagged && record) {
+ store.remove(record);
+ }
+ },
+
+ setIncludeUntagged: function (include) {
+ let me = this;
+ me.includeUntagged = include;
+ let value = me.getValue();
+ if (!include && Ext.isArray(value)) {
+ me.setValue(value.filter((id) => id !== 'untagged'));
+ }
+ me.syncUntagged();
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let store = new Ext.data.Store({
+ model: 'pve-sdn-microseg-group',
+ sorters: {
+ property: 'id',
+ direction: 'ASC',
+ },
+ });
+
+ me.mon(store, 'load', () => me.syncUntagged());
+
+ Ext.apply(me, {
+ store: store,
+ autoSelect: false,
+ listConfig: {
+ columns: [
+ {
+ header: gettext('Group'),
+ sortable: true,
+ dataIndex: 'id',
+ flex: 1,
+ },
+ {
+ 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', 'comment'],
+ proxy: {
+ type: 'proxmox',
+ url: '/api2/json/cluster/sdn/microseg/group',
+ },
+ });
+ },
+);
diff --git a/www/manager6/sdn/MicrosegView.js b/www/manager6/sdn/MicrosegView.js
new file mode 100644
index 000000000000..805b5b7ebdd9
--- /dev/null
+++ b/www/manager6/sdn/MicrosegView.js
@@ -0,0 +1,92 @@
+Ext.define('PVE.sdn.MicrosegView', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.pveSDNMicroseg',
+
+ onlineHelp: 'pvesdn_config_microseg',
+
+ layout: 'border',
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ reload: function () {
+ let me = this;
+ PVE.sdn.microseg.Membership.load(me.getView(), (parts) => {
+ me.lookup('rules').setData(parts.rules);
+ me.lookup('assignments').setData(parts.assignments);
+ me.lookup('tree').setData(
+ parts.groups,
+ parts.assignments,
+ parts.groupSets,
+ parts.idSpace,
+ );
+ });
+ },
+
+ onTreeSelect: function (tree, selection) {
+ let me = this;
+ let sel = selection[0]?.data.microsegSelection ?? null;
+ me.lookup('rules').setTreeSelection(sel);
+ me.lookup('assignments').setTreeSelection(sel);
+ },
+
+ selectedGroup: function () {
+ let node = this.lookup('tree').getSelection()[0];
+ return node && PVE.sdn.microseg.Tree.editable(node) ? node.data.microsegId : undefined;
+ },
+
+ addGroup: function () {
+ this.lookup('tree').openEdit('group');
+ },
+
+ editGroup: function () {
+ let me = this;
+ let group = me.selectedGroup();
+ if (group) {
+ me.lookup('tree').openEdit('group', group);
+ }
+ },
+ },
+
+ items: [
+ {
+ xtype: 'pveSDNMicrosegTree',
+ reference: 'tree',
+ region: 'west',
+ title: gettext('Groups'),
+ width: 400,
+ minWidth: 250,
+ split: true,
+ listeners: {
+ microsegreload: 'reload',
+ selectionchange: 'onTreeSelect',
+ itemdblclick: 'editGroup',
+ },
+ },
+ {
+ xtype: 'tabpanel',
+ region: 'center',
+ border: false,
+ items: [
+ {
+ xtype: 'pveSDNMicrosegRules',
+ reference: 'rules',
+ listeners: {
+ microsegreload: 'reload',
+ },
+ },
+ {
+ xtype: 'pveSDNMicrosegAssignments',
+ reference: 'assignments',
+ listeners: {
+ microsegreload: 'reload',
+ },
+ },
+ ],
+ },
+ ],
+
+ listeners: {
+ activate: 'reload',
+ },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
new file mode 100644
index 000000000000..71cabaeeea52
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -0,0 +1,80 @@
+Ext.define('PVE.sdn.microseg.AssignmentInputPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+
+ onlineHelp: 'pvesdn_microseg_assignment',
+
+ onGetValues: function (values) {
+ let me = this;
+ if (me.isCreate) {
+ values.type = me.type;
+ }
+ return values;
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ ...me.matcherItems,
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iface',
+ fieldLabel: gettext('Network Interface'),
+ minValue: 0,
+ maxValue: 31,
+ allowBlank: true,
+ emptyText: gettext('All NICs'),
+ disabled: !me.isCreate,
+ },
+ {
+ xtype: 'pveSDNMicrosegGroupSelector',
+ name: 'groups',
+ fieldLabel: gettext('Groups'),
+ multiSelect: true,
+ allowBlank: false,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.advancedItems = [
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'position',
+ fieldLabel: gettext('Position'),
+ value: me.position,
+ minValue: 0,
+ allowBlank: true,
+ emptyText: me.isCreate ? '0' : gettext('Unchanged'),
+ },
+ ];
+
+ me.callParent();
+ },
+});
+
+Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', {
+ extend: 'PVE.sdn.microseg.AssignmentInputPanel',
+
+ initComponent: function () {
+ let me = this;
+
+ me.matcherItems = [
+ {
+ xtype: 'vmComboSelector',
+ name: 'vmid',
+ fieldLabel: gettext('Guest'),
+ allowBlank: false,
+ disabled: !me.isCreate,
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentView.js b/www/manager6/sdn/microseg/AssignmentView.js
new file mode 100644
index 000000000000..4744380461a7
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentView.js
@@ -0,0 +1,193 @@
+Ext.define(
+ 'PVE.sdn.microseg.AssignmentView',
+ {
+ extend: 'PVE.sdn.microseg.BaseGrid',
+ alias: 'widget.pveSDNMicrosegAssignments',
+
+ title: gettext('Assignments'),
+
+ baseurl: '/cluster/sdn/microseg/assignment/',
+
+ getRecordName: (rec) => PVE.Utils.format_sdnmicroseg_matcher(rec.data),
+
+ editPanelConfig: (rec) => ({ position: rec.data.rank }),
+
+ setData: function (assignments) {
+ let me = this;
+ let rows = assignments.map((a) => ({
+ ...a,
+ rank: me.rankOf(a),
+ memberCount: (a.members || []).reduce((n, m) => n + m.nics.length, 0),
+ }));
+ me.getStore().loadData(rows);
+ },
+
+ setTreeSelection: function (sel) {
+ let me = this;
+ me.treeSelection = sel;
+ me.getView().refresh();
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let store = Ext.create('Ext.data.Store', {
+ model: 'pve-sdn-microseg-assignment',
+ sorters: [
+ { property: 'rank', direction: 'ASC' },
+ { property: 'id', direction: 'ASC' },
+ ],
+ });
+
+ let move = (rec, index) =>
+ Proxmox.Utils.API2Request({
+ url: `${me.baseurl}${rec.data.id}`,
+ method: 'PUT',
+ params: { position: index },
+ waitMsgTarget: me,
+ failure: (response) =>
+ Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+ callback: () => me.reload(),
+ });
+
+ let movable = (rec) => rec.data.state !== 'deleted';
+
+ let winsNic = (rec, vmid, nic) =>
+ (rec.data.members || []).some(
+ (m) => m.vmid === vmid && (nic === undefined || m.nics.includes(nic)),
+ );
+
+ let dimmed = (rec) => {
+ if (!rec.data.memberCount) {
+ return true;
+ }
+ let sel = me.treeSelection;
+ if (!sel) {
+ return false;
+ }
+ if (sel.vmid !== undefined) {
+ return !winsNic(rec, sel.vmid, sel.nic);
+ }
+ let group = sel.mode === 'membership' ? sel.groups[0] : undefined;
+ if (group && group !== 'untagged') {
+ return !(rec.data.groups ?? []).includes(group);
+ }
+ return false;
+ };
+
+ let renderMembers = (value, rec) => {
+ if (!value) {
+ return '<span class="pve-microseg-muted">0</span>';
+ }
+ let nics = [];
+ for (const member of rec.data.members || []) {
+ for (const nic of member.nics) {
+ nics.push(`vm${member.vmid} net${nic}`);
+ }
+ }
+ return `<span data-qtip="${Ext.String.htmlEncode(nics.join(', '))}">${value}</span>`;
+ };
+
+ me.columns = [
+ {
+ text: '#',
+ dataIndex: 'rank',
+ width: 50,
+ renderer: (v, m, rec) => (rec.data.state === 'deleted' ? '' : v),
+ },
+ {
+ text: gettext('Matcher'),
+ dataIndex: 'type',
+ flex: 2,
+ renderer: (v, m, rec) => {
+ let text = Ext.String.htmlEncode(
+ PVE.Utils.format_sdnmicroseg_matcher(rec.data),
+ );
+ let icon = PVE.Utils.sdnmicrosegSchema[v]?.faIcon;
+ return icon ? `<i class="fa fa-fw fa-${icon}"></i> ${text}` : text;
+ },
+ },
+ {
+ text: gettext('Groups'),
+ dataIndex: 'groups',
+ flex: 2,
+ renderer: (v) => (v ?? []).map(PVE.Utils.render_sdnmicroseg_group).join(''),
+ },
+ {
+ text: gettext('NICs'),
+ dataIndex: 'memberCount',
+ width: 90,
+ renderer: (v, m, rec) => renderMembers(v, rec),
+ },
+ { xtype: 'pveSDNMicrosegStateColumn' },
+ {
+ text: gettext('Comment'),
+ dataIndex: 'comment',
+ flex: 1,
+ renderer: Ext.String.htmlEncode,
+ },
+ ];
+
+ Ext.apply(me, {
+ store: store,
+ viewConfig: {
+ plugins: [
+ {
+ ptype: 'gridviewdragdrop',
+ dragGroup: 'pveSDNMicrosegAssignmentDD',
+ dropGroup: 'pveSDNMicrosegAssignmentDD',
+ },
+ ],
+ getRowClass: (rec) =>
+ [
+ PVE.Utils.sdnmicroseg_row_cls(rec),
+ dimmed(rec) ? 'pve-microseg-muted' : '',
+ ].join(' '),
+ listeners: {
+ beforedrop: (node, data, dropRec, dropPosition) => {
+ let rec = data.records[0];
+ if (!dropRec || rec === dropRec || !movable(rec) || !movable(dropRec)) {
+ return false;
+ }
+ let list = store
+ .getRange()
+ .filter((r) => r !== rec && r.data.state !== 'deleted');
+ move(rec, list.indexOf(dropRec) + (dropPosition === 'after' ? 1 : 0));
+ return false;
+ },
+ },
+ },
+ addButton: {
+ text: gettext('Add'),
+ menu: {
+ items: Object.keys(PVE.Utils.sdnmicrosegSchema)
+ .filter((type) => PVE.Utils.sdnmicroseg_is_assignment(type))
+ .map((type) => ({
+ text: PVE.Utils.format_sdnmicroseg_type(type),
+ handler: () => me.openEdit(type),
+ })),
+ },
+ },
+ });
+
+ me.callParent();
+ },
+ },
+ function () {
+ Ext.define('pve-sdn-microseg-assignment', {
+ extend: 'Ext.data.Model',
+ fields: [
+ 'id',
+ 'type',
+ 'vmid',
+ 'iface',
+ 'groups',
+ 'comment',
+ 'state',
+ 'members',
+ 'rank',
+ 'memberCount',
+ ],
+ });
+ },
+);
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
new file mode 100644
index 000000000000..6751da1c7e88
--- /dev/null
+++ b/www/manager6/sdn/microseg/Base.js
@@ -0,0 +1,215 @@
+Ext.define('PVE.sdn.microseg.BaseEdit', {
+ extend: 'Proxmox.window.Edit',
+
+ initComponent: function () {
+ let me = this;
+
+ me.isCreate = !me.microsegId;
+
+ let schema = PVE.Utils.sdnmicrosegSchema[me.type];
+ if (!schema || !schema.ipanel) {
+ throw 'no editor registered for microseg type: ' + me.type;
+ }
+
+ if (me.isCreate) {
+ me.url = '/api2/extjs' + me.baseurl;
+ me.method = 'POST';
+ } else {
+ me.url = '/api2/extjs' + me.baseurl + 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,
+ width: schema.width,
+ items: [ipanel],
+ });
+
+ me.callParent();
+
+ if (!me.isCreate) {
+ me.load();
+ }
+ },
+});
+
+Ext.define('PVE.sdn.microseg.Membership', {
+ singleton: true,
+
+ load: function (view, callback) {
+ Proxmox.Utils.API2Request({
+ url: '/cluster/sdn/microseg/all?pending=1',
+ method: 'GET',
+ failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+ success: (response) => {
+ if (view.destroyed) {
+ return;
+ }
+ let data = response.result.data;
+ let objects = data.objects.map(PVE.Utils.sdnmicroseg_merge_pending);
+ callback({
+ groups: objects.filter((o) => o.type === 'group'),
+ rules: objects.filter((o) => o.type === 'rule'),
+ assignments: objects.filter((o) => PVE.Utils.sdnmicroseg_is_assignment(o.type)),
+ groupSets: data['group-sets'],
+ idSpace: data['id-space'],
+ });
+ },
+ });
+ },
+
+ rulesFor: function (groupSets) {
+ let classRules = {};
+ groupSets.forEach((cls) => {
+ classRules[cls.groups.join(',')] = cls;
+ });
+ return (sets) => {
+ let outbound = new Set();
+ let inbound = new Set();
+ sets.forEach((groupSet) => {
+ let cls = classRules[groupSet.join(',')];
+ cls?.outbound.forEach((r) => outbound.add(r));
+ cls?.inbound.forEach((r) => inbound.add(r));
+ });
+ return { outbound: outbound, inbound: inbound };
+ };
+ },
+
+ nicGroupSets: function (assignments, rulesFor) {
+ let nicGroupSet = {};
+ assignments.forEach((a) => {
+ let sortedGroups = (a.groups || []).slice().sort();
+ let groupSet = {
+ groups: sortedGroups,
+ rules: rulesFor([sortedGroups]),
+ note: PVE.Utils.format_sdnmicroseg_matcher(a),
+ };
+ (a.members || []).forEach((m) => {
+ m.nics.forEach((nic) => {
+ nicGroupSet[`${m.vmid}/${nic}`] = groupSet;
+ });
+ });
+ });
+ return nicGroupSet;
+ },
+
+ guestSelection: function (vmid, nicGroupSet, rulesFor) {
+ let keys = Object.keys(nicGroupSet).filter((key) => key.startsWith(`${vmid}/`));
+ let rules = rulesFor(keys.map((key) => nicGroupSet[key].groups));
+ return {
+ scope: `vm${vmid}`,
+ mode: 'group-set',
+ outbound: rules.outbound,
+ inbound: rules.inbound,
+ vmid: vmid,
+ };
+ },
+});
+
+Ext.define('PVE.sdn.microseg.StateColumn', {
+ extend: 'Ext.grid.column.Column',
+ xtype: 'pveSDNMicrosegStateColumn',
+
+ text: '',
+ dataIndex: 'state',
+ width: 40,
+ resizable: false,
+ tdCls: 'pve-microseg-state',
+ renderer: PVE.Utils.render_sdnmicroseg_state,
+});
+
+Ext.define('PVE.sdn.microseg.ViewMixin', {
+ extend: 'Ext.Mixin',
+
+ reload: function () {
+ this.fireEvent('microsegreload');
+ },
+
+ rankOf: function (data) {
+ return data.rank ?? Number.MAX_SAFE_INTEGER;
+ },
+
+ openEdit: function (type, microsegId, panelConfig) {
+ let me = this;
+ Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: type,
+ baseurl: me.baseurl,
+ microsegId: microsegId,
+ panelConfig: panelConfig,
+ autoShow: true,
+ listeners: { destroy: () => me.reload() },
+ });
+ },
+});
+
+Ext.define('PVE.sdn.microseg.BaseGrid', {
+ extend: 'Ext.grid.GridPanel',
+ mixins: ['PVE.sdn.microseg.ViewMixin'],
+
+ border: false,
+ sortableColumns: false,
+
+ initComponent: function () {
+ let me = this;
+
+ if (!me.baseurl) {
+ throw 'no baseurl given';
+ }
+
+ let sm = Ext.create('Ext.selection.RowModel', {});
+
+ let editable = (rec) => rec.data.state !== 'deleted';
+
+ let runEditor = () => {
+ let rec = sm.getSelection()[0];
+ if (!rec || !editable(rec)) {
+ return;
+ }
+ me.openEdit(rec.data.type, rec.data.id, me.editPanelConfig?.(rec));
+ };
+
+ Ext.apply(me, {
+ selModel: sm,
+ tbar: [
+ me.addButton,
+ new Proxmox.button.Button({
+ text: gettext('Edit'),
+ disabled: true,
+ selModel: sm,
+ enableFn: editable,
+ handler: runEditor,
+ }),
+ Ext.create('Proxmox.button.StdRemoveButton', {
+ selModel: sm,
+ enableFn: editable,
+ baseurl: me.baseurl,
+ getRecordName: me.getRecordName,
+ callback: () => me.reload(),
+ }),
+ {
+ text: gettext('Reload'),
+ handler: () => me.reload(),
+ },
+ ...(me.tbar ?? []),
+ ],
+ listeners: {
+ itemdblclick: runEditor,
+ },
+ });
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js
new file mode 100644
index 000000000000..82639c344264
--- /dev/null
+++ b/www/manager6/sdn/microseg/GroupEdit.js
@@ -0,0 +1,30 @@
+Ext.define('PVE.sdn.microseg.GroupInputPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+
+ onlineHelp: 'pvesdn_microseg_group',
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ {
+ xtype: me.isCreate ? 'textfield' : 'displayfield',
+ name: 'id',
+ value: me.microsegId || '',
+ fieldLabel: gettext('Name'),
+ allowBlank: false,
+ maxLength: 32,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js
new file mode 100644
index 000000000000..4ec5467f61be
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleEdit.js
@@ -0,0 +1,158 @@
+Ext.define('PVE.sdn.microseg.RuleInputPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+
+ onlineHelp: 'pvesdn_microseg_rule',
+
+ initComponent: function () {
+ let me = this;
+
+ let matchItems = [
+ ['any', gettext('Any (shares a group)')],
+ ['all', gettext('All (has every group)')],
+ ['exact', gettext('Exact (same groups)')],
+ ['every', gettext('Every (has a group)')],
+ ];
+ let setEvery = (selector, every) => {
+ selector.setDisabled(every || !me.isCreate);
+ if (every) {
+ selector.setValue([]);
+ }
+ };
+
+ me.srcSelector = Ext.create({
+ xtype: 'pveSDNMicrosegGroupSelector',
+ name: 'src',
+ fieldLabel: gettext('Source Groups'),
+ multiSelect: true,
+ allowBlank: false,
+ includeUntagged: true,
+ disabled: !me.isCreate,
+ validator: () => {
+ let groups = me.srcSelector.getValue() || [];
+ let match = me.down('field[name=src-match]')?.getValue() ?? 'any';
+ if (
+ match !== 'any' &&
+ groups.includes('untagged') &&
+ groups.some((group) => group !== 'untagged')
+ ) {
+ return gettext(
+ "With 'all' or 'exact', 'untagged' cannot be combined with other groups",
+ );
+ }
+ return true;
+ },
+ });
+
+ me.dstSelector = Ext.create({
+ xtype: 'pveSDNMicrosegGroupSelector',
+ name: 'dst',
+ fieldLabel: gettext('Dest. Groups'),
+ multiSelect: true,
+ allowBlank: false,
+ includeUntagged: true,
+ disabled: !me.isCreate,
+ validator: () => {
+ let groups = me.dstSelector.getValue() || [];
+ if (groups.length === 1 && groups[0] === 'untagged') {
+ return gettext("'untagged' cannot be the only destination group");
+ }
+ return true;
+ },
+ });
+
+ me.column1 = [
+ me.srcSelector,
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'src-match',
+ fieldLabel: gettext('Source Match'),
+ value: 'any',
+ disabled: !me.isCreate,
+ comboItems: matchItems,
+ listeners: {
+ change: function (f, value) {
+ setEvery(me.srcSelector, value === 'every');
+ me.srcSelector.validate();
+ },
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'bidirectional',
+ fieldLabel: gettext('Both Directions'),
+ uncheckedValue: null,
+ disabled: !me.isCreate,
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext(
+ 'Also apply the rule with source and destination swapped.',
+ ),
+ },
+ },
+ ];
+
+ me.column2 = [
+ me.dstSelector,
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'dst-match',
+ fieldLabel: gettext('Dest. Match'),
+ value: 'any',
+ disabled: !me.isCreate,
+ comboItems: matchItems,
+ listeners: {
+ change: function (f, value) {
+ setEvery(me.dstSelector, value === 'every');
+ me.dstSelector.setIncludeUntagged(value === 'any');
+ me.dstSelector.validate();
+ },
+ },
+ },
+ {
+ 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, the most specific rules decide and a deny wins among equals.',
+ ),
+ },
+ },
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'allow',
+ fieldLabel: gettext('Action'),
+ value: 1,
+ comboItems: [
+ [1, gettext('Allow')],
+ [0, gettext('Deny')],
+ ],
+ },
+ ];
+
+ me.columnB = [
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ {
+ xtype: 'displayfield',
+ userCls: 'pmx-hint',
+ value: gettext(
+ 'Rules are stateless. A one-way rule needs a second rule for the replies.',
+ ),
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/RuleView.js b/www/manager6/sdn/microseg/RuleView.js
new file mode 100644
index 000000000000..57ca0eddc1c9
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleView.js
@@ -0,0 +1,242 @@
+Ext.define(
+ 'PVE.sdn.microseg.RuleView',
+ {
+ extend: 'PVE.sdn.microseg.BaseGrid',
+ alias: 'widget.pveSDNMicrosegRules',
+
+ title: gettext('Rules'),
+
+ baseurl: '/cluster/sdn/microseg/rule/',
+
+ features: [
+ {
+ ftype: 'grouping',
+ groupHeaderTpl: Ext.String.format(gettext('Priority {0}'), '{name:htmlEncode}'),
+ enableGroupingMenu: false,
+ },
+ ],
+
+ deriveRow: function (data) {
+ let me = this;
+ let sel = me.treeSelection;
+ let src = data.src || [];
+ let dst = data.dst || [];
+ let out = false;
+ let inbound = false;
+ if (sel?.mode === 'membership') {
+ let every = (match) => match === 'every' && !sel.untagged;
+ out = every(data['src-match']) || src.some((g) => sel.groups.has(g));
+ inbound = every(data['dst-match']) || dst.some((g) => sel.groups.has(g));
+ if (data.bidirectional && (out || inbound)) {
+ out = true;
+ inbound = true;
+ }
+ } else if (sel?.mode === 'group-set') {
+ out = sel.outbound.has(data.id);
+ inbound = sel.inbound.has(data.id);
+ }
+ return {
+ direction: out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '',
+ };
+ },
+
+ loadRows: function () {
+ let me = this;
+ let rows = (me.rules || []).map((data) => ({ ...data, ...me.deriveRow(data) }));
+ if (me.treeSelection) {
+ rows = rows.filter((row) => row.direction !== '');
+ }
+ me.getStore().loadData(rows);
+ },
+
+ setData: function (rules) {
+ let me = this;
+ me.rules = rules;
+ me.loadRows();
+ },
+
+ setTreeSelection: function (sel) {
+ let me = this;
+
+ let mode = sel?.mode;
+ me.treeSelection = mode
+ ? {
+ mode: mode,
+ untagged: sel.untagged,
+ groups: new Set(sel.groups),
+ outbound: sel.outbound,
+ inbound: sel.inbound,
+ }
+ : null;
+
+ let text = '';
+ if (sel?.scope) {
+ text = Ext.String.format(
+ gettext('Rules for {0}'),
+ Ext.String.htmlEncode(sel.scope),
+ );
+ }
+ me.down('#microsegScopeText').setText(text);
+
+ me.loadRows();
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let store = Ext.create('Ext.data.Store', {
+ model: 'pve-sdn-microseg-rule',
+ grouper: {
+ property: 'prio',
+ sortProperty: 'prio',
+ direction: 'DESC',
+ },
+ sorters: [{ property: 'id', direction: 'ASC' }],
+ });
+
+ let matchWords = {
+ any: gettext('any of'),
+ all: gettext('all of'),
+ exact: gettext('exactly'),
+ every: gettext('every'),
+ };
+
+ let renderPredicate = (match, groups) => {
+ if (match === 'every') {
+ return matchWords.every;
+ }
+ let list = groups || [];
+ let chips = list.map(PVE.Utils.render_sdnmicroseg_group).join('');
+ let kind = match || 'any';
+ if (kind !== 'any' || list.length > 1) {
+ return `${matchWords[kind]} ${chips}`;
+ }
+ return chips;
+ };
+
+ let allowIcon =
+ '<i class="fa fa-fw fa-check good" data-qtip="' + gettext('Allow') + '"></i>';
+ let denyIcon =
+ '<i class="fa fa-fw fa-times critical" data-qtip="' + gettext('Deny') + '"></i>';
+
+ 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 bothIcon =
+ '<i class="fa fa-fw fa-exchange" data-qtip="' +
+ gettext('Both, the selection is the source and the destination') +
+ '"></i>';
+ let arrowIcon = '<i class="fa fa-fw fa-long-arrow-right"></i>';
+ let bothWaysIcon =
+ '<i class="fa fa-fw fa-arrows-h" data-qtip="' +
+ gettext('Both directions') +
+ '"></i>';
+ let renderDirection = (value, rec) => {
+ if (value === 'both') {
+ return bothIcon;
+ }
+ if (value === 'out') {
+ return outIcon;
+ }
+ if (value === 'in') {
+ return inIcon;
+ }
+ return rec.data.bidirectional ? bothWaysIcon : arrowIcon;
+ };
+
+ let ruleName = (rec) => {
+ let arrow = rec.data.bidirectional ? '<->' : '->';
+ let src = PVE.Utils.format_sdnmicroseg_predicate(
+ rec.data['src-match'],
+ rec.data.src,
+ );
+ let dst = PVE.Utils.format_sdnmicroseg_predicate(
+ rec.data['dst-match'],
+ rec.data.dst,
+ );
+ return `${src} ${arrow} ${dst}`;
+ };
+
+ me.columns = [
+ {
+ text: '',
+ dataIndex: 'allow',
+ width: 40,
+ resizable: false,
+ renderer: (v) => (v ? allowIcon : denyIcon),
+ },
+ {
+ text: gettext('Source'),
+ dataIndex: 'src',
+ flex: 2,
+ renderer: (v, m, rec) => renderPredicate(rec.data['src-match'], v),
+ },
+ {
+ text: '',
+ dataIndex: 'direction',
+ width: 40,
+ resizable: false,
+ renderer: (v, m, rec) => renderDirection(v, rec),
+ },
+ {
+ text: gettext('Destination'),
+ dataIndex: 'dst',
+ flex: 2,
+ renderer: (v, m, rec) => renderPredicate(rec.data['dst-match'], v),
+ },
+ { xtype: 'pveSDNMicrosegStateColumn' },
+ {
+ text: gettext('Comment'),
+ dataIndex: 'comment',
+ flex: 1,
+ renderer: Ext.String.htmlEncode,
+ },
+ ];
+
+ Ext.apply(me, {
+ store: store,
+ viewConfig: {
+ getRowClass: PVE.Utils.sdnmicroseg_row_cls,
+ },
+ getRecordName: ruleName,
+ addButton: {
+ text: gettext('Add Rule'),
+ handler: () => me.openEdit('rule'),
+ },
+ tbar: [
+ '->',
+ {
+ xtype: 'tbtext',
+ itemId: 'microsegScopeText',
+ },
+ ],
+ });
+
+ me.callParent();
+ },
+ },
+ function () {
+ Ext.define('pve-sdn-microseg-rule', {
+ extend: 'Ext.data.Model',
+ fields: [
+ 'id',
+ 'type',
+ 'comment',
+ 'state',
+ 'src',
+ 'src-match',
+ 'dst',
+ 'dst-match',
+ 'prio',
+ 'allow',
+ 'bidirectional',
+ 'direction',
+ ],
+ });
+ },
+);
diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js
new file mode 100644
index 000000000000..e261e4944e81
--- /dev/null
+++ b/www/manager6/sdn/microseg/Tree.js
@@ -0,0 +1,249 @@
+Ext.define(
+ 'PVE.sdn.microseg.Tree',
+ {
+ extend: 'Ext.tree.Panel',
+ alias: 'widget.pveSDNMicrosegTree',
+ mixins: ['PVE.sdn.microseg.ViewMixin'],
+
+ baseurl: '/cluster/sdn/microseg/group/',
+
+ rootVisible: false,
+ animate: false,
+ border: false,
+
+ buildNodes: function (groups, assignments, groupSets) {
+ let guestDisplay = (vmid) => {
+ let store = PVE.data.ResourceStore;
+ let rec = store.getById(`qemu/${vmid}`) || store.getById(`lxc/${vmid}`);
+ if (!rec) {
+ return { text: `vm${vmid}`, iconCls: 'fa fa-fw fa-desktop' };
+ }
+ return {
+ text: PVE.Utils.getFormattedGuestIdentifier(vmid, rec.data.name),
+ iconCls: `fa-fw ${PVE.Utils.get_object_icon_class(rec.data.type, rec.data)}`,
+ };
+ };
+
+ groups.sort((a, b) => a.id.localeCompare(b.id));
+
+ let membership = PVE.sdn.microseg.Membership;
+ let rulesFor = membership.rulesFor(groupSets);
+ let nicGroupSet = membership.nicGroupSets(assignments, rulesFor);
+
+ let groupNics = {};
+ for (const [key, groupSet] of Object.entries(nicGroupSet)) {
+ let [vmid, nic] = key.split('/').map(Number);
+ groupSet.groups.forEach((g) => {
+ (groupNics[g] = groupNics[g] || []).push({ vmid: vmid, nic: nic });
+ });
+ }
+
+ let guests = {};
+ let guestInfo = (vmid) => {
+ if (!guests[vmid]) {
+ guests[vmid] = {
+ ...guestDisplay(vmid),
+ selection: membership.guestSelection(vmid, nicGroupSet, rulesFor),
+ };
+ }
+ return guests[vmid];
+ };
+
+ let nodes = [
+ {
+ id: 'all',
+ text: gettext('All Rules'),
+ iconCls: 'fa fa-fw fa-list',
+ leaf: true,
+ },
+ {
+ id: 'untagged',
+ text: 'untagged',
+ iconCls: 'fa fa-fw fa-ban',
+ microsegSelection: {
+ scope: 'untagged',
+ mode: 'membership',
+ untagged: true,
+ groups: ['untagged'],
+ },
+ leaf: true,
+ },
+ ];
+
+ for (const group of groups) {
+ let groupId = `g/${group.id}`;
+ let byVmid = {};
+ for (const { vmid, nic } of groupNics[group.id] || []) {
+ (byVmid[vmid] = byVmid[vmid] || []).push(nic);
+ }
+ let guestNodes = Object.keys(byVmid)
+ .map(Number)
+ .sort((a, b) => a - b)
+ .map((vmid) => {
+ let guestId = `${groupId}/m/${vmid}`;
+ let nics = byVmid[vmid].sort((a, b) => a - b);
+ let guest = guestInfo(vmid);
+ return {
+ id: guestId,
+ text: guest.text,
+ iconCls: guest.iconCls,
+ microsegSelection: guest.selection,
+ children: nics.map((nic) => {
+ let groupSet = nicGroupSet[`${vmid}/${nic}`];
+ return {
+ id: `${guestId}/n/${nic}`,
+ text: `net${nic}`,
+ microsegNote: groupSet.note,
+ iconCls: 'fa fa-fw fa-plug',
+ microsegSelection: {
+ scope: `vm${vmid}/net${nic} {${groupSet.groups.join(', ')}}`,
+ mode: 'group-set',
+ outbound: groupSet.rules.outbound,
+ inbound: groupSet.rules.inbound,
+ vmid: vmid,
+ nic: nic,
+ },
+ leaf: true,
+ };
+ }),
+ };
+ });
+
+ let count = (groupNics[group.id] || []).length;
+ nodes.push({
+ id: groupId,
+ text: group.id,
+ microsegNote: count ? `(${count})` : undefined,
+ iconCls: 'fa fa-fw fa-object-group',
+ microsegId: group.id,
+ microsegSelection: {
+ scope: group.id,
+ mode: 'membership',
+ groups: [group.id],
+ },
+ state: group.state,
+ leaf: !guestNodes.length,
+ children: guestNodes,
+ });
+ }
+ return nodes;
+ },
+
+ updateIdSpace: function (ids) {
+ let me = this;
+ let text = Ext.String.format(gettext('{0} of {1} IDs in use'), ids.used, ids.total);
+ let tip = ids.retired ? Ext.String.format(gettext('{0} retired IDs'), ids.retired) : '';
+ me.down('#microsegIdSpace').update(
+ `<span data-qtip="${Ext.String.htmlEncode(tip)}">${Ext.String.htmlEncode(text)}</span>`,
+ );
+ },
+
+ setData: function (groups, assignments, groupSets, idSpace) {
+ let me = this;
+ 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();
+
+ me.updateIdSpace(idSpace);
+ me.setRootNode({
+ expanded: true,
+ children: me.buildNodes(groups, assignments, groupSets),
+ });
+ 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);
+ }
+ }
+ },
+
+ statics: {
+ editable: (node) => !!node.data.microsegId && node.data.state !== 'deleted',
+ },
+
+ header: {
+ items: [
+ {
+ xtype: 'component',
+ itemId: 'microsegIdSpace',
+ cls: 'pve-microseg-muted pve-microseg-id-space',
+ },
+ ],
+ },
+
+ viewConfig: {
+ getRowClass: PVE.Utils.sdnmicroseg_row_cls,
+ toggleOnDblClick: false,
+ },
+
+ store: {
+ type: 'tree',
+ model: 'pve-sdn-microseg-tree',
+ root: { expanded: true, children: [] },
+ },
+
+ columns: [
+ {
+ xtype: 'treecolumn',
+ text: gettext('Name'),
+ dataIndex: 'text',
+ flex: 1,
+ renderer: (v, m, rec) => {
+ let html = Ext.String.htmlEncode(v);
+ if (rec.data.microsegNote) {
+ html += ` <span class="pve-microseg-muted">${Ext.String.htmlEncode(rec.data.microsegNote)}</span>`;
+ }
+ return html;
+ },
+ },
+ { xtype: 'pveSDNMicrosegStateColumn' },
+ ],
+
+ tbar: [
+ {
+ text: gettext('Add Group'),
+ handler: 'addGroup',
+ },
+ '-',
+ {
+ xtype: 'proxmoxButton',
+ text: gettext('Edit'),
+ parentXType: 'treepanel',
+ disabled: true,
+ enableFn: (node) => PVE.sdn.microseg.Tree.editable(node),
+ handler: 'editGroup',
+ },
+ {
+ xtype: 'proxmoxStdRemoveButton',
+ parentXType: 'treepanel',
+ enableFn: (node) => PVE.sdn.microseg.Tree.editable(node),
+ getUrl: function (node) {
+ return this.up('treepanel').baseurl + node.data.microsegId;
+ },
+ getRecordName: (node) => node.data.microsegId,
+ callback: 'reload',
+ },
+ {
+ text: gettext('Reload'),
+ handler: 'reload',
+ },
+ ],
+ },
+ function () {
+ Ext.define('pve-sdn-microseg-tree', {
+ extend: 'Ext.data.TreeModel',
+ fields: ['microsegId', 'microsegNote', 'microsegSelection', 'state'],
+ });
+ },
+);
--
2.47.3
next prev parent reply other threads:[~2026-10-27 9:03 UTC|newest]
Thread overview: 46+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-27 8:54 [PATCH access-control/cluster/container/docs/manager/network/proxmox{-ebpf,-ve-rs,-perl-rs}/qemu-server v3 00/45] sdn: add microsegmentation support Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ebpf v3 01/45] build: keep the build directory out of the BPF objects Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ebpf v3 02/45] policy: add the enforcement subsystem for guest interfaces Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ebpf v3 03/45] policy: add a check for non-first fragments Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 04/45] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 05/45] ve-config: sdn: microseg: add identity allocation for group sets Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 06/45] ve-config: sdn: microseg: add config types Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 07/45] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 08/45] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 09/45] ve-config: sdn: microseg: narrow rules by protocol and ports Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 10/45] ve-config: sdn: microseg: add group hierarchy Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 11/45] ve-config: firewall: expose protocol, icmp type and code numbers Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 12/45] ve-config: sdn: microseg: add the desired state Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 13/45] microseg: add a crate that applies the policy to a node's guest NICs Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-ve-rs v3 14/45] microseg: add a rule option that matches non-first fragments Hannes Laimer
2026-10-27 8:54 ` [PATCH pve-cluster v3 15/45] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-10-27 8:54 ` [PATCH pve-access-control v3 16/45] permissions: add ACL path for SDN microsegmentation Hannes Laimer
2026-10-27 8:54 ` [PATCH proxmox-perl-rs v3 17/45] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-10-27 8:55 ` [PATCH proxmox-perl-rs v3 18/45] pve-rs: sdn: microseg: bind the data plane Hannes Laimer
2026-10-27 8:55 ` [PATCH proxmox-perl-rs v3 19/45] pve-rs: sdn: microseg: check the data plane limits on render Hannes Laimer
2026-10-27 8:55 ` [PATCH proxmox-perl-rs v3 20/45] pve-rs: debian: fire the pve-microseg-update trigger on upgrade Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 21/45] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 22/45] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 23/45] sdn: dry-run: show pending microseg changes Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 24/45] sdn: microseg: enforce the policy on guest NICs Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 25/45] sdn: microseg: add tag matcher Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 26/45] sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 27/45] sdn: microseg: add an update for a running NIC Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 28/45] sdn: microseg: expose protocol, port and macro rule options Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 29/45] sdn: microseg: allow nesting groups under a parent Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-network v3 30/45] sdn: microseg: expose the fragments rule option Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 31/45] network: apply the microseg policy on reload Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 32/45] pve-sdn-commit: apply the microseg policy at boot Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 33/45] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-10-27 8:55 ` Hannes Laimer [this message]
2026-10-27 8:55 ` [PATCH pve-manager v3 35/45] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 36/45] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 37/45] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 38/45] ui: sdn: microseg: add narrowing to the rule form Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 39/45] ui: sdn: microseg: show and edit the group hierarchy Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 40/45] ui: sdn: microseg: add fragments option to the rule form Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-manager v3 41/45] ui: sdn: microseg: add a guest tab with its groups and rules Hannes Laimer
2026-10-27 8:55 ` [PATCH qemu-server v3 42/45] network: update the microseg enforcement on a tag or name change Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-container v3 43/45] lxc: update the microseg enforcement on a tag or hostname change Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-docs v3 44/45] sdn: zones: document the VXLAN-GBP option Hannes Laimer
2026-10-27 8:55 ` [PATCH pve-docs v3 45/45] sdn: add microsegmentation section 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=20261027085527.702738-35-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