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
|
|
@ -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")) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue