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 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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
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 { 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")) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue