From 124bb2a26f902f21b64193412bd8ff958db15adf Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Fri, 10 Oct 2025 10:17:59 -0300 Subject: [PATCH] feat(admin/machines): redesign overview as cards grid with status dot and metrics; add machine detail page (/admin/machines/[id]) reusing existing detail panel --- src/app/admin/machines/[id]/page.tsx | 21 +++ .../machines/admin-machine-details.client.tsx | 28 +++ .../machines/admin-machines-overview.tsx | 177 +++++++++--------- 3 files changed, 138 insertions(+), 88 deletions(-) create mode 100644 src/app/admin/machines/[id]/page.tsx create mode 100644 src/components/admin/machines/admin-machine-details.client.tsx diff --git a/src/app/admin/machines/[id]/page.tsx b/src/app/admin/machines/[id]/page.tsx new file mode 100644 index 0000000..c8f0824 --- /dev/null +++ b/src/app/admin/machines/[id]/page.tsx @@ -0,0 +1,21 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { AdminMachineDetailsClient } from "@/components/admin/machines/admin-machine-details.client" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default function AdminMachineDetailsPage({ params }: { params: { id: string } }) { + const { id } = params + return ( + } + > +
+ +
+
+ ) +} + diff --git a/src/components/admin/machines/admin-machine-details.client.tsx b/src/components/admin/machines/admin-machine-details.client.tsx new file mode 100644 index 0000000..ba5c0af --- /dev/null +++ b/src/components/admin/machines/admin-machine-details.client.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useMemo } from "react" +import { useQuery } from "convex/react" +import { api } from "@/convex/_generated/api" +import { MachineDetails, type MachinesQueryItem } from "@/components/admin/machines/admin-machines-overview" +import { Card, CardContent } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" + +export function AdminMachineDetailsClient({ tenantId, machineId }: { tenantId: string; machineId: string }) { + const list = (useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) ?? []) as MachinesQueryItem[] + const machine = useMemo(() => list.find((m) => m.id === machineId) ?? null, [list, machineId]) + + if (!list) { + return ( + + + + + + + + ) + } + + return +} + diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 1e4c1bc..71254c4 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -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 | 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(null) const [q, setQ] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [osFilter, setOsFilter] = useState("all") const [companyFilter, setCompanyFilter] = useState("all") const [onlyAlerts, setOnlyAlerts] = useState(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() 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 ( -
+
Máquinas registradas @@ -310,79 +296,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { {machines.length === 0 ? ( ) : ( -
- - - - Hostname - Status - Último heartbeat - Empresa - Plataforma - Resumo - - - - {filteredMachines.map((machine: MachinesQueryItem) => ( - setSelectedId(machine.id)} - className={cn( - "cursor-pointer transition-colors hover:bg-muted/50", - selectedId === machine.id ? "bg-muted/60" : undefined - )} - > - -
{machine.hostname}
-

{machine.authEmail ?? "—"}

-
- -
- -
-
- -

- {formatRelativeTime(machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null)} -

-
- -

{machine.companySlug ?? "—"}

-
- -

- {machine.osName ?? "—"} - {machine.osVersion ? ` ${machine.osVersion}` : ""} -

-

- {machine.architecture ? machine.architecture.toUpperCase() : "—"} -

-
- -
- {Array.isArray(machine.postureAlerts) && machine.postureAlerts.length > 0 ? ( - {machine.postureAlerts.length} alertas - ) : ( - 0 alertas - )} - {Array.isArray(machine.inventory?.disks) ? ( - {machine.inventory?.disks?.length ?? 0} discos - ) : null} - {Array.isArray(machine.inventory?.services) ? ( - {machine.inventory?.services?.length ?? 0} serviços - ) : null} -
-
-
- ))} -
-
-
+ )}
- -
) } @@ -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 + return ( +
+ {machines.map((m) => ( + + ))} +
+ ) +} + +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 ( + + + + + + {machine.hostname} + + {machine.authEmail ?? "—"} + + +
+ + {machine.osName ?? "SO"} {machine.osVersion ?? ""} + + {machine.architecture ? ( + + {machine.architecture.toUpperCase()} + + ) : null} + {machine.companySlug ? ( + {machine.companySlug} + ) : null} +
+
+
+ + {formatPercent(cpuPct)} +
+
+ + + {Number.isFinite(memUsed) && Number.isFinite(memTotal) + ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` + : formatPercent(memPct)} + +
+
+
+ + + {Array.isArray(machine.inventory?.disks) ? `${machine.inventory?.disks?.length ?? 0} discos` : "—"} + + + {lastHeartbeat ? formatRelativeTime(lastHeartbeat) : "sem heartbeat"} + +
+
+
+ + ) +} + 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")) {