From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 34FCC1FF146 for ; Tue, 09 Jun 2026 15:26:57 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 97FDB12927; Tue, 9 Jun 2026 15:26:17 +0200 (CEST) From: Hannes Laimer 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 Message-ID: <20260609132522.235917-12-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260609132522.235917-1-h.laimer@proxmox.com> References: <20260609132522.235917-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: 1781011484031 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.084 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment 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: EPATLDPMCQ72RZSBJNPJ2QV5UVMDYRSH X-Message-ID-Hash: EPATLDPMCQ72RZSBJNPJ2QV5UVMDYRSH 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: Signed-off-by: Hannes Laimer --- 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} ${nic}`; + if (rec.data.state === 'deleted') { + return `${label}`; + } + 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 ``; + }, + }, + { + header: gettext('Source'), + flex: 1, + dataIndex: 'src', + renderer: (value, md, rec) => { + if (!eff(rec, 'src')) { + return '' + gettext('unstamped') + ''; + } + 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')) + ? ' ' + gettext('allow') + : ' ' + gettext('deny'), + }, + { + header: gettext('Inherited'), + width: 150, + renderer: (value, md, rec) => { + let group = matched(rec); + if (!group || group === me.ownGroup) { + return ''; + } + return ( + '' + + Ext.String.format(gettext('from {0}'), Ext.String.htmlEncode(group)) + + '' + ); + }, + }, + { + 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