feat: melhorar inventário e gestão de máquinas

This commit is contained in:
Esdras Renan 2025-10-10 23:20:21 -03:00
parent b1d334045d
commit 3f0702d80b
5 changed files with 584 additions and 59 deletions

View file

@ -26,6 +26,7 @@ import {
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
@ -102,6 +103,8 @@ type MachineInventory = {
logicalCores?: number
memoryBytes?: number
memory?: number
primaryGpu?: { name?: string; memoryBytes?: number; driver?: string; vendor?: string }
gpus?: Array<{ name?: string; memoryBytes?: number; driver?: string; vendor?: string }>
}
network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | Array<{ name?: string; mac?: string; ip?: string }>
software?: MachineSoftware[]
@ -113,9 +116,10 @@ type MachineInventory = {
osqueryVersion?: string
}
// Dados enviados pelo agente desktop (inventário básico/estendido)
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }>
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }>
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
services?: Array<{ name?: string; status?: string; displayName?: string }>
collaborator?: { email?: string; name?: string }
}
export type MachinesQueryItem = {
@ -354,7 +358,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
{machines.length === 0 ? (
<EmptyState />
) : (
<MachinesGrid machines={filteredMachines} />
<MachinesGrid machines={filteredMachines} companyNameBySlug={companyNameBySlug} />
)}
</CardContent>
</Card>
@ -420,6 +424,7 @@ type MachineDetailsProps = {
export function MachineDetails({ machine }: MachineDetailsProps) {
const { convexUserId } = useAuth()
const router = useRouter()
// Company name lookup (by slug)
const companies = useQuery(
convexUserId && machine ? api.companies.list : "skip",
@ -437,6 +442,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null
const hardwareGpus = Array.isArray((hardware as any)?.gpus)
? (((hardware as any)?.gpus as Array<Record<string, unknown>>) ?? [])
: []
const primaryGpu = (hardware as any)?.primaryGpu as Record<string, unknown> | undefined
type WinCpuInfo = {
Name?: string
@ -502,6 +511,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("")
const [deleteDialog, setDeleteDialog] = useState(false)
const [deleting, setDeleting] = useState(false)
const jsonText = useMemo(() => {
const payload = {
id: machine?.id,
@ -551,7 +562,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</p>
{machine.companySlug ? (
<p className="text-xs text-muted-foreground">
Empresa vinculada: <span className="font-medium text-foreground">{machine.companySlug}</span>
Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
</p>
) : null}
</div>
@ -707,6 +718,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
/>
<DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} />
{hardwareGpus.length > 0 ? (
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<p className="font-semibold uppercase text-slate-500">GPUs</p>
<ul className="space-y-1">
{hardwareGpus.slice(0, 3).map((gpu, idx) => {
const gpuObj = gpu as Record<string, unknown>
const name =
typeof gpuObj?.["name"] === "string"
? (gpuObj["name"] as string)
: typeof gpuObj?.["Name"] === "string"
? (gpuObj["Name"] as string)
: undefined
const memoryBytes = parseNumberLike(gpuObj?.["memoryBytes"] ?? gpuObj?.["AdapterRAM"])
const driver =
typeof gpuObj?.["driver"] === "string"
? (gpuObj["driver"] as string)
: typeof gpuObj?.["DriverVersion"] === "string"
? (gpuObj["DriverVersion"] as string)
: undefined
const vendor = typeof gpuObj?.["vendor"] === "string" ? (gpuObj["vendor"] as string) : undefined
return (
<li key={`gpu-${idx}`}>
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
{memoryBytes ? <span className="ml-1 text-muted-foreground">{formatBytes(memoryBytes)}</span> : null}
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
</li>
)
})}
{hardwareGpus.length > 3 ? (
<li className="text-muted-foreground">+{hardwareGpus.length - 3} adaptadores adicionais</li>
) : null}
</ul>
</div>
) : null}
</div>
</div>
) : null}
@ -1215,6 +1261,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</section>
) : null}
{machine ? (
<section className="space-y-2 rounded-md border border-rose-200 bg-rose-50/60 p-3">
<div className="space-y-1">
<h4 className="text-sm font-semibold text-rose-700">Zona perigosa</h4>
<p className="text-xs text-rose-600">
Excluir a máquina revoga o token atual e remove os dados de inventário sincronizados.
</p>
</div>
<Button variant="destructive" size="sm" onClick={() => setDeleteDialog(true)}>Excluir máquina</Button>
</section>
) : null}
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<div className="flex justify-end">
<DialogTrigger asChild>
@ -1236,6 +1294,47 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir máquina</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>Tem certeza que deseja excluir <span className="font-semibold text-foreground">{machine?.hostname}</span>? Esta ação não pode ser desfeita.</p>
<p>Os tokens ativos serão revogados e o inventário deixará de aparecer no painel.</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteDialog(false)} disabled={deleting}>Cancelar</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={async () => {
if (!machine) return
setDeleting(true)
try {
const res = await fetch("/api/admin/machines/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
toast.success("Máquina excluída")
setDeleteDialog(false)
router.push("/admin/machines")
} catch (err) {
console.error(err)
toast.error("Falha ao excluir máquina")
} finally {
setDeleting(false)
}
}}
>
{deleting ? "Excluindo..." : "Excluir máquina"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
</CardContent>
@ -1243,18 +1342,22 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
)
}
function MachinesGrid({ machines }: { machines: MachinesQueryItem[] }) {
function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQueryItem[]; companyNameBySlug: Map<string, string> }) {
if (!machines || machines.length === 0) return <EmptyState />
return (
<div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{machines.map((m) => (
<MachineCard key={m.id} machine={m} />
<MachineCard
key={m.id}
machine={m}
companyName={m.companySlug ? companyNameBySlug.get(m.companySlug) ?? m.companySlug : null}
/>
))}
</div>
)
}
function MachineCard({ machine }: { machine: MachinesQueryItem }) {
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
const { className } = getStatusVariant(machine.status)
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
type AgentMetrics = {
@ -1268,6 +1371,18 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
const memTotal = mm?.memoryTotalBytes ?? NaN
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
const cpuPct = mm?.cpuUsagePercent ?? NaN
const collaborator = (() => {
const inv = machine.inventory as unknown
if (!inv || typeof inv !== "object") return null
const raw = (inv as Record<string, unknown>).collaborator
if (!raw || typeof raw !== "object") return null
const obj = raw as Record<string, unknown>
const email = typeof obj.email === "string" ? obj.email : undefined
const name = typeof obj.name === "string" ? obj.name : undefined
if (!email) return null
return { email, name }
})()
const companyLabel = companyName ?? machine.companySlug ?? null
return (
<Link href={`/admin/machines/${machine.id}`} className="group">
@ -1306,10 +1421,16 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
{machine.architecture.toUpperCase()}
</Badge>
) : null}
{machine.companySlug ? (
<Badge variant="outline" className="text-xs">{machine.companySlug}</Badge>
{companyLabel ? (
<Badge variant="outline" className="text-xs">{companyLabel}</Badge>
) : null}
</div>
{collaborator?.email ? (
<p className="text-[11px] text-muted-foreground">
{collaborator.name ? `${collaborator.name} · ` : ""}
{collaborator.email}
</p>
) : null}
<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" />
@ -1339,6 +1460,17 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
)
}
function parseNumberLike(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return null
const numeric = Number(trimmed.replace(/[^0-9.]/g, ""))
if (Number.isFinite(numeric)) return numeric
}
return null
}
function DetailLine({ label, value, classNameValue }: DetailLineProps) {
if (value === null || value === undefined) return null
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {