Merge pull request #562 from DrOwl/feature/disk-controller-mapping

Map storage devices to their PCI storage controller
This commit is contained in:
MacRimi
2026-10-30 21:39:07 +01:00
committed by GitHub
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 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) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4"> <div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
@@ -2358,14 +2365,23 @@ return (
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <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 <div
key={index} key={index}
onClick={() => setSelectedPCIDevice(device)} 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" 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"> <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> <span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
</div> </div>
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p> <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> <p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
)} )}
</div> </div>
))} )
})}
</div> </div>
</Card> </Card>
)} )}
@@ -2434,6 +2451,37 @@ return (
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span> <span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
</div> </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> </div>
)} )}
</DialogContent> </DialogContent>
@@ -2827,6 +2875,34 @@ return (
</div> </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 && ( {selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
<div className="flex justify-between border-b border-border/50 pb-2"> <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> <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 { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages" import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card" 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" import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
// GitHub Dark color palette for bash syntax highlighting // GitHub Dark color palette for bash syntax highlighting
@@ -1233,6 +1234,9 @@ export function Settings() {
</CardContent> </CardContent>
</Card> </Card>
{/* Storage Display: group physical disks by controller */}
<StorageDisplayCard />
{/* Health Monitor Settings */} {/* Health Monitor Settings */}
<Card> <Card>
<CardHeader> <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 { getDiskType as resolveDiskType } from "../lib/disk-type"
import { APP_VERSION } from "../lib/version" import { APP_VERSION } from "../lib/version"
import { useT } from "@/lib/i18n/provider" import { useT } from "@/lib/i18n/provider"
import { useGroupDisksByController } from "../lib/storage-display"
import { import {
useDiskTempThresholds, useDiskTempThresholds,
loadDiskTempThresholds, loadDiskTempThresholds,
@@ -102,6 +103,7 @@ interface DiskInfo {
} }
observations_count?: number observations_count?: number
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown' connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
controller_slot?: string | null // PCI slot of the storage controller, key into StorageData.controllers
removable?: boolean removable?: boolean
is_system_disk?: boolean is_system_disk?: boolean
system_usage?: string[] system_usage?: string[]
@@ -173,11 +175,21 @@ interface ZFSPool {
vdevs?: ZFSVdev[] vdevs?: ZFSVdev[]
} }
interface StorageController {
slot: string
class: string
vendor: string
device: string
sdevice?: string | null
driver?: string | null
}
interface StorageData { interface StorageData {
total: number total: number
used: number used: number
available: number available: number
disks: DiskInfo[] disks: DiskInfo[]
controllers?: Record<string, StorageController>
zfs_pools: ZFSPool[] zfs_pools: ZFSPool[]
disk_count: number disk_count: number
healthy_disks: number healthy_disks: number
@@ -275,6 +287,21 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
return out.length ? out.join('; ') : null 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. // `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) { export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
const t = useT() const t = useT()
@@ -284,6 +311,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
// sensible defaults from `lib/health-thresholds`, so first paint // sensible defaults from `lib/health-thresholds`, so first paint
// never blocks on the network. // never blocks on the network.
const dtThresholds = useDiskTempThresholds() const dtThresholds = useDiskTempThresholds()
const [groupDisksByController] = useGroupDisksByController()
const [storageData, setStorageData] = useState<StorageData | null>(null) const [storageData, setStorageData] = useState<StorageData | null>(null)
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null) const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
@@ -2123,11 +2151,43 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {groupDisksByController ? (
{storageData.disks <div className="space-y-6">
.filter((d) => d.connection_type !== 'usb') {groupDisksBySlot(storageData.disks.filter((d) => d.connection_type !== 'usb')).map(([slot, disks]) => {
.map((disk) => renderDiskCardV2(disk))} const controller = slot ? storageData.controllers?.[slot] : undefined
</div> 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> </CardContent>
</Card> </Card>
@@ -2282,6 +2342,28 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
)} )}
</div> </div>
</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> </div>
{/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */} {/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}
+55
View File
@@ -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]
}
+11
View File
@@ -308,6 +308,10 @@
"remoteMounts": "Remote Mounts", "remoteMounts": "Remote Mounts",
"externalStorageUsb": "External Storage (USB)", "externalStorageUsb": "External Storage (USB)",
"physicalDisksSmart": "Physical Disks & SMART Status", "physicalDisksSmart": "Physical Disks & SMART Status",
"storageController": "Storage Controller",
"noController": "No controller detected",
"diskCountOne": "{count} disk",
"diskCountMany": "{count} disks",
"zfsPools": "ZFS Pools", "zfsPools": "ZFS Pools",
"zfs": { "zfs": {
"poolDetails": "Pool layout, scrub status and device health", "poolDetails": "Pool layout, scrub status and device health",
@@ -2114,6 +2118,11 @@
"bytes": "Bytes", "bytes": "Bytes",
"bits": "Bits" "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": { "healthMonitor": {
"title": "Health Monitor", "title": "Health Monitor",
"description": "Configure how long dismissed alerts stay hidden for each category. Changes apply to existing and future dismissed alerts.", "description": "Configure how long dismissed alerts stay hidden for each category. Changes apply to existing and future dismissed alerts.",
@@ -4209,6 +4218,8 @@
"vendor": "Vendor", "vendor": "Vendor",
"pciSlot": "PCI Slot", "pciSlot": "PCI Slot",
"driver": "Driver", "driver": "Driver",
"storageController": "Storage Controller",
"attachedDisks": "Attached Disks",
"kernelModule": "Kernel Module", "kernelModule": "Kernel Module",
"kernelDriver": "Kernel Driver", "kernelDriver": "Kernel Driver",
"kernelModules": "Kernel Modules", "kernelModules": "Kernel Modules",
+109 -3
View File
@@ -3967,6 +3967,106 @@ def is_disk_removable(disk_name):
return False 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): def is_disk_usb(disk_name):
"""Return True when sysfs places the disk behind a USB bus.""" """Return True when sysfs places the disk behind a USB bus."""
return resolver_is_usb_disk(disk_name) return resolver_is_usb_disk(disk_name)
@@ -4492,6 +4592,7 @@ def get_storage_info():
'total_lbas_written': smart_data.get('total_lbas_written'), 'total_lbas_written': smart_data.get('total_lbas_written'),
'ssd_life_left': smart_data.get('ssd_life_left'), 'ssd_life_left': smart_data.get('ssd_life_left'),
'connection_type': conn_type, 'connection_type': conn_type,
'controller_slot': get_disk_controller_slot(disk_name),
'removable': removable, 'removable': removable,
'is_system_disk': is_system_disk, 'is_system_disk': is_system_disk,
'system_usage': system_usage, 'system_usage': system_usage,
@@ -4775,7 +4876,10 @@ def get_storage_info():
pass pass
storage_data['disks'] = list(physical_disks.values()) 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 return storage_data
except Exception as e: except Exception as e:
@@ -9579,6 +9683,7 @@ def _get_hardware_info_uncached():
'rotation_rate': smart_data.get('rotation_rate', 0), 'rotation_rate': smart_data.get('rotation_rate', 0),
'form_factor': form_factor, 'form_factor': form_factor,
'sata_version': sata_version, 'sata_version': sata_version,
'controller_slot': get_disk_controller_slot(disk_name),
} }
if pcie_info: if pcie_info:
@@ -9723,8 +9828,9 @@ def _get_hardware_info_uncached():
if any(keyword in device_class for keyword in ['VGA', 'Display', '3D']): if any(keyword in device_class for keyword in ['VGA', 'Display', '3D']):
device_type = 'Graphics Card' device_type = 'Graphics Card'
include_device = True include_device = True
# Storage controllers (including SCSI/SAS HBA cards like LSI 9400-16i) # 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']): # 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' device_type = 'Storage Controller'
include_device = True include_device = True
# Network controllers # 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()
+2
View File
@@ -47,6 +47,7 @@ export interface StorageDevice {
sas_version?: string // e.g., "SAS-3" sas_version?: string // e.g., "SAS-3"
sas_speed?: string // e.g., "12Gb/s" sas_speed?: string // e.g., "12Gb/s"
link_speed?: string // Generic link speed info link_speed?: string // Generic link speed info
controller_slot?: string | null // PCI slot of the controller, matches PCIDevice.slot
} }
export interface PCIDevice { export interface PCIDevice {
@@ -55,6 +56,7 @@ export interface PCIDevice {
device: string device: string
vendor: string vendor: string
class: string class: string
sdevice?: string // Subsystem device name, e.g. "HBA 9400-16i"
driver?: string driver?: string
kernel_module?: string kernel_module?: string
irq?: string irq?: string