feat: melhorar inventário e gestão de máquinas
This commit is contained in:
parent
b1d334045d
commit
3f0702d80b
5 changed files with 584 additions and 59 deletions
|
|
@ -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<serde_json::Value> {
|
|||
entries
|
||||
}
|
||||
|
||||
fn collect_disks(_system: &System) -> Vec<serde_json::Value> {
|
||||
// API de discos mudou no sysinfo 0.31: usamos Disks diretamente
|
||||
fn collect_disks(system: &System) -> Vec<serde_json::Value> {
|
||||
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<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 {
|
||||
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<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 })
|
||||
|
|
@ -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::<serde_json::Value>(&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::<serde_json::Value>(&out.stdout) {
|
||||
if let Ok(val) =
|
||||
serde_json::from_slice::<serde_json::Value>(&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<serde_json::Value> {
|
||||
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::<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"#)
|
||||
.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::<serde_json::Value>(&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::<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();
|
||||
let services_text = Command::new("sh")
|
||||
.arg("-lc")
|
||||
|
|
@ -668,7 +884,11 @@ static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
|
|||
.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 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MachineProfile>("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<string, unknown> = { ...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",
|
||||
|
|
|
|||
|
|
@ -112,9 +112,39 @@ async function getActiveToken(
|
|||
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>) {
|
||||
if (!current || typeof current !== "object") return patch
|
||||
return { ...(current as Record<string, unknown>), ...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 (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<string, unknown>) : 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<string, unknown> = {}
|
||||
if (args.inventory && typeof args.inventory === "object") {
|
||||
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
|
||||
.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<string, unknown> = {}
|
||||
if (args.metadata && typeof args.metadata === "object") {
|
||||
Object.assign(metadataPatch, args.metadata as Record<string, unknown>)
|
||||
}
|
||||
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, {
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
60
src/app/api/admin/machines/delete/route.ts
Normal file
60
src/app/api/admin/machines/delete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<MachinesGrid machines={filteredMachines} />
|
||||
<MachinesGrid machines={filteredMachines} companyNameBySlug={companyNameBySlug} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -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<Record<string, unknown>>) ?? [])
|
||||
: []
|
||||
const primaryGpu = (hardware as any)?.primaryGpu as Record<string, unknown> | 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) {
|
|||
</p>
|
||||
{machine.companySlug ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -707,6 +718,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
|
||||
/>
|
||||
<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>
|
||||
) : null}
|
||||
|
|
@ -1215,6 +1261,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</section>
|
||||
) : 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}>
|
||||
<div className="flex justify-end">
|
||||
<DialogTrigger asChild>
|
||||
|
|
@ -1236,6 +1294,47 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
)}
|
||||
</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 />
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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<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 (
|
||||
<Link href={`/admin/machines/${machine.id}`} className="group">
|
||||
|
|
@ -1306,10 +1421,16 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
{machine.architecture.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{machine.companySlug ? (
|
||||
<Badge variant="outline" className="text-xs">{machine.companySlug}</Badge>
|
||||
{companyLabel ? (
|
||||
<Badge variant="outline" className="text-xs">{companyLabel}</Badge>
|
||||
) : null}
|
||||
</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="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" />
|
||||
|
|
@ -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")) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue