From 3f0702d80bd7f515911ceb5f719410a2e4ad3869 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Fri, 10 Oct 2025 23:20:21 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20melhorar=20invent=C3=A1rio=20e=20gest?= =?UTF-8?q?=C3=A3o=20de=20m=C3=A1quinas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src-tauri/src/agent.rs | 292 ++++++++++++++++-- apps/desktop/src/main.tsx | 41 ++- convex/machines.ts | 102 +++++- src/app/api/admin/machines/delete/route.ts | 60 ++++ .../machines/admin-machines-overview.tsx | 148 ++++++++- 5 files changed, 584 insertions(+), 59 deletions(-) create mode 100644 src/app/api/admin/machines/delete/route.ts diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index 9fc0e0b..efe8139 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -6,8 +6,8 @@ use once_cell::sync::Lazy; use parking_lot::Mutex; use serde::Serialize; use serde_json::json; -use sysinfo::{Networks, System}; use std::collections::HashMap; +use sysinfo::{DiskExt, Networks, System, SystemExt}; use tauri::async_runtime::{self, JoinHandle}; use tokio::sync::Notify; @@ -166,27 +166,90 @@ fn collect_network_addrs() -> Vec { entries } -fn collect_disks(_system: &System) -> Vec { - // API de discos mudou no sysinfo 0.31: usamos Disks diretamente +fn collect_disks(system: &System) -> Vec { let mut out = Vec::new(); - let disks = sysinfo::Disks::new_with_refreshed_list(); - for d in disks.list() { - let name = d.name().to_string_lossy().to_string(); - let mount = d.mount_point().to_string_lossy().to_string(); - let fs = d.file_system().to_string_lossy().to_string(); - let total = d.total_space(); - let avail = d.available_space(); + for disk in system.disks() { + let name = disk.name().to_string_lossy().to_string(); + let mount = disk.mount_point().to_string_lossy().to_string(); + let fs = String::from_utf8_lossy(disk.file_system()).to_string(); + let total = disk.total_space(); + let avail = disk.available_space(); out.push(json!({ - "name": name, + "name": if name.is_empty() { mount.clone() } else { name }, "mountPoint": mount, "fs": fs, "totalBytes": total, "availableBytes": avail, })); } + + if out.is_empty() { + let disks = sysinfo::Disks::new_with_refreshed_list(); + for d in disks.list() { + let name = d.name().to_string_lossy().to_string(); + let mount = d.mount_point().to_string_lossy().to_string(); + let fs = String::from_utf8_lossy(d.file_system()).to_string(); + let total = d.total_space(); + let avail = d.available_space(); + out.push(json!({ + "name": if name.is_empty() { mount.clone() } else { name }, + "mountPoint": mount, + "fs": fs, + "totalBytes": total, + "availableBytes": avail, + })); + } + } out } +fn parse_u64(value: &serde_json::Value) -> Option { + if let Some(num) = value.as_u64() { + return Some(num); + } + if let Some(num) = value.as_f64() { + if num.is_finite() && num >= 0.0 { + return Some(num as u64); + } + } + if let Some(text) = value.as_str() { + if let Ok(parsed) = text.trim().parse::() { + return Some(parsed); + } + } + None +} + +fn push_gpu( + list: &mut Vec, + name: Option<&str>, + memory: Option, + vendor: Option<&str>, + driver: Option<&str>, +) { + if let Some(name) = name { + if name.trim().is_empty() { + return; + } + let mut obj = serde_json::Map::new(); + obj.insert("name".into(), json!(name.trim())); + if let Some(memory) = memory { + obj.insert("memoryBytes".into(), json!(memory)); + } + if let Some(vendor) = vendor { + if !vendor.trim().is_empty() { + obj.insert("vendor".into(), json!(vendor.trim())); + } + } + if let Some(driver) = driver { + if !driver.trim().is_empty() { + obj.insert("driver".into(), json!(driver.trim())); + } + } + list.push(serde_json::Value::Object(obj)); + } +} + fn build_inventory_metadata(system: &System) -> serde_json::Value { let cpu_brand = system .cpus() @@ -204,6 +267,26 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { "disks": disks, }); + if let Some(obj) = inventory.as_object_mut() { + let mut hardware = serde_json::Map::new(); + if let Some(brand) = cpu_brand.clone() { + if !brand.trim().is_empty() { + hardware.insert("cpuType".into(), json!(brand.trim())); + } + } + if let Some(physical) = system.physical_core_count() { + hardware.insert("physicalCores".into(), json!(physical)); + } + hardware.insert("logicalCores".into(), json!(system.cpus().len())); + if mem_total_bytes > 0 { + hardware.insert("memoryBytes".into(), json!(mem_total_bytes)); + hardware.insert("memory".into(), json!(mem_total_bytes)); + } + if !hardware.is_empty() { + obj.insert("hardware".into(), serde_json::Value::Object(hardware)); + } + } + #[cfg(target_os = "linux")] { // Softwares instalados (dpkg ou rpm) @@ -253,10 +336,19 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { 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 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 })); + software + .push(json!({ "name": name, "version": version, "source": publisher })); } } } @@ -285,7 +377,9 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { 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 })); + services.push( + json!({ "name": name, "status": status, "displayName": display }), + ); } } } @@ -293,6 +387,106 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { if !services.is_empty() { obj.insert("services".into(), json!(services)); } + + let mut gpus: Vec = Vec::new(); + 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(video) = win.get("videoControllers").and_then(|v| v.as_array()) { + for controller in video { + let name = controller.get("Name").and_then(|v| v.as_str()); + let memory = controller.get("AdapterRAM").and_then(parse_u64); + let driver = controller.get("DriverVersion").and_then(|v| v.as_str()); + push_gpu(&mut gpus, name, memory, None, driver); + } + } + if obj + .get("disks") + .and_then(|v| v.as_array()) + .map(|arr| arr.is_empty()) + .unwrap_or(true) + { + if let Some(raw) = win.get("disks").and_then(|v| v.as_array()) { + let mapped = raw + .iter() + .map(|disk| { + let model = disk.get("Model").and_then(|v| v.as_str()).unwrap_or_default(); + let serial = disk.get("SerialNumber").and_then(|v| v.as_str()).unwrap_or_default(); + let size = parse_u64(disk.get("Size").unwrap_or(&serde_json::Value::Null)).unwrap_or(0); + let interface = disk.get("InterfaceType").and_then(|v| v.as_str()).unwrap_or(""); + let media = disk.get("MediaType").and_then(|v| v.as_str()).unwrap_or(""); + json!({ + "name": if !model.is_empty() { model } else { serial }, + "mountPoint": "", + "fs": if !media.is_empty() { media } else { "—" }, + "interface": if !interface.is_empty() { interface } else { serde_json::Value::Null }, + "serial": if !serial.is_empty() { serial } else { serde_json::Value::Null }, + "totalBytes": size, + "availableBytes": serde_json::Value::Null, + }) + }) + .collect::>(); + if !mapped.is_empty() { + obj.insert("disks".into(), json!(mapped)); + } + } + } + } + if let Some(linux) = ext.get("linux").and_then(|v| v.as_object()) { + if let Some(pci) = linux.get("pciList").and_then(|v| v.as_array()) { + for entry in pci { + if let Some(text) = entry.get("text").and_then(|v| v.as_str()) { + let lower = text.to_lowercase(); + if lower.contains(" vga ") + || lower.contains(" 3d controller") + || lower.contains("display controller") + { + push_gpu(&mut gpus, Some(text), None, None, None); + } + } + } + } + } + if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) { + if let Some(profiler) = macos.get("systemProfiler").and_then(|v| v.as_object()) { + if let Some(displays) = profiler + .get("SPDisplaysDataType") + .and_then(|v| v.as_array()) + { + for display in displays { + if let Some(d) = display.as_object() { + let name = d.get("_name").and_then(|v| v.as_str()); + let vram = d + .get("spdisplays_vram") + .and_then(|v| v.as_str()) + .and_then(|s| { + let digits = s.split_whitespace().next().unwrap_or(""); + digits.parse::().ok().map(|n| { + if s.to_lowercase().contains("gb") { + n * 1024 * 1024 * 1024 + } else if s.to_lowercase().contains("mb") { + n * 1024 * 1024 + } else { + n + } + }) + }); + push_gpu(&mut gpus, name, vram, None, None); + } + } + } + } + } + } + + if !gpus.is_empty() { + let entry = obj.entry("hardware").or_insert_with(|| json!({})); + if let Some(hardware) = entry.as_object_mut() { + hardware.insert("gpus".into(), json!(gpus.clone())); + if let Some(primary) = gpus.first() { + hardware.insert("primaryGpu".into(), primary.clone()); + } + } + } } json!({ "inventory": inventory }) @@ -369,7 +563,9 @@ fn collect_services_linux() -> serde_json::Value { // Typical format: UNIT LOAD ACTIVE SUB DESCRIPTION // We take UNIT and ACTIVE let cols: Vec<&str> = line.split_whitespace().collect(); - if cols.is_empty() { continue; } + if cols.is_empty() { + continue; + } let unit = cols.get(0).unwrap_or(&""); let active = cols.get(2).copied().unwrap_or(""); if !unit.is_empty() { @@ -391,7 +587,13 @@ fn collect_linux_extended() -> serde_json::Value { .arg("lsblk -J -b 2>/dev/null || true") .output() .ok() - .and_then(|out| if out.status.success() { Some(out.stdout) } else { Some(out.stdout) }) + .and_then(|out| { + if out.status.success() { + Some(out.stdout) + } else { + Some(out.stdout) + } + }) .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .unwrap_or_else(|| json!({})); @@ -427,12 +629,10 @@ fn collect_linux_extended() -> serde_json::Value { if let Ok(out) = std::process::Command::new("sh") .arg("-lc") .arg("command -v smartctl >/dev/null 2>&1 && echo yes || echo no") - .output() { + .output() + { if out.status.success() && String::from_utf8_lossy(&out.stdout).contains("yes") { - if let Some(devices) = block_json - .get("blockdevices") - .and_then(|v| v.as_array()) - { + if let Some(devices) = block_json.get("blockdevices").and_then(|v| v.as_array()) { for dev in devices { let t = dev.get("type").and_then(|v| v.as_str()).unwrap_or(""); let name = dev.get("name").and_then(|v| v.as_str()).unwrap_or(""); @@ -441,9 +641,12 @@ fn collect_linux_extended() -> serde_json::Value { if let Ok(out) = Command::new("sh") .arg("-lc") .arg(format!("smartctl -H -j {} 2>/dev/null || true", path)) - .output() { + .output() + { if out.status.success() || !out.stdout.is_empty() { - if let Ok(val) = serde_json::from_slice::(&out.stdout) { + if let Ok(val) = + serde_json::from_slice::(&out.stdout) + { smart.push(val); } } @@ -468,8 +671,8 @@ fn collect_linux_extended() -> serde_json::Value { #[cfg(target_os = "windows")] fn collect_windows_extended() -> serde_json::Value { - use std::process::Command; use std::os::windows::process::CommandExt; + use std::process::Command; const CREATE_NO_WINDOW: u32 = 0x08000000; fn ps(cmd: &str) -> Option { let ps_cmd = format!( @@ -479,19 +682,23 @@ fn collect_windows_extended() -> serde_json::Value { let out = Command::new("powershell") .creation_flags(CREATE_NO_WINDOW) .arg("-NoProfile") - .arg("-WindowStyle").arg("Hidden") + .arg("-WindowStyle") + .arg("Hidden") .arg("-NoLogo") .arg("-Command") .arg(ps_cmd) .output() .ok()?; - if out.stdout.is_empty() { return None; } + if out.stdout.is_empty() { + return None; + } serde_json::from_slice::(&out.stdout).ok() } let software = ps(r#"@(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion, Publisher"#) .unwrap_or_else(|| json!([])); - let services = ps("@(Get-Service | Select-Object Name,Status,DisplayName)").unwrap_or_else(|| json!([])); + let services = + ps("@(Get-Service | Select-Object Name,Status,DisplayName)").unwrap_or_else(|| json!([])); let defender = ps("Get-MpComputerStatus | Select-Object AMRunningMode,AntivirusEnabled,RealTimeProtectionEnabled,AntispywareEnabled").unwrap_or_else(|| json!({})); let hotfix = ps("Get-HotFix | Select-Object HotFixID,InstalledOn").unwrap_or_else(|| json!([])); @@ -514,7 +721,10 @@ fn collect_windows_extended() -> serde_json::Value { // Hardware detalhado (CPU/Board/BIOS/Memória/Vídeo/Discos) let cpu = ps("Get-CimInstance Win32_Processor | Select-Object Name,Manufacturer,SocketDesignation,NumberOfCores,NumberOfLogicalProcessors,L2CacheSize,L3CacheSize,MaxClockSpeed").unwrap_or_else(|| json!({})); - let baseboard = ps("Get-CimInstance Win32_BaseBoard | Select-Object Product,Manufacturer,SerialNumber,Version").unwrap_or_else(|| json!({})); + let baseboard = ps( + "Get-CimInstance Win32_BaseBoard | Select-Object Product,Manufacturer,SerialNumber,Version", + ) + .unwrap_or_else(|| json!({})); let bios = ps("Get-CimInstance Win32_BIOS | Select-Object Manufacturer,SMBIOSBIOSVersion,ReleaseDate,Version").unwrap_or_else(|| json!({})); let memory = ps("@(Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,Capacity,Manufacturer,PartNumber,SerialNumber,ConfiguredClockSpeed,Speed,ConfiguredVoltage)").unwrap_or_else(|| json!([])); let video = ps("@(Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID)").unwrap_or_else(|| json!([])); @@ -543,7 +753,7 @@ fn collect_macos_extended() -> serde_json::Value { // system_profiler em JSON (pode ser pesado; limitar a alguns tipos) let profiler = Command::new("sh") .arg("-lc") - .arg("system_profiler -json SPHardwareDataType SPSoftwareDataType SPNetworkDataType SPStorageDataType 2>/dev/null || true") + .arg("system_profiler -json SPHardwareDataType SPSoftwareDataType SPNetworkDataType SPStorageDataType SPDisplaysDataType 2>/dev/null || true") .output() .ok() .and_then(|out| serde_json::from_slice::(&out.stdout).ok()) @@ -553,7 +763,13 @@ fn collect_macos_extended() -> serde_json::Value { .arg("pkgutil --pkgs 2>/dev/null || true") .output() .ok() - .map(|out| String::from_utf8_lossy(&out.stdout).lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect::>()) + .map(|out| { + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) .unwrap_or_default(); let services_text = Command::new("sh") .arg("-lc") @@ -668,7 +884,11 @@ static HTTP_CLIENT: Lazy = Lazy::new(|| { .expect("failed to build http client") }); -async fn post_heartbeat(base_url: &str, token: &str, status: Option) -> Result<(), AgentError> { +async fn post_heartbeat( + base_url: &str, + token: &str, + status: Option, +) -> Result<(), AgentError> { let system = collect_system(); let metrics = collect_metrics(&system); let hostname = hostname::get() @@ -760,7 +980,9 @@ impl AgentRuntime { let status_clone = status.clone(); let join_handle = async_runtime::spawn(async move { - if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { + if let Err(error) = + post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await + { eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}"); } @@ -776,7 +998,9 @@ impl AgentRuntime { _ = ticker.tick() => {} } - if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { + if let Err(error) = + post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await + { eprintln!("[agent] Falha ao enviar heartbeat: {error}"); } } diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index cde647d..f96c5ca 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -52,6 +52,8 @@ type AgentConfig = { tenantId?: string | null companySlug?: string | null machineEmail?: string | null + collaboratorEmail?: string | null + collaboratorName?: string | null apiBaseUrl: string appUrl: string createdAt: number @@ -138,6 +140,8 @@ function App() { setToken(t) const cfg = await readConfig(s) setConfig(cfg) + if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail) + if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName) if (!t) { const p = await invoke("collect_machine_profile") setProfile(p) @@ -149,6 +153,27 @@ function App() { })() }, []) + useEffect(() => { + if (!store || !config) return + const email = collabEmail.trim() + const name = collabName.trim() + const normalizedEmail = email.length > 0 ? email : null + const normalizedName = name.length > 0 ? name : null + if ( + config.collaboratorEmail === normalizedEmail && + config.collaboratorName === normalizedName + ) { + return + } + const nextConfig: AgentConfig = { + ...config, + collaboratorEmail: normalizedEmail, + collaboratorName: normalizedName, + } + setConfig(nextConfig) + writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err)) + }, [store, config?.machineId, config?.collaboratorEmail, config?.collaboratorName, collabEmail, collabName]) + useEffect(() => { if (!store || !config) return const normalizedAppUrl = normalizeUrl(config.appUrl, appUrl) @@ -177,6 +202,9 @@ function App() { if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return } setBusy(true); setError(null) try { + const collaboratorPayload = collabEmail.trim() + ? { email: collabEmail.trim(), name: collabName.trim() || undefined } + : undefined const payload = { provisioningSecret: provisioningSecret.trim(), tenantId: tenantId.trim() || undefined, @@ -185,7 +213,7 @@ function App() { os: profile.os, macAddresses: profile.macAddresses, serialNumbers: profile.serialNumbers, - metadata: { inventory: profile.inventory, metrics: profile.metrics, collaborator: collabEmail ? { email: collabEmail.trim(), name: collabName.trim() || undefined } : undefined }, + metadata: { inventory: profile.inventory, metrics: profile.metrics, collaborator: collaboratorPayload }, registeredBy: "desktop-agent", } const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) @@ -201,6 +229,8 @@ function App() { tenantId: data.tenantId ?? null, companySlug: data.companySlug ?? null, machineEmail: data.machineEmail ?? null, + collaboratorEmail: collaboratorPayload?.email ?? null, + collaboratorName: collaboratorPayload?.name ?? null, apiBaseUrl, appUrl, createdAt: Date.now(), @@ -236,12 +266,19 @@ function App() { if (!token || !profile) return setBusy(true); setError(null) try { + const collaboratorPayload = collabEmail.trim() + ? { email: collabEmail.trim(), name: collabName.trim() || undefined } + : undefined + const inventoryPayload: Record = { ...profile.inventory } + if (collaboratorPayload) { + inventoryPayload.collaborator = collaboratorPayload + } const payload = { machineToken: token, hostname: profile.hostname, os: profile.os, metrics: profile.metrics, - inventory: profile.inventory, + inventory: inventoryPayload, } const res = await fetch(`${apiBaseUrl}/api/machines/inventory`, { method: "POST", diff --git a/convex/machines.ts b/convex/machines.ts index 36c5ba5..f30f8ff 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -112,9 +112,39 @@ async function getActiveToken( return { token, machine } } +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function mergeInventory(current: unknown, patch: unknown): unknown { + if (!isObject(patch)) { + return patch + } + const base: Record = isObject(current) ? { ...(current as Record) } : {} + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue + if (isObject(value) && isObject(base[key])) { + base[key] = mergeInventory(base[key], value) + } else { + base[key] = value + } + } + return base +} + function mergeMetadata(current: unknown, patch: Record) { - if (!current || typeof current !== "object") return patch - return { ...(current as Record), ...patch } + const base: Record = isObject(current) ? { ...(current as Record) } : {} + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue + if (key === "inventory") { + base[key] = mergeInventory(base[key], value) + } else if (isObject(value) && isObject(base[key])) { + base[key] = mergeInventory(base[key], value) + } else { + base[key] = value + } + } + return base } type PostureFinding = { @@ -272,6 +302,7 @@ export const register = mutation({ const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers) const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) const now = Date.now() + const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record) : undefined const existing = await ctx.db .query("machines") @@ -291,7 +322,7 @@ export const register = mutation({ architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, - metadata: args.metadata ? mergeMetadata(existing.metadata, { inventory: args.metadata }) : existing.metadata, + metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata, lastHeartbeatAt: now, updatedAt: now, status: "online", @@ -310,7 +341,7 @@ export const register = mutation({ macAddresses: identifiers.macs, serialNumbers: identifiers.serials, fingerprint, - metadata: args.metadata ? { inventory: args.metadata } : undefined, + metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined, lastHeartbeatAt: now, status: "online", createdAt: now, @@ -385,10 +416,13 @@ export const upsertInventory = mutation({ const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) const now = Date.now() - const metadataPatch = mergeMetadata({}, { - ...(args.inventory ? { inventory: args.inventory } : {}), - ...(args.metrics ? { metrics: args.metrics } : {}), - }) + const metadataPatch: Record = {} + if (args.inventory && typeof args.inventory === "object") { + metadataPatch.inventory = args.inventory as Record + } + if (args.metrics && typeof args.metrics === "object") { + metadataPatch.metrics = args.metrics as Record + } const existing = await ctx.db .query("machines") @@ -408,7 +442,7 @@ export const upsertInventory = mutation({ architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, - metadata: mergeMetadata(existing.metadata, metadataPatch), + metadata: Object.keys(metadataPatch).length ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata, lastHeartbeatAt: now, updatedAt: now, status: args.metrics ? "online" : existing.status ?? "unknown", @@ -427,7 +461,7 @@ export const upsertInventory = mutation({ macAddresses: identifiers.macs, serialNumbers: identifiers.serials, fingerprint, - metadata: metadataPatch, + metadata: Object.keys(metadataPatch).length ? mergeMetadata(undefined, metadataPatch) : undefined, lastHeartbeatAt: now, status: args.metrics ? "online" : "unknown", createdAt: now, @@ -470,11 +504,17 @@ export const heartbeat = mutation({ const { machine, token } = await getActiveToken(ctx, args.machineToken) const now = Date.now() - const mergedMetadata = mergeMetadata(machine.metadata, { - ...(args.metadata ?? {}), - ...(args.metrics ? { metrics: args.metrics } : {}), - ...(args.inventory ? { inventory: args.inventory } : {}), - }) + const metadataPatch: Record = {} + if (args.metadata && typeof args.metadata === "object") { + Object.assign(metadataPatch, args.metadata as Record) + } + if (args.inventory && typeof args.inventory === "object") { + metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record) + } + if (args.metrics && typeof args.metrics === "object") { + metadataPatch.metrics = args.metrics as Record + } + const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata await ctx.db.patch(machine._id, { hostname: args.hostname ?? machine.hostname, @@ -684,3 +724,35 @@ export const rename = mutation({ return { ok: true } }, }) + +export const remove = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + }, + handler: async (ctx, { machineId, actorId }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Máquina não encontrada") + } + + const actor = await ctx.db.get(actorId) + if (!actor || actor.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da máquina") + } + const role = (actor.role ?? "AGENT").toUpperCase() + const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) + if (!STAFF.has(role)) { + throw new ConvexError("Apenas equipe interna pode excluir máquinas") + } + + const tokens = await ctx.db + .query("machineTokens") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .collect() + + await Promise.all(tokens.map((token) => ctx.db.delete(token._id))) + await ctx.db.delete(machineId) + return { ok: true } + }, +}) diff --git a/src/app/api/admin/machines/delete/route.ts b/src/app/api/admin/machines/delete/route.ts new file mode 100644 index 0000000..c25dbf7 --- /dev/null +++ b/src/app/api/admin/machines/delete/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), +}) + +export async function POST(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const payload = await request.json().catch(() => null) + const parsed = schema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + + const actorId = ensured?._id + if (!actorId) { + return NextResponse.json({ error: "Falha ao obter ID do usuário no Convex" }, { status: 500 }) + } + + const client = convex as unknown as { mutation: (name: string, args: unknown) => Promise } + await client.mutation("machines:remove", { + machineId: parsed.data.machineId, + actorId, + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.delete] Falha ao excluir", error) + return NextResponse.json({ error: "Falha ao excluir máquina" }, { status: 500 }) + } +} diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index f969e28..7747bb2 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -26,6 +26,7 @@ import { import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" import Link from "next/link" +import { useRouter } from "next/navigation" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" @@ -102,6 +103,8 @@ type MachineInventory = { logicalCores?: number memoryBytes?: number memory?: number + primaryGpu?: { name?: string; memoryBytes?: number; driver?: string; vendor?: string } + gpus?: Array<{ name?: string; memoryBytes?: number; driver?: string; vendor?: string }> } network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | Array<{ name?: string; mac?: string; ip?: string }> software?: MachineSoftware[] @@ -113,9 +116,10 @@ type MachineInventory = { osqueryVersion?: string } // Dados enviados pelo agente desktop (inventário básico/estendido) - disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }> + disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }> extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended } services?: Array<{ name?: string; status?: string; displayName?: string }> + collaborator?: { email?: string; name?: string } } export type MachinesQueryItem = { @@ -354,7 +358,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { {machines.length === 0 ? ( ) : ( - + )} @@ -420,6 +424,7 @@ type MachineDetailsProps = { export function MachineDetails({ machine }: MachineDetailsProps) { const { convexUserId } = useAuth() + const router = useRouter() // Company name lookup (by slug) const companies = useQuery( convexUserId && machine ? api.companies.list : "skip", @@ -437,6 +442,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const linuxExt = extended?.linux ?? null const windowsExt = extended?.windows ?? null const macosExt = extended?.macos ?? null + const hardwareGpus = Array.isArray((hardware as any)?.gpus) + ? (((hardware as any)?.gpus as Array>) ?? []) + : [] + const primaryGpu = (hardware as any)?.primaryGpu as Record | undefined type WinCpuInfo = { Name?: string @@ -502,6 +511,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const [openDialog, setOpenDialog] = useState(false) const [dialogQuery, setDialogQuery] = useState("") + const [deleteDialog, setDeleteDialog] = useState(false) + const [deleting, setDeleting] = useState(false) const jsonText = useMemo(() => { const payload = { id: machine?.id, @@ -551,7 +562,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

{machine.companySlug ? (

- Empresa vinculada: {machine.companySlug} + Empresa vinculada: {companyName ?? machine.companySlug}

) : null} @@ -707,6 +718,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) { value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`} /> + {hardwareGpus.length > 0 ? ( +
+

GPUs

+
    + {hardwareGpus.slice(0, 3).map((gpu, idx) => { + const gpuObj = gpu as Record + const name = + typeof gpuObj?.["name"] === "string" + ? (gpuObj["name"] as string) + : typeof gpuObj?.["Name"] === "string" + ? (gpuObj["Name"] as string) + : undefined + const memoryBytes = parseNumberLike(gpuObj?.["memoryBytes"] ?? gpuObj?.["AdapterRAM"]) + const driver = + typeof gpuObj?.["driver"] === "string" + ? (gpuObj["driver"] as string) + : typeof gpuObj?.["DriverVersion"] === "string" + ? (gpuObj["DriverVersion"] as string) + : undefined + const vendor = typeof gpuObj?.["vendor"] === "string" ? (gpuObj["vendor"] as string) : undefined + return ( +
  • + {name ?? "Adaptador de vídeo"} + {memoryBytes ? {formatBytes(memoryBytes)} : null} + {vendor ? · {vendor} : null} + {driver ? · Driver {driver} : null} +
  • + ) + })} + {hardwareGpus.length > 3 ? ( +
  • +{hardwareGpus.length - 3} adaptadores adicionais
  • + ) : null} +
+
+ ) : null} ) : null} @@ -1215,6 +1261,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ) : null} + {machine ? ( +
+
+

Zona perigosa

+

+ Excluir a máquina revoga o token atual e remove os dados de inventário sincronizados. +

+
+ +
+ ) : null} +
@@ -1236,6 +1294,47 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
+ + { if (!open) setDeleting(false); setDeleteDialog(open) }}> + + + Excluir máquina + +
+

Tem certeza que deseja excluir {machine?.hostname}? Esta ação não pode ser desfeita.

+

Os tokens ativos serão revogados e o inventário deixará de aparecer no painel.

+
+ + +
+
+
+
)} @@ -1243,18 +1342,22 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ) } -function MachinesGrid({ machines }: { machines: MachinesQueryItem[] }) { +function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQueryItem[]; companyNameBySlug: Map }) { if (!machines || machines.length === 0) return return (
{machines.map((m) => ( - + ))}
) } -function MachineCard({ machine }: { machine: MachinesQueryItem }) { +function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) { const { className } = getStatusVariant(machine.status) const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null type AgentMetrics = { @@ -1268,6 +1371,18 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) { const memTotal = mm?.memoryTotalBytes ?? NaN const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN) const cpuPct = mm?.cpuUsagePercent ?? NaN + const collaborator = (() => { + const inv = machine.inventory as unknown + if (!inv || typeof inv !== "object") return null + const raw = (inv as Record).collaborator + if (!raw || typeof raw !== "object") return null + const obj = raw as Record + const email = typeof obj.email === "string" ? obj.email : undefined + const name = typeof obj.name === "string" ? obj.name : undefined + if (!email) return null + return { email, name } + })() + const companyLabel = companyName ?? machine.companySlug ?? null return ( @@ -1306,10 +1421,16 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) { {machine.architecture.toUpperCase()} ) : null} - {machine.companySlug ? ( - {machine.companySlug} + {companyLabel ? ( + {companyLabel} ) : null} + {collaborator?.email ? ( +

+ {collaborator.name ? `${collaborator.name} · ` : ""} + {collaborator.email} +

+ ) : null}
@@ -1339,6 +1460,17 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) { ) } +function parseNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return null + const numeric = Number(trimmed.replace(/[^0-9.]/g, "")) + if (Number.isFinite(numeric)) return numeric + } + return null +} + function DetailLine({ label, value, classNameValue }: DetailLineProps) { if (value === null || value === undefined) return null if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {