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
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user