mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-18 03:26:17 +00:00
833 lines
36 KiB
TypeScript
833 lines
36 KiB
TypeScript
"use client"
|
||
|
||
import { Card } from "@/components/ui/card"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
import { Thermometer, CpuIcon, HardDrive, Cpu, MemoryStick, Cpu as Gpu } from "lucide-react"
|
||
import useSWR from "swr"
|
||
import { useState, useEffect } from "react"
|
||
import { type HardwareData, type GPU, type PCIDevice, type StorageDevice, fetcher } from "../types/hardware"
|
||
|
||
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): string => {
|
||
const kb = typeof memoryKB === "string" ? Number.parseFloat(memoryKB) : memoryKB
|
||
|
||
if (isNaN(kb)) return "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`
|
||
}
|
||
|
||
// Convert to GB if >= 1024 MB
|
||
if (mb >= 1024) {
|
||
const gb = mb / 1024
|
||
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()
|
||
if (lowerType.includes("storage") || lowerType.includes("sata") || lowerType.includes("raid")) {
|
||
return "bg-orange-500/10 text-orange-500 border-orange-500/20"
|
||
}
|
||
if (lowerType.includes("usb")) {
|
||
return "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||
}
|
||
if (lowerType.includes("network") || lowerType.includes("ethernet")) {
|
||
return "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||
}
|
||
if (lowerType.includes("graphics") || lowerType.includes("vga") || lowerType.includes("display")) {
|
||
return "bg-green-500/10 text-green-500 border-green-500/20"
|
||
}
|
||
return "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||
}
|
||
|
||
const getMonitoringToolRecommendation = (vendor: string): string => {
|
||
const lowerVendor = vendor.toLowerCase()
|
||
if (lowerVendor.includes("intel")) {
|
||
return "To get extended GPU monitoring information, please install intel-gpu-tools or igt-gpu-tools package."
|
||
}
|
||
if (lowerVendor.includes("nvidia")) {
|
||
return "For NVIDIA GPUs, real-time monitoring requires the proprietary drivers (nvidia-driver package). Install them only if your GPU is used directly by the host."
|
||
}
|
||
|
||
if (lowerVendor.includes("amd") || lowerVendor.includes("ati")) {
|
||
return "To get extended GPU monitoring information for AMD GPUs, please install amdgpu_top. You can download it from: https://github.com/Umio-Yasuno/amdgpu_top"
|
||
}
|
||
return "To get extended GPU monitoring information, please install the appropriate GPU monitoring tools for your hardware."
|
||
}
|
||
|
||
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 {
|
||
data: hardwareData,
|
||
error,
|
||
isLoading,
|
||
} = useSWR<HardwareData>("/api/hardware", fetcher, {
|
||
refreshInterval: 5000,
|
||
})
|
||
|
||
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)
|
||
|
||
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 apiUrl = `http://${window.location.hostname}:8008/api/gpu/${fullSlot}/realtime`
|
||
|
||
const response = await fetch(apiUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
signal: abortController.signal,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
setRealtimeGPUData(data)
|
||
setDetailsLoading(false)
|
||
} catch (error) {
|
||
// Only log non-abort errors
|
||
if (error instanceof Error && error.name !== "AbortError") {
|
||
console.error("[v0] Error fetching GPU realtime data:", error)
|
||
}
|
||
setRealtimeGPUData({ has_monitoring_tool: false })
|
||
setDetailsLoading(false)
|
||
}
|
||
}
|
||
|
||
// Initial fetch
|
||
fetchRealtimeData()
|
||
|
||
// Poll every 3 seconds
|
||
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="space-y-6">
|
||
<div className="text-center py-8">
|
||
<div className="text-lg font-medium text-foreground mb-2">Loading hardware data...</div>
|
||
</div>
|
||
</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">System Information</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">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">Cores</span>
|
||
<span className="font-medium">
|
||
{hardwareData.cpu.sockets} × {hardwareData.cpu.cores_per_socket} ={" "}
|
||
{hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket} cores
|
||
</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.cpu.total_threads && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">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">L3 Cache</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">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">Motherboard</h3>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{hardwareData.motherboard.manufacturer && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Manufacturer</span>
|
||
<span className="font-medium text-right">{hardwareData.motherboard.manufacturer}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.model && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Model</span>
|
||
<span className="font-medium text-right">{hardwareData.motherboard.model}</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">{hardwareData.motherboard.bios.vendor}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.bios?.version && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Version</span>
|
||
<span className="font-medium">{hardwareData.motherboard.bios.version}</span>
|
||
</div>
|
||
)}
|
||
{hardwareData.motherboard.bios?.date && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">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">Memory Modules</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{hardwareData.memory_modules.length} installed
|
||
</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">Size</span>
|
||
<span className="font-medium text-green-500">{formatMemory(module.size)}</span>
|
||
</div>
|
||
)}
|
||
{module.type && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Type</span>
|
||
<span className="font-medium">{module.type}</span>
|
||
</div>
|
||
)}
|
||
{module.speed && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Speed</span>
|
||
<span className="font-medium">{module.speed}</span>
|
||
</div>
|
||
)}
|
||
{module.manufacturer && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Manufacturer</span>
|
||
<span className="font-medium text-right">{module.manufacturer}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 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">Storage Summary</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{
|
||
hardwareData.storage_devices.filter(
|
||
(device) =>
|
||
device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"),
|
||
).length
|
||
}{" "}
|
||
devices
|
||
</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) => (
|
||
<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="bg-blue-500/10 text-blue-500 border-blue-500/20 px-2.5 py-0.5 shrink-0">
|
||
{device.type}
|
||
</Badge>
|
||
</div>
|
||
{device.size && <p className="text-sm font-medium">{formatMemory(parseLsblkSize(device.size))}</p>}
|
||
{device.model && (
|
||
<p className="text-xs text-muted-foreground line-clamp-2 break-words">{device.model}</p>
|
||
)}
|
||
{device.driver && (
|
||
<p className="mt-1 font-mono text-xs text-green-500 truncate">Driver: {device.driver}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="mt-4 text-xs text-muted-foreground">Click on a device for detailed hardware information</p>
|
||
</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">Thermal Monitoring</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{hardwareData.temperatures.length} sensors
|
||
</Badge>
|
||
</div>
|
||
|
||
{(() => {
|
||
const groupedTemps = groupAndSortTemperatures(hardwareData.temperatures)
|
||
|
||
return (
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{/* CPU Sensors */}
|
||
{groupedTemps.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">
|
||
{groupedTemps.CPU.length}
|
||
</Badge>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{groupedTemps.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 */}
|
||
{groupedTemps.GPU.length > 0 && (
|
||
<div className={groupedTemps.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">
|
||
{groupedTemps.GPU.length}
|
||
</Badge>
|
||
</div>
|
||
<div className={`grid gap-4 ${groupedTemps.GPU.length > 1 ? "md:grid-cols-2" : ""}`}>
|
||
{groupedTemps.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 */}
|
||
{groupedTemps.NVME.length > 0 && (
|
||
<div className={groupedTemps.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">
|
||
{groupedTemps.NVME.length}
|
||
</Badge>
|
||
</div>
|
||
<div className={`grid gap-4 ${groupedTemps.NVME.length > 1 ? "md:grid-cols-2" : ""}`}>
|
||
{groupedTemps.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 */}
|
||
{groupedTemps.PCI.length > 0 && (
|
||
<div className={groupedTemps.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">
|
||
{groupedTemps.PCI.length}
|
||
</Badge>
|
||
</div>
|
||
<div className={`grid gap-4 ${groupedTemps.PCI.length > 1 ? "md:grid-cols-2" : ""}`}>
|
||
{groupedTemps.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 */}
|
||
{groupedTemps.OTHER.length > 0 && (
|
||
<div className={groupedTemps.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">OTHER</h3>
|
||
<Badge variant="outline" className="text-xs">
|
||
{groupedTemps.OTHER.length}
|
||
</Badge>
|
||
</div>
|
||
<div className={`grid gap-4 ${groupedTemps.OTHER.length > 1 ? "md:grid-cols-2" : ""}`}>
|
||
{groupedTemps.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">Graphics Cards</h2>
|
||
<Badge variant="outline" className="ml-auto">
|
||
{hardwareData.gpus.length} GPU{hardwareData.gpus.length > 1 ? "s" : ""}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{hardwareData.gpus.map((gpu, index) => {
|
||
const pciDevice = findPCIDeviceForGPU(gpu)
|
||
const fullSlot = pciDevice?.slot || gpu.slot
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
onClick={() => handleGPUClick(gpu)}
|
||
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="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">Type</span>
|
||
<span className="font-medium">{gpu.type}</span>
|
||
</div>
|
||
|
||
{fullSlot && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">PCI Slot</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">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">Kernel Module</span>
|
||
<span className="font-mono text-xs">{gpu.pci_kernel_module}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* GPU Detail Modal - Shows immediately with basic info, then loads real-time data */}
|
||
<Dialog
|
||
open={selectedGPU !== null}
|
||
onOpenChange={() => {
|
||
setSelectedGPU(null)
|
||
setRealtimeGPUData(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>GPU Real-Time Monitoring</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-6 py-4">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
Basic Information
|
||
</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">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">Type</span>
|
||
<span className="text-sm font-medium">{selectedGPU.type}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">PCI Slot</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">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.5 shrink-0">
|
||
Monitoring Available
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Real-Time Data */}
|
||
{realtimeGPUData && hasRealtimeData() && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
Real-Time Data
|
||
</h3>
|
||
<div className="grid gap-2">
|
||
{realtimeGPUData.data.map((data, index) => (
|
||
<div key={index} className="flex justify-between border-b border-border/50 pb-2">
|
||
<span className="text-sm text-muted-foreground">{data.name}</span>
|
||
<span className="font-mono text-sm">{data.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Monitoring Tool Recommendation */}
|
||
{!hasRealtimeData() && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||
Monitoring Tool Recommendation
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{getMonitoringToolRecommendation(selectedGPU.vendor)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|