feat: adicionar painel de máquinas e autenticação por agente

This commit is contained in:
Esdras Renan 2025-10-07 21:37:41 -03:00
parent e2a5b560b1
commit ee18619519
52 changed files with 7598 additions and 1 deletions

View file

@ -0,0 +1,543 @@
"use client"
import { useEffect, useMemo, useState } 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 } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
type MachineMetrics = Record<string, unknown> | null
type MachineLabel = {
id?: number | string
name?: string
}
type MachineSoftware = {
name?: string
version?: string
source?: string
}
type MachineInventory = {
hardware?: {
vendor?: string
model?: string
serial?: string
cpuType?: string
physicalCores?: number
logicalCores?: number
memoryBytes?: number
memory?: number
}
network?: {
primaryIp?: string
publicIp?: string
macAddresses?: string[]
}
software?: MachineSoftware[]
labels?: MachineLabel[]
fleet?: {
id?: number | string
teamId?: number | string
detailUpdatedAt?: string
osqueryVersion?: string
}
}
type MachinesQueryItem = {
id: string
tenantId: string
hostname: string
companyId: string | null
companySlug: string | null
osName: string | null
osVersion: string | null
architecture: string | null
macAddresses: string[]
serialNumbers: string[]
authUserId: string | null
authEmail: string | null
status: string | null
lastHeartbeatAt: number | null
heartbeatAgeMs: number | null
registeredBy: string | null
createdAt: number
updatedAt: number
token: {
expiresAt: number
lastUsedAt: number | null
usageCount: number
} | null
metrics: MachineMetrics
inventory: MachineInventory | null
}
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
return (
(useQuery(api.machines.listByTenant, {
tenantId,
includeMetadata: true,
}) ?? []) as MachinesQueryItem[]
)
}
const statusLabels: Record<string, string> = {
online: "Online",
offline: "Offline",
maintenance: "Manutenção",
blocked: "Bloqueada",
unknown: "Desconhecida",
}
const statusClasses: Record<string, string> = {
online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600",
offline: "border-rose-500/20 bg-rose-500/15 text-rose-600",
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600",
unknown: "border-slate-300 bg-slate-200 text-slate-700",
}
function formatRelativeTime(date?: Date | null) {
if (!date) return "Nunca"
try {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
} catch {
return "—"
}
}
function formatDate(date?: Date | null) {
if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm")
}
function formatBytes(bytes?: number | null) {
if (!bytes || Number.isNaN(bytes)) return "—"
const units = ["B", "KB", "MB", "GB", "TB"]
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
function formatPercent(value?: number | null) {
if (value === null || value === undefined || Number.isNaN(value)) return "—"
const normalized = value > 1 ? value : value * 100
return `${normalized.toFixed(0)}%`
}
function getStatusVariant(status?: string | null) {
if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown }
const normalized = status.toLowerCase()
return {
label: statusLabels[normalized] ?? status,
className: statusClasses[normalized] ?? statusClasses.unknown,
}
}
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
const machines = useMachinesQuery(tenantId)
const [selectedId, setSelectedId] = useState<string | null>(null)
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 selectedMachine = useMemo(() => machines.find((item) => item.id === selectedId) ?? null, [machines, selectedId])
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,400px)]">
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Máquinas registradas</CardTitle>
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
</CardHeader>
<CardContent className="overflow-hidden">
{machines.length === 0 ? (
<EmptyState />
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Hostname</TableHead>
<TableHead>Status</TableHead>
<TableHead>Último heartbeat</TableHead>
<TableHead>Empresa</TableHead>
<TableHead>Plataforma</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{machines.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="space-y-1">
<MachineStatusBadge status={machine.status} />
</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<MachineDetails machine={selectedMachine ?? null} />
</div>
)
}
function MachineStatusBadge({ status }: { status?: string | null }) {
const { label, className } = getStatusVariant(status)
return <Badge className={cn("border", className)}>{label}</Badge>
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/50 py-12 text-center">
<ServerCog className="size-10 text-slate-400" />
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-600">Nenhuma máquina registrada ainda</p>
<p className="text-sm text-muted-foreground">
Execute o agente local ou o webhook do Fleet para registrar as máquinas do tenant.
</p>
</div>
</div>
)
}
type MachineDetailsProps = {
machine: MachinesQueryItem | null
}
function MachineDetails({ machine }: MachineDetailsProps) {
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const hardware = metadata?.hardware ?? null
const network = metadata?.network ?? null
const software = metadata?.software ?? null
const labels = metadata?.labels ?? null
const fleet = metadata?.fleet ?? null
const lastHeartbeatDate = machine?.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
const tokenExpiry = machine?.token?.expiresAt ? new Date(machine.token.expiresAt) : null
const tokenLastUsed = machine?.token?.lastUsedAt ? new Date(machine.token.lastUsedAt) : null
const copyEmail = async () => {
if (!machine?.authEmail) return
try {
await navigator.clipboard.writeText(machine.authEmail)
toast.success("E-mail da máquina copiado.")
} catch {
toast.error("Não foi possível copiar o e-mail da máquina.")
}
}
return (
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Detalhes</CardTitle>
<CardDescription>Resumo da máquina selecionada.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!machine ? (
<p className="text-sm text-muted-foreground">Selecione uma máquina para visualizar detalhes.</p>
) : (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">{machine.hostname}</p>
<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">{machine.companySlug}</span>
</p>
) : null}
</div>
<MachineStatusBadge status={machine.status} />
</div>
<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 desconhecido"} {machine.osVersion ?? ""}
</Badge>
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2">
<ClipboardCopy className="size-4" />
Copiar e-mail
</Button>
) : null}
{machine.registeredBy ? (
<Badge variant="outline">Registrada via {machine.registeredBy}</Badge>
) : null}
</div>
</section>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex justify-between gap-4">
<span>Último heartbeat</span>
<span className="text-right font-medium text-foreground">
{formatRelativeTime(lastHeartbeatDate)}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Criada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(machine.createdAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Atualizada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(machine.updatedAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Token expira</span>
<span className="text-right font-medium text-foreground">
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Token usado por último</span>
<span className="text-right font-medium text-foreground">
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Uso do token</span>
<span className="text-right font-medium text-foreground">{machine.token?.usageCount ?? 0} trocas</span>
</div>
</div>
</section>
{metrics && typeof metrics === "object" ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
<MetricsGrid metrics={metrics} />
</section>
) : null}
{hardware || network || (labels && labels.length > 0) ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário</h4>
<p className="text-xs text-muted-foreground">
Dados sincronizados via agente ou Fleet.
</p>
</div>
<div className="space-y-3 text-sm text-muted-foreground">
{hardware ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Hardware</p>
<div className="mt-2 grid gap-1">
<DetailLine label="Fabricante" value={hardware.vendor} />
<DetailLine label="Modelo" value={hardware.model} />
<DetailLine label="Número de série" value={hardware.serial} />
<DetailLine label="CPU" value={hardware.cpuType} />
<DetailLine
label="Núcleos"
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
/>
<DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} />
</div>
</div>
) : null}
{network ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Rede</p>
<div className="mt-2 grid gap-1">
<DetailLine label="IP primário" value={network.primaryIp} />
<DetailLine label="IP público" value={network.publicIp} />
<DetailLine
label="MAC addresses"
value={
Array.isArray(network.macAddresses)
? (network.macAddresses as string[]).join(", ")
: machine?.macAddresses.join(", ")
}
/>
</div>
</div>
) : null}
{labels && labels.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Labels</p>
<div className="mt-2 flex flex-wrap gap-2">
{labels.slice(0, 12).map((label, index) => (
<Badge key={String(label.id ?? `${label.name ?? "label"}-${index}`)} variant="outline">
{label.name ?? `Label ${index + 1}`}
</Badge>
))}
{labels.length > 12 ? (
<Badge variant="outline">+{labels.length - 12} outras</Badge>
) : null}
</div>
</div>
) : null}
</div>
</section>
) : null}
{fleet ? (
<section className="space-y-2 text-sm text-muted-foreground">
<Separator />
<div className="flex items-center justify-between">
<span>ID Fleet</span>
<span className="font-medium text-foreground">{fleet.id ?? "—"}</span>
</div>
<div className="flex items-center justify-between">
<span>Team ID</span>
<span className="font-medium text-foreground">{fleet.teamId ?? "—"}</span>
</div>
<div className="flex items-center justify-between">
<span>Detalhes atualizados</span>
<span className="font-medium text-foreground">
{fleet.detailUpdatedAt ? formatDate(new Date(String(fleet.detailUpdatedAt))) : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Versão osquery</span>
<span className="font-medium text-foreground">{fleet.osqueryVersion ?? "—"}</span>
</div>
</section>
) : null}
{software && software.length > 0 ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Softwares detectados</h4>
<div className="rounded-md border border-slate-200 bg-slate-50/60">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Versão</TableHead>
<TableHead className="text-xs text-slate-500">Fonte</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{software.slice(0, 6).map((item, index) => (
<TableRow key={`${item.name ?? "software"}-${index}`} className="border-slate-100">
<TableCell className="text-sm text-foreground">{item.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item.version ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item.source ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{software.length > 6 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
+{software.length - 6} softwares adicionais sincronizados via Fleet.
</p>
) : null}
</div>
</section>
) : null}
</div>
)}
</CardContent>
</Card>
)
}
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")) {
return null
}
return (
<div className="flex items-center justify-between gap-4">
<span>{label}</span>
<span className="text-right font-medium text-foreground">{value}</span>
</div>
)
}
function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
const data = (metrics ?? {}) as Record<string, unknown>
const cpu = Number(data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? NaN)
const memory = Number(data.memoryBytes ?? data.memory ?? data.memory_used ?? NaN)
const disk = Number(data.diskUsage ?? data.disk ?? NaN)
return (
<div className="grid gap-2 rounded-md border border-slate-200 bg-slate-50/60 p-3 text-sm text-muted-foreground sm:grid-cols-3">
<div>
<p className="text-xs uppercase text-slate-500">CPU</p>
<p className="text-sm font-semibold text-foreground">{formatPercent(cpu)}</p>
</div>
<div>
<p className="text-xs uppercase text-slate-500">Memória</p>
<p className="text-sm font-semibold text-foreground">{formatBytes(memory)}</p>
</div>
<div>
<p className="text-xs uppercase text-slate-500">Disco</p>
<p className="text-sm font-semibold text-foreground">
{Number.isNaN(disk) ? "—" : `${formatPercent(disk)}`}
</p>
</div>
</div>
)
}

View file

@ -11,7 +11,8 @@ import {
PanelsTopLeft,
Users,
Waypoints,
Timer,
Timer,
MonitorCog,
Layers3,
UserPlus,
} from "lucide-react"
@ -90,6 +91,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
{ title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" },
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
{ title: "Alertas enviados", url: "/admin/alerts", icon: Gauge, requiredRole: "admin" },