mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user