mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #562 from DrOwl/feature/disk-controller-mapping
Map storage devices to their PCI storage controller
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[]
|
||||
@@ -173,11 +175,21 @@ interface ZFSPool {
|
||||
vdevs?: ZFSVdev[]
|
||||
}
|
||||
|
||||
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
|
||||
@@ -275,6 +287,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()
|
||||
@@ -284,6 +311,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)
|
||||
@@ -2123,11 +2151,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>
|
||||
|
||||
@@ -2282,6 +2342,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. */}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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",
|
||||
"zfs": {
|
||||
"poolDetails": "Pool layout, scrub status and device health",
|
||||
@@ -2114,6 +2118,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.",
|
||||
@@ -4209,6 +4218,8 @@
|
||||
"vendor": "Vendor",
|
||||
"pciSlot": "PCI Slot",
|
||||
"driver": "Driver",
|
||||
"storageController": "Storage Controller",
|
||||
"attachedDisks": "Attached Disks",
|
||||
"kernelModule": "Kernel Module",
|
||||
"kernelDriver": "Kernel Driver",
|
||||
"kernelModules": "Kernel Modules",
|
||||
|
||||
@@ -3967,6 +3967,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)
|
||||
@@ -4492,6 +4592,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,
|
||||
@@ -4775,7 +4876,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:
|
||||
@@ -9579,6 +9683,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:
|
||||
@@ -9723,8 +9828,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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user