update gpu-switch-mode-indicator.tsx

This commit is contained in:
MacRimi
2026-04-19 12:26:52 +02:00
parent bcca760403
commit 834795d6d9
15 changed files with 1235 additions and 119 deletions

View File

@@ -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...

View File

@@ -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">