feat(admin/ui): filters + badges + full inventory dialog with search; CSV export; types tightened; feat(desktop): charts in diagnostics and heartbeat interval settings; feat(agent): normalized software/services; linux lspci/lsusb parsed

This commit is contained in:
Esdras Renan 2025-10-09 22:29:59 -03:00
parent e682c6773a
commit 0556502685
4 changed files with 308 additions and 46 deletions

View file

@ -14,6 +14,7 @@ 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import {
Table,
TableBody,
@ -38,6 +39,28 @@ type MachineSoftware = {
source?: string
}
type LinuxExtended = {
lsblk?: unknown
lspci?: string
lsusb?: string
pciList?: Array<{ text: string }>
usbList?: Array<{ text: string }>
smart?: Array<Record<string, unknown>>
}
type WindowsExtended = {
software?: Array<Record<string, unknown>>
services?: Array<Record<string, unknown>>
defender?: Record<string, unknown>
hotfix?: Array<Record<string, unknown>>
}
type MacExtended = {
systemProfiler?: Record<string, unknown>
packages?: string[]
launchctl?: string
}
type MachineInventory = {
hardware?: {
vendor?: string
@ -49,11 +72,7 @@ type MachineInventory = {
memoryBytes?: number
memory?: number
}
network?: {
primaryIp?: string
publicIp?: string
macAddresses?: string[]
}
network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | Array<{ name?: string; mac?: string; ip?: string }>
software?: MachineSoftware[]
labels?: MachineLabel[]
fleet?: {
@ -64,26 +83,7 @@ type MachineInventory = {
}
// 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
}
}
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
}
type MachinesQueryItem = {
@ -296,15 +296,16 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
) : (
<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>
</TableRow>
</TableHeader>
<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
@ -341,6 +342,21 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
{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 as any)?.services) ? (
<Badge variant="outline">{(machine.inventory as any).services.length} serviços</Badge>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@ -387,7 +403,7 @@ function MachineDetails({ machine }: MachineDetailsProps) {
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 extended = metadata?.extended ?? null
const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null
@ -406,6 +422,29 @@ function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("")
const jsonText = useMemo(() => {
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,
}
return JSON.stringify(payload, null, 2)
}, [machine, metrics, metadata])
const filteredJsonHtml = useMemo(() => {
if (!dialogQuery.trim()) return jsonText
const q = dialogQuery.trim().toLowerCase()
// highlight simples
return jsonText.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `__HIGHLIGHT__${m}__END__`)
}, [jsonText, dialogQuery])
const exportInventoryJson = () => {
if (!machine) return
const payload = {
@ -802,6 +841,12 @@ function MachineDetails({ machine }: MachineDetailsProps) {
<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>
{Array.isArray(software) && software.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button>
) : null}
{Array.isArray((metadata as any)?.services) && (metadata as any).services.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv((metadata as any).services, "servicos.csv")}>Serviços CSV</Button>
) : null}
</div>
{fleet ? (
<section className="space-y-2 text-sm text-muted-foreground">
@ -857,6 +902,28 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</div>
</section>
) : null}
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<div className="flex justify-end">
<DialogTrigger asChild>
<Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button>
</DialogTrigger>
</div>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Inventário completo {machine.hostname}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Input placeholder="Buscar no JSON" value={dialogQuery} onChange={(e) => setDialogQuery(e.target.value)} />
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs">
<pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml
.replaceAll("__HIGHLIGHT__", '<mark class="bg-yellow-200 text-foreground">')
.replaceAll("__END__", '</mark>')
}} />
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
</CardContent>
@ -902,3 +969,31 @@ function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
</div>
)
}
function exportCsv(items: Array<Record<string, unknown>>, filename: string) {
if (!Array.isArray(items) || items.length === 0) return
const headersSet = new Set<string>()
items.forEach((it) => Object.keys(it ?? {}).forEach((k) => headersSet.add(k)))
const headers = Array.from(headersSet)
const csv = [headers.join(",")]
for (const it of items) {
const row = headers
.map((h) => {
const v = (it as any)?.[h]
if (v === undefined || v === null) return ""
const s = typeof v === "string" ? v : JSON.stringify(v)
return `"${s.replace(/"/g, '""')}"`
})
.join(",")
csv.push(row)
}
const blob = new Blob([csv.join("\n")], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}