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}
+
+
+
)}
@@ -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")) {