public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility
@ 2026-07-31 10:21 Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 01/27] diskmanage: collect disk transport type from lsblk Dietmar Maurer
                   ` (26 more replies)
  0 siblings, 27 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Adding remote storage currently starts with selecting a storage plugin
from the Add menu. The type-specific dialogs then assume that the
administrator already knows the Proxmox VE storage model and how the
selected backend maps to it.

This series adds a question-driven remote storage wizard. It first asks
which kind of storage to add, then separates connection, resource
selection, and common storage settings into focused steps. The existing
type-specific Add entries stay available for administrators who prefer
the direct workflow.

The wizard supports NFS, SMB/CIFS, iSCSI, Fibre Channel shared LVM, and
ZFS over iSCSI. The iSCSI shared LVM flow creates the connection storage
before selecting a logical unit number (LUN) and creating the LVM
storage. The Fibre Channel flow can initialize an unused SAN LUN or
reuse an existing volume group.

A dedicated NVMe over Fabrics wizard flow is not part of this RFC and
will be added in a follow-up. The SAN view already reports existing
NVMe-FC devices and native NVMe multipath state.

Multi-step submissions keep completed steps when a later request fails.
The wizard reports what it already created and how to finish the setup.
It does not try to roll back storage or volume groups automatically,
because doing so after a partial setup can be more destructive than
leaving the completed resource in place.

Device safety
-------------

Generic disk selectors currently also see remotely attached devices.
This makes it too easy to accidentally select a shared LUN for local
storage. The storage API now hides transports that unambiguously denote
remote attachment by default. Specialized callers can request them with
the new include-remote parameter; the widget-toolkit patch exposes the
corresponding includeRemoteDisks option.

This transport check is deliberately a guard, not proof that a device is
local or shared. It can diverge from that property in these cases:

- An exclusively assigned FC, FCoE, iSCSI, or NVMe fabrics LUN is safe
  for local use but is hidden by default. Explicit opt-in handles this
  false positive.
- SAS and missing or unknown transport information cannot distinguish a
  local disk from a shared attachment. They therefore remain visible to
  generic disk callers.
- The Fibre Channel wizard offers only FC and NVMe-FC devices by
  default. Ambiguous SAS devices require enabling the local/unknown
  transport list. FCoE and software-managed initiator transports stay
  excluded.

The SAN scan combines disk usage, multipath maps, LVM volume groups,
configured storage entries, and iSCSI sessions. It rechecks an unused
device under the disk lock before creating a volume group. It scans one
selected node, however, and cannot prove that the same LUN is visible on
every node selected for the shared storage. This is intentional: the
wizard calls out the visibility requirement and leaves its verification
to the administrator, like the existing shared storage workflows.

SAN visibility
--------------

The new node view lists SAN LUNs separately from local disks and shows
transport, usage, storage ownership, and multipath health. It reports
path state from multipathd and native NVMe multipath on a best-effort
basis.

The same view shows the iSCSI targets and sessions known to the node.
It exposes the initiator identity, connection state, negotiated session
parameters, rescan, logout, and stale node-record cleanup. The storage
plugin remains responsible for session login and activation.

Questions for this RFC
----------------------

1. Is a guided workflow next to the existing expert dialogs the right UI
   boundary?
2. Is default filtering with explicit remote-device opt-in an acceptable
   safety model given the transport predicate limitations above?
3. Does the node SAN view provide the right boundary for LUN, multipath,
   and iSCSI initiator observability?

Series order and dependencies
-----------------------------

Patches 01-14 add the pve-storage APIs and disk handling. Patch 15 adds
the widget-toolkit opt-in. Patches 16-27 add the pve-manager wizard and
node views. Apply them in that order.

pve-storage (01-14):

  diskmanage: collect disk transport type from lsblk
  diskmanage: add helper to list multipath devices
  diskmanage: qualify NVMe over fabrics transport
  disks: list: add include-remote parameter
  diskmanage: include iSCSI session devices in disk enumeration
  diskmanage: link multipath member disks to their map device
  iscsi: factor out session device map from device list
  api: scan: add san-luns method listing SAN LUN candidates
  disks: lvm: allow creating volume groups on multipath devices
  diskmanage: add helper querying multipath path state
  diskmanage: add helper querying NVMe native multipath path state
  api: scan: san-luns: report path state
  iscsi plugin: list sessions of all transports and capture transport
  api: add node-level iSCSI initiator target and session API

proxmox-widget-toolkit (15):

  disk selectors: allow opting into remote devices

pve-manager (16-27):

  ui: storage: allow switching the scan node of NFS/CIFS scan combos
  ui: storage: add guided remote storage wizard with NFS support
  ui: storage wizard: add SMB/CIFS support
  ui: storage wizard: add iSCSI support
  ui: storage wizard: add FC-attached SAN (shared LVM) support
  ui: storage wizard: add ZFS over iSCSI support
  ui: dc: storage: add remote storage wizard entry to the add menu
  ui: node: add SAN LUNs panel
  ui: san luns: show multipath path state
  api: nodes: add iSCSI initiator API endpoint
  pvenode: add iscsi commands
  ui: san luns: show iSCSI targets and sessions

Diffstat
--------

pve-storage:
  97 files changed, 2157 insertions(+), 52 deletions(-)

proxmox-widget-toolkit:
  2 files changed, 16 insertions(+)

pve-manager:
  17 files changed, 2447 insertions(+), 1 deletion(-)

-- 
2.47.3



^ permalink raw reply	[flat|nested] 28+ messages in thread

* [RFC pve-storage 01/27] diskmanage: collect disk transport type from lsblk
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 02/27] diskmanage: add helper to list multipath devices Dietmar Maurer
                   ` (25 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Expose which bus a disk is attached with (sata, sas, fc, nvme, usb)
in the disk list. This allows telling SAN-attached LUNs apart from
local disks, which the storage wizard needs when listing candidate
devices for a shared LVM volume group.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Disks.pm                          | 6 ++++++
 src/PVE/Diskmanage.pm                          | 6 +++++-
 src/test/disk_tests/sas/disklist_expected.json | 1 +
 src/test/disk_tests/sas/lsblk                  | 5 +++++
 4 files changed, 17 insertions(+), 1 deletion(-)
 create mode 100644 src/test/disk_tests/sas/lsblk

diff --git a/src/PVE/API2/Disks.pm b/src/PVE/API2/Disks.pm
index e707a9e..e64a391 100644
--- a/src/PVE/API2/Disks.pm
+++ b/src/PVE/API2/Disks.pm
@@ -135,6 +135,12 @@ __PACKAGE__->register_method({
                 serial => { type => 'string', optional => 1 },
                 wwn => { type => 'string', optional => 1 },
                 health => { type => 'string', optional => 1 },
+                transport => {
+                    type => 'string',
+                    description => 'The bus type the disk is attached with '
+                        . '(for example sata, sas, fc, nvme, usb).',
+                    optional => 1,
+                },
                 parent => {
                     type => 'string',
                     description => 'For partitions only. The device path of '
diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index d9ce0d0..c04408e 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -174,7 +174,7 @@ sub get_smart_data {
 }
 
 sub get_lsblk_info {
-    my $cmd = [$LSBLK, '--json', '-o', 'path,parttype,fstype'];
+    my $cmd = [$LSBLK, '--json', '-o', 'path,parttype,fstype,tran'];
     my $output = "";
     eval {
         run_command($cmd, outfunc => sub { $output .= "$_[0]\n"; });
@@ -191,6 +191,7 @@ sub get_lsblk_info {
             $_->{path} => {
                 parttype => $_->{parttype},
                 fstype => $_->{fstype},
+                tran => $_->{tran},
             }
         } @{$list}
     };
@@ -625,6 +626,9 @@ sub get_disks {
                 devpath => $devpath,
                 wearout => $wearout,
             };
+
+            my $transport = $lsblk_info->{$devpath}->{tran};
+            $disklist->{$dev}->{transport} = $transport if defined($transport);
             $disklist->{$dev}->{mounted} = 1 if exists $mounted->{$devpath};
 
             my $by_id_link = $data->{by_id_link};
diff --git a/src/test/disk_tests/sas/disklist_expected.json b/src/test/disk_tests/sas/disklist_expected.json
index a4a1c4c..26d0d17 100644
--- a/src/test/disk_tests/sas/disklist_expected.json
+++ b/src/test/disk_tests/sas/disklist_expected.json
@@ -13,6 +13,7 @@
 	"size" : 5120000,
 	"serial" : "SER2",
 	"wearout" : "N/A",
+	"transport" : "sas",
 	"by_id_link" : "/dev/disk/by-id/scsi-00000000000000000"
     }
 }
diff --git a/src/test/disk_tests/sas/lsblk b/src/test/disk_tests/sas/lsblk
new file mode 100644
index 0000000..cae9819
--- /dev/null
+++ b/src/test/disk_tests/sas/lsblk
@@ -0,0 +1,5 @@
+{
+   "blockdevices": [
+      {"path":"/dev/sda", "parttype":null, "fstype":null, "tran":"sas"}
+   ]
+}
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 02/27] diskmanage: add helper to list multipath devices
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 01/27] diskmanage: collect disk transport type from lsblk Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 03/27] diskmanage: qualify NVMe over fabrics transport Dietmar Maurer
                   ` (24 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Multipath mapped devices are deliberately not part of get_disks, whose
callers (node disk list, local storage creation, ceph OSDs) manage
local disks only. The storage wizard however needs to offer SAN LUNs,
which on production FC/SAS setups are usually multipath devices.

Aggregate vendor, model and transport from the member paths and reuse
the usage detection of get_disks, so callers can tell whether a LUN is
unused, already an LVM physical volume or otherwise occupied.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/Diskmanage.pm                         | 79 ++++++++++++++++++-
 src/test/disk_tests/multipath/disklist        |  9 +++
 .../multipath/disklist_expected.json          | 74 +++++++++++++++++
 src/test/disk_tests/multipath/dm-0/dm/name    |  1 +
 src/test/disk_tests/multipath/dm-0/dm/uuid    |  1 +
 src/test/disk_tests/multipath/dm-0/size       |  1 +
 src/test/disk_tests/multipath/dm-0_slaves     |  4 +
 src/test/disk_tests/multipath/dm-1/dm/name    |  1 +
 src/test/disk_tests/multipath/dm-1/dm/uuid    |  1 +
 src/test/disk_tests/multipath/dm-1/size       |  1 +
 src/test/disk_tests/multipath/dm-1_slaves     |  4 +
 src/test/disk_tests/multipath/dm-2/dm/name    |  1 +
 src/test/disk_tests/multipath/dm-2/dm/uuid    |  1 +
 src/test/disk_tests/multipath/dm-2/size       |  1 +
 src/test/disk_tests/multipath/dm-2_slaves     |  1 +
 src/test/disk_tests/multipath/dm-3/dm/name    |  1 +
 src/test/disk_tests/multipath/dm-3/dm/uuid    |  1 +
 src/test/disk_tests/multipath/dm-3/size       |  1 +
 src/test/disk_tests/multipath/dm-3_slaves     |  1 +
 src/test/disk_tests/multipath/dm-4/dm/name    |  1 +
 src/test/disk_tests/multipath/dm-4/dm/uuid    |  1 +
 src/test/disk_tests/multipath/dm-4/size       |  1 +
 src/test/disk_tests/multipath/dm-4_slaves     |  1 +
 src/test/disk_tests/multipath/lsblk           | 63 +++++++++++++++
 src/test/disk_tests/multipath/lvs             |  0
 src/test/disk_tests/multipath/mounts          |  1 +
 .../multipath/multipath_expected.json         | 59 ++++++++++++++
 src/test/disk_tests/multipath/partlist        |  0
 src/test/disk_tests/multipath/pvs             |  1 +
 .../disk_tests/multipath/sda/device/model     |  1 +
 .../disk_tests/multipath/sda/device/vendor    |  1 +
 .../disk_tests/multipath/sda/holders/holder   |  1 +
 .../disk_tests/multipath/sda/queue/rotational |  1 +
 src/test/disk_tests/multipath/sda/size        |  1 +
 src/test/disk_tests/multipath/sda_udevadm     | 18 +++++
 .../disk_tests/multipath/sdb/device/model     |  1 +
 .../disk_tests/multipath/sdb/device/vendor    |  1 +
 .../disk_tests/multipath/sdb/holders/holder   |  1 +
 .../disk_tests/multipath/sdb/queue/rotational |  1 +
 src/test/disk_tests/multipath/sdb/size        |  1 +
 src/test/disk_tests/multipath/sdb_udevadm     | 18 +++++
 .../disk_tests/multipath/sdc/device/model     |  1 +
 .../disk_tests/multipath/sdc/device/vendor    |  1 +
 .../disk_tests/multipath/sdc/holders/holder   |  1 +
 .../disk_tests/multipath/sdc/queue/rotational |  1 +
 src/test/disk_tests/multipath/sdc/size        |  1 +
 src/test/disk_tests/multipath/sdc_udevadm     | 18 +++++
 .../disk_tests/multipath/sdd/device/model     |  1 +
 .../disk_tests/multipath/sdd/device/vendor    |  1 +
 .../disk_tests/multipath/sdd/holders/holder   |  1 +
 .../disk_tests/multipath/sdd/queue/rotational |  1 +
 src/test/disk_tests/multipath/sdd/size        |  1 +
 src/test/disk_tests/multipath/sdd_udevadm     | 18 +++++
 src/test/disk_tests/multipath/zpool           |  1 +
 src/test/disklist_test.pm                     | 49 +++++++++++-
 55 files changed, 452 insertions(+), 2 deletions(-)
 create mode 100644 src/test/disk_tests/multipath/disklist
 create mode 100644 src/test/disk_tests/multipath/disklist_expected.json
 create mode 100644 src/test/disk_tests/multipath/dm-0/dm/name
 create mode 100644 src/test/disk_tests/multipath/dm-0/dm/uuid
 create mode 100644 src/test/disk_tests/multipath/dm-0/size
 create mode 100644 src/test/disk_tests/multipath/dm-0_slaves
 create mode 100644 src/test/disk_tests/multipath/dm-1/dm/name
 create mode 100644 src/test/disk_tests/multipath/dm-1/dm/uuid
 create mode 100644 src/test/disk_tests/multipath/dm-1/size
 create mode 100644 src/test/disk_tests/multipath/dm-1_slaves
 create mode 100644 src/test/disk_tests/multipath/dm-2/dm/name
 create mode 100644 src/test/disk_tests/multipath/dm-2/dm/uuid
 create mode 100644 src/test/disk_tests/multipath/dm-2/size
 create mode 100644 src/test/disk_tests/multipath/dm-2_slaves
 create mode 100644 src/test/disk_tests/multipath/dm-3/dm/name
 create mode 100644 src/test/disk_tests/multipath/dm-3/dm/uuid
 create mode 100644 src/test/disk_tests/multipath/dm-3/size
 create mode 100644 src/test/disk_tests/multipath/dm-3_slaves
 create mode 100644 src/test/disk_tests/multipath/dm-4/dm/name
 create mode 100644 src/test/disk_tests/multipath/dm-4/dm/uuid
 create mode 100644 src/test/disk_tests/multipath/dm-4/size
 create mode 100644 src/test/disk_tests/multipath/dm-4_slaves
 create mode 100644 src/test/disk_tests/multipath/lsblk
 create mode 100644 src/test/disk_tests/multipath/lvs
 create mode 100644 src/test/disk_tests/multipath/mounts
 create mode 100644 src/test/disk_tests/multipath/multipath_expected.json
 create mode 100644 src/test/disk_tests/multipath/partlist
 create mode 100644 src/test/disk_tests/multipath/pvs
 create mode 100644 src/test/disk_tests/multipath/sda/device/model
 create mode 100644 src/test/disk_tests/multipath/sda/device/vendor
 create mode 100644 src/test/disk_tests/multipath/sda/holders/holder
 create mode 100644 src/test/disk_tests/multipath/sda/queue/rotational
 create mode 100644 src/test/disk_tests/multipath/sda/size
 create mode 100644 src/test/disk_tests/multipath/sda_udevadm
 create mode 100644 src/test/disk_tests/multipath/sdb/device/model
 create mode 100644 src/test/disk_tests/multipath/sdb/device/vendor
 create mode 100644 src/test/disk_tests/multipath/sdb/holders/holder
 create mode 100644 src/test/disk_tests/multipath/sdb/queue/rotational
 create mode 100644 src/test/disk_tests/multipath/sdb/size
 create mode 100644 src/test/disk_tests/multipath/sdb_udevadm
 create mode 100644 src/test/disk_tests/multipath/sdc/device/model
 create mode 100644 src/test/disk_tests/multipath/sdc/device/vendor
 create mode 100644 src/test/disk_tests/multipath/sdc/holders/holder
 create mode 100644 src/test/disk_tests/multipath/sdc/queue/rotational
 create mode 100644 src/test/disk_tests/multipath/sdc/size
 create mode 100644 src/test/disk_tests/multipath/sdc_udevadm
 create mode 100644 src/test/disk_tests/multipath/sdd/device/model
 create mode 100644 src/test/disk_tests/multipath/sdd/device/vendor
 create mode 100644 src/test/disk_tests/multipath/sdd/holders/holder
 create mode 100644 src/test/disk_tests/multipath/sdd/queue/rotational
 create mode 100644 src/test/disk_tests/multipath/sdd/size
 create mode 100644 src/test/disk_tests/multipath/sdd_udevadm
 create mode 100644 src/test/disk_tests/multipath/zpool

diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index c04408e..ed5181f 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -174,7 +174,7 @@ sub get_smart_data {
 }
 
 sub get_lsblk_info {
-    my $cmd = [$LSBLK, '--json', '-o', 'path,parttype,fstype,tran'];
+    my $cmd = [$LSBLK, '--json', '-o', 'path,parttype,fstype,tran,pttype'];
     my $output = "";
     eval {
         run_command($cmd, outfunc => sub { $output .= "$_[0]\n"; });
@@ -192,6 +192,7 @@ sub get_lsblk_info {
                 parttype => $_->{parttype},
                 fstype => $_->{fstype},
                 tran => $_->{tran},
+                pttype => $_->{pttype},
             }
         } @{$list}
     };
@@ -771,6 +772,82 @@ sub get_disks {
     return $disklist;
 }
 
+# Lists multipath mapped devices with information aggregated from their member paths, keyed by
+# their kernel device name (dm-X). These are deliberately not returned by get_disks, as its
+# callers manage local disks only.
+sub get_multipath_disks {
+    my $mounted = mounted_blockdevs();
+    my $lsblk_info = get_lsblk_info();
+    my $zfshash = get_zfs_devices($lsblk_info);
+    my $lvmhash = get_lvm_devices($lsblk_info);
+
+    my $disklist = {};
+
+    dir_glob_foreach(
+        '/sys/block',
+        'dm-\d+',
+        sub {
+            my ($dev) = @_;
+            my $sysdir = "/sys/block/$dev";
+
+            my $uuid = file_read_firstline("$sysdir/dm/uuid") // return;
+            return if $uuid !~ m/^mpath-(\S+)$/;
+            my $wwid = $1;
+
+            my $name = file_read_firstline("$sysdir/dm/name") // return;
+            my $devpath = "/dev/mapper/$name";
+            my $resolved_devpath = abs_path($devpath);
+            my @devpaths = ($devpath);
+            push @devpaths, $resolved_devpath
+                if defined($resolved_devpath) && $resolved_devpath ne $devpath;
+
+            my $size = get_sysdir_size($sysdir) or return;
+
+            my $slaves = [];
+            dir_glob_foreach("$sysdir/slaves", '[^.].*', sub { push @$slaves, $_[0]; });
+            @$slaves = sort @$slaves;
+
+            my ($vendor, $model, $transport) = ('unknown', 'unknown', undef);
+            if (my $first = $slaves->[0]) {
+                if (my $sysdata = get_sysdir_info("/sys/block/$first")) {
+                    $vendor = $sysdata->{vendor};
+                    $model = $sysdata->{model};
+                }
+                $transport = $lsblk_info->{"/dev/$first"}->{tran};
+            }
+
+            my $used;
+            $used = 'LVM' if grep { $lvmhash->{$_} } @devpaths;
+            $used //= 'ZFS' if grep { $zfshash->{$_} } @devpaths;
+            my $info = {};
+            for my $path (@devpaths) {
+                $info = $lsblk_info->{$path} // next;
+                last;
+            }
+            $used //= "$info->{fstype}" if defined($info->{fstype});
+            $used //= 'mounted' if grep { $mounted->{$_} } @devpaths;
+            $used //= 'partitions'
+                if defined($info->{pttype}) && $info->{pttype} ne '';
+            # partition mappings and other stacked devices show up as holders
+            $used //= 'Device Mapper' if !dir_is_empty("$sysdir/holders");
+
+            $disklist->{$dev} = {
+                devpath => $devpath,
+                size => $size,
+                vendor => $vendor,
+                model => $model,
+                wwn => $wwid,
+                paths => scalar($slaves->@*),
+                slaves => [map { "/dev/$_" } $slaves->@*],
+            };
+            $disklist->{$dev}->{transport} = $transport if defined($transport);
+            $disklist->{$dev}->{used} = $used if $used;
+        },
+    );
+
+    return $disklist;
+}
+
 sub get_partnum {
     my ($part_path) = @_;
 
diff --git a/src/test/disk_tests/multipath/disklist b/src/test/disk_tests/multipath/disklist
new file mode 100644
index 0000000..9293589
--- /dev/null
+++ b/src/test/disk_tests/multipath/disklist
@@ -0,0 +1,9 @@
+sda
+sdb
+sdc
+sdd
+dm-0
+dm-1
+dm-2
+dm-3
+dm-4
diff --git a/src/test/disk_tests/multipath/disklist_expected.json b/src/test/disk_tests/multipath/disklist_expected.json
new file mode 100644
index 0000000..a8e74fa
--- /dev/null
+++ b/src/test/disk_tests/multipath/disklist_expected.json
@@ -0,0 +1,74 @@
+{
+    "sda": {
+        "devpath": "/dev/sda",
+        "size": 10737418240,
+        "vendor": "SANVEND",
+        "model": "SANMODEL",
+        "serial": "SSERa",
+        "wwn": "0x600140500000000a",
+        "gpt": 0,
+        "rpm": -1,
+        "type": "unknown",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000a",
+        "transport": "fc",
+        "used": "Device Mapper"
+    },
+    "sdb": {
+        "devpath": "/dev/sdb",
+        "size": 10737418240,
+        "vendor": "SANVEND",
+        "model": "SANMODEL",
+        "serial": "SSERb",
+        "wwn": "0x600140500000000b",
+        "gpt": 0,
+        "rpm": -1,
+        "type": "unknown",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000b",
+        "transport": "fc",
+        "used": "Device Mapper"
+    },
+    "sdc": {
+        "devpath": "/dev/sdc",
+        "size": 10737418240,
+        "vendor": "SANVEND",
+        "model": "SANMODEL",
+        "serial": "SSERc",
+        "wwn": "0x600140500000000c",
+        "gpt": 0,
+        "rpm": -1,
+        "type": "unknown",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000c",
+        "transport": "fc",
+        "used": "Device Mapper"
+    },
+    "sdd": {
+        "devpath": "/dev/sdd",
+        "size": 10737418240,
+        "vendor": "SANVEND",
+        "model": "SANMODEL",
+        "serial": "SSERd",
+        "wwn": "0x600140500000000d",
+        "gpt": 0,
+        "rpm": -1,
+        "type": "unknown",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000d",
+        "transport": "fc",
+        "used": "Device Mapper"
+    }
+}
diff --git a/src/test/disk_tests/multipath/dm-0/dm/name b/src/test/disk_tests/multipath/dm-0/dm/name
new file mode 100644
index 0000000..0ed057a
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-0/dm/name
@@ -0,0 +1 @@
+mpatha
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/dm-0/dm/uuid b/src/test/disk_tests/multipath/dm-0/dm/uuid
new file mode 100644
index 0000000..094955a
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-0/dm/uuid
@@ -0,0 +1 @@
+mpath-3600140500000000000000000000000001
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/dm-0/size b/src/test/disk_tests/multipath/dm-0/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-0/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/dm-0_slaves b/src/test/disk_tests/multipath/dm-0_slaves
new file mode 100644
index 0000000..1637315
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-0_slaves
@@ -0,0 +1,4 @@
+.
+..
+sda
+sdb
diff --git a/src/test/disk_tests/multipath/dm-1/dm/name b/src/test/disk_tests/multipath/dm-1/dm/name
new file mode 100644
index 0000000..f70ac3d
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-1/dm/name
@@ -0,0 +1 @@
+mpathb
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/dm-1/dm/uuid b/src/test/disk_tests/multipath/dm-1/dm/uuid
new file mode 100644
index 0000000..7e8567b
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-1/dm/uuid
@@ -0,0 +1 @@
+mpath-3600140500000000000000000000000002
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/dm-1/size b/src/test/disk_tests/multipath/dm-1/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-1/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/dm-1_slaves b/src/test/disk_tests/multipath/dm-1_slaves
new file mode 100644
index 0000000..cde08b3
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-1_slaves
@@ -0,0 +1,4 @@
+.
+..
+sdc
+sdd
diff --git a/src/test/disk_tests/multipath/dm-2/dm/name b/src/test/disk_tests/multipath/dm-2/dm/name
new file mode 100644
index 0000000..4770de4
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-2/dm/name
@@ -0,0 +1 @@
+mpathc
diff --git a/src/test/disk_tests/multipath/dm-2/dm/uuid b/src/test/disk_tests/multipath/dm-2/dm/uuid
new file mode 100644
index 0000000..2d607ea
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-2/dm/uuid
@@ -0,0 +1 @@
+mpath-3600140500000000000000000000000003
diff --git a/src/test/disk_tests/multipath/dm-2/size b/src/test/disk_tests/multipath/dm-2/size
new file mode 100644
index 0000000..14faeec
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-2/size
@@ -0,0 +1 @@
+20971520
diff --git a/src/test/disk_tests/multipath/dm-2_slaves b/src/test/disk_tests/multipath/dm-2_slaves
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-2_slaves
@@ -0,0 +1 @@
+
diff --git a/src/test/disk_tests/multipath/dm-3/dm/name b/src/test/disk_tests/multipath/dm-3/dm/name
new file mode 100644
index 0000000..a98ef69
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-3/dm/name
@@ -0,0 +1 @@
+mpathd
diff --git a/src/test/disk_tests/multipath/dm-3/dm/uuid b/src/test/disk_tests/multipath/dm-3/dm/uuid
new file mode 100644
index 0000000..75f25b8
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-3/dm/uuid
@@ -0,0 +1 @@
+mpath-3600140500000000000000000000000004
diff --git a/src/test/disk_tests/multipath/dm-3/size b/src/test/disk_tests/multipath/dm-3/size
new file mode 100644
index 0000000..14faeec
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-3/size
@@ -0,0 +1 @@
+20971520
diff --git a/src/test/disk_tests/multipath/dm-3_slaves b/src/test/disk_tests/multipath/dm-3_slaves
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-3_slaves
@@ -0,0 +1 @@
+
diff --git a/src/test/disk_tests/multipath/dm-4/dm/name b/src/test/disk_tests/multipath/dm-4/dm/name
new file mode 100644
index 0000000..e084e8f
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-4/dm/name
@@ -0,0 +1 @@
+mpathe
diff --git a/src/test/disk_tests/multipath/dm-4/dm/uuid b/src/test/disk_tests/multipath/dm-4/dm/uuid
new file mode 100644
index 0000000..0ca7875
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-4/dm/uuid
@@ -0,0 +1 @@
+mpath-3600140500000000000000000000000005
diff --git a/src/test/disk_tests/multipath/dm-4/size b/src/test/disk_tests/multipath/dm-4/size
new file mode 100644
index 0000000..14faeec
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-4/size
@@ -0,0 +1 @@
+20971520
diff --git a/src/test/disk_tests/multipath/dm-4_slaves b/src/test/disk_tests/multipath/dm-4_slaves
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/test/disk_tests/multipath/dm-4_slaves
@@ -0,0 +1 @@
+
diff --git a/src/test/disk_tests/multipath/lsblk b/src/test/disk_tests/multipath/lsblk
new file mode 100644
index 0000000..91f7457
--- /dev/null
+++ b/src/test/disk_tests/multipath/lsblk
@@ -0,0 +1,63 @@
+{
+   "blockdevices": [
+      {
+         "path": "/dev/sda",
+         "parttype": null,
+         "fstype": null,
+         "tran": "fc"
+      },
+      {
+         "path": "/dev/sdb",
+         "parttype": null,
+         "fstype": null,
+         "tran": "fc"
+      },
+      {
+         "path": "/dev/sdc",
+         "parttype": null,
+         "fstype": null,
+         "tran": "fc"
+      },
+      {
+         "path": "/dev/sdd",
+         "parttype": null,
+         "fstype": null,
+         "tran": "fc"
+      },
+      {
+         "path": "/dev/mapper/mpatha",
+         "parttype": null,
+         "fstype": null,
+         "tran": null,
+         "pttype": null
+      },
+      {
+         "path": "/dev/mapper/mpathb",
+         "parttype": null,
+         "fstype": null,
+         "tran": null,
+         "pttype": null
+      },
+      {
+         "path": "/dev/mapper/mpathc",
+         "parttype": null,
+         "fstype": null,
+         "tran": null,
+         "pttype": "gpt"
+      },
+      {
+         "path": "/dev/dm-3",
+         "parttype": null,
+         "fstype": null,
+         "tran": null,
+         "pttype": null
+      },
+      {
+         "path": "/dev/dm-4",
+         "parttype": null,
+         "fstype": null,
+         "tran": null,
+         "pttype": null
+      }
+   ]
+}
diff --git a/src/test/disk_tests/multipath/lvs b/src/test/disk_tests/multipath/lvs
new file mode 100644
index 0000000..e69de29
diff --git a/src/test/disk_tests/multipath/mounts b/src/test/disk_tests/multipath/mounts
new file mode 100644
index 0000000..06ada27
--- /dev/null
+++ b/src/test/disk_tests/multipath/mounts
@@ -0,0 +1 @@
+/dev/mapper/mpathe /mnt
diff --git a/src/test/disk_tests/multipath/multipath_expected.json b/src/test/disk_tests/multipath/multipath_expected.json
new file mode 100644
index 0000000..62e6e1c
--- /dev/null
+++ b/src/test/disk_tests/multipath/multipath_expected.json
@@ -0,0 +1,59 @@
+{
+    "dm-0": {
+        "devpath": "/dev/mapper/mpatha",
+        "size": 10737418240,
+        "vendor": "SANVEND",
+        "model": "SANMODEL",
+        "wwn": "3600140500000000000000000000000001",
+        "paths": 2,
+        "slaves": [
+            "/dev/sda",
+            "/dev/sdb"
+        ],
+        "transport": "fc"
+    },
+    "dm-1": {
+        "devpath": "/dev/mapper/mpathb",
+        "size": 10737418240,
+        "vendor": "SANVEND",
+        "model": "SANMODEL",
+        "wwn": "3600140500000000000000000000000002",
+        "paths": 2,
+        "slaves": [
+            "/dev/sdc",
+            "/dev/sdd"
+        ],
+        "transport": "fc",
+        "used": "LVM"
+    },
+    "dm-2": {
+        "devpath": "/dev/mapper/mpathc",
+        "size": 10737418240,
+        "vendor": "unknown",
+        "model": "unknown",
+        "wwn": "3600140500000000000000000000000003",
+        "paths": 0,
+        "slaves": [],
+        "used": "partitions"
+    },
+    "dm-3": {
+        "devpath": "/dev/mapper/mpathd",
+        "size": 10737418240,
+        "vendor": "unknown",
+        "model": "unknown",
+        "wwn": "3600140500000000000000000000000004",
+        "paths": 0,
+        "slaves": [],
+        "used": "ZFS"
+    },
+    "dm-4": {
+        "devpath": "/dev/mapper/mpathe",
+        "size": 10737418240,
+        "vendor": "unknown",
+        "model": "unknown",
+        "wwn": "3600140500000000000000000000000005",
+        "paths": 0,
+        "slaves": [],
+        "used": "mounted"
+    }
+}
diff --git a/src/test/disk_tests/multipath/partlist b/src/test/disk_tests/multipath/partlist
new file mode 100644
index 0000000..e69de29
diff --git a/src/test/disk_tests/multipath/pvs b/src/test/disk_tests/multipath/pvs
new file mode 100644
index 0000000..9c2f9b9
--- /dev/null
+++ b/src/test/disk_tests/multipath/pvs
@@ -0,0 +1 @@
+  /dev/dm-1
diff --git a/src/test/disk_tests/multipath/sda/device/model b/src/test/disk_tests/multipath/sda/device/model
new file mode 100644
index 0000000..2f50f3f
--- /dev/null
+++ b/src/test/disk_tests/multipath/sda/device/model
@@ -0,0 +1 @@
+SANMODEL
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sda/device/vendor b/src/test/disk_tests/multipath/sda/device/vendor
new file mode 100644
index 0000000..ead8944
--- /dev/null
+++ b/src/test/disk_tests/multipath/sda/device/vendor
@@ -0,0 +1 @@
+SANVEND
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sda/holders/holder b/src/test/disk_tests/multipath/sda/holders/holder
new file mode 100644
index 0000000..9255a92
--- /dev/null
+++ b/src/test/disk_tests/multipath/sda/holders/holder
@@ -0,0 +1 @@
+dm-holder
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sda/queue/rotational b/src/test/disk_tests/multipath/sda/queue/rotational
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/disk_tests/multipath/sda/queue/rotational
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sda/size b/src/test/disk_tests/multipath/sda/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/sda/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sda_udevadm b/src/test/disk_tests/multipath/sda_udevadm
new file mode 100644
index 0000000..7bd58b4
--- /dev/null
+++ b/src/test/disk_tests/multipath/sda_udevadm
@@ -0,0 +1,18 @@
+P: /devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sda
+N: sda
+S: disk/by-id/scsi-36001405000000000000000000000000a
+E: DEVLINKS=/dev/disk/by-id/scsi-36001405000000000000000000000000a
+E: DEVNAME=/dev/sda
+E: DEVPATH=/devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sda
+E: DEVTYPE=disk
+E: ID_BUS=scsi
+E: ID_MODEL=SANMODEL
+E: ID_SCSI=1
+E: ID_SERIAL=SANSERIALa
+E: ID_SERIAL_SHORT=SSERa
+E: ID_TYPE=disk
+E: ID_VENDOR=SANVEND
+E: ID_WWN=0x600140500000000a
+E: MAJOR=8
+E: MINOR=0
+E: SUBSYSTEM=block
diff --git a/src/test/disk_tests/multipath/sdb/device/model b/src/test/disk_tests/multipath/sdb/device/model
new file mode 100644
index 0000000..2f50f3f
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdb/device/model
@@ -0,0 +1 @@
+SANMODEL
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdb/device/vendor b/src/test/disk_tests/multipath/sdb/device/vendor
new file mode 100644
index 0000000..ead8944
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdb/device/vendor
@@ -0,0 +1 @@
+SANVEND
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdb/holders/holder b/src/test/disk_tests/multipath/sdb/holders/holder
new file mode 100644
index 0000000..9255a92
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdb/holders/holder
@@ -0,0 +1 @@
+dm-holder
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdb/queue/rotational b/src/test/disk_tests/multipath/sdb/queue/rotational
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdb/queue/rotational
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdb/size b/src/test/disk_tests/multipath/sdb/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdb/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdb_udevadm b/src/test/disk_tests/multipath/sdb_udevadm
new file mode 100644
index 0000000..9a04c63
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdb_udevadm
@@ -0,0 +1,18 @@
+P: /devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sdb
+N: sdb
+S: disk/by-id/scsi-36001405000000000000000000000000b
+E: DEVLINKS=/dev/disk/by-id/scsi-36001405000000000000000000000000b
+E: DEVNAME=/dev/sdb
+E: DEVPATH=/devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sdb
+E: DEVTYPE=disk
+E: ID_BUS=scsi
+E: ID_MODEL=SANMODEL
+E: ID_SCSI=1
+E: ID_SERIAL=SANSERIALb
+E: ID_SERIAL_SHORT=SSERb
+E: ID_TYPE=disk
+E: ID_VENDOR=SANVEND
+E: ID_WWN=0x600140500000000b
+E: MAJOR=8
+E: MINOR=0
+E: SUBSYSTEM=block
diff --git a/src/test/disk_tests/multipath/sdc/device/model b/src/test/disk_tests/multipath/sdc/device/model
new file mode 100644
index 0000000..2f50f3f
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdc/device/model
@@ -0,0 +1 @@
+SANMODEL
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdc/device/vendor b/src/test/disk_tests/multipath/sdc/device/vendor
new file mode 100644
index 0000000..ead8944
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdc/device/vendor
@@ -0,0 +1 @@
+SANVEND
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdc/holders/holder b/src/test/disk_tests/multipath/sdc/holders/holder
new file mode 100644
index 0000000..9255a92
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdc/holders/holder
@@ -0,0 +1 @@
+dm-holder
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdc/queue/rotational b/src/test/disk_tests/multipath/sdc/queue/rotational
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdc/queue/rotational
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdc/size b/src/test/disk_tests/multipath/sdc/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdc/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdc_udevadm b/src/test/disk_tests/multipath/sdc_udevadm
new file mode 100644
index 0000000..acff253
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdc_udevadm
@@ -0,0 +1,18 @@
+P: /devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sdc
+N: sdc
+S: disk/by-id/scsi-36001405000000000000000000000000c
+E: DEVLINKS=/dev/disk/by-id/scsi-36001405000000000000000000000000c
+E: DEVNAME=/dev/sdc
+E: DEVPATH=/devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sdc
+E: DEVTYPE=disk
+E: ID_BUS=scsi
+E: ID_MODEL=SANMODEL
+E: ID_SCSI=1
+E: ID_SERIAL=SANSERIALc
+E: ID_SERIAL_SHORT=SSERc
+E: ID_TYPE=disk
+E: ID_VENDOR=SANVEND
+E: ID_WWN=0x600140500000000c
+E: MAJOR=8
+E: MINOR=0
+E: SUBSYSTEM=block
diff --git a/src/test/disk_tests/multipath/sdd/device/model b/src/test/disk_tests/multipath/sdd/device/model
new file mode 100644
index 0000000..2f50f3f
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdd/device/model
@@ -0,0 +1 @@
+SANMODEL
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdd/device/vendor b/src/test/disk_tests/multipath/sdd/device/vendor
new file mode 100644
index 0000000..ead8944
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdd/device/vendor
@@ -0,0 +1 @@
+SANVEND
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdd/holders/holder b/src/test/disk_tests/multipath/sdd/holders/holder
new file mode 100644
index 0000000..9255a92
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdd/holders/holder
@@ -0,0 +1 @@
+dm-holder
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdd/queue/rotational b/src/test/disk_tests/multipath/sdd/queue/rotational
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdd/queue/rotational
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdd/size b/src/test/disk_tests/multipath/sdd/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdd/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/sdd_udevadm b/src/test/disk_tests/multipath/sdd_udevadm
new file mode 100644
index 0000000..813c5f1
--- /dev/null
+++ b/src/test/disk_tests/multipath/sdd_udevadm
@@ -0,0 +1,18 @@
+P: /devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sdd
+N: sdd
+S: disk/by-id/scsi-36001405000000000000000000000000d
+E: DEVLINKS=/dev/disk/by-id/scsi-36001405000000000000000000000000d
+E: DEVNAME=/dev/sdd
+E: DEVPATH=/devices/pci0000:00/0000:00:03.0/0000:02:00.0/host4/rport-4:0-0/target4:0:0/4:0:0:0/block/sdd
+E: DEVTYPE=disk
+E: ID_BUS=scsi
+E: ID_MODEL=SANMODEL
+E: ID_SCSI=1
+E: ID_SERIAL=SANSERIALd
+E: ID_SERIAL_SHORT=SSERd
+E: ID_TYPE=disk
+E: ID_VENDOR=SANVEND
+E: ID_WWN=0x600140500000000d
+E: MAJOR=8
+E: MINOR=0
+E: SUBSYSTEM=block
diff --git a/src/test/disk_tests/multipath/zpool b/src/test/disk_tests/multipath/zpool
new file mode 100644
index 0000000..e723f7e
--- /dev/null
+++ b/src/test/disk_tests/multipath/zpool
@@ -0,0 +1 @@
+	/dev/dm-3	-
diff --git a/src/test/disklist_test.pm b/src/test/disklist_test.pm
index a0dc01f..9e35430 100644
--- a/src/test/disklist_test.pm
+++ b/src/test/disklist_test.pm
@@ -18,6 +18,14 @@ my $testcount = 0; # testcount for TAP::Harness
 my $diskmanage_module; # mockmodule for PVE::Diskmanage
 my $print = 0;
 
+my $multipath_aliases = {
+    '/dev/mapper/mpatha' => '/dev/dm-0',
+    '/dev/mapper/mpathb' => '/dev/dm-1',
+    '/dev/mapper/mpathc' => '/dev/dm-2',
+    '/dev/mapper/mpathd' => '/dev/dm-3',
+    '/dev/mapper/mpathe' => '/dev/dm-4',
+};
+
 sub mocked_run_command {
     my ($cmd, %param) = @_;
 
@@ -101,6 +109,16 @@ sub mocked_is_iscsi {
     return 0;
 }
 
+sub mocked_file_read_firstline {
+    my ($path) = @_;
+
+    my $originalsub = $diskmanage_module->original('file_read_firstline');
+
+    $path =~ s|^/sys/block|disk_tests/$testcasedir|;
+
+    return &$originalsub($path);
+}
+
 sub mocked_dir_glob_foreach {
     my ($dir, $regex, $sub) = @_;
 
@@ -109,6 +127,8 @@ sub mocked_dir_glob_foreach {
     # read lines in from file
     if ($dir =~ m{^/sys/block$}) {
         @$lines = split(/\n/, read_test_file('disklist'));
+    } elsif ($dir =~ m{^/sys/block/([^/]+)/slaves$}) {
+        @$lines = split(/\n/, read_test_file("${1}_slaves"));
     } elsif ($dir =~ m{^/sys/block/([^/]+)}) {
         @$lines = split(/\n/, read_test_file('partlist'));
     }
@@ -213,6 +233,15 @@ sub test_disk_list {
         $testcount++;
         is_deeply($disks, $expected_disk_list, 'disk list should be the same');
 
+        if (-f "disk_tests/$testcasedir/multipath_expected.json") {
+            my $multipath = PVE::Diskmanage::get_multipath_disks();
+            my $expected_multipath = decode_json(read_test_file('multipath_expected.json'));
+
+            print Dumper($multipath) if $print;
+            $testcount++;
+            is_deeply($multipath, $expected_multipath, 'multipath list should be the same');
+        }
+
         done_testing($testcount);
     };
 }
@@ -239,8 +268,15 @@ $diskmanage_module->mock('assert_blockdev' => sub { return 1; });
 print("\tMocked assert_blockdev\n");
 $diskmanage_module->mock(
     'dir_is_empty' => sub {
-        # all partitions have a holder dir
         my $val = shift;
+        # let test cases provide the dir (e.g. holders) with content
+        my $mapped = $val;
+        $mapped =~ s|^/sys/block|disk_tests/$testcasedir|;
+        if (-d $mapped) {
+            my $originalsub = $diskmanage_module->original('dir_is_empty');
+            return &$originalsub($mapped);
+        }
+        # all partitions have a holder dir
         if ($val =~ m|^/sys/block/.+/.+/|) {
             return 0;
         }
@@ -248,6 +284,17 @@ $diskmanage_module->mock(
     },
 );
 print("\tMocked dir_is_empty\n");
+$diskmanage_module->mock('file_read_firstline' => \&mocked_file_read_firstline);
+print("\tMocked file_read_firstline\n");
+$diskmanage_module->mock(
+    'abs_path' => sub {
+        my ($path) = @_;
+        return $multipath_aliases->{$path}
+            if defined($multipath_aliases->{$path});
+        return &{ $diskmanage_module->original('abs_path') }($path);
+    },
+);
+print("\tMocked abs_path\n");
 $diskmanage_module->mock('check_bin' => sub { return 1; });
 print("\tMocked check_bin\n");
 my $tools_module = Test::MockModule->new('PVE::ProcFSTools', no_auto => 1);
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 03/27] diskmanage: qualify NVMe over fabrics transport
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 01/27] diskmanage: collect disk transport type from lsblk Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 02/27] diskmanage: add helper to list multipath devices Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 04/27] disks: list: add include-remote parameter Dietmar Maurer
                   ` (23 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

lsblk reports plain 'nvme' for fabrics attached namespaces, so
clients cannot tell local PCIe drives from shared SAN namespaces.
Read the transport from the namespace controller, or from any
controller of the subsystem when the namespace belongs to a
subsystem due to native NVMe multipath, and report nvme-fc,
nvme-tcp, nvme-rdma or nvme-loop instead.

This lets clients like the SAN LUN scan or the local storage
creation dialogs decide which namespaces are suitable, instead of
hiding fabrics attached devices server side.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/Diskmanage.pm                         | 27 +++++++++++++++
 src/test/disk_tests/multipath/disklist        |  2 ++
 .../multipath/disklist_expected.json          | 33 +++++++++++++++++++
 src/test/disk_tests/multipath/lsblk           | 14 ++++++++
 .../disk_tests/multipath/nvme8n1/device/model |  1 +
 .../multipath/nvme8n1/device/transport        |  1 +
 .../multipath/nvme8n1/queue/rotational        |  1 +
 src/test/disk_tests/multipath/nvme8n1/size    |  1 +
 src/test/disk_tests/multipath/nvme8n1_udevadm | 14 ++++++++
 .../disk_tests/multipath/nvme9n1/device/model |  1 +
 .../multipath/nvme9n1/device/transport        |  1 +
 .../multipath/nvme9n1/device/vendor           |  1 +
 .../multipath/nvme9n1/queue/rotational        |  1 +
 src/test/disk_tests/multipath/nvme9n1/size    |  1 +
 src/test/disk_tests/multipath/nvme9n1_udevadm | 12 +++++++
 15 files changed, 111 insertions(+)
 create mode 100644 src/test/disk_tests/multipath/nvme8n1/device/model
 create mode 100644 src/test/disk_tests/multipath/nvme8n1/device/transport
 create mode 100644 src/test/disk_tests/multipath/nvme8n1/queue/rotational
 create mode 100644 src/test/disk_tests/multipath/nvme8n1/size
 create mode 100644 src/test/disk_tests/multipath/nvme8n1_udevadm
 create mode 100644 src/test/disk_tests/multipath/nvme9n1/device/model
 create mode 100644 src/test/disk_tests/multipath/nvme9n1/device/transport
 create mode 100644 src/test/disk_tests/multipath/nvme9n1/device/vendor
 create mode 100644 src/test/disk_tests/multipath/nvme9n1/queue/rotational
 create mode 100644 src/test/disk_tests/multipath/nvme9n1/size
 create mode 100644 src/test/disk_tests/multipath/nvme9n1_udevadm

diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index ed5181f..b006ec3 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -485,6 +485,26 @@ sub is_iscsi {
     return 0;
 }
 
+# Returns the NVMe transport (pcie, fc, tcp, rdma, loop) of a namespace, either directly from
+# its controller, or from any controller of its subsystem for native NVMe multipath setups.
+sub get_nvme_transport {
+    my ($sysdir) = @_;
+
+    my $transport = file_read_firstline("$sysdir/device/transport");
+    return $transport if defined($transport);
+
+    dir_glob_foreach(
+        "$sysdir/device",
+        'nvme\d+',
+        sub {
+            my ($controller) = @_;
+            $transport //= file_read_firstline("$sysdir/device/$controller/transport");
+        },
+    );
+
+    return $transport;
+}
+
 my sub is_ssdlike {
     my ($type) = @_;
     return $type eq 'ssd' || $type eq 'nvme';
@@ -577,6 +597,9 @@ sub get_disks {
             # we do not want iscsi devices
             return if is_iscsi($sysdir);
 
+            my $nvme_transport;
+            $nvme_transport = get_nvme_transport($sysdir) if $dev =~ m/^nvme/;
+
             my $sysdata = get_sysdir_info($sysdir);
             return if !defined($sysdata);
 
@@ -628,7 +651,11 @@ sub get_disks {
                 wearout => $wearout,
             };
 
+            # lsblk reports plain 'nvme' for fabrics attached namespaces
             my $transport = $lsblk_info->{$devpath}->{tran};
+            if (defined($nvme_transport) && $nvme_transport ne 'pcie') {
+                $transport = "nvme-$nvme_transport";
+            }
             $disklist->{$dev}->{transport} = $transport if defined($transport);
             $disklist->{$dev}->{mounted} = 1 if exists $mounted->{$devpath};
 
diff --git a/src/test/disk_tests/multipath/disklist b/src/test/disk_tests/multipath/disklist
index 9293589..f759ba9 100644
--- a/src/test/disk_tests/multipath/disklist
+++ b/src/test/disk_tests/multipath/disklist
@@ -7,3 +7,5 @@ dm-1
 dm-2
 dm-3
 dm-4
+nvme8n1
+nvme9n1
diff --git a/src/test/disk_tests/multipath/disklist_expected.json b/src/test/disk_tests/multipath/disklist_expected.json
index a8e74fa..ec44296 100644
--- a/src/test/disk_tests/multipath/disklist_expected.json
+++ b/src/test/disk_tests/multipath/disklist_expected.json
@@ -70,5 +70,38 @@
         "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000d",
         "transport": "fc",
         "used": "Device Mapper"
+    },
+    "nvme8n1": {
+        "devpath": "/dev/nvme8n1",
+        "size": 10737418240,
+        "vendor": "unknown",
+        "model": "FCMODEL",
+        "serial": "FCNVME1",
+        "wwn": "uuid.20000000-0000-0000-0000-000000000001",
+        "gpt": 0,
+        "rpm": 0,
+        "type": "nvme",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "by_id_link": "/dev/disk/by-id/nvme-FCMODEL_FCNVME1",
+        "transport": "nvme-fc"
+    },
+    "nvme9n1": {
+        "devpath": "/dev/nvme9n1",
+        "size": 10737418240,
+        "vendor": "FABVEND",
+        "model": "FABRICS CTRL",
+        "serial": "FAB1",
+        "wwn": "uuid.10000000-0000-0000-0000-000000000001",
+        "gpt": 0,
+        "rpm": 0,
+        "type": "nvme",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "transport": "nvme-tcp"
     }
 }
diff --git a/src/test/disk_tests/multipath/lsblk b/src/test/disk_tests/multipath/lsblk
index 91f7457..dfc1ae1 100644
--- a/src/test/disk_tests/multipath/lsblk
+++ b/src/test/disk_tests/multipath/lsblk
@@ -58,6 +58,20 @@
          "fstype": null,
          "tran": null,
          "pttype": null
+      },
+      {
+         "path": "/dev/nvme8n1",
+         "parttype": null,
+         "fstype": null,
+         "tran": "nvme",
+         "pttype": null
+      },
+      {
+         "path": "/dev/nvme9n1",
+         "parttype": null,
+         "fstype": null,
+         "tran": "nvme",
+         "pttype": null
       }
    ]
 }
diff --git a/src/test/disk_tests/multipath/nvme8n1/device/model b/src/test/disk_tests/multipath/nvme8n1/device/model
new file mode 100644
index 0000000..1aac689
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme8n1/device/model
@@ -0,0 +1 @@
+FCMODEL
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme8n1/device/transport b/src/test/disk_tests/multipath/nvme8n1/device/transport
new file mode 100644
index 0000000..8b517d0
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme8n1/device/transport
@@ -0,0 +1 @@
+fc
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme8n1/queue/rotational b/src/test/disk_tests/multipath/nvme8n1/queue/rotational
new file mode 100644
index 0000000..c227083
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme8n1/queue/rotational
@@ -0,0 +1 @@
+0
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme8n1/size b/src/test/disk_tests/multipath/nvme8n1/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme8n1/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme8n1_udevadm b/src/test/disk_tests/multipath/nvme8n1_udevadm
new file mode 100644
index 0000000..d0cd5ea
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme8n1_udevadm
@@ -0,0 +1,14 @@
+P: /devices/virtual/nvme-subsystem/nvme-subsys8/nvme8n1
+N: nvme8n1
+S: disk/by-id/nvme-FCMODEL_FCNVME1
+E: DEVLINKS=/dev/disk/by-id/nvme-FCMODEL_FCNVME1
+E: DEVNAME=/dev/nvme8n1
+E: DEVPATH=/devices/virtual/nvme-subsystem/nvme-subsys8/nvme8n1
+E: DEVTYPE=disk
+E: ID_MODEL=FCMODEL
+E: ID_SERIAL=FCNVME_1
+E: ID_SERIAL_SHORT=FCNVME1
+E: ID_WWN=uuid.20000000-0000-0000-0000-000000000001
+E: MAJOR=259
+E: MINOR=1
+E: SUBSYSTEM=block
diff --git a/src/test/disk_tests/multipath/nvme9n1/device/model b/src/test/disk_tests/multipath/nvme9n1/device/model
new file mode 100644
index 0000000..aa0287c
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme9n1/device/model
@@ -0,0 +1 @@
+FABMODEL
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme9n1/device/transport b/src/test/disk_tests/multipath/nvme9n1/device/transport
new file mode 100644
index 0000000..f5483db
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme9n1/device/transport
@@ -0,0 +1 @@
+tcp
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme9n1/device/vendor b/src/test/disk_tests/multipath/nvme9n1/device/vendor
new file mode 100644
index 0000000..cdc6ddf
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme9n1/device/vendor
@@ -0,0 +1 @@
+FABVEND
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme9n1/queue/rotational b/src/test/disk_tests/multipath/nvme9n1/queue/rotational
new file mode 100644
index 0000000..c227083
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme9n1/queue/rotational
@@ -0,0 +1 @@
+0
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme9n1/size b/src/test/disk_tests/multipath/nvme9n1/size
new file mode 100644
index 0000000..8280959
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme9n1/size
@@ -0,0 +1 @@
+20971520
\ No newline at end of file
diff --git a/src/test/disk_tests/multipath/nvme9n1_udevadm b/src/test/disk_tests/multipath/nvme9n1_udevadm
new file mode 100644
index 0000000..b6d13cc
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme9n1_udevadm
@@ -0,0 +1,12 @@
+P: /devices/virtual/nvme-subsystem/nvme-subsys9/nvme9n1
+N: nvme9n1
+E: DEVNAME=/dev/nvme9n1
+E: DEVPATH=/devices/virtual/nvme-subsystem/nvme-subsys9/nvme9n1
+E: DEVTYPE=disk
+E: ID_MODEL=FABRICS CTRL
+E: ID_SERIAL=FABRICS_1
+E: ID_SERIAL_SHORT=FAB1
+E: ID_WWN=uuid.10000000-0000-0000-0000-000000000001
+E: MAJOR=259
+E: MINOR=0
+E: SUBSYSTEM=block
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 04/27] disks: list: add include-remote parameter
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (2 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 03/27] diskmanage: qualify NVMe over fabrics transport Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 05/27] diskmanage: include iSCSI session devices in disk enumeration Dietmar Maurer
                   ` (22 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Generic disk selectors are primarily used for node-local storage.
Offering devices attached through an identifiable remote transport
there makes accidental reuse too easy, regardless of whether the
administrator assigned the LUN exclusively or shared it.

Require an explicit opt-in for FC, FCoE, iSCSI, and remote NVMe
attachments. SAS remains visible because its transport alone does not
identify a remote attachment, while NVMe loopback is local.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Disks.pm          |  21 +++++-
 src/PVE/Diskmanage.pm          |   9 +++
 src/test/Makefile              |   5 +-
 src/test/disk_api_test.pm      | 113 +++++++++++++++++++++++++++++++++
 src/test/run_disk_api_tests.pl |  11 ++++
 5 files changed, 157 insertions(+), 2 deletions(-)
 create mode 100644 src/test/disk_api_test.pm
 create mode 100755 src/test/run_disk_api_tests.pl

diff --git a/src/PVE/API2/Disks.pm b/src/PVE/API2/Disks.pm
index e64a391..673bbcb 100644
--- a/src/PVE/API2/Disks.pm
+++ b/src/PVE/API2/Disks.pm
@@ -82,7 +82,7 @@ __PACKAGE__->register_method({
     name => 'list',
     path => 'list',
     method => 'GET',
-    description => "List local disks.",
+    description => "List disks attached to the node.",
     protected => 1,
     proxyto => 'node',
     permissions => {
@@ -98,6 +98,14 @@ __PACKAGE__->register_method({
                 optional => 1,
                 default => 0,
             },
+            'include-remote' => {
+                description => "Also include devices attached through a remote storage"
+                    . " transport (for example FC, iSCSI, or NVMe over TCP), which are"
+                    . " excluded by default.",
+                type => 'boolean',
+                optional => 1,
+                default => 0,
+            },
             skipsmart => {
                 description => "Skip smart checks.",
                 type => 'boolean',
@@ -155,16 +163,27 @@ __PACKAGE__->register_method({
 
         my $skipsmart = $param->{skipsmart} // 0;
         my $include_partitions = $param->{'include-partitions'} // 0;
+        my $include_remote = $param->{'include-remote'} // 0;
 
         my $disks = PVE::Diskmanage::get_disks(
             undef, $skipsmart, $include_partitions,
         );
 
+        my $by_devpath = {};
+        $by_devpath->{ $disks->{$_}->{devpath} } = $disks->{$_} for keys %$disks;
+
         my $type = $param->{type} // '';
         my $result = [];
 
         foreach my $disk (sort keys %$disks) {
             my $entry = $disks->{$disk};
+            if (!$include_remote) {
+                # partitions inherit the visibility of the disk they reside on
+                my $device = $entry;
+                $device = $by_devpath->{ $entry->{parent} } // $entry
+                    if defined($entry->{parent});
+                next if PVE::Diskmanage::is_remote_transport($device->{transport});
+            }
             if ($type eq 'journal_disks') {
                 next if $entry->{osdid} >= 0;
                 if (my $usage = $entry->{used}) {
diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index b006ec3..7ab8119 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -505,6 +505,15 @@ sub get_nvme_transport {
     return $transport;
 }
 
+# Whether a transport belongs to the set of remote storage transports excluded
+# from the disk list by default. SAS is not included because its transport does
+# not distinguish local from remote attachments.
+sub is_remote_transport {
+    my ($transport) = @_;
+
+    return ($transport // '') =~ m/^(?:fc|fcoe|iscsi|nvme-(?:fc|tcp|rdma))$/;
+}
+
 my sub is_ssdlike {
     my ($type) = @_;
     return $type eq 'ssd' || $type eq 'nvme';
diff --git a/src/test/Makefile b/src/test/Makefile
index ee025bc..4504743 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -1,6 +1,6 @@
 all: test
 
-test: test_zfspoolplugin test_lvmplugin test_disklist test_bwlimit test_plugin test_ovf test_volume_access
+test: test_zfspoolplugin test_lvmplugin test_disklist test_disk_api test_bwlimit test_plugin test_ovf test_volume_access
 
 test_zfspoolplugin: run_test_zfspoolplugin.pl
 	./run_test_zfspoolplugin.pl
@@ -11,6 +11,9 @@ test_lvmplugin: run_test_lvmplugin.pl
 test_disklist: run_disk_tests.pl
 	./run_disk_tests.pl
 
+test_disk_api: run_disk_api_tests.pl
+	./run_disk_api_tests.pl
+
 test_bwlimit: run_bwlimit_tests.pl
 	./run_bwlimit_tests.pl
 
diff --git a/src/test/disk_api_test.pm b/src/test/disk_api_test.pm
new file mode 100644
index 0000000..c7735c0
--- /dev/null
+++ b/src/test/disk_api_test.pm
@@ -0,0 +1,113 @@
+package PVE::API2::Disks::Test;
+
+use strict;
+use warnings;
+
+use lib qw(..);
+
+use PVE::API2::Disks;
+use PVE::Diskmanage;
+
+use Test::MockModule;
+use Test::More;
+
+my @transport_cases = (
+    ['none', undef, 0],
+    ['unknown', 'unknown', 0],
+    ['ata', 'ata', 0],
+    ['sata', 'sata', 0],
+    ['sas', 'sas', 0],
+    ['spi', 'spi', 0],
+    ['sbp', 'sbp', 0],
+    ['usb', 'usb', 0],
+    ['fc', 'fc', 1],
+    ['fcoe', 'fcoe', 1],
+    ['iscsi', 'iscsi', 1],
+    ['nvme', 'nvme', 0],
+    ['virtio', 'virtio', 0],
+    ['nvme-fc', 'nvme-fc', 1],
+    ['nvme-tcp', 'nvme-tcp', 1],
+    ['nvme-rdma', 'nvme-rdma', 1],
+    ['nvme-loop', 'nvme-loop', 0],
+);
+
+my $disks = {};
+my (@all, @local);
+for my $case (@transport_cases) {
+    my ($name, $transport, $remote) = $case->@*;
+    my $devpath = "/dev/test-$name";
+    my $entry = {
+        devpath => $devpath,
+        gpt => 0,
+        osdid => -1,
+    };
+    $entry->{transport} = $transport if defined($transport);
+    $disks->{$name} = $entry;
+    push @all, $devpath;
+    push @local, $devpath if !$remote;
+}
+
+my $diskmanage_module = Test::MockModule->new('PVE::Diskmanage', no_auto => 1);
+$diskmanage_module->mock('get_disks' => sub { return $disks });
+
+my $list_handler = PVE::API2::Disks->map_method_by_name('list')->{code};
+my $list_devpaths = sub {
+    my ($param) = @_;
+    my $result = $list_handler->({ node => 'test-node', skipsmart => 1, $param->%* });
+    return [sort map { $_->{devpath} } $result->@*];
+};
+
+is_deeply(
+    $list_devpaths->({}), [sort @local], 'the default disk list excludes remote transports',
+);
+
+is_deeply(
+    $list_devpaths->({ type => 'unused' }),
+    [sort @local],
+    'unused disk selection excludes remote transports',
+);
+
+is_deeply(
+    $list_devpaths->({ 'include-remote' => 1 }),
+    [sort @all],
+    'include-remote returns all transports',
+);
+
+$diskmanage_module->mock(
+    'get_disks' => sub {
+        return {
+            'fc-disk' => {
+                devpath => '/dev/fc-disk',
+                gpt => 0,
+                osdid => -1,
+                transport => 'fc',
+            },
+            'fc-partition' => {
+                devpath => '/dev/fc-disk1',
+                gpt => 0,
+                osdid => -1,
+                parent => '/dev/fc-disk',
+            },
+            'loop-disk' => {
+                devpath => '/dev/loop-disk',
+                gpt => 0,
+                osdid => -1,
+                transport => 'nvme-loop',
+            },
+            'loop-partition' => {
+                devpath => '/dev/loop-disk1',
+                gpt => 0,
+                osdid => -1,
+                parent => '/dev/loop-disk',
+            },
+        };
+    },
+);
+
+is_deeply(
+    $list_devpaths->({ 'include-partitions' => 1 }),
+    ['/dev/loop-disk', '/dev/loop-disk1'],
+    'partitions inherit the visibility of their parent disk',
+);
+
+done_testing();
diff --git a/src/test/run_disk_api_tests.pl b/src/test/run_disk_api_tests.pl
new file mode 100755
index 0000000..75fc98c
--- /dev/null
+++ b/src/test/run_disk_api_tests.pl
@@ -0,0 +1,11 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use TAP::Harness;
+
+my $harness = TAP::Harness->new({ verbosity => -2 });
+my $res = $harness->runtests("disk_api_test.pm");
+
+exit -1 if !$res || $res->{failed} || $res->{parse_errors};
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 05/27] diskmanage: include iSCSI session devices in disk enumeration
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (3 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 04/27] disks: list: add include-remote parameter Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 06/27] diskmanage: link multipath member disks to their map device Dietmar Maurer
                   ` (21 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

iSCSI attached disks were excluded from disk enumeration since
forever, which made them invisible to every API client, so tooling
working with SAN LUNs, like the SAN LUN scan, could not see them at
all.

Enumerate them instead. The transport property reports 'iscsi', so
the disk list keeps hiding them unless include-remote is set, while
clients wanting the full picture can filter on their own. This also
turns iSCSI LUNs into valid targets for volume group creation via
the disks API, as used for shared LVM on iSCSI setups.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Disks.pm                         |  4 ++--
 src/PVE/Diskmanage.pm                         | 13 ------------
 src/test/disk_tests/iscsi/disklist            |  1 +
 .../disk_tests/iscsi/disklist_expected.json   | 19 +++++++++++++++++
 src/test/disk_tests/iscsi/lsblk               |  5 +++++
 src/test/disk_tests/iscsi/sda/device/model    |  1 +
 src/test/disk_tests/iscsi/sda/device/vendor   |  1 +
 .../disk_tests/iscsi/sda/queue/rotational     |  1 +
 src/test/disk_tests/iscsi/sda/size            |  1 +
 src/test/disk_tests/iscsi/sda_udevadm         | 21 +++++++++++++++++++
 src/test/disklist_test.pm                     |  6 ------
 11 files changed, 52 insertions(+), 21 deletions(-)
 create mode 100644 src/test/disk_tests/iscsi/disklist
 create mode 100644 src/test/disk_tests/iscsi/disklist_expected.json
 create mode 100644 src/test/disk_tests/iscsi/lsblk
 create mode 100644 src/test/disk_tests/iscsi/sda/device/model
 create mode 100644 src/test/disk_tests/iscsi/sda/device/vendor
 create mode 100644 src/test/disk_tests/iscsi/sda/queue/rotational
 create mode 100644 src/test/disk_tests/iscsi/sda/size
 create mode 100644 src/test/disk_tests/iscsi/sda_udevadm

diff --git a/src/PVE/API2/Disks.pm b/src/PVE/API2/Disks.pm
index 673bbcb..ee287f7 100644
--- a/src/PVE/API2/Disks.pm
+++ b/src/PVE/API2/Disks.pm
@@ -145,8 +145,8 @@ __PACKAGE__->register_method({
                 health => { type => 'string', optional => 1 },
                 transport => {
                     type => 'string',
-                    description => 'The bus type the disk is attached with '
-                        . '(for example sata, sas, fc, nvme, usb).',
+                    description => 'The bus type the disk is attached with (for '
+                        . 'example sata, sas, usb, fc, iscsi, nvme, nvme-fc, nvme-tcp).',
                     optional => 1,
                 },
                 parent => {
diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index 7ab8119..ed270e9 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -475,16 +475,6 @@ sub dir_is_empty {
     return 1;
 }
 
-sub is_iscsi {
-    my ($sysdir) = @_;
-
-    if (-l $sysdir && readlink($sysdir) =~ m|host[^/]*/session[^/]*|) {
-        return 1;
-    }
-
-    return 0;
-}
-
 # Returns the NVMe transport (pcie, fc, tcp, rdma, loop) of a namespace, either directly from
 # its controller, or from any controller of its subsystem for native NVMe multipath setups.
 sub get_nvme_transport {
@@ -603,9 +593,6 @@ sub get_disks {
 
             my $sysdir = "/sys/block/$dev";
 
-            # we do not want iscsi devices
-            return if is_iscsi($sysdir);
-
             my $nvme_transport;
             $nvme_transport = get_nvme_transport($sysdir) if $dev =~ m/^nvme/;
 
diff --git a/src/test/disk_tests/iscsi/disklist b/src/test/disk_tests/iscsi/disklist
new file mode 100644
index 0000000..9191c61
--- /dev/null
+++ b/src/test/disk_tests/iscsi/disklist
@@ -0,0 +1 @@
+sda
diff --git a/src/test/disk_tests/iscsi/disklist_expected.json b/src/test/disk_tests/iscsi/disklist_expected.json
new file mode 100644
index 0000000..ca33410
--- /dev/null
+++ b/src/test/disk_tests/iscsi/disklist_expected.json
@@ -0,0 +1,19 @@
+{
+    "sda": {
+        "devpath": "/dev/sda",
+        "size": 10737418240,
+        "vendor": "LIO-ORG",
+        "model": "vdisk1",
+        "serial": "ISER1",
+        "wwn": "0x60014051111111111",
+        "gpt": 0,
+        "rpm": -1,
+        "type": "unknown",
+        "health": "UNKNOWN",
+        "wearout": "N/A",
+        "osdid": -1,
+        "osdid-list": null,
+        "by_id_link": "/dev/disk/by-id/scsi-360014051111111111",
+        "transport": "iscsi"
+    }
+}
diff --git a/src/test/disk_tests/iscsi/lsblk b/src/test/disk_tests/iscsi/lsblk
new file mode 100644
index 0000000..526d27c
--- /dev/null
+++ b/src/test/disk_tests/iscsi/lsblk
@@ -0,0 +1,5 @@
+{
+   "blockdevices": [
+      {"path":"/dev/sda", "parttype":null, "fstype":null, "tran":"iscsi"}
+   ]
+}
diff --git a/src/test/disk_tests/iscsi/sda/device/model b/src/test/disk_tests/iscsi/sda/device/model
new file mode 100644
index 0000000..b14d6fe
--- /dev/null
+++ b/src/test/disk_tests/iscsi/sda/device/model
@@ -0,0 +1 @@
+vdisk1
diff --git a/src/test/disk_tests/iscsi/sda/device/vendor b/src/test/disk_tests/iscsi/sda/device/vendor
new file mode 100644
index 0000000..968831c
--- /dev/null
+++ b/src/test/disk_tests/iscsi/sda/device/vendor
@@ -0,0 +1 @@
+LIO-ORG
diff --git a/src/test/disk_tests/iscsi/sda/queue/rotational b/src/test/disk_tests/iscsi/sda/queue/rotational
new file mode 100644
index 0000000..d00491f
--- /dev/null
+++ b/src/test/disk_tests/iscsi/sda/queue/rotational
@@ -0,0 +1 @@
+1
diff --git a/src/test/disk_tests/iscsi/sda/size b/src/test/disk_tests/iscsi/sda/size
new file mode 100644
index 0000000..14faeec
--- /dev/null
+++ b/src/test/disk_tests/iscsi/sda/size
@@ -0,0 +1 @@
+20971520
diff --git a/src/test/disk_tests/iscsi/sda_udevadm b/src/test/disk_tests/iscsi/sda_udevadm
new file mode 100644
index 0000000..ca9f781
--- /dev/null
+++ b/src/test/disk_tests/iscsi/sda_udevadm
@@ -0,0 +1,21 @@
+P: /devices/platform/host5/session1/target5:0:0/5:0:0:0/block/sda
+N: sda
+S: disk/by-id/scsi-360014051111111111
+S: disk/by-id/wwn-0x60014051111111111
+S: disk/by-path/ip-192.168.1.10:3260-iscsi-iqn.2003-01.org.linux-iscsi.storage:target1-lun-0
+E: DEVLINKS=/dev/disk/by-id/scsi-360014051111111111 /dev/disk/by-id/wwn-0x60014051111111111 /dev/disk/by-path/ip-192.168.1.10:3260-iscsi-iqn.2003-01.org.linux-iscsi.storage:target1-lun-0
+E: DEVNAME=/dev/sda
+E: DEVPATH=/devices/platform/host5/session1/target5:0:0/5:0:0:0/block/sda
+E: DEVTYPE=disk
+E: ID_BUS=scsi
+E: ID_MODEL=vdisk1
+E: ID_PATH=ip-192.168.1.10:3260-iscsi-iqn.2003-01.org.linux-iscsi.storage:target1-lun-0
+E: ID_SCSI=1
+E: ID_SERIAL=36001405111111111
+E: ID_SERIAL_SHORT=ISER1
+E: ID_TYPE=disk
+E: ID_VENDOR=LIO-ORG
+E: ID_WWN=0x60014051111111111
+E: MAJOR=8
+E: MINOR=0
+E: SUBSYSTEM=block
diff --git a/src/test/disklist_test.pm b/src/test/disklist_test.pm
index 9e35430..dc84510 100644
--- a/src/test/disklist_test.pm
+++ b/src/test/disklist_test.pm
@@ -105,10 +105,6 @@ sub mocked_get_sysdir_size {
     return &$originalsub($param);
 }
 
-sub mocked_is_iscsi {
-    return 0;
-}
-
 sub mocked_file_read_firstline {
     my ($path) = @_;
 
@@ -262,8 +258,6 @@ $diskmanage_module->mock('get_sysdir_info' => \&mocked_get_sysdir_info);
 print("\tMocked get_sysdir_info\n");
 $diskmanage_module->mock('get_sysdir_size' => \&mocked_get_sysdir_size);
 print("\tMocked get_sysdir_size\n");
-$diskmanage_module->mock('is_iscsi' => \&mocked_is_iscsi);
-print("\tMocked is_iscsi\n");
 $diskmanage_module->mock('assert_blockdev' => sub { return 1; });
 print("\tMocked assert_blockdev\n");
 $diskmanage_module->mock(
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 06/27] diskmanage: link multipath member disks to their map device
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (4 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 05/27] diskmanage: include iSCSI session devices in disk enumeration Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 07/27] iscsi: factor out session device map from device list Dietmar Maurer
                   ` (20 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Multipath member disks only reported the generic 'Device Mapper'
usage, with no way to tell which map a member belongs to, or that
the device mapper holder is a multipath map at all. Report the
mapped device path in the new multipath property, so clients can
group paths by map or hide members in favor of the mapped device.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Disks.pm                         |  6 ++++
 src/PVE/Diskmanage.pm                         | 33 +++++++++++++++++++
 .../multipath/disklist_expected.json          | 12 ++++---
 3 files changed, 47 insertions(+), 4 deletions(-)

diff --git a/src/PVE/API2/Disks.pm b/src/PVE/API2/Disks.pm
index ee287f7..2417340 100644
--- a/src/PVE/API2/Disks.pm
+++ b/src/PVE/API2/Disks.pm
@@ -149,6 +149,12 @@ __PACKAGE__->register_method({
                         . 'example sata, sas, usb, fc, iscsi, nvme, nvme-fc, nvme-tcp).',
                     optional => 1,
                 },
+                multipath => {
+                    type => 'string',
+                    description => 'For multipath member devices only. The mapped '
+                        . 'device path of the multipath map the device belongs to.',
+                    optional => 1,
+                },
                 parent => {
                     type => 'string',
                     description => 'For partitions only. The device path of '
diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index ed270e9..5ac04dd 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -535,12 +535,42 @@ sub mounted_paths {
     return $mounted;
 }
 
+# Maps multipath member devices (e.g. 'sda') to the mapped device path of
+# their multipath map (e.g. '/dev/mapper/mpatha').
+my sub get_multipath_slave_map {
+    my $slave_map = {};
+
+    dir_glob_foreach(
+        '/sys/block',
+        'dm-\d+',
+        sub {
+            my ($dev) = @_;
+            my $sysdir = "/sys/block/$dev";
+
+            my $uuid = file_read_firstline("$sysdir/dm/uuid") // return;
+            return if $uuid !~ m/^mpath-/;
+
+            my $name = file_read_firstline("$sysdir/dm/name") // return;
+
+            dir_glob_foreach(
+                "$sysdir/slaves",
+                '[^.].*',
+                sub { $slave_map->{ $_[0] } = "/dev/mapper/$name"; },
+            );
+        },
+    );
+
+    return $slave_map;
+}
+
 sub get_disks {
     my ($disks, $nosmart, $include_partitions) = @_;
     my $disklist = {};
 
     my $mounted = mounted_blockdevs();
 
+    my $mpath_slave_map = get_multipath_slave_map();
+
     my $lsblk_info = get_lsblk_info();
 
     my $journalhash = get_ceph_journals($lsblk_info);
@@ -655,6 +685,9 @@ sub get_disks {
             $disklist->{$dev}->{transport} = $transport if defined($transport);
             $disklist->{$dev}->{mounted} = 1 if exists $mounted->{$devpath};
 
+            my $mpath = $mpath_slave_map->{$dev};
+            $disklist->{$dev}->{multipath} = $mpath if defined($mpath);
+
             my $by_id_link = $data->{by_id_link};
             $disklist->{$dev}->{by_id_link} = $by_id_link if defined($by_id_link);
 
diff --git a/src/test/disk_tests/multipath/disklist_expected.json b/src/test/disk_tests/multipath/disklist_expected.json
index ec44296..001193d 100644
--- a/src/test/disk_tests/multipath/disklist_expected.json
+++ b/src/test/disk_tests/multipath/disklist_expected.json
@@ -15,7 +15,8 @@
         "osdid-list": null,
         "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000a",
         "transport": "fc",
-        "used": "Device Mapper"
+        "used": "Device Mapper",
+        "multipath": "/dev/mapper/mpatha"
     },
     "sdb": {
         "devpath": "/dev/sdb",
@@ -33,7 +34,8 @@
         "osdid-list": null,
         "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000b",
         "transport": "fc",
-        "used": "Device Mapper"
+        "used": "Device Mapper",
+        "multipath": "/dev/mapper/mpatha"
     },
     "sdc": {
         "devpath": "/dev/sdc",
@@ -51,7 +53,8 @@
         "osdid-list": null,
         "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000c",
         "transport": "fc",
-        "used": "Device Mapper"
+        "used": "Device Mapper",
+        "multipath": "/dev/mapper/mpathb"
     },
     "sdd": {
         "devpath": "/dev/sdd",
@@ -69,7 +72,8 @@
         "osdid-list": null,
         "by_id_link": "/dev/disk/by-id/scsi-36001405000000000000000000000000d",
         "transport": "fc",
-        "used": "Device Mapper"
+        "used": "Device Mapper",
+        "multipath": "/dev/mapper/mpathb"
     },
     "nvme8n1": {
         "devpath": "/dev/nvme8n1",
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 07/27] iscsi: factor out session device map from device list
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (5 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 06/27] diskmanage: link multipath member disks to their map device Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 08/27] api: scan: add san-luns method listing SAN LUN candidates Dietmar Maurer
                   ` (19 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Walking the iSCSI sessions in sysfs to find the attached block
devices and their target is useful on its own, for example to check
whether a local disk belongs to a configured iSCSI storage. Split
that part out of the device list helper into a reusable map of
kernel device names to target and address. No functional change.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/Storage/ISCSIPlugin.pm | 74 +++++++++++++++++++++-------------
 1 file changed, 47 insertions(+), 27 deletions(-)

diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 801b5d1..c18a4b6 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -251,14 +251,14 @@ sub load_stable_scsi_paths {
     return $stable_paths;
 }
 
-sub iscsi_device_list {
+# Maps kernel block device names (e.g. 'sda') of all disks attached through an
+# iSCSI session to their target IQN and channel/id/lun address.
+sub iscsi_session_device_map {
 
     my $res = {};
 
     my $dirname = '/sys/class/iscsi_session';
 
-    my $stable_paths = load_stable_scsi_paths();
-
     dir_glob_foreach(
         $dirname,
         'session(\d+)',
@@ -290,31 +290,12 @@ sub iscsi_device_list {
                     }
                     return if !$bdev;
 
-                    #check multipath
-                    if (-d "/sys/block/$bdev/holders") {
-                        my $multipathdev =
-                            dir_glob_regex("/sys/block/$bdev/holders", '[A-Za-z]\S*');
-                        $bdev = $multipathdev if $multipathdev;
-                    }
-
-                    my $blockdev = $stable_paths->{$bdev};
-                    return if !$blockdev;
-
-                    my $size = file_read_firstline("/sys/block/$bdev/size");
-                    return if !$size;
-
-                    my $volid = "$channel.$id.$lun.$blockdev";
-
-                    $res->{$target}->{$volid} = {
-                        'format' => 'raw',
-                        'size' => int($size * 512),
-                        'vmid' => 0, # not assigned to any vm
-                        'channel' => int($channel),
-                        'id' => int($id),
-                        'lun' => int($lun),
+                    $res->{$bdev} = {
+                        target => $target,
+                        channel => int($channel),
+                        id => int($id),
+                        lun => int($lun),
                     };
-
-                    #print "TEST: $target $session $host,$bus,$tg,$lun $blockdev\n";
                 },
             );
 
@@ -324,6 +305,45 @@ sub iscsi_device_list {
     return $res;
 }
 
+sub iscsi_device_list {
+
+    my $res = {};
+
+    my $stable_paths = load_stable_scsi_paths();
+
+    my $device_map = iscsi_session_device_map();
+
+    for my $bdev (sort keys %$device_map) {
+        my $info = $device_map->{$bdev};
+        my ($channel, $id, $lun) = $info->@{qw(channel id lun)};
+
+        #check multipath
+        if (-d "/sys/block/$bdev/holders") {
+            my $multipathdev = dir_glob_regex("/sys/block/$bdev/holders", '[A-Za-z]\S*');
+            $bdev = $multipathdev if $multipathdev;
+        }
+
+        my $blockdev = $stable_paths->{$bdev};
+        next if !$blockdev;
+
+        my $size = file_read_firstline("/sys/block/$bdev/size");
+        next if !$size;
+
+        my $volid = "$channel.$id.$lun.$blockdev";
+
+        $res->{ $info->{target} }->{$volid} = {
+            'format' => 'raw',
+            'size' => int($size * 512),
+            'vmid' => 0, # not assigned to any vm
+            'channel' => $channel,
+            'id' => $id,
+            'lun' => $lun,
+        };
+    }
+
+    return $res;
+}
+
 # Configuration
 
 sub type {
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 08/27] api: scan: add san-luns method listing SAN LUN candidates
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (6 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 07/27] iscsi: factor out session device map from device list Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 09/27] disks: lvm: allow creating volume groups on multipath devices Dietmar Maurer
                   ` (18 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

The storage wizard needs to offer the block devices of a SAN as
candidates for a shared LVM volume group. The node disk list alone
is not sufficient for that: aggregated multipath devices are missing
and mapping a device to its volume group and to a storage definition
that already uses it requires combining several calls.

Return one entry per whole disk or multipath mapped device with its
transport type and usage: unused devices are candidates for a new
volume group, devices holding an LVM physical volume report the
volume group name and, if the volume group is already referenced by
a storage definition, the storage ID, so a UI can filter those out.
LUNs belonging to the target of a configured iSCSI storage report
that storage as well, so a UI can warn before placing a volume group
on a LUN that already serves as raw VM disk storage. Member paths of
a multipath device are folded into the mapped device.

Also expose the method as 'pvesm scan san-luns'.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Storage/Scan.pm | 175 +++++++++++++++++++++++++++++++++++
 src/PVE/CLI/pvesm.pm         |   8 ++
 2 files changed, 183 insertions(+)

diff --git a/src/PVE/API2/Storage/Scan.pm b/src/PVE/API2/Storage/Scan.pm
index a58bdd8..9b944d5 100644
--- a/src/PVE/API2/Storage/Scan.pm
+++ b/src/PVE/API2/Storage/Scan.pm
@@ -5,10 +5,12 @@ use warnings;
 
 # NOTE: This API endpoints are mounted by pve-manager's API2::Node module and pvesm CLI
 
+use PVE::Diskmanage;
 use PVE::JSONSchema qw(get_standard_option);
 use PVE::RESTHandler;
 use PVE::SafeSyslog;
 
+use PVE::Storage::ISCSIPlugin;
 use PVE::Storage::LVMPlugin;
 use PVE::Storage::LvmThinPlugin;
 use PVE::Storage::PBSPlugin;
@@ -49,6 +51,7 @@ __PACKAGE__->register_method({
             { method => 'lvm' },
             { method => 'nfs' },
             { method => 'pbs' },
+            { method => 'san-luns' },
             { method => 'zfs' },
         ];
 
@@ -373,6 +376,178 @@ __PACKAGE__->register_method({
     },
 });
 
+__PACKAGE__->register_method({
+    name => 'sanlunscan',
+    path => 'san-luns',
+    method => 'GET',
+    description => "List block devices (disks and multipath devices) together with their"
+        . " usage, for example as candidates for a SAN backed LVM volume group.",
+    protected => 1,
+    proxyto => "node",
+    permissions => {
+        check => ['perm', '/storage', ['Datastore.Allocate']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+        },
+    },
+    returns => {
+        type => 'array',
+        items => {
+            type => "object",
+            properties => {
+                devpath => {
+                    description => "The device path.",
+                    type => 'string',
+                },
+                size => {
+                    description => "The device size in bytes.",
+                    type => 'integer',
+                    optional => 1,
+                },
+                transport => {
+                    description => "The bus type the device is attached with (for example"
+                        . " sata, sas, usb, fc, iscsi, nvme, nvme-fc, nvme-tcp).",
+                    type => 'string',
+                    optional => 1,
+                },
+                vendor => { type => 'string', optional => 1 },
+                model => { type => 'string', optional => 1 },
+                serial => { type => 'string', optional => 1 },
+                wwn => { type => 'string', optional => 1 },
+                paths => {
+                    description => "The number of paths of a multipath mapped device.",
+                    type => 'integer',
+                    optional => 1,
+                },
+                usage => {
+                    description => "How the device is currently used. 'lvm' means it is an LVM"
+                        . " physical volume, see the 'vgname' property.",
+                    type => 'string',
+                    enum => ['unused', 'lvm', 'other'],
+                },
+                vgname => {
+                    description => "The name of the LVM volume group the device belongs to.",
+                    type => 'string',
+                    optional => 1,
+                },
+                'used-by-storage' => {
+                    description => "The ID of an existing storage definition that uses this"
+                        . " device, either through the LVM volume group on it or because it"
+                        . " is a LUN of the storage's iSCSI target.",
+                    type => 'string',
+                    optional => 1,
+                },
+                detail => {
+                    description =>
+                        "Details about other usage, for example the file system type.",
+                    type => 'string',
+                    optional => 1,
+                },
+            },
+        },
+    },
+    code => sub {
+        my ($param) = @_;
+
+        my $disks = PVE::Diskmanage::get_disks(undef, 1, 0);
+        my $multipath = PVE::Diskmanage::get_multipath_disks();
+
+        # map PV device to VG name, PVs may sit on a partition of a listed device
+        my $vgs = PVE::Storage::LVMPlugin::lvm_vgs(1);
+        my $pv2vg = {};
+        for my $vgname (sort keys %$vgs) {
+            for my $pv (@{ $vgs->{$vgname}->{pvs} // [] }) {
+                $pv2vg->{ $pv->{name} } = $vgname;
+            }
+        }
+        my $find_vg = sub {
+            my ($devpath) = @_;
+            return $pv2vg->{$devpath} if defined($pv2vg->{$devpath});
+            for my $pv (sort keys %$pv2vg) {
+                return $pv2vg->{$pv} if $pv =~ m/^\Q$devpath\Ep?\d+$/;
+            }
+            return undef;
+        };
+
+        my $cfg = PVE::Storage::config();
+        my $vg_used_by = {};
+        my $iscsi_target_used_by = {};
+        for my $sid (sort keys %{ $cfg->{ids} }) {
+            my $scfg = $cfg->{ids}->{$sid};
+            if (defined(my $vgname = $scfg->{vgname})) {
+                $vg_used_by->{$vgname} //= $sid;
+            }
+            if ($scfg->{type} eq 'iscsi' && defined(my $target = $scfg->{target})) {
+                $iscsi_target_used_by->{$target} //= $sid;
+            }
+        }
+
+        # map kernel device name to the storage using its iSCSI target
+        my $iscsi_used_by = {};
+        if (scalar(keys %$iscsi_target_used_by)) {
+            my $device_map = PVE::Storage::ISCSIPlugin::iscsi_session_device_map();
+            for my $bdev (sort keys %$device_map) {
+                my $sid = $iscsi_target_used_by->{ $device_map->{$bdev}->{target} } // next;
+                $iscsi_used_by->{$bdev} = $sid;
+            }
+        }
+
+        my $res = [];
+        my $add_entry = sub {
+            my ($disk) = @_;
+
+            my $entry = {
+                devpath => $disk->{devpath},
+                size => $disk->{size},
+            };
+            for my $key (qw(transport vendor model serial wwn paths)) {
+                $entry->{$key} = $disk->{$key} if defined($disk->{$key});
+            }
+
+            if (!defined($disk->{used})) {
+                $entry->{usage} = 'unused';
+            } elsif ($disk->{used} eq 'LVM') {
+                $entry->{usage} = 'lvm';
+                if (defined(my $vgname = $find_vg->($disk->{devpath}))) {
+                    $entry->{vgname} = $vgname;
+                    if (defined(my $sid = $vg_used_by->{$vgname})) {
+                        $entry->{'used-by-storage'} = $sid;
+                    }
+                }
+            } else {
+                $entry->{usage} = 'other';
+                $entry->{detail} = $disk->{used};
+            }
+
+            if (!defined($entry->{'used-by-storage'})) {
+                my @members =
+                    map { $_ =~ s|^/dev/||r } ($disk->{slaves} // [$disk->{devpath}])->@*;
+                for my $member (@members) {
+                    if (defined(my $sid = $iscsi_used_by->{$member})) {
+                        $entry->{'used-by-storage'} = $sid;
+                        last;
+                    }
+                }
+            }
+
+            push @$res, $entry;
+        };
+
+        for my $dev (sort keys %$disks) {
+            my $disk = $disks->{$dev};
+            # member paths are represented by their multipath device
+            next if defined($disk->{multipath});
+            $add_entry->($disk);
+        }
+        $add_entry->($multipath->{$_}) for sort keys %$multipath;
+
+        return $res;
+    },
+});
+
 __PACKAGE__->register_method({
     name => 'zfsscan',
     path => 'zfs',
diff --git a/src/PVE/CLI/pvesm.pm b/src/PVE/CLI/pvesm.pm
index 3edf31a..7348e8e 100755
--- a/src/PVE/CLI/pvesm.pm
+++ b/src/PVE/CLI/pvesm.pm
@@ -741,6 +741,14 @@ our $cmddef = {
             $print_api_result,
             $PVE::RESTHandler::standard_output_options,
         ],
+        'san-luns' => [
+            "PVE::API2::Storage::Scan",
+            'sanlunscan',
+            [],
+            { node => $nodename },
+            $print_api_result,
+            $PVE::RESTHandler::standard_output_options,
+        ],
         zfs => [
             "PVE::API2::Storage::Scan",
             'zfsscan',
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 09/27] disks: lvm: allow creating volume groups on multipath devices
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (7 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 08/27] api: scan: add san-luns method listing SAN LUN candidates Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 10/27] diskmanage: add helper querying multipath path state Dietmar Maurer
                   ` (17 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

The device checks resolved the mapper symlink to the dm device, which
get_disks does not know, so creating a volume group on a multipath
mapped SAN LUN was rejected with 'not a valid local disk'. Accept
multipath devices that are not in use, so the storage wizard can
initialize a shared LVM volume group directly on a multipath LUN.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Disks/LVM.pm |  8 +++++---
 src/PVE/Diskmanage.pm     | 27 +++++++++++++++++++++++----
 src/test/disklist_test.pm | 19 +++++++++++++++++++
 3 files changed, 47 insertions(+), 7 deletions(-)

diff --git a/src/PVE/API2/Disks/LVM.pm b/src/PVE/API2/Disks/LVM.pm
index 9f8f951..35051fd 100644
--- a/src/PVE/API2/Disks/LVM.pm
+++ b/src/PVE/API2/Disks/LVM.pm
@@ -131,7 +131,8 @@ __PACKAGE__->register_method({
             name => get_standard_option('pve-storage-id'),
             device => {
                 type => 'string',
-                description => 'The block device you want to create the volume group on',
+                description => 'The block device you want to create the volume group on.'
+                    . ' Multipath mapped devices are supported.',
             },
             add_storage => {
                 description => "Configure storage using the Volume Group",
@@ -153,7 +154,8 @@ __PACKAGE__->register_method({
         my $node = $param->{node};
 
         $dev = PVE::Diskmanage::verify_blockdev_path($dev);
-        PVE::Diskmanage::assert_disk_unused($dev);
+
+        PVE::Diskmanage::assert_disk_unused($dev, 1);
 
         my $storage_params = {
             type => 'lvm',
@@ -176,7 +178,7 @@ __PACKAGE__->register_method({
 
         my $worker = sub {
             PVE::Diskmanage::locked_disk_action(sub {
-                PVE::Diskmanage::assert_disk_unused($dev);
+                PVE::Diskmanage::assert_disk_unused($dev, 1);
                 die "volume group with name '${name}' already exists on node '${node}'\n"
                     if PVE::Storage::LVMPlugin::lvm_vgs()->{$name};
 
diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index 5ac04dd..ab5db06 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -74,14 +74,22 @@ sub init_disk {
 }
 
 sub disk_is_used {
-    my ($disk) = @_;
+    my ($disk, $allow_multipath) = @_;
 
     my $dev = $disk;
     $dev =~ s|^/dev/||;
 
     my $disklist = get_disks($dev, 1, 1);
 
-    die "'$disk' is not a valid local disk\n" if !defined($disklist->{$dev});
+    if (!defined($disklist->{$dev})) {
+        if ($allow_multipath) {
+            my $multipath = lookup_multipath_map($disk);
+            return defined($multipath->{used}) ? 1 : 0 if defined($multipath);
+        }
+
+        die "'$disk' is not a valid local disk\n";
+    }
+
     return 1 if $disklist->{$dev}->{used};
 
     return 0;
@@ -904,6 +912,17 @@ sub get_multipath_disks {
     return $disklist;
 }
 
+# Returns the multipath device entry if the given path refers to a multipath mapped device.
+sub lookup_multipath_map {
+    my ($devpath) = @_;
+
+    my $path = abs_path($devpath) // $devpath;
+    my $dev = strip_dev($path);
+    return undef if $dev !~ m/^dm-\d+$/;
+
+    return get_multipath_disks()->{$dev};
+}
+
 sub get_partnum {
     my ($part_path) = @_;
 
@@ -958,8 +977,8 @@ sub locked_disk_action {
 }
 
 sub assert_disk_unused {
-    my ($dev) = @_;
-    die "device '$dev' is already in use\n" if disk_is_used($dev);
+    my ($dev, $allow_multipath) = @_;
+    die "device '$dev' is already in use\n" if disk_is_used($dev, $allow_multipath);
     return;
 }
 
diff --git a/src/test/disklist_test.pm b/src/test/disklist_test.pm
index dc84510..d2ea3b2 100644
--- a/src/test/disklist_test.pm
+++ b/src/test/disklist_test.pm
@@ -236,6 +236,25 @@ sub test_disk_list {
             print Dumper($multipath) if $print;
             $testcount++;
             is_deeply($multipath, $expected_multipath, 'multipath list should be the same');
+
+            eval { PVE::Diskmanage::assert_disk_unused('/dev/dm-0'); };
+            $testcount++;
+            like($@, qr/not a valid local disk/,
+                'multipath devices require an explicit opt-in');
+
+            eval { PVE::Diskmanage::assert_disk_unused('/dev/mapper/mpatha', 1); };
+            $testcount++;
+            is($@, '', 'unused multipath alias should pass');
+
+            for my $dev (qw(dm-1 dm-2 dm-3 dm-4)) {
+                eval { PVE::Diskmanage::assert_disk_unused("/dev/$dev", 1); };
+                $testcount++;
+                like($@, qr/already in use/, "used multipath device $dev should fail");
+            }
+
+            eval { PVE::Diskmanage::assert_disk_unused('/dev/dm-99', 1); };
+            $testcount++;
+            like($@, qr/not a valid local disk/, 'unknown device mapper device should fail');
         }
 
         done_testing($testcount);
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 10/27] diskmanage: add helper querying multipath path state
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (8 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 09/27] disks: lvm: allow creating volume groups on multipath devices Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 11/27] diskmanage: add helper querying NVMe native " Dietmar Maurer
                   ` (16 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Only multipathd knows the health of member paths: a faulty path stays
in the device mapper table (and thus in the sysfs slave list) until
the transport removes the device, so the slave count alone cannot
detect a degraded map. Parse "multipathd show maps json" into per-map
active path counts, fault counters and per-path states, keyed by
WWID. ALUA standby paths count as active, only paths the kernel
failed are unusable.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/Diskmanage.pm                         |  57 ++++++++
 src/test/disk_tests/multipath/multipathd      | 124 ++++++++++++++++++
 .../multipath/multipathd_status_expected.json |  42 ++++++
 src/test/disklist_test.pm                     |  20 +++
 4 files changed, 243 insertions(+)
 create mode 100644 src/test/disk_tests/multipath/multipathd
 create mode 100644 src/test/disk_tests/multipath/multipathd_status_expected.json

diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index ab5db06..a6730a6 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -20,6 +20,7 @@ my $SGDISK = "/sbin/sgdisk";
 my $PVS = "/sbin/pvs";
 my $LVS = "/sbin/lvs";
 my $LSBLK = "/bin/lsblk";
+my $MULTIPATHD = "/sbin/multipathd";
 
 my sub strip_dev : prototype($) {
     my ($devpath) = @_;
@@ -912,6 +913,62 @@ sub get_multipath_disks {
     return $disklist;
 }
 
+# Queries multipathd for the path state of all multipath mapped devices, keyed by their WWID.
+# Only the daemon knows the health of the member paths; a faulty path stays in the device
+# mapper table (and thus in the sysfs slave list) until the transport removes the device.
+# Dies if multipathd is not available.
+sub get_multipath_status {
+    die "multipathd is not available\n" if !check_bin($MULTIPATHD);
+
+    my $output = '';
+    run_command(
+        [$MULTIPATHD, 'show', 'maps', 'json'],
+        outfunc => sub { $output .= "$_[0]\n"; },
+        timeout => 5,
+    );
+
+    my $parsed = decode_json($output);
+
+    my $res = {};
+    for my $map (@{ $parsed->{maps} // [] }) {
+        my $wwid = $map->{uuid} // next;
+
+        my $paths = [];
+        my $active = 0;
+        for my $group (@{ $map->{path_groups} // [] }) {
+            for my $path (@{ $group->{paths} // [] }) {
+                my $dev = $path->{dev} // next;
+                my $state = $path->{dm_st} // 'undef';
+                # ALUA standby paths are 'active' with a 'ghost' checker state, only
+                # paths the kernel actually failed count as unusable
+                $active++ if $state ne 'failed';
+
+                my $entry = { device => "/dev/$dev", state => $state };
+                $entry->{'checker-state'} = $path->{chk_st} if defined($path->{chk_st});
+                my $wwpns = {
+                    'host-wwpn' => $path->{host_wwpn},
+                    'target-wwpn' => $path->{target_wwpn},
+                };
+                for my $key (sort keys %$wwpns) {
+                    my $wwpn = $wwpns->{$key} // next;
+                    # multipathd reports '[undef]' for transports without a WWPN
+                    next if $wwpn eq '' || $wwpn eq '[undef]';
+                    $entry->{$key} = $wwpn;
+                }
+                push @$paths, $entry;
+            }
+        }
+
+        $res->{$wwid} = {
+            active => $active,
+            faults => $map->{path_faults} // 0,
+            paths => $paths,
+        };
+    }
+
+    return $res;
+}
+
 # Returns the multipath device entry if the given path refers to a multipath mapped device.
 sub lookup_multipath_map {
     my ($devpath) = @_;
diff --git a/src/test/disk_tests/multipath/multipathd b/src/test/disk_tests/multipath/multipathd
new file mode 100644
index 0000000..40243f9
--- /dev/null
+++ b/src/test/disk_tests/multipath/multipathd
@@ -0,0 +1,124 @@
+{
+   "major_version": 0,
+   "minor_version": 1,
+   "maps": [{
+      "name" : "mpatha",
+      "uuid" : "3600140500000000000000000000000001",
+      "sysfs" : "dm-0",
+      "failback" : "immediate",
+      "queueing" : "5 chk",
+      "paths" : 2,
+      "write_prot" : "rw",
+      "dm_st" : "active",
+      "features" : "1 queue_if_no_path",
+      "hwhandler" : "1 alua",
+      "action" : "",
+      "path_faults" : 0,
+      "vend" : "SANVEND ",
+      "prod" : "SANMODEL",
+      "rev" : "4.1",
+      "switch_grp" : 0,
+      "map_loads" : 1,
+      "total_q_time" : 0,
+      "q_timeouts" : 0,
+      "path_groups": [{
+         "selector" : "service-time 0",
+         "pri" : 50,
+         "dm_st" : "active",
+         "marginal_st" : "normal",
+         "group" : 1,
+         "paths": [{
+            "dev" : "sda",
+            "dev_t" : "8:0",
+            "dm_st" : "active",
+            "dev_st" : "running",
+            "chk_st" : "ready",
+            "checker" : "tur",
+            "pri" : 50,
+            "host_wwnn" : "0x20000024ff7d6a1c",
+            "target_wwnn" : "0x2000d039ea28bf00",
+            "host_wwpn" : "0x21000024ff7d6a1c",
+            "target_wwpn" : "0x2100d039ea28bf00",
+            "host_adapter" : "0000:41:00.0",
+            "lun_hex" : "0x0001000000000000",
+            "marginal_st" : "normal"
+         },
+         {
+            "dev" : "sdb",
+            "dev_t" : "8:16",
+            "dm_st" : "active",
+            "dev_st" : "running",
+            "chk_st" : "ready",
+            "checker" : "tur",
+            "pri" : 50,
+            "host_wwnn" : "0x20000024ff7d6a1d",
+            "target_wwnn" : "0x2000d039ea28bf00",
+            "host_wwpn" : "0x21000024ff7d6a1d",
+            "target_wwpn" : "0x2200d039ea28bf00",
+            "host_adapter" : "0000:41:00.1",
+            "lun_hex" : "0x0001000000000000",
+            "marginal_st" : "normal"
+         }]
+      }]
+   },
+   {
+      "name" : "mpathb",
+      "uuid" : "3600140500000000000000000000000002",
+      "sysfs" : "dm-1",
+      "failback" : "immediate",
+      "queueing" : "5 chk",
+      "paths" : 2,
+      "write_prot" : "rw",
+      "dm_st" : "active",
+      "features" : "1 queue_if_no_path",
+      "hwhandler" : "1 alua",
+      "action" : "",
+      "path_faults" : 3,
+      "vend" : "SANVEND ",
+      "prod" : "SANMODEL",
+      "rev" : "4.1",
+      "switch_grp" : 0,
+      "map_loads" : 1,
+      "total_q_time" : 0,
+      "q_timeouts" : 0,
+      "path_groups": [{
+         "selector" : "service-time 0",
+         "pri" : 50,
+         "dm_st" : "active",
+         "marginal_st" : "normal",
+         "group" : 1,
+         "paths": [{
+            "dev" : "sdc",
+            "dev_t" : "8:32",
+            "dm_st" : "active",
+            "dev_st" : "running",
+            "chk_st" : "ready",
+            "checker" : "tur",
+            "pri" : 50,
+            "host_wwnn" : "0x20000024ff7d6a1c",
+            "target_wwnn" : "0x2000d039ea28bf00",
+            "host_wwpn" : "0x21000024ff7d6a1c",
+            "target_wwpn" : "0x2100d039ea28bf00",
+            "host_adapter" : "0000:41:00.0",
+            "lun_hex" : "0x0002000000000000",
+            "marginal_st" : "normal"
+         },
+         {
+            "dev" : "sdd",
+            "dev_t" : "8:48",
+            "dm_st" : "failed",
+            "dev_st" : "running",
+            "chk_st" : "faulty",
+            "checker" : "tur",
+            "pri" : 0,
+            "host_wwnn" : "0x20000024ff7d6a1d",
+            "target_wwnn" : "0x2000d039ea28bf00",
+            "host_wwpn" : "0x21000024ff7d6a1d",
+            "target_wwpn" : "0x2200d039ea28bf00",
+            "host_adapter" : "0000:41:00.1",
+            "lun_hex" : "0x0002000000000000",
+            "marginal_st" : "normal"
+         }]
+      }]
+   }]
+}
diff --git a/src/test/disk_tests/multipath/multipathd_status_expected.json b/src/test/disk_tests/multipath/multipathd_status_expected.json
new file mode 100644
index 0000000..d57e620
--- /dev/null
+++ b/src/test/disk_tests/multipath/multipathd_status_expected.json
@@ -0,0 +1,42 @@
+{
+    "3600140500000000000000000000000001": {
+        "active": 2,
+        "faults": 0,
+        "paths": [
+            {
+                "device": "/dev/sda",
+                "state": "active",
+                "checker-state": "ready",
+                "host-wwpn": "0x21000024ff7d6a1c",
+                "target-wwpn": "0x2100d039ea28bf00"
+            },
+            {
+                "device": "/dev/sdb",
+                "state": "active",
+                "checker-state": "ready",
+                "host-wwpn": "0x21000024ff7d6a1d",
+                "target-wwpn": "0x2200d039ea28bf00"
+            }
+        ]
+    },
+    "3600140500000000000000000000000002": {
+        "active": 1,
+        "faults": 3,
+        "paths": [
+            {
+                "device": "/dev/sdc",
+                "state": "active",
+                "checker-state": "ready",
+                "host-wwpn": "0x21000024ff7d6a1c",
+                "target-wwpn": "0x2100d039ea28bf00"
+            },
+            {
+                "device": "/dev/sdd",
+                "state": "failed",
+                "checker-state": "faulty",
+                "host-wwpn": "0x21000024ff7d6a1d",
+                "target-wwpn": "0x2200d039ea28bf00"
+            }
+        ]
+    }
+}
diff --git a/src/test/disklist_test.pm b/src/test/disklist_test.pm
index d2ea3b2..e804485 100644
--- a/src/test/disklist_test.pm
+++ b/src/test/disklist_test.pm
@@ -68,6 +68,8 @@ sub mocked_run_command {
                 $content = '{}';
             }
             @$outputlines = split(/\n/, $content);
+        } elsif ($cmd->[0] =~ m/multipathd/i) {
+            @$outputlines = split(/\n/, read_test_file('multipathd'));
         } else {
             die "unexpected run_command call: '@$cmd', aborting\n";
         }
@@ -257,6 +259,16 @@ sub test_disk_list {
             like($@, qr/not a valid local disk/, 'unknown device mapper device should fail');
         }
 
+        if (-f "disk_tests/$testcasedir/multipathd_status_expected.json") {
+            my $status = PVE::Diskmanage::get_multipath_status();
+            my $expected_status =
+                decode_json(read_test_file('multipathd_status_expected.json'));
+
+            print Dumper($status) if $print;
+            $testcount++;
+            is_deeply($status, $expected_status, 'multipath status should be the same');
+        }
+
         done_testing($testcount);
     };
 }
@@ -279,6 +291,14 @@ $diskmanage_module->mock('get_sysdir_size' => \&mocked_get_sysdir_size);
 print("\tMocked get_sysdir_size\n");
 $diskmanage_module->mock('assert_blockdev' => sub { return 1; });
 print("\tMocked assert_blockdev\n");
+$diskmanage_module->mock(
+    'check_bin' => sub {
+        my ($path) = @_;
+        return 1 if $path =~ m/multipathd/;
+        return &{ $diskmanage_module->original('check_bin') }($path);
+    },
+);
+print("\tMocked check_bin\n");
 $diskmanage_module->mock(
     'dir_is_empty' => sub {
         my $val = shift;
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 11/27] diskmanage: add helper querying NVMe native multipath path state
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (9 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 10/27] diskmanage: add helper querying multipath path state Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 12/27] api: scan: san-luns: report " Dietmar Maurer
                   ` (15 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

NVMe over fabrics namespaces are usually handled by native NVMe
multipath instead of device mapper, so multipathd knows nothing
about them. The kernel exposes the equivalent information in sysfs:
a per-path namespace entry below each controller of a shared
subsystem, with the controller state and address and the ANA state
of the namespace on that path. Collect it per head namespace, so
path health can be reported uniformly for both multipath stacks.
Local single-controller devices have no per-path entries and are
not reported.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/Diskmanage.pm                         | 63 +++++++++++++++++++
 .../nvme-subsystem/nvme-subsys0/nvme3/address |  1 +
 .../nvme-subsys0/nvme3/nvme2c3n1/ana_state    |  1 +
 .../nvme-subsystem/nvme-subsys0/nvme3/state   |  1 +
 .../nvme-subsystem/nvme-subsys0/nvme4/address |  1 +
 .../nvme-subsys0/nvme4/nvme2c4n1/ana_state    |  1 +
 .../nvme-subsystem/nvme-subsys0/nvme4/state   |  1 +
 .../nvme-subsystem/nvme-subsys1/nvme0/address |  1 +
 .../nvme-subsystem/nvme-subsys1/nvme0/state   |  1 +
 .../multipath/nvme_path_status_expected.json  | 19 ++++++
 src/test/disklist_test.pm                     | 16 +++++
 11 files changed, 106 insertions(+)
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/address
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/nvme2c3n1/ana_state
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/state
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/address
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/nvme2c4n1/ana_state
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/state
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/address
 create mode 100644 src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/state
 create mode 100644 src/test/disk_tests/multipath/nvme_path_status_expected.json

diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm
index a6730a6..706f696 100644
--- a/src/PVE/Diskmanage.pm
+++ b/src/PVE/Diskmanage.pm
@@ -969,6 +969,69 @@ sub get_multipath_status {
     return $res;
 }
 
+# Queries the path state of NVMe namespaces handled by native NVMe multipath, keyed by the
+# kernel device name of the head namespace (for example nvme0n1). The kernel creates a
+# per-path namespace entry (nvmeXcYnZ) below each controller of a shared subsystem; local
+# single-controller devices have none and are not reported.
+sub get_nvme_path_status {
+    my $res = {};
+
+    dir_glob_foreach(
+        '/sys/class/nvme-subsystem',
+        'nvme-subsys\d+',
+        sub {
+            my ($subsys) = @_;
+            my $subsysdir = "/sys/class/nvme-subsystem/$subsys";
+
+            dir_glob_foreach(
+                $subsysdir,
+                'nvme\d+',
+                sub {
+                    my ($ctrl) = @_;
+                    my $ctrldir = "$subsysdir/$ctrl";
+                    my $state = file_read_firstline("$ctrldir/state") // return;
+                    my $address = file_read_firstline("$ctrldir/address");
+
+                    dir_glob_foreach(
+                        $ctrldir,
+                        'nvme\d+c\d+n\d+',
+                        sub {
+                            my ($pathdev) = @_;
+                            return if $pathdev !~ m/^nvme(\d+)c\d+n(\d+)$/;
+                            my $head = "nvme$1n$2";
+
+                            my $entry = { device => "/dev/$ctrl", state => $state };
+                            $entry->{address} = $address
+                                if defined($address) && $address ne '';
+                            my $ana = file_read_firstline("$ctrldir/$pathdev/ana_state");
+                            $entry->{'ana-state'} = $ana if defined($ana);
+
+                            # 'change' is transient, inaccessible and persistent-loss
+                            # paths cannot serve IO even on a live controller
+                            my $usable = $state eq 'live'
+                                && (!defined($ana)
+                                    || $ana eq 'optimized'
+                                    || $ana eq 'non-optimized');
+
+                            my $status = $res->{$head} //= { active => 0, paths => [] };
+                            $status->{active}++ if $usable;
+                            push @{ $status->{paths} }, $entry;
+                        },
+                    );
+                },
+            );
+        },
+    );
+
+    # readdir order is not deterministic
+    for my $status (values %$res) {
+        @{ $status->{paths} } =
+            sort { $a->{device} cmp $b->{device} } @{ $status->{paths} };
+    }
+
+    return $res;
+}
+
 # Returns the multipath device entry if the given path refers to a multipath mapped device.
 sub lookup_multipath_map {
     my ($devpath) = @_;
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/address b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/address
new file mode 100644
index 0000000..803fab3
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/address
@@ -0,0 +1 @@
+traddr=192.168.3.209,trsvcid=4420,src_addr=192.168.2.171
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/nvme2c3n1/ana_state b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/nvme2c3n1/ana_state
new file mode 100644
index 0000000..e48c58f
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/nvme2c3n1/ana_state
@@ -0,0 +1 @@
+optimized
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/state b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/state
new file mode 100644
index 0000000..e23fe64
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme3/state
@@ -0,0 +1 @@
+live
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/address b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/address
new file mode 100644
index 0000000..3661874
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/address
@@ -0,0 +1 @@
+traddr=192.168.3.109,trsvcid=4420,src_addr=192.168.2.171
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/nvme2c4n1/ana_state b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/nvme2c4n1/ana_state
new file mode 100644
index 0000000..436c48e
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/nvme2c4n1/ana_state
@@ -0,0 +1 @@
+inaccessible
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/state b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/state
new file mode 100644
index 0000000..e23fe64
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys0/nvme4/state
@@ -0,0 +1 @@
+live
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/address b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/address
new file mode 100644
index 0000000..f99fcb1
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/address
@@ -0,0 +1 @@
+0000:02:00.0
diff --git a/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/state b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/state
new file mode 100644
index 0000000..e23fe64
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme-subsystem/nvme-subsys1/nvme0/state
@@ -0,0 +1 @@
+live
diff --git a/src/test/disk_tests/multipath/nvme_path_status_expected.json b/src/test/disk_tests/multipath/nvme_path_status_expected.json
new file mode 100644
index 0000000..62611b3
--- /dev/null
+++ b/src/test/disk_tests/multipath/nvme_path_status_expected.json
@@ -0,0 +1,19 @@
+{
+    "nvme2n1": {
+        "active": 1,
+        "paths": [
+            {
+                "device": "/dev/nvme3",
+                "state": "live",
+                "address": "traddr=192.168.3.209,trsvcid=4420,src_addr=192.168.2.171",
+                "ana-state": "optimized"
+            },
+            {
+                "device": "/dev/nvme4",
+                "state": "live",
+                "address": "traddr=192.168.3.109,trsvcid=4420,src_addr=192.168.2.171",
+                "ana-state": "inaccessible"
+            }
+        ]
+    }
+}
diff --git a/src/test/disklist_test.pm b/src/test/disklist_test.pm
index e804485..0b090f8 100644
--- a/src/test/disklist_test.pm
+++ b/src/test/disklist_test.pm
@@ -113,6 +113,7 @@ sub mocked_file_read_firstline {
     my $originalsub = $diskmanage_module->original('file_read_firstline');
 
     $path =~ s|^/sys/block|disk_tests/$testcasedir|;
+    $path =~ s|^/sys/class/nvme-subsystem|disk_tests/$testcasedir/nvme-subsystem|;
 
     return &$originalsub($path);
 }
@@ -122,6 +123,12 @@ sub mocked_dir_glob_foreach {
 
     my $lines = [];
 
+    # nvme subsystem trees are provided as directories, glob them like production code
+    if ($dir =~ s|^/sys/class/nvme-subsystem|disk_tests/$testcasedir/nvme-subsystem|) {
+        my $originalsub = $diskmanage_module->original('dir_glob_foreach');
+        return &$originalsub($dir, $regex, $sub);
+    }
+
     # read lines in from file
     if ($dir =~ m{^/sys/block$}) {
         @$lines = split(/\n/, read_test_file('disklist'));
@@ -269,6 +276,15 @@ sub test_disk_list {
             is_deeply($status, $expected_status, 'multipath status should be the same');
         }
 
+        if (-f "disk_tests/$testcasedir/nvme_path_status_expected.json") {
+            my $status = PVE::Diskmanage::get_nvme_path_status();
+            my $expected_status = decode_json(read_test_file('nvme_path_status_expected.json'));
+
+            print Dumper($status) if $print;
+            $testcount++;
+            is_deeply($status, $expected_status, 'nvme path status should be the same');
+        }
+
         done_testing($testcount);
     };
 }
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 12/27] api: scan: san-luns: report path state
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (10 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 11/27] diskmanage: add helper querying NVMe native " Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 13/27] iscsi plugin: list sessions of all transports and capture transport Dietmar Maurer
                   ` (14 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

The path count from the sysfs slave list cannot detect a degraded
multipath map: a faulty path stays in the device mapper table until
the transport removes the device. Enrich aggregated multipath
entries with the active path count, the fault counter and the
per-path states from multipathd, so clients can show path health
per LUN. NVMe namespaces handled by native NVMe multipath get the
equivalent controller and ANA states from sysfs. The queries are
best-effort, on failure the entries simply lack the new optional
fields.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/Storage/Scan.pm | 96 +++++++++++++++++++++++++++++++++++-
 1 file changed, 94 insertions(+), 2 deletions(-)

diff --git a/src/PVE/API2/Storage/Scan.pm b/src/PVE/API2/Storage/Scan.pm
index 9b944d5..b8e5bbe 100644
--- a/src/PVE/API2/Storage/Scan.pm
+++ b/src/PVE/API2/Storage/Scan.pm
@@ -381,7 +381,9 @@ __PACKAGE__->register_method({
     path => 'san-luns',
     method => 'GET',
     description => "List block devices (disks and multipath devices) together with their"
-        . " usage, for example as candidates for a SAN backed LVM volume group.",
+        . " usage, for example as candidates for a SAN backed LVM volume group. For multipath"
+        . " mapped devices and NVMe namespaces handled by native NVMe multipath, the state"
+        . " of the member paths is included.",
     protected => 1,
     proxyto => "node",
     permissions => {
@@ -418,10 +420,76 @@ __PACKAGE__->register_method({
                 serial => { type => 'string', optional => 1 },
                 wwn => { type => 'string', optional => 1 },
                 paths => {
-                    description => "The number of paths of a multipath mapped device.",
+                    description => "The number of paths of a multipath mapped device or of"
+                        . " an NVMe namespace attached through multiple controllers.",
                     type => 'integer',
                     optional => 1,
                 },
+                'active-paths' => {
+                    description => "The number of paths that are usable, that is, neither"
+                        . " reported as failed by multipathd nor on a dead controller or"
+                        . " inaccessible through ANA.",
+                    type => 'integer',
+                    optional => 1,
+                },
+                'path-faults' => {
+                    description => "The number of path failures of a multipath mapped"
+                        . " device since its creation, as reported by multipathd.",
+                    type => 'integer',
+                    optional => 1,
+                },
+                'path-list' => {
+                    description => "The member paths of the device with their state.",
+                    type => 'array',
+                    optional => 1,
+                    items => {
+                        type => 'object',
+                        properties => {
+                            device => {
+                                description => "The path block device, or the NVMe"
+                                    . " controller device the path uses.",
+                                type => 'string',
+                            },
+                            state => {
+                                description => "The device mapper state of the path (for"
+                                    . " example active or failed) or the state of the NVMe"
+                                    . " controller (for example live or connecting).",
+                                type => 'string',
+                            },
+                            'checker-state' => {
+                                description => "The state reported by the multipathd path"
+                                    . " checker, for example ready, ghost or faulty.",
+                                type => 'string',
+                                optional => 1,
+                            },
+                            'ana-state' => {
+                                description => "The Asymmetric Namespace Access state of"
+                                    . " the namespace on this path, for example optimized"
+                                    . " or inaccessible.",
+                                type => 'string',
+                                optional => 1,
+                            },
+                            'host-wwpn' => {
+                                description => "The WWPN of the host (initiator) port of"
+                                    . " Fibre Channel attached paths.",
+                                type => 'string',
+                                optional => 1,
+                            },
+                            'target-wwpn' => {
+                                description => "The WWPN of the target port of Fibre"
+                                    . " Channel attached paths.",
+                                type => 'string',
+                                optional => 1,
+                            },
+                            address => {
+                                description => "The address of the NVMe controller the"
+                                    . " path uses.",
+                                type => 'string',
+                                optional => 1,
+                            },
+                        },
+                    },
+                },
                 usage => {
                     description => "How the device is currently used. 'lvm' means it is an LVM"
                         . " physical volume, see the 'vgname' property.",
@@ -455,6 +523,15 @@ __PACKAGE__->register_method({
         my $disks = PVE::Diskmanage::get_disks(undef, 1, 0);
         my $multipath = PVE::Diskmanage::get_multipath_disks();
 
+        # path state is auxiliary information, a multipathd failure must not break the scan
+        my $mp_status = {};
+        if (scalar(keys %$multipath)) {
+            $mp_status = eval { PVE::Diskmanage::get_multipath_status() } // {};
+            warn $@ if $@;
+        }
+        my $nvme_status = eval { PVE::Diskmanage::get_nvme_path_status() } // {};
+        warn $@ if $@;
+
         # map PV device to VG name, PVs may sit on a partition of a listed device
         my $vgs = PVE::Storage::LVMPlugin::lvm_vgs(1);
         my $pv2vg = {};
@@ -507,6 +584,21 @@ __PACKAGE__->register_method({
                 $entry->{$key} = $disk->{$key} if defined($disk->{$key});
             }
 
+            # aggregated multipath entries carry their member list and their WWID as wwn
+            if (defined($disk->{slaves})) {
+                if (my $status = $mp_status->{ $disk->{wwn} // '' }) {
+                    $entry->{'active-paths'} = $status->{active};
+                    $entry->{'path-faults'} = $status->{faults};
+                    $entry->{'path-list'} = $status->{paths};
+                }
+            } elsif ($disk->{devpath} =~ m|^/dev/(nvme\d+n\d+)$|) {
+                if (my $status = $nvme_status->{$1}) {
+                    $entry->{paths} = scalar($status->{paths}->@*);
+                    $entry->{'active-paths'} = $status->{active};
+                    $entry->{'path-list'} = $status->{paths};
+                }
+            }
+
             if (!defined($disk->{used})) {
                 $entry->{usage} = 'unused';
             } elsif ($disk->{used} eq 'LVM') {
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 13/27] iscsi plugin: list sessions of all transports and capture transport
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (11 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 12/27] api: scan: san-luns: report " Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-storage 14/27] api: add node-level iSCSI initiator target and session API Dietmar Maurer
                   ` (13 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

The session list only matched software iSCSI (tcp) lines, so sessions
established over iser or offload initiators were invisible to the
session-based activation and status checks. Report every session
together with its transport; the node-level session API also needs
the transport to display it.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/Storage/ISCSIPlugin.pm | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index c18a4b6..4fbc5ad 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -48,10 +48,15 @@ sub iscsi_session_list {
             outfunc => sub {
                 my $line = shift;
                 # example: tcp: [1] 192.168.122.252:3260,1 iqn.2003-01.org.linux-iscsi.proxmox-nfs.x8664:sn.00567885ba8f (non-flash)
-                if ($line =~ m/^tcp:\s+\[(\S+)\]\s+(\S+:\d+)\,\S+\s+(\S+)\s+\S+?\s*$/) {
-                    my ($session_id, $portal, $target) = ($1, $2, $3);
+                if ($line =~ m/^(\S+):\s+\[(\S+)\]\s+(\S+:\d+)\,\S+\s+(\S+)\s+\S+?\s*$/) {
+                    my ($transport, $session_id, $portal, $target) = ($1, $2, $3, $4);
                     # there can be several sessions per target (multipath)
-                    push @{ $res->{$target} }, { session_id => $session_id, portal => $portal };
+                    push @{ $res->{$target} },
+                        {
+                            session_id => $session_id,
+                            portal => $portal,
+                            transport => $transport,
+                        };
                 }
             },
         );
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-storage 14/27] api: add node-level iSCSI initiator target and session API
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (12 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 13/27] iscsi plugin: list sessions of all transports and capture transport Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC proxmox-widget-toolkit 15/27] disk selectors: allow opting into remote devices Dietmar Maurer
                   ` (12 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

The iSCSI storage plugin manages session logins automatically on
storage activation, but nothing surfaces the resulting state: admins
cannot see which targets a node knows or is logged in to, how healthy
the sessions are, or which parameters were negotiated. Sessions and
node records also linger after their storage is removed from the
configuration, with no way to clean them up short of iscsiadm on the
shell.

Add a small observability-focused API at /nodes/{node}/iscsi: report
the initiator identity, list the known targets per portal with their
session state and the configured storage using them, read the
negotiated parameters of a session, rescan a session for new LUNs,
log out of a single session and remove the node records of a target
or of a single portal. The target list merges the initiator's node
records with the active sessions, so a target stays visible as
not-connected after a logout or while its portal is unreachable,
instead of silently disappearing.

Portals need special handling in two places, since iscsiadm reports
and accepts them in several representations: the list normalizes
them before merging (bracketed IPv6 in session listings, bare in
node listings, v4-mapped addresses for IPv4 portals reached over
IPv6 sockets), and record removal passes v4-mapped addresses back
with their IPv4 tail rewritten in hex groups, the only spelling
iscsiadm's portal parser accepts with a port.

Reads require Datastore.Allocate on /storage like the related
scan/iscsi and scan/san-luns endpoints, so the SAN views work with a
single privilege; logout, rescan and node record removal mutate node
state and require Sys.Modify.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/PVE/API2/ISCSI.pm          | 586 +++++++++++++++++++++++++++++++++
 src/PVE/API2/Makefile          |   1 +
 src/PVE/Storage/ISCSIPlugin.pm |  23 ++
 3 files changed, 610 insertions(+)
 create mode 100644 src/PVE/API2/ISCSI.pm

diff --git a/src/PVE/API2/ISCSI.pm b/src/PVE/API2/ISCSI.pm
new file mode 100644
index 0000000..6ce5e2a
--- /dev/null
+++ b/src/PVE/API2/ISCSI.pm
@@ -0,0 +1,586 @@
+package PVE::API2::ISCSI;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RESTHandler;
+use PVE::Storage;
+use PVE::Storage::ISCSIPlugin;
+use PVE::Tools qw(run_command file_get_contents file_read_firstline);
+
+use base qw(PVE::RESTHandler);
+
+# Node-level view of the local iSCSI initiator: report the initiator identity,
+# inspect the known targets with their session state and negotiated parameters,
+# rescan a session for new LUNs, log out of stale sessions and remove leftover
+# node records. Session logins are managed by the iSCSI storage plugin on
+# storage activation; this API only observes them and cleans up what no
+# configured storage owns anymore.
+
+my $ISCSIADM = '/usr/bin/iscsiadm';
+my $INITIATOR_NAME_FILE = '/etc/iscsi/initiatorname.iscsi';
+
+my $session_id_schema = {
+    description => "Kernel iSCSI session id.",
+    type => 'integer',
+    minimum => 0,
+};
+
+my $target_schema = {
+    description => "iSCSI target name (IQN or EUI).",
+    type => 'string',
+    minLength => 1,
+    maxLength => 255,
+};
+
+my $initiator_return = {
+    type => 'object',
+    properties => {
+        name => {
+            description => "The initiator IQN (InitiatorName).",
+            type => 'string',
+        },
+        alias => {
+            description => "The initiator alias (InitiatorAlias), if set.",
+            type => 'string',
+            optional => 1,
+        },
+    },
+};
+
+my $status_return = {
+    type => 'object',
+    properties => {
+        supported => {
+            description =>
+                "Whether iSCSI is supported, that is the open-iscsi package is installed.",
+            type => 'boolean',
+        },
+        initiator => {
+            %$initiator_return,
+            description => "The local initiator identity, if available.",
+            optional => 1,
+        },
+    },
+};
+
+my $target_return = {
+    type => 'object',
+    properties => {
+        target => {
+            description => "The target IQN.",
+            type => 'string',
+        },
+        portal => {
+            description => "The portal ('host:port') this entry refers to.",
+            type => 'string',
+        },
+        'session-id' => {
+            description => "The kernel session id; absent when there is no active session"
+                . " on this portal.",
+            type => 'integer',
+            optional => 1,
+        },
+        transport => {
+            description => "The transport the session runs over (for example 'tcp' or"
+                . " 'iser'); only set for active sessions.",
+            type => 'string',
+            optional => 1,
+        },
+        health => {
+            description => "The session health derived from the kernel session state:"
+                . " 'recovering' means the connection is down and iscsid is retrying the"
+                . " login, 'not-connected' means only a node record exists, without an"
+                . " active session.",
+            type => 'string',
+            enum => ['logged-in', 'recovering', 'logging-out', 'not-connected', 'unknown'],
+        },
+        'kernel-state' => {
+            description => "The raw kernel session state read from sysfs (for example"
+                . " 'LOGGED_IN'), if available.",
+            type => 'string',
+            optional => 1,
+        },
+        'used-by-storage' => {
+            description => "Storage identifier of a configured iSCSI-type storage using"
+                . " this target.",
+            type => 'string',
+            optional => 1,
+        },
+    },
+};
+
+my $session_params_return = {
+    type => 'object',
+    properties => {
+        'header-digest' => {
+            description => "The negotiated header digest.",
+            type => 'string',
+            optional => 1,
+        },
+        'data-digest' => {
+            description => "The negotiated data digest.",
+            type => 'string',
+            optional => 1,
+        },
+        'max-recv-data-segment-length' => {
+            description => "Max data segment length (bytes) the initiator accepts in a PDU.",
+            type => 'integer',
+            optional => 1,
+        },
+        'max-xmit-data-segment-length' => {
+            description => "Max data segment length (bytes) the target accepts in a PDU.",
+            type => 'integer',
+            optional => 1,
+        },
+        'first-burst-length' => {
+            description => "Max unsolicited data (bytes) sent with a command (first burst).",
+            type => 'integer',
+            optional => 1,
+        },
+        'max-burst-length' => {
+            description => "Max data (bytes) in one solicited burst.",
+            type => 'integer',
+            optional => 1,
+        },
+        'max-outstanding-r2t' => {
+            description => "Max number of outstanding Ready-To-Transfer PDUs.",
+            type => 'integer',
+            optional => 1,
+        },
+        'immediate-data' => {
+            description => "Whether unsolicited immediate data is allowed ('Yes'/'No').",
+            type => 'string',
+            optional => 1,
+        },
+        'initial-r2t' => {
+            description =>
+                "Whether the target requires an R2T before any data is sent ('Yes'/'No').",
+            type => 'string',
+            optional => 1,
+        },
+        'recovery-timeout' => {
+            description =>
+                "Seconds the session waits for path recovery before failing its commands.",
+            type => 'integer',
+            optional => 1,
+        },
+        'abort-timeout' => {
+            description => "Seconds to wait for a SCSI abort task to complete.",
+            type => 'integer',
+            optional => 1,
+        },
+        'lu-reset-timeout' => {
+            description => "Seconds to wait for a LUN reset to complete.",
+            type => 'integer',
+            optional => 1,
+        },
+        'tgt-reset-timeout' => {
+            description => "Seconds to wait for a target reset to complete.",
+            type => 'integer',
+            optional => 1,
+        },
+    },
+};
+
+my sub read_initiator_info {
+    my $content = eval { file_get_contents($INITIATOR_NAME_FILE) };
+    return undef if !defined($content);
+
+    my $res = {};
+    for my $line (split(/\n/, $content)) {
+        if ($line =~ m/^\s*InitiatorName\s*=\s*(\S+)\s*$/) {
+            $res->{name} = $1;
+        } elsif ($line =~ m/^\s*InitiatorAlias\s*=\s*(.+?)\s*$/) {
+            $res->{alias} = $1;
+        }
+    }
+    return defined($res->{name}) ? $res : undef;
+}
+
+my sub session_health {
+    my ($kernel_state) = @_;
+
+    return 'unknown' if !defined($kernel_state);
+    return 'logged-in' if $kernel_state eq 'LOGGED_IN';
+    return 'recovering' if $kernel_state eq 'FAILED';
+    return 'not-connected' if $kernel_state eq 'FREE';
+    return 'unknown';
+}
+
+# iscsiadm reports portals in several representations: session listings print
+# IPv6 addresses in brackets while node record listings do not, and connections
+# to IPv4 portals made over IPv6 sockets appear with a v4-mapped address
+# (::ffff:a.b.c.d). Normalize them so session and node record entries merge on
+# the same key, unmapping v4-mapped addresses and bracketing IPv6 addresses
+# like the storage configuration does.
+my sub normalize_portal {
+    my ($portal) = @_;
+    return $portal if $portal !~ m/^(.+):(\d+)$/;
+    my ($addr, $port) = ($1, $2);
+    $addr =~ s/^\[(.*)\]$/$1/;
+    $addr =~ s/^::ffff:(\d+\.\d+\.\d+\.\d+)$/$1/i;
+    $addr = "[$addr]" if $addr =~ m/:/;
+    return "$addr:$port";
+}
+
+# iscsiadm's --portal parser only accepts a port on a bracketed address, and
+# only takes the bracket path when the address contains no dots, so a stored
+# v4-mapped address (::ffff:a.b.c.d) cannot be passed back as printed in any
+# form. Rewrite an embedded IPv4 tail in hex groups: that spelling parses,
+# and iscsiadm resolves it back to the same address when matching records.
+my sub iscsiadm_portal_arg {
+    my ($portal) = @_;
+    my ($addr, $port) = $portal =~ m/^(.+):(\d+)$/;
+    return $portal if !defined($addr) || $addr !~ m/:/;
+    if ($addr =~ m/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/) {
+        $addr = sprintf("%s%x:%x", $1, ($2 << 8) + $3, ($4 << 8) + $5);
+    }
+    return "[$addr]:$port";
+}
+
+# One entry per known target and portal: the active sessions, merged with the
+# node records so a target stays visible as 'not-connected' after a logout or
+# while its portal is unreachable.
+my sub list_targets {
+    my $session_map = PVE::Storage::ISCSIPlugin::iscsi_session_list();
+    my $node_list = PVE::Storage::ISCSIPlugin::iscsi_node_list();
+
+    # map each target IQN to the first configured iSCSI-type storage using it
+    my $target_storage = {};
+    my $cfg = PVE::Storage::config();
+    for my $storeid (sort keys %{ $cfg->{ids} }) {
+        my $scfg = $cfg->{ids}->{$storeid};
+        next if $scfg->{type} ne 'iscsi' || !defined($scfg->{target});
+        $target_storage->{ $scfg->{target} } //= $storeid;
+    }
+
+    my $res = [];
+    my $have_session = {};
+    for my $target (sort keys %$session_map) {
+        for my $session (@{ $session_map->{$target} }) {
+            my $sid = int($session->{session_id});
+            my $state = file_read_firstline("/sys/class/iscsi_session/session${sid}/state");
+            my $portal = normalize_portal($session->{portal});
+            my $entry = {
+                target => $target,
+                'session-id' => $sid,
+                portal => $portal,
+                transport => $session->{transport},
+                health => session_health($state),
+            };
+            $entry->{'kernel-state'} = $state if defined($state);
+            $entry->{'used-by-storage'} = $target_storage->{$target}
+                if defined($target_storage->{$target});
+            push @$res, $entry;
+            $have_session->{"$target\0$portal"} = 1;
+        }
+    }
+
+    for my $node (@$node_list) {
+        my $portal = normalize_portal($node->{portal});
+        next if $have_session->{"$node->{target}\0$portal"};
+        my $entry = {
+            target => $node->{target},
+            portal => $portal,
+            health => 'not-connected',
+        };
+        $entry->{'used-by-storage'} = $target_storage->{ $node->{target} }
+            if defined($target_storage->{ $node->{target} });
+        push @$res, $entry;
+    }
+
+    return [sort { $a->{target} cmp $b->{target} || $a->{portal} cmp $b->{portal} } @$res];
+}
+
+# Map the interesting lines of 'iscsiadm -m session -P 2' (the 'Timeouts' and
+# 'Negotiated iSCSI params' sections) to the API field names.
+my $session_param_map = {
+    'HeaderDigest' => ['header-digest', 'string'],
+    'DataDigest' => ['data-digest', 'string'],
+    'MaxRecvDataSegmentLength' => ['max-recv-data-segment-length', 'integer'],
+    'MaxXmitDataSegmentLength' => ['max-xmit-data-segment-length', 'integer'],
+    'FirstBurstLength' => ['first-burst-length', 'integer'],
+    'MaxBurstLength' => ['max-burst-length', 'integer'],
+    'ImmediateData' => ['immediate-data', 'string'],
+    'InitialR2T' => ['initial-r2t', 'string'],
+    'MaxOutstandingR2T' => ['max-outstanding-r2t', 'integer'],
+    'Recovery Timeout' => ['recovery-timeout', 'integer'],
+    'Abort Timeout' => ['abort-timeout', 'integer'],
+    'LUN Reset Timeout' => ['lu-reset-timeout', 'integer'],
+    'Target Reset Timeout' => ['tgt-reset-timeout', 'integer'],
+};
+
+my sub read_session_params {
+    my ($session_id) = @_;
+
+    my $res = {};
+    run_command(
+        [$ISCSIADM, '--mode', 'session', '--sid', $session_id, '-P', '2'],
+        errmsg => "reading parameters of iSCSI session $session_id failed",
+        outfunc => sub {
+            my ($line) = @_;
+            return if $line !~ m/^\s*([^:]+?)\s*:\s*(.+?)\s*$/;
+            my ($key, $value) = ($1, $2);
+            my $field = $session_param_map->{$key} or return;
+            my ($name, $type) = @$field;
+            if ($type eq 'integer') {
+                return if $value !~ m/^\d+$/;
+                $value = int($value);
+            }
+            $res->{$name} = $value;
+        },
+    );
+    return $res;
+}
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    proxyto => 'node',
+    permissions => { user => 'all' },
+    description => "Node iSCSI initiator index.",
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+        },
+    },
+    returns => {
+        type => 'array',
+        items => {
+            type => "object",
+            properties => {},
+        },
+        links => [{ rel => 'child', href => "{name}" }],
+    },
+    code => sub {
+        return [
+            { name => 'rescan' },
+            { name => 'session-logout' },
+            { name => 'session-params' },
+            { name => 'status' },
+            { name => 'target' },
+        ];
+    },
+});
+
+# Reads require Datastore.Allocate on /storage like the related scan/iscsi and
+# scan/san-luns endpoints, so the SAN views work with a single privilege.
+# Session logout and rescan mutate node state and require Sys.Modify.
+__PACKAGE__->register_method({
+    name => 'status',
+    path => 'status',
+    method => 'GET',
+    protected => 1,
+    proxyto => 'node',
+    description => "Report iSCSI support and, when open-iscsi is installed, the"
+        . " initiator identity.",
+    permissions => {
+        check => ['perm', '/storage', ['Datastore.Allocate']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+        },
+    },
+    returns => $status_return,
+    code => sub {
+        my $res = { supported => -x $ISCSIADM ? 1 : 0 };
+        if ($res->{supported}) {
+            my $initiator = read_initiator_info();
+            $res->{initiator} = $initiator if defined($initiator);
+        }
+        return $res;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'targets',
+    path => 'target',
+    method => 'GET',
+    protected => 1,
+    proxyto => 'node',
+    description => "List the iSCSI targets known to the initiator, together with the state"
+        . " of their sessions.",
+    permissions => {
+        check => ['perm', '/storage', ['Datastore.Allocate']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+        },
+    },
+    returns => {
+        type => 'array',
+        items => $target_return,
+    },
+    code => sub {
+        return list_targets();
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'target_delete',
+    path => 'target',
+    method => 'DELETE',
+    protected => 1,
+    proxyto => 'node',
+    description => "Remove the node records of a target from the initiator database, either"
+        . " of a single portal or of the whole target. Requires the affected records to be"
+        . " logged out.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            target => $target_schema,
+            portal => {
+                description => "Only remove the node record of this portal.",
+                type => 'string',
+                minLength => 2,
+                maxLength => 256,
+                optional => 1,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+        my ($target, $portal) = ($param->{target}, $param->{portal});
+
+        my $sessions = PVE::Storage::ISCSIPlugin::iscsi_session_list()->{$target} // [];
+        for my $session (@$sessions) {
+            next if defined($portal) && normalize_portal($session->{portal}) ne $portal;
+            die "target '$target' still has an active session - log out first\n";
+        }
+
+        if (!defined($portal)) {
+            run_command(
+                [$ISCSIADM, '--mode', 'node', '--targetname', $target, '--op', 'delete'],
+                errmsg => "removing the node records of target '$target' failed",
+                outfunc => sub { },
+            );
+            return undef;
+        }
+
+        # look up the records by their normalized portal and address each one
+        # in the representation it is actually stored under
+        my @records =
+            grep { $_->{target} eq $target && normalize_portal($_->{portal}) eq $portal }
+            @{ PVE::Storage::ISCSIPlugin::iscsi_node_list() };
+        die "no node record for target '$target', portal '$portal' found\n"
+            if !scalar(@records);
+
+        for my $record (@records) {
+            run_command(
+                [
+                    $ISCSIADM,
+                    '--mode',
+                    'node',
+                    '--targetname',
+                    $target,
+                    '--portal',
+                    iscsiadm_portal_arg($record->{portal}),
+                    '--op',
+                    'delete',
+                ],
+                errmsg => "removing the node record of target '$target' failed",
+                outfunc => sub { },
+            );
+        }
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'session_params',
+    path => 'session-params',
+    method => 'GET',
+    protected => 1,
+    proxyto => 'node',
+    description => "Read the parameters an active iSCSI session negotiated at login.",
+    permissions => {
+        check => ['perm', '/storage', ['Datastore.Allocate']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            'session-id' => $session_id_schema,
+        },
+    },
+    returns => $session_params_return,
+    code => sub {
+        my ($param) = @_;
+        return read_session_params($param->{'session-id'});
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'session_logout',
+    path => 'session-logout',
+    method => 'POST',
+    protected => 1,
+    proxyto => 'node',
+    description => "Log out of a single iSCSI session, tearing down one path of a target.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            'session-id' => $session_id_schema,
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+        my $sid = $param->{'session-id'};
+        run_command(
+            [$ISCSIADM, '--mode', 'session', '--sid', $sid, '--logout'],
+            errmsg => "logout of iSCSI session $sid failed",
+            outfunc => sub { },
+        );
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'rescan',
+    path => 'rescan',
+    method => 'POST',
+    protected => 1,
+    proxyto => 'node',
+    description => "Rescan an iSCSI session to pick up newly attached LUNs.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            'session-id' => $session_id_schema,
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+        my $sid = $param->{'session-id'};
+        run_command(
+            [$ISCSIADM, '--mode', 'session', '--sid', $sid, '--rescan'],
+            errmsg => "rescan of iSCSI session $sid failed",
+            outfunc => sub { },
+        );
+        return undef;
+    },
+});
+
+1;
diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index fe316c5..10e18f9 100644
--- a/src/PVE/API2/Makefile
+++ b/src/PVE/API2/Makefile
@@ -3,5 +3,6 @@
 .PHONY: install
 install:
 	install -D -m 0644 Disks.pm ${DESTDIR}${PERLDIR}/PVE/API2/Disks.pm
+	install -D -m 0644 ISCSI.pm ${DESTDIR}${PERLDIR}/PVE/API2/ISCSI.pm
 	make -C Storage install
 	make -C Disks install
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 4fbc5ad..e9a585f 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -68,6 +68,29 @@ sub iscsi_session_list {
     return $res;
 }
 
+sub iscsi_node_list {
+    assert_iscsi_support();
+
+    my $res = [];
+    eval {
+        run_command(
+            [$ISCSIADM, '--mode', 'node'],
+            errmsg => 'iscsi node record scan failed',
+            outfunc => sub {
+                my $line = shift;
+                if ($line =~ $ISCSI_TARGET_RE) {
+                    push @$res, { portal => $1, target => $2 };
+                }
+            },
+        );
+    };
+    if (my $err = $@) {
+        die $err if $err !~ m/: No records found/i;
+    }
+
+    return $res;
+}
+
 sub iscsi_test_session {
     my ($sid) = @_;
 
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC proxmox-widget-toolkit 15/27] disk selectors: allow opting into remote devices
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (13 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-storage 14/27] api: add node-level iSCSI initiator target and session API Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 16/27] ui: storage: allow switching the scan node of the NFS/CIFS scan combos Dietmar Maurer
                   ` (11 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Generic disk selectors omit devices attached through known remote
storage transports by default, because selecting them for node-local
storage without an explicit opt-in is risky.

Allow specialized callers to request the full list through the new
includeRemoteDisks option.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 src/form/DiskSelector.js      | 8 ++++++++
 src/form/MultiDiskSelector.js | 8 ++++++++
 2 files changed, 16 insertions(+)

diff --git a/src/form/DiskSelector.js b/src/form/DiskSelector.js
index c2fb924..ec1f8de 100644
--- a/src/form/DiskSelector.js
+++ b/src/form/DiskSelector.js
@@ -11,6 +11,10 @@ Ext.define('Proxmox.form.DiskSelector', {
     // use include-partitions=1 as a parameter
     includePartitions: false,
 
+    // also list devices attached through a remote storage transport (for
+    // example FC, iSCSI, or NVMe over TCP), which the backend excludes by default
+    includeRemoteDisks: false,
+
     // the property the backend wants for the type ('type' by default)
     typeProperty: 'type',
 
@@ -60,6 +64,10 @@ Ext.define('Proxmox.form.DiskSelector', {
             extraParams['include-partitions'] = 1;
         }
 
+        if (me.includeRemoteDisks) {
+            extraParams['include-remote'] = 1;
+        }
+
         var store = Ext.create('Ext.data.Store', {
             filterOnLoad: true,
             model: 'pmx-disk-list',
diff --git a/src/form/MultiDiskSelector.js b/src/form/MultiDiskSelector.js
index 50e819b..98402bc 100644
--- a/src/form/MultiDiskSelector.js
+++ b/src/form/MultiDiskSelector.js
@@ -27,6 +27,10 @@ Ext.define('Proxmox.form.MultiDiskSelector', {
     // add include-partitions=1 as a request parameter
     includePartitions: false,
 
+    // also list devices attached through a remote storage transport (for
+    // example FC, iSCSI, or NVMe over TCP), which the backend excludes by default
+    includeRemoteDisks: false,
+
     disks: [],
 
     allowBlank: false,
@@ -159,6 +163,10 @@ Ext.define('Proxmox.form.MultiDiskSelector', {
             if (me.includePartitions) {
                 extraParams['include-partitions'] = 1;
             }
+
+            if (me.includeRemoteDisks) {
+                extraParams['include-remote'] = 1;
+            }
         }
 
         me.disks = [];
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 16/27] ui: storage: allow switching the scan node of the NFS/CIFS scan combos
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (14 preceding siblings ...)
  2026-07-31 10:21 ` [RFC proxmox-widget-toolkit 15/27] disk selectors: allow opting into remote devices Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 17/27] ui: storage: add guided remote storage wizard with NFS support Dietmar Maurer
                   ` (10 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

The iSCSI and LVM scan combos already support scanning from an
arbitrary cluster node, but the NFS and CIFS share scanners have the
node baked into the store proxy URL at creation time. Add a public
setNodeName() so callers with an explicit scan node selector can
redirect the scan. No behavior change for the existing edit dialogs.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/storage/CIFSEdit.js | 10 ++++++++++
 www/manager6/storage/NFSEdit.js  | 10 ++++++++++
 2 files changed, 20 insertions(+)

diff --git a/www/manager6/storage/CIFSEdit.js b/www/manager6/storage/CIFSEdit.js
index 5fe3fefe..5e19bc16 100644
--- a/www/manager6/storage/CIFSEdit.js
+++ b/www/manager6/storage/CIFSEdit.js
@@ -72,6 +72,16 @@ Ext.define('PVE.storage.CIFSScan', {
         }
     },
 
+    setNodeName: function (nodename) {
+        let me = this;
+        nodename = nodename || 'localhost';
+        if (me.nodename !== nodename) {
+            me.nodename = nodename;
+            me.getStore().getProxy().setUrl(`/api2/json/nodes/${nodename}/scan/cifs`);
+            me.resetProxy();
+        }
+    },
+
     initComponent: function () {
         var me = this;
 
diff --git a/www/manager6/storage/NFSEdit.js b/www/manager6/storage/NFSEdit.js
index 72db156f..af522d2b 100644
--- a/www/manager6/storage/NFSEdit.js
+++ b/www/manager6/storage/NFSEdit.js
@@ -33,6 +33,16 @@ Ext.define('PVE.storage.NFSScan', {
         me.nfsServer = server;
     },
 
+    setNodeName: function (nodename) {
+        let me = this;
+        nodename = nodename || 'localhost';
+        if (me.nodename !== nodename) {
+            me.nodename = nodename;
+            me.getStore().getProxy().setUrl(`/api2/json/nodes/${nodename}/scan/nfs`);
+            me.lastQuery = null;
+        }
+    },
+
     initComponent: function () {
         var me = this;
 
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 17/27] ui: storage: add guided remote storage wizard with NFS support
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (15 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 16/27] ui: storage: allow switching the scan node of the NFS/CIFS scan combos Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 18/27] ui: storage wizard: add SMB/CIFS support Dietmar Maurer
                   ` (9 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Adding remote storage currently requires picking the right plugin type
from the Add menu and filling a single type-specific dialog, which
assumes the user already knows the PVE storage model. Introduce a
question-driven wizard that first asks what kind of storage should be
added and then walks through connection, selection and common settings
with a final confirmation step.

The wizard builds its tab chain from a per-type registry, so each
storage type is contained in its own file. Changing the answer on the
type question swaps the window for a freshly built wizard, since the
wizard base wires field validity tracking only at creation time. The
submit path runs a chain of API requests, preparing for types that
need to create multiple storage entries in one go.

This adds the infrastructure, the shared settings tab and the NFS
flow; the wizard is not yet reachable from the UI.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile                         |   3 +
 www/manager6/storage/wizard/CommonSettings.js |  73 +++++
 www/manager6/storage/wizard/NFS.js            |  91 ++++++
 www/manager6/storage/wizard/Wizard.js         | 268 ++++++++++++++++++
 4 files changed, 435 insertions(+)
 create mode 100644 www/manager6/storage/wizard/CommonSettings.js
 create mode 100644 www/manager6/storage/wizard/NFS.js
 create mode 100644 www/manager6/storage/wizard/Wizard.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index eb0e9d9c..10340b5a 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -377,6 +377,9 @@ JSSRC= 							\
 	storage/ZFSEdit.js				\
 	storage/ZFSPoolEdit.js				\
 	storage/ESXIEdit.js				\
+	storage/wizard/Wizard.js			\
+	storage/wizard/CommonSettings.js		\
+	storage/wizard/NFS.js				\
 	Workspace.js					\
 # end of JSSRC list
 
diff --git a/www/manager6/storage/wizard/CommonSettings.js b/www/manager6/storage/wizard/CommonSettings.js
new file mode 100644
index 00000000..a49bb8f8
--- /dev/null
+++ b/www/manager6/storage/wizard/CommonSettings.js
@@ -0,0 +1,73 @@
+Ext.define('PVE.storage.wizard.CommonSettings', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardCommonSettings',
+
+    onlineHelp: 'chapter_storage',
+
+    // content types offered by the content selector (undefined for all)
+    cts: undefined,
+    // preselected content types
+    defaultContent: undefined,
+    // content is not user-selectable, the submit handler uses this value
+    fixedContent: undefined,
+    // extra config merged into the content selector, for example
+    // mode-dependent bindings
+    contentFieldConfig: undefined,
+
+    onGetValues: function (values) {
+        values.disable = values.enable ? 0 : 1;
+        delete values.enable;
+        return values;
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.column1 = [
+            {
+                xtype: 'textfield',
+                name: 'storage',
+                fieldLabel: 'ID',
+                vtype: 'StorageId',
+                allowBlank: false,
+            },
+        ];
+
+        if (!me.fixedContent) {
+            me.column1.push(
+                Ext.apply(
+                    {
+                        xtype: 'pveContentTypeSelector',
+                        cts: me.cts,
+                        name: 'content',
+                        value: me.defaultContent,
+                        multiSelect: true,
+                        fieldLabel: gettext('Content'),
+                        allowBlank: false,
+                    },
+                    me.contentFieldConfig,
+                ),
+            );
+        }
+
+        me.column2 = [
+            {
+                xtype: 'pveNodeSelector',
+                name: 'nodes',
+                fieldLabel: gettext('Nodes'),
+                emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')',
+                multiSelect: true,
+                autoSelect: false,
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                name: 'enable',
+                checked: true,
+                uncheckedValue: 0,
+                fieldLabel: gettext('Enable'),
+            },
+        ];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/storage/wizard/NFS.js b/www/manager6/storage/wizard/NFS.js
new file mode 100644
index 00000000..afb4f455
--- /dev/null
+++ b/www/manager6/storage/wizard/NFS.js
@@ -0,0 +1,91 @@
+Ext.define('PVE.storage.wizard.NFSConnection', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardNFSConnection',
+
+    onlineHelp: 'storage_nfs',
+
+    onGetValues: function (values) {
+        if (values.nfsversion && values.nfsversion !== '__default__') {
+            values.options = `vers=${values.nfsversion}`;
+        }
+        delete values.nfsversion;
+        return values;
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.column1 = [
+            {
+                xtype: 'textfield',
+                name: 'server',
+                fieldLabel: gettext('Server'),
+                allowBlank: false,
+                listeners: {
+                    change: function (f, value) {
+                        let exportField = me.down('field[name=export]');
+                        exportField.setServer(value);
+                        exportField.setValue('');
+                    },
+                },
+            },
+            {
+                xtype: 'pveNFSScan',
+                name: 'export',
+                fieldLabel: 'Export',
+                allowBlank: false,
+            },
+        ];
+
+        me.column2 = [];
+        if (!PVE.Utils.isStandaloneNode()) {
+            me.column2.push({
+                xtype: 'pveStorageScanNodeSelector',
+                listeners: {
+                    change: function (f, value) {
+                        me.down('field[name=export]').setNodeName(value);
+                        let wizard = me.up('window');
+                        wizard.scanNode = value;
+                        wizard.down('field[name=nodes]').setValue(value);
+                    },
+                },
+            });
+        }
+
+        me.advancedColumn1 = [
+            {
+                xtype: 'proxmoxKVComboBox',
+                fieldLabel: gettext('NFS Version'),
+                name: 'nfsversion',
+                value: '__default__',
+                deleteEmpty: false,
+                comboItems: [
+                    ['__default__', Proxmox.Utils.defaultText],
+                    ['3', '3'],
+                    ['4', '4'],
+                    ['4.1', '4.1'],
+                    ['4.2', '4.2'],
+                ],
+            },
+        ];
+
+        me.callParent();
+    },
+});
+
+PVE.storage.wizard.types.nfs = {
+    text: 'NFS',
+    description: gettext('Shared folder on a NAS or file server, accessed via the NFS protocol.'),
+    group: 'nas',
+    apiType: 'nfs',
+    steps: () => [
+        {
+            xtype: 'pveStorageWizardNFSConnection',
+            title: gettext('Connection'),
+        },
+    ],
+    settings: {
+        onlineHelp: 'storage_nfs',
+        defaultContent: ['images'],
+    },
+};
diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js
new file mode 100644
index 00000000..f4118a79
--- /dev/null
+++ b/www/manager6/storage/wizard/Wizard.js
@@ -0,0 +1,268 @@
+Ext.ns('PVE.storage.wizard');
+
+// Registry of storage types offered by the remote storage wizard. Each
+// per-type file registers an entry with the following properties:
+// - text, description: chooser radio label and explanation
+// - group: chooser group key, see PVE.storage.wizard.groups
+// - apiType: storage plugin type for a plain single-storage creation
+// - steps(): tab configs shown between the chooser and the settings tab
+// - settings: extra config for PVE.storage.wizard.CommonSettings
+// - viewModel: optional view model config for cross-tab state
+// - summaryNotes(values): optional list of hints shown on the confirm tab
+// - submit(wizard, values): custom submit handler, defaults to a single
+//   POST to /storage with type set to apiType
+PVE.storage.wizard.types = {};
+
+// Chooser groups, in display order.
+PVE.storage.wizard.groups = [
+    { key: 'nas', text: gettext('File Storage (NAS)') },
+    { key: 'san', text: gettext('Block Storage (SAN)') },
+];
+
+Ext.define('PVE.storage.wizard.RemoteStorage', {
+    extend: 'PVE.window.Wizard',
+    alias: 'widget.pveRemoteStorageWizard',
+
+    subject: gettext('Remote Storage'),
+
+    wizardType: 'nfs',
+
+    // called on destroy, when the wizard may have changed the storage config
+    reloadCallback: undefined,
+
+    // node used for storage scans, updated by the scan node selectors
+    scanNode: undefined,
+
+    // the view model config must be in place before the component config
+    // system runs, initComponent would be too late for the field bindings
+    constructor: function (config) {
+        let me = this;
+        let entry = PVE.storage.wizard.types[config?.wizardType || me.wizardType];
+        if (entry?.viewModel) {
+            config = Ext.apply({ viewModel: Ext.clone(entry.viewModel) }, config);
+        }
+        me.callParent([config]);
+    },
+
+    changeWizardType: function (type) {
+        let me = this;
+        if (!type || type === me.wizardType) {
+            return;
+        }
+        Ext.create('PVE.storage.wizard.RemoteStorage', {
+            wizardType: type,
+            reloadCallback: me.reloadCallback,
+            autoShow: true,
+        });
+        me.close();
+    },
+
+    // run async submit steps in order and close the wizard when all
+    // succeeded; a step calls next() on success and simply returns after
+    // reporting a failure, so a later Finish retries the remaining steps
+    runSubmitChain: function (steps) {
+        let me = this;
+        let run = function (idx) {
+            if (idx >= steps.length) {
+                me.close();
+                return;
+            }
+            steps[idx](() => run(idx + 1));
+        };
+        run(0);
+    },
+
+    // POST one storage creation, dropping empty optional values; errorHtml
+    // (safe HTML) is prepended to the error message, for example to explain
+    // what earlier steps already created
+    createStorage: function (params, next, errorHtml) {
+        let me = this;
+        params = Ext.apply({}, params);
+        Ext.Object.each(params, function (key, value) {
+            if (value === undefined || value === null || (Ext.isArray(value) && !value.length)) {
+                delete params[key];
+            }
+        });
+        Proxmox.Utils.API2Request({
+            url: '/storage',
+            method: 'POST',
+            waitMsgTarget: me,
+            params: params,
+            success: () => next(),
+            failure: function (response) {
+                let msg = response.htmlStatus;
+                if (errorHtml) {
+                    msg = `${errorHtml}<br><br>${msg}`;
+                }
+                Ext.Msg.alert(gettext('Error'), msg);
+            },
+        });
+    },
+
+    submitSimple: function (values) {
+        let me = this;
+        let entry = PVE.storage.wizard.types[me.wizardType];
+        values.type = entry.apiType;
+        if (entry.settings?.fixedContent) {
+            values.content = entry.settings.fixedContent;
+        }
+        me.runSubmitChain([(next) => me.createStorage(values, next)]);
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let entry = PVE.storage.wizard.types[me.wizardType];
+        if (!entry) {
+            throw `unknown wizard storage type: ${me.wizardType}`;
+        }
+
+        let chooserItems = [
+            {
+                xtype: 'displayfield',
+                value: gettext('What kind of storage do you want to add?'),
+            },
+        ];
+        for (const group of PVE.storage.wizard.groups) {
+            let typeRadios = Object.entries(PVE.storage.wizard.types)
+                .filter(([, typeEntry]) => typeEntry.group === group.key)
+                .map(([type, typeEntry]) => ({
+                    boxLabel: `<b>${typeEntry.text}</b> - ${typeEntry.description}`,
+                    name: 'wizardType',
+                    inputValue: type,
+                    checked: type === me.wizardType,
+                    submitValue: false,
+                    margin: '0 0 10 0',
+                }));
+            if (!typeRadios.length) {
+                continue;
+            }
+            chooserItems.push({
+                xtype: 'displayfield',
+                value: `<b>${group.text}</b>`,
+            });
+            chooserItems.push({
+                xtype: 'radiogroup',
+                columns: 1,
+                vertical: true,
+                margin: '0 0 0 15',
+                items: typeRadios,
+                listeners: {
+                    change: function (rg, value) {
+                        // ignore the uncheck event fired when a radio in
+                        // another group takes over the selection
+                        if (!value.wizardType) {
+                            return;
+                        }
+                        let wizard = rg.up('window');
+                        // let the radio group finish its change
+                        // handling before it gets destroyed
+                        Ext.defer(() => wizard.changeWizardType(value.wizardType), 10);
+                    },
+                },
+            });
+        }
+
+        me.items = [
+            {
+                xtype: 'inputpanel',
+                title: gettext('Storage Type'),
+                onlineHelp: 'chapter_storage',
+                items: chooserItems,
+            },
+            ...entry.steps(),
+            Ext.apply(
+                {
+                    xtype: 'pveStorageWizardCommonSettings',
+                    title: gettext('General'),
+                },
+                entry.settings,
+            ),
+            {
+                title: gettext('Confirm'),
+                layout: { type: 'vbox', align: 'stretch' },
+                defaults: { border: false },
+                items: [
+                    {
+                        xtype: 'grid',
+                        flex: 1,
+                        store: {
+                            model: 'KeyValue',
+                            sorters: [{ property: 'key', direction: 'ASC' }],
+                        },
+                        columns: [
+                            { header: gettext('Key'), width: 150, dataIndex: 'key' },
+                            {
+                                header: gettext('Value'),
+                                flex: 1,
+                                dataIndex: 'value',
+                                renderer: Ext.htmlEncode,
+                            },
+                        ],
+                    },
+                    {
+                        xtype: 'container',
+                        itemId: 'summaryNotes',
+                        padding: '5 0 0 0',
+                        defaults: {
+                            xtype: 'displayfield',
+                            userCls: 'pmx-hint',
+                            margin: 0,
+                        },
+                    },
+                ],
+                listeners: {
+                    show: function (panel) {
+                        let wizard = panel.up('window');
+                        let values = wizard.getValues();
+
+                        let data = [];
+                        Ext.Object.each(values, function (key, value) {
+                            if (key === 'delete') {
+                                return;
+                            }
+                            if (key === 'password') {
+                                value = '********';
+                            }
+                            data.push({ key, value });
+                        });
+
+                        let summarystore = panel.down('grid').getStore();
+                        summarystore.suspendEvents();
+                        summarystore.removeAll();
+                        summarystore.add(data);
+                        summarystore.sort();
+                        summarystore.resumeEvents();
+                        summarystore.fireEvent('refresh');
+
+                        let typeEntry = PVE.storage.wizard.types[wizard.wizardType];
+                        let notes = panel.down('#summaryNotes');
+                        notes.removeAll();
+                        (typeEntry.summaryNotes?.(values) || []).forEach((note) =>
+                            notes.add({ value: note }),
+                        );
+                    },
+                },
+                onSubmit: function () {
+                    let wizard = this.up('window');
+                    let values = wizard.getValues();
+                    delete values.delete;
+                    let typeEntry = PVE.storage.wizard.types[wizard.wizardType];
+                    if (typeEntry.submit) {
+                        typeEntry.submit(wizard, values);
+                    } else {
+                        wizard.submitSimple(values);
+                    }
+                },
+            },
+        ];
+
+        me.on('destroy', function () {
+            if (me.reloadCallback) {
+                me.reloadCallback();
+            }
+        });
+
+        me.callParent();
+    },
+});
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 18/27] ui: storage wizard: add SMB/CIFS support
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (16 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 17/27] ui: storage: add guided remote storage wizard with NFS support Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 19/27] ui: storage wizard: add iSCSI support Dietmar Maurer
                   ` (8 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Reuse the CIFS share scanner from the edit dialog and keep the rarely
needed domain and subdirectory options in the advanced section.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile               |   1 +
 www/manager6/storage/wizard/CIFS.js | 117 ++++++++++++++++++++++++++++
 2 files changed, 118 insertions(+)
 create mode 100644 www/manager6/storage/wizard/CIFS.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 10340b5a..3020ca05 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -380,6 +380,7 @@ JSSRC= 							\
 	storage/wizard/Wizard.js			\
 	storage/wizard/CommonSettings.js		\
 	storage/wizard/NFS.js				\
+	storage/wizard/CIFS.js				\
 	Workspace.js					\
 # end of JSSRC list
 
diff --git a/www/manager6/storage/wizard/CIFS.js b/www/manager6/storage/wizard/CIFS.js
new file mode 100644
index 00000000..d9e5f5cf
--- /dev/null
+++ b/www/manager6/storage/wizard/CIFS.js
@@ -0,0 +1,117 @@
+Ext.define('PVE.storage.wizard.CIFSConnection', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardCIFSConnection',
+
+    onlineHelp: 'storage_cifs',
+
+    onGetValues: function (values) {
+        ['username', 'password', 'domain', 'subdir'].forEach(function (opt) {
+            if (values[opt]?.length === 0) {
+                delete values[opt];
+            }
+        });
+        return values;
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let shareField = () => me.down('field[name=share]');
+
+        me.column1 = [
+            {
+                xtype: 'textfield',
+                name: 'server',
+                fieldLabel: gettext('Server'),
+                allowBlank: false,
+                listeners: {
+                    change: (f, value) => shareField().setServer(value),
+                },
+            },
+            {
+                xtype: 'textfield',
+                name: 'username',
+                fieldLabel: gettext('Username'),
+                emptyText: gettext('Guest user'),
+                allowBlank: true,
+                listeners: {
+                    change: (f, value) => shareField().setUsername(value),
+                },
+            },
+            {
+                xtype: 'textfield',
+                inputType: 'password',
+                name: 'password',
+                fieldLabel: gettext('Password'),
+                emptyText: gettext('None'),
+                minLength: 1,
+                allowBlank: true,
+                listeners: {
+                    change: (f, value) => shareField().setPassword(value),
+                },
+            },
+            {
+                xtype: 'pveCIFSScan',
+                name: 'share',
+                fieldLabel: 'Share',
+                allowBlank: false,
+            },
+        ];
+
+        me.column2 = [];
+        if (!PVE.Utils.isStandaloneNode()) {
+            me.column2.push({
+                xtype: 'pveStorageScanNodeSelector',
+                listeners: {
+                    change: function (f, value) {
+                        shareField().setNodeName(value);
+                        let wizard = me.up('window');
+                        wizard.scanNode = value;
+                        wizard.down('field[name=nodes]').setValue(value);
+                    },
+                },
+            });
+        }
+
+        me.advancedColumn1 = [
+            {
+                xtype: 'textfield',
+                name: 'domain',
+                fieldLabel: gettext('Domain'),
+                allowBlank: true,
+                listeners: {
+                    change: (f, value) => shareField().setDomain(value),
+                },
+            },
+        ];
+
+        me.advancedColumn2 = [
+            {
+                xtype: 'textfield',
+                name: 'subdir',
+                fieldLabel: gettext('Subdirectory'),
+                allowBlank: true,
+                emptyText: gettext('/some/path'),
+            },
+        ];
+
+        me.callParent();
+    },
+});
+
+PVE.storage.wizard.types.cifs = {
+    text: 'SMB/CIFS',
+    description: gettext('Shared folder on a Windows server, NAS or Samba server.'),
+    group: 'nas',
+    apiType: 'cifs',
+    steps: () => [
+        {
+            xtype: 'pveStorageWizardCIFSConnection',
+            title: gettext('Connection'),
+        },
+    ],
+    settings: {
+        onlineHelp: 'storage_cifs',
+        defaultContent: ['images'],
+    },
+};
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 19/27] ui: storage wizard: add iSCSI support
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (17 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 18/27] ui: storage wizard: add SMB/CIFS support Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 20/27] ui: storage wizard: add FC-attached SAN (shared LVM) support Dietmar Maurer
                   ` (7 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Using an iSCSI SAN properly usually means two storage entries: an
iSCSI storage for the connection and a shared LVM storage on top of a
LUN, with the backend creating the volume group automatically. The
existing dialogs leave this setup to the user; the wizard asks how the
SAN space should be used and defaults to the shared LVM setup, with
direct LUN usage as alternative.

For the LVM setup the submit chain first creates the iSCSI storage,
because LUNs can only be listed through an existing storage entry.
When the target provides more than one LUN, a small prompt asks which
one to use. If a later step fails, the wizard stays open and explains
what was already created; retrying skips the completed steps. Removing
the already created iSCSI storage again is intentionally not attempted.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile                |   1 +
 www/manager6/storage/wizard/IScsi.js | 351 +++++++++++++++++++++++++++
 2 files changed, 352 insertions(+)
 create mode 100644 www/manager6/storage/wizard/IScsi.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 3020ca05..e0a11e5e 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -381,6 +381,7 @@ JSSRC= 							\
 	storage/wizard/CommonSettings.js		\
 	storage/wizard/NFS.js				\
 	storage/wizard/CIFS.js				\
+	storage/wizard/IScsi.js				\
 	Workspace.js					\
 # end of JSSRC list
 
diff --git a/www/manager6/storage/wizard/IScsi.js b/www/manager6/storage/wizard/IScsi.js
new file mode 100644
index 00000000..9ffb5005
--- /dev/null
+++ b/www/manager6/storage/wizard/IScsi.js
@@ -0,0 +1,351 @@
+Ext.define('PVE.storage.wizard.IScsiTarget', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardIScsiTarget',
+
+    onlineHelp: 'storage_open_iscsi',
+
+    initComponent: function () {
+        let me = this;
+
+        me.column1 = [
+            {
+                xtype: 'textfield',
+                name: 'portal',
+                fieldLabel: 'Portal',
+                allowBlank: false,
+                listeners: {
+                    change: {
+                        fn: function (f, value) {
+                            let targetField = me.down('field[name=target]');
+                            targetField.setDisabled(!value);
+                            targetField.setPortal(value);
+                            targetField.setValue('');
+                        },
+                        buffer: 500,
+                    },
+                },
+            },
+            {
+                xtype: 'pveIScsiScan',
+                name: 'target',
+                fieldLabel: gettext('Target'),
+                disabled: true,
+                showNodeSelector: false,
+            },
+        ];
+
+        me.column2 = [];
+        if (!PVE.Utils.isStandaloneNode()) {
+            me.column2.push({
+                xtype: 'pveStorageScanNodeSelector',
+                listeners: {
+                    change: function (f, value) {
+                        me.down('field[name=target]').setNodeName(value);
+                        let wizard = me.up('window');
+                        wizard.scanNode = value;
+                        wizard.down('field[name=nodes]').setValue(value);
+                    },
+                },
+            });
+        }
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.wizard.IScsiUsage', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardIScsiUsage',
+
+    onlineHelp: 'storage_open_iscsi',
+
+    items: [
+        {
+            xtype: 'displayfield',
+            value: gettext('How should the SAN space be used?'),
+        },
+        {
+            xtype: 'radiogroup',
+            columns: 1,
+            vertical: true,
+            items: [
+                {
+                    boxLabel:
+                        `<b>${gettext('Shared LVM on a LUN (recommended)')}</b> - ` +
+                        gettext(
+                            'Create an LVM volume group on a LUN. Disk images are allocated as logical volumes, usable from all nodes.',
+                        ),
+                    name: 'usage',
+                    inputValue: 'lvm',
+                    checked: true,
+                    margin: '0 0 10 0',
+                },
+                {
+                    boxLabel:
+                        `<b>${gettext('Use LUNs directly')}</b> - ` +
+                        gettext(
+                            'Each existing LUN is used one-to-one as a disk image. LUN management stays on the SAN.',
+                        ),
+                    name: 'usage',
+                    inputValue: 'direct',
+                },
+            ],
+            listeners: {
+                change: function (rg, value) {
+                    rg.up('window').getViewModel().set('usage', value.usage);
+                },
+            },
+        },
+        {
+            xtype: 'displayfield',
+            userCls: 'pmx-hint',
+            value: gettext(
+                'This creates two storage entries: an iSCSI storage for the SAN connection and an LVM storage on the selected LUN.',
+            ),
+            bind: {
+                hidden: '{!isLvmUsage}',
+            },
+        },
+    ],
+});
+
+Ext.define('PVE.storage.wizard.LunPrompt', {
+    extend: 'Ext.window.Window',
+
+    modal: true,
+    resizable: false,
+    width: 500,
+    layout: 'anchor',
+    bodyPadding: 10,
+
+    title: gettext('Select Base Volume (LUN)'),
+
+    // config: nodename, storage, callback(volid)
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: 'displayfield',
+                userCls: 'pmx-hint',
+                anchor: '100%',
+                value: gettext(
+                    'The iSCSI target provides more than one LUN, select the one to use for the LVM volume group.',
+                ),
+            },
+            {
+                xtype: 'pveStorageLunSelector',
+                itemId: 'lunSelector',
+                fieldLabel: gettext('Base Volume'),
+                anchor: '100%',
+                nodename: me.nodename,
+                listeners: {
+                    // setting the storage after render triggers the load,
+                    // passing it as config would not
+                    afterrender: function (f) {
+                        f.setStorage(me.storage);
+                    },
+                    change: function (f, value) {
+                        me.down('#okButton').setDisabled(!value);
+                    },
+                },
+            },
+        ];
+
+        me.buttons = [
+            {
+                text: gettext('OK'),
+                itemId: 'okButton',
+                disabled: true,
+                handler: function () {
+                    let volid = me.down('#lunSelector').getValue();
+                    me.close();
+                    me.callback(volid);
+                },
+            },
+            {
+                text: gettext('Cancel'),
+                handler: () => me.close(),
+            },
+        ];
+
+        me.callParent();
+    },
+});
+
+PVE.storage.wizard.types.iscsi = {
+    text: 'iSCSI',
+    description: gettext('Block storage (LUNs) on a SAN, accessed over the network.'),
+    group: 'san',
+    apiType: 'iscsi',
+    viewModel: {
+        data: {
+            usage: 'lvm',
+        },
+        formulas: {
+            isLvmUsage: (get) => get('usage') === 'lvm',
+        },
+    },
+    steps: () => [
+        {
+            xtype: 'pveStorageWizardIScsiTarget',
+            title: gettext('Target'),
+        },
+        {
+            xtype: 'pveStorageWizardIScsiUsage',
+            title: gettext('Usage'),
+        },
+    ],
+    settings: {
+        onlineHelp: 'storage_open_iscsi',
+        cts: ['images', 'rootdir'],
+        defaultContent: ['images', 'rootdir'],
+        contentFieldConfig: {
+            bind: {
+                disabled: '{!isLvmUsage}',
+                hidden: '{!isLvmUsage}',
+            },
+        },
+        advancedColumn1: [
+            {
+                xtype: 'textfield',
+                name: 'base-storage',
+                fieldLabel: gettext('iSCSI storage ID'),
+                emptyText: gettext('ID') + '-base',
+                vtype: 'StorageId',
+                allowBlank: true,
+                bind: {
+                    disabled: '{!isLvmUsage}',
+                    hidden: '{!isLvmUsage}',
+                },
+            },
+        ],
+    },
+    summaryNotes: function (values) {
+        if (values.usage !== 'lvm') {
+            return [];
+        }
+        let baseId = values['base-storage'] || `${values.storage}-base`;
+        return [
+            Ext.String.format(
+                gettext(
+                    'Two storage entries will be created: iSCSI storage "{0}" and LVM storage "{1}".',
+                ),
+                baseId,
+                values.storage,
+            ),
+            gettext(
+                'The selected LUN is initialized as an LVM volume group. Existing data on the LUN will be destroyed!',
+            ),
+            gettext(
+                'If the target provides more than one LUN, you will be asked to select one after the iSCSI storage was created.',
+            ),
+        ];
+    },
+    submit: function (wizard, values) {
+        let usage = values.usage;
+        delete values.usage;
+
+        if (usage === 'direct') {
+            delete values['base-storage'];
+            values.type = 'iscsi';
+            values.content = 'images';
+            wizard.runSubmitChain([(next) => wizard.createStorage(values, next)]);
+            return;
+        }
+
+        let baseId = values['base-storage'] || `${values.storage}-base`;
+        delete values['base-storage'];
+
+        let nodesList = values.nodes ? [].concat(values.nodes) : [];
+        let contentNode = wizard.scanNode || nodesList[0] || Proxmox.NodeName;
+
+        let baseCreatedHint = Ext.String.format(
+            gettext(
+                'The iSCSI storage "{0}" was already created. You can complete the setup later by adding an LVM storage with a base volume via Add -> LVM.',
+            ),
+            Ext.htmlEncode(baseId),
+        );
+
+        wizard.runSubmitChain([
+            (next) => {
+                if (wizard.createdBaseStorage) {
+                    next();
+                    return;
+                }
+                wizard.createStorage(
+                    {
+                        storage: baseId,
+                        type: 'iscsi',
+                        portal: values.portal,
+                        target: values.target,
+                        content: 'none',
+                        nodes: values.nodes,
+                    },
+                    () => {
+                        wizard.createdBaseStorage = true;
+                        next();
+                    },
+                );
+            },
+            (next) => {
+                if (wizard.baseVolume) {
+                    next();
+                    return;
+                }
+                Proxmox.Utils.API2Request({
+                    url: `/nodes/${contentNode}/storage/${baseId}/content`,
+                    method: 'GET',
+                    params: { content: 'images' },
+                    waitMsgTarget: wizard,
+                    failure: (response) =>
+                        Ext.Msg.alert(
+                            gettext('Error'),
+                            `${baseCreatedHint}<br><br>${response.htmlStatus}`,
+                        ),
+                    success: function (response) {
+                        let luns = response.result.data || [];
+                        if (luns.length === 0) {
+                            Ext.Msg.alert(
+                                gettext('Error'),
+                                gettext('No LUN found on the iSCSI target.') +
+                                    `<br><br>${baseCreatedHint}`,
+                            );
+                            return;
+                        }
+                        if (luns.length === 1) {
+                            wizard.baseVolume = luns[0].volid;
+                            next();
+                            return;
+                        }
+                        Ext.create('PVE.storage.wizard.LunPrompt', {
+                            autoShow: true,
+                            nodename: contentNode,
+                            storage: baseId,
+                            callback: function (volid) {
+                                wizard.baseVolume = volid;
+                                next();
+                            },
+                        });
+                    },
+                });
+            },
+            (next) =>
+                wizard.createStorage(
+                    {
+                        storage: values.storage,
+                        type: 'lvm',
+                        vgname: values.storage,
+                        base: wizard.baseVolume,
+                        shared: 1,
+                        content: values.content,
+                        nodes: values.nodes,
+                        disable: values.disable,
+                    },
+                    next,
+                    baseCreatedHint,
+                ),
+        ]);
+    },
+};
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 20/27] ui: storage wizard: add FC-attached SAN (shared LVM) support
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (18 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 19/27] ui: storage wizard: add iSCSI support Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 21/27] ui: storage wizard: add ZFS over iSCSI support Dietmar Maurer
                   ` (6 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

There is no dedicated storage plugin for Fibre Channel SANs; the LUN
shows up as a block device on every attached node and is used as a
shared LVM volume group. This setup is not obvious from the current
Add menu, so give it an explicit wizard entry.

The wizard lists the node's block devices via the new san-luns scan
API and autodetects their usage: selecting an unused LUN initializes
it as a new volume group through the node disk management API, while
a LUN that already carries a volume group is reused. Devices whose
volume group is already referenced by another storage definition, or
that are otherwise in use, are shown but not selectable.

Requires libpve-storage-perl with the san-luns scan method and
multipath support for volume group creation.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile                         |   1 +
 www/manager6/Utils.js                         |  35 ++
 www/manager6/storage/wizard/CommonSettings.js |   3 +
 www/manager6/storage/wizard/FibreChannel.js   | 373 ++++++++++++++++++
 4 files changed, 412 insertions(+)
 create mode 100644 www/manager6/storage/wizard/FibreChannel.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index e0a11e5e..4a413d56 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -382,6 +382,7 @@ JSSRC= 							\
 	storage/wizard/NFS.js				\
 	storage/wizard/CIFS.js				\
 	storage/wizard/IScsi.js				\
+	storage/wizard/FibreChannel.js			\
 	Workspace.js					\
 # end of JSSRC list
 
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 040b5ae0..5972e1aa 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -1097,6 +1097,41 @@ Ext.define('PVE.Utils', {
             return Ext.String.htmlEncode(result);
         },
 
+        render_san_lun_device: function (value, metaData, rec) {
+            let text = Ext.htmlEncode(value);
+            // only multipath mapped devices report their path count
+            let paths = rec.get('paths');
+            if (paths) {
+                text += ` (${paths} ${ngettext('path', 'paths', paths)})`;
+            }
+            return text;
+        },
+
+        render_san_lun_usage: function (value, metaData, rec) {
+            if (value === 'unused') {
+                let sid = rec.get('used-by-storage');
+                if (sid) {
+                    // for example a LUN of a configured iSCSI storage
+                    return Ext.htmlEncode(Ext.String.format(gettext('used by storage "{0}"'), sid));
+                }
+                return gettext('unused');
+            }
+            if (value === 'lvm') {
+                let vgname = rec.get('vgname');
+                let sid = rec.get('used-by-storage');
+                if (sid) {
+                    return Ext.htmlEncode(
+                        Ext.String.format(gettext('VG "{0}", used by storage "{1}"'), vgname, sid),
+                    );
+                }
+                if (vgname) {
+                    return Ext.htmlEncode(Ext.String.format(gettext('VG "{0}"'), vgname));
+                }
+                return 'LVM';
+            }
+            return Ext.htmlEncode(rec.get('detail') || gettext('in use'));
+        },
+
         render_serverity: function (value) {
             return PVE.Utils.log_severity_hash[value] || value;
         },
diff --git a/www/manager6/storage/wizard/CommonSettings.js b/www/manager6/storage/wizard/CommonSettings.js
index a49bb8f8..64a514de 100644
--- a/www/manager6/storage/wizard/CommonSettings.js
+++ b/www/manager6/storage/wizard/CommonSettings.js
@@ -13,6 +13,8 @@ Ext.define('PVE.storage.wizard.CommonSettings', {
     // extra config merged into the content selector, for example
     // mode-dependent bindings
     contentFieldConfig: undefined,
+    // extra fields shown between the storage ID and the content selector
+    extraColumn1: undefined,
 
     onGetValues: function (values) {
         values.disable = values.enable ? 0 : 1;
@@ -31,6 +33,7 @@ Ext.define('PVE.storage.wizard.CommonSettings', {
                 vtype: 'StorageId',
                 allowBlank: false,
             },
+            ...(me.extraColumn1 || []),
         ];
 
         if (!me.fixedContent) {
diff --git a/www/manager6/storage/wizard/FibreChannel.js b/www/manager6/storage/wizard/FibreChannel.js
new file mode 100644
index 00000000..8aee5283
--- /dev/null
+++ b/www/manager6/storage/wizard/FibreChannel.js
@@ -0,0 +1,373 @@
+Ext.define('PVE.storage.wizard.FCLunSelect', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardFCLunSelect',
+
+    onlineHelp: 'storage_lvm',
+
+    initComponent: function () {
+        let me = this;
+
+        let sanTransports = { fc: 1, 'nvme-fc': 1 };
+
+        // transports that depend on a software-managed initiator connection,
+        // which this storage type does not set up on the other nodes
+        let remoteTransports = {
+            fcoe: 1,
+            iscsi: 1,
+            'nvme-tcp': 1,
+            'nvme-rdma': 1,
+            'nvme-loop': 1,
+        };
+
+        // hide devices with local or unknown transports unless 'Show all' is enabled
+        let sanTransportFilter = new Ext.util.Filter({
+            id: 'san-transport',
+            filterFn: (rec) => !!sanTransports[rec.get('transport')],
+        });
+
+        // devices with remote transports are never usable here, always hide them
+        let remoteTransportFilter = new Ext.util.Filter({
+            id: 'remote-transport',
+            filterFn: (rec) => !remoteTransports[rec.get('transport')],
+        });
+
+        let store = Ext.create('Ext.data.Store', {
+            autoLoad: true,
+            fields: [
+                'devpath',
+                'size',
+                'transport',
+                'vendor',
+                'model',
+                'serial',
+                'wwn',
+                'paths',
+                'usage',
+                'vgname',
+                'used-by-storage',
+                'detail',
+                {
+                    name: 'selectable',
+                    calculate: (data) =>
+                        !data['used-by-storage'] &&
+                        (data.usage === 'unused' || (data.usage === 'lvm' && !!data.vgname)),
+                },
+            ],
+            proxy: {
+                type: 'proxmox',
+                url: `/api2/json/nodes/${Proxmox.NodeName}/scan/san-luns`,
+            },
+            filters: [remoteTransportFilter, sanTransportFilter],
+            sorters: [
+                {
+                    // SAN attached devices are the likely candidates, list them first
+                    sorterFn: function (a, b) {
+                        let rank = (rec) => (sanTransports[rec.data.transport] ? 0 : 1);
+                        return rank(a) - rank(b) || a.data.devpath.localeCompare(b.data.devpath);
+                    },
+                },
+            ],
+        });
+
+        let updateSelection = function (rec) {
+            let vm = me.up('window').getViewModel();
+            let hint = me.down('#selectionHint');
+            let setHint = function (text) {
+                hint.setValue(text);
+                hint.setHidden(!text);
+            };
+
+            if (!rec) {
+                vm.set({ vgmode: '', device: '', vgname: '' });
+                setHint('');
+            } else if (rec.get('usage') === 'unused') {
+                vm.set({ vgmode: 'create', device: rec.get('devpath'), vgname: '' });
+                setHint(
+                    Ext.String.format(
+                        gettext(
+                            'A new volume group will be created on "{0}", erasing all data on it!',
+                        ),
+                        rec.get('devpath'),
+                    ),
+                );
+            } else {
+                vm.set({ vgmode: 'existing', device: '', vgname: rec.get('vgname') });
+                setHint(
+                    Ext.String.format(
+                        gettext('The existing volume group "{0}" will be used.'),
+                        rec.get('vgname'),
+                    ),
+                );
+            }
+        };
+
+        me.items = [
+            {
+                xtype: 'displayfield',
+                value: gettext('Select the SAN LUN for the volume group:'),
+            },
+            {
+                xtype: 'grid',
+                itemId: 'lunGrid',
+                height: 220,
+                store: store,
+                emptyText: gettext(
+                    'No SAN attached devices found - "Show devices with local or unknown transports" lists additional devices',
+                ),
+                viewConfig: {
+                    trackOver: false,
+                    getRowClass: (rec) => (rec.get('selectable') ? '' : 'x-item-disabled'),
+                },
+                listeners: {
+                    beforeselect: (grid, rec) => rec.get('selectable'),
+                    selectionchange: (sm, selection) => updateSelection(selection[0]),
+                },
+                columns: [
+                    {
+                        header: gettext('Device'),
+                        dataIndex: 'devpath',
+                        flex: 3,
+                        renderer: PVE.Utils.render_san_lun_device,
+                    },
+                    {
+                        header: gettext('Transport'),
+                        dataIndex: 'transport',
+                        width: 80,
+                    },
+                    {
+                        header: gettext('Size'),
+                        dataIndex: 'size',
+                        width: 90,
+                        renderer: Proxmox.Utils.format_size,
+                    },
+                    {
+                        header: gettext('Vendor') + '/' + gettext('Model'),
+                        dataIndex: 'model',
+                        flex: 2,
+                        renderer: (value, metaData, rec) =>
+                            Ext.htmlEncode(`${rec.get('vendor') || ''} ${value || ''}`.trim()),
+                    },
+                    {
+                        header: gettext('Usage'),
+                        dataIndex: 'usage',
+                        flex: 2,
+                        renderer: PVE.Utils.render_san_lun_usage,
+                    },
+                ],
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                itemId: 'showAllDevices',
+                boxLabel: gettext('Show devices with local or unknown transports'),
+                submitValue: false,
+                autoEl: {
+                    tag: 'div',
+                    'data-qtip': gettext(
+                        'Also list devices with local or unknown transports. Only use devices that are visible on all selected nodes!',
+                    ),
+                },
+                listeners: {
+                    change: function (f, value) {
+                        let filters = store.getFilters();
+                        if (value) {
+                            filters.remove(sanTransportFilter);
+                        } else {
+                            filters.add(sanTransportFilter);
+                            // drop a selection that got filtered away
+                            let sm = me.down('#lunGrid').getSelectionModel();
+                            let selected = sm.getSelection()[0];
+                            if (selected && store.indexOf(selected) < 0) {
+                                sm.deselectAll();
+                            }
+                        }
+                    },
+                },
+            },
+            {
+                xtype: 'textfield',
+                name: 'vgmode',
+                hidden: true,
+                allowBlank: false,
+                bind: { value: '{vgmode}' },
+            },
+            {
+                xtype: 'textfield',
+                name: 'device',
+                hidden: true,
+                allowBlank: true,
+                bind: { value: '{device}' },
+            },
+            {
+                xtype: 'displayfield',
+                itemId: 'selectionHint',
+                userCls: 'pmx-hint',
+                hidden: true,
+                value: '',
+            },
+        ];
+
+        if (!PVE.Utils.isStandaloneNode()) {
+            me.items.unshift({
+                xtype: 'pveStorageScanNodeSelector',
+                listeners: {
+                    change: function (f, value) {
+                        me.up('window').scanNode = value;
+                        let node = value || Proxmox.NodeName;
+                        store.getProxy().setUrl(`/api2/json/nodes/${node}/scan/san-luns`);
+                        me.down('#lunGrid').getSelectionModel().deselectAll();
+                        store.load();
+                    },
+                },
+            });
+        }
+
+        me.callParent();
+
+        Proxmox.Utils.monStoreErrors(me.down('#lunGrid'), store);
+    },
+});
+
+PVE.storage.wizard.types.fc = {
+    text: gettext('Fibre Channel'),
+    description: gettext('Block storage (LUNs) on a SAN, attached via Fibre Channel.'),
+    group: 'san',
+    apiType: 'lvm',
+    viewModel: {
+        data: {
+            vgmode: '',
+            device: '',
+            vgname: '',
+        },
+        formulas: {
+            vgCreate: (get) => get('vgmode') === 'create',
+        },
+    },
+    steps: () => [
+        {
+            xtype: 'pveStorageWizardFCLunSelect',
+            title: gettext('SAN LUN'),
+        },
+    ],
+    settings: {
+        onlineHelp: 'storage_lvm',
+        cts: ['images', 'rootdir'],
+        defaultContent: ['images', 'rootdir'],
+        extraColumn1: [
+            {
+                xtype: 'textfield',
+                name: 'vgname',
+                fieldLabel: gettext('Volume group'),
+                allowBlank: true,
+                emptyText: gettext('use storage ID'),
+                bind: {
+                    value: '{vgname}',
+                    readOnly: '{!vgCreate}',
+                },
+            },
+        ],
+        advancedColumn1: [
+            {
+                xtype: 'proxmoxcheckbox',
+                name: 'saferemove',
+                uncheckedValue: 0,
+                fieldLabel: gettext('Wipe Removed Volumes'),
+            },
+        ],
+        columnB: [
+            {
+                xtype: 'displayfield',
+                userCls: 'pmx-hint',
+                value: gettext(
+                    'The storage will be marked as shared. The SAN LUN must be visible on all selected nodes.',
+                ),
+            },
+        ],
+    },
+    summaryNotes: function (values) {
+        let notes = [
+            gettext(
+                'The storage will be marked as shared. The SAN LUN must be visible on all selected nodes.',
+            ),
+        ];
+        if (values.vgmode === 'create') {
+            notes.push(
+                Ext.String.format(
+                    gettext(
+                        'The volume group "{0}" is created on "{1}". Existing data on the device will be destroyed!',
+                    ),
+                    values.vgname || values.storage,
+                    values.device,
+                ),
+            );
+        } else {
+            notes.push(
+                Ext.String.format(
+                    gettext('The existing volume group "{0}" will be used.'),
+                    values.vgname,
+                ),
+            );
+        }
+        return notes;
+    },
+    submit: function (wizard, values) {
+        let mode = values.vgmode;
+        delete values.vgmode;
+
+        let params = {
+            storage: values.storage,
+            type: 'lvm',
+            vgname: values.vgname || values.storage,
+            shared: 1,
+            content: values.content,
+            nodes: values.nodes,
+            disable: values.disable,
+            saferemove: values.saferemove,
+        };
+
+        if (mode === 'existing') {
+            wizard.runSubmitChain([(next) => wizard.createStorage(params, next)]);
+            return;
+        }
+
+        let node = wizard.scanNode || Proxmox.NodeName;
+        let vgCreatedHint = Ext.String.format(
+            gettext(
+                'The volume group "{0}" was already created on node "{1}". You can complete the setup later via Add -> LVM.',
+            ),
+            Ext.htmlEncode(params.vgname),
+            Ext.htmlEncode(node),
+        );
+
+        wizard.runSubmitChain([
+            (next) => {
+                if (wizard.createdVolumeGroup) {
+                    next();
+                    return;
+                }
+                Proxmox.Utils.API2Request({
+                    url: `/nodes/${node}/disks/lvm`,
+                    method: 'POST',
+                    waitMsgTarget: wizard,
+                    params: {
+                        name: params.vgname,
+                        device: values.device,
+                    },
+                    failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+                    success: function (response) {
+                        Ext.create('Proxmox.window.TaskProgress', {
+                            upid: response.result.data,
+                            autoShow: true,
+                            taskDone: function (success) {
+                                if (success) {
+                                    wizard.createdVolumeGroup = true;
+                                    next();
+                                }
+                            },
+                        });
+                    },
+                });
+            },
+            (next) => wizard.createStorage(params, next, vgCreatedHint),
+        ]);
+    },
+};
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 21/27] ui: storage wizard: add ZFS over iSCSI support
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (19 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 20/27] ui: storage wizard: add FC-attached SAN (shared LVM) support Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 22/27] ui: dc: storage: add remote storage wizard entry to the add menu Dietmar Maurer
                   ` (5 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

There is no scan API for this SSH-based storage type, so the wizard
splits the existing single dialog into an appliance connection step,
showing only the fields relevant for the selected iSCSI provider, and
a volume settings step.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile                       |   1 +
 www/manager6/storage/wizard/ZFSOverISCSI.js | 162 ++++++++++++++++++++
 2 files changed, 163 insertions(+)
 create mode 100644 www/manager6/storage/wizard/ZFSOverISCSI.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 4a413d56..532d4d55 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -383,6 +383,7 @@ JSSRC= 							\
 	storage/wizard/CIFS.js				\
 	storage/wizard/IScsi.js				\
 	storage/wizard/FibreChannel.js			\
+	storage/wizard/ZFSOverISCSI.js			\
 	Workspace.js					\
 # end of JSSRC list
 
diff --git a/www/manager6/storage/wizard/ZFSOverISCSI.js b/www/manager6/storage/wizard/ZFSOverISCSI.js
new file mode 100644
index 00000000..579720a8
--- /dev/null
+++ b/www/manager6/storage/wizard/ZFSOverISCSI.js
@@ -0,0 +1,162 @@
+Ext.define('PVE.storage.wizard.ZFSOverISCSIAppliance', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardZFSOverISCSIAppliance',
+
+    onlineHelp: 'storage_zfs',
+
+    column1: [
+        {
+            xtype: 'pveiScsiProviderSelector',
+            name: 'iscsiprovider',
+            value: 'comstar',
+            fieldLabel: gettext('iSCSI Provider'),
+            allowBlank: false,
+            listeners: {
+                change: function (f, value) {
+                    let vm = this.up('window').getViewModel();
+                    vm.set('isLIO', value === 'LIO');
+                    vm.set('isComstar', value === 'comstar');
+                    vm.set('hasWriteCacheOption', value === 'comstar' || value === 'istgt');
+                },
+            },
+        },
+        {
+            xtype: 'textfield',
+            name: 'portal',
+            fieldLabel: gettext('Portal'),
+            allowBlank: false,
+        },
+        {
+            xtype: 'textfield',
+            name: 'target',
+            fieldLabel: gettext('Target'),
+            allowBlank: false,
+        },
+    ],
+
+    column2: [
+        {
+            xtype: 'textfield',
+            name: 'comstar_tg',
+            fieldLabel: gettext('Target group'),
+            allowBlank: true,
+            bind: {
+                disabled: '{!isComstar}',
+                hidden: '{!isComstar}',
+            },
+        },
+        {
+            xtype: 'textfield',
+            name: 'comstar_hg',
+            fieldLabel: gettext('Host group'),
+            allowBlank: true,
+            bind: {
+                disabled: '{!isComstar}',
+                hidden: '{!isComstar}',
+            },
+        },
+        {
+            xtype: 'textfield',
+            name: 'lio_tpg',
+            fieldLabel: gettext('Target portal group'),
+            allowBlank: false,
+            disabled: true,
+            hidden: true,
+            bind: {
+                disabled: '{!isLIO}',
+                hidden: '{!isLIO}',
+            },
+        },
+    ],
+
+    columnB: [
+        {
+            xtype: 'displayfield',
+            userCls: 'pmx-hint',
+            value: gettext(
+                'The ZFS appliance is managed over SSH, which requires passwordless root access from all nodes.',
+            ),
+        },
+    ],
+});
+
+Ext.define('PVE.storage.wizard.ZFSOverISCSIVolume', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardZFSOverISCSIVolume',
+
+    onlineHelp: 'storage_zfs',
+
+    onGetValues: function (values) {
+        values.nowritecache = values.writecache ? 0 : 1;
+        delete values.writecache;
+        return values;
+    },
+
+    column1: [
+        {
+            xtype: 'textfield',
+            name: 'pool',
+            fieldLabel: gettext('Pool'),
+            allowBlank: false,
+        },
+        {
+            xtype: 'textfield',
+            name: 'blocksize',
+            value: '4k',
+            fieldLabel: gettext('Block Size'),
+            allowBlank: false,
+            validator: PVE.Utils.validateZfsBlocksize,
+        },
+    ],
+
+    column2: [
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'sparse',
+            checked: false,
+            uncheckedValue: 0,
+            fieldLabel: gettext('Thin provision'),
+        },
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'writecache',
+            checked: true,
+            uncheckedValue: 0,
+            fieldLabel: gettext('Write cache'),
+            bind: {
+                disabled: '{!hasWriteCacheOption}',
+                hidden: '{!hasWriteCacheOption}',
+            },
+        },
+    ],
+});
+
+PVE.storage.wizard.types['zfs-over-iscsi'] = {
+    text: gettext('ZFS over iSCSI'),
+    description: gettext(
+        'ZFS storage appliance managed over SSH, exporting volumes via iSCSI with snapshot support.',
+    ),
+    group: 'san',
+    apiType: 'zfs',
+    viewModel: {
+        data: {
+            isLIO: false,
+            isComstar: true,
+            hasWriteCacheOption: true,
+        },
+    },
+    steps: () => [
+        {
+            xtype: 'pveStorageWizardZFSOverISCSIAppliance',
+            title: gettext('Appliance'),
+        },
+        {
+            xtype: 'pveStorageWizardZFSOverISCSIVolume',
+            title: gettext('Volume Settings'),
+        },
+    ],
+    settings: {
+        onlineHelp: 'storage_zfs',
+        fixedContent: 'images',
+    },
+};
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 22/27] ui: dc: storage: add remote storage wizard entry to the add menu
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (20 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 21/27] ui: storage wizard: add ZFS over iSCSI support Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 23/27] ui: node: add SAN LUNs panel Dietmar Maurer
                   ` (4 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Offer the guided wizard as first entry, separated from the per-type
entries, which all stay available for users who know exactly what they
want.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/dc/StorageView.js | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index e4c6f07d..31601922 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -72,7 +72,18 @@ Ext.define(
                     me.createStorageEditWindow(type);
                 };
             };
-            let addMenuItems = [];
+            let addMenuItems = [
+                {
+                    text: gettext('Remote Storage') + '...',
+                    iconCls: 'fa fa-fw fa-magic',
+                    handler: () =>
+                        Ext.create('PVE.storage.wizard.RemoteStorage', {
+                            autoShow: true,
+                            reloadCallback: () => store.load(),
+                        }),
+                },
+                '-',
+            ];
             for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
                 if (storage.hideAdd) {
                     continue;
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 23/27] ui: node: add SAN LUNs panel
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (21 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 22/27] ui: dc: storage: add remote storage wizard entry to the add menu Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 24/27] ui: san luns: show multipath path state Dietmar Maurer
                   ` (3 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

node > Disks intentionally lists only local disks, so devices
attached through a SAN or a software-managed initiator connection
(FC, SAS, iSCSI, NVMe-oF) are otherwise only visible inside the FC
storage wizard. Add a read-only panel below the disk related views
that lists them per node, including the multipath path count and
which storage uses each LUN, for setup verification and
troubleshooting.

The entry is only shown with Datastore.Allocate, which the san-luns
scan endpoint requires.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile        |   1 +
 www/manager6/node/Config.js  |  14 ++++
 www/manager6/node/SANLuns.js | 154 +++++++++++++++++++++++++++++++++++
 3 files changed, 169 insertions(+)
 create mode 100644 www/manager6/node/SANLuns.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 532d4d55..c354b3aa 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -245,6 +245,7 @@ JSSRC= 							\
 	node/Directory.js				\
 	node/LVM.js					\
 	node/LVMThin.js					\
+	node/SANLuns.js					\
 	node/StatusView.js				\
 	node/Subscription.js				\
 	node/Summary.js					\
diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js
index 217ee284..6329241a 100644
--- a/www/manager6/node/Config.js
+++ b/www/manager6/node/Config.js
@@ -357,6 +357,20 @@ Ext.define('PVE.node.Config', {
                     groups: ['storage'],
                     xtype: 'pveZFSList',
                 },
+            );
+
+            // the san-luns scan endpoint requires Datastore.Allocate
+            if (caps.storage['Datastore.Allocate']) {
+                me.items.push({
+                    xtype: 'pveSANLunList',
+                    title: gettext('SAN LUNs'),
+                    itemId: 'san-luns',
+                    iconCls: 'fa fa-cubes',
+                    groups: ['storage'],
+                });
+            }
+
+            me.items.push(
                 {
                     xtype: 'pveNodeCephStatus',
                     title: 'Ceph',
diff --git a/www/manager6/node/SANLuns.js b/www/manager6/node/SANLuns.js
new file mode 100644
index 00000000..b2b9e24d
--- /dev/null
+++ b/www/manager6/node/SANLuns.js
@@ -0,0 +1,154 @@
+Ext.define('PVE.node.SANLunList', {
+    extend: 'Ext.grid.GridPanel',
+    xtype: 'pveSANLunList',
+
+    stateful: true,
+    stateId: 'grid-node-san-luns',
+
+    disableSelection: true,
+
+    emptyText: gettext(
+        'No SAN attached devices found - "Show all devices" also lists devices with local or unknown transports',
+    ),
+
+    viewConfig: {
+        trackOver: false,
+    },
+
+    columns: [
+        {
+            header: gettext('Device'),
+            dataIndex: 'devpath',
+            flex: 2,
+            renderer: PVE.Utils.render_san_lun_device,
+        },
+        {
+            header: gettext('Transport'),
+            dataIndex: 'transport',
+            width: 100,
+        },
+        {
+            header: gettext('Size'),
+            dataIndex: 'size',
+            width: 100,
+            renderer: Proxmox.Utils.format_size,
+        },
+        {
+            header: gettext('Vendor') + '/' + gettext('Model'),
+            dataIndex: 'model',
+            flex: 1,
+            renderer: (value, metaData, rec) =>
+                Ext.htmlEncode(`${rec.get('vendor') || ''} ${value || ''}`.trim()),
+        },
+        {
+            header: 'WWN',
+            dataIndex: 'wwn',
+            width: 200,
+        },
+        {
+            header: gettext('Serial'),
+            dataIndex: 'serial',
+            width: 140,
+            hidden: true,
+        },
+        {
+            header: gettext('Usage'),
+            dataIndex: 'usage',
+            flex: 2,
+            renderer: PVE.Utils.render_san_lun_usage,
+        },
+    ],
+
+    reload: function () {
+        this.getStore().load();
+    },
+
+    listeners: {
+        activate: function () {
+            this.reload();
+        },
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.nodename = me.pveSelNode.data.node;
+        if (!me.nodename) {
+            throw 'no node name specified';
+        }
+
+        // devices sitting on a SAN or attached through a software-managed
+        // initiator connection; local disks are covered by the disk list
+        let sanTransports = {
+            fc: 1,
+            sas: 1,
+            iscsi: 1,
+            'nvme-fc': 1,
+            'nvme-tcp': 1,
+            'nvme-rdma': 1,
+            'nvme-loop': 1,
+        };
+
+        // hide devices with local or unknown transports unless 'Show all devices' is enabled
+        let sanTransportFilter = new Ext.util.Filter({
+            id: 'san-transport',
+            filterFn: (rec) => !!sanTransports[rec.get('transport')],
+        });
+
+        let store = Ext.create('Ext.data.Store', {
+            fields: [
+                'devpath',
+                'size',
+                'transport',
+                'vendor',
+                'model',
+                'serial',
+                'wwn',
+                'paths',
+                'usage',
+                'vgname',
+                'used-by-storage',
+                'detail',
+            ],
+            proxy: {
+                type: 'proxmox',
+                url: `/api2/json/nodes/${me.nodename}/scan/san-luns`,
+            },
+            filters: [sanTransportFilter],
+            sorters: 'devpath',
+        });
+        me.store = store;
+
+        me.tbar = [
+            {
+                text: gettext('Reload'),
+                iconCls: 'fa fa-refresh',
+                handler: () => me.reload(),
+            },
+            '->',
+            {
+                xtype: 'proxmoxcheckbox',
+                boxLabel: gettext('Show all devices'),
+                autoEl: {
+                    tag: 'div',
+                    'data-qtip': gettext('Also list devices with local or unknown transports.'),
+                },
+                listeners: {
+                    change: function (f, value) {
+                        let filters = store.getFilters();
+                        if (value) {
+                            filters.remove(sanTransportFilter);
+                        } else {
+                            filters.add(sanTransportFilter);
+                        }
+                    },
+                },
+            },
+        ];
+
+        me.callParent();
+
+        Proxmox.Utils.monStoreErrors(me, store);
+        me.reload();
+    },
+});
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 24/27] ui: san luns: show multipath path state
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (22 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 23/27] ui: node: add SAN LUNs panel Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 25/27] api: nodes: add iSCSI initiator API endpoint Dietmar Maurer
                   ` (2 subsequent siblings)
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Render the active path count next to the total and prepend a warning
icon when they differ, so a degraded multipath device stands out. A
row expander shows the member paths with their state and, depending
on the multipath stack, checker state and WWPNs (multipathd) or ANA
state and controller address (native NVMe multipath), plus the fault
counter, which would otherwise require "multipath -ll" or digging
through sysfs on the CLI. The FC storage wizard LUN grid picks up
the degraded hint through the shared renderer.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Utils.js        | 12 +++++-
 www/manager6/node/SANLuns.js | 74 ++++++++++++++++++++++++++++++++++++
 2 files changed, 85 insertions(+), 1 deletion(-)

diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 5972e1aa..a34e1363 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -1102,7 +1102,17 @@ Ext.define('PVE.Utils', {
             // only multipath mapped devices report their path count
             let paths = rec.get('paths');
             if (paths) {
-                text += ` (${paths} ${ngettext('path', 'paths', paths)})`;
+                let active = rec.get('active-paths');
+                if (Ext.isNumeric(active) && active < paths) {
+                    let qtip = Ext.htmlEncode(
+                        gettext('Some paths failed, the multipath device is degraded'),
+                    );
+                    text =
+                        `<i data-qtip="${qtip}" class="fa fa-exclamation-triangle warning"></i> ` +
+                        `${text} (${active}/${paths} ${gettext('paths')})`;
+                } else {
+                    text += ` (${paths} ${ngettext('path', 'paths', paths)})`;
+                }
             }
             return text;
         },
diff --git a/www/manager6/node/SANLuns.js b/www/manager6/node/SANLuns.js
index b2b9e24d..ce11c39b 100644
--- a/www/manager6/node/SANLuns.js
+++ b/www/manager6/node/SANLuns.js
@@ -15,6 +15,77 @@ Ext.define('PVE.node.SANLunList', {
         trackOver: false,
     },
 
+    plugins: [
+        {
+            ptype: 'rowexpander',
+            expandOnDblClick: false,
+            scrollIntoViewOnExpand: false,
+            rowBodyTpl: [
+                '{[this.renderDetails(values)]}',
+                {
+                    renderDetails: function (values) {
+                        let enc = Ext.htmlEncode;
+                        let lines = [];
+                        if (values.wwn) {
+                            lines.push(`<b>WWN:</b> ${enc(values.wwn)}`);
+                        }
+                        if (values.serial) {
+                            lines.push(`<b>${gettext('Serial')}:</b> ${enc(values.serial)}`);
+                        }
+                        let faults = values['path-faults'];
+                        if (Ext.isNumeric(faults)) {
+                            lines.push(
+                                `<b>${gettext('Path Failures Since Map Creation')}:</b> ${enc(faults)}`,
+                            );
+                        }
+                        let paths = values['path-list'] || [];
+                        if (paths.length) {
+                            // multipathd paths report checker state and WWPNs, NVMe
+                            // paths their ANA state and controller address
+                            let goodStates = { active: 1, live: 1 };
+                            let goodAna = { optimized: 1, 'non-optimized': 1 };
+                            let columns = [
+                                { key: 'device', label: gettext('Path') },
+                                {
+                                    key: 'state',
+                                    label: gettext('State'),
+                                    bad: (path) => !goodStates[path.state],
+                                },
+                                { key: 'checker-state', label: gettext('Checker') },
+                                {
+                                    key: 'ana-state',
+                                    label: 'ANA',
+                                    bad: (path) => !goodAna[path['ana-state']],
+                                },
+                                { key: 'host-wwpn', label: 'Host WWPN' },
+                                { key: 'target-wwpn', label: 'Target WWPN' },
+                                { key: 'address', label: gettext('Address') },
+                            ].filter((col) => paths.some((path) => path[col.key] !== undefined));
+                            let header = columns
+                                .map((col) => `<th style="text-align: left;">${col.label}</th>`)
+                                .join('');
+                            let rows = paths.map((path) => {
+                                let cells = columns.map((col) => {
+                                    let text = enc(path[col.key] ?? '');
+                                    if (text !== '' && col.bad && col.bad(path)) {
+                                        text = `<i class="fa fa-exclamation-triangle warning"></i> ${text}`;
+                                    }
+                                    return text;
+                                });
+                                return `<tr><td>${cells.join('</td><td>')}</td></tr>`;
+                            });
+                            lines.push(
+                                `<table style="border-spacing: 10px 2px;">` +
+                                    `<tr>${header}</tr>${rows.join('')}</table>`,
+                            );
+                        }
+                        return lines.join('<br>');
+                    },
+                },
+            ],
+        },
+    ],
+
     columns: [
         {
             header: gettext('Device'),
@@ -105,6 +176,9 @@ Ext.define('PVE.node.SANLunList', {
                 'serial',
                 'wwn',
                 'paths',
+                'active-paths',
+                'path-faults',
+                'path-list',
                 'usage',
                 'vgname',
                 'used-by-storage',
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 25/27] api: nodes: add iSCSI initiator API endpoint
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (23 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 24/27] ui: san luns: show multipath path state Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 26/27] pvenode: add iscsi commands Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 27/27] ui: san luns: show iSCSI targets and sessions Dietmar Maurer
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Mount the node-level iSCSI initiator API from libpve-storage-perl at
/nodes/{node}/iscsi, next to the other storage-backed node endpoints.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 PVE/API2/Nodes.pm | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 2ca3244d..e27a5f13 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -49,6 +49,7 @@ use PVE::API2::Certificates;
 use PVE::API2::Disks;
 use PVE::API2::Firewall::Host;
 use PVE::API2::Hardware;
+use PVE::API2::ISCSI;
 use PVE::API2::LXC::Status;
 use PVE::API2::LXC;
 use PVE::API2::Network;
@@ -159,6 +160,11 @@ __PACKAGE__->register_method({
     path => 'hardware',
 });
 
+__PACKAGE__->register_method({
+    subclass => "PVE::API2::ISCSI",
+    path => 'iscsi',
+});
+
 __PACKAGE__->register_method({
     subclass => "PVE::API2::Capabilities",
     path => 'capabilities',
@@ -239,6 +245,7 @@ __PACKAGE__->register_method({
             { name => 'firewall' },
             { name => 'hardware' },
             { name => 'hosts' },
+            { name => 'iscsi' },
             { name => 'journal' },
             { name => 'lxc' },
             { name => 'migrateall' },
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 26/27] pvenode: add iscsi commands
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (24 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 25/27] api: nodes: add iSCSI initiator API endpoint Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  2026-07-31 10:21 ` [RFC pve-manager 27/27] ui: san luns: show iSCSI targets and sessions Dietmar Maurer
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Expose the iSCSI initiator API on the command line, so target and
session state, negotiated parameters, rescan, logout and node record
removal are reachable without the web interface or raw pvesh calls.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 PVE/CLI/pvenode.pm | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)

diff --git a/PVE/CLI/pvenode.pm b/PVE/CLI/pvenode.pm
index 7f717642..f40190ac 100644
--- a/PVE/CLI/pvenode.pm
+++ b/PVE/CLI/pvenode.pm
@@ -7,6 +7,7 @@ use PVE::API2::ACME;
 use PVE::API2::ACMEAccount;
 use PVE::API2::ACMEPlugin;
 use PVE::API2::Certificates;
+use PVE::API2::ISCSI;
 use PVE::API2::NodeConfig;
 use PVE::API2::Nodes;
 use PVE::API2::Tasks;
@@ -370,6 +371,47 @@ our $cmddef = {
         },
     ],
 
+    iscsi => {
+        status => [
+            'PVE::API2::ISCSI',
+            'status',
+            [],
+            { node => $nodename },
+            sub {
+                my ($data, $schema, $options) = @_;
+                PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
+            },
+            $PVE::RESTHandler::standard_output_options,
+        ],
+        targets => [
+            'PVE::API2::ISCSI',
+            'targets',
+            [],
+            { node => $nodename },
+            sub {
+                my ($data, $schema, $options) = @_;
+                PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
+            },
+            $PVE::RESTHandler::standard_output_options,
+        ],
+        'target-delete' =>
+            ['PVE::API2::ISCSI', 'target_delete', ['target'], { node => $nodename }],
+        'session-params' => [
+            'PVE::API2::ISCSI',
+            'session_params',
+            ['session-id'],
+            { node => $nodename },
+            sub {
+                my ($data, $schema, $options) = @_;
+                PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
+            },
+            $PVE::RESTHandler::standard_output_options,
+        ],
+        'session-logout' =>
+            ['PVE::API2::ISCSI', 'session_logout', ['session-id'], { node => $nodename }],
+        rescan => ['PVE::API2::ISCSI', 'rescan', ['session-id'], { node => $nodename }],
+    },
+
 };
 
 1;
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

* [RFC pve-manager 27/27] ui: san luns: show iSCSI targets and sessions
  2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
                   ` (25 preceding siblings ...)
  2026-07-31 10:21 ` [RFC pve-manager 26/27] pvenode: add iscsi commands Dietmar Maurer
@ 2026-07-31 10:21 ` Dietmar Maurer
  26 siblings, 0 replies; 28+ messages in thread
From: Dietmar Maurer @ 2026-07-31 10:21 UTC (permalink / raw)
  To: pve-devel

Show the iSCSI targets known to the node's initiator in a collapsible
area below the SAN LUN list, since iSCSI sessions provide a share of
the listed LUNs. The view is a tree with one node per target and its
portals as leaves, keeping it compact for setups with many portals:
the leaves merge active sessions with the initiator's node records,
so a target stays visible as not connected after a logout or while
its portal is unreachable, and each target summarizes how many of its
portals are connected. Targets start collapsed; a reload keeps the
expansion and selection state. Leaves report session id, transport
and health; the toolbar shows the initiator IQN with a copy button,
as the SAN side needs it for LUN masking. An info window shows the
parameters a session negotiated at login.

Session logins are managed by the storage layer, so the offered
actions are a rescan for new or resized LUNs, logging out of a
session and removing the node records of a logged-out portal, or of
a whole logged-out target, the latter two mainly to clean up after
removed storages. The confirmations warn when a configured storage
still uses the target, as records and sessions come back on the next
activation. The actions are only offered with Sys.Modify, and the
whole area hides itself when open-iscsi is not installed, keeping FC
or NVMe only nodes unaffected.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 www/manager6/Makefile             |   1 +
 www/manager6/Utils.js             |  33 ++
 www/manager6/node/Config.js       |   4 +-
 www/manager6/node/ISCSITargets.js | 552 ++++++++++++++++++++++++++++++
 www/manager6/node/SANLuns.js      |  47 +++
 5 files changed, 635 insertions(+), 2 deletions(-)
 create mode 100644 www/manager6/node/ISCSITargets.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index c354b3aa..7dcb3c59 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -243,6 +243,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/Utils.js b/www/manager6/Utils.js
index a34e1363..53aeb341 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -1142,6 +1142,39 @@ Ext.define('PVE.Utils', {
             return Ext.htmlEncode(rec.get('detail') || gettext('in use'));
         },
 
+        render_iscsi_transport: function (value) {
+            if (!value) {
+                return '';
+            }
+            return value === 'iser' ? 'iSER' : value.toUpperCase();
+        },
+
+        // Render a session health value as icon plus label; a value this map
+        // does not know falls back to the raw string with the unknown icon.
+        render_iscsi_session_health: function (value) {
+            if (!value) {
+                return '';
+            }
+            let display = {
+                'logged-in': { icon: 'good fa-check', label: gettext('Logged in') },
+                recovering: { icon: 'warning fa-exclamation', label: gettext('Recovering') },
+                'logging-out': { icon: 'faded fa-sign-out', label: gettext('Logging out') },
+                'not-connected': { icon: 'faded fa-unlink', label: gettext('Not connected') },
+                unknown: { icon: 'faded fa-question', label: gettext('Unknown') },
+            }[value];
+            let icon = display?.icon ?? 'faded fa-question';
+            let label = display?.label ?? value;
+            return `<i class="fa fa-fw ${icon}"></i> ${Ext.htmlEncode(label)}`;
+        },
+
+        // Render a whole-second duration like "120 s"; empty for missing values.
+        render_seconds: function (value) {
+            if (value === undefined || value === null) {
+                return '';
+            }
+            return `${value} s`;
+        },
+
         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 6329241a..3ac599e8 100644
--- a/www/manager6/node/Config.js
+++ b/www/manager6/node/Config.js
@@ -359,10 +359,10 @@ Ext.define('PVE.node.Config', {
                 },
             );
 
-            // the san-luns scan endpoint requires Datastore.Allocate
+            // the san-luns scan and iscsi endpoints require Datastore.Allocate
             if (caps.storage['Datastore.Allocate']) {
                 me.items.push({
-                    xtype: 'pveSANLunList',
+                    xtype: 'pveSANLunPanel',
                     title: gettext('SAN LUNs'),
                     itemId: 'san-luns',
                     iconCls: 'fa fa-cubes',
diff --git a/www/manager6/node/ISCSITargets.js b/www/manager6/node/ISCSITargets.js
new file mode 100644
index 00000000..5a2d8265
--- /dev/null
+++ b/www/manager6/node/ISCSITargets.js
@@ -0,0 +1,552 @@
+// Tree of the iSCSI targets known to the node's initiator, with their portals
+// as leaves: the active sessions merged with the initiator's node records, so
+// a target stays visible as not connected after a logout or while its portal
+// is unreachable. An info window shows the parameters a session negotiated at
+// login. Session logins are managed by the iSCSI storage plugin on storage
+// activation; the offered actions are a rescan for new LUNs, logging out of a
+// session and removing the node records of a logged-out portal or target, the
+// latter two mainly to clean up after removed storages.
+
+// Read-only details for a single session: the negotiated parameters loaded from
+// the session-params endpoint, prefixed by the session identity. The identity
+// rows are seeded as defaults from the list record so they show before the load
+// completes.
+Ext.define('PVE.node.ISCSISessionParams', {
+    extend: 'Proxmox.grid.ObjectGrid',
+    xtype: 'pveISCSISessionParams',
+
+    viewConfig: {
+        enableTextSelection: true,
+    },
+
+    // The negotiated session parameters, loaded from the endpoint. The session
+    // identity rows are prepended per-instance in initComponent.
+    rows: {
+        '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: PVE.Utils.render_seconds,
+        },
+        'abort-timeout': {
+            header: gettext('Abort Timeout'),
+            renderer: PVE.Utils.render_seconds,
+        },
+        'lu-reset-timeout': {
+            header: gettext('LUN Reset Timeout'),
+            renderer: PVE.Utils.render_seconds,
+        },
+        'tgt-reset-timeout': {
+            header: gettext('Target Reset Timeout'),
+            renderer: PVE.Utils.render_seconds,
+        },
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        if (!me.nodename) {
+            throw 'no node name specified';
+        }
+        if (!me.sessionData) {
+            throw 'no session specified';
+        }
+
+        let session = me.sessionData;
+
+        me.url = `/api2/json/nodes/${me.nodename}/iscsi/session-params`;
+        me.extraParams = { 'session-id': session['session-id'] };
+
+        // prepend the session identity, seeded from the list record so it shows
+        // before the parameter 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.Utils.render_iscsi_transport,
+            },
+            health: {
+                header: gettext('Status'),
+                defaultValue: session.health,
+                renderer: PVE.Utils.render_iscsi_session_health,
+            },
+            'kernel-state': {
+                header: gettext('Kernel State'),
+                defaultValue: session['kernel-state'],
+            },
+            ...me.rows,
+        };
+
+        me.callParent();
+
+        me.rstore.load();
+    },
+});
+
+Ext.define('PVE.node.ISCSITargetTree', {
+    extend: 'Ext.tree.Panel',
+    xtype: 'pveISCSITargetTree',
+
+    stateful: true,
+    stateId: 'grid-node-iscsi-target',
+
+    rootVisible: false,
+
+    viewConfig: {
+        emptyText: gettext('No iSCSI targets found'),
+    },
+
+    selModel: { allowDeselect: true },
+
+    viewModel: {
+        data: {
+            // The bare initiator IQN, empty when none is available.
+            initiatorName: '',
+            // The text shown in the toolbar, including the alias if set.
+            initiatorText: '',
+        },
+    },
+
+    controller: {
+        xclass: 'Ext.app.ViewController',
+
+        // group the flat per-portal entries of the target endpoint into one
+        // tree node per target with its portals as leaves
+        buildChildren: function (rows, expandedTargets) {
+            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 connected = portals.filter((p) => p.health === 'logged-in').length;
+                    let sessions = portals.filter((p) => p['session-id'] !== undefined).length;
+                    return {
+                        text: target,
+                        target: target,
+                        isTarget: true,
+                        iconCls: 'fa fa-bullseye',
+                        expanded: !!expandedTargets[target],
+                        connectedCount: connected,
+                        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-plug',
+                            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'],
+                        })),
+                    };
+                });
+        },
+
+        // a stable key for a tree node, so the selection survives a reload
+        // that rebuilds the whole tree from scratch
+        nodeKey: function (node) {
+            return node.get('isTarget')
+                ? `target:${node.get('target')}`
+                : `portal:${node.get('target')}\0${node.get('portal')}`;
+        },
+
+        reloadData: function () {
+            let me = this;
+            let view = me.getView();
+            Proxmox.Utils.API2Request({
+                url: `/nodes/${view.nodename}/iscsi/target`,
+                method: 'GET',
+                waitMsgTarget: view,
+                failure: (response) => Proxmox.Utils.setErrorMask(view, response.htmlStatus),
+                success: function (response) {
+                    Proxmox.Utils.setErrorMask(view, false);
+                    let prev = view.getSelection()[0];
+                    let key = prev ? me.nodeKey(prev) : undefined;
+                    // targets start collapsed, but a reload keeps what the
+                    // user expanded by hand
+                    let expandedTargets = {};
+                    view.getRootNode().eachChild((n) => {
+                        if (n.isExpanded()) {
+                            expandedTargets[n.get('target')] = true;
+                        }
+                    });
+                    view.setRootNode({
+                        expanded: true,
+                        children: me.buildChildren(response.result.data, expandedTargets),
+                    });
+                    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(),
+            });
+        },
+
+        onLogout: function () {
+            let me = this;
+            let view = me.getView();
+            let rec = view.getSelection()[0];
+            let msg = Ext.String.format(
+                gettext("Log out of the session on portal '{0}'?"),
+                Ext.htmlEncode(rec.get('portal')),
+            );
+            let storage = rec.get('used-by-storage');
+            if (storage) {
+                msg += `<br><br>${Ext.String.format(
+                    gettext(
+                        'Storage "{0}" uses this session - it will be logged in again' +
+                            ' automatically on the next storage activation.',
+                    ),
+                    Ext.htmlEncode(storage),
+                )}`;
+            }
+            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(),
+                });
+            });
+        },
+
+        // on a portal leaf only that portal's node record is removed, on a
+        // target node all records of the target
+        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 initiator records for target '{0}'?"),
+                    Ext.htmlEncode(rec.get('target')),
+                );
+            } else {
+                params.portal = rec.get('portal');
+                msg = Ext.String.format(
+                    gettext("Remove the record for target '{0}', portal '{1}'?"),
+                    Ext.htmlEncode(rec.get('target')),
+                    Ext.htmlEncode(rec.get('portal')),
+                );
+            }
+            let storage = rec.get('used-by-storage');
+            if (storage) {
+                msg += `<br><br>${Ext.String.format(
+                    gettext(
+                        'Storage "{0}" uses this target - the records will be created again' +
+                            ' automatically on the next storage activation.',
+                    ),
+                    Ext.htmlEncode(storage),
+                )}`;
+            }
+            Ext.Msg.confirm(gettext('Confirm'), msg, 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) {
+                navigator.clipboard?.writeText(name);
+            }
+        },
+
+        setInitiator: function (status) {
+            let me = this;
+            let view = me.getView();
+            if (!status.supported) {
+                // without open-iscsi there are no targets to show, and nodes
+                // with FC or NVMe attached SANs only need not install it
+                view.hide();
+                return;
+            }
+            let initiator = status.initiator || {};
+            let text = initiator.name || Proxmox.Utils.unknownText;
+            if (initiator.alias) {
+                text += ` (${initiator.alias})`;
+            }
+            me.getViewModel().set({
+                initiatorName: initiator.name || '',
+                initiatorText: text,
+            });
+            view.iscsiSupported = true;
+            view.reload();
+        },
+
+        init: function (view) {
+            let me = this;
+            if (!view.nodename) {
+                throw 'no node name specified';
+            }
+            Proxmox.Utils.API2Request({
+                url: `/nodes/${view.nodename}/iscsi/status`,
+                method: 'GET',
+                failure: (response) => Proxmox.Utils.setErrorMask(view, response.htmlStatus),
+                success: (response) => me.setInitiator(response.result.data),
+            });
+        },
+    },
+
+    store: {
+        type: 'tree',
+        fields: [
+            'text',
+            'isTarget',
+            'connectedCount',
+            'sessionCount',
+            'totalPortals',
+            'target',
+            'portal',
+            'session-id',
+            'transport',
+            'health',
+            'kernel-state',
+            'used-by-storage',
+        ],
+        root: { expanded: true, children: [] },
+    },
+
+    // loading is deferred until the status query confirms open-iscsi is
+    // installed, and skipped entirely when it is not
+    reload: function () {
+        if (this.iscsiSupported) {
+            this.getController().reloadData();
+        }
+    },
+
+    listeners: {
+        activate: 'onReload',
+        itemdblclick: 'onItemDblClick',
+    },
+
+    columns: [
+        {
+            xtype: 'treecolumn',
+            text: gettext('Target'),
+            dataIndex: 'text',
+            flex: 3,
+        },
+        {
+            text: gettext('Session ID'),
+            dataIndex: 'session-id',
+            width: 110,
+        },
+        {
+            text: gettext('Transport'),
+            dataIndex: 'transport',
+            width: 100,
+            renderer: PVE.Utils.render_iscsi_transport,
+        },
+        {
+            text: gettext('Status'),
+            dataIndex: 'health',
+            width: 150,
+            renderer: function (value, metaData, rec) {
+                if (!rec.get('isTarget')) {
+                    return PVE.Utils.render_iscsi_session_health(value);
+                }
+                let connected = rec.get('connectedCount');
+                let total = rec.get('totalPortals');
+                if (connected === 0) {
+                    return `<i class="fa fa-fw faded fa-unlink"></i> ${gettext('Not connected')}`;
+                }
+                if (connected === total) {
+                    return `<i class="fa fa-fw good fa-check"></i> ${gettext('Connected')}`;
+                }
+                return `<i class="fa fa-fw warning fa-exclamation"></i> ${connected}/${total} ${gettext('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;
+
+        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.nodes['Sys.Modify']) {
+            items.push(
+                {
+                    xtype: 'proxmoxButton',
+                    text: gettext('Rescan'),
+                    iconCls: 'fa fa-refresh',
+                    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,
+                    // a portal leaf must be logged out; a target node covers
+                    // all of its records, so every portal must be logged out
+                    enableFn: (rec) =>
+                        rec.get('isTarget') ? rec.get('sessionCount') === 0 : !hasSession(rec),
+                    handler: 'onRemove',
+                },
+            );
+        }
+        items.push(
+            {
+                xtype: 'proxmoxButton',
+                text: gettext('Info'),
+                iconCls: 'fa fa-info-circle',
+                disabled: true,
+                enableFn: hasSession,
+                handler: 'onInfo',
+            },
+            '->',
+            {
+                xtype: 'displayfield',
+                fieldLabel: gettext('Initiator Name'),
+                labelWidth: 100,
+                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();
+    },
+});
diff --git a/www/manager6/node/SANLuns.js b/www/manager6/node/SANLuns.js
index ce11c39b..0503a3f8 100644
--- a/www/manager6/node/SANLuns.js
+++ b/www/manager6/node/SANLuns.js
@@ -226,3 +226,50 @@ Ext.define('PVE.node.SANLunList', {
         me.reload();
     },
 });
+
+// The SAN observability page: the LUN list with the iSCSI target list of the
+// node below it, since iSCSI sessions provide a share of the listed LUNs.
+Ext.define('PVE.node.SANLunPanel', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveSANLunPanel',
+
+    layout: 'border',
+
+    // the config panel activates the card itself, so propagate the reload to
+    // both views by hand
+    listeners: {
+        activate: function () {
+            this.items.each((panel) => panel.reload());
+        },
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let nodename = me.pveSelNode?.data?.node;
+        if (!nodename) {
+            throw 'no node name specified';
+        }
+
+        me.items = [
+            {
+                xtype: 'pveSANLunList',
+                region: 'center',
+                border: false,
+                pveSelNode: me.pveSelNode,
+            },
+            {
+                xtype: 'pveISCSITargetTree',
+                region: 'south',
+                title: gettext('iSCSI Targets'),
+                height: '35%',
+                collapsible: true,
+                animCollapse: false,
+                border: false,
+                nodename: nodename,
+            },
+        ];
+
+        me.callParent();
+    },
+});
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 28+ messages in thread

end of thread, other threads:[~2026-07-31 10:31 UTC | newest]

Thread overview: 28+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 01/27] diskmanage: collect disk transport type from lsblk Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 02/27] diskmanage: add helper to list multipath devices Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 03/27] diskmanage: qualify NVMe over fabrics transport Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 04/27] disks: list: add include-remote parameter Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 05/27] diskmanage: include iSCSI session devices in disk enumeration Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 06/27] diskmanage: link multipath member disks to their map device Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 07/27] iscsi: factor out session device map from device list Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 08/27] api: scan: add san-luns method listing SAN LUN candidates Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 09/27] disks: lvm: allow creating volume groups on multipath devices Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 10/27] diskmanage: add helper querying multipath path state Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 11/27] diskmanage: add helper querying NVMe native " Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 12/27] api: scan: san-luns: report " Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 13/27] iscsi plugin: list sessions of all transports and capture transport Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 14/27] api: add node-level iSCSI initiator target and session API Dietmar Maurer
2026-07-31 10:21 ` [RFC proxmox-widget-toolkit 15/27] disk selectors: allow opting into remote devices Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 16/27] ui: storage: allow switching the scan node of the NFS/CIFS scan combos Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 17/27] ui: storage: add guided remote storage wizard with NFS support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 18/27] ui: storage wizard: add SMB/CIFS support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 19/27] ui: storage wizard: add iSCSI support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 20/27] ui: storage wizard: add FC-attached SAN (shared LVM) support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 21/27] ui: storage wizard: add ZFS over iSCSI support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 22/27] ui: dc: storage: add remote storage wizard entry to the add menu Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 23/27] ui: node: add SAN LUNs panel Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 24/27] ui: san luns: show multipath path state Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 25/27] api: nodes: add iSCSI initiator API endpoint Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 26/27] pvenode: add iscsi commands Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 27/27] ui: san luns: show iSCSI targets and sessions Dietmar Maurer

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal