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:
parent
e682c6773a
commit
0556502685
4 changed files with 308 additions and 46 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue