From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 397E91FF0E0 for ; Thu, 09 Jul 2026 11:23:43 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2B38F2145F; Thu, 09 Jul 2026 11:22:04 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v2 20/27] ui: sdn: add microsegmentation panel Date: Thu, 9 Jul 2026 11:18:45 +0200 Message-ID: <20260709091852.538885-21-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com> References: <20260709091852.538885-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1783588748844 X-SPAM-LEVEL: Spam detection results: 1 AWL -0.733 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_MAILER 2 Automated Mailer Tag Left in Email SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: NCA74MGOOOT6KYHEJQYWVEJSD6H235OP X-Message-ID-Hash: NCA74MGOOOT6KYHEJQYWVEJSD6H235OP X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add a Microsegmentation panel under Datacenter > SDN. A policy grid of the rules sits next to a membership tree (group -> assignment -> guest -> NIC). Selecting a node in the tree filters the grid to the rules that govern that NIC as source or destination. The backend computes the matching, so the UI doesn't re-implement predicate semantics. Signed-off-by: Hannes Laimer --- www/manager6/Makefile | 9 + www/manager6/Utils.js | 24 ++ www/manager6/dc/Config.js | 8 + www/manager6/form/MicrosegGroupSelector.js | 77 +++++ www/manager6/form/MicrosegGuestSelector.js | 83 +++++ www/manager6/sdn/MicrosegView.js | 143 +++++++++ www/manager6/sdn/microseg/AssignmentEdit.js | 59 ++++ www/manager6/sdn/microseg/Base.js | 134 ++++++++ www/manager6/sdn/microseg/GroupEdit.js | 45 +++ www/manager6/sdn/microseg/PolicyView.js | 181 +++++++++++ www/manager6/sdn/microseg/RuleEdit.js | 104 ++++++ www/manager6/sdn/microseg/Tree.js | 339 ++++++++++++++++++++ 12 files changed, 1206 insertions(+) create mode 100644 www/manager6/form/MicrosegGroupSelector.js create mode 100644 www/manager6/form/MicrosegGuestSelector.js create mode 100644 www/manager6/sdn/MicrosegView.js create mode 100644 www/manager6/sdn/microseg/AssignmentEdit.js create mode 100644 www/manager6/sdn/microseg/Base.js create mode 100644 www/manager6/sdn/microseg/GroupEdit.js create mode 100644 www/manager6/sdn/microseg/PolicyView.js create mode 100644 www/manager6/sdn/microseg/RuleEdit.js create mode 100644 www/manager6/sdn/microseg/Tree.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index d4dd3f35..ba0fbc19 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -71,6 +71,8 @@ JSSRC= \ form/SDNControllerSelector.js \ form/SDNZoneSelector.js \ form/SDNVnetSelector.js \ + form/MicrosegGroupSelector.js \ + form/MicrosegGuestSelector.js \ form/SDNIpamSelector.js \ form/SDNDnsSelector.js \ form/ScsiHwSelector.js \ @@ -338,6 +340,13 @@ JSSRC= \ sdn/zones/VxlanEdit.js \ sdn/FabricsView.js \ sdn/FabricsContentView.js \ + sdn/microseg/Base.js \ + sdn/MicrosegView.js \ + sdn/microseg/Tree.js \ + sdn/microseg/GroupEdit.js \ + sdn/microseg/RuleEdit.js \ + sdn/microseg/AssignmentEdit.js \ + sdn/microseg/PolicyView.js \ sdn/fabrics/Common.js \ sdn/fabrics/InterfacePanel.js \ sdn/fabrics/NodeEdit.js \ diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 040b5ae0..6d7ac5e3 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -966,6 +966,30 @@ Ext.define('PVE.Utils', { }, }, + sdnmicrosegSchema: { + group: { + name: gettext('Group'), + ipanel: 'GroupInputPanel', + }, + rule: { + name: gettext('Rule'), + ipanel: 'RuleInputPanel', + }, + guestassignment: { + name: gettext('Guest Assignment'), + ipanel: 'GuestAssignmentInputPanel', + urlType: 'assignment', + }, + }, + + format_sdnmicroseg_type: function (value, md, record) { + var schema = PVE.Utils.sdnmicrosegSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + sdnipamSchema: { ipam: { name: 'ipam', diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index e1706636..e473f937 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -309,6 +309,14 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-road', itemId: 'sdnfabrics', }, + { + xtype: 'pveSDNMicroseg', + groups: ['sdn'], + title: gettext('Microseg'), + hidden: true, + iconCls: 'fa fa-tags', + itemId: 'sdnmicroseg', + }, { xtype: 'pveSDNRouteMaps', groups: ['sdn'], diff --git a/www/manager6/form/MicrosegGroupSelector.js b/www/manager6/form/MicrosegGroupSelector.js new file mode 100644 index 00000000..ca150457 --- /dev/null +++ b/www/manager6/form/MicrosegGroupSelector.js @@ -0,0 +1,77 @@ +Ext.define( + 'PVE.form.MicrosegGroupSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveMicrosegGroupSelector'], + + valueField: 'id', + displayField: 'id', + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-microseg-group', + sorters: { + property: 'id', + direction: 'ASC', + }, + }); + + if (me.includeUntagged) { + store.on('load', function (loaded) { + if (!loaded.getById('untagged')) { + loaded.add({ + id: 'untagged', + type: 'group', + mark: 0, + comment: gettext('no group / untagged'), + }); + } + }); + } + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('Group'), + sortable: true, + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Mark'), + sortable: true, + dataIndex: 'mark', + width: 70, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + flex: 2, + renderer: Ext.String.htmlEncode, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-microseg-group', { + extend: 'Ext.data.Model', + fields: ['id', 'type', 'mark', 'comment'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/microseg/group', + }, + idProperty: 'id', + }); + }, +); diff --git a/www/manager6/form/MicrosegGuestSelector.js b/www/manager6/form/MicrosegGuestSelector.js new file mode 100644 index 00000000..19c4bcc9 --- /dev/null +++ b/www/manager6/form/MicrosegGuestSelector.js @@ -0,0 +1,83 @@ +Ext.define( + 'PVE.form.MicrosegGuestSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveMicrosegGuestSelector'], + + valueField: 'vmid', + displayField: 'display', + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-microseg-guest', + sorters: { + property: 'vmid', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: 'VMID', + sortable: true, + dataIndex: 'vmid', + width: 80, + }, + { + header: gettext('Name'), + sortable: true, + dataIndex: 'name', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + { + header: gettext('Type'), + dataIndex: 'type', + width: 70, + }, + { + header: gettext('Node'), + dataIndex: 'node', + width: 100, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-microseg-guest', { + extend: 'Ext.data.Model', + fields: [ + 'vmid', + 'name', + 'node', + 'type', + 'status', + { + name: 'display', + convert: function (value, rec) { + let name = rec.get('name'); + let vmid = rec.get('vmid'); + return name ? `${vmid} (${name})` : `${vmid}`; + }, + }, + ], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/resources?type=vm', + }, + idProperty: 'vmid', + }); + }, +); diff --git a/www/manager6/sdn/MicrosegView.js b/www/manager6/sdn/MicrosegView.js new file mode 100644 index 00000000..e0ac5b76 --- /dev/null +++ b/www/manager6/sdn/MicrosegView.js @@ -0,0 +1,143 @@ +Ext.define('PVE.sdn.MicrosegGrid', { + extend: 'Ext.grid.GridPanel', + xtype: 'pveSDNMicrosegGrid', + + border: false, + + initComponent: function () { + let me = this; + + if (!me.microsegType) { + throw 'no microsegType given'; + } + + let store = Ext.create('Ext.data.Store', { + model: 'pve-sdn-microseg', + proxy: { + type: 'proxmox', + url: `/api2/json/cluster/sdn/microseg/${me.microsegType}`, + extraParams: { pending: 1 }, + }, + sorters: me.storeSorters ?? 'id', + }); + + let reload = () => store.load(); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let openEdit = (type, microsegId) => + Ext.create('PVE.sdn.microseg.BaseEdit', { + type: type, + microsegId: microsegId, + autoShow: true, + listeners: { destroy: reload }, + }); + + let run_editor = () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + openEdit(rec.data.type, rec.data.id); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: `/cluster/sdn/microseg/${me.microsegType}/`, + callback: reload, + }); + + let set_button_status = () => { + let rec = sm.getSelection()[0]; + if (!rec || rec.data.state === 'deleted') { + edit_btn.disable(); + remove_btn.disable(); + } + }; + + let addButton = me.addMenu + ? { + text: me.addText || gettext('Add'), + menu: { + items: me.addMenu.map((type) => ({ + text: PVE.Utils.format_sdnmicroseg_type(type), + handler: () => openEdit(type), + })), + }, + } + : { + text: me.addText || gettext('Add'), + handler: () => openEdit(me.microsegType), + }; + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + addButton, + edit_btn, + remove_btn, + { + xtype: 'proxmoxButton', + text: gettext('Reload'), + selModel: false, + handler: reload, + }, + ], + listeners: { + itemdblclick: run_editor, + selectionchange: set_button_status, + activate: reload, + }, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.MicrosegView', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNMicroseg', + + onlineHelp: 'pvesdn_config_microseg', + + layout: 'border', + + initComponent: function () { + let me = this; + + let policyGrid = Ext.create('PVE.sdn.microseg.PolicyView', { + region: 'center', + }); + + let tree = Ext.create('PVE.sdn.MicrosegTree', { + region: 'west', + title: gettext('Groups'), + width: 400, + minWidth: 250, + split: true, + listeners: { + microsegselect: (sel) => policyGrid.setSelection(sel), + }, + }); + + Ext.apply(me, { + items: [tree, policyGrid], + listeners: { + activate: () => { + tree.reloadTree(); + policyGrid.getStore().load(); + }, + }, + }); + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js new file mode 100644 index 00000000..2a9c5cce --- /dev/null +++ b/www/manager6/sdn/microseg/AssignmentEdit.js @@ -0,0 +1,59 @@ +Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', { + extend: 'PVE.panel.SDNMicrosegBase', + + onlineHelp: 'pvesdn_microseg_assignment', + + autoId: true, + + onGetValues: function (values) { + let me = this; + if (me.isCreate) { + values.type = me.type; + } + if (values.iface === '' || values.iface === null || values.iface === undefined) { + delete values.iface; + } + return me.callParent([values]); + }, + + initComponent: function () { + let me = this; + + me.items = [ + { + xtype: 'pveMicrosegGuestSelector', + name: 'vmid', + fieldLabel: gettext('Guest'), + allowBlank: false, + disabled: !me.isCreate, + }, + { + xtype: 'proxmoxintegerfield', + name: 'iface', + fieldLabel: gettext('Network interface'), + minValue: 0, + maxValue: 31, + allowBlank: true, + emptyText: gettext('All NICs'), + disabled: !me.isCreate, + }, + { + xtype: 'pveMicrosegGroupSelector', + name: 'groups', + fieldLabel: gettext('Groups'), + multiSelect: true, + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + maxLength: 256, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js new file mode 100644 index 00000000..6e226ce2 --- /dev/null +++ b/www/manager6/sdn/microseg/Base.js @@ -0,0 +1,134 @@ +Ext.define('pve-sdn-microseg', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'type', + 'comment', + 'digest', + 'state', + 'pending', + 'mark', + 'src', + 'src_match', + 'dst', + 'dst_match', + 'prio', + 'allow', + 'vmid', + 'iface', + 'groups', + 'direction', + ], + idProperty: 'id', +}); + +Ext.define('pve-sdn-microseg-tree', { + extend: 'Ext.data.TreeModel', + fields: [ + 'microsegEditType', + 'microsegId', + 'microsegGroups', + 'microsegScope', + 'microsegMode', + 'microsegOutbound', + 'microsegInbound', + 'state', + 'pending', + ], +}); + +Ext.define('PVE.panel.SDNMicrosegBase', { + extend: 'Proxmox.panel.InputPanel', + + type: '', + + autoId: false, + idLabel: 'ID', + + onGetValues: function (values) { + let me = this; + + if (!me.isCreate) { + delete values.id; + } + + return values; + }, + + initComponent: function () { + let me = this; + + me.items = me.items ?? []; + if (!me.autoId) { + me.items.unshift({ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'id', + value: me.microsegId || '', + fieldLabel: me.idLabel, + allowBlank: false, + regex: /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,30}[a-zA-Z0-9]$/, + regexText: gettext( + 'must be 2-32 chars, start/end alphanumeric, may contain dashes and underscores', + ), + }); + } + + if (!me.isCreate) { + me.items.push({ xtype: 'hiddenfield', name: 'digest' }); + } + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.microseg.BaseEdit', { + extend: 'Proxmox.window.Edit', + + width: 400, + + initComponent: function () { + let me = this; + + me.isCreate = !me.microsegId; + + let schema = PVE.Utils.sdnmicrosegSchema[me.type]; + let urlType = schema.urlType || me.type; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/sdn/microseg/' + urlType; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/sdn/microseg/' + urlType + '/' + me.microsegId; + me.method = 'PUT'; + } + + let ipanel = Ext.create( + 'PVE.sdn.microseg.' + schema.ipanel, + Ext.apply( + { + type: me.type, + isCreate: me.isCreate, + microsegId: me.microsegId, + }, + me.panelConfig || {}, + ), + ); + + Ext.apply(me, { + subject: PVE.Utils.format_sdnmicroseg_type(me.type), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response) { + let values = response.result.data; + ipanel.setValues(values); + }, + }); + } + }, +}); diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js new file mode 100644 index 00000000..e4f5ed9a --- /dev/null +++ b/www/manager6/sdn/microseg/GroupEdit.js @@ -0,0 +1,45 @@ +Ext.define('PVE.sdn.microseg.GroupInputPanel', { + extend: 'PVE.panel.SDNMicrosegBase', + + onlineHelp: 'pvesdn_microseg_group', + + idLabel: gettext('Name'), + + onGetValues: function (values) { + let me = this; + if (me.isCreate && !values.mark) { + delete values.mark; + } + return me.callParent([values]); + }, + + initComponent: function () { + let me = this; + + me.items = [ + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + maxLength: 256, + deleteEmpty: !me.isCreate, + }, + ]; + + me.advancedItems = [ + { + xtype: 'proxmoxintegerfield', + name: 'mark', + fieldLabel: gettext('Mark'), + minValue: 1, + maxValue: 65535, + allowBlank: true, + disabled: !me.isCreate, + emptyText: gettext('auto'), + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/PolicyView.js b/www/manager6/sdn/microseg/PolicyView.js new file mode 100644 index 00000000..e9775ba1 --- /dev/null +++ b/www/manager6/sdn/microseg/PolicyView.js @@ -0,0 +1,181 @@ +Ext.define('PVE.sdn.microseg.PolicyView', { + extend: 'PVE.sdn.MicrosegGrid', + xtype: 'pveSDNMicrosegPolicyView', + + title: gettext('Policies'), + microsegType: 'rule', + addText: gettext('Add Rule'), + + storeSorters: [ + { property: 'prio', direction: 'DESC' }, + { property: 'id', direction: 'ASC' }, + ], + + setSelection: function (sel) { + let me = this; + + let scope = sel && sel.scope; + let mode = sel && sel.mode; + let title = gettext('Policies'); + if (scope) { + let fmt = + mode === 'identity' + ? gettext('Policies governing {0}') + : gettext('Policies referencing {0}'); + title = Ext.String.format(fmt, Ext.String.htmlEncode(scope)); + } + me.setTitle(title); + + let col = me.down('#microsegDirection'); + if (col) { + col.setVisible(mode === 'membership' || mode === 'identity'); + } + + if (mode === 'membership') { + let set = {}; + (sel.groups || []).forEach((g) => { + set[g] = true; + }); + me.filterByDirection((groups) => (groups || []).some((g) => set[g])); + } else if (mode === 'identity') { + let outSet = {}; + let inSet = {}; + (sel.outbound || []).forEach((r) => { + outSet[r] = true; + }); + (sel.inbound || []).forEach((r) => { + inSet[r] = true; + }); + me.filterByRuleId(outSet, inSet); + } else { + me.getStore().clearFilter(); + } + }, + + filterByDirection: function (matches) { + let store = this.getStore(); + store.clearFilter(true); + store.filterBy((rec) => { + let data = rec.data; + let out = matches(data.pending?.src ?? data.src ?? []); + let inbound = matches(data.pending?.dst ?? data.dst ?? []); + data.direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : ''; + return out || inbound; + }); + }, + + filterByRuleId: function (outSet, inSet) { + let store = this.getStore(); + store.clearFilter(true); + store.filterBy((rec) => { + let out = !!outSet[rec.data.id]; + let inbound = !!inSet[rec.data.id]; + rec.data.direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : ''; + return out || inbound; + }); + }, + + initComponent: function () { + let me = this; + + let renderPredicate = (match, groups) => { + let kind = match || 'any'; + let list = (groups || []).join(', '); + return Ext.String.htmlEncode(`${kind}(${list})`); + }; + + let outIcon = + ''; + let inIcon = + ''; + let renderDirection = (value) => { + if (value === 'both') { + return outIcon + inIcon; + } + if (value === 'out') { + return outIcon; + } + if (value === 'in') { + return inIcon; + } + return ''; + }; + + let strikeIfDeleted = (rec, html) => + rec.data.state === 'deleted' + ? `${html}` + : html; + + me.columns = [ + { + text: '', + itemId: 'microsegDirection', + dataIndex: 'direction', + width: 50, + resizable: false, + hidden: true, + renderer: renderDirection, + }, + { + text: gettext('Source'), + dataIndex: 'src', + flex: 2, + renderer: (v, m, rec) => + strikeIfDeleted( + rec, + renderPredicate( + rec.data.pending?.src_match ?? rec.data.src_match, + rec.data.pending?.src ?? v, + ), + ), + }, + { + text: gettext('Destination'), + dataIndex: 'dst', + flex: 2, + renderer: (v, m, rec) => + strikeIfDeleted( + rec, + renderPredicate( + rec.data.pending?.dst_match ?? rec.data.dst_match, + rec.data.pending?.dst ?? v, + ), + ), + }, + { + text: gettext('Action'), + dataIndex: 'allow', + width: 90, + renderer: (v, m, rec) => + strikeIfDeleted( + rec, + (rec.data.pending?.allow ?? v) ? gettext('Allow') : gettext('Deny'), + ), + }, + { + text: gettext('Priority'), + dataIndex: 'prio', + width: 90, + renderer: (v, m, rec) => strikeIfDeleted(rec, rec.data.pending?.prio ?? v ?? 0), + }, + { + text: gettext('Comment'), + dataIndex: 'comment', + flex: 1, + renderer: (v, m, rec) => PVE.Utils.render_sdn_pending(rec, v ?? '', 'comment'), + }, + { + text: gettext('State'), + dataIndex: 'state', + width: 100, + renderer: (v, m, rec) => PVE.Utils.render_sdn_pending_state(rec, v), + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js new file mode 100644 index 00000000..8b3749e8 --- /dev/null +++ b/www/manager6/sdn/microseg/RuleEdit.js @@ -0,0 +1,104 @@ +Ext.define('PVE.sdn.microseg.RuleInputPanel', { + extend: 'PVE.panel.SDNMicrosegBase', + + onlineHelp: 'pvesdn_microseg_rule', + + autoId: true, + + onGetValues: function (values) { + let me = this; + if (me.isCreate) { + values.src = me.srcSelector.getValue() || []; + values.dst = me.dstSelector.getValue() || []; + } else { + delete values.src; + delete values.dst; + delete values.src_match; + delete values.dst_match; + } + return me.callParent([values]); + }, + + initComponent: function () { + let me = this; + + let matchItems = [ + ['any', gettext('Any (intersects)')], + ['all', gettext('All (subset of)')], + ['exact', gettext('Exact (equals)')], + ]; + + me.srcSelector = Ext.create({ + xtype: 'pveMicrosegGroupSelector', + name: 'src', + fieldLabel: gettext('Source groups'), + multiSelect: true, + allowBlank: false, + includeUntagged: true, + disabled: !me.isCreate, + }); + + me.dstSelector = Ext.create({ + xtype: 'pveMicrosegGroupSelector', + name: 'dst', + fieldLabel: gettext('Destination groups'), + multiSelect: true, + allowBlank: false, + includeUntagged: true, + disabled: !me.isCreate, + }); + + me.items = [ + me.srcSelector, + { + xtype: 'proxmoxKVComboBox', + name: 'src_match', + fieldLabel: gettext('Source match'), + value: 'any', + disabled: !me.isCreate, + comboItems: matchItems, + }, + me.dstSelector, + { + xtype: 'proxmoxKVComboBox', + name: 'dst_match', + fieldLabel: gettext('Destination match'), + value: 'any', + disabled: !me.isCreate, + comboItems: matchItems, + }, + { + xtype: 'proxmoxintegerfield', + name: 'prio', + fieldLabel: gettext('Priority'), + minValue: 0, + maxValue: 65535, + value: 0, + allowBlank: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext('The highest priority wins. At a tie, a deny overrides an allow.'), + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'allow', + fieldLabel: gettext('Action'), + boxLabel: gettext('Allow (unchecked = deny)'), + checked: true, + uncheckedValue: 0, + inputValue: 1, + }, + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + maxLength: 256, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js new file mode 100644 index 00000000..9bfa7608 --- /dev/null +++ b/www/manager6/sdn/microseg/Tree.js @@ -0,0 +1,339 @@ +Ext.define('PVE.sdn.MicrosegTree', { + extend: 'Ext.tree.Panel', + xtype: 'pveSDNMicrosegTree', + + rootVisible: false, + animate: false, + border: false, + + initComponent: function () { + let me = this; + + // pending objects carry their staged values under `pending`; fall back to the applied value + let pv = (o, k) => o.pending?.[k] ?? o[k]; + + let assignmentLabel = (a) => { + let ifaceVal = pv(a, 'iface'); + let iface = + ifaceVal === undefined || ifaceVal === null || ifaceVal === '' + ? '' + : ` net${ifaceVal}`; + return `vm${pv(a, 'vmid')}${iface}`; + }; + + let buildNodes = (objects, identities) => { + let groups = (objects || []) + .filter((o) => o.type === 'group') + .sort((a, b) => a.id.localeCompare(b.id)); + let assignments = (objects || []).filter( + (o) => PVE.Utils.sdnmicrosegSchema[o.type]?.urlType === 'assignment', + ); + + let nicIdentity = {}; + assignments.forEach((a) => { + let aGroups = pv(a, 'groups') || []; + (a.realized || []).forEach((m) => { + (m.nics || []).forEach((nic) => { + let key = `${m.vmid}/${nic}`; + if (!nicIdentity[key]) { + nicIdentity[key] = {}; + } + aGroups.forEach((g) => { + nicIdentity[key][g] = true; + }); + }); + }); + }); + let identityGroups = (vmid, nic) => + Object.keys(nicIdentity[`${vmid}/${nic}`] || {}).sort(); + let guestIdentities = (vmid) => + Object.keys(nicIdentity) + .filter((key) => key.startsWith(`${vmid}/`)) + .map((key) => Object.keys(nicIdentity[key]).sort()); + + let classRules = {}; + (identities || []).forEach((cls) => { + classRules[(cls.groups || []).slice().sort().join(',')] = cls; + }); + let rulesFor = (identityList) => { + let out = {}; + let inb = {}; + identityList.forEach((groupSet) => { + let cls = classRules[groupSet.join(',')] || {}; + (cls.outbound || []).forEach((r) => { + out[r] = true; + }); + (cls.inbound || []).forEach((r) => { + inb[r] = true; + }); + }); + return { outbound: Object.keys(out), inbound: Object.keys(inb) }; + }; + + let nodes = [ + { + id: 'all', + text: gettext('All Policies'), + iconCls: 'fa fa-fw fa-list', + microsegMode: null, + microsegScope: null, + leaf: true, + }, + { + id: 'untagged', + text: 'untagged', + iconCls: 'fa fa-fw fa-ban', + microsegMode: 'membership', + microsegGroups: ['untagged'], + microsegScope: 'untagged', + leaf: true, + }, + ]; + + for (const group of groups) { + let groupId = `g/${group.id}`; + let assignNodes = assignments + .filter((a) => (pv(a, 'groups') || []).includes(group.id)) + .map((a) => { + let aGroups = pv(a, 'groups') || []; + let assignId = `${groupId}/a/${a.id}`; + let realized = a.realized || []; + let anyPending = realized.some((m) => (m.pending || []).length); + return { + id: assignId, + text: assignmentLabel(a), + iconCls: 'fa fa-fw fa-link', + microsegEditType: a.type, + microsegId: a.id, + microsegMode: 'membership', + microsegGroups: aGroups, + microsegScope: assignmentLabel(a), + state: a.state || (anyPending ? 'changed' : undefined), + pending: a.pending, + leaf: !realized.length, + children: realized.map((member) => { + let pendingNics = member.pending || []; + let guestRules = rulesFor(guestIdentities(member.vmid)); + return { + id: `${assignId}/m/${member.vmid}`, + text: `vm${member.vmid}`, + iconCls: 'fa fa-fw fa-desktop', + microsegMode: 'identity', + microsegOutbound: guestRules.outbound, + microsegInbound: guestRules.inbound, + microsegScope: `vm${member.vmid}`, + state: pendingNics.length ? 'new' : undefined, + leaf: !(member.nics || []).length, + children: (member.nics || []).map((nic) => { + let ig = identityGroups(member.vmid, nic); + let nicRules = rulesFor([ig]); + return { + id: `${assignId}/m/${member.vmid}/n/${nic}`, + text: `net${nic}`, + iconCls: 'fa fa-fw fa-plug', + microsegMode: 'identity', + microsegOutbound: nicRules.outbound, + microsegInbound: nicRules.inbound, + microsegScope: ig.length + ? `vm${member.vmid}/net${nic} {${ig.join(', ')}}` + : `vm${member.vmid}/net${nic}`, + state: pendingNics.includes(nic) ? 'new' : undefined, + leaf: true, + }; + }), + }; + }), + }; + }); + + nodes.push({ + id: groupId, + text: group.id, + iconCls: 'fa fa-fw fa-object-group', + microsegEditType: 'group', + microsegId: group.id, + microsegMode: 'membership', + microsegGroups: [group.id], + microsegScope: group.id, + state: group.state, + pending: group.pending, + leaf: !assignNodes.length, + children: assignNodes, + }); + } + return nodes; + }; + + let reload = () => { + let expanded = []; + let oldRoot = me.getRootNode(); + if (oldRoot) { + oldRoot.cascadeBy((node) => { + if (!node.isRoot() && node.isExpanded()) { + expanded.push(node.getId()); + } + }); + } + let selectedId = me.getSelection()?.[0]?.getId(); + + Proxmox.Utils.API2Request({ + url: '/cluster/sdn/microseg/all?pending=1', + method: 'GET', + failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus), + success: (response) => { + let data = response.result.data; + me.setRootNode({ + expanded: true, + children: buildNodes(data.objects, data.identities), + }); + let store = me.getStore(); + for (const id of expanded) { + store.getNodeById(id)?.expand(); + } + if (selectedId) { + let node = store.getNodeById(selectedId); + if (node) { + me.setSelection(node); + } + } + }, + }); + }; + + let openEdit = (type, microsegId) => + Ext.create('PVE.sdn.microseg.BaseEdit', { + type: type, + microsegId: microsegId, + autoShow: true, + listeners: { destroy: reload }, + }); + + let selected = () => me.getSelection()?.[0]; + + let editSelected = () => { + let node = selected(); + if (!node || !node.data.microsegEditType) { + return; + } + openEdit(node.data.microsegEditType, node.data.microsegId); + }; + + let removeSelected = () => { + let node = selected(); + if (!node || !node.data.microsegEditType) { + return; + } + let type = node.data.microsegEditType; + let id = node.data.microsegId; + let schema = PVE.Utils.sdnmicrosegSchema[type]; + let urlType = (schema && schema.urlType) || type; + Ext.Msg.confirm( + gettext('Confirm'), + Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${id}'`), + (btn) => { + if (btn !== 'yes') { + return; + } + Proxmox.Utils.API2Request({ + url: `/cluster/sdn/microseg/${urlType}/${encodeURIComponent(id)}`, + method: 'DELETE', + waitMsgTarget: me, + failure: (response) => + Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus), + callback: reload, + }); + }, + ); + }; + + let editButton = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + handler: editSelected, + }); + + let removeButton = new Proxmox.button.Button({ + text: gettext('Remove'), + disabled: true, + handler: removeSelected, + }); + + Ext.apply(me, { + store: Ext.create('Ext.data.TreeStore', { + model: 'pve-sdn-microseg-tree', + root: { expanded: true, children: [] }, + }), + columns: [ + { + xtype: 'treecolumn', + text: gettext('Group / Assignment'), + dataIndex: 'text', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + { + text: gettext('State'), + dataIndex: 'state', + width: 100, + renderer: (value, meta, rec) => PVE.Utils.render_sdn_pending_state(rec, value), + }, + ], + tbar: [ + { + text: gettext('Add Group'), + handler: () => openEdit('group'), + }, + { + text: gettext('Add Assignment'), + menu: { + items: Object.keys(PVE.Utils.sdnmicrosegSchema) + .filter( + (type) => + PVE.Utils.sdnmicrosegSchema[type].urlType === 'assignment', + ) + .map((type) => ({ + text: PVE.Utils.format_sdnmicroseg_type(type), + handler: () => openEdit(type), + })), + }, + }, + '-', + editButton, + removeButton, + { + text: gettext('Reload'), + handler: reload, + }, + ], + listeners: { + itemdblclick: editSelected, + selectionchange: () => { + let node = selected(); + let editable = !!( + node && + node.data.microsegEditType && + node.data.state !== 'deleted' + ); + editButton.setDisabled(!editable); + removeButton.setDisabled(!editable); + me.fireEvent( + 'microsegselect', + node + ? { + scope: node.data.microsegScope, + mode: node.data.microsegMode, + groups: node.data.microsegGroups, + outbound: node.data.microsegOutbound, + inbound: node.data.microsegInbound, + } + : null, + ); + }, + }, + }); + + me.reloadTree = reload; + + me.callParent(); + }, +}); -- 2.47.3