feat(desktop-agent,admin/inventory): secure token storage via keyring; extended inventory collectors per OS; new /api/machines/inventory endpoint; posture rules + tickets; Admin UI inventory with filters, search and export; docs + CI desktop release

This commit is contained in:
Esdras Renan 2025-10-09 22:08:20 -03:00
parent c2050f311a
commit 479c66d52c
18 changed files with 1205 additions and 38 deletions

View file

@ -10,6 +10,9 @@ 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 { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
@ -59,6 +62,28 @@ type MachineInventory = {
detailUpdatedAt?: string
osqueryVersion?: string
}
// Dados enviados pelo agente desktop (inventário básico/estendido)
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }>
network?: any // pode ser objeto (Fleet) ou array de interfaces (agente desktop)
extended?: {
linux?: {
lsblk?: any
lspci?: string
lsusb?: string
smart?: any[]
}
windows?: {
software?: any
services?: any
defender?: any
hotfix?: any
}
macos?: {
systemProfiler?: any
packages?: string[]
launchctl?: string
}
}
}
type MachinesQueryItem = {
@ -87,6 +112,8 @@ type MachinesQueryItem = {
} | null
metrics: MachineMetrics
inventory: MachineInventory | null
postureAlerts?: Array<Record<string, unknown>> | null
lastPostureAt?: number | null
}
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
@ -158,6 +185,11 @@ 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) {
@ -171,7 +203,42 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
}
}, [machines, selectedId])
const selectedMachine = useMemo(() => machines.find((item) => item.id === selectedId) ?? null, [machines, selectedId])
const osOptions = useMemo(() => {
const set = new Set<string>()
machines.forEach((m) => m.osName && set.add(m.osName))
return Array.from(set).sort()
}, [machines])
const companyOptions = useMemo(() => {
const set = new Set<string>()
machines.forEach((m) => m.companySlug && set.add(m.companySlug))
return Array.from(set).sort()
}, [machines])
const filteredMachines = useMemo(() => {
const text = q.trim().toLowerCase()
return machines.filter((m) => {
if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false
if (statusFilter !== "all") {
const s = (m.status ?? "unknown").toLowerCase()
if (s !== statusFilter) return false
}
if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) return false
if (companyFilter !== "all" && (m.companySlug ?? "") !== companyFilter) return false
if (!text) return true
const hay = [
m.hostname,
m.authEmail ?? "",
(m.macAddresses ?? []).join(" "),
(m.serialNumbers ?? []).join(" "),
]
.join(" ")
.toLowerCase()
return hay.includes(text)
})
}, [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)]">
@ -181,6 +248,49 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
</CardHeader>
<CardContent className="overflow-hidden">
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-[220px] flex-1">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar hostname, e-mail, MAC, serial..." />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
<Select value={osFilter} onValueChange={setOsFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Sistema" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos sistemas</SelectItem>
{osOptions.map((os) => (
<SelectItem key={os} value={os}>{os}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={companyFilter} onValueChange={setCompanyFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas empresas</SelectItem>
{companyOptions.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
<label className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-1.5 text-sm">
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</label>
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setOsFilter("all"); setCompanyFilter("all"); setOnlyAlerts(false) }}>Limpar</Button>
</div>
{machines.length === 0 ? (
<EmptyState />
) : (
@ -196,7 +306,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
</TableRow>
</TableHeader>
<TableBody>
{machines.map((machine: MachinesQueryItem) => (
{filteredMachines.map((machine: MachinesQueryItem) => (
<TableRow
key={machine.id}
onClick={() => setSelectedId(machine.id)}
@ -276,6 +386,11 @@ function MachineDetails({ machine }: MachineDetailsProps) {
const software = metadata?.software ?? null
const labels = metadata?.labels ?? null
const fleet = metadata?.fleet ?? null
const disks = Array.isArray(metadata?.disks) ? metadata?.disks ?? [] : []
const extended = (metadata as any)?.extended ?? null
const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null
const lastHeartbeatDate = machine?.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
const tokenExpiry = machine?.token?.expiresAt ? new Date(machine.token.expiresAt) : null
@ -291,6 +406,49 @@ function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const exportInventoryJson = () => {
if (!machine) return
const payload = {
id: machine.id,
hostname: machine.hostname,
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metrics,
inventory: metadata,
postureAlerts: machine.postureAlerts ?? null,
lastPostureAt: machine.lastPostureAt ?? null,
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `inventario-${machine.hostname}.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const copyInventoryJson = async () => {
if (!machine) return
const payload = {
id: machine.id,
hostname: machine.hostname,
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metrics,
inventory: metadata,
postureAlerts: machine.postureAlerts ?? null,
lastPostureAt: machine.lastPostureAt ?? null,
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2))
toast.success("Inventário copiado para a área de transferência.")
} catch {
toast.error("Não foi possível copiar o inventário.")
}
}
return (
<Card className="border-slate-200">
<CardHeader>
@ -375,11 +533,11 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</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}
<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">
@ -407,7 +565,31 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</div>
) : null}
{network ? (
{Array.isArray(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 (interfaces)</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Interface</TableHead>
<TableHead className="text-xs text-slate-500">MAC</TableHead>
<TableHead className="text-xs text-slate-500">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(network as any[]).map((iface, idx) => (
<TableRow key={`iface-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{iface?.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.mac ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.ip ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : 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">
@ -444,6 +626,183 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</section>
) : null}
{/* Discos (agente) */}
{disks.length > 0 ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Discos e partições</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">Mount</TableHead>
<TableHead className="text-xs text-slate-500">FS</TableHead>
<TableHead className="text-xs text-slate-500">Capacidade</TableHead>
<TableHead className="text-xs text-slate-500">Livre</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{disks.map((d, idx) => (
<TableRow key={`disk-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{d.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.mountPoint ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.fs ?? "—"}</TableCell>
<TableCell className="text-sm text-foreground">{formatBytes(Number(d.totalBytes))}</TableCell>
<TableCell className="text-sm text-muted-foreground">{formatBytes(Number(d.availableBytes))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : null}
{/* Inventário estendido por SO */}
{extended ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário estendido</h4>
<p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p>
</div>
{/* Linux */}
{linuxExt ? (
<div className="space-y-3">
{Array.isArray(linuxExt.smart) && linuxExt.smart.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-emerald-50/40 p-3 dark:bg-emerald-900/10">
<p className="text-xs font-semibold uppercase text-slate-500">SMART</p>
<div className="mt-2 grid gap-2">
{linuxExt.smart.map((s: any, idx: number) => {
const ok = s?.smart_status?.passed !== false
const model = s?.model_name ?? s?.model_family ?? "Disco"
const serial = s?.serial_number ?? s?.device?.name ?? "—"
return (
<div key={`smart-${idx}`} className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
style={{ borderColor: ok ? "rgba(16,185,129,0.3)" : "rgba(244,63,94,0.35)", backgroundColor: ok ? "rgba(16,185,129,0.08)" : "rgba(244,63,94,0.06)" }}>
<span className="font-medium text-foreground">{model} <span className="text-muted-foreground">({serial})</span></span>
<Badge className={cn("border", ok ? "border-emerald-500/20 bg-emerald-500/15 text-emerald-700" : "border-rose-500/20 bg-rose-500/15 text-rose-700")}>{ok ? "OK" : "ALERTA"}</Badge>
</div>
)
})}
</div>
</div>
) : null}
{linuxExt.lspci ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">PCI</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lspci}</pre>
</div>
) : null}
{linuxExt.lsusb ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">USB</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lsusb}</pre>
</div>
) : null}
</div>
) : null}
{/* Windows */}
{windowsExt ? (
<div className="space-y-3">
{Array.isArray(windowsExt.services) ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Serviços</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<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">Exibição</TableHead>
<TableHead className="text-xs text-slate-500">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(windowsExt.services as any[]).slice(0, 10).map((svc: any, i: number) => (
<TableRow key={`svc-${i}`} className="border-slate-100">
<TableCell className="text-sm">{svc?.Name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{svc?.DisplayName ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{svc?.Status ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
{Array.isArray(windowsExt.software) ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Softwares (amostra)</p>
<ul className="mt-2 grid gap-1 text-xs text-muted-foreground">
{(windowsExt.software as any[]).slice(0, 8).map((s: any, i: number) => (
<li key={`sw-${i}`}>
<span className="font-medium text-foreground">{s?.DisplayName ?? s?.name ?? "—"}</span>
{s?.DisplayVersion ? <span className="ml-1">{s.DisplayVersion}</span> : null}
{s?.Publisher ? <span className="ml-1">· {s.Publisher}</span> : null}
</li>
))}
</ul>
</div>
) : null}
{windowsExt.defender ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Defender</p>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<DetailLine label="Antivirus" value={String(windowsExt.defender?.AntivirusEnabled ?? "—")} />
<DetailLine label="Tempo real" value={String(windowsExt.defender?.RealTimeProtectionEnabled ?? "—")} />
</div>
</div>
) : null}
</div>
) : null}
{/* macOS */}
{macosExt ? (
<div className="space-y-3">
{Array.isArray(macosExt.packages) && macosExt.packages.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">Pacotes</p>
<p className="mt-1 text-xs text-muted-foreground">{macosExt.packages.slice(0, 8).join(", ")}</p>
</div>
) : null}
{macosExt.launchctl ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Launchctl</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{macosExt.launchctl}</pre>
</div>
) : null}
</div>
) : null}
</section>
) : null}
{/* Postura/Alertas */}
{Array.isArray(machine?.postureAlerts) && machine?.postureAlerts?.length ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Alertas de postura</h4>
<div className="space-y-2">
{machine?.postureAlerts?.map((a: any, i: number) => (
<div key={`alert-${i}`} className={cn("flex items-center justify-between rounded-md border px-3 py-2 text-sm",
(a?.severity ?? "warning").toLowerCase() === "critical" ? "border-rose-500/20 bg-rose-500/10" : "border-amber-500/20 bg-amber-500/10")
}>
<span className="font-medium text-foreground">{a?.message ?? a?.kind ?? "Alerta"}</span>
<Badge variant="outline">{String(a?.kind ?? "ALERTA")}</Badge>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Última avaliação: {machine?.lastPostureAt ? formatRelativeTime(new Date(machine.lastPostureAt)) : "—"}
</p>
</section>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" onClick={copyInventoryJson}>Copiar JSON</Button>
<Button size="sm" onClick={exportInventoryJson}>Exportar JSON</Button>
</div>
{fleet ? (
<section className="space-y-2 text-sm text-muted-foreground">
<Separator />