diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 80021881..18ccf771 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -633,6 +633,13 @@ export default function Hardware() { return realtimeGPUData.has_monitoring_tool === true } + const diskCountBySlot = new Map() + for (const disk of hardwareData?.storage_devices ?? []) { + if (disk.controller_slot) { + diskCountBySlot.set(disk.controller_slot, (diskCountBySlot.get(disk.controller_slot) ?? 0) + 1) + } + } + if (isLoading) { return (
@@ -2358,14 +2365,23 @@ return (
- {hardwareData.pci_devices.map((device, index) => ( + {hardwareData.pci_devices.map((device, index) => { + const diskCount = diskCountBySlot.get(device.slot) ?? 0 + return (
setSelectedPCIDevice(device)} className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors" >
- {translateDeviceType(device.type, t)} +
+ {translateDeviceType(device.type, t)} + {diskCount > 0 && ( + + {t("hardware.counts.devices", { count: diskCount })} + + )} +
{device.slot}

{device.device}

@@ -2374,7 +2390,8 @@ return (

{t("hardware.labels.driver")}: {device.driver}

)}
- ))} + ) + })}
)} @@ -2434,6 +2451,37 @@ return ( {selectedPCIDevice.kernel_module} )} + + {(() => { + const attachedDisks = (hardwareData?.storage_devices ?? []).filter( + (disk) => disk.controller_slot === selectedPCIDevice.slot, + ) + if (attachedDisks.length === 0) return null + return ( +
+ + {t("hardware.labels.attachedDisks")} ({attachedDisks.length}) + +
+ {attachedDisks.map((disk) => ( + + ))} +
+
+ ) + })()} )} @@ -2827,6 +2875,34 @@ return ( )} + {selectedDisk.controller_slot && (() => { + // Fall back to the bare slot when the controller's PCI class isn't one we list + const controller = hardwareData?.pci_devices?.find((d) => d.slot === selectedDisk.controller_slot) + return ( +
+ {t("hardware.labels.storageController")} + + {controller ? ( + + ) : null} + {selectedDisk.controller_slot} + +
+ ) + })()} + {selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
{t("hardware.labels.rotationRate")} diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index c6385ae5..3fbba080 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -20,6 +20,7 @@ import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider" import { useIsPbs } from "../lib/use-platform" import type { LanguageCode } from "../lib/i18n/languages" import { NavTabOrderCard } from "./nav-tab-order-card" +import { StorageDisplayCard } from "./storage-display-card" import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors" // GitHub Dark color palette for bash syntax highlighting @@ -1233,6 +1234,9 @@ export function Settings() { + {/* Storage Display: group physical disks by controller */} + + {/* Health Monitor Settings */} diff --git a/AppImage/components/storage-display-card.tsx b/AppImage/components/storage-display-card.tsx new file mode 100644 index 00000000..ef92d386 --- /dev/null +++ b/AppImage/components/storage-display-card.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { Switch } from "./ui/switch" +import { Check, HardDrive, Settings2 } from "lucide-react" +import { useT } from "../lib/i18n/provider" +import { useGroupDisksByController } from "../lib/storage-display" + +export function StorageDisplayCard() { + const t = useT() + const [groupByController, setGroupByController] = useGroupDisksByController() + const [editMode, setEditMode] = useState(false) + const [pending, setPending] = useState(null) + const [saved, setSaved] = useState(false) + + const handleCancel = () => { + setPending(null) + setEditMode(false) + } + + const handleSave = () => { + if (pending !== null && pending !== groupByController) { + setGroupByController(pending) + } + setPending(null) + setEditMode(false) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + + return ( + + +
+
+ + {t("settings.storageDisplay.title")} +
+
+ {saved && ( + + + {t("status.saved")} + + )} + {editMode ? ( + <> + + + + ) : ( + + )} +
+
+ {t("settings.storageDisplay.description")} +
+ +
+
{t("settings.storageDisplay.groupByController")}
+ setPending(checked)} + className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`} + /> +
+
+
+ ) +} diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 2f8907ba..dde02cc1 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -14,6 +14,7 @@ import { DiskTemperatureCard } from "./disk-temperature-card" import { getDiskType as resolveDiskType } from "../lib/disk-type" import { APP_VERSION } from "../lib/version" import { useT } from "@/lib/i18n/provider" +import { useGroupDisksByController } from "../lib/storage-display" import { useDiskTempThresholds, loadDiskTempThresholds, @@ -102,6 +103,7 @@ interface DiskInfo { } observations_count?: number connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown' + controller_slot?: string | null // PCI slot of the storage controller, key into StorageData.controllers removable?: boolean is_system_disk?: boolean system_usage?: string[] @@ -129,11 +131,21 @@ interface ZFSPool { health: string } +interface StorageController { + slot: string + class: string + vendor: string + device: string + sdevice?: string | null + driver?: string | null +} + interface StorageData { total: number used: number available: number disks: DiskInfo[] + controllers?: Record zfs_pools: ZFSPool[] disk_count: number healthy_disks: number @@ -231,6 +243,21 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu return out.length ? out.join('; ') : null } +// Bucket disks by controller PCI slot, ordered by slot so groups follow +// the bus layout. Disks with no resolvable controller go last. +function groupDisksBySlot(disks: DiskInfo[]): [string | null, DiskInfo[]][] { + const groups = new Map() + for (const disk of disks) { + const slot = disk.controller_slot ?? null + groups.set(slot, [...(groups.get(slot) ?? []), disk]) + } + return [...groups.entries()].sort(([a], [b]) => { + if (a === null) return 1 + if (b === null) return -1 + return a.localeCompare(b) + }) +} + // `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages. export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) { const t = useT() @@ -240,6 +267,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = // sensible defaults from `lib/health-thresholds`, so first paint // never blocks on the network. const dtThresholds = useDiskTempThresholds() + const [groupDisksByController] = useGroupDisksByController() const [storageData, setStorageData] = useState(null) const [proxmoxStorage, setProxmoxStorage] = useState(null) @@ -1807,11 +1835,43 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
-
- {storageData.disks - .filter((d) => d.connection_type !== 'usb') - .map((disk) => renderDiskCardV2(disk))} -
+ {groupDisksByController ? ( +
+ {groupDisksBySlot(storageData.disks.filter((d) => d.connection_type !== 'usb')).map(([slot, disks]) => { + const controller = slot ? storageData.controllers?.[slot] : undefined + return ( +
+
+ + + {controller + ? controller.sdevice || controller.device + : slot ?? t("storage.noController")} + + {slot && {slot}} + {controller?.driver && ( + + {controller.driver} + + )} + + {t(disks.length === 1 ? "storage.diskCountOne" : "storage.diskCountMany", { count: disks.length })} + +
+
+ {disks.map((disk) => renderDiskCardV2(disk))} +
+
+ ) + })} +
+ ) : ( +
+ {storageData.disks + .filter((d) => d.connection_type !== 'usb') + .map((disk) => renderDiskCardV2(disk))} +
+ )}
@@ -1966,6 +2026,28 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = )}
+ {selectedDisk.controller_slot && (() => { + const controller = storageData.controllers?.[selectedDisk.controller_slot] + return ( +
+

{t("storage.storageController")}

+

+ {controller ? controller.sdevice || controller.device : selectedDisk.controller_slot} + {controller && ( + {selectedDisk.controller_slot} + )} + {controller?.driver && ( + + {controller.driver} + + )} +

+ {controller && controller.sdevice && ( +

{controller.vendor} - {controller.device}

+ )} +
+ ) + })()} {/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */} diff --git a/AppImage/lib/storage-display.ts b/AppImage/lib/storage-display.ts new file mode 100644 index 00000000..6a88f5ef --- /dev/null +++ b/AppImage/lib/storage-display.ts @@ -0,0 +1,55 @@ +import { useEffect, useState, useCallback } from "react" + +// Per-browser display preference for the Storage view: whether the +// "Physical Disks & SMART Status" card groups disks under the storage +// controller they hang off. Off by default so the layout is unchanged +// until the user opts in from Settings. + +const STORAGE_KEY = "proxmenux-group-disks-by-controller" +const CHANGE_EVENT = "proxmenux-group-disks-by-controller-changed" + +export function readGroupDisksByController(): boolean { + if (typeof window === "undefined") return false + try { + return window.localStorage.getItem(STORAGE_KEY) === "true" + } catch { + return false + } +} + +export function writeGroupDisksByController(enabled: boolean): void { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(STORAGE_KEY, String(enabled)) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) + } catch { + // Storage full / disabled; the in-memory state still updates. + } +} + +// Hook that keeps the Settings card and the Storage view in sync in the +// same tab (custom event) and across tabs (storage event). +export function useGroupDisksByController(): [boolean, (enabled: boolean) => void] { + const [enabled, setEnabledState] = useState(false) + + useEffect(() => { + setEnabledState(readGroupDisksByController()) + const onChange = () => setEnabledState(readGroupDisksByController()) + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) onChange() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", onStorage) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + window.removeEventListener("storage", onStorage) + } + }, []) + + const setEnabled = useCallback((next: boolean) => { + writeGroupDisksByController(next) + setEnabledState(next) + }, []) + + return [enabled, setEnabled] +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ac9d283b..4dfe265b 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -308,6 +308,10 @@ "remoteMounts": "Remote Mounts", "externalStorageUsb": "External Storage (USB)", "physicalDisksSmart": "Physical Disks & SMART Status", + "storageController": "Storage Controller", + "noController": "No controller detected", + "diskCountOne": "{count} disk", + "diskCountMany": "{count} disks", "zfsPools": "ZFS Pools", "local": "Local", "remote": "Remote", @@ -2064,6 +2068,11 @@ "bytes": "Bytes", "bits": "Bits" }, + "storageDisplay": { + "title": "Storage Display", + "description": "Change how disks are laid out on the Storage page", + "groupByController": "Group physical disks by storage controller" + }, "healthMonitor": { "title": "Health Monitor", "description": "Configure how long dismissed alerts stay hidden for each category. Changes apply to existing and future dismissed alerts.", @@ -4159,6 +4168,8 @@ "vendor": "Vendor", "pciSlot": "PCI Slot", "driver": "Driver", + "storageController": "Storage Controller", + "attachedDisks": "Attached Disks", "kernelModule": "Kernel Module", "kernelDriver": "Kernel Driver", "kernelModules": "Kernel Modules", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index c4cbd287..97a827cd 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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 diff --git a/AppImage/scripts/tests/test_disk_controller_slot.py b/AppImage/scripts/tests/test_disk_controller_slot.py new file mode 100644 index 00000000..47f9a07a --- /dev/null +++ b/AppImage/scripts/tests/test_disk_controller_slot.py @@ -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() diff --git a/AppImage/types/hardware.ts b/AppImage/types/hardware.ts index 10a3af1a..02a6b614 100644 --- a/AppImage/types/hardware.ts +++ b/AppImage/types/hardware.ts @@ -47,6 +47,7 @@ export interface StorageDevice { sas_version?: string // e.g., "SAS-3" sas_speed?: string // e.g., "12Gb/s" link_speed?: string // Generic link speed info + controller_slot?: string | null // PCI slot of the controller, matches PCIDevice.slot } export interface PCIDevice { @@ -55,6 +56,7 @@ export interface PCIDevice { device: string vendor: string class: string + sdevice?: string // Subsystem device name, e.g. "HBA 9400-16i" driver?: string kernel_module?: string irq?: string