mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-25 08:56:21 +00:00
update gpu-switch-mode-indicator.tsx
This commit is contained in:
@@ -2,12 +2,20 @@
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SriovInfo {
|
||||
role: "vf" | "pf-active" | "pf-idle"
|
||||
physfn?: string // VF only: parent PF BDF
|
||||
vfCount?: number // PF only: active VF count
|
||||
totalvfs?: number // PF only: maximum VFs
|
||||
}
|
||||
|
||||
interface GpuSwitchModeIndicatorProps {
|
||||
mode: "lxc" | "vm" | "unknown"
|
||||
mode: "lxc" | "vm" | "sriov" | "unknown"
|
||||
isEditing?: boolean
|
||||
pendingMode?: "lxc" | "vm" | null
|
||||
onToggle?: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
sriovInfo?: SriovInfo
|
||||
}
|
||||
|
||||
export function GpuSwitchModeIndicator({
|
||||
@@ -16,20 +24,38 @@ export function GpuSwitchModeIndicator({
|
||||
pendingMode = null,
|
||||
onToggle,
|
||||
className,
|
||||
sriovInfo,
|
||||
}: GpuSwitchModeIndicatorProps) {
|
||||
const displayMode = pendingMode ?? mode
|
||||
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
||||
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
||||
const isLxcActive = displayMode === "lxc"
|
||||
const isVmActive = displayMode === "vm"
|
||||
const hasChanged = pendingMode !== null && pendingMode !== mode
|
||||
const isSriovActive = displayMode === "sriov"
|
||||
const hasChanged =
|
||||
mode !== "sriov" && pendingMode !== null && pendingMode !== mode
|
||||
|
||||
// Colors
|
||||
const activeColor = isLxcActive ? "#3b82f6" : isVmActive ? "#a855f7" : "#6b7280"
|
||||
const sriovColor = "#14b8a6" // teal-500
|
||||
const activeColor = isSriovActive
|
||||
? sriovColor
|
||||
: isLxcActive
|
||||
? "#3b82f6"
|
||||
: isVmActive
|
||||
? "#a855f7"
|
||||
: "#6b7280"
|
||||
const inactiveColor = "#374151" // gray-700 for dark theme
|
||||
const dimmedColor = "#4b5563" // gray-600 for dashed SR-IOV branches
|
||||
const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor
|
||||
const vmColor = isVmActive ? "#a855f7" : inactiveColor
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Only stop propagation and handle toggle when in editing mode
|
||||
// SR-IOV state can't be toggled — swallow the click so it doesn't reach
|
||||
// the card (which would open the detail modal unexpectedly from this
|
||||
// area). For lxc/vm, preserve the original behavior.
|
||||
if (isSriovActive) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
if (onToggle) {
|
||||
@@ -39,11 +65,20 @@ export function GpuSwitchModeIndicator({
|
||||
// When not editing, let the click propagate to the card to open the modal
|
||||
}
|
||||
|
||||
// Build the VF count label shown in the SR-IOV badge. For PFs we know
|
||||
// exactly how many VFs are active; for a VF we show its parent PF.
|
||||
const sriovBadgeText = (() => {
|
||||
if (!isSriovActive) return ""
|
||||
if (sriovInfo?.role === "vf") return "SR-IOV VF"
|
||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
|
||||
return "SR-IOV"
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6",
|
||||
isEditing && "cursor-pointer",
|
||||
isEditing && !isSriovActive && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
@@ -77,10 +112,10 @@ export function GpuSwitchModeIndicator({
|
||||
<line x1="26" y1="44" x2="26" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="44" x2="38" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* GPU text */}
|
||||
<text
|
||||
x="26"
|
||||
y="32"
|
||||
textAnchor="middle"
|
||||
<text
|
||||
x="26"
|
||||
y="32"
|
||||
textAnchor="middle"
|
||||
fill={activeColor}
|
||||
className="text-[14px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
@@ -106,8 +141,8 @@ export function GpuSwitchModeIndicator({
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="14"
|
||||
fill={isEditing ? "#f59e0b20" : `${activeColor}20`}
|
||||
stroke={isEditing ? "#f59e0b" : activeColor}
|
||||
fill={isEditing && !isSriovActive ? "#f59e0b20" : `${activeColor}20`}
|
||||
stroke={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
|
||||
strokeWidth="3"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
@@ -115,112 +150,198 @@ export function GpuSwitchModeIndicator({
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="6"
|
||||
fill={isEditing ? "#f59e0b" : activeColor}
|
||||
fill={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* LXC Branch Line - going up-right */}
|
||||
{/* LXC Branch Line - going up-right.
|
||||
In SR-IOV mode the branch is dashed + dimmed to show that the
|
||||
target is theoretically reachable via a VF but not controlled
|
||||
by ProxMenux. */}
|
||||
<path
|
||||
d="M 109 42 L 135 20"
|
||||
fill="none"
|
||||
stroke={lxcColor}
|
||||
stroke={isSriovActive ? dimmedColor : lxcColor}
|
||||
strokeWidth={isLxcActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isSriovActive ? "3 3" : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* VM Branch Line - going down-right */}
|
||||
{/* VM Branch Line - going down-right (dashed/dimmed in SR-IOV). */}
|
||||
<path
|
||||
d="M 109 58 L 135 80"
|
||||
fill="none"
|
||||
stroke={vmColor}
|
||||
stroke={isSriovActive ? dimmedColor : vmColor}
|
||||
strokeWidth={isVmActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isSriovActive ? "3 3" : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* LXC Container Icon - Server/Stack icon */}
|
||||
<g transform="translate(138, 2)">
|
||||
{/* Container box */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="28"
|
||||
rx="4"
|
||||
fill={isLxcActive ? `${lxcColor}25` : "transparent"}
|
||||
stroke={lxcColor}
|
||||
strokeWidth={isLxcActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Container layers/lines */}
|
||||
<line x1="0" y1="10" x2="32" y2="10" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<line x1="0" y1="19" x2="32" y2="19" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
{/* Status dots */}
|
||||
<circle cx="7" cy="5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="14.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="23.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
</g>
|
||||
{/* SR-IOV in-line connector + badge (only when mode === 'sriov').
|
||||
A horizontal line from the switch node leads to a pill-shaped
|
||||
badge carrying the "SR-IOV ×N" label. Placed on the GPU's
|
||||
baseline to visually read as an in-line extension, not as a
|
||||
third branch. */}
|
||||
{isSriovActive && (
|
||||
<>
|
||||
<line
|
||||
x1="109"
|
||||
y1="50"
|
||||
x2="130"
|
||||
y2="50"
|
||||
stroke={sriovColor}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<rect
|
||||
x="132"
|
||||
y="40"
|
||||
width="60"
|
||||
height="20"
|
||||
rx="10"
|
||||
fill={`${sriovColor}25`}
|
||||
stroke={sriovColor}
|
||||
strokeWidth="2"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<text
|
||||
x="162"
|
||||
y="54"
|
||||
textAnchor="middle"
|
||||
fill={sriovColor}
|
||||
className="text-[11px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
{sriovBadgeText}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LXC Container Icon - dimmed/smaller in SR-IOV mode. */}
|
||||
{!isSriovActive && (
|
||||
<g transform="translate(138, 2)">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="28"
|
||||
rx="4"
|
||||
fill={isLxcActive ? `${lxcColor}25` : "transparent"}
|
||||
stroke={lxcColor}
|
||||
strokeWidth={isLxcActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<line x1="0" y1="10" x2="32" y2="10" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<line x1="0" y1="19" x2="32" y2="19" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="14.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="23.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
</g>
|
||||
)}
|
||||
{/* SR-IOV: compact dimmed LXC glyph so the geometry stays recognizable
|
||||
but it's clearly not the active target. */}
|
||||
{isSriovActive && (
|
||||
<g transform="translate(138, 6)" opacity="0.35">
|
||||
<rect x="0" y="0" width="20" height="18" rx="3" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
|
||||
<line x1="0" y1="6" x2="20" y2="6" stroke={dimmedColor} strokeWidth="1" />
|
||||
<line x1="0" y1="12" x2="20" y2="12" stroke={dimmedColor} strokeWidth="1" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* LXC Label */}
|
||||
<text
|
||||
x="188"
|
||||
y="22"
|
||||
textAnchor="start"
|
||||
fill={lxcColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isLxcActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
{!isSriovActive && (
|
||||
<text
|
||||
x="188"
|
||||
y="22"
|
||||
textAnchor="start"
|
||||
fill={lxcColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isLxcActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
<text
|
||||
x="162"
|
||||
y="16"
|
||||
fill={dimmedColor}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* VM Monitor Icon */}
|
||||
<g transform="translate(138, 65)">
|
||||
{/* Monitor screen */}
|
||||
<rect
|
||||
x="2"
|
||||
y="0"
|
||||
width="28"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill={isVmActive ? `${vmColor}25` : "transparent"}
|
||||
stroke={vmColor}
|
||||
strokeWidth={isVmActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Screen inner/shine */}
|
||||
<rect
|
||||
x="5"
|
||||
y="3"
|
||||
width="22"
|
||||
height="12"
|
||||
rx="1"
|
||||
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Monitor stand */}
|
||||
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* Monitor base */}
|
||||
<line x1="8" y1="24" x2="24" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
</g>
|
||||
{/* VM Monitor Icon - active view */}
|
||||
{!isSriovActive && (
|
||||
<g transform="translate(138, 65)">
|
||||
<rect
|
||||
x="2"
|
||||
y="0"
|
||||
width="28"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill={isVmActive ? `${vmColor}25` : "transparent"}
|
||||
stroke={vmColor}
|
||||
strokeWidth={isVmActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<rect
|
||||
x="5"
|
||||
y="3"
|
||||
width="22"
|
||||
height="12"
|
||||
rx="1"
|
||||
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="8" y1="24" x2="24" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
</g>
|
||||
)}
|
||||
{/* SR-IOV: compact dimmed VM monitor glyph, mirror of the LXC glyph. */}
|
||||
{isSriovActive && (
|
||||
<g transform="translate(138, 72)" opacity="0.35">
|
||||
<rect x="0" y="0" width="20" height="13" rx="2" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
|
||||
<line x1="10" y1="13" x2="10" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="5" y1="17" x2="15" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* VM Label */}
|
||||
<text
|
||||
x="188"
|
||||
y="84"
|
||||
textAnchor="start"
|
||||
fill={vmColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isVmActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
{!isSriovActive && (
|
||||
<text
|
||||
x="188"
|
||||
y="84"
|
||||
textAnchor="start"
|
||||
fill={vmColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isVmActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
<text
|
||||
x="162"
|
||||
y="82"
|
||||
fill={dimmedColor}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Status Text - Large like GPU name */}
|
||||
@@ -228,22 +349,41 @@ export function GpuSwitchModeIndicator({
|
||||
<span
|
||||
className={cn(
|
||||
"text-base font-semibold transition-all duration-300",
|
||||
isLxcActive ? "text-blue-500" : isVmActive ? "text-purple-500" : "text-muted-foreground"
|
||||
isSriovActive
|
||||
? "text-teal-500"
|
||||
: isLxcActive
|
||||
? "text-blue-500"
|
||||
: isVmActive
|
||||
? "text-purple-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
{isSriovActive
|
||||
? "SR-IOV active"
|
||||
: isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isLxcActive
|
||||
? "Native driver active"
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
{isSriovActive
|
||||
? "Virtual Functions managed externally"
|
||||
: isLxcActive
|
||||
? "Native driver active"
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
</span>
|
||||
{isSriovActive && sriovInfo && (
|
||||
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
||||
{sriovInfo.role === "vf"
|
||||
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
|
||||
: sriovInfo.vfCount !== undefined
|
||||
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
|
||||
: null}
|
||||
</span>
|
||||
)}
|
||||
{hasChanged && (
|
||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||
Change pending...
|
||||
|
||||
@@ -293,11 +293,16 @@ export default function Hardware() {
|
||||
const [showSwitchModeModal, setShowSwitchModeModal] = useState(false)
|
||||
const [switchModeParams, setSwitchModeParams] = useState<{ gpuSlot: string; targetMode: "lxc" | "vm" } | null>(null)
|
||||
|
||||
// Determine GPU mode based on driver (vfio-pci = VM, native driver = LXC)
|
||||
const getGpuSwitchMode = (gpu: GPU): "lxc" | "vm" | "unknown" => {
|
||||
// Determine GPU mode based on driver (vfio-pci = VM, native driver = LXC).
|
||||
// SR-IOV short-circuits the driver check: if the GPU is either a VF or a
|
||||
// PF with active VFs, the slot is in a hardware-partitioned state that
|
||||
// ProxMenux does not manage from the UI, so it's surfaced as its own mode.
|
||||
const getGpuSwitchMode = (gpu: GPU): "lxc" | "vm" | "sriov" | "unknown" => {
|
||||
if (gpu.sriov_role === "vf" || gpu.sriov_role === "pf-active") return "sriov"
|
||||
|
||||
const driver = gpu.pci_driver?.toLowerCase() || ""
|
||||
const kernelModule = gpu.pci_kernel_module?.toLowerCase() || ""
|
||||
|
||||
|
||||
// Check driver first
|
||||
if (driver === "vfio-pci") return "vm"
|
||||
if (driver === "nvidia" || driver === "amdgpu" || driver === "radeon" || driver === "i915" || driver === "xe" || driver === "nouveau" || driver === "mgag200") return "lxc"
|
||||
@@ -940,7 +945,11 @@ return (
|
||||
Switch Mode
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingSwitchModeGpu === fullSlot ? (
|
||||
{getGpuSwitchMode(gpu) === "sriov" ? (
|
||||
// SR-IOV: edit controls hidden — the state is
|
||||
// hardware-managed and not togglable from here.
|
||||
null
|
||||
) : editingSwitchModeGpu === fullSlot ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
@@ -981,6 +990,16 @@ return (
|
||||
isEditing={editingSwitchModeGpu === fullSlot}
|
||||
pendingMode={pendingSwitchModes[gpu.slot] || null}
|
||||
onToggle={(e) => handleSwitchModeToggle(gpu, e)}
|
||||
sriovInfo={
|
||||
gpu.sriov_role === "vf" || gpu.sriov_role === "pf-active"
|
||||
? {
|
||||
role: gpu.sriov_role,
|
||||
physfn: gpu.sriov_physfn,
|
||||
vfCount: gpu.sriov_vf_count,
|
||||
totalvfs: gpu.sriov_totalvfs,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1053,8 +1072,104 @@ return (
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-2 text-primary" />
|
||||
<p className="text-sm">Loading real-time data...</p>
|
||||
</div>
|
||||
) : selectedGPU.sriov_role === "vf" ? (
|
||||
// SR-IOV Virtual Function: per-VF telemetry is not exposed
|
||||
// by the kernel, so we skip the metrics panel and show
|
||||
// identity + consumer + a link back to the parent PF.
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-teal-500/10 p-4 border border-teal-500/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-teal-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-teal-500 mb-1">SR-IOV Virtual Function</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This device is a Virtual Function spawned by a Physical Function. Per-VF
|
||||
telemetry (temperature, utilization, memory) is not exposed by the kernel —
|
||||
open the parent PF to see aggregate GPU metrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/50 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
|
||||
Virtual Function Detail
|
||||
</h3>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Parent Physical Function</span>
|
||||
{selectedGPU.sriov_physfn ? (
|
||||
<button
|
||||
className="font-mono text-sm text-teal-500 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const pf = hardwareData?.gpus?.find(
|
||||
(g) => g.slot === selectedGPU.sriov_physfn
|
||||
)
|
||||
if (pf) setSelectedGPU(pf)
|
||||
}}
|
||||
>
|
||||
{selectedGPU.sriov_physfn}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-sm text-muted-foreground">unknown</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Current Driver</span>
|
||||
<span className="font-mono text-sm">
|
||||
{selectedGPU.pci_driver || "none"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-sm text-muted-foreground">Consumer</span>
|
||||
<div className="text-sm text-right">
|
||||
{realtimeGPUData?.sriov_consumer ? (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium",
|
||||
realtimeGPUData.sriov_consumer.running
|
||||
? "bg-teal-500/10 text-teal-500"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{realtimeGPUData.sriov_consumer.type.toUpperCase()} {realtimeGPUData.sriov_consumer.id}
|
||||
{realtimeGPUData.sriov_consumer.name && ` · ${realtimeGPUData.sriov_consumer.name}`}
|
||||
{` · ${realtimeGPUData.sriov_consumer.running ? "running" : "stopped"}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">unused</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : realtimeGPUData?.has_monitoring_tool === true ? (
|
||||
<>
|
||||
{selectedGPU.sriov_role === "pf-active" && (
|
||||
// SR-IOV Physical Function: metrics below are the
|
||||
// aggregate of the whole GPU (PF + all active VFs).
|
||||
// Flag it explicitly so the reader interprets numbers
|
||||
// correctly.
|
||||
<div className="rounded-lg bg-teal-500/10 p-3 border border-teal-500/20">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-teal-500/15 text-teal-500 text-xs font-semibold">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-teal-500" />
|
||||
SR-IOV active
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Metrics below reflect the Physical Function (aggregate across
|
||||
{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{realtimeGPUData?.sriov_vf_count ?? selectedGPU.sriov_vf_count ?? "N"}
|
||||
</span>
|
||||
{" "}VFs).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>Updating every 3 seconds</span>
|
||||
@@ -1285,6 +1400,67 @@ return (
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedGPU.sriov_role === "pf-active" &&
|
||||
Array.isArray(realtimeGPUData?.sriov_vfs) &&
|
||||
realtimeGPUData.sriov_vfs.length > 0 && (
|
||||
// Per-VF table: one row per virtfn* under the PF.
|
||||
// Driver is color-coded (teal native / purple vfio-pci
|
||||
// / muted fallback) and consumer pills go green when
|
||||
// the guest is currently running, muted otherwise.
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
Virtual Functions
|
||||
</h3>
|
||||
<div className="rounded-lg border border-border/50 divide-y divide-border/30 overflow-hidden">
|
||||
{realtimeGPUData.sriov_vfs.map((vf: any) => (
|
||||
<div
|
||||
key={vf.bdf}
|
||||
className="flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="font-mono text-xs text-foreground">{vf.bdf}</span>
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] px-2 py-0.5 rounded",
|
||||
vf.driver === "vfio-pci"
|
||||
? "bg-purple-500/10 text-purple-500"
|
||||
: vf.driver === "i915" ||
|
||||
vf.driver === "xe" ||
|
||||
vf.driver === "amdgpu" ||
|
||||
vf.driver === "radeon" ||
|
||||
vf.driver === "nvidia"
|
||||
? "bg-teal-500/10 text-teal-500"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{vf.driver || "unbound"}
|
||||
</span>
|
||||
{vf.consumer ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium",
|
||||
vf.consumer.running
|
||||
? "bg-green-500/10 text-green-500"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{vf.consumer.type.toUpperCase()} {vf.consumer.id}
|
||||
{vf.consumer.name && (
|
||||
<span className="opacity-70">· {vf.consumer.name}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
unused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (findPCIDeviceForGPU(selectedGPU)?.driver === 'vfio-pci' || selectedGPU.pci_driver === 'vfio-pci') ? (
|
||||
<div className="rounded-lg bg-purple-500/10 p-4 border border-purple-500/20">
|
||||
|
||||
@@ -6151,6 +6151,211 @@ def get_network_hardware_info(pci_slot):
|
||||
|
||||
return net_info
|
||||
|
||||
def _get_sriov_info(slot):
|
||||
"""Return SR-IOV role for a PCI slot via sysfs.
|
||||
|
||||
Reads /sys/bus/pci/devices/<BDF>/ for:
|
||||
- physfn symlink → slot is a Virtual Function; link target is its PF
|
||||
- sriov_numvfs → active VF count if slot is a Physical Function
|
||||
- sriov_totalvfs → maximum VFs this PF can spawn
|
||||
|
||||
Returns a dict ready to merge into the GPU object, or {} on any error.
|
||||
The 'role' key uses the same vocabulary as _pci_sriov_role in the
|
||||
bash helpers (pci_passthrough_helpers.sh): vf | pf-active | pf-idle | none.
|
||||
"""
|
||||
try:
|
||||
bdf = slot if slot.startswith('0000:') else f'0000:{slot}'
|
||||
base = f'/sys/bus/pci/devices/{bdf}'
|
||||
if not os.path.isdir(base):
|
||||
return {}
|
||||
|
||||
physfn = os.path.join(base, 'physfn')
|
||||
if os.path.islink(physfn):
|
||||
parent = os.path.basename(os.path.realpath(physfn))
|
||||
return {
|
||||
'sriov_role': 'vf',
|
||||
'sriov_physfn': parent,
|
||||
}
|
||||
|
||||
totalvfs_path = os.path.join(base, 'sriov_totalvfs')
|
||||
if not os.path.isfile(totalvfs_path):
|
||||
return {'sriov_role': 'none'}
|
||||
|
||||
try:
|
||||
totalvfs = int((open(totalvfs_path).read() or '0').strip() or 0)
|
||||
except (ValueError, OSError):
|
||||
totalvfs = 0
|
||||
if totalvfs <= 0:
|
||||
return {'sriov_role': 'none'}
|
||||
|
||||
try:
|
||||
numvfs = int((open(os.path.join(base, 'sriov_numvfs')).read() or '0').strip() or 0)
|
||||
except (ValueError, OSError):
|
||||
numvfs = 0
|
||||
|
||||
return {
|
||||
'sriov_role': 'pf-active' if numvfs > 0 else 'pf-idle',
|
||||
'sriov_vf_count': numvfs,
|
||||
'sriov_totalvfs': totalvfs,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _sriov_list_vfs_of_pf(pf_bdf):
|
||||
"""Return sorted list of VF BDFs that belong to a Physical Function.
|
||||
Reads /sys/bus/pci/devices/<PF>/virtfn<N> symlinks (one per VF).
|
||||
"""
|
||||
try:
|
||||
pf_full = pf_bdf if pf_bdf.startswith('0000:') else f'0000:{pf_bdf}'
|
||||
base = f'/sys/bus/pci/devices/{pf_full}'
|
||||
if not os.path.isdir(base):
|
||||
return []
|
||||
# virtfn links are numbered (virtfn0, virtfn1, ...) and point to the VF.
|
||||
entries = sorted(glob.glob(f'{base}/virtfn*'),
|
||||
key=lambda p: int(re.search(r'virtfn(\d+)', p).group(1))
|
||||
if re.search(r'virtfn(\d+)', p) else 0)
|
||||
return [os.path.basename(os.path.realpath(p)) for p in entries]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _sriov_pci_driver(bdf):
|
||||
"""Return the current driver bound to a PCI BDF, '' if unbound."""
|
||||
try:
|
||||
link = f'/sys/bus/pci/devices/{bdf}/driver'
|
||||
if os.path.islink(link):
|
||||
return os.path.basename(os.path.realpath(link))
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def _sriov_pci_render_node(bdf):
|
||||
"""If the device exposes a DRM render node, return '/dev/dri/renderDX'.
|
||||
LXC containers consume GPUs through these nodes, so this lets us
|
||||
cross-reference an LXC's `dev<N>: /dev/dri/renderD<N>` config line
|
||||
back to a specific VF.
|
||||
"""
|
||||
try:
|
||||
drm_dir = f'/sys/bus/pci/devices/{bdf}/drm'
|
||||
if not os.path.isdir(drm_dir):
|
||||
return ''
|
||||
for name in sorted(os.listdir(drm_dir)):
|
||||
if name.startswith('renderD'):
|
||||
return f'/dev/dri/{name}'
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def _sriov_guest_running(guest_type, gid):
|
||||
"""Best-effort status check. Returns True if running, False otherwise."""
|
||||
try:
|
||||
cmd = ['qm' if guest_type == 'vm' else 'pct', 'status', str(gid)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
||||
return 'running' in (r.stdout or '').lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sriov_find_guest_consumer(bdf):
|
||||
"""Find the VM or LXC that consumes a given VF (or PF) on the host.
|
||||
|
||||
VMs: scan /etc/pve/qemu-server/*.conf for a `hostpci<N>: ` line that
|
||||
references the BDF (short or full form, possibly alongside other
|
||||
ids separated by ';' and trailing options after ',').
|
||||
LXCs: resolve the BDF to its DRM render node (if any) and scan
|
||||
/etc/pve/lxc/*.conf for `dev<N>:` or `lxc.mount.entry:` lines that
|
||||
reference that node.
|
||||
|
||||
Returns {type, id, name, running} or None.
|
||||
"""
|
||||
short_bdf = bdf[5:] if bdf.startswith('0000:') else bdf
|
||||
full_bdf = bdf if bdf.startswith('0000:') else f'0000:{bdf}'
|
||||
|
||||
# ── VM scan ──
|
||||
try:
|
||||
for conf in sorted(glob.glob('/etc/pve/qemu-server/*.conf')):
|
||||
try:
|
||||
with open(conf, 'r') as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(
|
||||
rf'^hostpci\d+:\s*[^\n]*(?:0000:)?{re.escape(short_bdf)}(?:[,;\s]|$)',
|
||||
text, re.MULTILINE,
|
||||
):
|
||||
vmid = os.path.basename(conf)[:-5] # strip '.conf'
|
||||
nm = re.search(r'^name:\s*(\S+)', text, re.MULTILINE)
|
||||
name = nm.group(1) if nm else ''
|
||||
return {
|
||||
'type': 'vm',
|
||||
'id': vmid,
|
||||
'name': name,
|
||||
'running': _sriov_guest_running('vm', vmid),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── LXC scan (via render node) ──
|
||||
render_node = _sriov_pci_render_node(full_bdf)
|
||||
if render_node:
|
||||
try:
|
||||
for conf in sorted(glob.glob('/etc/pve/lxc/*.conf')):
|
||||
try:
|
||||
with open(conf, 'r') as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(
|
||||
rf'^(?:dev\d+|lxc\.mount\.entry):\s*[^\n]*{re.escape(render_node)}(?:[,;\s]|$)',
|
||||
text, re.MULTILINE,
|
||||
):
|
||||
ctid = os.path.basename(conf)[:-5]
|
||||
nm = re.search(r'^hostname:\s*(\S+)', text, re.MULTILINE)
|
||||
name = nm.group(1) if nm else ''
|
||||
return {
|
||||
'type': 'lxc',
|
||||
'id': ctid,
|
||||
'name': name,
|
||||
'running': _sriov_guest_running('lxc', ctid),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _sriov_enrich_detail(gpu):
|
||||
"""On-demand enrichment for the GPU detail modal.
|
||||
|
||||
For a PF with active VFs, populates gpu['sriov_vfs'] with per-VF driver
|
||||
and consumer info. For a VF, populates gpu['sriov_consumer'] with the
|
||||
guest (if any) currently referencing it. Heavier than _get_sriov_info()
|
||||
because it scans guest configs, so it is NOT called from the hardware
|
||||
snapshot path — only from the realtime endpoint.
|
||||
"""
|
||||
role = gpu.get('sriov_role')
|
||||
slot = gpu.get('slot', '')
|
||||
if not slot:
|
||||
return
|
||||
full_bdf = slot if slot.startswith('0000:') else f'0000:{slot}'
|
||||
|
||||
if role == 'pf-active':
|
||||
vf_list = []
|
||||
for vf_bdf in _sriov_list_vfs_of_pf(full_bdf):
|
||||
vf_list.append({
|
||||
'bdf': vf_bdf,
|
||||
'driver': _sriov_pci_driver(vf_bdf) or '',
|
||||
'render_node': _sriov_pci_render_node(vf_bdf) or '',
|
||||
'consumer': _sriov_find_guest_consumer(vf_bdf),
|
||||
})
|
||||
gpu['sriov_vfs'] = vf_list
|
||||
elif role == 'vf':
|
||||
gpu['sriov_consumer'] = _sriov_find_guest_consumer(full_bdf)
|
||||
|
||||
|
||||
def get_gpu_info():
|
||||
"""Detect and return information about GPUs in the system"""
|
||||
gpus = []
|
||||
@@ -6196,7 +6401,11 @@ def get_gpu_info():
|
||||
gpu['pci_class'] = pci_info.get('class', '')
|
||||
gpu['pci_driver'] = pci_info.get('driver', '')
|
||||
gpu['pci_kernel_module'] = pci_info.get('kernel_module', '')
|
||||
|
||||
|
||||
sriov_fields = _get_sriov_info(slot)
|
||||
if sriov_fields:
|
||||
gpu.update(sriov_fields)
|
||||
|
||||
# detailed_info = get_detailed_gpu_info(gpu) # Removed this call here
|
||||
# gpu.update(detailed_info) # It will be called later in api_gpu_realtime
|
||||
|
||||
@@ -10010,7 +10219,12 @@ def api_gpu_realtime(slot):
|
||||
pass
|
||||
detailed_info = get_detailed_gpu_info(gpu)
|
||||
gpu.update(detailed_info)
|
||||
|
||||
|
||||
# SR-IOV detail is only relevant when the modal is actually open,
|
||||
# so we build it on demand here (not in get_gpu_info) to avoid
|
||||
# scanning every guest config on the hardware snapshot path.
|
||||
_sriov_enrich_detail(gpu)
|
||||
|
||||
# Extract only the monitoring-related fields
|
||||
realtime_data = {
|
||||
'has_monitoring_tool': gpu.get('has_monitoring_tool', False),
|
||||
@@ -10035,9 +10249,17 @@ def api_gpu_realtime(slot):
|
||||
# Added for NVIDIA/AMD specific engine info if available
|
||||
'engine_encoder': gpu.get('engine_encoder'),
|
||||
'engine_decoder': gpu.get('engine_decoder'),
|
||||
'driver_version': gpu.get('driver_version') # Added driver_version
|
||||
'driver_version': gpu.get('driver_version'), # Added driver_version
|
||||
# SR-IOV modal detail (populated only when the GPU is an SR-IOV
|
||||
# Physical Function with active VFs, or a Virtual Function).
|
||||
'sriov_role': gpu.get('sriov_role'),
|
||||
'sriov_physfn': gpu.get('sriov_physfn'),
|
||||
'sriov_vf_count': gpu.get('sriov_vf_count'),
|
||||
'sriov_totalvfs': gpu.get('sriov_totalvfs'),
|
||||
'sriov_vfs': gpu.get('sriov_vfs'),
|
||||
'sriov_consumer': gpu.get('sriov_consumer'),
|
||||
}
|
||||
|
||||
|
||||
return jsonify(realtime_data)
|
||||
except Exception as e:
|
||||
# print(f"[v0] Error getting real-time GPU data: {e}")
|
||||
|
||||
@@ -190,6 +190,34 @@ export interface GPU {
|
||||
}>
|
||||
has_monitoring_tool?: boolean
|
||||
note?: string
|
||||
// SR-IOV state — populated from sysfs (physfn symlink + sriov_{num,total}vfs).
|
||||
// "vf" — this slot is a Virtual Function; sriov_physfn is its PF.
|
||||
// "pf-active" — this slot is a Physical Function with sriov_vf_count > 0.
|
||||
// "pf-idle" — SR-IOV capable PF but no VFs currently active.
|
||||
// "none" — not involved in SR-IOV.
|
||||
sriov_role?: "vf" | "pf-active" | "pf-idle" | "none"
|
||||
sriov_physfn?: string
|
||||
sriov_vf_count?: number
|
||||
sriov_totalvfs?: number
|
||||
// SR-IOV detail — only populated by the /api/gpu/<slot>/realtime endpoint
|
||||
// when the modal is open (scanning guest configs is too expensive for the
|
||||
// hardware snapshot path).
|
||||
sriov_vfs?: SriovVfDetail[] // filled when role === "pf-active"
|
||||
sriov_consumer?: SriovConsumer | null // filled when role === "vf"
|
||||
}
|
||||
|
||||
export interface SriovVfDetail {
|
||||
bdf: string // e.g. "0000:00:02.1"
|
||||
driver: string // current kernel driver (i915, vfio-pci, ...)
|
||||
render_node: string // "" when the VF does not expose a DRM node
|
||||
consumer: SriovConsumer | null // which guest is using this VF, if any
|
||||
}
|
||||
|
||||
export interface SriovConsumer {
|
||||
type: "vm" | "lxc"
|
||||
id: string // VMID or CTID
|
||||
name: string // VM name / LXC hostname
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface DiskHardwareInfo {
|
||||
|
||||
Reference in New Issue
Block a user