From 479c66d52ca6c3ffd6028985e00570c839cac4eb Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 9 Oct 2025 22:08:20 -0300 Subject: [PATCH] feat(desktop-agent,admin/inventory): secure token storage via keyring; extended inventory collectors per OS; new /api/machines/inventory endpoint; posture rules + tickets; Admin UI inventory with filters, search and export; docs + CI desktop release --- .github/workflows/desktop-release.yml | 66 +++ agents.md | 25 ++ apps/desktop/.env.example | 6 +- apps/desktop/README.md | 50 ++- apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.lock | 125 +++++- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/agent.rs | 194 ++++++++- apps/desktop/src-tauri/src/lib.rs | 2 + apps/desktop/src/main.ts | 56 ++- convex/machines.ts | 124 ++++++ docs/OPERACAO-PRODUCAO.md | 16 +- docs/SETUP-HISTORICO.md | 7 +- docs/admin-inventory-ui.md | 31 ++ docs/desktop-build.md | 46 +++ docs/plano-app-desktop-maquinas.md | 7 +- src/app/api/machines/inventory/route.ts | 111 ++++++ .../machines/admin-machines-overview.tsx | 375 +++++++++++++++++- 18 files changed, 1205 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/desktop-release.yml create mode 100644 docs/admin-inventory-ui.md create mode 100644 docs/desktop-build.md create mode 100644 src/app/api/machines/inventory/route.ts diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..1cc6e55 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,66 @@ +name: Desktop Release (Tauri) + +on: + workflow_dispatch: + push: + tags: + - 'desktop-v*' + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux + runner: ubuntu-latest + - platform: windows + runner: windows-latest + - platform: macos + runner: macos-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable Corepack + run: corepack enable && corepack prepare pnpm@9 --activate + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux deps + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev build-essential curl wget file + + - name: Install pnpm deps + run: pnpm -C apps/desktop install --frozen-lockfile + + - name: Build desktop + env: + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VITE_APP_URL: https://tickets.esdrasrenan.com.br + VITE_API_BASE_URL: https://tickets.esdrasrenan.com.br + run: pnpm -C apps/desktop tauri build + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.platform }} + path: apps/desktop/src-tauri/target/release/bundle + diff --git a/agents.md b/agents.md index 3e1ea73..f1e4d1c 100644 --- a/agents.md +++ b/agents.md @@ -23,6 +23,24 @@ 5. `pnpm convex:dev` 6. Em outro terminal: `pnpm dev` +## App Desktop (Agente de Máquinas) +- Código: `apps/desktop` (Tauri v2 + Vite). +- Padrões de URL: + - Produção: usa `https://tickets.esdrasrenan.com.br` por padrão (fallback em release). + - Desenvolvimento: use `apps/desktop/.env` (ver `.env.example`). +- Comandos úteis: + - `pnpm -C apps/desktop tauri dev` — dev completo (abre WebView em 1420 + backend Rust). + - `pnpm -C apps/desktop build` — build do frontend (dist). + - `pnpm -C apps/desktop tauri build` — gera instaladores (bundle) por SO. +- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`. +- Fluxo: + 1) Coleta perfil (hostname/OS/MAC/seriais/métricas). + 2) Provisiona via `POST /api/machines/register` com `MACHINE_PROVISIONING_SECRET`. + 3) Envia heartbeats a cada 5 min para `/api/machines/heartbeat` com inventário básico. + 4) Abre `APP_URL/machines/handshake?token=...` para autenticar sessão na UI. + - Segurança: token salvo no cofre do SO (Keyring). Store guarda apenas metadados não sensíveis. + - Endpoint extra: `POST /api/machines/inventory` (atualiza inventário por token ou provisioningSecret). + ## Desenvolvimento local — boas práticas (atualizado) - Ambientes separados: mantenha seu `.env.local` só para DEV e o `.env` da VPS só para PROD. Nunca commitar arquivos `.env`. - Convex em DEV: rode `pnpm convex:dev` e aponte o front para `http://127.0.0.1:3210` via `NEXT_PUBLIC_CONVEX_URL`. @@ -147,6 +165,13 @@ Observações: - CSAT CSV: `/api/reports/csat.csv?range=7d|30d|90d` - SLA CSV: `/api/reports/sla.csv` - Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d` + +## Referências de inventário de máquinas +- UI (Admin > Máquinas): filtros, pesquisa e export detalhados — ver docs/admin-inventory-ui.md +- Endpoints do agente: + - `POST /api/machines/register` + - `POST /api/machines/heartbeat` + - `POST /api/machines/inventory` ## Rotina antes de abrir PR - `pnpm lint` diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example index e6b6e4b..e88b217 100644 --- a/apps/desktop/.env.example +++ b/apps/desktop/.env.example @@ -2,9 +2,13 @@ # Copie para `apps/desktop/.env` e ajuste. # URL da aplicação web (Next.js) que será carregada dentro do app desktop. +# Em produção, o app já usa por padrão: https://tickets.esdrasrenan.com.br VITE_APP_URL=http://localhost:3000 +# Base da API (para as rotas /api/machines/*) +# Se não definir, cai no mesmo valor de VITE_APP_URL +VITE_API_BASE_URL= + # Opcional: IP do host para desenvolvimento com HMR fora do localhost # Ex.: 192.168.0.10 TAURI_DEV_HOST= - diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b381dcf..cef0ec2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,7 +1,49 @@ -# Tauri + Vanilla TS +# Sistema de Chamados — App Desktop (Tauri) -This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript. +Cliente desktop (Tauri v2 + Vite) que: +- Coleta perfil/métricas da máquina via comandos Rust. +- Registra a máquina com um código de provisionamento. +- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`). +- Redireciona para a UI web do sistema após provisionamento. + - Armazena o token da máquina com segurança no cofre do SO (Keyring). -## Recommended IDE Setup +## URLs e ambiente -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +- Em produção, o app usa por padrão `https://tickets.esdrasrenan.com.br`. +- Em desenvolvimento, use `apps/desktop/.env` (copiado do `.env.example`): + +``` +VITE_APP_URL=http://localhost:3000 +# Opcional: se vazio, usa o mesmo do APP_URL +VITE_API_BASE_URL= +``` + +## Comandos + +- Dev (abre janela Tauri e Vite em 1420): + - `pnpm -C apps/desktop tauri dev` +- Build frontend (somente Vite): + - `pnpm -C apps/desktop build` +- Build executável (bundle): + - `pnpm -C apps/desktop tauri build` + +Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/` (AppImage/deb/msi/dmg conforme SO). + +## Pré‑requisitos Tauri +- Rust toolchain instalado. +- Dependências nativas por SO (webkit2gtk no Linux, WebView2/VS Build Tools no Windows, Xcode CLT no macOS). + Consulte https://tauri.app/start/prerequisites/ + +## Fluxo (resumo) +1) Ao abrir, o app coleta o perfil da máquina e exibe um resumo. +2) Informe o “código de provisionamento” (chave definida no servidor) e confirme. +3) O servidor retorna um `machineToken`; o app salva e inicia o heartbeat. +4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI. + +## Segurança do token +- O `machineToken` é salvo no cofre nativo do SO via plugin Keyring (Linux Secret Service, Windows Credential Manager, macOS Keychain). +- O arquivo de preferências (`Store`) guarda apenas metadados não sensíveis (IDs, URLs, datas). + +## Suporte +- Logs do Rust aparecem no console do Tauri (dev) e em stderr (release). Em caso de falha de rede, o app exibe alertas na própria UI. +- Para alterar endpoints/domínios, use as variáveis de ambiente acima. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ea65d0b..58f4e73 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-keyring": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "^2" }, diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2cfd3bf..24244e2 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -71,6 +71,7 @@ dependencies = [ "sysinfo", "tauri", "tauri-build", + "tauri-plugin-keyring", "tauri-plugin-opener", "tauri-plugin-store", "thiserror 1.0.69", @@ -544,6 +545,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -567,7 +578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -580,7 +591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -718,6 +729,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dbus" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.4" @@ -1955,6 +1987,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.5.1", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2003,6 +2050,15 @@ version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "libdbus-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -3417,6 +3473,42 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3866,7 +3958,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" dependencies = [ "bitflags 2.9.4", "block2 0.6.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -4047,6 +4139,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-keyring" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a52455d6472f3c0f9cac1b1f017cfce85e177db32ccd5f4b50e2e31deeaf25e" +dependencies = [ + "keyring", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.0" @@ -5691,6 +5796,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "zerotrie" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c78a41d..4f734c6 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["wry"] } tauri-plugin-opener = "2" tauri-plugin-store = "2.4" +tauri-plugin-keyring = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index abfe45b..e25b38e 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -7,6 +7,7 @@ use parking_lot::Mutex; use serde::Serialize; use serde_json::json; use sysinfo::{Networks, System}; +use std::collections::HashMap; use tauri::async_runtime::{self, JoinHandle}; use tokio::sync::Notify; @@ -131,13 +132,30 @@ fn collect_serials() -> Vec { } fn collect_network_addrs() -> Vec { + // Mapa name -> mac via sysinfo (mais estável que get_if_addrs para MAC) + let mut mac_by_name: HashMap = HashMap::new(); + let mut networks = Networks::new(); + networks.refresh_list(); + networks.refresh(); + for (name, data) in networks.iter() { + let bytes = data.mac_address().0; + if bytes.iter().any(|b| *b != 0) { + let mac = bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":"); + mac_by_name.insert(name.to_string(), mac); + } + } + let mut entries = Vec::new(); if let Ok(ifaces) = get_if_addrs::get_if_addrs() { for iface in ifaces { - let name = iface.name; - let mac = iface.mac.map(|m| m.to_string()); + let name = iface.name.clone(); let addr = iface.ip(); let ip = addr.to_string(); + let mac = mac_by_name.get(&name).cloned(); entries.push(json!({ "name": name, "mac": mac, @@ -148,12 +166,14 @@ fn collect_network_addrs() -> Vec { entries } -fn collect_disks(system: &System) -> Vec { +fn collect_disks(_system: &System) -> Vec { + // API de discos mudou no sysinfo 0.31: usamos Disks diretamente let mut out = Vec::new(); - for d in system.disks() { + 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 fs = d.file_system().to_string_lossy().to_string(); let total = d.total_space(); let avail = d.available_space(); out.push(json!({ @@ -196,6 +216,28 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { if let Some(obj) = inventory.as_object_mut() { obj.insert("services".into(), services); } + + // Informações estendidas (lsblk/lspci/lsusb/smartctl) + let extended = collect_linux_extended(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("extended".into(), extended); + } + } + + #[cfg(target_os = "windows")] + { + let extended = collect_windows_extended(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("extended".into(), extended); + } + } + + #[cfg(target_os = "macos")] + { + let extended = collect_macos_extended(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("extended".into(), extended); + } } json!({ "inventory": inventory }) @@ -276,6 +318,146 @@ fn collect_services_linux() -> serde_json::Value { json!([]) } +#[cfg(target_os = "linux")] +fn collect_linux_extended() -> serde_json::Value { + use std::process::Command; + // lsblk em JSON (block devices) + let block_json = Command::new("sh") + .arg("-lc") + .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(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_else(|| json!({})); + + // lspci e lsusb — texto livre (depende de pacotes pciutils/usbutils) + let lspci = Command::new("sh") + .arg("-lc") + .arg("lspci 2>/dev/null || true") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + let lsusb = Command::new("sh") + .arg("-lc") + .arg("lsusb 2>/dev/null || true") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + + // smartctl (se disponível) por disco + let mut smart: Vec = Vec::new(); + if let Ok(out) = std::process::Command::new("sh") + .arg("-lc") + .arg("command -v smartctl >/dev/null 2>&1 && echo yes || echo no") + .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()) + { + 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(""); + if t == "disk" && !name.is_empty() { + let path = format!("/dev/{}", name); + if let Ok(out) = Command::new("sh") + .arg("-lc") + .arg(format!("smartctl -H -j {} 2>/dev/null || true", path)) + .output() { + if out.status.success() || !out.stdout.is_empty() { + if let Ok(val) = serde_json::from_slice::(&out.stdout) { + smart.push(val); + } + } + } + } + } + } + } + } + + json!({ + "linux": { + "lsblk": block_json, + "lspci": lspci, + "lsusb": lsusb, + "smart": smart, + } + }) +} + +#[cfg(target_os = "windows")] +fn collect_windows_extended() -> serde_json::Value { + use std::process::Command; + fn ps(cmd: &str) -> Option { + let ps_cmd = format!( + "$ErrorActionPreference='SilentlyContinue'; {} | ConvertTo-Json -Depth 4 -Compress", + cmd + ); + let out = Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(ps_cmd) + .output() + .ok()?; + if out.stdout.is_empty() { return None; } + serde_json::from_slice::(&out.stdout).ok() + } + + let software = ps(r#"@(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion, Publisher"#) + .unwrap_or_else(|| json!([])); + let services = ps("Get-Service | Select-Object Name,Status,DisplayName").unwrap_or_else(|| json!([])); + let 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!([])); + + json!({ + "windows": { + "software": software, + "services": services, + "defender": defender, + "hotfix": hotfix, + } + }) +} + +#[cfg(target_os = "macos")] +fn collect_macos_extended() -> serde_json::Value { + use std::process::Command; + // 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") + .output() + .ok() + .and_then(|out| serde_json::from_slice::(&out.stdout).ok()) + .unwrap_or_else(|| json!({})); + let pkgs = Command::new("sh") + .arg("-lc") + .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::>()) + .unwrap_or_default(); + let services_text = Command::new("sh") + .arg("-lc") + .arg("launchctl list 2>/dev/null || true") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + + json!({ + "macos": { + "systemProfiler": profiler, + "packages": pkgs, + "launchctl": services_text, + } + }) +} + fn collect_system() -> System { let mut system = System::new_all(); system.refresh_all(); @@ -403,7 +585,6 @@ async fn post_heartbeat(base_url: &str, token: &str, status: Option) -> struct HeartbeatHandle { token: String, base_url: String, - status: Option, stop_signal: Arc, join_handle: JoinHandle<()>, } @@ -489,7 +670,6 @@ impl AgentRuntime { let handle = HeartbeatHandle { token, base_url: sanitized_base, - status, stop_signal, join_handle, }; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ec4ca58..1a6553a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod agent; use agent::{collect_profile, AgentRuntime, MachineProfile}; use tauri_plugin_store::Builder as StorePluginBuilder; +use tauri_plugin_keyring as keyring; #[tauri::command] fn collect_machine_profile() -> Result { @@ -33,6 +34,7 @@ pub fn run() { .manage(AgentRuntime::new()) .plugin(tauri_plugin_opener::init()) .plugin(StorePluginBuilder::default().build()) + .plugin(keyring::init()) .invoke_handler(tauri::generate_handler![ collect_machine_profile, start_machine_agent, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 838c96a..269dd86 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core" import { Store } from "@tauri-apps/plugin-store" +import { getPassword, setPassword, deletePassword } from "@tauri-apps/plugin-keyring" type MachineOs = { name: string @@ -47,7 +48,6 @@ type MachineRegisterResponse = { type AgentConfig = { machineId: string - machineToken: string tenantId?: string | null companySlug?: string | null machineEmail?: string | null @@ -70,7 +70,11 @@ declare global { } const STORE_FILENAME = "machine-agent.json" -const DEFAULT_APP_URL = "http://localhost:3000" +// Defaults: em produção, apontamos para o domínio público; em dev, localhost +const DEFAULT_APP_URL = + import.meta.env.MODE === "production" + ? "https://tickets.esdrasrenan.com.br" + : "http://localhost:3000" function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { const trimmed = (value ?? fallback).trim() @@ -81,7 +85,10 @@ function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { } const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL) -const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl) +const apiBaseUrl = normalizeUrl( + import.meta.env.VITE_API_BASE_URL, + appUrl +) const alertElement = document.getElementById("alert-container") as HTMLDivElement | null const contentElement = document.getElementById("content") as HTMLDivElement | null @@ -107,6 +114,9 @@ function setStatus(message: string) { let storeInstance: Store | null = null +const KEYRING_SERVICE = "sistema-de-chamados" +const KEYRING_ACCOUNT = "machine-token" + async function ensureStoreLoaded(): Promise { if (!storeInstance) { try { @@ -141,6 +151,24 @@ async function clearConfig() { const store = await ensureStoreLoaded() await store.delete("config") await store.save() + try { + await deletePassword({ service: KEYRING_SERVICE, account: KEYRING_ACCOUNT }) + } catch { + // ignore + } +} + +async function getMachineToken(): Promise { + try { + const token = await getPassword({ service: KEYRING_SERVICE, account: KEYRING_ACCOUNT }) + return token && token.length > 0 ? token : null + } catch { + return null + } +} + +async function setMachineToken(token: string) { + await setPassword({ service: KEYRING_SERVICE, account: KEYRING_ACCOUNT, password: token }) } async function collectMachineProfile(): Promise { @@ -148,9 +176,11 @@ async function collectMachineProfile(): Promise { } async function startHeartbeat(config: AgentConfig) { + const token = await getMachineToken() + if (!token) throw new Error("Token da máquina ausente no cofre seguro") await invoke("start_machine_agent", { baseUrl: config.apiBaseUrl, - token: config.machineToken, + token, status: "online", intervalSeconds: 300, }) @@ -348,9 +378,10 @@ async function handleRegister(profile: MachineProfile, form: HTMLFormElement) { } const data = (await response.json()) as MachineRegisterResponse + // Guarda token com segurança no Keyring + await setMachineToken(data.machineToken) const config: AgentConfig = { machineId: data.machineId, - machineToken: data.machineToken, tenantId: data.tenantId ?? null, companySlug: data.companySlug ?? null, machineEmail: data.machineEmail ?? null, @@ -387,8 +418,16 @@ async function handleRegister(profile: MachineProfile, form: HTMLFormElement) { } function redirectToApp(config: AgentConfig) { - const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(config.machineToken)}` - window.location.replace(url) + const perform = async () => { + const token = await getMachineToken() + if (!token) { + setAlert("Token da máquina não encontrado. Reprovisione a máquina.", "error") + return + } + const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(token)}` + window.location.replace(url) + } + void perform() } async function ensureHeartbeat(config: AgentConfig): Promise { @@ -411,7 +450,8 @@ async function bootstrap() { try { const stored = await loadConfig() - if (stored?.machineToken) { + const token = await getMachineToken() + if (stored && token) { const updated = await ensureHeartbeat(stored) renderRegistered(updated) return diff --git a/convex/machines.ts b/convex/machines.ts index a131903..037d810 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -116,6 +116,112 @@ function mergeMetadata(current: unknown, patch: Record) { return { ...(current as Record), ...patch } } +type PostureFinding = { + kind: "CPU_HIGH" | "SERVICE_DOWN" | "SMART_FAIL" + message: string + severity: "warning" | "critical" +} + +async function createTicketForAlert( + ctx: MutationCtx, + tenantId: string, + companyId: Id<"companies"> | undefined, + subject: string, + summary: string +) { + const actorEmail = process.env["MACHINE_ALERTS_TICKET_REQUESTER_EMAIL"] ?? "admin@sistema.dev" + const actor = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q: any) => q.eq("tenantId", tenantId).eq("email", actorEmail)) + .unique() + if (!actor) return null + + // pick first category/subcategory if not configured + const category = await ctx.db.query("ticketCategories").withIndex("by_tenant", (q: any) => q.eq("tenantId", tenantId)).first() + if (!category) return null + const subcategory = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q: any) => q.eq("categoryId", category._id)) + .first() + if (!subcategory) return null + + try { + const id = await (await import("./tickets")) + .create + .handler(ctx as any, { + actorId: actor._id, + tenantId, + subject, + summary, + priority: "Alta", + channel: "Automação", + queueId: undefined, + requesterId: actor._id, + assigneeId: undefined, + categoryId: category._id, + subcategoryId: subcategory._id, + customFields: undefined, + } as any) + return id + } catch (error) { + console.error("[machines.alerts] Falha ao criar ticket:", error) + return null + } +} + +async function evaluatePostureAndMaybeRaise( + ctx: MutationCtx, + machine: Doc<"machines">, + args: { metrics?: any; inventory?: any; metadata?: any } +) { + const findings: PostureFinding[] = [] + + const metrics = args.metrics ?? (args.metadata?.metrics ?? null) + if (metrics && typeof metrics === "object") { + const usage = Number((metrics as any).cpuUsagePercent ?? (metrics as any).cpu_usage_percent) + if (Number.isFinite(usage) && usage >= 90) { + findings.push({ kind: "CPU_HIGH", message: `CPU acima de ${usage.toFixed(0)}%`, severity: "warning" }) + } + } + + const inventory = args.inventory ?? (args.metadata?.inventory ?? null) + if (inventory && typeof inventory === "object") { + const services = (inventory as any).services + if (Array.isArray(services)) { + const criticalDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? "").toLowerCase() !== "running") + if (criticalDown) { + findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${criticalDown.name}`, severity: "warning" }) + } + } + const smart = (inventory as any).extended?.linux?.smart + if (Array.isArray(smart)) { + const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false) + if (failing) { + findings.push({ kind: "SMART_FAIL", message: `Disco com SMART em falha`, severity: "critical" }) + } + } + } + + if (!findings.length) return + + const now = Date.now() + const record = { + postureAlerts: findings, + lastPostureAt: now, + } + const prevMeta = (machine.metadata && typeof machine.metadata === "object") ? (machine.metadata as Record) : null + const lastAtPrev = typeof prevMeta?.lastPostureAt === "number" ? (prevMeta!.lastPostureAt as number) : 0 + await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) + + if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return + // Evita excesso: não cria ticket se já houve alerta nos últimos 30 minutos + if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return + + const subject = `Alerta de máquina: ${machine.hostname}` + const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ") + await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary) +} + export const register = mutation({ args: { provisioningSecret: v.string(), @@ -307,6 +413,10 @@ export const upsertInventory = mutation({ }) } + // Evaluate posture/alerts based on provided metrics/inventory + const machine = (await ctx.db.get(machineId)) as Doc<"machines"> + await evaluatePostureAndMaybeRaise(ctx, machine, { metrics: args.metrics, inventory: args.inventory }) + return { machineId, tenantId, @@ -360,6 +470,10 @@ export const heartbeat = mutation({ expiresAt: now + getTokenTtlMs(), }) + // Evaluate posture/alerts & optionally create ticket + const fresh = (await ctx.db.get(machine._id)) as Doc<"machines"> + await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata }) + return { ok: true, machineId: machine._id, @@ -437,6 +551,8 @@ export const listByTenant = query({ let metrics: Record | null = null let inventory: Record | null = null + let postureAlerts: Array> | null = null + let lastPostureAt: number | null = null if (metadata && typeof metadata === "object") { const metaRecord = metadata as Record @@ -446,6 +562,12 @@ export const listByTenant = query({ if (metaRecord.inventory && typeof metaRecord.inventory === "object") { inventory = metaRecord.inventory as Record } + if (Array.isArray(metaRecord.postureAlerts)) { + postureAlerts = metaRecord.postureAlerts as Array> + } + if (typeof metaRecord.lastPostureAt === "number") { + lastPostureAt = metaRecord.lastPostureAt as number + } } return { @@ -476,6 +598,8 @@ export const listByTenant = query({ : null, metrics, inventory, + postureAlerts, + lastPostureAt, } }) ) diff --git a/docs/OPERACAO-PRODUCAO.md b/docs/OPERACAO-PRODUCAO.md index e8d24b3..32cc4d4 100644 --- a/docs/OPERACAO-PRODUCAO.md +++ b/docs/OPERACAO-PRODUCAO.md @@ -134,6 +134,20 @@ healthcheck: Observação: o CI já força `docker service update --force` após `stack deploy` e passa `RELEASE_SHA` no ambiente para variar a spec em todo commit, assegurando rollout. +## App Desktop (Tauri) +- Build local por SO: + - Linux: `pnpm -C apps/desktop tauri build` + - Windows/macOS: executar o mesmo comando no respectivo sistema (o Tauri gera .msi/.dmg/.app). +- Por padrão, o executável em modo release usa `https://tickets.esdrasrenan.com.br` como `APP_URL` e `API_BASE_URL`. +- Para customizar, crie `apps/desktop/.env` com `VITE_APP_URL` e `VITE_API_BASE_URL`. +- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`. + +### Alertas de postura (opcional) +- Variáveis de ambiente para geração automática de tickets em alertas de postura (CPU alta, serviço parado, SMART em falha): + - `MACHINE_ALERTS_CREATE_TICKETS=true|false` (padrão: true) + - `MACHINE_ALERTS_TICKET_REQUESTER_EMAIL=admin@sistema.dev` (usuário solicitante dos tickets automáticos) + + ### Dashboard (opcional) Você pode expor o painel do Convex para inspeção em produção. @@ -263,7 +277,7 @@ Benefícios - `MAILER_SENDER_EMAIL` com erro de parsing: - Adicionar aspas no `.env`. - `pnpm` reclama de workspace: - - `pnpm-workspace.yaml` já aponta só para `.` (evita apps/desktop no deploy). + - O `pnpm-workspace.yaml` inclui `apps/desktop`. No deploy do web isso não impacta pois usamos filtros/paths do projeto. Se preferir isolar, ajuste para apenas `.`. - Portainer erro de bind relativo: - Usar caminho absoluto `/srv/apps/sistema:/app` no stack (feito). - Prisma CLI “not found”: diff --git a/docs/SETUP-HISTORICO.md b/docs/SETUP-HISTORICO.md index 585ce42..e06bcfb 100644 --- a/docs/SETUP-HISTORICO.md +++ b/docs/SETUP-HISTORICO.md @@ -16,7 +16,7 @@ - `.github/workflows/ci-cd-web-desktop.yml` — pipeline de deploy web + desktop + deploy do Convex. - `docs/OPERACAO-PRODUCAO.md` — runbook de operação (deploy, seeds, CI/CD, troubleshooting). - `docs/SETUP-HISTORICO.md` — este histórico. -- `pnpm-workspace.yaml` — restrito a `packages: ['.']` para evitar o apps/desktop no deploy (corrige lockfile/CI). +- `pnpm-workspace.yaml` — inclui `packages: ['.', 'apps/desktop']` para permitir comandos e builds do desktop. No deploy do web usamos filtros/paths; se preferir isolar, volte para apenas `'.'`. - `scripts/deploy-from-git.sh` — fallback de deploy pull‑based na VPS (sem Actions). ## Gestão de .env @@ -60,8 +60,8 @@ - Solução: `NPM_CONFIG_PRODUCTION=false` e `pnpm install --prod=false` no container de build. 5) Lockfile/Workspace quebrando CI -- Causa: incluir `apps/desktop` no workspace. -- Solução: `pnpm-workspace.yaml` com `packages: ['.']`. +- Causa: conflito de versões quando o desktop entrou no workspace. +- Solução: hoje mantemos `['.', 'apps/desktop']` e usamos filtros no CI/deploy. Alternativa: isolar o desktop fora do workspace. 6) Bind relativo no Swarm/Portainer - Causa: `./:/app` vira path inválido. @@ -86,4 +86,3 @@ - Fixar versão do `convex-backend` (ao invés de `latest`) para releases mais controladas. - Substituir bind‑mount por imagens construídas no CI (tempo de deploy menor, reprodutibilidade). - Adicionar cache de dependências pnpm no container de build. - diff --git a/docs/admin-inventory-ui.md b/docs/admin-inventory-ui.md new file mode 100644 index 0000000..30fb9b3 --- /dev/null +++ b/docs/admin-inventory-ui.md @@ -0,0 +1,31 @@ +# Admin UI — Inventário por máquina + +A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação. + +## Filtros e busca +- Busca livre por hostname, e-mail, MAC e número de série. +- Filtro por status: Online, Offline, Desconhecido. +- Filtro por sistema operacional (OS). +- Filtro por empresa (slug). +- Marcação “Somente com alertas” para investigar postura. + +## Painel de detalhes +- Resumo: hostname, status, e-mail vinculado, SO/arch, sincronização do token (expiração/uso). +- Métricas recentes: CPU/Memory/Disco. +- Inventário básico: hardware (CPU/mem/serial), rede (IP/MAC), labels. +- Discos e partições: nome, mount, FS, capacidade, livre. +- Inventário estendido (varia por SO): + - Linux: SMART (OK/ALERTA), `lspci`, `lsusb` (texto), `lsblk` (interno para discos). + - Windows: serviços (amostra), softwares instalados (amostra), Defender. + - macOS: pacotes (`pkgutil`), serviços (`launchctl`). +- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação. + +## Exportação +- Copiar JSON: copia para a área de transferência todo o inventário exibido (métricas + inventário + alertas). +- Exportar JSON: baixa um arquivo `inventario-.json` com os dados atuais. + +## Notas +- Os dados vêm de duas fontes: + - Agente desktop (Tauri): envia inventário básico + estendido por SO via `POST /api/machines/heartbeat`. + - FleetDM (osquery): opcionalmente, via webhook `POST /api/integrations/fleet/hosts`. +- Postura é avaliada no servidor (Convex) a cada heartbeat/upsert. Tickets automáticos podem ser gerados se habilitado. diff --git a/docs/desktop-build.md b/docs/desktop-build.md new file mode 100644 index 0000000..a2162bc --- /dev/null +++ b/docs/desktop-build.md @@ -0,0 +1,46 @@ +# Build do App Desktop (Tauri) + +Guia rápido para gerar instaladores do app desktop em cada sistema operacional. + +## Pré‑requisitos +- Node.js 20+ e pnpm (Corepack habilitado): + - `corepack enable && corepack prepare pnpm@9 --activate` +- Rust toolchain (stable) instalado. +- Dependências nativas por SO: + - Linux (Debian/Ubuntu): + ```bash + sudo apt update && sudo apt install -y \ + libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + ``` + - Windows: Visual Studio Build Tools + WebView2 Runtime. + - macOS: Xcode Command Line Tools. + +## Configuração de URLs +- Produção: por padrão o app usa `https://tickets.esdrasrenan.com.br`. +- Desenvolvimento: crie `apps/desktop/.env` a partir de `apps/desktop/.env.example` e ajuste: + ``` + VITE_APP_URL=http://localhost:3000 + VITE_API_BASE_URL= + ``` + +## Comandos de build +- Linux/macOS/Windows (rodar no próprio sistema): + ```bash + pnpm -C apps/desktop tauri build + ``` +- Apenas frontend (Vite): + ```bash + pnpm -C apps/desktop build + ``` + +Saída de artefatos: `apps/desktop/src-tauri/target/release/bundle/`. + +## Dicas +- Primeira compilação do Rust pode demorar (download de crates e linkedição). +- Se o link‑editor for lento no Linux, considere instalar `lld` e usar: + ```bash + RUSTFLAGS="-Clink-arg=-fuse-ld=lld" pnpm -C apps/desktop tauri build + ``` +- Para logs detalhados em dev, rode `pnpm -C apps/desktop tauri dev`. + diff --git a/docs/plano-app-desktop-maquinas.md b/docs/plano-app-desktop-maquinas.md index fce8da4..0aa3d45 100644 --- a/docs/plano-app-desktop-maquinas.md +++ b/docs/plano-app-desktop-maquinas.md @@ -14,6 +14,7 @@ - Web atual permanece operacional com login por usuário/senha. - Novas features serão adições compatíveis (machine login opcional). - Melhor abordagem para inventário: usar **osquery + FleetDM** (stack pronta) integrando registros no Convex. + - Agente desktop coleta inventário básico + estendido por SO (Linux: dpkg/rpm + systemd + lsblk/lspci/lsusb/smartctl; Windows: WMI/registry via PowerShell; macOS: system_profiler/pkgutil/launchctl) e envia via heartbeat e/ou `/api/machines/inventory`. ## Marcos & Progresso | Macro-entrega | Status | Observações | @@ -22,6 +23,7 @@ | Projeto Tauri inicial apontando para UI Next | 🔄 Em andamento | Estrutura `apps/desktop` criada; pendente testar build após instalar toolchain Rust. | | Schema Convex + tokens de máquina | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. | | API de registro/heartbeat e exchange Better Auth | 🔄 Em andamento | Endpoints `/api/machines/*` disponíveis; falta testar fluxo end-to-end com app desktop. | +| Endpoint upsert de inventário dedicado | ✅ Concluído | `POST /api/machines/inventory` (modo por token ou provisioningSecret). | | Integração FleetDM → Convex (inventário básico) | 🔄 Em andamento | Endpoint `/api/integrations/fleet/hosts` criado; falta validar payload real e ajustes de métricas/empresa. | | Admin > Máquinas (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/machines` exibe parque completo com status ao vivo, inventário e métricas. | | Ajustes na UI/Next para sessão por máquina | ⏳ A fazer | Detectar token e exibir info da máquina em tickets. | @@ -38,8 +40,9 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer. - **Infra extra:** Endpoints públicos para updater do Tauri, armazenamento de inventário seguro, certificados para assinatura de builds. ## Próximos Passos Imediatos -1. Instalar toolchain Tauri local (Rust + dependências nativas) e testar `pnpm --filter appsdesktop tauri dev` apontando para o Next (`pnpm dev`). -2. Detalhar fluxo de provisioning de máquina no Convex e atualizar este documento. +1. Finalizar coletores específicos para Windows/macOS (ajustes finos e parse de dados). +2. Adicionar UI administrativa para visualizar inventário estendido e alertas de postura por máquina. +3. Refinar regras (janela temporal para CPU alta, whitelists de serviços, severidades por SMART). ## Notas de Implementação (Atual) - Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`. diff --git a/src/app/api/machines/inventory/route.ts b/src/app/api/machines/inventory/route.ts new file mode 100644 index 0000000..20c6ceb --- /dev/null +++ b/src/app/api/machines/inventory/route.ts @@ -0,0 +1,111 @@ +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { env } from "@/lib/env" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" + +const tokenModeSchema = z.object({ + machineToken: z.string().min(1), + hostname: z.string().optional(), + os: z + .object({ + name: z.string(), + version: z.string().optional(), + architecture: z.string().optional(), + }) + .optional(), + metrics: z.record(z.string(), z.unknown()).optional(), + inventory: z.record(z.string(), z.unknown()).optional(), +}) + +const provisioningModeSchema = z.object({ + provisioningSecret: z.string().min(1), + tenantId: z.string().optional(), + companySlug: z.string().optional(), + hostname: z.string().min(1), + os: z.object({ + name: z.string().min(1), + version: z.string().optional(), + architecture: z.string().optional(), + }), + macAddresses: z.array(z.string()).default([]), + serialNumbers: z.array(z.string()).default([]), + inventory: z.record(z.string(), z.unknown()).optional(), + metrics: z.record(z.string(), z.unknown()).optional(), + registeredBy: z.string().optional(), +}) + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function POST(request: Request) { + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS) + } + + let raw: unknown + try { + raw = await request.json() + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + request.headers.get("origin"), + CORS_METHODS + ) + } + + const client = new ConvexHttpClient(convexUrl) + + // Modo A: com token da máquina (usa heartbeat para juntar inventário) + const tokenParsed = tokenModeSchema.safeParse(raw) + if (tokenParsed.success) { + try { + const result = await client.mutation(api.machines.heartbeat, { + machineToken: tokenParsed.data.machineToken, + hostname: tokenParsed.data.hostname, + os: tokenParsed.data.os, + metrics: tokenParsed.data.metrics, + inventory: tokenParsed.data.inventory, + }) + return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, request.headers.get("origin"), CORS_METHODS) + } catch (error) { + console.error("[machines.inventory:token] Falha ao atualizar inventário", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, request.headers.get("origin"), CORS_METHODS) + } + } + + // Modo B: com segredo de provisionamento (upsert sem token) + const provParsed = provisioningModeSchema.safeParse(raw) + if (provParsed.success) { + try { + const result = await client.mutation(api.machines.upsertInventory, { + provisioningSecret: provParsed.data.provisioningSecret, + tenantId: provParsed.data.tenantId ?? DEFAULT_TENANT_ID, + companySlug: provParsed.data.companySlug ?? undefined, + hostname: provParsed.data.hostname, + os: provParsed.data.os, + macAddresses: provParsed.data.macAddresses, + serialNumbers: provParsed.data.serialNumbers, + inventory: provParsed.data.inventory, + metrics: provParsed.data.metrics, + registeredBy: provParsed.data.registeredBy ?? "agent:inventory", + }) + return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, request.headers.get("origin"), CORS_METHODS) + } catch (error) { + console.error("[machines.inventory:prov] Falha ao fazer upsert de inventário", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, request.headers.get("origin"), CORS_METHODS) + } + } + + return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS) +} + diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index e5ab691..89d7fac 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -10,6 +10,9 @@ import { ClipboardCopy, ServerCog } from "lucide-react" import { api } from "@/convex/_generated/api" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Table, @@ -59,6 +62,28 @@ type MachineInventory = { detailUpdatedAt?: string osqueryVersion?: string } + // Dados enviados pelo agente desktop (inventário básico/estendido) + disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }> + network?: any // pode ser objeto (Fleet) ou array de interfaces (agente desktop) + extended?: { + linux?: { + lsblk?: any + lspci?: string + lsusb?: string + smart?: any[] + } + windows?: { + software?: any + services?: any + defender?: any + hotfix?: any + } + macos?: { + systemProfiler?: any + packages?: string[] + launchctl?: string + } + } } type MachinesQueryItem = { @@ -87,6 +112,8 @@ type MachinesQueryItem = { } | null metrics: MachineMetrics inventory: MachineInventory | null + postureAlerts?: Array> | null + lastPostureAt?: number | null } function useMachinesQuery(tenantId: string): MachinesQueryItem[] { @@ -158,6 +185,11 @@ function getStatusVariant(status?: string | null) { export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { const machines = useMachinesQuery(tenantId) const [selectedId, setSelectedId] = useState(null) + const [q, setQ] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [osFilter, setOsFilter] = useState("all") + const [companyFilter, setCompanyFilter] = useState("all") + const [onlyAlerts, setOnlyAlerts] = useState(false) useEffect(() => { if (machines.length === 0) { @@ -171,7 +203,42 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { } }, [machines, selectedId]) - const selectedMachine = useMemo(() => machines.find((item) => item.id === selectedId) ?? null, [machines, selectedId]) + const osOptions = useMemo(() => { + const set = new Set() + machines.forEach((m) => m.osName && set.add(m.osName)) + return Array.from(set).sort() + }, [machines]) + + const companyOptions = useMemo(() => { + const set = new Set() + machines.forEach((m) => m.companySlug && set.add(m.companySlug)) + return Array.from(set).sort() + }, [machines]) + + const filteredMachines = useMemo(() => { + const text = q.trim().toLowerCase() + return machines.filter((m) => { + if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false + if (statusFilter !== "all") { + const s = (m.status ?? "unknown").toLowerCase() + if (s !== statusFilter) return false + } + if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) return false + if (companyFilter !== "all" && (m.companySlug ?? "") !== companyFilter) return false + if (!text) return true + const hay = [ + m.hostname, + m.authEmail ?? "", + (m.macAddresses ?? []).join(" "), + (m.serialNumbers ?? []).join(" "), + ] + .join(" ") + .toLowerCase() + return hay.includes(text) + }) + }, [machines, q, statusFilter, osFilter, companyFilter, onlyAlerts]) + + const selectedMachine = useMemo(() => filteredMachines.find((item) => item.id === selectedId) ?? filteredMachines[0] ?? null, [filteredMachines, selectedId]) return (
@@ -181,6 +248,49 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { Sincronizadas via agente local ou Fleet. Atualiza em tempo real. +
+
+ setQ(e.target.value)} placeholder="Buscar hostname, e-mail, MAC, serial..." /> +
+ + + + + +
{machines.length === 0 ? ( ) : ( @@ -196,7 +306,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { - {machines.map((machine: MachinesQueryItem) => ( + {filteredMachines.map((machine: MachinesQueryItem) => ( setSelectedId(machine.id)} @@ -276,6 +386,11 @@ function MachineDetails({ machine }: MachineDetailsProps) { const software = metadata?.software ?? null const labels = metadata?.labels ?? null const fleet = metadata?.fleet ?? null + const disks = Array.isArray(metadata?.disks) ? metadata?.disks ?? [] : [] + const extended = (metadata as any)?.extended ?? null + const linuxExt = extended?.linux ?? null + const windowsExt = extended?.windows ?? null + const macosExt = extended?.macos ?? null const lastHeartbeatDate = machine?.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null const tokenExpiry = machine?.token?.expiresAt ? new Date(machine.token.expiresAt) : null @@ -291,6 +406,49 @@ function MachineDetails({ machine }: MachineDetailsProps) { } } + const exportInventoryJson = () => { + if (!machine) return + const payload = { + id: machine.id, + hostname: machine.hostname, + status: machine.status, + lastHeartbeatAt: machine.lastHeartbeatAt, + metrics, + inventory: metadata, + postureAlerts: machine.postureAlerts ?? null, + lastPostureAt: machine.lastPostureAt ?? null, + } + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `inventario-${machine.hostname}.json` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } + + const copyInventoryJson = async () => { + if (!machine) return + const payload = { + id: machine.id, + hostname: machine.hostname, + status: machine.status, + lastHeartbeatAt: machine.lastHeartbeatAt, + metrics, + inventory: metadata, + postureAlerts: machine.postureAlerts ?? null, + lastPostureAt: machine.lastPostureAt ?? null, + } + try { + await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)) + toast.success("Inventário copiado para a área de transferência.") + } catch { + toast.error("Não foi possível copiar o inventário.") + } + } + return ( @@ -375,11 +533,11 @@ function MachineDetails({ machine }: MachineDetailsProps) { {metrics && typeof metrics === "object" ? ( -
-

Métricas recentes

- -
- ) : null} +
+

Métricas recentes

+ +
+ ) : null} {hardware || network || (labels && labels.length > 0) ? (
@@ -407,7 +565,31 @@ function MachineDetails({ machine }: MachineDetailsProps) {
) : null} - {network ? ( + {Array.isArray(network) ? ( +
+

Rede (interfaces)

+
+ + + + Interface + MAC + IP + + + + {(network as any[]).map((iface, idx) => ( + + {iface?.name ?? "—"} + {iface?.mac ?? "—"} + {iface?.ip ?? "—"} + + ))} + +
+
+
+ ) : network ? (

Rede

@@ -444,6 +626,183 @@ function MachineDetails({ machine }: MachineDetailsProps) { ) : null} + {/* Discos (agente) */} + {disks.length > 0 ? ( +
+

Discos e partições

+
+ + + + Nome + Mount + FS + Capacidade + Livre + + + + {disks.map((d, idx) => ( + + {d.name ?? "—"} + {d.mountPoint ?? "—"} + {d.fs ?? "—"} + {formatBytes(Number(d.totalBytes))} + {formatBytes(Number(d.availableBytes))} + + ))} + +
+
+
+ ) : null} + + {/* Inventário estendido por SO */} + {extended ? ( +
+
+

Inventário estendido

+

Dados ricos coletados pelo agente, variam por sistema operacional.

+
+ + {/* Linux */} + {linuxExt ? ( +
+ {Array.isArray(linuxExt.smart) && linuxExt.smart.length > 0 ? ( +
+

SMART

+
+ {linuxExt.smart.map((s: any, idx: number) => { + const ok = s?.smart_status?.passed !== false + const model = s?.model_name ?? s?.model_family ?? "Disco" + const serial = s?.serial_number ?? s?.device?.name ?? "—" + return ( +
+ {model} ({serial}) + {ok ? "OK" : "ALERTA"} +
+ ) + })} +
+
+ ) : null} + + {linuxExt.lspci ? ( +
+

PCI

+
{linuxExt.lspci}
+
+ ) : null} + {linuxExt.lsusb ? ( +
+

USB

+
{linuxExt.lsusb}
+
+ ) : null} +
+ ) : null} + + {/* Windows */} + {windowsExt ? ( +
+ {Array.isArray(windowsExt.services) ? ( +
+

Serviços

+
+ + + + Nome + Exibição + Status + + + + {(windowsExt.services as any[]).slice(0, 10).map((svc: any, i: number) => ( + + {svc?.Name ?? "—"} + {svc?.DisplayName ?? "—"} + {svc?.Status ?? "—"} + + ))} + +
+
+
+ ) : null} + + {Array.isArray(windowsExt.software) ? ( +
+

Softwares (amostra)

+
    + {(windowsExt.software as any[]).slice(0, 8).map((s: any, i: number) => ( +
  • + {s?.DisplayName ?? s?.name ?? "—"} + {s?.DisplayVersion ? {s.DisplayVersion} : null} + {s?.Publisher ? · {s.Publisher} : null} +
  • + ))} +
+
+ ) : null} + + {windowsExt.defender ? ( +
+

Defender

+
+ + +
+
+ ) : null} +
+ ) : null} + + {/* macOS */} + {macosExt ? ( +
+ {Array.isArray(macosExt.packages) && macosExt.packages.length > 0 ? ( +
+

Pacotes

+

{macosExt.packages.slice(0, 8).join(", ")}

+
+ ) : null} + {macosExt.launchctl ? ( +
+

Launchctl

+
{macosExt.launchctl}
+
+ ) : null} +
+ ) : null} +
+ ) : null} + + {/* Postura/Alertas */} + {Array.isArray(machine?.postureAlerts) && machine?.postureAlerts?.length ? ( +
+

Alertas de postura

+
+ {machine?.postureAlerts?.map((a: any, i: number) => ( +
+ {a?.message ?? a?.kind ?? "Alerta"} + {String(a?.kind ?? "ALERTA")} +
+ ))} +
+

+ Última avaliação: {machine?.lastPostureAt ? formatRelativeTime(new Date(machine.lastPostureAt)) : "—"} +

+
+ ) : null} + +
+ + +
{fleet ? (