Atualiza portal e admin com bloqueio de máquinas desativadas

This commit is contained in:
Esdras Renan 2025-10-18 00:02:15 -03:00
parent e5085962e9
commit 630110bf3a
31 changed files with 1756 additions and 244 deletions

View file

@ -1,11 +1,12 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { ReactNode } from "react"
import { useQuery } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal } from "lucide-react"
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal, Power, PlayCircle, Download } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
@ -24,7 +25,9 @@ import {
TableRow,
} from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
import { ChartContainer } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-client"
@ -567,6 +570,7 @@ export type MachinesQueryItem = {
assignedUserName: string | null
assignedUserRole: string | null
status: string | null
isActive: boolean
lastHeartbeatAt: number | null
heartbeatAgeMs: number | null
registeredBy: string | null
@ -611,6 +615,7 @@ const statusLabels: Record<string, string> = {
stale: "Sem sinal",
maintenance: "Manutenção",
blocked: "Bloqueada",
deactivated: "Desativada",
unknown: "Desconhecida",
}
@ -620,6 +625,7 @@ const statusClasses: Record<string, string> = {
stale: "border-slate-400/30 bg-slate-200 text-slate-700",
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600",
deactivated: "border-slate-400/40 bg-slate-100 text-slate-600",
unknown: "border-slate-300 bg-slate-200 text-slate-700",
}
@ -707,7 +713,8 @@ function getStatusVariant(status?: string | null) {
}
}
function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null }): string {
function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }): string {
if (machine.isActive === false) return "deactivated"
const manualStatus = (machine.status ?? "").toLowerCase()
if (["maintenance", "blocked"].includes(manualStatus)) {
return manualStatus
@ -871,6 +878,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
? "bg-amber-500"
: s === "blocked"
? "bg-orange-500"
: s === "deactivated"
? "bg-slate-500"
: "bg-slate-400"
const ringClass =
s === "online"
@ -881,6 +890,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
? "bg-amber-400/30"
: s === "blocked"
? "bg-orange-400/30"
: s === "deactivated"
? "bg-slate-400/40"
: "bg-slate-300/30"
const isOnline = s === "online"
@ -961,6 +972,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
? windowsBaseboardRaw
: null
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
const isActive = machine?.isActive ?? true
const windowsMemoryModules = useMemo(() => {
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw]
@ -1111,6 +1123,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
const osName = machine?.osName ?? "Sistema desconhecido"
const osVersion = machine?.osVersion ?? windowsVersionLabel ?? ""
chips.push({
key: "os",
label: "Sistema",
value: [osName, osVersion].filter(Boolean).join(" ").trim(),
icon: <OsIcon osName={machine?.osName} />,
})
if (machine?.architecture) {
chips.push({
key: "arch",
label: "Arquitetura",
value: machine.architecture.toUpperCase(),
icon: <Cpu className="size-4 text-neutral-500" />,
})
}
if (windowsBuildLabel) {
chips.push({
key: "build",
label: "Build",
value: windowsBuildLabel,
icon: <ServerCog className="size-4 text-neutral-500" />,
})
}
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
chips.push({
key: "activation",
label: "Licença",
value: windowsActivationStatus ? "Ativada" : "Não ativada",
icon: windowsActivationStatus ? <ShieldCheck className="size-4 text-emerald-500" /> : <ShieldAlert className="size-4 text-amber-500" />,
tone: windowsActivationStatus ? undefined : "warning",
})
}
if (primaryGpu?.name) {
chips.push({
key: "gpu",
label: "GPU principal",
value: `${primaryGpu.name}${typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}`,
icon: <MemoryStick className="size-4 text-neutral-500" />,
})
}
if (collaborator?.email) {
const collaboratorValue = collaborator.name ? `${collaborator.name} · ${collaborator.email}` : collaborator.email
chips.push({
key: "collaborator",
label: personaLabel,
value: collaboratorValue,
icon: <ShieldCheck className="size-4 text-neutral-500" />,
})
}
return chips
}, [machine?.osName, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel])
const companyName = (() => {
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
const found = companies.find((c) => c.slug === machine.companySlug)
@ -1131,6 +1198,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
)
const [savingAccess, setSavingAccess] = useState(false)
const [togglingActive, setTogglingActive] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const jsonText = useMemo(() => {
const payload = {
@ -1145,6 +1213,20 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
return JSON.stringify(payload, null, 2)
}, [machine, metrics, metadata])
const handleDownloadInventory = useCallback(() => {
if (!machine) return
const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
const fileName = `${safeHostname || "machine"}_${machine.id}.json`
const blob = new Blob([jsonText], { type: "application/json" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [jsonText, machine])
const filteredJsonHtml = useMemo(() => {
if (!dialogQuery.trim()) return jsonText
@ -1200,6 +1282,29 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const handleToggleActive = async () => {
if (!machine) return
setTogglingActive(true)
try {
const response = await fetch("/api/admin/machines/toggle-active", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id, active: !isActive }),
credentials: "include",
})
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string }
throw new Error(payload?.error ?? "Falha ao atualizar status")
}
toast.success(!isActive ? "Máquina reativada" : "Máquina desativada")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o status da máquina.")
} finally {
setTogglingActive(false)
}
}
return (
<Card className="border-slate-200">
<CardHeader>
@ -1224,55 +1329,26 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<p className="text-xs text-muted-foreground">
{machine.authEmail ?? "E-mail não definido"}
</p>
{machine.companySlug ? (
<p className="text-xs text-muted-foreground">
Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-sm">
{companyName ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm">
{companyName}
</div>
) : null}
<MachineStatusBadge status={effectiveStatus} />
{!isActive ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 text-xs font-semibold uppercase text-rose-700">
Máquina desativada
</Badge>
) : null}
</div>
<MachineStatusBadge status={effectiveStatus} />
</div>
{/* ping integrado na badge de status */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
<span className="mr-2 inline-flex items-center"><OsIcon osName={machine.osName} /></span>
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
</Badge>
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
{machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"}
</Badge>
{windowsOsInfo ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Build: {windowsBuildLabel ?? "—"}
</Badge>
) : null}
{windowsOsInfo ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Ativado: {
windowsActivationStatus == null
? "—"
: windowsActivationStatus
? "Sim"
: "Não"
}
</Badge>
) : null}
{primaryGpu?.name ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
GPU: {primaryGpu.name}
{typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}
</Badge>
) : null}
{companyName ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Empresa: {companyName}
</Badge>
) : null}
{collaborator?.email ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</Badge>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{summaryChips.map((chip) => (
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
))}
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (
@ -1285,6 +1361,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
<Button
size="sm"
variant={isActive ? "outline" : "default"}
className={cn(
"gap-2 border-dashed",
!isActive && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)}
onClick={handleToggleActive}
disabled={togglingActive}
>
{isActive ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
{machine.registeredBy ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Registrada via {machine.registeredBy}
@ -1405,12 +1494,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div>
</section>
{metrics && typeof metrics === "object" ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
<MetricsGrid metrics={metrics} />
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
</section>
) : null}
{hardware || network || (labels && labels.length > 0) ? (
<section className="space-y-3">
@ -2149,7 +2236,23 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<DialogTitle>Inventário completo {machine.hostname}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Input placeholder="Buscar no JSON" value={dialogQuery} onChange={(e) => setDialogQuery(e.target.value)} />
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Buscar no JSON"
value={dialogQuery}
onChange={(e) => setDialogQuery(e.target.value)}
className="sm:flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownloadInventory}
className="inline-flex items-center gap-2"
>
<Download className="size-4" /> Baixar JSON
</Button>
</div>
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs">
<pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml
.replaceAll("__HIGHLIGHT__", '<mark class="bg-yellow-200 text-foreground">')
@ -2225,7 +2328,7 @@ function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQuery
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
const effectiveStatus = resolveMachineStatus(machine)
const { className } = getStatusVariant(effectiveStatus)
const isActive = machine.isActive
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
type AgentMetrics = {
memoryUsedBytes?: number
@ -2264,19 +2367,23 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
return (
<Link href={`/admin/machines/${machine.id}`} className="group">
<Card className="relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
<Card className={cn("relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300", !isActive && "border-slate-300 bg-slate-50") }>
<div className="absolute right-2 top-2">
<span
aria-hidden
className={cn(
"relative block size-2 rounded-full",
className.includes("emerald")
effectiveStatus === "online"
? "bg-emerald-500"
: className.includes("rose")
: effectiveStatus === "offline"
? "bg-rose-500"
: className.includes("amber")
: effectiveStatus === "maintenance"
? "bg-amber-500"
: "bg-slate-400"
: effectiveStatus === "blocked"
? "bg-orange-500"
: effectiveStatus === "deactivated"
? "bg-slate-500"
: "bg-slate-400"
)}
/>
{effectiveStatus === "online" ? (
@ -2288,6 +2395,11 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
</CardTitle>
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
{!isActive ? (
<Badge variant="outline" className="mt-2 w-fit border-rose-200 bg-rose-50 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
Desativada
</Badge>
) : null}
</CardHeader>
<CardContent className="flex grow flex-col gap-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
@ -2351,52 +2463,243 @@ function DetailLine({ label, value, classNameValue }: DetailLineProps) {
)
}
function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
function InfoChip({ label, value, icon, tone = "default" }: { label: string; value: string; icon?: ReactNode; tone?: "default" | "warning" | "muted" }) {
const toneClasses =
tone === "warning"
? "border-amber-200 bg-amber-50 text-amber-700"
: tone === "muted"
? "border-slate-200 bg-slate-50 text-neutral-600"
: "border-slate-200 bg-white text-neutral-800"
return (
<div className={cn("flex items-center gap-3 rounded-xl border px-3 py-2 shadow-sm", toneClasses)}>
{icon ? <span className="text-neutral-500">{icon}</span> : null}
<div className="min-w-0 leading-tight">
<p className="text-xs uppercase text-neutral-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</div>
)
}
function clampPercent(raw: number): number {
if (!Number.isFinite(raw)) return 0
const normalized = raw > 1 && raw <= 100 ? raw : raw <= 1 ? raw * 100 : raw
return Math.max(0, Math.min(100, normalized))
}
function deriveUsageMetrics({
metrics,
hardware,
disks,
}: {
metrics: MachineMetrics
hardware?: MachineInventory["hardware"]
disks?: MachineInventory["disks"]
}) {
const data = (metrics ?? {}) as Record<string, unknown>
// Compat: aceitar chaves do agente desktop (cpuUsagePercent, memoryUsedBytes, memoryTotalBytes)
const cpu = (() => {
const v = Number(
data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? data.cpuUsagePercent ?? NaN
)
return v
})()
const memory = (() => {
// valor absoluto em bytes, se disponível
const memBytes = Number(
data.memoryBytes ?? data.memory ?? data.memory_used ?? data.memoryUsedBytes ?? NaN
)
if (Number.isFinite(memBytes)) return memBytes
// tentar derivar a partir de percentuais do agente
const usedPct = Number(data.memoryUsedPercent ?? NaN)
const totalBytes = Number(data.memoryTotalBytes ?? NaN)
if (Number.isFinite(usedPct) && Number.isFinite(totalBytes)) {
return Math.max(0, Math.min(1, usedPct > 1 ? usedPct / 100 : usedPct)) * totalBytes
const cpuRaw = Number(
data.cpuUsagePercent ?? data.cpuUsage ?? data.cpu_percent ?? data.cpu ?? NaN
)
const cpuPercent = Number.isFinite(cpuRaw) ? clampPercent(cpuRaw) : null
const totalCandidates = [
data.memoryTotalBytes,
data.memory_total,
data.memoryTotal,
hardware?.memoryBytes,
hardware?.memory,
]
let memoryTotalBytes: number | null = null
for (const candidate of totalCandidates) {
const parsed = parseBytesLike(candidate)
if (parsed && Number.isFinite(parsed) && parsed > 0) {
memoryTotalBytes = parsed
break
}
return NaN
})()
const disk = Number(data.diskUsage ?? data.disk ?? NaN)
const gpuUsage = Number(
data.gpuUsage ?? data.gpu ?? data.gpuUsagePercent ?? data.gpu_percent ?? NaN
const numeric = Number(candidate)
if (Number.isFinite(numeric) && numeric > 0) {
memoryTotalBytes = numeric
break
}
}
const usedCandidates = [
data.memoryUsedBytes,
data.memoryBytes,
data.memory_used,
data.memory,
]
let memoryUsedBytes: number | null = null
for (const candidate of usedCandidates) {
const parsed = parseBytesLike(candidate)
if (parsed !== undefined && Number.isFinite(parsed)) {
memoryUsedBytes = parsed
break
}
const numeric = Number(candidate)
if (Number.isFinite(numeric)) {
memoryUsedBytes = numeric
break
}
}
const memoryPercentRaw = Number(data.memoryUsedPercent ?? data.memory_percent ?? NaN)
let memoryPercent = Number.isFinite(memoryPercentRaw) ? clampPercent(memoryPercentRaw) : null
if (memoryTotalBytes && memoryUsedBytes === null && memoryPercent !== null) {
memoryUsedBytes = (memoryPercent / 100) * memoryTotalBytes
} else if (memoryTotalBytes && memoryUsedBytes !== null) {
memoryPercent = clampPercent((memoryUsedBytes / memoryTotalBytes) * 100)
}
let diskTotalBytes: number | null = null
let diskUsedBytes: number | null = null
let diskPercent: number | null = null
if (Array.isArray(disks) && disks.length > 0) {
let total = 0
let available = 0
disks.forEach((disk) => {
const totalParsed = parseBytesLike(disk?.totalBytes)
if (typeof totalParsed === "number" && Number.isFinite(totalParsed) && totalParsed > 0) {
total += totalParsed
}
const availableParsed = parseBytesLike(disk?.availableBytes)
if (typeof availableParsed === "number" && Number.isFinite(availableParsed) && availableParsed >= 0) {
available += availableParsed
}
})
if (total > 0) {
diskTotalBytes = total
const used = Math.max(0, total - available)
diskUsedBytes = used
diskPercent = clampPercent((used / total) * 100)
}
}
if (diskPercent === null) {
const diskMetric = Number(
data.diskUsage ?? data.disk ?? data.diskUsedPercent ?? data.storageUsedPercent ?? NaN
)
if (Number.isFinite(diskMetric)) {
diskPercent = clampPercent(diskMetric)
}
}
const gpuMetric = Number(
data.gpuUsagePercent ?? data.gpuUsage ?? data.gpu_percent ?? data.gpu ?? NaN
)
const gpuPercent = Number.isFinite(gpuMetric) ? clampPercent(gpuMetric) : null
return {
cpuPercent,
memoryUsedBytes,
memoryTotalBytes,
memoryPercent,
diskPercent,
diskUsedBytes,
diskTotalBytes,
gpuPercent,
}
}
function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; hardware?: MachineInventory["hardware"]; disks?: MachineInventory["disks"] }) {
const derived = useMemo(
() => deriveUsageMetrics({ metrics, hardware, disks }),
[metrics, hardware, disks]
)
const cards: Array<{ label: string; value: string }> = [
{ label: "CPU", value: formatPercent(cpu) },
{ label: "Memória", value: formatBytes(memory) },
{ label: "Disco", value: Number.isNaN(disk) ? "—" : formatPercent(disk) },
]
const cards = [
{
key: "cpu",
label: "CPU",
percent: derived.cpuPercent,
primaryText: derived.cpuPercent !== null ? formatPercent(derived.cpuPercent) : "Sem dados",
secondaryText: derived.cpuPercent !== null ? "Uso instantâneo" : "Sem leituras recentes",
icon: <Cpu className="size-4 text-neutral-500" />,
color: "var(--chart-1)",
},
{
key: "memory",
label: "Memória",
percent: derived.memoryPercent,
primaryText:
derived.memoryUsedBytes !== null && derived.memoryTotalBytes !== null
? `${formatBytes(derived.memoryUsedBytes)} / ${formatBytes(derived.memoryTotalBytes)}`
: derived.memoryPercent !== null
? formatPercent(derived.memoryPercent)
: "Sem dados",
secondaryText: derived.memoryPercent !== null ? `${Math.round(derived.memoryPercent)}% em uso` : null,
icon: <MemoryStick className="size-4 text-neutral-500" />,
color: "var(--chart-2)",
},
{
key: "disk",
label: "Disco",
percent: derived.diskPercent,
primaryText:
derived.diskUsedBytes !== null && derived.diskTotalBytes !== null
? `${formatBytes(derived.diskUsedBytes)} / ${formatBytes(derived.diskTotalBytes)}`
: derived.diskPercent !== null
? formatPercent(derived.diskPercent)
: "Sem dados",
secondaryText: derived.diskPercent !== null ? `${Math.round(derived.diskPercent)}% utilizado` : null,
icon: <HardDrive className="size-4 text-neutral-500" />,
color: "var(--chart-3)",
},
] as Array<{ key: string; label: string; percent: number | null; primaryText: string; secondaryText?: string | null; icon: ReactNode; color: string }>
if (!Number.isNaN(gpuUsage)) {
cards.push({ label: "GPU", value: formatPercent(gpuUsage) })
if (derived.gpuPercent !== null) {
cards.push({
key: "gpu",
label: "GPU",
percent: derived.gpuPercent,
primaryText: formatPercent(derived.gpuPercent),
secondaryText: null,
icon: <Monitor className="size-4 text-neutral-500" />,
color: "var(--chart-4)",
})
}
return (
<div className="grid gap-2 sm:grid-cols-3 md:grid-cols-4">
{cards.map((card) => (
<div key={card.label} className="rounded-md border border-slate-200 bg-slate-50/60 p-3 text-sm text-muted-foreground">
<p className="text-xs uppercase text-slate-500">{card.label}</p>
<p className="text-sm font-semibold text-foreground">{card.value}</p>
</div>
))}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => {
const percentValue = Number.isFinite(card.percent ?? NaN) ? Math.max(0, Math.min(100, card.percent ?? 0)) : 0
const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—"
return (
<div key={card.key} className="flex items-center gap-4 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="relative h-20 w-20">
<ChartContainer
config={{ usage: { label: card.label, color: card.color } }}
className="h-20 w-20 aspect-square"
>
<RadialBarChart
data={[{ name: card.label, value: percentValue }]}
innerRadius="55%"
outerRadius="100%"
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" cornerRadius={10} fill="var(--color-usage)" background />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm font-semibold text-neutral-800">
{percentLabel}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
{card.icon}
{card.label}
</div>
<div className="text-sm text-neutral-700">{card.primaryText}</div>
{card.secondaryText ? (
<div className="text-xs text-neutral-500">{card.secondaryText}</div>
) : null}
</div>
</div>
)
})}
</div>
)
}