Map storage devices to their PCI storage controller

Each disk in /api/hardware now carries a controller_slot, resolved from
the disk's sysfs device path: the deepest PCI address in that path is the
controller (bridges sit above it), normalised to lspci's slot format so it
matches pci_devices[].slot directly. NVMe falls back to the controller's
own sysfs link when the namespace resolves to a subsystem with no PCI
parent; zvols and other virtual devices get None.

Hardware page:
- The disk dialog shows its storage controller (click through to the
  controller), and the PCI device dialog lists the disks attached to it.
- Storage controllers in the PCI Devices list get a badge with the
  number of attached disks.

Storage view:
- /api/storage returns controller_slot on each physical disk plus a
  controllers map (slot -> class, vendor, device, subsystem name,
  driver), built from the cached `lspci -vmm` output so the Storage view
  doesn't need the heavier /api/hardware enumeration.
- "Physical Disks & SMART Status" can group disks under their storage
  controller, with a header showing the controller name, slot, driver
  and disk count. Groups follow PCI slot order; disks with no resolvable
  controller are listed last. USB disks keep their own section.
- The disk details dialog shows the storage controller on the Overview
  tab.
- Grouping is opt-in via a new Storage Display card in Settings, stored
  per browser (localStorage) and synced across open views, following the
  existing nav-tab-order preference pattern. Off by default.

Also:
- Treat "IDE interface" as a storage controller, so SATA controllers left
  in BIOS IDE mode (e.g. Intel 7 Series, ata_piix) appear in pci_devices
  and disks behind them can be mapped.
- lspci prints "Device xxxx" when it has no subsystem name; that
  placeholder is ignored so the chip name is shown instead.
- Add the missing sdevice field to the PCIDevice type.

Tests cover the sysfs path -> controller slot rule against real paths
from two PVE 9 hosts (SAS HBA, AHCI SATA, SATA in BIOS IDE mode, USB
behind a hub, NVMe behind chipset bridges), the non-zero PCI domain case
(Intel VMD), the NVMe multipath fallback, and get_storage_controllers'
lspci parsing including the "Device xxxx" placeholder and driver lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DrOwl
2026-10-29 11:47:00 +00:00
co-authored by Claude Opus 5.5
parent 5db3ae9c92
commit 30034e7aa3
9 changed files with 594 additions and 11 deletions
+109 -3
View File
@@ -4028,6 +4028,106 @@ def is_disk_removable(disk_name):
return False
_PCI_ADDR_RE = re.compile(r'^([0-9a-f]{4,}):([0-9a-f]{2}:[0-9a-f]{2}\.[0-7])$')
def _last_pci_slot(sysfs_path):
"""Return the deepest PCI address in a resolved sysfs path, in lspci format.
lspci omits the PCI domain when it is 0000, so we do too; that keeps the
result directly comparable with pci_devices[].slot. Non-zero domains (e.g.
Intel VMD's 10000:) are kept, matching what lspci prints for them.
"""
slot = None
for part in sysfs_path.split('/'):
m = _PCI_ADDR_RE.match(part)
if m:
domain, bdf = m.groups()
slot = bdf if domain == '0000' else f'{domain}:{bdf}'
return slot
def get_disk_controller_slot(disk_name):
"""Return the PCI slot of the controller a disk hangs off, or None.
The disk's sysfs device path runs from the PCI root down through any
bridges to the controller, then into the controller's own topology:
SAS HBA .../0000:01:00.0/host6/port-6:1/end_device-6:1/... -> 01:00.0
SATA .../0000:02:00.1/ata1/host0/... -> 02:00.1
USB .../0000:0c:00.3/usb3/3-2/3-2.4/.../host7/... -> 0c:00.3
NVMe .../0000:03:04.0/0000:08:00.0/nvme/nvme0 -> 08:00.0
Bridges come earlier in the path, so the last PCI address is always the
controller. zvols, loop and other virtual devices have no device link.
"""
try:
device_path = f'/sys/block/{disk_name}/device'
if os.path.exists(device_path):
slot = _last_pci_slot(os.path.realpath(device_path))
if slot:
return slot
# NVMe native multipath can point the namespace at an nvme-subsystem
# with no PCI parent; fall back to the controller's own device link.
match = re.match(r'(nvme\d+)n\d+', disk_name)
if match:
ctrl_path = f'/sys/class/nvme/{match.group(1)}/device'
if os.path.exists(ctrl_path):
return _last_pci_slot(os.path.realpath(ctrl_path))
except Exception:
pass
return None
def get_storage_controllers(slots):
"""Describe the PCI controllers at the given slots, keyed by slot.
Built from the cached `lspci -vmm` output rather than the full
/api/hardware enumeration, so the Storage view can label its
controller groups without paying for get_hardware_info(). No class
filter: whatever a disk resolved to is described, including SATA
controllers left in BIOS IDE mode.
"""
wanted = {s for s in slots if s}
controllers = {}
if not wanted:
return controllers
for block in get_cached_lspci_vmm().split('\n\n'):
fields = {}
for line in block.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
fields[key.strip()] = value.strip()
slot = fields.get('Slot')
if slot not in wanted:
continue
driver = None
try:
sysfs_addr = slot if slot.count(':') == 2 else f'0000:{slot}'
driver_link = f'/sys/bus/pci/devices/{sysfs_addr}/driver'
if os.path.exists(driver_link):
driver = os.path.basename(os.readlink(driver_link))
except Exception:
pass
# lspci prints "Device 84ca" when it has no name for the subsystem;
# drop that so the UI falls back to the real chip name.
sdevice = fields.get('SDevice') or None
if sdevice and re.fullmatch(r'Device [0-9a-f]{4}', sdevice):
sdevice = None
controllers[slot] = {
'slot': slot,
'class': fields.get('Class', ''),
'vendor': fields.get('Vendor', ''),
'device': fields.get('Device', ''),
'sdevice': sdevice,
'driver': driver,
}
return controllers
def is_disk_usb(disk_name):
"""Return True when sysfs places the disk behind a USB bus."""
return resolver_is_usb_disk(disk_name)
@@ -4410,6 +4510,7 @@ def get_storage_info():
'total_lbas_written': smart_data.get('total_lbas_written'),
'ssd_life_left': smart_data.get('ssd_life_left'),
'connection_type': conn_type,
'controller_slot': get_disk_controller_slot(disk_name),
'removable': removable,
'is_system_disk': is_system_disk,
'system_usage': system_usage,
@@ -4687,7 +4788,10 @@ def get_storage_info():
pass
storage_data['disks'] = list(physical_disks.values())
storage_data['controllers'] = get_storage_controllers(
d.get('controller_slot') for d in physical_disks.values()
)
return storage_data
except Exception as e:
@@ -9491,6 +9595,7 @@ def _get_hardware_info_uncached():
'rotation_rate': smart_data.get('rotation_rate', 0),
'form_factor': form_factor,
'sata_version': sata_version,
'controller_slot': get_disk_controller_slot(disk_name),
}
if pcie_info:
@@ -9635,8 +9740,9 @@ def _get_hardware_info_uncached():
if any(keyword in device_class for keyword in ['VGA', 'Display', '3D']):
device_type = 'Graphics Card'
include_device = True
# Storage controllers (including SCSI/SAS HBA cards like LSI 9400-16i)
elif any(keyword in device_class for keyword in ['SATA', 'RAID', 'Mass storage', 'Non-Volatile memory', 'SCSI', 'Serial Attached SCSI', 'SAS']):
# Storage controllers (including SCSI/SAS HBA cards like LSI 9400-16i,
# and SATA controllers left in BIOS IDE mode, which report "IDE interface")
elif any(keyword in device_class for keyword in ['SATA', 'RAID', 'Mass storage', 'Non-Volatile memory', 'SCSI', 'Serial Attached SCSI', 'SAS', 'IDE']):
device_type = 'Storage Controller'
include_device = True
# Network controllers
@@ -0,0 +1,157 @@
"""Disk -> storage controller mapping; never imports flask_server.
The sysfs paths and lspci records are real output from two PVE 9 hosts:
a Broadcom 9400-16i SAS HBA, AMD/ASMedia SATA, an Intel 7 Series SATA
controller left in BIOS IDE mode, USB disks behind a hub, and NVMe drives
behind chipset bridges.
Run: python3 -m unittest discover -s AppImage/scripts/tests -p test_disk_controller_slot.py -v
"""
import ast
import posixpath
import re
from pathlib import Path
from types import SimpleNamespace
import unittest
SOURCE = Path(__file__).resolve().parents[1] / "flask_server.py"
NAMES = {"_PCI_ADDR_RE", "_last_pci_slot", "get_disk_controller_slot", "get_storage_controllers"}
LSPCI_VMM = """Slot:\t01:00.0
Class:\tSerial Attached SCSI controller
Vendor:\tBroadcom / LSI
Device:\tSAS3416 Fusion-MPT Tri-Mode I/O Controller Chip (IOC)
SVendor:\tBroadcom / LSI
SDevice:\tHBA 9400-16i
Slot:\t02:00.1
Class:\tSATA controller
Vendor:\tAdvanced Micro Devices, Inc. [AMD]
Device:\t500 Series Chipset SATA Controller
SVendor:\tASMedia Technology Inc.
SDevice:\tASM1062 Serial ATA Controller
Slot:\t08:00.0
Class:\tNon-Volatile memory controller
Vendor:\tMicron/Crucial Technology
Device:\tP5 Plus NVMe PCIe SSD
SVendor:\tMicron/Crucial Technology
SDevice:\tDevice 0100
Slot:\t00:1f.2
Class:\tIDE interface
Vendor:\tIntel Corporation
Device:\t7 Series/C210 Series Chipset Family 4-port SATA Controller [IDE mode]
SVendor:\tASUSTeK Computer Inc.
SDevice:\tDevice 84ca
"""
def fake_os(links=None, readlinks=None):
links, readlinks = links or {}, readlinks or {}
known = set(links) | set(readlinks)
return SimpleNamespace(
readlink=lambda p: readlinks[p],
path=SimpleNamespace(
realpath=lambda p: links.get(p, p),
exists=lambda p: p in known,
basename=posixpath.basename,
),
)
def load(os_module=None, lspci=LSPCI_VMM):
tree = ast.parse(SOURCE.read_text())
nodes = [n for n in tree.body
if (isinstance(n, ast.FunctionDef) and n.name in NAMES)
or (isinstance(n, ast.Assign) and any(getattr(t, "id", None) in NAMES for t in n.targets))]
found = {n.name if isinstance(n, ast.FunctionDef) else n.targets[0].id for n in nodes}
assert found == NAMES, NAMES - found
env = {"os": os_module or fake_os(), "re": re, "get_cached_lspci_vmm": lambda: lspci}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SOURCE), "exec"), env)
return env
class LastPciSlotTests(unittest.TestCase):
def setUp(self):
self.slot = load()["_last_pci_slot"]
def test_controller_is_the_deepest_pci_address(self):
cases = {
# SAS HBA, direct attach (no expander)
"/sys/devices/pci0000:00/0000:00:01.1/0000:01:00.0/host6/port-6:1/end_device-6:1/target6:0:1/6:0:1:0": "01:00.0",
# Onboard SATA (AHCI)
"/sys/devices/pci0000:00/0000:00:02.1/0000:02:00.1/ata1/host0/target0:0:0/0:0:0:0": "02:00.1",
# SATA in BIOS IDE mode (ata_piix)
"/sys/devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0": "00:1f.2",
# USB disk behind a hub -> the xHCI controller
"/sys/devices/pci0000:00/0000:00:08.1/0000:0c:00.3/usb3/3-2/3-2.4/3-2.4.1/3-2.4.1:1.0/host7/target7:0:0/7:0:0:0": "0c:00.3",
# NVMe behind two chipset bridges -> the drive itself
"/sys/devices/pci0000:00/0000:00:02.1/0000:02:00.2/0000:03:04.0/0000:08:00.0/nvme/nvme0": "08:00.0",
}
for path, expected in cases.items():
self.assertEqual(self.slot(path), expected, path)
def test_domain_is_kept_when_not_zero(self):
# Intel VMD exposes NVMe under a 10000: domain, which lspci prints too
path = "/sys/devices/pci0000:00/0000:00:0e.0/pci10000:e0/10000:e0:1d.0/10000:e1:00.0/nvme/nvme2"
self.assertEqual(self.slot(path), "10000:e1:00.0")
def test_path_without_pci_gives_none(self):
self.assertIsNone(self.slot("/sys/devices/virtual/nvme-subsystem/nvme-subsys0"))
class DiskControllerSlotTests(unittest.TestCase):
def test_disk_resolves_through_block_device_link(self):
get_slot = load(fake_os(links={
"/sys/block/sdf/device": "/sys/devices/pci0000:00/0000:00:02.1/0000:02:00.1/ata1/host0/target0:0:0/0:0:0:0",
}))["get_disk_controller_slot"]
self.assertEqual(get_slot("sdf"), "02:00.1")
def test_nvme_multipath_falls_back_to_controller_link(self):
get_slot = load(fake_os(links={
"/sys/block/nvme0n1/device": "/sys/devices/virtual/nvme-subsystem/nvme-subsys0",
"/sys/class/nvme/nvme0/device": "/sys/devices/pci0000:00/0000:00:02.2/0000:0b:00.0",
}))["get_disk_controller_slot"]
self.assertEqual(get_slot("nvme0n1"), "0b:00.0")
def test_zvol_without_device_link_gives_none(self):
self.assertIsNone(load()["get_disk_controller_slot"]("zd16"))
class StorageControllersTests(unittest.TestCase):
def test_describes_only_requested_slots_once(self):
controllers = load()["get_storage_controllers"](
iter(["01:00.0", "01:00.0", "02:00.1", None, "99:00.0"]))
self.assertEqual(set(controllers), {"01:00.0", "02:00.1"})
self.assertEqual(controllers["01:00.0"]["sdevice"], "HBA 9400-16i")
self.assertEqual(controllers["01:00.0"]["class"], "Serial Attached SCSI controller")
def test_ide_mode_controller_is_described(self):
controllers = load()["get_storage_controllers"](["00:1f.2"])
self.assertEqual(controllers["00:1f.2"]["class"], "IDE interface")
def test_lspci_placeholder_subsystem_name_is_dropped(self):
controllers = load()["get_storage_controllers"](["08:00.0", "00:1f.2"])
self.assertIsNone(controllers["08:00.0"]["sdevice"])
self.assertIsNone(controllers["00:1f.2"]["sdevice"])
self.assertEqual(controllers["08:00.0"]["device"], "P5 Plus NVMe PCIe SSD")
def test_driver_read_from_sysfs_with_domain_prefix(self):
controllers = load(fake_os(readlinks={
"/sys/bus/pci/devices/0000:01:00.0/driver": "../../../bus/pci/drivers/mpt3sas",
}))["get_storage_controllers"](["01:00.0", "02:00.1"])
self.assertEqual(controllers["01:00.0"]["driver"], "mpt3sas")
self.assertIsNone(controllers["02:00.1"]["driver"])
def test_no_slots_skips_lspci(self):
def fail():
raise AssertionError("lspci should not be read")
env = load()
env["get_cached_lspci_vmm"] = fail
self.assertEqual(env["get_storage_controllers"]([None]), {})
if __name__ == "__main__":
unittest.main()