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
+79 -3
View File
@@ -633,6 +633,13 @@ export default function Hardware() {
return realtimeGPUData.has_monitoring_tool === true
}
const diskCountBySlot = new Map<string, number>()
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 (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
@@ -2358,14 +2365,23 @@ return (
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{hardwareData.pci_devices.map((device, index) => (
{hardwareData.pci_devices.map((device, index) => {
const diskCount = diskCountBySlot.get(device.slot) ?? 0
return (
<div
key={index}
onClick={() => 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"
>
<div className="flex items-center justify-between gap-2 mb-2">
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
<div className="flex items-center gap-1.5 min-w-0">
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
{diskCount > 0 && (
<Badge variant="outline" className="text-xs shrink-0">
{t("hardware.counts.devices", { count: diskCount })}
</Badge>
)}
</div>
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
</div>
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
@@ -2374,7 +2390,8 @@ return (
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
)}
</div>
))}
)
})}
</div>
</Card>
)}
@@ -2434,6 +2451,37 @@ return (
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
</div>
)}
{(() => {
const attachedDisks = (hardwareData?.storage_devices ?? []).filter(
(disk) => disk.controller_slot === selectedPCIDevice.slot,
)
if (attachedDisks.length === 0) return null
return (
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">
{t("hardware.labels.attachedDisks")} ({attachedDisks.length})
</span>
<div className="flex flex-wrap justify-end gap-1">
{attachedDisks.map((disk) => (
<button
key={disk.name}
type="button"
title={disk.model}
onClick={() => {
setSelectedPCIDevice(null)
setSelectedDisk(disk)
}}
>
<Badge variant="outline" className="font-mono hover:bg-accent">
{disk.name}
</Badge>
</button>
))}
</div>
</div>
)
})()}
</div>
)}
</DialogContent>
@@ -2827,6 +2875,34 @@ return (
</div>
)}
{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 (
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.storageController")}</span>
<span className="text-sm text-right">
{controller ? (
<button
type="button"
className="text-blue-400 hover:underline text-right"
onClick={() => {
setSelectedDisk(null)
setSelectedPCIDevice(controller)
}}
>
{/* lspci's "Device 84ca" placeholder means no subsystem name, so use the chip name */}
{controller.sdevice && !/^Device [0-9a-f]{4}$/.test(controller.sdevice)
? controller.sdevice
: controller.device}
</button>
) : null}
<span className="ml-2 font-mono text-muted-foreground">{selectedDisk.controller_slot}</span>
</span>
</div>
)
})()}
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
+4
View File
@@ -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() {
</CardContent>
</Card>
{/* Storage Display: group physical disks by controller */}
<StorageDisplayCard />
{/* Health Monitor Settings */}
<Card>
<CardHeader>
@@ -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<boolean | null>(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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-blue-500" />
<CardTitle>{t("settings.storageDisplay.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={pending === null || pending === groupByController}
>
<Check className="h-3 w-3" />
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</div>
<CardDescription>{t("settings.storageDisplay.description")}</CardDescription>
</CardHeader>
<CardContent className={editMode ? "bg-accent" : undefined}>
<div className="text-foreground flex items-center justify-between gap-4">
<div className="flex items-center">{t("settings.storageDisplay.groupByController")}</div>
<Switch
checked={pending ?? groupByController}
disabled={!editMode}
onCheckedChange={(checked) => setPending(checked)}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
/>
</div>
</CardContent>
</Card>
)
}
+87 -5
View File
@@ -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<string, StorageController>
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<string | null, DiskInfo[]>()
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<StorageData | null>(null)
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
@@ -1807,11 +1835,43 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{storageData.disks
.filter((d) => d.connection_type !== 'usb')
.map((disk) => renderDiskCardV2(disk))}
</div>
{groupDisksByController ? (
<div className="space-y-6">
{groupDisksBySlot(storageData.disks.filter((d) => d.connection_type !== 'usb')).map(([slot, disks]) => {
const controller = slot ? storageData.controllers?.[slot] : undefined
return (
<div key={slot ?? "none"}>
<div className="flex flex-wrap items-center gap-2 mb-3 pb-2 border-b border-border/50">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{controller
? controller.sdevice || controller.device
: slot ?? t("storage.noController")}
</span>
{slot && <span className="font-mono text-xs text-muted-foreground">{slot}</span>}
{controller?.driver && (
<Badge variant="outline" className="font-mono text-xs text-green-500">
{controller.driver}
</Badge>
)}
<Badge variant="outline" className="ml-auto text-xs">
{t(disks.length === 1 ? "storage.diskCountOne" : "storage.diskCountMany", { count: disks.length })}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{disks.map((disk) => renderDiskCardV2(disk))}
</div>
</div>
)
})}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{storageData.disks
.filter((d) => d.connection_type !== 'usb')
.map((disk) => renderDiskCardV2(disk))}
</div>
)}
</CardContent>
</Card>
@@ -1966,6 +2026,28 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
)}
</div>
</div>
{selectedDisk.controller_slot && (() => {
const controller = storageData.controllers?.[selectedDisk.controller_slot]
return (
<div className="col-span-2">
<p className="text-sm text-muted-foreground">{t("storage.storageController")}</p>
<p className="font-medium">
{controller ? controller.sdevice || controller.device : selectedDisk.controller_slot}
{controller && (
<span className="ml-2 font-mono text-xs text-muted-foreground">{selectedDisk.controller_slot}</span>
)}
{controller?.driver && (
<Badge variant="outline" className="ml-2 font-mono text-xs text-green-500">
{controller.driver}
</Badge>
)}
</p>
{controller && controller.sdevice && (
<p className="text-xs text-muted-foreground">{controller.vendor} - {controller.device}</p>
)}
</div>
)
})()}
</div>
{/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}