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:
parent
5851bfe366
commit
124bb2a26f
3 changed files with 138 additions and 88 deletions
21
src/app/admin/machines/[id]/page.tsx
Normal file
21
src/app/admin/machines/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<AppShell
|
||||||
|
header={<SiteHeader title="Detalhe da máquina" lead="Inventário e métricas da máquina selecionada." />}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||||
|
<AdminMachineDetailsClient tenantId={DEFAULT_TENANT_ID} machineId={id} />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-3 p-6">
|
||||||
|
<Skeleton className="h-6 w-64" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MachineDetails machine={machine} />
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
type MachineMetrics = Record<string, unknown> | null
|
type MachineMetrics = Record<string, unknown> | null
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ type MachineInventory = {
|
||||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachinesQueryItem = {
|
export type MachinesQueryItem = {
|
||||||
id: string
|
id: string
|
||||||
tenantId: string
|
tenantId: string
|
||||||
hostname: string
|
hostname: string
|
||||||
|
|
@ -200,25 +201,12 @@ function getStatusVariant(status?: string | null) {
|
||||||
|
|
||||||
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||||
const machines = useMachinesQuery(tenantId)
|
const machines = useMachinesQuery(tenantId)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
const [osFilter, setOsFilter] = useState<string>("all")
|
const [osFilter, setOsFilter] = useState<string>("all")
|
||||||
const [companyFilter, setCompanyFilter] = useState<string>("all")
|
const [companyFilter, setCompanyFilter] = useState<string>("all")
|
||||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
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 osOptions = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
machines.forEach((m) => m.osName && set.add(m.osName))
|
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])
|
}, [machines, q, statusFilter, osFilter, companyFilter, onlyAlerts])
|
||||||
|
|
||||||
const selectedMachine = useMemo(() => filteredMachines.find((item) => item.id === selectedId) ?? filteredMachines[0] ?? null, [filteredMachines, selectedId])
|
|
||||||
|
|
||||||
return (
|
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">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Máquinas registradas</CardTitle>
|
<CardTitle>Máquinas registradas</CardTitle>
|
||||||
|
|
@ -310,79 +296,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||||
{machines.length === 0 ? (
|
{machines.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto rounded-md border border-slate-200">
|
<MachinesGrid machines={filteredMachines} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<MachineDetails machine={selectedMachine ?? null} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +327,7 @@ type MachineDetailsProps = {
|
||||||
machine: MachinesQueryItem | null
|
machine: MachinesQueryItem | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function MachineDetails({ machine }: MachineDetailsProps) {
|
export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
const metadata = machine?.inventory ?? null
|
const metadata = machine?.inventory ?? null
|
||||||
const metrics = machine?.metrics ?? null
|
const metrics = machine?.metrics ?? null
|
||||||
const hardware = metadata?.hardware ?? 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 }) {
|
function DetailLine({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
if (value === null || value === undefined) return null
|
if (value === null || value === undefined) return null
|
||||||
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
|
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue