diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index c1bb2c4..cd3cc26 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -240,6 +240,60 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { } } + // Normalização de software/serviços no topo do inventário + if let Some(obj) = inventory.as_object_mut() { + // Merge software + let mut software: Vec = Vec::new(); + if let Some(existing) = obj.get("software").and_then(|v| v.as_array()) { + software.extend(existing.iter().cloned()); + } + if let Some(ext) = obj.get("extended").and_then(|v| v.as_object()) { + // Windows normalize + if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { + if let Some(ws) = win.get("software").and_then(|v| v.as_array()) { + for item in ws { + let name = item.get("DisplayName").or_else(|| item.get("name")).cloned().unwrap_or(json!(null)); + let version = item.get("DisplayVersion").or_else(|| item.get("version")).cloned().unwrap_or(json!(null)); + let publisher = item.get("Publisher").cloned().unwrap_or(json!(null)); + software.push(json!({ "name": name, "version": version, "source": publisher })); + } + } + } + // macOS normalize + if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) { + if let Some(pkgs) = macos.get("packages").and_then(|v| v.as_array()) { + for p in pkgs { + software.push(json!({ "name": p, "version": null, "source": "pkgutil" })); + } + } + } + } + if !software.is_empty() { + obj.insert("software".into(), json!(software)); + } + + // Merge services + let mut services: Vec = Vec::new(); + if let Some(existing) = obj.get("services").and_then(|v| v.as_array()) { + services.extend(existing.iter().cloned()); + } + if let Some(ext) = obj.get("extended").and_then(|v| v.as_object()) { + if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { + if let Some(wsvc) = win.get("services").and_then(|v| v.as_array()) { + for s in wsvc { + let name = s.get("Name").cloned().unwrap_or(json!(null)); + let status = s.get("Status").cloned().unwrap_or(json!(null)); + let display = s.get("DisplayName").cloned().unwrap_or(json!(null)); + services.push(json!({ "name": name, "status": status, "displayName": display })); + } + } + } + } + if !services.is_empty() { + obj.insert("services".into(), json!(services)); + } + } + json!({ "inventory": inventory }) } @@ -355,6 +409,17 @@ fn collect_linux_extended() -> serde_json::Value { .ok() .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) .unwrap_or_default(); + // Parse básico de lspci/lsusb em listas + fn parse_lines_to_list(input: &str) -> Vec { + input + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|l| json!({ "text": l })) + .collect::>() + } + let pci_list = parse_lines_to_list(&lspci); + let usb_list = parse_lines_to_list(&lsusb); // smartctl (se disponível) por disco let mut smart: Vec = Vec::new(); @@ -393,6 +458,8 @@ fn collect_linux_extended() -> serde_json::Value { "lsblk": block_json, "lspci": lspci, "lsusb": lsusb, + "pciList": pci_list, + "usbList": usb_list, "smart": smart, } }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 34886f4..dfca100 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -56,6 +56,7 @@ type AgentConfig = { createdAt: number lastSyncedAt?: number | null expiresAt?: number | null + heartbeatIntervalSec?: number | null } declare global { @@ -186,7 +187,7 @@ async function startHeartbeat(config: AgentConfig) { baseUrl: config.apiBaseUrl, token, status: "online", - intervalSeconds: 300, + intervalSeconds: Math.max(60, Number(config.heartbeatIntervalSec ?? 300)), }) } @@ -319,7 +320,59 @@ function renderDiagnosticsPanel(profile: MachineProfile) { .map((v) => (v ? v.toFixed(2) : "—")) .join(" / ")} +
+
Histórico (curto)
+ + +

Amostras locais a cada ~3s, mantemos ~60 pontos.

+
` + + const cpuCanvas = panel.querySelector("#diag-cpu") + const memCanvas = panel.querySelector("#diag-mem") + const cpuData: number[] = [] + const memData: number[] = [] + + function draw(canvas: HTMLCanvasElement, series: number[], maxValue: number, color: string) { + const ctx = canvas.getContext("2d") + if (!ctx) return + ctx.clearRect(0, 0, canvas.width, canvas.height) + const w = canvas.width + const h = canvas.height + const n = Math.max(1, series.length) + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.beginPath() + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * (w - 6) + 3 + const v = Math.max(0, Math.min(maxValue, series[i] ?? 0)) + const y = h - 4 - (v / maxValue) * (h - 8) + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.stroke() + } + + let stop = false + async function pump() { + if (stop) return + try { + const p = await collectMachineProfile() + cpuData.push(Math.max(0, Math.min(100, p.metrics.cpuUsagePercent))) + const memPct = (p.metrics.memoryUsedPercent) + memData.push(Math.max(0, Math.min(100, memPct))) + while (cpuData.length > 60) cpuData.shift() + while (memData.length > 60) memData.shift() + if (cpuCanvas) draw(cpuCanvas, cpuData, 100, "#2563eb") + if (memCanvas) draw(memCanvas, memData, 100, "#10b981") + } catch { + // ignore + } finally { + setTimeout(pump, 3000) + } + } + pump() + panel.addEventListener("DOMNodeRemoved", () => { stop = true }) } function renderSettingsPanel(config: AgentConfig) { @@ -339,6 +392,14 @@ function renderSettingsPanel(config: AgentConfig) {
+
+
Intervalo do heartbeat (segundos)
+
+ + +
+

Mínimo 60s. Salvar reinicia o processo de heartbeat.

+
` document.getElementById("open-app-settings")?.addEventListener("click", () => redirectToApp(config)) @@ -374,6 +435,21 @@ function renderSettingsPanel(config: AgentConfig) { setAlert("Falha ao enviar inventário agora.", "error") } }) + + document.getElementById("save-hb-interval")?.addEventListener("click", async () => { + try { + const input = document.getElementById("hb-interval") as HTMLInputElement | null + const value = Math.max(60, Number(input?.value ?? 300)) + const updated: AgentConfig = { ...config, heartbeatIntervalSec: value, lastSyncedAt: Date.now() } + await saveConfig(updated) + await stopHeartbeat().catch(() => undefined) + await startHeartbeat(updated) + setAlert("Intervalo do heartbeat atualizado.", "success") + } catch (error) { + console.error("[agent] Falha ao salvar intervalo", error) + setAlert("Falha ao salvar intervalo do heartbeat.", "error") + } + }) } function formatBytes(bytes: number) { diff --git a/convex/machines.ts b/convex/machines.ts index f809c7f..526dcca 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -175,11 +175,22 @@ async function evaluatePostureAndMaybeRaise( ) { const findings: PostureFinding[] = [] + // Janela temporal de CPU (5 minutos) + const now = Date.now() const metrics = args.metrics ?? (args.metadata?.metrics ?? null) - if (metrics && typeof metrics === "object") { - const usage = Number((metrics as any).cpuUsagePercent ?? (metrics as any).cpu_usage_percent) - if (Number.isFinite(usage) && usage >= 90) { - findings.push({ kind: "CPU_HIGH", message: `CPU acima de ${usage.toFixed(0)}%`, severity: "warning" }) + const metaObj = machine.metadata && typeof machine.metadata === "object" ? (machine.metadata as Record) : {} + const prevWindow: Array<{ ts: number; usage: number }> = Array.isArray((metaObj as any).cpuWindow) + ? (((metaObj as any).cpuWindow as Array).map((p) => ({ ts: Number(p.ts ?? 0), usage: Number(p.usage ?? NaN) })).filter((p) => Number.isFinite(p.ts) && Number.isFinite(p.usage))) + : [] + const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000) + const usage = Number((metrics as any)?.cpuUsagePercent ?? (metrics as any)?.cpu_usage_percent ?? NaN) + if (Number.isFinite(usage)) { + window.push({ ts: now, usage }) + } + if (window.length > 0) { + const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length + if (avg >= 90) { + findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, severity: "warning" }) } } @@ -187,23 +198,37 @@ async function evaluatePostureAndMaybeRaise( if (inventory && typeof inventory === "object") { const services = (inventory as any).services if (Array.isArray(services)) { - const criticalDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? "").toLowerCase() !== "running") - if (criticalDown) { - findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${criticalDown.name}`, severity: "warning" }) + const criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "") + .split(/[\s,]+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + const criticalSet = new Set(criticalList) + const firstDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? s?.Status ?? "").toLowerCase() !== "running") + if (firstDown) { + const name = String(firstDown.name ?? firstDown.Name ?? "serviço") + const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning" + findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev }) } } const smart = (inventory as any).extended?.linux?.smart if (Array.isArray(smart)) { const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false) if (failing) { - findings.push({ kind: "SMART_FAIL", message: `Disco com SMART em falha`, severity: "critical" }) + const model = failing?.model_name ?? failing?.model_family ?? "Disco" + const serial = failing?.serial_number ?? failing?.device?.name ?? "—" + const temp = failing?.temperature?.current ?? failing?.temperature?.value ?? null + const details = temp ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})` + findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" }) } } } + // Persistir janela de CPU (limite de 120 amostras) + const cpuWindowCapped = window.slice(-120) + await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) }) + if (!findings.length) return - const now = Date.now() const record = { postureAlerts: findings, lastPostureAt: now, @@ -213,7 +238,6 @@ async function evaluatePostureAndMaybeRaise( await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return - // Evita excesso: não cria ticket se já houve alerta nos últimos 30 minutos if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return const subject = `Alerta de máquina: ${machine.hostname}` diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 89d7fac..d39bd99 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -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> +} + +type WindowsExtended = { + software?: Array> + services?: Array> + defender?: Record + hotfix?: Array> +} + +type MacExtended = { + systemProfiler?: Record + 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 }) { ) : (
- - - Hostname - Status - Último heartbeat - Empresa - Plataforma - - + + + Hostname + Status + Último heartbeat + Empresa + Plataforma + Resumo + + {filteredMachines.map((machine: MachinesQueryItem) => ( + +
+ {Array.isArray(machine.postureAlerts) && machine.postureAlerts.length > 0 ? ( + {machine.postureAlerts.length} alertas + ) : ( + 0 alertas + )} + {Array.isArray(machine.inventory?.disks) ? ( + {machine.inventory?.disks?.length ?? 0} discos + ) : null} + {Array.isArray((machine.inventory as any)?.services) ? ( + {(machine.inventory as any).services.length} serviços + ) : null} +
+
))}
@@ -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) {
+ {Array.isArray(software) && software.length > 0 ? ( + + ) : null} + {Array.isArray((metadata as any)?.services) && (metadata as any).services.length > 0 ? ( + + ) : null}
{fleet ? (
@@ -857,6 +902,28 @@ function MachineDetails({ machine }: MachineDetailsProps) {
) : null} + + +
+ + + +
+ + + Inventário completo — {machine.hostname} + +
+ setDialogQuery(e.target.value)} /> +
+
')
+                      .replaceAll("__END__", '')
+                    }} />
+                  
+
+
+
)} @@ -902,3 +969,31 @@ function MetricsGrid({ metrics }: { metrics: MachineMetrics }) { ) } + +function exportCsv(items: Array>, filename: string) { + if (!Array.isArray(items) || items.length === 0) return + const headersSet = new Set() + 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) +}