feat(admin/machines): redesign overview as cards grid with status dot and metrics; add machine detail page (/admin/machines/[id]) reusing existing detail panel

This commit is contained in:
Esdras Renan 2025-10-10 10:17:59 -03:00
parent 5851bfe366
commit 124bb2a26f
3 changed files with 138 additions and 88 deletions

View file

@ -25,6 +25,7 @@ import {
} from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import Link from "next/link"
type MachineMetrics = Record<string, unknown> | null
@ -102,7 +103,7 @@ type MachineInventory = {
services?: Array<{ name?: string; status?: string; displayName?: string }>
}
type MachinesQueryItem = {
export type MachinesQueryItem = {
id: string
tenantId: string
hostname: string
@ -200,25 +201,12 @@ function getStatusVariant(status?: string | null) {
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
const machines = useMachinesQuery(tenantId)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [osFilter, setOsFilter] = useState<string>("all")
const [companyFilter, setCompanyFilter] = useState<string>("all")
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
useEffect(() => {
if (machines.length === 0) {
setSelectedId(null)
return
}
if (!selectedId) {
setSelectedId(machines[0]?.id ?? null)
} else if (!machines.some((machine) => machine.id === selectedId)) {
setSelectedId(machines[0]?.id ?? null)
}
}, [machines, selectedId])
const osOptions = useMemo(() => {
const set = new Set<string>()
machines.forEach((m) => m.osName && set.add(m.osName))
@ -254,10 +242,8 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
})
}, [machines, q, statusFilter, osFilter, companyFilter, onlyAlerts])
const selectedMachine = useMemo(() => filteredMachines.find((item) => item.id === selectedId) ?? filteredMachines[0] ?? null, [filteredMachines, selectedId])
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,400px)]">
<div className="grid gap-6">
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Máquinas registradas</CardTitle>
@ -310,79 +296,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
{machines.length === 0 ? (
<EmptyState />
) : (
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto rounded-md border border-slate-200">
<Table className="">
<TableHeader className="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<TableRow className="border-slate-200">
<TableHead>Hostname</TableHead>
<TableHead>Status</TableHead>
<TableHead>Último heartbeat</TableHead>
<TableHead>Empresa</TableHead>
<TableHead>Plataforma</TableHead>
<TableHead>Resumo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMachines.map((machine: MachinesQueryItem) => (
<TableRow
key={machine.id}
onClick={() => setSelectedId(machine.id)}
className={cn(
"cursor-pointer transition-colors hover:bg-muted/50",
selectedId === machine.id ? "bg-muted/60" : undefined
)}
>
<TableCell>
<div className="font-medium">{machine.hostname}</div>
<p className="text-xs text-muted-foreground">{machine.authEmail ?? "—"}</p>
</TableCell>
<TableCell className="align-middle">
<div className="flex items-center">
<MachineStatusBadge status={machine.status} />
</div>
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground">
{formatRelativeTime(machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null)}
</p>
</TableCell>
<TableCell>
<p className="text-sm font-medium text-muted-foreground">{machine.companySlug ?? "—"}</p>
</TableCell>
<TableCell>
<p className="text-sm font-medium">
{machine.osName ?? "—"}
{machine.osVersion ? ` ${machine.osVersion}` : ""}
</p>
<p className="text-xs text-muted-foreground">
{machine.architecture ? machine.architecture.toUpperCase() : "—"}
</p>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
{Array.isArray(machine.postureAlerts) && machine.postureAlerts.length > 0 ? (
<Badge className="border-rose-500/20 bg-rose-500/15 text-rose-700">{machine.postureAlerts.length} alertas</Badge>
) : (
<Badge variant="outline" className="text-slate-600">0 alertas</Badge>
)}
{Array.isArray(machine.inventory?.disks) ? (
<Badge variant="outline">{machine.inventory?.disks?.length ?? 0} discos</Badge>
) : null}
{Array.isArray(machine.inventory?.services) ? (
<Badge variant="outline">{machine.inventory?.services?.length ?? 0} serviços</Badge>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<MachinesGrid machines={filteredMachines} />
)}
</CardContent>
</Card>
<MachineDetails machine={selectedMachine ?? null} />
</div>
)
}
@ -410,7 +327,7 @@ type MachineDetailsProps = {
machine: MachinesQueryItem | null
}
function MachineDetails({ machine }: MachineDetailsProps) {
export function MachineDetails({ machine }: MachineDetailsProps) {
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const hardware = metadata?.hardware ?? null
@ -1115,6 +1032,90 @@ function MachineDetails({ machine }: MachineDetailsProps) {
)
}
function MachinesGrid({ machines }: { machines: MachinesQueryItem[] }) {
if (!machines || machines.length === 0) return <EmptyState />
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{machines.map((m) => (
<MachineCard key={m.id} machine={m} />
))}
</div>
)
}
function MachineCard({ machine }: { machine: MachinesQueryItem }) {
const { className } = getStatusVariant(machine.status)
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
const memUsed = Number((machine.metrics as any)?.memoryUsedBytes ?? NaN)
const memTotal = Number((machine.metrics as any)?.memoryTotalBytes ?? NaN)
const memPct = Number((machine.metrics as any)?.memoryUsedPercent ?? (memUsed && memTotal ? (memUsed / memTotal) * 100 : NaN))
const cpuPct = Number((machine.metrics as any)?.cpuUsagePercent ?? NaN)
return (
<Link href={`/admin/machines/${machine.id}`} className="group">
<Card className="relative overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
<span
aria-hidden
className={cn(
"absolute right-2 top-2 size-2 rounded-full border border-white",
className.includes("emerald")
? "bg-emerald-500"
: className.includes("rose")
? "bg-rose-500"
: className.includes("amber")
? "bg-amber-500"
: "bg-slate-400"
)}
/>
<CardHeader className="pb-2">
<CardTitle className="line-clamp-1 flex items-center gap-2 text-base font-semibold">
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
</CardTitle>
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.osName ?? "SO"} {machine.osVersion ?? ""}
</Badge>
{machine.architecture ? (
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.architecture.toUpperCase()}
</Badge>
) : null}
{machine.companySlug ? (
<Badge variant="outline" className="text-xs">{machine.companySlug}</Badge>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
<Cpu className="size-4 text-slate-500" />
<span className="text-xs font-medium text-slate-800">{formatPercent(cpuPct)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
<MemoryStick className="size-4 text-slate-500" />
<span className="text-xs font-medium text-slate-800">
{Number.isFinite(memUsed) && Number.isFinite(memTotal)
? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}`
: formatPercent(memPct)}
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<HardDrive className="size-3.5 text-slate-500" />
{Array.isArray(machine.inventory?.disks) ? `${machine.inventory?.disks?.length ?? 0} discos` : "—"}
</span>
<span>
{lastHeartbeat ? formatRelativeTime(lastHeartbeat) : "sem heartbeat"}
</span>
</div>
</CardContent>
</Card>
</Link>
)
}
function DetailLine({ label, value }: { label: string; value?: string | number | null }) {
if (value === null || value === undefined) return null
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {