feat: melhorar inventário e gestão de máquinas

This commit is contained in:
Esdras Renan 2025-10-10 23:20:21 -03:00
parent b1d334045d
commit 3f0702d80b
5 changed files with 584 additions and 59 deletions

View file

@ -6,8 +6,8 @@ use once_cell::sync::Lazy;
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
use sysinfo::{Networks, System};
use std::collections::HashMap; use std::collections::HashMap;
use sysinfo::{DiskExt, Networks, System, SystemExt};
use tauri::async_runtime::{self, JoinHandle}; use tauri::async_runtime::{self, JoinHandle};
use tokio::sync::Notify; use tokio::sync::Notify;
@ -166,27 +166,90 @@ fn collect_network_addrs() -> Vec<serde_json::Value> {
entries entries
} }
fn collect_disks(_system: &System) -> Vec<serde_json::Value> { fn collect_disks(system: &System) -> Vec<serde_json::Value> {
// API de discos mudou no sysinfo 0.31: usamos Disks diretamente
let mut out = Vec::new(); let mut out = Vec::new();
let disks = sysinfo::Disks::new_with_refreshed_list(); for disk in system.disks() {
for d in disks.list() { let name = disk.name().to_string_lossy().to_string();
let name = d.name().to_string_lossy().to_string(); let mount = disk.mount_point().to_string_lossy().to_string();
let mount = d.mount_point().to_string_lossy().to_string(); let fs = String::from_utf8_lossy(disk.file_system()).to_string();
let fs = d.file_system().to_string_lossy().to_string(); let total = disk.total_space();
let total = d.total_space(); let avail = disk.available_space();
let avail = d.available_space();
out.push(json!({ out.push(json!({
"name": name, "name": if name.is_empty() { mount.clone() } else { name },
"mountPoint": mount, "mountPoint": mount,
"fs": fs, "fs": fs,
"totalBytes": total, "totalBytes": total,
"availableBytes": avail, "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 out
} }
fn parse_u64(value: &serde_json::Value) -> Option<u64> {
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::<u64>() {
return Some(parsed);
}
}
None
}
fn push_gpu(
list: &mut Vec<serde_json::Value>,
name: Option<&str>,
memory: Option<u64>,
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 { fn build_inventory_metadata(system: &System) -> serde_json::Value {
let cpu_brand = system let cpu_brand = system
.cpus() .cpus()
@ -204,6 +267,26 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value {
"disks": disks, "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")] #[cfg(target_os = "linux")]
{ {
// Softwares instalados (dpkg ou rpm) // 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(win) = ext.get("windows").and_then(|v| v.as_object()) {
if let Some(ws) = win.get("software").and_then(|v| v.as_array()) { if let Some(ws) = win.get("software").and_then(|v| v.as_array()) {
for item in ws { for item in ws {
let name = item.get("DisplayName").or_else(|| item.get("name")).cloned().unwrap_or(json!(null)); let name = item
let version = item.get("DisplayVersion").or_else(|| item.get("version")).cloned().unwrap_or(json!(null)); .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)); 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 name = s.get("Name").cloned().unwrap_or(json!(null));
let status = s.get("Status").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)); 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() { if !services.is_empty() {
obj.insert("services".into(), json!(services)); obj.insert("services".into(), json!(services));
} }
let mut gpus: Vec<serde_json::Value> = 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::<Vec<_>>();
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::<u64>().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 }) json!({ "inventory": inventory })
@ -369,7 +563,9 @@ fn collect_services_linux() -> serde_json::Value {
// Typical format: UNIT LOAD ACTIVE SUB DESCRIPTION // Typical format: UNIT LOAD ACTIVE SUB DESCRIPTION
// We take UNIT and ACTIVE // We take UNIT and ACTIVE
let cols: Vec<&str> = line.split_whitespace().collect(); 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 unit = cols.get(0).unwrap_or(&"");
let active = cols.get(2).copied().unwrap_or(""); let active = cols.get(2).copied().unwrap_or("");
if !unit.is_empty() { if !unit.is_empty() {
@ -391,7 +587,13 @@ fn collect_linux_extended() -> serde_json::Value {
.arg("lsblk -J -b 2>/dev/null || true") .arg("lsblk -J -b 2>/dev/null || true")
.output() .output()
.ok() .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::<serde_json::Value>(&bytes).ok()) .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or_else(|| json!({})); .unwrap_or_else(|| json!({}));
@ -427,12 +629,10 @@ fn collect_linux_extended() -> serde_json::Value {
if let Ok(out) = std::process::Command::new("sh") if let Ok(out) = std::process::Command::new("sh")
.arg("-lc") .arg("-lc")
.arg("command -v smartctl >/dev/null 2>&1 && echo yes || echo no") .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 out.status.success() && String::from_utf8_lossy(&out.stdout).contains("yes") {
if let Some(devices) = block_json if let Some(devices) = block_json.get("blockdevices").and_then(|v| v.as_array()) {
.get("blockdevices")
.and_then(|v| v.as_array())
{
for dev in devices { for dev in devices {
let t = dev.get("type").and_then(|v| v.as_str()).unwrap_or(""); 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(""); 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") if let Ok(out) = Command::new("sh")
.arg("-lc") .arg("-lc")
.arg(format!("smartctl -H -j {} 2>/dev/null || true", path)) .arg(format!("smartctl -H -j {} 2>/dev/null || true", path))
.output() { .output()
{
if out.status.success() || !out.stdout.is_empty() { if out.status.success() || !out.stdout.is_empty() {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&out.stdout) { if let Ok(val) =
serde_json::from_slice::<serde_json::Value>(&out.stdout)
{
smart.push(val); smart.push(val);
} }
} }
@ -468,8 +671,8 @@ fn collect_linux_extended() -> serde_json::Value {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn collect_windows_extended() -> serde_json::Value { fn collect_windows_extended() -> serde_json::Value {
use std::process::Command;
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
fn ps(cmd: &str) -> Option<serde_json::Value> { fn ps(cmd: &str) -> Option<serde_json::Value> {
let ps_cmd = format!( let ps_cmd = format!(
@ -479,19 +682,23 @@ fn collect_windows_extended() -> serde_json::Value {
let out = Command::new("powershell") let out = Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.arg("-NoProfile") .arg("-NoProfile")
.arg("-WindowStyle").arg("Hidden") .arg("-WindowStyle")
.arg("Hidden")
.arg("-NoLogo") .arg("-NoLogo")
.arg("-Command") .arg("-Command")
.arg(ps_cmd) .arg(ps_cmd)
.output() .output()
.ok()?; .ok()?;
if out.stdout.is_empty() { return None; } if out.stdout.is_empty() {
return None;
}
serde_json::from_slice::<serde_json::Value>(&out.stdout).ok() serde_json::from_slice::<serde_json::Value>(&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"#) 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!([])); .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 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!([])); 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) // 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 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 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 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!([])); 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) // system_profiler em JSON (pode ser pesado; limitar a alguns tipos)
let profiler = Command::new("sh") let profiler = Command::new("sh")
.arg("-lc") .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() .output()
.ok() .ok()
.and_then(|out| serde_json::from_slice::<serde_json::Value>(&out.stdout).ok()) .and_then(|out| serde_json::from_slice::<serde_json::Value>(&out.stdout).ok())
@ -553,7 +763,13 @@ fn collect_macos_extended() -> serde_json::Value {
.arg("pkgutil --pkgs 2>/dev/null || true") .arg("pkgutil --pkgs 2>/dev/null || true")
.output() .output()
.ok() .ok()
.map(|out| String::from_utf8_lossy(&out.stdout).lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect::<Vec<_>>()) .map(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default(); .unwrap_or_default();
let services_text = Command::new("sh") let services_text = Command::new("sh")
.arg("-lc") .arg("-lc")
@ -668,7 +884,11 @@ static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
.expect("failed to build http client") .expect("failed to build http client")
}); });
async fn post_heartbeat(base_url: &str, token: &str, status: Option<String>) -> Result<(), AgentError> { async fn post_heartbeat(
base_url: &str,
token: &str,
status: Option<String>,
) -> Result<(), AgentError> {
let system = collect_system(); let system = collect_system();
let metrics = collect_metrics(&system); let metrics = collect_metrics(&system);
let hostname = hostname::get() let hostname = hostname::get()
@ -760,7 +980,9 @@ impl AgentRuntime {
let status_clone = status.clone(); let status_clone = status.clone();
let join_handle = async_runtime::spawn(async move { 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}"); eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}");
} }
@ -776,7 +998,9 @@ impl AgentRuntime {
_ = ticker.tick() => {} _ = 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}"); eprintln!("[agent] Falha ao enviar heartbeat: {error}");
} }
} }

View file

@ -52,6 +52,8 @@ type AgentConfig = {
tenantId?: string | null tenantId?: string | null
companySlug?: string | null companySlug?: string | null
machineEmail?: string | null machineEmail?: string | null
collaboratorEmail?: string | null
collaboratorName?: string | null
apiBaseUrl: string apiBaseUrl: string
appUrl: string appUrl: string
createdAt: number createdAt: number
@ -138,6 +140,8 @@ function App() {
setToken(t) setToken(t)
const cfg = await readConfig(s) const cfg = await readConfig(s)
setConfig(cfg) setConfig(cfg)
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
if (!t) { if (!t) {
const p = await invoke<MachineProfile>("collect_machine_profile") const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p) 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(() => { useEffect(() => {
if (!store || !config) return if (!store || !config) return
const normalizedAppUrl = normalizeUrl(config.appUrl, appUrl) const normalizedAppUrl = normalizeUrl(config.appUrl, appUrl)
@ -177,6 +202,9 @@ function App() {
if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return } if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return }
setBusy(true); setError(null) setBusy(true); setError(null)
try { try {
const collaboratorPayload = collabEmail.trim()
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
: undefined
const payload = { const payload = {
provisioningSecret: provisioningSecret.trim(), provisioningSecret: provisioningSecret.trim(),
tenantId: tenantId.trim() || undefined, tenantId: tenantId.trim() || undefined,
@ -185,7 +213,7 @@ function App() {
os: profile.os, os: profile.os,
macAddresses: profile.macAddresses, macAddresses: profile.macAddresses,
serialNumbers: profile.serialNumbers, 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", registeredBy: "desktop-agent",
} }
const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) 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, tenantId: data.tenantId ?? null,
companySlug: data.companySlug ?? null, companySlug: data.companySlug ?? null,
machineEmail: data.machineEmail ?? null, machineEmail: data.machineEmail ?? null,
collaboratorEmail: collaboratorPayload?.email ?? null,
collaboratorName: collaboratorPayload?.name ?? null,
apiBaseUrl, apiBaseUrl,
appUrl, appUrl,
createdAt: Date.now(), createdAt: Date.now(),
@ -236,12 +266,19 @@ function App() {
if (!token || !profile) return if (!token || !profile) return
setBusy(true); setError(null) setBusy(true); setError(null)
try { try {
const collaboratorPayload = collabEmail.trim()
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
: undefined
const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
if (collaboratorPayload) {
inventoryPayload.collaborator = collaboratorPayload
}
const payload = { const payload = {
machineToken: token, machineToken: token,
hostname: profile.hostname, hostname: profile.hostname,
os: profile.os, os: profile.os,
metrics: profile.metrics, metrics: profile.metrics,
inventory: profile.inventory, inventory: inventoryPayload,
} }
const res = await fetch(`${apiBaseUrl}/api/machines/inventory`, { const res = await fetch(`${apiBaseUrl}/api/machines/inventory`, {
method: "POST", method: "POST",

View file

@ -112,9 +112,39 @@ async function getActiveToken(
return { token, machine } return { token, machine }
} }
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function mergeInventory(current: unknown, patch: unknown): unknown {
if (!isObject(patch)) {
return patch
}
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
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<string, unknown>) { function mergeMetadata(current: unknown, patch: Record<string, unknown>) {
if (!current || typeof current !== "object") return patch const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
return { ...(current as Record<string, unknown>), ...patch } 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 = { type PostureFinding = {
@ -272,6 +302,7 @@ export const register = mutation({
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers) const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
const now = Date.now() const now = Date.now()
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
const existing = await ctx.db const existing = await ctx.db
.query("machines") .query("machines")
@ -291,7 +322,7 @@ export const register = mutation({
architecture: args.os.architecture, architecture: args.os.architecture,
macAddresses: identifiers.macs, macAddresses: identifiers.macs,
serialNumbers: identifiers.serials, serialNumbers: identifiers.serials,
metadata: args.metadata ? mergeMetadata(existing.metadata, { inventory: args.metadata }) : existing.metadata, metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
lastHeartbeatAt: now, lastHeartbeatAt: now,
updatedAt: now, updatedAt: now,
status: "online", status: "online",
@ -310,7 +341,7 @@ export const register = mutation({
macAddresses: identifiers.macs, macAddresses: identifiers.macs,
serialNumbers: identifiers.serials, serialNumbers: identifiers.serials,
fingerprint, fingerprint,
metadata: args.metadata ? { inventory: args.metadata } : undefined, metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
lastHeartbeatAt: now, lastHeartbeatAt: now,
status: "online", status: "online",
createdAt: now, createdAt: now,
@ -385,10 +416,13 @@ export const upsertInventory = mutation({
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
const now = Date.now() const now = Date.now()
const metadataPatch = mergeMetadata({}, { const metadataPatch: Record<string, unknown> = {}
...(args.inventory ? { inventory: args.inventory } : {}), if (args.inventory && typeof args.inventory === "object") {
...(args.metrics ? { metrics: args.metrics } : {}), metadataPatch.inventory = args.inventory as Record<string, unknown>
}) }
if (args.metrics && typeof args.metrics === "object") {
metadataPatch.metrics = args.metrics as Record<string, unknown>
}
const existing = await ctx.db const existing = await ctx.db
.query("machines") .query("machines")
@ -408,7 +442,7 @@ export const upsertInventory = mutation({
architecture: args.os.architecture, architecture: args.os.architecture,
macAddresses: identifiers.macs, macAddresses: identifiers.macs,
serialNumbers: identifiers.serials, serialNumbers: identifiers.serials,
metadata: mergeMetadata(existing.metadata, metadataPatch), metadata: Object.keys(metadataPatch).length ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
lastHeartbeatAt: now, lastHeartbeatAt: now,
updatedAt: now, updatedAt: now,
status: args.metrics ? "online" : existing.status ?? "unknown", status: args.metrics ? "online" : existing.status ?? "unknown",
@ -427,7 +461,7 @@ export const upsertInventory = mutation({
macAddresses: identifiers.macs, macAddresses: identifiers.macs,
serialNumbers: identifiers.serials, serialNumbers: identifiers.serials,
fingerprint, fingerprint,
metadata: metadataPatch, metadata: Object.keys(metadataPatch).length ? mergeMetadata(undefined, metadataPatch) : undefined,
lastHeartbeatAt: now, lastHeartbeatAt: now,
status: args.metrics ? "online" : "unknown", status: args.metrics ? "online" : "unknown",
createdAt: now, createdAt: now,
@ -470,11 +504,17 @@ export const heartbeat = mutation({
const { machine, token } = await getActiveToken(ctx, args.machineToken) const { machine, token } = await getActiveToken(ctx, args.machineToken)
const now = Date.now() const now = Date.now()
const mergedMetadata = mergeMetadata(machine.metadata, { const metadataPatch: Record<string, unknown> = {}
...(args.metadata ?? {}), if (args.metadata && typeof args.metadata === "object") {
...(args.metrics ? { metrics: args.metrics } : {}), Object.assign(metadataPatch, args.metadata as Record<string, unknown>)
...(args.inventory ? { inventory: args.inventory } : {}), }
}) if (args.inventory && typeof args.inventory === "object") {
metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record<string, unknown>)
}
if (args.metrics && typeof args.metrics === "object") {
metadataPatch.metrics = args.metrics as Record<string, unknown>
}
const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata
await ctx.db.patch(machine._id, { await ctx.db.patch(machine._id, {
hostname: args.hostname ?? machine.hostname, hostname: args.hostname ?? machine.hostname,
@ -684,3 +724,35 @@ export const rename = mutation({
return { ok: true } 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 }
},
})

View file

@ -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<unknown> }
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 })
}
}

View file

@ -26,6 +26,7 @@ import {
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
@ -102,6 +103,8 @@ type MachineInventory = {
logicalCores?: number logicalCores?: number
memoryBytes?: number memoryBytes?: number
memory?: 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 }> network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | Array<{ name?: string; mac?: string; ip?: string }>
software?: MachineSoftware[] software?: MachineSoftware[]
@ -113,9 +116,10 @@ type MachineInventory = {
osqueryVersion?: string osqueryVersion?: string
} }
// Dados enviados pelo agente desktop (inventário básico/estendido) // 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 } extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
services?: Array<{ name?: string; status?: string; displayName?: string }> services?: Array<{ name?: string; status?: string; displayName?: string }>
collaborator?: { email?: string; name?: string }
} }
export type MachinesQueryItem = { export type MachinesQueryItem = {
@ -354,7 +358,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
{machines.length === 0 ? ( {machines.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<MachinesGrid machines={filteredMachines} /> <MachinesGrid machines={filteredMachines} companyNameBySlug={companyNameBySlug} />
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -420,6 +424,7 @@ type MachineDetailsProps = {
export function MachineDetails({ machine }: MachineDetailsProps) { export function MachineDetails({ machine }: MachineDetailsProps) {
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const router = useRouter()
// Company name lookup (by slug) // Company name lookup (by slug)
const companies = useQuery( const companies = useQuery(
convexUserId && machine ? api.companies.list : "skip", convexUserId && machine ? api.companies.list : "skip",
@ -437,6 +442,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const linuxExt = extended?.linux ?? null const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null const macosExt = extended?.macos ?? null
const hardwareGpus = Array.isArray((hardware as any)?.gpus)
? (((hardware as any)?.gpus as Array<Record<string, unknown>>) ?? [])
: []
const primaryGpu = (hardware as any)?.primaryGpu as Record<string, unknown> | undefined
type WinCpuInfo = { type WinCpuInfo = {
Name?: string Name?: string
@ -502,6 +511,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const [openDialog, setOpenDialog] = useState(false) const [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("") const [dialogQuery, setDialogQuery] = useState("")
const [deleteDialog, setDeleteDialog] = useState(false)
const [deleting, setDeleting] = useState(false)
const jsonText = useMemo(() => { const jsonText = useMemo(() => {
const payload = { const payload = {
id: machine?.id, id: machine?.id,
@ -551,7 +562,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</p> </p>
{machine.companySlug ? ( {machine.companySlug ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Empresa vinculada: <span className="font-medium text-foreground">{machine.companySlug}</span> Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
</p> </p>
) : null} ) : null}
</div> </div>
@ -707,6 +718,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`} value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
/> />
<DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} /> <DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} />
{hardwareGpus.length > 0 ? (
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<p className="font-semibold uppercase text-slate-500">GPUs</p>
<ul className="space-y-1">
{hardwareGpus.slice(0, 3).map((gpu, idx) => {
const gpuObj = gpu as Record<string, unknown>
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 (
<li key={`gpu-${idx}`}>
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
{memoryBytes ? <span className="ml-1 text-muted-foreground">{formatBytes(memoryBytes)}</span> : null}
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
</li>
)
})}
{hardwareGpus.length > 3 ? (
<li className="text-muted-foreground">+{hardwareGpus.length - 3} adaptadores adicionais</li>
) : null}
</ul>
</div>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@ -1215,6 +1261,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</section> </section>
) : null} ) : null}
{machine ? (
<section className="space-y-2 rounded-md border border-rose-200 bg-rose-50/60 p-3">
<div className="space-y-1">
<h4 className="text-sm font-semibold text-rose-700">Zona perigosa</h4>
<p className="text-xs text-rose-600">
Excluir a máquina revoga o token atual e remove os dados de inventário sincronizados.
</p>
</div>
<Button variant="destructive" size="sm" onClick={() => setDeleteDialog(true)}>Excluir máquina</Button>
</section>
) : null}
<Dialog open={openDialog} onOpenChange={setOpenDialog}> <Dialog open={openDialog} onOpenChange={setOpenDialog}>
<div className="flex justify-end"> <div className="flex justify-end">
<DialogTrigger asChild> <DialogTrigger asChild>
@ -1236,6 +1294,47 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir máquina</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>Tem certeza que deseja excluir <span className="font-semibold text-foreground">{machine?.hostname}</span>? Esta ação não pode ser desfeita.</p>
<p>Os tokens ativos serão revogados e o inventário deixará de aparecer no painel.</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteDialog(false)} disabled={deleting}>Cancelar</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={async () => {
if (!machine) return
setDeleting(true)
try {
const res = await fetch("/api/admin/machines/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
toast.success("Máquina excluída")
setDeleteDialog(false)
router.push("/admin/machines")
} catch (err) {
console.error(err)
toast.error("Falha ao excluir máquina")
} finally {
setDeleting(false)
}
}}
>
{deleting ? "Excluindo..." : "Excluir máquina"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -1243,18 +1342,22 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
) )
} }
function MachinesGrid({ machines }: { machines: MachinesQueryItem[] }) { function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQueryItem[]; companyNameBySlug: Map<string, string> }) {
if (!machines || machines.length === 0) return <EmptyState /> if (!machines || machines.length === 0) return <EmptyState />
return ( return (
<div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]"> <div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{machines.map((m) => ( {machines.map((m) => (
<MachineCard key={m.id} machine={m} /> <MachineCard
key={m.id}
machine={m}
companyName={m.companySlug ? companyNameBySlug.get(m.companySlug) ?? m.companySlug : null}
/>
))} ))}
</div> </div>
) )
} }
function MachineCard({ machine }: { machine: MachinesQueryItem }) { function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
const { className } = getStatusVariant(machine.status) const { className } = getStatusVariant(machine.status)
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
type AgentMetrics = { type AgentMetrics = {
@ -1268,6 +1371,18 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
const memTotal = mm?.memoryTotalBytes ?? NaN const memTotal = mm?.memoryTotalBytes ?? NaN
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN) const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
const cpuPct = mm?.cpuUsagePercent ?? 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<string, unknown>).collaborator
if (!raw || typeof raw !== "object") return null
const obj = raw as Record<string, unknown>
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 ( return (
<Link href={`/admin/machines/${machine.id}`} className="group"> <Link href={`/admin/machines/${machine.id}`} className="group">
@ -1306,10 +1421,16 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
{machine.architecture.toUpperCase()} {machine.architecture.toUpperCase()}
</Badge> </Badge>
) : null} ) : null}
{machine.companySlug ? ( {companyLabel ? (
<Badge variant="outline" className="text-xs">{machine.companySlug}</Badge> <Badge variant="outline" className="text-xs">{companyLabel}</Badge>
) : null} ) : null}
</div> </div>
{collaborator?.email ? (
<p className="text-[11px] text-muted-foreground">
{collaborator.name ? `${collaborator.name} · ` : ""}
{collaborator.email}
</p>
) : null}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5"> <div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
<Cpu className="size-4 text-slate-500" /> <Cpu className="size-4 text-slate-500" />
@ -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) { function DetailLine({ label, value, classNameValue }: DetailLineProps) {
if (value === null || value === undefined) return null if (value === null || value === undefined) return null
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) { if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {