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

@ -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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> {
input
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| json!({ "text": l }))
.collect::<Vec<_>>()
}
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<serde_json::Value> = 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,
}
})

View file

@ -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(" / ")}</div>
</div>
<div class="machine-summary">
<div><strong>Histórico (curto)</strong></div>
<canvas id="diag-cpu" width="520" height="100" style="background:rgba(148,163,184,0.15); border-radius:8px;"></canvas>
<canvas id="diag-mem" width="520" height="100" style="background:rgba(148,163,184,0.15); border-radius:8px;"></canvas>
<p class="text-xs">Amostras locais a cada ~3s, mantemos ~60 pontos.</p>
</div>
`
const cpuCanvas = panel.querySelector<HTMLCanvasElement>("#diag-cpu")
const memCanvas = panel.querySelector<HTMLCanvasElement>("#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) {
<div class="actions">
<button id="reset-agent-settings" class="secondary">Reprovisionar</button>
</div>
<div class="machine-summary">
<div><strong>Intervalo do heartbeat (segundos)</strong></div>
<div style="display:flex; gap:8px; align-items:center;">
<input id="hb-interval" type="number" min="60" step="30" value="${String(config.heartbeatIntervalSec ?? 300)}" style="max-width:140px;" />
<button id="save-hb-interval">Salvar</button>
</div>
<p class="text-xs">Mínimo 60s. Salvar reinicia o processo de heartbeat.</p>
</div>
`
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) {