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 CE8D01FF0AF for ; Fri, 06 Nov 2026 02:00:59 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 03EC9216E6; Fri, 06 Nov 2026 01:52:04 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Date: Fri, 6 Nov 2026 00:31:36 +0100 Message-ID: <20261105233336.2178064-62-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926181713 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.290 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) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: AUI2KO7YYCNNRTJOCV7GZEYIHVZTFWCC X-Message-ID-Hash: AUI2KO7YYCNNRTJOCV7GZEYIHVZTFWCC X-MailFrom: t.lamprecht@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: From: Dietmar Maurer The LUN inventory cannot explain iSCSI connections. Add a Node > SAN child view for targets, portals, sessions, and initiator node records. Keep disconnected targets visible and preserve selection and expansion across reloads. Include the initiator IQN for array access rules and negotiated session parameters. Offer rescan, logout, and removal of logged-out records with Sys.Modify on "/", explaining when configured storage will reconnect. Show a missing-package message rather than hiding the view without open-iscsi. Signed-off-by: Dietmar Maurer [TL: adapt the message to the separate SAN child view and missing-package explanation; distinguish recovering and unknown sessions, mask the initial listing, report copy failures, explain storage-owned logins in the logout confirmation, bounded to storages visible to the user, encode target and initiator names and keep the session-only renderers with the view] Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 1 + www/manager6/SANUtils.js | 18 + www/manager6/Utils.js | 22 ++ www/manager6/node/Config.js | 23 +- www/manager6/node/ISCSITargets.js | 615 ++++++++++++++++++++++++++++++ 5 files changed, 672 insertions(+), 7 deletions(-) create mode 100644 www/manager6/node/ISCSITargets.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 2e1b23fed..3b8780790 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -245,6 +245,7 @@ JSSRC= \ node/CmdMenu.js \ node/Config.js \ node/Directory.js \ + node/ISCSITargets.js \ node/LVM.js \ node/LVMThin.js \ node/SANLuns.js \ diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js index 25c733263..b48ad969d 100644 --- a/www/manager6/SANUtils.js +++ b/www/manager6/SANUtils.js @@ -194,4 +194,22 @@ Ext.define('PVE.SANUtils', { }[value] ?? Ext.htmlEncode(value) ); }, + + render_san_connection_state: function (value) { + if (!value) { + return ''; + } + let display = { + connected: { icon: 'good fa-check-circle', label: gettext('Connected') }, + reconnecting: { + icon: 'warning fa-exclamation-circle', + label: gettext('Reconnecting'), + }, + 'not-connected': { icon: 'faded fa-times-circle', label: gettext('Not connected') }, + unknown: { icon: 'faded fa-question-circle', label: gettext('Unknown') }, + }[value]; + let icon = display?.icon ?? 'faded fa-question-circle'; + let label = display?.label ?? value; + return ` ${Ext.htmlEncode(label)}`; + }, }); diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index bc5171712..68bb7b913 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1122,6 +1122,28 @@ Ext.define('PVE.Utils', { return Ext.String.htmlEncode(result); }, + copyToClipboard: function (text) { + let fail = (err) => + Ext.Msg.alert( + gettext('Error'), + Ext.htmlEncode( + Ext.String.format( + gettext('Copying to the clipboard failed: {0}'), + `${err}`, + ), + ), + ); + // navigator.clipboard only exists in a secure context + if (!navigator.clipboard?.writeText) { + Ext.Msg.alert( + gettext('Error'), + gettext('The browser does not allow copying to the clipboard on this page.'), + ); + return; + } + navigator.clipboard.writeText(text).catch(fail); + }, + render_serverity: function (value) { return PVE.Utils.log_severity_hash[value] || value; }, diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js index 3a84e96f6..d4a292d68 100644 --- a/www/manager6/node/Config.js +++ b/www/manager6/node/Config.js @@ -360,14 +360,23 @@ Ext.define('PVE.node.Config', { ); } - // the san-luns scan accepts either privilege + // the san-luns and iscsi reads accept either privilege if (caps.nodes['Sys.Audit'] || caps.storage['Datastore.Allocate']) { - me.items.push({ - xtype: 'pveSANLunList', - title: 'SAN', - itemId: 'san', - iconCls: 'fa fa-cubes', - }); + me.items.push( + { + xtype: 'pveSANLunList', + title: 'SAN', + itemId: 'san', + iconCls: 'fa fa-cubes', + }, + { + xtype: 'pveISCSITargetTree', + title: 'iSCSI', + itemId: 'san-iscsi', + iconCls: 'fa fa-bullseye', + groups: ['san'], + }, + ); } if (caps.nodes['Sys.Audit']) { diff --git a/www/manager6/node/ISCSITargets.js b/www/manager6/node/ISCSITargets.js new file mode 100644 index 000000000..a5621ba98 --- /dev/null +++ b/www/manager6/node/ISCSITargets.js @@ -0,0 +1,615 @@ +Ext.define('PVE.node.ISCSISessionParams', { + extend: 'Proxmox.grid.ObjectGrid', + xtype: 'pveISCSISessionParams', + + viewConfig: { + enableTextSelection: true, + }, + + statics: { + renderSessionState: function (value) { + let state = + { + 'logged-in': 'connected', + recovering: 'reconnecting', + 'not-connected': 'not-connected', + }[value] ?? (value ? 'unknown' : undefined); + return PVE.SANUtils.render_san_connection_state(state); + }, + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (!me.sessionData) { + throw 'no session specified'; + } + + let session = me.sessionData; + let seconds = (value) => (value === undefined || value === null ? '' : `${value} s`); + + me.url = `/api2/json/nodes/${me.nodename}/iscsi/session-params`; + me.extraParams = { 'session-id': session['session-id'] }; + + // identity rows from the list record, shown before the load completes + me.rows = { + target: { header: gettext('Target'), defaultValue: session.target }, + 'session-id': { + header: gettext('Session ID'), + defaultValue: session['session-id'], + }, + portal: { header: gettext('Portal'), defaultValue: session.portal }, + transport: { + header: gettext('Transport'), + defaultValue: session.transport, + renderer: PVE.SANUtils.render_san_transport, + }, + health: { + header: gettext('Status'), + defaultValue: session.health, + renderer: PVE.node.ISCSISessionParams.renderSessionState, + }, + 'kernel-state': { + header: gettext('Kernel State'), + defaultValue: session['kernel-state'], + }, + 'header-digest': { header: gettext('Header Digest') }, + 'data-digest': { header: gettext('Data Digest') }, + 'max-recv-data-segment-length': { + header: gettext('Max Receive Segment'), + renderer: Proxmox.Utils.render_size, + }, + 'max-xmit-data-segment-length': { + header: gettext('Max Transmit Segment'), + renderer: Proxmox.Utils.render_size, + }, + 'first-burst-length': { + header: gettext('First Burst Length'), + renderer: Proxmox.Utils.render_size, + }, + 'max-burst-length': { + header: gettext('Max Burst Length'), + renderer: Proxmox.Utils.render_size, + }, + 'max-outstanding-r2t': { header: gettext('Max Outstanding R2T') }, + 'immediate-data': { header: gettext('Immediate Data') }, + 'initial-r2t': { header: gettext('Initial R2T') }, + 'recovery-timeout': { header: gettext('Recovery Timeout'), renderer: seconds }, + 'abort-timeout': { header: gettext('Abort Timeout'), renderer: seconds }, + 'lu-reset-timeout': { header: gettext('LUN Reset Timeout'), renderer: seconds }, + 'tgt-reset-timeout': { header: gettext('Target Reset Timeout'), renderer: seconds }, + }; + + me.callParent(); + + me.rstore.load(); + }, +}); + +Ext.define('PVE.node.ISCSITargetTree', { + extend: 'Ext.tree.Panel', + xtype: 'pveISCSITargetTree', + + stateful: true, + stateId: 'grid-node-iscsi-target', + + onlineHelp: 'storage_open_iscsi', + + rootVisible: false, + + viewConfig: { + emptyText: gettext('No iSCSI targets found'), + }, + + selModel: { allowDeselect: true }, + + viewModel: { + data: { + initiatorName: '', + initiatorText: '', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + buildChildren: function (rows, collapsedTargets) { + let targets = {}; + for (const row of rows) { + targets[row.target] ??= []; + targets[row.target].push(row); + } + return Object.keys(targets) + .sort() + .map((target) => { + let portals = targets[target]; + // Failed and free sessions still have IDs, so derive connectivity from health. + let countHealth = (health) => portals.filter((p) => p.health === health).length; + let connected = countHealth('logged-in'); + let sessions = portals.filter((p) => p['session-id'] !== undefined).length; + return { + text: target, + target: target, + isTarget: true, + iconCls: 'fa fa-bullseye', + expanded: !collapsedTargets[target], + connectedCount: connected, + recoveringCount: countHealth('recovering'), + unknownCount: countHealth('unknown'), + sessionCount: sessions, + totalPortals: portals.length, + 'used-by-storage': portals[0]['used-by-storage'], + children: portals.map((p) => ({ + text: p.portal, + isTarget: false, + leaf: true, + iconCls: 'fa fa-link', + target: p.target, + portal: p.portal, + 'session-id': p['session-id'], + transport: p.transport, + health: p.health, + 'kernel-state': p['kernel-state'], + 'used-by-storage': p['used-by-storage'], + })), + }; + }); + }, + + // the selection key survives the tree rebuild on reload + nodeKey: function (node) { + return node.get('isTarget') + ? `target:${node.get('target')}` + : `portal:${node.get('target')}\0${node.get('portal')}`; + }, + + // until the first listing, keep the load mask instead of the empty text + dataPending: true, + + reloadData: function () { + let me = this; + let view = me.getView(); + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/iscsi/target`, + method: 'GET', + waitMsgTarget: view, + failure: function (response) { + me.dataPending = false; + Proxmox.Utils.setErrorMask(view, response.htmlStatus); + }, + success: function (response) { + me.dataPending = false; + Proxmox.Utils.setErrorMask(view, false); + let prev = view.getSelection()[0]; + let key = prev ? me.nodeKey(prev) : undefined; + let collapsedTargets = {}; + view.getRootNode().eachChild((n) => { + if (!n.isExpanded()) { + collapsedTargets[n.get('target')] = true; + } + }); + view.setRootNode({ + expanded: true, + children: me.buildChildren(response.result.data, collapsedTargets), + }); + if (key) { + let node = view + .getRootNode() + .findChildBy((n) => me.nodeKey(n) === key, me, true); + if (node) { + view.getSelectionModel().select(node); + } + } + }, + }); + }, + + onReload: function () { + this.getView().reload(); + }, + + onRescan: function () { + let me = this; + let view = me.getView(); + let rec = view.getSelection()[0]; + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/iscsi/rescan`, + method: 'POST', + params: { 'session-id': rec.get('session-id') }, + waitMsgTarget: view, + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: () => view.reload(), + }); + }, + + // the storage that logs in again on its next activation; unknown if the list cannot be + // read + withReloginStorage: function (target, callback) { + let view = this.getView(); + Proxmox.Utils.API2Request({ + url: '/storage', + method: 'GET', + params: { type: 'iscsi' }, + failure: () => callback(undefined, true), + success: function (response) { + let entry = (response.result.data || []).find( + (scfg) => + scfg.target === target && + !scfg.disable && + (!scfg.nodes || `${scfg.nodes}`.split(',').includes(view.nodename)), + ); + callback(entry?.storage, false); + }, + }); + }, + + onLogout: function () { + let me = this; + let view = me.getView(); + let rec = view.getSelection()[0]; + me.withReloginStorage(rec.get('target'), function (storage, unknown) { + let msg = Ext.htmlEncode( + Ext.String.format( + gettext('Log out of the session on portal "{0}"?'), + rec.get('portal'), + ), + ); + if (storage) { + msg += `

${Ext.htmlEncode( + Ext.String.format( + gettext('Storage "{0}" logs in again within seconds.'), + storage, + ), + )}`; + } else if (!unknown) { + msg += `

${Ext.htmlEncode(gettext('No storage you can access will log in to this target again.'))}`; + } + Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) { + if (btn !== 'yes') { + return; + } + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/iscsi/session-logout`, + method: 'POST', + params: { 'session-id': rec.get('session-id') }, + waitMsgTarget: view, + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: () => view.reload(), + }); + }); + }); + }, + + onRemove: function () { + let me = this; + let view = me.getView(); + let rec = view.getSelection()[0]; + let params = { target: rec.get('target') }; + let msg; + if (rec.get('isTarget')) { + msg = Ext.String.format( + gettext('Remove all records of target "{0}"?'), + rec.get('target'), + ); + } else { + params.portal = rec.get('portal'); + msg = Ext.String.format( + gettext('Remove the record of target "{0}", portal "{1}"?'), + rec.get('target'), + rec.get('portal'), + ); + } + let target = rec.get('isTarget') ? rec : rec.parentNode; + let sessionsLeft = !!target?.get('sessionCount'); + me.withReloginStorage(rec.get('target'), function (storage) { + let text = Ext.htmlEncode(msg); + if (storage) { + let note = sessionsLeft + ? gettext( + 'Storage "{0}" uses this target. The record comes back with its next discovery, for example after a reboot.', + ) + : gettext( + 'Storage "{0}" uses this target and discovers it again within seconds.', + ); + text += `

${Ext.htmlEncode(Ext.String.format(note, storage))}`; + } + Ext.Msg.confirm(gettext('Confirm'), text, function (btn) { + if (btn !== 'yes') { + return; + } + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/iscsi/target?${Ext.Object.toQueryString(params)}`, + method: 'DELETE', + waitMsgTarget: view, + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: () => view.reload(), + }); + }); + }); + }, + + showInfo: function (rec) { + let view = this.getView(); + if (rec.get('session-id') === undefined) { + return; + } + Ext.create('Ext.window.Window', { + title: gettext('Session Parameters'), + modal: true, + width: 500, + height: 600, + resizable: true, + layout: 'fit', + items: [ + { + xtype: 'pveISCSISessionParams', + nodename: view.nodename, + sessionData: rec.data, + }, + ], + }).show(); + }, + + onInfo: function () { + this.showInfo(this.getView().getSelection()[0]); + }, + + onItemDblClick: function (tree, rec) { + this.showInfo(rec); + }, + + onCopyInitiator: function () { + let name = this.getViewModel().get('initiatorName'); + if (name) { + PVE.Utils.copyToClipboard(name); + } + }, + + setInitiator: function (status) { + let me = this; + let view = me.getView(); + if (!status.supported) { + me.dataPending = false; + view.setLoading(false); + Proxmox.Utils.setErrorMask( + view, + gettext('open-iscsi is not installed on this node.'), + ); + return; + } + Proxmox.Utils.setErrorMask(view, false); + let name = status['initiator-name']; + let text = name || Proxmox.Utils.unknownText; + if (status['initiator-alias']) { + text += ` (${status['initiator-alias']})`; + } + me.getViewModel().set({ + initiatorName: name || '', + initiatorText: text, + }); + view.iscsiSupported = true; + view.reload(); + }, + + loadStatus: function () { + let me = this; + let view = me.getView(); + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/iscsi/status`, + method: 'GET', + failure: function (response) { + me.dataPending = false; + view.setLoading(false); + Proxmox.Utils.setErrorMask(view, response.htmlStatus); + }, + success: (response) => me.setInitiator(response.result.data), + }); + }, + + init: function (view) { + let me = this; + if (!view.nodename) { + throw 'no node name specified'; + } + view.on( + 'afterrender', + function () { + if (me.dataPending) { + view.setLoading(true); + } + }, + me, + { single: true }, + ); + me.loadStatus(); + }, + }, + + store: { + type: 'tree', + fields: [ + 'text', + 'isTarget', + 'connectedCount', + 'recoveringCount', + 'unknownCount', + 'sessionCount', + 'totalPortals', + 'target', + 'portal', + 'session-id', + 'transport', + 'health', + 'kernel-state', + 'used-by-storage', + ], + root: { expanded: true, children: [] }, + }, + + // list only once the status confirms open-iscsi, until then ask for the status again + reload: function () { + if (this.iscsiSupported) { + this.getController().reloadData(); + } else { + this.getController().loadStatus(); + } + }, + + listeners: { + activate: 'onReload', + itemdblclick: 'onItemDblClick', + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Target'), + dataIndex: 'text', + flex: 3, + renderer: Ext.htmlEncode, + }, + { + text: gettext('Session ID'), + dataIndex: 'session-id', + width: 110, + hidden: true, + }, + { + text: gettext('Transport'), + dataIndex: 'transport', + width: 100, + renderer: PVE.SANUtils.render_san_transport, + }, + { + text: gettext('Status'), + dataIndex: 'health', + width: 150, + // rank by what it means for the LUNs: reconnecting, partly connected, unknown, not + // connected + renderer: function (value, metaData, rec) { + if (!rec.get('isTarget')) { + return PVE.node.ISCSISessionParams.renderSessionState(value); + } + let connected = rec.get('connectedCount'); + let total = rec.get('totalPortals'); + let icon = (cls) => ` `; + if (rec.get('recoveringCount')) { + let label = connected + ? Ext.String.format( + gettext('{0}/{1} connected, reconnecting'), + connected, + total, + ) + : gettext('Reconnecting'); + return icon('warning fa-exclamation-circle') + label; + } + if (connected === total) { + return PVE.SANUtils.render_san_connection_state('connected'); + } + if (connected > 0) { + return ( + icon('warning fa-exclamation-circle') + + Ext.String.format(gettext('{0}/{1} connected'), connected, total) + ); + } + if (rec.get('unknownCount')) { + return PVE.SANUtils.render_san_connection_state('unknown'); + } + return PVE.SANUtils.render_san_connection_state('not-connected'); + }, + }, + { + text: gettext('Storage'), + dataIndex: 'used-by-storage', + flex: 1, + renderer: (value, metaData, rec) => + rec.get('isTarget') ? Ext.htmlEncode(value ?? '') : '', + }, + ], + + initComponent: function () { + let me = this; + + me.nodename ??= me.pveSelNode?.data?.node; + + let caps = Ext.state.Manager.get('GuiCap'); + let hasSession = (rec) => !rec.get('isTarget') && rec.get('session-id') !== undefined; + + let items = [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'onReload', + }, + ]; + if (caps.dc['Sys.Modify']) { + items.push( + { + xtype: 'proxmoxButton', + text: gettext('Rescan'), + iconCls: 'fa fa-search', + disabled: true, + enableFn: hasSession, + handler: 'onRescan', + }, + { + xtype: 'proxmoxButton', + text: gettext('Logout'), + iconCls: 'fa fa-sign-out', + disabled: true, + dangerous: true, + enableFn: hasSession, + handler: 'onLogout', + }, + { + xtype: 'proxmoxButton', + text: gettext('Remove'), + iconCls: 'fa fa-trash-o', + disabled: true, + dangerous: true, + enableFn: (rec) => + rec.get('isTarget') ? rec.get('sessionCount') === 0 : !hasSession(rec), + handler: 'onRemove', + }, + ); + } + items.push( + { + xtype: 'proxmoxButton', + text: gettext('Details'), + iconCls: 'fa fa-info-circle', + disabled: true, + enableFn: hasSession, + handler: 'onInfo', + }, + '->', + { + xtype: 'displayfield', + fieldLabel: gettext('Initiator Name'), + labelWidth: 100, + htmlEncode: true, + bind: { + value: '{initiatorText}', + }, + }, + { + xtype: 'button', + iconCls: 'fa fa-clipboard', + tooltip: gettext('Copy to Clipboard'), + handler: 'onCopyInitiator', + bind: { + disabled: '{!initiatorName}', + }, + }, + ); + me.tbar = { + defaults: { parentXType: 'treepanel' }, + items: items, + }; + + me.callParent(); + }, +}); -- 2.47.3