mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
3034 lines
149 KiB
TypeScript
3034 lines
149 KiB
TypeScript
"use client"
|
||
|
||
import { Card } from "@/components/ui/card"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Progress } from "@/components/ui/progress"
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
import { Cpu, HardDrive, Thermometer, Zap, Loader2, CpuIcon, Cpu as Gpu, Network, MemoryStick, PowerIcon, FanIcon, Battery, Usb, BrainCircuit, AlertCircle } from "lucide-react"
|
||
import { Download } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { getDiskType } from "../lib/disk-type"
|
||
import useSWR from "swr"
|
||
import { useState, useEffect } from "react"
|
||
import {
|
||
type HardwareData,
|
||
type GPU,
|
||
type PCIDevice,
|
||
type StorageDevice,
|
||
type CoralTPU,
|
||
type UsbDevice,
|
||
fetcher as swrFetcher,
|
||
} from "../types/hardware"
|
||
import { fetchApi } from "@/lib/api-config"
|
||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||
import { GpuSwitchModeIndicator } from "./gpu-switch-mode-indicator"
|
||
import { Settings2, CheckCircle2 } from "lucide-react"
|
||
import { useT } from "../lib/i18n/provider"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||
|
||
const parseLsblkSize = (sizeStr: string | undefined): number => {
|
||
if (!sizeStr) return 0
|
||
|
||
// Remove spaces and convert to uppercase
|
||
const cleaned = sizeStr.trim().toUpperCase()
|
||
|
||
// Extract number and unit
|
||
const match = cleaned.match(/^([\d.]+)([KMGT]?)$/)
|
||
if (!match) return 0
|
||
|
||
const value = Number.parseFloat(match[1])
|
||
const unit = match[2] || "K" // Default to KB if no unit
|
||
|
||
// Convert to KB
|
||
switch (unit) {
|
||
case "K":
|
||
return value
|
||
case "M":
|
||
return value * 1024
|
||
case "G":
|
||
return value * 1024 * 1024
|
||
case "T":
|
||
return value * 1024 * 1024 * 1024
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
const formatMemory = (memoryKB: number | string, t?: TFunction): string => {
|
||
const kb = typeof memoryKB === "string" ? Number.parseFloat(memoryKB) : memoryKB
|
||
|
||
if (isNaN(kb)) return t ? t("common.notAvailable") : "N/A"
|
||
|
||
// Convert KB to MB
|
||
const mb = kb / 1024
|
||
|
||
// Convert to TB if >= 1024 GB
|
||
if (mb >= 1024 * 1024) {
|
||
const tb = mb / (1024 * 1024)
|
||
return `${tb.toFixed(1)} TB`
|
||
}
|
||
|
||
if (mb >= 1024) {
|
||
const gb = mb / 1024
|
||
// If GB value is greater than 999, convert to TB
|
||
if (gb > 999) {
|
||
return `${(gb / 1024).toFixed(2)} TB`
|
||
}
|
||
return `${gb.toFixed(1)} GB`
|
||
}
|
||
|
||
// Keep in MB if < 1024 MB
|
||
return `${mb.toFixed(0)} MB`
|
||
}
|
||
|
||
const formatClock = (clockString: string | number): string => {
|
||
let mhz: number
|
||
|
||
if (typeof clockString === "number") {
|
||
mhz = clockString
|
||
} else {
|
||
// Extract numeric value from string like "1138.179107 MHz"
|
||
const match = clockString.match(/([\d.]+)\s*MHz/i)
|
||
if (!match) return clockString
|
||
mhz = Number.parseFloat(match[1])
|
||
}
|
||
|
||
if (isNaN(mhz)) return String(clockString)
|
||
|
||
// Convert to GHz if >= 1000 MHz
|
||
if (mhz >= 1000) {
|
||
const ghz = mhz / 1000
|
||
return `${ghz.toFixed(2)} GHz`
|
||
}
|
||
|
||
// Keep in MHz if < 1000 MHz
|
||
return `${mhz.toFixed(0)} MHz`
|
||
}
|
||
|
||
const getDeviceTypeColor = (type: string): string => {
|
||
const lowerType = type.toLowerCase()
|
||
|
||
// UPS / battery — amber: warm orange-yellow, distinct from the orange used
|
||
// for Storage and avoids the "warning" connotation of pure yellow.
|
||
if (lowerType === "ups" || lowerType.includes("battery")) {
|
||
return "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||
}
|
||
|
||
// Storage family — orange (Mass Storage USB class + PCI storage controllers)
|
||
if (lowerType.includes("storage") || lowerType.includes("sata") || lowerType.includes("raid")) {
|
||
return "bg-orange-500/10 text-orange-500 border-orange-500/20"
|
||
}
|
||
|
||
// Printer — rose, unmistakable
|
||
if (lowerType.includes("printer")) {
|
||
return "bg-rose-500/10 text-rose-500 border-rose-500/20"
|
||
}
|
||
|
||
// Audio family — teal (Audio, Audio/Video); placed before video so that
|
||
// combined "Audio/Video" class labels read as audio-family.
|
||
if (lowerType.includes("audio")) {
|
||
return "bg-teal-500/10 text-teal-500 border-teal-500/20"
|
||
}
|
||
|
||
// Graphics / Video / Imaging — green (cameras, webcams, displays, GPUs).
|
||
if (
|
||
lowerType.includes("graphics") ||
|
||
lowerType.includes("vga") ||
|
||
lowerType.includes("display") ||
|
||
lowerType.includes("video") ||
|
||
lowerType.includes("imaging")
|
||
) {
|
||
return "bg-green-500/10 text-green-500 border-green-500/20"
|
||
}
|
||
|
||
// Network family — blue (Ethernet / Wi-Fi PCI controllers, USB Communications,
|
||
// CDC Data, Wireless Controllers like Bluetooth dongles).
|
||
if (
|
||
lowerType.includes("network") ||
|
||
lowerType.includes("ethernet") ||
|
||
lowerType.includes("communications") ||
|
||
lowerType.includes("wireless") ||
|
||
lowerType === "cdc data"
|
||
) {
|
||
return "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||
}
|
||
|
||
// HID — purple: keyboards, mice, game controllers.
|
||
if (lowerType === "hid") {
|
||
return "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||
}
|
||
|
||
// USB host controllers (PCI-level) keep the existing purple identity.
|
||
if (lowerType.includes("usb")) {
|
||
return "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||
}
|
||
|
||
// Smart Card, Billboard, Diagnostic, Hub, Physical, Content Security,
|
||
// Personal Healthcare, Miscellaneous, Application/Vendor Specific, unknown.
|
||
return "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||
}
|
||
|
||
const getMonitoringToolRecommendation = (vendor: string, t: TFunction): string => {
|
||
const lowerVendor = vendor.toLowerCase()
|
||
if (lowerVendor.includes("intel")) {
|
||
return t("hardware.recommendations.intel")
|
||
}
|
||
if (lowerVendor.includes("nvidia")) {
|
||
return t("hardware.recommendations.nvidia")
|
||
}
|
||
|
||
if (lowerVendor.includes("amd") || lowerVendor.includes("ati")) {
|
||
return t("hardware.recommendations.amd")
|
||
}
|
||
return t("hardware.recommendations.generic")
|
||
}
|
||
|
||
const formatHardwareValue = (value: string | null | undefined, t: TFunction): string => {
|
||
const text = value?.trim()
|
||
if (!text) return t("common.notAvailable")
|
||
|
||
const normalized = text.toLowerCase()
|
||
if (
|
||
normalized === "not specified" ||
|
||
normalized === "not available" ||
|
||
normalized === "to be filled by o.e.m." ||
|
||
normalized === "to be filled by oem" ||
|
||
normalized === "default string"
|
||
) {
|
||
return t("hardware.values.notSpecified")
|
||
}
|
||
|
||
return text
|
||
}
|
||
|
||
const translateDeviceType = (type: string | null | undefined, t: TFunction): string => {
|
||
const text = type?.trim()
|
||
if (!text) return t("common.unknown")
|
||
|
||
const normalized = text.toLowerCase()
|
||
const directMap: Record<string, string> = {
|
||
"graphics": "hardware.deviceTypes.graphics",
|
||
"graphics card": "hardware.deviceTypes.graphicsCard",
|
||
"vga compatible controller": "hardware.deviceTypes.graphicsCard",
|
||
"3d controller": "hardware.deviceTypes.graphicsCard",
|
||
"display controller": "hardware.deviceTypes.graphicsCard",
|
||
"usb": "hardware.deviceTypes.usb",
|
||
"usb controller": "hardware.deviceTypes.usbController",
|
||
"audio": "hardware.deviceTypes.audio",
|
||
"audio device": "hardware.deviceTypes.audio",
|
||
"audio controller": "hardware.deviceTypes.audioController",
|
||
"network": "hardware.deviceTypes.network",
|
||
"network controller": "hardware.deviceTypes.networkController",
|
||
"ethernet": "hardware.deviceTypes.ethernet",
|
||
"ethernet controller": "hardware.deviceTypes.ethernet",
|
||
"wireless": "hardware.deviceTypes.wireless",
|
||
"wireless controller": "hardware.deviceTypes.wirelessController",
|
||
"wi-fi": "hardware.deviceTypes.wifi",
|
||
"wifi": "hardware.deviceTypes.wifi",
|
||
"storage": "hardware.deviceTypes.storage",
|
||
"storage controller": "hardware.deviceTypes.storageController",
|
||
"mass storage": "hardware.deviceTypes.storage",
|
||
"hid": "hardware.deviceTypes.hid",
|
||
"vendor specific": "hardware.deviceTypes.vendorSpecific",
|
||
"communications": "hardware.deviceTypes.communications",
|
||
"integrated": "hardware.deviceTypes.integrated",
|
||
"discrete": "hardware.deviceTypes.discrete",
|
||
}
|
||
|
||
if (directMap[normalized]) return t(directMap[normalized])
|
||
return text
|
||
}
|
||
|
||
const groupAndSortTemperatures = (temperatures: any[]) => {
|
||
const groups = {
|
||
CPU: [] as any[],
|
||
GPU: [] as any[],
|
||
NVME: [] as any[],
|
||
PCI: [] as any[],
|
||
OTHER: [] as any[],
|
||
}
|
||
|
||
temperatures.forEach((temp) => {
|
||
const nameLower = temp.name.toLowerCase()
|
||
const adapterLower = temp.adapter?.toLowerCase() || ""
|
||
|
||
if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) {
|
||
groups.CPU.push(temp)
|
||
} else if (nameLower.includes("gpu") || adapterLower.includes("gpu")) {
|
||
groups.GPU.push(temp)
|
||
} else if (nameLower.includes("nvme") || adapterLower.includes("nvme")) {
|
||
groups.NVME.push(temp)
|
||
} else if (adapterLower.includes("pci")) {
|
||
groups.PCI.push(temp)
|
||
} else {
|
||
groups.OTHER.push(temp)
|
||
}
|
||
})
|
||
|
||
return groups
|
||
}
|
||
|
||
export default function Hardware() {
|
||
const t = useT()
|
||
|
||
// Static data - loaded once on mount. Static fields (CPU, motherboard, memory
|
||
// modules, PCI, disks, GPU list) don't change at runtime, so no auto-refresh.
|
||
// `mutateStatic` is triggered explicitly after GPU switch-mode changes.
|
||
const {
|
||
data: staticHardwareData,
|
||
error: staticError,
|
||
isLoading: staticLoading,
|
||
mutate: mutateStatic,
|
||
} = useSWR<HardwareData>("/api/hardware", swrFetcher, {
|
||
revalidateOnFocus: false,
|
||
revalidateOnReconnect: false,
|
||
refreshInterval: 0,
|
||
})
|
||
|
||
// Live data - only temperatures, fans, power, UPS. Polled every 5s.
|
||
// Backend /api/hardware/live uses cached ipmitool output (10s) so this is cheap.
|
||
const {
|
||
data: dynamicHardwareData,
|
||
error: dynamicError,
|
||
} = useSWR<HardwareData>("/api/hardware/live", swrFetcher, {
|
||
refreshInterval: 5000,
|
||
revalidateOnFocus: true,
|
||
revalidateOnReconnect: true,
|
||
dedupingInterval: 2000,
|
||
})
|
||
|
||
// Merge: static fields from initial load, live fields from the 5s poll.
|
||
// coral_tpus and usb_devices live in the dynamic payload so that the
|
||
// "Install Drivers" button disappears immediately after install_coral.sh
|
||
// finishes, without requiring a page reload.
|
||
const hardwareData = staticHardwareData
|
||
? {
|
||
...staticHardwareData,
|
||
temperatures: dynamicHardwareData?.temperatures ?? staticHardwareData.temperatures,
|
||
fans: dynamicHardwareData?.fans ?? staticHardwareData.fans,
|
||
power_meter: dynamicHardwareData?.power_meter ?? staticHardwareData.power_meter,
|
||
power_supplies: dynamicHardwareData?.power_supplies ?? staticHardwareData.power_supplies,
|
||
ups: dynamicHardwareData?.ups ?? staticHardwareData.ups,
|
||
coral_tpus: dynamicHardwareData?.coral_tpus ?? staticHardwareData.coral_tpus,
|
||
usb_devices: dynamicHardwareData?.usb_devices ?? staticHardwareData.usb_devices,
|
||
}
|
||
: undefined
|
||
|
||
const error = staticError || dynamicError
|
||
const isLoading = staticLoading
|
||
|
||
useEffect(() => {
|
||
if (hardwareData?.storage_devices) {
|
||
hardwareData.storage_devices.forEach((device) => {
|
||
if (device.name.startsWith("nvme")) {
|
||
console.log(`NVMe device ${device.name}:`, {
|
||
pcie_gen: device.pcie_gen,
|
||
pcie_width: device.pcie_width,
|
||
pcie_max_gen: device.pcie_max_gen,
|
||
pcie_max_width: device.pcie_max_width,
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}, [hardwareData])
|
||
|
||
const [managedInstalls, setManagedInstalls] = useState<Array<{
|
||
id: string
|
||
type: string
|
||
name?: string
|
||
current_version?: string | null
|
||
menu_label?: string | null
|
||
update_check?: {
|
||
available: boolean
|
||
latest?: string | null
|
||
last_check?: string | null
|
||
error?: string | null
|
||
} | null
|
||
}>>([])
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetchApi<{ success: boolean; items: any[] }>("/api/managed-installs")
|
||
.then((res) => {
|
||
if (cancelled) return
|
||
if (res?.success && Array.isArray(res.items)) {
|
||
setManagedInstalls(res.items)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
const nvidiaInstall = managedInstalls.find((it) => it.type === "nvidia_xfree86")
|
||
|
||
const formatLastChecked = (iso?: string | null): string => {
|
||
if (!iso) return t("hardware.values.never")
|
||
const d = new Date(iso)
|
||
if (isNaN(d.getTime())) return t("common.unknown")
|
||
const now = Date.now()
|
||
const ageMs = now - d.getTime()
|
||
const sameDay = new Date(now).toDateString() === d.toDateString()
|
||
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
|
||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||
if (sameDay) return time
|
||
if (yesterday) return t("hardware.time.yesterdayAt", { time })
|
||
if (ageMs < 7 * 86_400_000) {
|
||
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
|
||
}
|
||
return d.toLocaleDateString([], { month: "short", day: "numeric" })
|
||
}
|
||
|
||
const [selectedGPU, setSelectedGPU] = useState<GPU | null>(null)
|
||
const [realtimeGPUData, setRealtimeGPUData] = useState<any>(null)
|
||
const [detailsLoading, setDetailsLoading] = useState(false)
|
||
const [selectedPCIDevice, setSelectedPCIDevice] = useState<PCIDevice | null>(null)
|
||
const [selectedDisk, setSelectedDisk] = useState<StorageDevice | null>(null)
|
||
const [selectedNetwork, setSelectedNetwork] = useState<PCIDevice | null>(null)
|
||
const [selectedUPS, setSelectedUPS] = useState<any>(null)
|
||
const [showNvidiaInstaller, setShowNvidiaInstaller] = useState(false)
|
||
const [installingNvidiaDriver, setInstallingNvidiaDriver] = useState(false)
|
||
const [showAmdInstaller, setShowAmdInstaller] = useState(false)
|
||
const [showIntelInstaller, setShowIntelInstaller] = useState(false)
|
||
const [showCoralInstaller, setShowCoralInstaller] = useState(false)
|
||
const [selectedCoral, setSelectedCoral] = useState<CoralTPU | null>(null)
|
||
const [selectedUsbDevice, setSelectedUsbDevice] = useState<UsbDevice | null>(null)
|
||
|
||
// GPU Switch Mode states
|
||
const [editingSwitchModeGpu, setEditingSwitchModeGpu] = useState<string | null>(null) // GPU slot being edited
|
||
const [pendingSwitchModes, setPendingSwitchModes] = useState<Record<string, "lxc" | "vm">>({})
|
||
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).
|
||
// 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"
|
||
if (driver && driver !== "none" && driver !== "") return "lxc"
|
||
|
||
// Fallback to kernel module if no driver
|
||
if (kernelModule.includes("vfio")) return "vm"
|
||
if (kernelModule.includes("nvidia") || kernelModule.includes("amdgpu") || kernelModule.includes("radeon") || kernelModule.includes("i915") || kernelModule.includes("xe") || kernelModule.includes("nouveau") || kernelModule.includes("mgag200")) return "lxc"
|
||
if (kernelModule && kernelModule !== "none" && kernelModule !== "") return "lxc"
|
||
|
||
return "unknown"
|
||
}
|
||
|
||
const handleSwitchModeEdit = (gpuSlot: string, e: React.MouseEvent) => {
|
||
e.stopPropagation() // Prevent opening GPU modal
|
||
setEditingSwitchModeGpu(gpuSlot)
|
||
}
|
||
|
||
const handleSwitchModeToggle = (gpu: GPU, e?: React.MouseEvent) => {
|
||
const slot = gpu.slot
|
||
const currentMode = getGpuSwitchMode(gpu)
|
||
const pendingMode = pendingSwitchModes[slot]
|
||
|
||
// Toggle between modes
|
||
if (pendingMode) {
|
||
// Already has pending - toggle it
|
||
const newMode = pendingMode === "lxc" ? "vm" : "lxc"
|
||
if (newMode === currentMode) {
|
||
// Back to original - remove pending
|
||
const newPending = { ...pendingSwitchModes }
|
||
delete newPending[slot]
|
||
setPendingSwitchModes(newPending)
|
||
} else {
|
||
setPendingSwitchModes({ ...pendingSwitchModes, [slot]: newMode })
|
||
}
|
||
} else {
|
||
// No pending - set opposite of current
|
||
const newMode = currentMode === "lxc" ? "vm" : "lxc"
|
||
setPendingSwitchModes({ ...pendingSwitchModes, [slot]: newMode })
|
||
}
|
||
}
|
||
|
||
const handleSwitchModeSave = (gpuSlot: string, e: React.MouseEvent) => {
|
||
e.stopPropagation()
|
||
const pendingMode = pendingSwitchModes[gpuSlot]
|
||
const gpu = hardwareData?.gpus?.find(g => g.slot === gpuSlot)
|
||
const currentMode = gpu ? getGpuSwitchMode(gpu) : "unknown"
|
||
|
||
if (pendingMode && pendingMode !== currentMode && gpu) {
|
||
// Mode has changed - save params and launch the script
|
||
setSwitchModeParams({
|
||
gpuSlot: gpu.slot,
|
||
targetMode: pendingMode
|
||
})
|
||
setShowSwitchModeModal(true)
|
||
}
|
||
setEditingSwitchModeGpu(null)
|
||
}
|
||
|
||
const handleSwitchModeCancel = (gpuSlot: string, e: React.MouseEvent) => {
|
||
e.stopPropagation()
|
||
// Remove pending change for this GPU
|
||
const newPending = { ...pendingSwitchModes }
|
||
delete newPending[gpuSlot]
|
||
setPendingSwitchModes(newPending)
|
||
setEditingSwitchModeGpu(null)
|
||
}
|
||
|
||
const handleSwitchModeModalClose = () => {
|
||
setShowSwitchModeModal(false)
|
||
// Clear params and pending changes after script runs
|
||
setSwitchModeParams(null)
|
||
setPendingSwitchModes({})
|
||
// Refresh hardware data
|
||
mutateStatic()
|
||
}
|
||
|
||
const handleInstallNvidiaDriver = () => {
|
||
setShowNvidiaInstaller(true)
|
||
}
|
||
|
||
const handleInstallAmdTools = () => {
|
||
setShowAmdInstaller(true)
|
||
}
|
||
|
||
const handleInstallIntelTools = () => {
|
||
setShowIntelInstaller(true)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!selectedGPU) return
|
||
|
||
const pciDevice = findPCIDeviceForGPU(selectedGPU)
|
||
const fullSlot = pciDevice?.slot || selectedGPU.slot
|
||
|
||
if (!fullSlot) return
|
||
|
||
const abortController = new AbortController()
|
||
|
||
const fetchRealtimeData = async () => {
|
||
try {
|
||
const data = await fetchApi(`/api/gpu/${fullSlot}/realtime`)
|
||
setRealtimeGPUData(data)
|
||
setDetailsLoading(false)
|
||
} catch (error) {
|
||
if (error instanceof Error && error.name !== "AbortError") {
|
||
console.error("Error fetching GPU realtime data:", error)
|
||
}
|
||
setRealtimeGPUData({ has_monitoring_tool: false })
|
||
setDetailsLoading(false)
|
||
}
|
||
}
|
||
|
||
fetchRealtimeData()
|
||
const interval = setInterval(fetchRealtimeData, 3000)
|
||
|
||
return () => {
|
||
clearInterval(interval)
|
||
abortController.abort()
|
||
}
|
||
}, [selectedGPU])
|
||
|
||
const handleGPUClick = async (gpu: GPU) => {
|
||
setSelectedGPU(gpu)
|
||
setDetailsLoading(true)
|
||
setRealtimeGPUData(null)
|
||
}
|
||
|
||
const findPCIDeviceForGPU = (gpu: GPU): PCIDevice | null => {
|
||
if (!hardwareData?.pci_devices || !gpu.slot) return null
|
||
|
||
// Try to find exact match first (e.g., "00:02.0")
|
||
let pciDevice = hardwareData.pci_devices.find((d) => d.slot === gpu.slot)
|
||
|
||
// If not found, try to match by partial slot (e.g., "00" matches "00:02.0")
|
||
if (!pciDevice && gpu.slot.length <= 2) {
|
||
pciDevice = hardwareData.pci_devices.find(
|
||
(d) =>
|
||
d.slot.startsWith(gpu.slot + ":") &&
|
||
(d.type.toLowerCase().includes("vga") ||
|
||
d.type.toLowerCase().includes("graphics") ||
|
||
d.type.toLowerCase().includes("display")),
|
||
)
|
||
}
|
||
|
||
return pciDevice || null
|
||
}
|
||
|
||
const hasRealtimeData = (): boolean => {
|
||
if (!realtimeGPUData) return false
|
||
|
||
// Esto permite mostrar datos incluso cuando la GPU está inactiva (valores en 0 o null)
|
||
return realtimeGPUData.has_monitoring_tool === true
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||
<div className="relative">
|
||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||
</div>
|
||
<div className="text-sm font-medium text-foreground">{t("hardware.loading.title")}</div>
|
||
<p className="text-xs text-muted-foreground">{t("hardware.loading.description")}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* System Information - CPU & Motherboard */}
|
||
{(hardwareData?.cpu || hardwareData?.motherboard) && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Cpu className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.systemInformation")}</h2>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{/* CPU Info */}
|
||
{hardwareData?.cpu && Object.keys(hardwareData.cpu).length > 0 && (
|
||
<div>
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<CpuIcon className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">CPU</h3>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{hardwareData.cpu.model && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.model")}</span>
|
||
<span className="font-medium text-right">{hardwareData.cpu.model}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.cpu.cores_per_socket && hardwareData.cpu.sockets && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.cores")}</span>
|
||
<span className="font-medium">
|
||
{hardwareData.cpu.sockets} × {hardwareData.cpu.cores_per_socket} ={" "}
|
||
{t("hardware.values.cores", { count: hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket })}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.cpu.total_threads && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.threads")}</span>
|
||
<span className="font-medium">{hardwareData.cpu.total_threads}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.cpu.l3_cache && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.l3Cache")}</span>
|
||
<span className="font-medium">{hardwareData.cpu.l3_cache}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.cpu.virtualization && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.virtualization")}</span>
|
||
<span className="font-medium">{hardwareData.cpu.virtualization}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Motherboard Info */}
|
||
{hardwareData?.motherboard && Object.keys(hardwareData.motherboard).length > 0 && (
|
||
<div>
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">{t("hardware.sections.motherboard")}</h3>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{hardwareData.motherboard.manufacturer && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.manufacturer")}</span>
|
||
<span className="font-medium text-right">{formatHardwareValue(hardwareData.motherboard.manufacturer, t)}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.model && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.model")}</span>
|
||
<span className="font-medium text-right">{formatHardwareValue(hardwareData.motherboard.model, t)}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.bios?.vendor && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">BIOS</span>
|
||
<span className="font-medium text-right">{formatHardwareValue(hardwareData.motherboard.bios.vendor, t)}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.bios?.version && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.version")}</span>
|
||
<span className="font-medium">{formatHardwareValue(hardwareData.motherboard.bios.version, t)}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.bios?.date && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.date")}</span>
|
||
<span className="font-medium">{hardwareData.motherboard.bios.date}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Memory Modules */}
|
||
{hardwareData?.memory_modules && hardwareData.memory_modules.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<MemoryStick className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.memoryModules")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.installed", { count: hardwareData.memory_modules.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||
{hardwareData.memory_modules.map((module, index) => (
|
||
<div key={index} className="rounded-lg border border-border/30 bg-background/60 p-4">
|
||
<div className="mb-2 font-medium text-sm">{module.slot}</div>
|
||
<div className="space-y-1">
|
||
{module.size && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.size")}</span>
|
||
<span className="font-medium text-green-500">{formatMemory(module.size, t)}</span>
|
||
</div>
|
||
)}
|
||
{module.type && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.type")}</span>
|
||
<span className="font-medium">{module.type}</span>
|
||
</div>
|
||
)}
|
||
{(module.configured_speed || module.max_speed) && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.speed")}</span>
|
||
<span className="font-medium">
|
||
{module.configured_speed && module.max_speed && module.configured_speed !== module.max_speed ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<span className={module.configured_speed.replace(/[^0-9]/g, '') < module.max_speed.replace(/[^0-9]/g, '') ? "text-orange-500" : "text-blue-500"}>
|
||
{module.configured_speed}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">{t("hardware.values.max", { value: module.max_speed })}</span>
|
||
</span>
|
||
) : (
|
||
<span>{module.configured_speed || module.max_speed}</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{module.manufacturer && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.manufacturer")}</span>
|
||
<span className="font-medium text-right">{module.manufacturer}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Thermal Monitoring */}
|
||
{hardwareData?.temperatures && hardwareData.temperatures.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Thermometer className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.thermalMonitoring")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.sensors", { count: hardwareData.temperatures.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{/* CPU Sensors */}
|
||
{groupAndSortTemperatures(hardwareData.temperatures).CPU.length > 0 && (
|
||
<div className="md:col-span-2">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<CpuIcon className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">CPU</h3>
|
||
<Badge variant="outline" className="text-xs">
|
||
{groupAndSortTemperatures(hardwareData.temperatures).CPU.length}
|
||
</Badge>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{groupAndSortTemperatures(hardwareData.temperatures).CPU.map((temp, index) => {
|
||
const percentage =
|
||
temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100
|
||
const isHot = temp.current > (temp.high || 80)
|
||
const isCritical = temp.current > (temp.critical || 90)
|
||
|
||
return (
|
||
<div key={index} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{temp.name}</span>
|
||
<span
|
||
className={`text-sm font-semibold ${isCritical ? "text-red-500" : isHot ? "text-orange-500" : "text-green-500"}`}
|
||
>
|
||
{temp.current.toFixed(1)}°C
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all"
|
||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||
/>
|
||
</div>
|
||
{temp.adapter && <span className="text-xs text-muted-foreground">{temp.adapter}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* GPU Sensors */}
|
||
{groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 0 && (
|
||
<div
|
||
className={groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 1 ? "md:col-span-2" : ""}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<Gpu className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">GPU</h3>
|
||
<Badge variant="outline" className="text-xs">
|
||
{groupAndSortTemperatures(hardwareData.temperatures).GPU.length}
|
||
</Badge>
|
||
</div>
|
||
<div
|
||
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 1 ? "md:grid-cols-2" : ""}`}
|
||
>
|
||
{groupAndSortTemperatures(hardwareData.temperatures).GPU.map((temp, index) => {
|
||
const percentage =
|
||
temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100
|
||
const isHot = temp.current > (temp.high || 80)
|
||
const isCritical = temp.current > (temp.critical || 90)
|
||
|
||
return (
|
||
<div key={index} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{temp.name}</span>
|
||
<span
|
||
className={`text-sm font-semibold ${isCritical ? "text-red-500" : isHot ? "text-orange-500" : "text-green-500"}`}
|
||
>
|
||
{temp.current.toFixed(1)}°C
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all"
|
||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||
/>
|
||
</div>
|
||
{temp.adapter && <span className="text-xs text-muted-foreground">{temp.adapter}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* NVME Sensors */}
|
||
{groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 0 && (
|
||
<div
|
||
className={
|
||
groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 1 ? "md:col-span-2" : ""
|
||
}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">NVME</h3>
|
||
<Badge variant="outline" className="text-xs">
|
||
{groupAndSortTemperatures(hardwareData.temperatures).NVME.length}
|
||
</Badge>
|
||
</div>
|
||
<div
|
||
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 1 ? "md:grid-cols-2" : ""}`}
|
||
>
|
||
{groupAndSortTemperatures(hardwareData.temperatures).NVME.map((temp, index) => {
|
||
const percentage =
|
||
temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100
|
||
const isHot = temp.current > (temp.high || 80)
|
||
const isCritical = temp.current > (temp.critical || 90)
|
||
|
||
return (
|
||
<div key={index} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{temp.name}</span>
|
||
<span
|
||
className={`text-sm font-semibold ${isCritical ? "text-red-500" : isHot ? "text-orange-500" : "text-green-500"}`}
|
||
>
|
||
{temp.current.toFixed(1)}°C
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all"
|
||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||
/>
|
||
</div>
|
||
{temp.adapter && <span className="text-xs text-muted-foreground">{temp.adapter}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* PCI Sensors */}
|
||
{groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 0 && (
|
||
<div
|
||
className={groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 1 ? "md:col-span-2" : ""}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<CpuIcon className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">PCI</h3>
|
||
<Badge variant="outline" className="text-xs">
|
||
{groupAndSortTemperatures(hardwareData.temperatures).PCI.length}
|
||
</Badge>
|
||
</div>
|
||
<div
|
||
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 1 ? "md:grid-cols-2" : ""}`}
|
||
>
|
||
{groupAndSortTemperatures(hardwareData.temperatures).PCI.map((temp, index) => {
|
||
const percentage =
|
||
temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100
|
||
const isHot = temp.current > (temp.high || 80)
|
||
const isCritical = temp.current > (temp.critical || 90)
|
||
|
||
return (
|
||
<div key={index} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{temp.name}</span>
|
||
<span
|
||
className={`text-sm font-semibold ${isCritical ? "text-red-500" : isHot ? "text-orange-500" : "text-green-500"}`}
|
||
>
|
||
{temp.current.toFixed(1)}°C
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all"
|
||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||
/>
|
||
</div>
|
||
{temp.adapter && <span className="text-xs text-muted-foreground">{temp.adapter}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* OTHER Sensors */}
|
||
{groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 0 && (
|
||
<div
|
||
className={
|
||
groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 1 ? "md:col-span-2" : ""
|
||
}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">{t("hardware.sections.otherSensors")}</h3>
|
||
<Badge variant="outline" className="text-xs">
|
||
{groupAndSortTemperatures(hardwareData.temperatures).OTHER.length}
|
||
</Badge>
|
||
</div>
|
||
<div
|
||
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 1 ? "md:grid-cols-2" : ""}`}
|
||
>
|
||
{groupAndSortTemperatures(hardwareData.temperatures).OTHER.map((temp, index) => {
|
||
const percentage =
|
||
temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100
|
||
const isHot = temp.current > (temp.high || 80)
|
||
const isCritical = temp.current > (temp.critical || 90)
|
||
|
||
return (
|
||
<div key={index} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{temp.name}</span>
|
||
<span
|
||
className={`text-sm font-semibold ${isCritical ? "text-red-500" : isHot ? "text-orange-500" : "text-green-500"}`}
|
||
>
|
||
{temp.current.toFixed(1)}°C
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all"
|
||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||
/>
|
||
</div>
|
||
{temp.adapter && <span className="text-xs text-muted-foreground">{temp.adapter}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* GPU Information - Enhanced with on-demand data fetching */}
|
||
{hardwareData?.gpus && hardwareData.gpus.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Gpu className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.graphicsCards")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.gpu", { count: hardwareData.gpus.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
{hardwareData.gpus.map((gpu, index) => {
|
||
const pciDevice = findPCIDeviceForGPU(gpu)
|
||
const fullSlot = pciDevice?.slot || gpu.slot
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
onClick={() => {
|
||
// Don't open modal if we're editing this GPU's switch mode
|
||
if (editingSwitchModeGpu !== fullSlot) {
|
||
handleGPUClick(gpu)
|
||
}
|
||
}}
|
||
className={`rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card p-4 transition-colors ${
|
||
editingSwitchModeGpu === fullSlot
|
||
? "cursor-default"
|
||
: "cursor-pointer sm:hover:bg-white/5"
|
||
}`}
|
||
>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<span className="font-medium text-sm">{gpu.name}</span>
|
||
<Badge className={getDeviceTypeColor("graphics")}>{gpu.vendor}</Badge>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.type")}</span>
|
||
<span className="font-medium">{translateDeviceType(gpu.type, t)}</span>
|
||
</div>
|
||
|
||
{fullSlot && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.pciSlot")}</span>
|
||
<span className="font-mono text-xs">{fullSlot}</span>
|
||
</div>
|
||
)}
|
||
|
||
{gpu.pci_driver && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
<span className="font-mono text-xs text-green-500">{gpu.pci_driver}</span>
|
||
</div>
|
||
)}
|
||
|
||
{gpu.pci_kernel_module && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">{t("hardware.labels.kernelModule")}</span>
|
||
<span className="font-mono text-xs">{gpu.pci_kernel_module}</span>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{gpu.vendor?.toLowerCase().includes("nvidia") &&
|
||
nvidiaInstall?.current_version &&
|
||
nvidiaInstall.update_check?.last_check && (
|
||
<div className="pt-2 mt-2 border-t border-border">
|
||
{nvidiaInstall.update_check.available ? (
|
||
<>
|
||
<div className="text-xs text-muted-foreground">
|
||
{t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "}
|
||
<span className="text-purple-400 font-medium">
|
||
{t("hardware.values.nvidiaDriverAvailable", { version: nvidiaInstall.update_check.latest || "" })}
|
||
</span>
|
||
</div>
|
||
{nvidiaInstall.menu_label && (
|
||
<div className="text-[11px] text-muted-foreground mt-1">
|
||
{t("hardware.values.reinstallViaPostInstall", { label: nvidiaInstall.menu_label })}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="text-xs text-muted-foreground">
|
||
{t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)}
|
||
{` · NVIDIA driver v${nvidiaInstall.current_version}`}
|
||
{" · "}
|
||
<span className="text-green-500/80">{t("hardware.values.noUpdatesAvailable")}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* GPU Switch Mode Indicator */}
|
||
{getGpuSwitchMode(gpu) !== "unknown" && (
|
||
<div className="mt-3 pt-3 border-t border-border/30">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||
{t("hardware.labels.switchMode")}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
{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"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleSwitchModeCancel(fullSlot, e)
|
||
}}
|
||
>
|
||
{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 flex items-center gap-1.5"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleSwitchModeSave(fullSlot, e)
|
||
}}
|
||
>
|
||
<CheckCircle2 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={(e) => {
|
||
e.stopPropagation()
|
||
handleSwitchModeEdit(fullSlot, e)
|
||
}}
|
||
>
|
||
<Settings2 className="h-3 w-3" />
|
||
{t("actions.edit")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<GpuSwitchModeIndicator
|
||
mode={getGpuSwitchMode(gpu)}
|
||
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>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* GPU Detail Modal - Shows immediately with basic info, then loads real-time data */}
|
||
<Dialog open={!!selectedGPU} onOpenChange={(open) => !open && setSelectedGPU(null)}>
|
||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||
{selectedGPU && (
|
||
<>
|
||
<DialogHeader className="pb-4 border-b border-border">
|
||
<DialogTitle>{selectedGPU.name}</DialogTitle>
|
||
<DialogDescription>{t("hardware.gpu.monitoringTitle")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-6 py-4">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.sections.basicInformation")}
|
||
</h3>
|
||
<div className="grid gap-2">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.vendor")}</span>
|
||
<Badge className={getDeviceTypeColor("graphics")}>{selectedGPU.vendor}</Badge>
|
||
</div>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.type")}</span>
|
||
<span className="text-sm font-medium">{translateDeviceType(selectedGPU.type, t)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.pciSlot")}</span>
|
||
<span className="font-mono text-sm">
|
||
{findPCIDeviceForGPU(selectedGPU)?.slot || selectedGPU.slot}
|
||
</span>
|
||
</div>
|
||
{(findPCIDeviceForGPU(selectedGPU)?.driver || selectedGPU.pci_driver) && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
{/* CHANGE: Added monitoring availability indicator */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-sm text-green-500">
|
||
{findPCIDeviceForGPU(selectedGPU)?.driver || selectedGPU.pci_driver}
|
||
</span>
|
||
{realtimeGPUData?.has_monitoring_tool === true && (
|
||
<Badge className="bg-green-500/10 text-green-500 border-green-500/20 text-xs px-1.5 py-0">
|
||
{realtimeGPUData?.driver_version ? `✓ v${realtimeGPUData.driver_version}` : "✓"}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(findPCIDeviceForGPU(selectedGPU)?.kernel_module || selectedGPU.pci_kernel_module) && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.kernelModule")}</span>
|
||
<span className="font-mono text-sm">
|
||
{findPCIDeviceForGPU(selectedGPU)?.kernel_module || selectedGPU.pci_kernel_module}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{detailsLoading ? (
|
||
<div className="text-center py-8 text-muted-foreground">
|
||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-2 text-primary" />
|
||
<p className="text-sm">{t("hardware.loading.realtimeData")}</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">{t("hardware.gpu.sriovVirtualFunction")}</h4>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("hardware.gpu.sriovVirtualFunctionDescription")}
|
||
</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">
|
||
{t("hardware.gpu.virtualFunctionDetail")}
|
||
</h3>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.gpu.parentPhysicalFunction")}</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">{t("common.unknown")}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.currentDriver")}</span>
|
||
<span className="font-mono text-sm">
|
||
{selectedGPU.pci_driver || t("hardware.values.none")}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-start">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.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 ? t("hardware.values.running") : t("hardware.values.stopped")}`}
|
||
</span>
|
||
) : (
|
||
<span className="text-muted-foreground italic">{t("hardware.values.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" />
|
||
{t("hardware.gpu.sriovActive")}
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">
|
||
{t("hardware.gpu.sriovMetricsDescription", {
|
||
count: realtimeGPUData?.sriov_vf_count ?? selectedGPU.sriov_vf_count ?? "N",
|
||
})}
|
||
</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>{t("hardware.gpu.updatingEverySeconds", { seconds: 3 })}</span>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.gpu.realTimeMetrics")}
|
||
</h3>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{realtimeGPUData.clock_graphics && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.graphicsClock")}</span>
|
||
<span className="text-sm font-medium">{formatClock(realtimeGPUData.clock_graphics)}</span>
|
||
</div>
|
||
)}
|
||
{realtimeGPUData.clock_memory && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.memoryClock")}</span>
|
||
<span className="text-sm font-medium">{formatClock(realtimeGPUData.clock_memory)}</span>
|
||
</div>
|
||
)}
|
||
{realtimeGPUData.power_draw && realtimeGPUData.power_draw !== "0.00 W" && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.powerDraw")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{realtimeGPUData.power_draw}</span>
|
||
</div>
|
||
)}
|
||
{realtimeGPUData.temperature !== undefined && realtimeGPUData.temperature !== null && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.temperature")}</span>
|
||
<span className="text-sm font-semibold text-green-500">
|
||
{realtimeGPUData.temperature}°C
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Engine Utilization (Intel/AMD) */}
|
||
{(realtimeGPUData.engine_render !== undefined ||
|
||
realtimeGPUData.engine_blitter !== undefined ||
|
||
realtimeGPUData.engine_video !== undefined ||
|
||
realtimeGPUData.engine_video_enhance !== undefined) && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.gpu.engineUtilizationTotal")}
|
||
</h3>
|
||
<div className="grid gap-3">
|
||
{realtimeGPUData.engine_render !== undefined && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">Render/3D</span>
|
||
<span className="text-sm font-medium">
|
||
{typeof realtimeGPUData.engine_render === "number"
|
||
? `${realtimeGPUData.engine_render.toFixed(1)}%`
|
||
: realtimeGPUData.engine_render}
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
value={
|
||
typeof realtimeGPUData.engine_render === "number"
|
||
? realtimeGPUData.engine_render
|
||
: Number.parseFloat(realtimeGPUData.engine_render) || 0
|
||
}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
{realtimeGPUData.engine_video !== undefined && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">Video</span>
|
||
<span className="text-sm font-medium">
|
||
{typeof realtimeGPUData.engine_video === "number"
|
||
? `${realtimeGPUData.engine_video.toFixed(1)}%`
|
||
: realtimeGPUData.engine_video}
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
value={
|
||
typeof realtimeGPUData.engine_video === "number"
|
||
? realtimeGPUData.engine_video
|
||
: Number.parseFloat(realtimeGPUData.engine_video) || 0
|
||
}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
{realtimeGPUData.engine_blitter !== undefined && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">Blitter</span>
|
||
<span className="text-sm font-medium">
|
||
{typeof realtimeGPUData.engine_blitter === "number"
|
||
? `${realtimeGPUData.engine_blitter.toFixed(1)}%`
|
||
: realtimeGPUData.engine_blitter}
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
value={
|
||
typeof realtimeGPUData.engine_blitter === "number"
|
||
? realtimeGPUData.engine_blitter
|
||
: Number.parseFloat(realtimeGPUData.engine_blitter) || 0
|
||
}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
{realtimeGPUData.engine_video_enhance !== undefined && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">VideoEnhance</span>
|
||
<span className="text-sm font-medium">
|
||
{typeof realtimeGPUData.engine_video_enhance === "number"
|
||
? `${realtimeGPUData.engine_video_enhance.toFixed(1)}%`
|
||
: realtimeGPUData.engine_video_enhance}
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
value={
|
||
typeof realtimeGPUData.engine_video_enhance === "number"
|
||
? realtimeGPUData.engine_video_enhance
|
||
: Number.parseFloat(realtimeGPUData.engine_video_enhance) || 0
|
||
}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* CHANGE: Changed process name badge from blue to purple to match Intel/AMD */}
|
||
{realtimeGPUData.processes && realtimeGPUData.processes.length > 0 && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.gpu.activeProcesses", { count: realtimeGPUData.processes.length })}
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{realtimeGPUData.processes.map((proc: any, idx: number) => (
|
||
<div key={idx} className="rounded-lg border border-border/30 bg-background/50 p-4">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div>
|
||
<Badge className="bg-purple-500/10 text-purple-500 border-purple-500/20 mb-1">
|
||
{proc.name}
|
||
</Badge>
|
||
<p className="font-mono text-xs text-muted-foreground">PID: {proc.pid}</p>
|
||
</div>
|
||
{proc.memory && (
|
||
<Badge
|
||
variant="outline"
|
||
className="font-mono text-xs bg-green-500/10 text-green-500 border-green-500/20"
|
||
>
|
||
{typeof proc.memory === "object"
|
||
? formatMemory(proc.memory.resident / 1024, t)
|
||
: formatMemory(proc.memory, t)}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{proc.engines && Object.keys(proc.engines).length > 0 && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground mb-1">{t("hardware.gpu.engineUtilization")}:</p>
|
||
{Object.entries(proc.engines).map(([engineName, engineData]: [string, any]) => {
|
||
const utilization =
|
||
typeof engineData === "object" ? engineData.busy || 0 : engineData
|
||
const utilizationNum =
|
||
typeof utilization === "string" ? Number.parseFloat(utilization) : utilization
|
||
|
||
if (utilizationNum === 0 || isNaN(utilizationNum)) return null
|
||
|
||
return (
|
||
<div key={engineName} className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-xs text-muted-foreground">{engineName}</span>
|
||
<span className="text-xs font-medium">{utilizationNum.toFixed(1)}%</span>
|
||
</div>
|
||
<Progress value={utilizationNum} className="h-2 [&>div]:bg-blue-500" />
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{realtimeGPUData.processes && realtimeGPUData.processes.length === 0 && (
|
||
<div className="rounded-lg bg-muted/50 p-4 text-center">
|
||
<p className="text-sm text-muted-foreground">{t("hardware.gpu.noActiveProcesses")}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Memory Info (NVIDIA) */}
|
||
{realtimeGPUData.memory_total && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.labels.memory")}
|
||
</h3>
|
||
<div className="grid gap-2">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.total")}</span>
|
||
<span className="text-sm font-medium">{realtimeGPUData.memory_total}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.used")}</span>
|
||
<span className="text-sm font-medium">{realtimeGPUData.memory_used}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.free")}</span>
|
||
<span className="text-sm font-medium">{realtimeGPUData.memory_free}</span>
|
||
</div>
|
||
{realtimeGPUData.utilization_memory !== undefined && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.memoryUtilization")}</span>
|
||
<span className="text-sm font-medium">{realtimeGPUData.utilization_memory}%</span>
|
||
</div>
|
||
<Progress
|
||
value={realtimeGPUData.utilization_memory}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
</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">
|
||
{t("hardware.gpu.virtualFunctions")}
|
||
</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 || t("hardware.values.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">
|
||
{t("hardware.values.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">
|
||
<div className="flex gap-3">
|
||
<div className="flex-shrink-0">
|
||
<svg className="h-5 w-5 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-sm font-semibold text-purple-500 mb-1">{t("hardware.gpu.switchModeVmTitle")}</h4>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("hardware.gpu.switchModeVmDescription")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg bg-blue-500/10 p-4 border border-blue-500/20">
|
||
<div className="flex gap-3">
|
||
<div className="flex-shrink-0">
|
||
<svg className="h-5 w-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-sm font-semibold text-blue-500 mb-1">{t("hardware.gpu.extendedMonitoringUnavailable")}</h4>
|
||
<p className="text-sm text-muted-foreground mb-3">
|
||
{getMonitoringToolRecommendation(selectedGPU.vendor, t)}
|
||
</p>
|
||
{selectedGPU.vendor.toLowerCase().includes("nvidia") && (
|
||
<Button
|
||
onClick={handleInstallNvidiaDriver}
|
||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||
>
|
||
<>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
{t("hardware.actions.installNvidiaDrivers")}
|
||
</>
|
||
</Button>
|
||
)}
|
||
{(selectedGPU.vendor.toLowerCase().includes("amd") || selectedGPU.vendor.toLowerCase().includes("ati")) && (
|
||
<Button
|
||
onClick={handleInstallAmdTools}
|
||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||
>
|
||
<>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
{t("hardware.actions.installAmdGpuTools")}
|
||
</>
|
||
</Button>
|
||
)}
|
||
{selectedGPU.vendor.toLowerCase().includes("intel") && (
|
||
<Button
|
||
onClick={handleInstallIntelTools}
|
||
className="w-full bg-sky-600 hover:bg-sky-700 text-white"
|
||
>
|
||
<>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
{t("hardware.actions.installIntelGpuTools")}
|
||
</>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Coral TPU / AI Accelerators — only rendered when at least one device is detected.
|
||
Unlike GPUs, Coral exposes no temperature/utilization/power counters, so the
|
||
modal shows identity + driver state + an Install CTA when drivers are missing. */}
|
||
{hardwareData?.coral_tpus && hardwareData.coral_tpus.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<BrainCircuit className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.coralTpu")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.devices", { count: hardwareData.coral_tpus.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{hardwareData.coral_tpus.map((coral, index) => (
|
||
<div
|
||
key={`coral-${index}-${coral.slot || coral.bus_device}`}
|
||
onClick={() => setSelectedCoral(coral)}
|
||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-4 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<span className="text-sm font-medium line-clamp-2 break-words flex-1">
|
||
{coral.name}
|
||
</span>
|
||
<Badge
|
||
className={
|
||
coral.type === "usb"
|
||
? "bg-purple-500/10 text-purple-500 border-purple-500/20 px-2.5 py-0.5 shrink-0"
|
||
: "bg-blue-500/10 text-blue-500 border-blue-500/20 px-2.5 py-0.5 shrink-0"
|
||
}
|
||
>
|
||
{coral.type === "usb" ? "USB" : "PCIe"}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="space-y-1 text-xs text-muted-foreground">
|
||
{coral.form_factor && (
|
||
<div className="flex items-center gap-1.5">
|
||
<span>{coral.form_factor}</span>
|
||
{coral.interface_speed && <span className="text-muted-foreground/60">· {coral.interface_speed}</span>}
|
||
</div>
|
||
)}
|
||
<div className="font-mono">
|
||
{coral.type === "pcie" ? coral.slot : coral.bus_device}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center gap-2 text-xs">
|
||
{coral.drivers_ready ? (
|
||
<>
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||
<span className="text-green-500">{t("hardware.values.driversReady")}</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<AlertCircle className="h-3.5 w-3.5 text-yellow-500" />
|
||
<span className="text-yellow-500">{t("hardware.values.driversNotInstalled")}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Primary CTA at the section level when ANY of the detected Coral devices
|
||
is missing drivers — avoids a per-card button repetition. */}
|
||
{hardwareData.coral_tpus.some((c) => !c.drivers_ready) && (
|
||
<div className="mt-4 rounded-lg border border-blue-500/20 bg-blue-500/10 p-3 flex items-center justify-between gap-3">
|
||
<div className="flex items-start gap-3 flex-1">
|
||
<AlertCircle className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
|
||
<div className="text-sm">
|
||
<p className="font-medium text-blue-500">{t("hardware.actions.installCoralDrivers")}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("hardware.coral.driversNeededDescription")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={() => setShowCoralInstaller(true)}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white shrink-0"
|
||
>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
{t("hardware.actions.installDrivers")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{/* Coral TPU detail modal */}
|
||
<Dialog open={selectedCoral !== null} onOpenChange={(open) => !open && setSelectedCoral(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{selectedCoral?.name}</DialogTitle>
|
||
<DialogDescription>{t("hardware.coral.deviceInformation")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedCoral && (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.connection")}</span>
|
||
<Badge
|
||
className={
|
||
selectedCoral.type === "usb"
|
||
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||
}
|
||
>
|
||
{selectedCoral.type === "usb" ? "USB" : "PCIe / M.2"}
|
||
</Badge>
|
||
</div>
|
||
|
||
{selectedCoral.form_factor && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.formFactor")}</span>
|
||
<span className="text-sm">{selectedCoral.form_factor}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedCoral.interface_speed && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.link")}</span>
|
||
<span className="font-mono text-sm">{selectedCoral.interface_speed}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">
|
||
{selectedCoral.type === "usb" ? t("hardware.labels.busDevice") : t("hardware.labels.pciSlot")}
|
||
</span>
|
||
<span className="font-mono text-sm">
|
||
{selectedCoral.type === "usb" ? selectedCoral.bus_device : selectedCoral.slot}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.vendorProductId")}</span>
|
||
<span className="font-mono text-sm">
|
||
{selectedCoral.vendor_id}:{selectedCoral.device_id}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.vendor")}</span>
|
||
<span className="text-sm">{selectedCoral.vendor}</span>
|
||
</div>
|
||
|
||
{selectedCoral.type === "pcie" && selectedCoral.kernel_driver && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.kernelDriver")}</span>
|
||
<span className={`font-mono text-sm ${selectedCoral.kernel_driver === "apex" ? "text-green-500" : "text-yellow-500"}`}>
|
||
{selectedCoral.kernel_driver}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedCoral.kernel_modules && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.kernelModules")}</span>
|
||
<div className="flex gap-2">
|
||
<Badge variant="outline" className={selectedCoral.kernel_modules.gasket ? "text-green-500 border-green-500/20" : "text-red-500 border-red-500/20"}>
|
||
gasket {selectedCoral.kernel_modules.gasket ? "✓" : "✗"}
|
||
</Badge>
|
||
<Badge variant="outline" className={selectedCoral.kernel_modules.apex ? "text-green-500 border-green-500/20" : "text-red-500 border-red-500/20"}>
|
||
apex {selectedCoral.kernel_modules.apex ? "✓" : "✗"}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedCoral.device_nodes && selectedCoral.device_nodes.length > 0 && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.deviceNodes")}</span>
|
||
<span className="font-mono text-xs text-right">
|
||
{selectedCoral.device_nodes.join(", ")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedCoral.type === "usb" && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.runtimeState")}</span>
|
||
<span className="text-sm">
|
||
{selectedCoral.programmed ? t("hardware.values.programmed") : t("hardware.values.unprogrammed")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.edgeTpuRuntime")}</span>
|
||
<span className="text-sm text-right">
|
||
{selectedCoral.edgetpu_runtime || <span className="text-muted-foreground/60">{t("hardware.values.notInstalled")}</span>}
|
||
</span>
|
||
</div>
|
||
|
||
{typeof selectedCoral.temperature === "number" && (() => {
|
||
const trips = selectedCoral.temperature_trips
|
||
// Dynamic thresholds when the driver exposes trip points.
|
||
// Otherwise fall back to conservative hardcoded limits.
|
||
// trips are reported warn→critical, so [N-1] is critical (red)
|
||
// and [N-2] is the throttle/warn level (amber).
|
||
const redAt = trips && trips.length >= 1 ? trips[trips.length - 1] : 85
|
||
const amberAt =
|
||
trips && trips.length >= 2
|
||
? trips[trips.length - 2]
|
||
: trips && trips.length === 1
|
||
? redAt - 10
|
||
: 75
|
||
const color =
|
||
selectedCoral.temperature >= redAt
|
||
? "text-red-500"
|
||
: selectedCoral.temperature >= amberAt
|
||
? "text-amber-500"
|
||
: "text-green-500"
|
||
return (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.temperature")}</span>
|
||
<div className="text-right">
|
||
<span className={`text-sm font-semibold ${color}`}>
|
||
{selectedCoral.temperature.toFixed(1)} °C
|
||
</span>
|
||
{trips && trips.length > 0 && (
|
||
<div className="text-xs text-muted-foreground mt-0.5">
|
||
{t("hardware.labels.thresholds")}: {trips.map((trip) => `${trip.toFixed(0)}°C`).join(" · ")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{selectedCoral.thermal_warnings && selectedCoral.thermal_warnings.length > 0 && (
|
||
<div className="flex justify-between items-start border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.hardwareWarnings")}</span>
|
||
<div className="flex flex-col gap-1 items-end">
|
||
{selectedCoral.thermal_warnings.map((w) => (
|
||
<div key={w.name} className="flex items-center gap-2">
|
||
<span className="text-xs font-mono text-muted-foreground">
|
||
{w.name}
|
||
{typeof w.threshold_c === "number" && ` @ ${w.threshold_c.toFixed(0)}°C`}
|
||
</span>
|
||
<Badge
|
||
variant="outline"
|
||
className={
|
||
w.enabled
|
||
? "text-green-500 border-green-500/20"
|
||
: "text-muted-foreground/70"
|
||
}
|
||
>
|
||
{w.enabled ? t("status.active") : t("status.disabled")}
|
||
</Badge>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!selectedCoral.drivers_ready && (
|
||
<Button
|
||
onClick={() => {
|
||
setSelectedCoral(null)
|
||
setShowCoralInstaller(true)
|
||
}}
|
||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||
>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
{t("hardware.actions.installCoralDrivers")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Power Consumption */}
|
||
{hardwareData?.power_meter && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Zap className="h-5 w-5 text-blue-500" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.powerConsumption")}</h2>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between rounded-lg border border-border/30 bg-background/60 p-4">
|
||
<div className="space-y-1">
|
||
<p className="text-sm font-medium">{hardwareData.power_meter.name}</p>
|
||
{hardwareData.power_meter.adapter && (
|
||
<p className="text-xs text-muted-foreground">{hardwareData.power_meter.adapter}</p>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-2xl font-bold text-blue-500">{hardwareData.power_meter.watts.toFixed(1)} W</p>
|
||
<p className="text-xs text-muted-foreground">{t("hardware.labels.currentDraw")}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Power Supplies */}
|
||
{hardwareData?.power_supplies && hardwareData.power_supplies.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<PowerIcon className="h-5 w-5 text-green-500" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.powerSupplies")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.psus", { count: hardwareData.power_supplies.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{hardwareData.power_supplies.map((psu, index) => (
|
||
<div key={index} className="rounded-lg border border-border/30 bg-background/60 p-4">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{psu.name}</span>
|
||
{psu.status && (
|
||
<Badge variant={psu.status.toLowerCase() === "ok" ? "default" : "destructive"}>{psu.status}</Badge>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-2xl font-bold text-green-500">{psu.watts} W</p>
|
||
<p className="text-xs text-muted-foreground">{t("hardware.labels.currentOutput")}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Fans */}
|
||
{hardwareData?.fans && hardwareData.fans.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<FanIcon className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.systemFans")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.fans", { count: hardwareData.fans.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{hardwareData.fans.map((fan, index) => {
|
||
const isPercentage = fan.unit === "percent" || fan.unit === "%"
|
||
const percentage = isPercentage ? fan.speed : Math.min((fan.speed / 5000) * 100, 100)
|
||
|
||
return (
|
||
<div key={index} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{fan.name}</span>
|
||
<span className="text-sm font-semibold text-blue-500">
|
||
{isPercentage ? `${fan.speed.toFixed(0)} %` : `${fan.speed.toFixed(0)} ${fan.unit}`}
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${percentage}%` }} />
|
||
</div>
|
||
{fan.adapter && <span className="text-xs text-muted-foreground">{fan.adapter}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* UPS */}
|
||
{hardwareData?.ups && Array.isArray(hardwareData.ups) && hardwareData.ups.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Battery className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.upsStatus")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.ups", { count: hardwareData.ups.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{hardwareData.ups.map((ups: any, index: number) => {
|
||
const batteryCharge =
|
||
ups.battery_charge_raw || Number.parseFloat(ups.battery_charge?.replace("%", "") || "0")
|
||
const loadPercent = ups.load_percent_raw || Number.parseFloat(ups.load_percent?.replace("%", "") || "0")
|
||
|
||
// Determine status badge color
|
||
const getStatusColor = (status: string) => {
|
||
if (!status) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||
const statusUpper = status.toUpperCase()
|
||
if (statusUpper.includes("OL")) return "bg-green-500/10 text-green-500 border-green-500/20"
|
||
if (statusUpper.includes("OB")) return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||
if (statusUpper.includes("LB")) return "bg-red-500/10 text-red-500 border-red-500/20"
|
||
return "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
onClick={() => setSelectedUPS(ups)}
|
||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-4 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-sm font-medium block truncate">{ups.model || ups.name}</span>
|
||
{ups.is_remote && <span className="text-xs text-muted-foreground">{t("hardware.labels.remote")}: {ups.host}</span>}
|
||
</div>
|
||
<Badge className={getStatusColor(ups.status)}>{ups.status || t("common.unknown")}</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{ups.battery_charge && (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-muted-foreground">{t("hardware.labels.batteryCharge")}</span>
|
||
<span className="text-sm font-semibold text-green-500">{ups.battery_charge}</span>
|
||
</div>
|
||
<Progress value={batteryCharge} className="h-2 [&>div]:bg-blue-500" />
|
||
</div>
|
||
)}
|
||
|
||
{ups.load_percent && (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-muted-foreground">{t("hardware.labels.load")}</span>
|
||
<span className="text-sm font-semibold text-green-500">{ups.load_percent}</span>
|
||
</div>
|
||
<Progress value={loadPercent} className="h-2 [&>div]:bg-blue-500" />
|
||
</div>
|
||
)}
|
||
|
||
{ups.time_left && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground">{t("hardware.labels.runtime")}</span>
|
||
<div className="mt-1">
|
||
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">{ups.time_left}</Badge>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{ups.input_voltage && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground">{t("hardware.labels.inputVoltage")}</span>
|
||
<div className="mt-1">
|
||
<span className="text-sm font-medium text-green-500">{ups.input_voltage}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
<Dialog open={selectedUPS !== null} onOpenChange={() => setSelectedUPS(null)}>
|
||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||
{selectedUPS && (
|
||
<>
|
||
<DialogHeader className="pb-4 border-b border-border">
|
||
<DialogTitle>{selectedUPS.model || selectedUPS.name}</DialogTitle>
|
||
<DialogDescription>
|
||
{t("hardware.ups.detailedInformation")}
|
||
{selectedUPS.is_remote && ` • ${t("hardware.labels.remote")}: ${selectedUPS.host}`}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-6 py-4">
|
||
{/* Status Overview */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.sections.statusOverview")}
|
||
</h3>
|
||
<div className="grid gap-2">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.status")}</span>
|
||
<Badge
|
||
className={
|
||
selectedUPS.status?.includes("OL")
|
||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||
: selectedUPS.status?.includes("OB")
|
||
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||
: selectedUPS.status?.includes("LB")
|
||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||
}
|
||
>
|
||
{selectedUPS.status || t("common.unknown")}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.connection")}</span>
|
||
<Badge variant="outline">{selectedUPS.connection_type}</Badge>
|
||
</div>
|
||
{selectedUPS.host && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.host")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.host}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Battery Information */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.sections.batteryInformation")}
|
||
</h3>
|
||
<div className="grid gap-3">
|
||
{selectedUPS.battery_charge && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.chargeLevel")}</span>
|
||
<span className="text-sm font-semibold text-green-500">{selectedUPS.battery_charge}</span>
|
||
</div>
|
||
<Progress
|
||
value={
|
||
selectedUPS.battery_charge_raw ||
|
||
Number.parseFloat(selectedUPS.battery_charge.replace("%", ""))
|
||
}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
{selectedUPS.time_left && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.runtimeRemaining")}</span>
|
||
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">
|
||
{selectedUPS.time_left}
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
{selectedUPS.battery_voltage && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.batteryVoltage")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.battery_voltage}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.battery_date && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.batteryDate")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.battery_date}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Input/Output Information */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.sections.powerInformation")}
|
||
</h3>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
{selectedUPS.input_voltage && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.inputVoltage")}</span>
|
||
<span className="text-sm font-medium text-green-500">{selectedUPS.input_voltage}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.output_voltage && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.outputVoltage")}</span>
|
||
<span className="text-sm font-medium text-green-500">{selectedUPS.output_voltage}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.input_frequency && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.inputFrequency")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.input_frequency}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.output_frequency && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.outputFrequency")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.output_frequency}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.load_percent && (
|
||
<div className="md:col-span-2 space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.load")}</span>
|
||
<span className="text-sm font-semibold text-green-500">{selectedUPS.load_percent}</span>
|
||
</div>
|
||
<Progress
|
||
value={
|
||
selectedUPS.load_percent_raw || Number.parseFloat(selectedUPS.load_percent.replace("%", ""))
|
||
}
|
||
className="h-2 [&>div]:bg-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
{selectedUPS.real_power && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.realPower")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{selectedUPS.real_power}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.apparent_power && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{t("hardware.labels.apparentPower")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{selectedUPS.apparent_power}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Device Information */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
{t("hardware.sections.deviceInformation")}
|
||
</h3>
|
||
<div className="grid gap-2">
|
||
{selectedUPS.manufacturer && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.manufacturer")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.manufacturer}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.model && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.model")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.model}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.serial && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.serialNumber")}</span>
|
||
<span className="font-mono text-sm">{selectedUPS.serial}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.firmware && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.firmware")}</span>
|
||
<span className="text-sm font-medium">{selectedUPS.firmware}</span>
|
||
</div>
|
||
)}
|
||
{selectedUPS.driver && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
<span className="font-mono text-sm text-green-500">{selectedUPS.driver}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* PCI Devices - Changed to modal */}
|
||
{hardwareData?.pci_devices && hardwareData.pci_devices.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<CpuIcon className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.pciDevices")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.devices", { count: hardwareData.pci_devices.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{hardwareData.pci_devices.map((device, index) => (
|
||
<div
|
||
key={index}
|
||
onClick={() => setSelectedPCIDevice(device)}
|
||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
|
||
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
||
</div>
|
||
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
||
<p className="text-xs text-muted-foreground truncate">{device.vendor}</p>
|
||
{device.driver && (
|
||
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* PCI Device Detail Modal */}
|
||
<Dialog open={selectedPCIDevice !== null} onOpenChange={() => setSelectedPCIDevice(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{selectedPCIDevice?.device}</DialogTitle>
|
||
<DialogDescription>{t("hardware.pci.deviceInformation")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedPCIDevice && (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.deviceType")}</span>
|
||
<Badge className={getDeviceTypeColor(selectedPCIDevice.type)}>{translateDeviceType(selectedPCIDevice.type, t)}</Badge>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.pciSlot")}</span>
|
||
<span className="font-mono text-sm">{selectedPCIDevice.slot}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.deviceName")}</span>
|
||
<span className="text-sm text-right">{selectedPCIDevice.device}</span>
|
||
</div>
|
||
|
||
{selectedPCIDevice.sdevice && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.productName")}</span>
|
||
<span className="text-sm text-right text-blue-400">{selectedPCIDevice.sdevice}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.vendor")}</span>
|
||
<span className="text-sm">{selectedPCIDevice.vendor}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.class")}</span>
|
||
<span className="font-mono text-sm">{selectedPCIDevice.class}</span>
|
||
</div>
|
||
|
||
{selectedPCIDevice.driver && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
<span className="font-mono text-sm text-green-500">{selectedPCIDevice.driver}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedPCIDevice.kernel_module && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.kernelModule")}</span>
|
||
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Network Summary - Clickable */}
|
||
{hardwareData?.pci_devices &&
|
||
hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Network className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.networkSummary")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.interfaces", {
|
||
count: hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length,
|
||
})}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{hardwareData.pci_devices
|
||
.filter((d) => d.type.toLowerCase().includes("network"))
|
||
.map((device, index) => (
|
||
<div
|
||
key={index}
|
||
onClick={() => setSelectedNetwork(device)}
|
||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between gap-2 mb-1">
|
||
<span className="text-sm font-medium line-clamp-2 break-words flex-1">{device.device}</span>
|
||
<Badge
|
||
className={
|
||
device.network_subtype === "Wireless"
|
||
? "bg-purple-500/10 text-purple-500 border-purple-500/20 px-2.5 py-0.5 shrink-0"
|
||
: "bg-blue-500/10 text-blue-500 border-blue-500/20 px-2.5 py-0.5 shrink-0"
|
||
}
|
||
>
|
||
{translateDeviceType(device.network_subtype || "Ethernet", t)}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground truncate">{device.vendor}</p>
|
||
{device.driver && (
|
||
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Network Detail Modal */}
|
||
<Dialog open={selectedNetwork !== null} onOpenChange={() => setSelectedNetwork(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{selectedNetwork?.device}</DialogTitle>
|
||
<DialogDescription>{t("hardware.network.interfaceInformation")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedNetwork && (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.deviceType")}</span>
|
||
<Badge className={getDeviceTypeColor(selectedNetwork.type)}>{translateDeviceType(selectedNetwork.type, t)}</Badge>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.pciSlot")}</span>
|
||
<span className="font-mono text-sm">{selectedNetwork.slot}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.vendor")}</span>
|
||
<span className="text-sm">{selectedNetwork.vendor}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.class")}</span>
|
||
<span className="font-mono text-sm">{selectedNetwork.class}</span>
|
||
</div>
|
||
|
||
{selectedNetwork.driver && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
<span className="font-mono text-sm text-green-500">{selectedNetwork.driver}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedNetwork.kernel_module && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.kernelModule")}</span>
|
||
<span className="font-mono text-sm">{selectedNetwork.kernel_module}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Storage Summary - Clickable */}
|
||
{hardwareData?.storage_devices && hardwareData.storage_devices.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<HardDrive className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.storageSummary")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.devices", {
|
||
count: hardwareData.storage_devices.filter(
|
||
(device) =>
|
||
device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"),
|
||
).length,
|
||
})}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{hardwareData.storage_devices
|
||
.filter(
|
||
(device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"),
|
||
)
|
||
.map((device, index) => {
|
||
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
|
||
// Classifier lives in lib/disk-type.ts — same rules
|
||
// the Storage page uses, so a drive can't show up as
|
||
// HDD here while showing as SSD there.
|
||
const diskType = getDiskType(diskName, rotationRate)
|
||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||
NVMe: {
|
||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||
label: "NVMe SSD",
|
||
},
|
||
SSD: {
|
||
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||
label: "SSD",
|
||
},
|
||
HDD: {
|
||
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||
label: "HDD",
|
||
},
|
||
}
|
||
return badgeStyles[diskType]
|
||
}
|
||
|
||
const diskBadge = getDiskTypeBadge(device.name, device.rotation_rate)
|
||
|
||
const getLinkSpeedInfo = (device: StorageDevice) => {
|
||
// NVMe PCIe information
|
||
if (device.name.startsWith("nvme") && (device.pcie_gen || device.pcie_width)) {
|
||
const current = `${device.pcie_gen || ""} ${device.pcie_width || ""}`.trim()
|
||
const max =
|
||
device.pcie_max_gen && device.pcie_max_width
|
||
? `${device.pcie_max_gen} ${device.pcie_max_width}`.trim()
|
||
: null
|
||
|
||
const isLowerSpeed = max && current !== max
|
||
|
||
return {
|
||
text: current || null,
|
||
maxText: max,
|
||
isWarning: isLowerSpeed,
|
||
color: isLowerSpeed ? "text-orange-500" : "text-blue-500",
|
||
}
|
||
}
|
||
|
||
// SATA information
|
||
if (device.sata_version) {
|
||
return {
|
||
text: device.sata_version,
|
||
maxText: null,
|
||
isWarning: false,
|
||
color: "text-blue-500",
|
||
}
|
||
}
|
||
|
||
// SAS information
|
||
if (device.sas_version || device.sas_speed) {
|
||
const text = [device.sas_version, device.sas_speed].filter(Boolean).join(" ")
|
||
return {
|
||
text: text || null,
|
||
maxText: null,
|
||
isWarning: false,
|
||
color: "text-blue-500",
|
||
}
|
||
}
|
||
|
||
// Generic link speed
|
||
if (device.link_speed) {
|
||
return {
|
||
text: device.link_speed,
|
||
maxText: null,
|
||
isWarning: false,
|
||
color: "text-blue-500",
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const linkSpeed = getLinkSpeedInfo(device)
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
onClick={() => setSelectedDisk(device)}
|
||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<span className="text-sm font-medium truncate flex-1">{device.name}</span>
|
||
<Badge className={`${diskBadge.className} px-2.5 py-0.5 shrink-0`}>{diskBadge.label}</Badge>
|
||
</div>
|
||
{device.size && <p className="text-sm font-medium">{formatMemory(parseLsblkSize(device.size), t)}</p>}
|
||
{device.model && (
|
||
<p className="text-xs text-muted-foreground line-clamp-2 break-words">{device.model}</p>
|
||
)}
|
||
{linkSpeed && (
|
||
<div className="mt-1 flex items-center gap-1">
|
||
<span className={`text-xs font-medium ${linkSpeed.color}`}>{linkSpeed.text}</span>
|
||
{linkSpeed.maxText && linkSpeed.isWarning && (
|
||
<span className="text-xs font-medium text-blue-500">{t("hardware.values.max", { value: linkSpeed.maxText })}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Disk Detail Modal */}
|
||
<Dialog open={selectedDisk !== null} onOpenChange={() => setSelectedDisk(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{selectedDisk?.name}</DialogTitle>
|
||
<DialogDescription>{t("hardware.storage.deviceHardwareInformation")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedDisk && (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.deviceName")}</span>
|
||
<span className="font-mono text-sm">{selectedDisk.name}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.type")}</span>
|
||
{(() => {
|
||
const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate)
|
||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||
NVMe: {
|
||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||
label: "NVMe SSD",
|
||
},
|
||
SSD: {
|
||
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||
label: "SSD",
|
||
},
|
||
HDD: {
|
||
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||
label: "HDD",
|
||
},
|
||
}
|
||
const diskBadge = badgeStyles[diskType]
|
||
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
|
||
})()}
|
||
</div>
|
||
|
||
{selectedDisk.size && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.capacity")}</span>
|
||
<span className="text-sm font-medium">{formatMemory(parseLsblkSize(selectedDisk.size), t)}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="pt-2">
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-2 uppercase tracking-wide">
|
||
{t("hardware.sections.interfaceInformation")}
|
||
</h3>
|
||
</div>
|
||
|
||
{/* NVMe PCIe Information */}
|
||
{selectedDisk.name.startsWith("nvme") && (
|
||
<>
|
||
{selectedDisk.pcie_gen || selectedDisk.pcie_width ? (
|
||
<>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.currentLinkSpeed")}</span>
|
||
<span
|
||
className={`text-sm font-medium ${
|
||
selectedDisk.pcie_max_gen &&
|
||
selectedDisk.pcie_max_width &&
|
||
`${selectedDisk.pcie_gen} ${selectedDisk.pcie_width}` !==
|
||
`${selectedDisk.pcie_max_gen} ${selectedDisk.pcie_max_width}`
|
||
? "text-orange-500"
|
||
: "text-blue-500"
|
||
}`}
|
||
>
|
||
{selectedDisk.pcie_gen || "PCIe"} {selectedDisk.pcie_width || ""}
|
||
</span>
|
||
</div>
|
||
{selectedDisk.pcie_max_gen && selectedDisk.pcie_max_width && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.maximumLinkSpeed")}</span>
|
||
<span className="text-sm font-medium text-blue-500">
|
||
{selectedDisk.pcie_max_gen} {selectedDisk.pcie_max_width}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.pcieLinkSpeed")}</span>
|
||
<span className="text-sm text-muted-foreground italic">{t("hardware.values.detecting")}</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* SATA Information */}
|
||
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sata_version && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.sataVersion")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{selectedDisk.sata_version}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* SAS Information */}
|
||
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_version && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.sasVersion")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{selectedDisk.sas_version}</span>
|
||
</div>
|
||
)}
|
||
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_speed && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.sasSpeed")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{selectedDisk.sas_speed}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Generic Link Speed - only show if no specific interface info */}
|
||
{!selectedDisk.name.startsWith("nvme") &&
|
||
selectedDisk.link_speed &&
|
||
!selectedDisk.pcie_gen &&
|
||
!selectedDisk.sata_version &&
|
||
!selectedDisk.sas_version && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.linkSpeed")}</span>
|
||
<span className="text-sm font-medium text-blue-500">{selectedDisk.link_speed}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.model && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.model")}</span>
|
||
<span className="text-sm text-right">{selectedDisk.model}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.family && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.family")}</span>
|
||
<span className="text-sm text-right">{selectedDisk.family}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.serial && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.serialNumber")}</span>
|
||
<span className="font-mono text-sm">{selectedDisk.serial}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.firmware && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.firmware")}</span>
|
||
<span className="font-mono text-sm">{selectedDisk.firmware}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.interface && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.interface")}</span>
|
||
<span className="text-sm font-medium">{selectedDisk.interface}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.driver && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
<span className="font-mono text-sm text-green-500">{selectedDisk.driver}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
|
||
<div className="text-sm">
|
||
{typeof selectedDisk.rotation_rate === "number" && selectedDisk.rotation_rate === -1
|
||
? t("common.notAvailable")
|
||
: typeof selectedDisk.rotation_rate === "number" && selectedDisk.rotation_rate > 0
|
||
? `${selectedDisk.rotation_rate} rpm`
|
||
: typeof selectedDisk.rotation_rate === "string"
|
||
? selectedDisk.rotation_rate
|
||
: t("hardware.values.solidStateDevice")}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedDisk.form_factor && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.formFactor")}</span>
|
||
<span className="text-sm">{selectedDisk.form_factor}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* USB Devices — everything physically plugged into the host's USB ports.
|
||
Root hubs (vendor 1d6b) are already filtered out by the backend. The
|
||
section is hidden on headless servers that have nothing attached. */}
|
||
{hardwareData?.usb_devices && hardwareData.usb_devices.length > 0 && (
|
||
<Card className="border-border/50 bg-card/50 p-6">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<Usb className="h-5 w-5 text-primary" />
|
||
<h2 className="text-lg font-semibold">{t("hardware.sections.usbDevices")}</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{t("hardware.counts.devices", { count: hardwareData.usb_devices.length })}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{hardwareData.usb_devices.map((usb, index) => (
|
||
<div
|
||
key={`usb-${index}-${usb.bus_device}`}
|
||
onClick={() => setSelectedUsbDevice(usb)}
|
||
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-1">
|
||
<span className="text-sm font-medium line-clamp-2 break-words flex-1">
|
||
{usb.name}
|
||
</span>
|
||
<Badge className={getDeviceTypeColor(usb.class_label)}>
|
||
{translateDeviceType(usb.class_label, t)}
|
||
</Badge>
|
||
</div>
|
||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||
{usb.speed_label && <div>{usb.speed_label}</div>}
|
||
<div className="font-mono">
|
||
{usb.bus_device} · {usb.vendor_id}:{usb.product_id}
|
||
</div>
|
||
{usb.driver && (
|
||
<div className="font-mono text-green-500/80">{t("hardware.labels.driver")}: {usb.driver}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* USB Device detail modal — mirrors the PCI Device modal for consistency. */}
|
||
<Dialog open={selectedUsbDevice !== null} onOpenChange={(open) => !open && setSelectedUsbDevice(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{selectedUsbDevice?.name}</DialogTitle>
|
||
<DialogDescription>{t("hardware.usb.deviceInformation")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedUsbDevice && (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.class")}</span>
|
||
<Badge className={getDeviceTypeColor(selectedUsbDevice.class_label)}>
|
||
{translateDeviceType(selectedUsbDevice.class_label, t)}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.busDevice")}</span>
|
||
<span className="font-mono text-sm">{selectedUsbDevice.bus_device}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.deviceName")}</span>
|
||
<span className="text-sm text-right">{selectedUsbDevice.name}</span>
|
||
</div>
|
||
|
||
{selectedUsbDevice.vendor && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.vendor")}</span>
|
||
<span className="text-sm">{selectedUsbDevice.vendor}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.vendorProductId")}</span>
|
||
<span className="font-mono text-sm">
|
||
{selectedUsbDevice.vendor_id}:{selectedUsbDevice.product_id}
|
||
</span>
|
||
</div>
|
||
|
||
{selectedUsbDevice.speed_label && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.speed")}</span>
|
||
<span className="text-sm">
|
||
{selectedUsbDevice.speed_label}
|
||
{selectedUsbDevice.speed_mbps > 0 && (
|
||
<span className="text-muted-foreground/60 ml-2">({selectedUsbDevice.speed_mbps} Mbps)</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.classCode")}</span>
|
||
<span className="font-mono text-sm">0x{selectedUsbDevice.class_code}</span>
|
||
</div>
|
||
|
||
{selectedUsbDevice.driver && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.driver")}</span>
|
||
<span className="font-mono text-sm text-green-500">{selectedUsbDevice.driver}</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedUsbDevice.serial && (
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.serial")}</span>
|
||
<span className="font-mono text-sm text-right break-all">{selectedUsbDevice.serial}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* NVIDIA Installation Monitor */}
|
||
{/* <HybridScriptMonitor
|
||
sessionId={nvidiaSessionId}
|
||
title={t("hardware.scripts.nvidiaTitle")}
|
||
description={t("hardware.scripts.nvidiaDescription")}
|
||
onClose={() => {
|
||
setNvidiaSessionId(null)
|
||
mutateStatic()
|
||
}}
|
||
onComplete={(success) => {
|
||
if (success) {
|
||
mutateStatic()
|
||
}
|
||
}}
|
||
/> */}
|
||
<ScriptTerminalModal
|
||
open={showNvidiaInstaller}
|
||
onClose={() => {
|
||
setShowNvidiaInstaller(false)
|
||
mutateStatic()
|
||
}}
|
||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/nvidia_installer.sh"
|
||
scriptName="nvidia_installer"
|
||
params={{
|
||
EXECUTION_MODE: "web",
|
||
}}
|
||
title={t("hardware.scripts.nvidiaTitle")}
|
||
description={t("hardware.scripts.nvidiaDescription")}
|
||
/>
|
||
<ScriptTerminalModal
|
||
open={showAmdInstaller}
|
||
onClose={() => {
|
||
setShowAmdInstaller(false)
|
||
mutateStatic()
|
||
}}
|
||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/amd_gpu_tools.sh"
|
||
scriptName="amd_gpu_tools"
|
||
params={{
|
||
EXECUTION_MODE: "web",
|
||
}}
|
||
title={t("hardware.scripts.amdTitle")}
|
||
description={t("hardware.scripts.amdDescription")}
|
||
/>
|
||
<ScriptTerminalModal
|
||
open={showIntelInstaller}
|
||
onClose={() => {
|
||
setShowIntelInstaller(false)
|
||
mutateStatic()
|
||
}}
|
||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/intel_gpu_tools.sh"
|
||
scriptName="intel_gpu_tools"
|
||
params={{
|
||
EXECUTION_MODE: "web",
|
||
}}
|
||
title={t("hardware.scripts.intelTitle")}
|
||
description={t("hardware.scripts.intelDescription")}
|
||
/>
|
||
<ScriptTerminalModal
|
||
open={showCoralInstaller}
|
||
onClose={() => {
|
||
setShowCoralInstaller(false)
|
||
mutateStatic()
|
||
}}
|
||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/install_coral.sh"
|
||
scriptName="install_coral"
|
||
params={{
|
||
EXECUTION_MODE: "web",
|
||
}}
|
||
title={t("hardware.scripts.coralTitle")}
|
||
description={t("hardware.scripts.coralDescription")}
|
||
/>
|
||
|
||
{/* GPU Switch Mode Modal */}
|
||
{switchModeParams && (
|
||
<ScriptTerminalModal
|
||
open={showSwitchModeModal}
|
||
onClose={handleSwitchModeModalClose}
|
||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/switch_gpu_mode_direct.sh"
|
||
scriptName="switch_gpu_mode_direct"
|
||
params={{
|
||
EXECUTION_MODE: "web",
|
||
GPU_SWITCH_PARAMS: `${switchModeParams.gpuSlot}|${switchModeParams.targetMode}`,
|
||
}}
|
||
title={t("hardware.scripts.switchModeTitle", { mode: switchModeParams.targetMode.toUpperCase() })}
|
||
description={t("hardware.scripts.switchModeDescription", {
|
||
slot: switchModeParams.gpuSlot,
|
||
mode: switchModeParams.targetMode === "vm"
|
||
? t("hardware.scripts.switchModeVm")
|
||
: t("hardware.scripts.switchModeLxc"),
|
||
})}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|