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

This commit is contained in:
Esdras Renan 2025-10-09 22:08:20 -03:00
parent c2050f311a
commit 479c66d52c
18 changed files with 1205 additions and 38 deletions

66
.github/workflows/desktop-release.yml vendored Normal file
View file

@ -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

View file

@ -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`.
@ -148,6 +166,13 @@ Observações:
- 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`
- `pnpm build --turbopack`

View file

@ -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=

View file

@ -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.

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-keyring": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2"
},

View file

@ -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"

View file

@ -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"] }

View file

@ -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<String> {
}
fn collect_network_addrs() -> Vec<serde_json::Value> {
// Mapa name -> mac via sysinfo (mais estável que get_if_addrs para MAC)
let mut mac_by_name: HashMap<String, String> = 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::<Vec<_>>()
.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<serde_json::Value> {
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();
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::<serde_json::Value>(&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<serde_json::Value> = 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::<serde_json::Value>(&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<serde_json::Value> {
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::<serde_json::Value>(&out.stdout).ok()
}
let software = ps(r#"@(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion, Publisher"#)
.unwrap_or_else(|| json!([]));
let services = ps("Get-Service | Select-Object Name,Status,DisplayName").unwrap_or_else(|| json!([]));
let 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::<serde_json::Value>(&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::<Vec<_>>())
.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<String>) ->
struct HeartbeatHandle {
token: String,
base_url: String,
status: Option<String>,
stop_signal: Arc<Notify>,
join_handle: JoinHandle<()>,
}
@ -489,7 +670,6 @@ impl AgentRuntime {
let handle = HeartbeatHandle {
token,
base_url: sanitized_base,
status,
stop_signal,
join_handle,
};

View file

@ -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<MachineProfile, String> {
@ -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,

View file

@ -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<Store> {
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<string | null> {
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<MachineProfile> {
@ -148,9 +176,11 @@ async function collectMachineProfile(): Promise<MachineProfile> {
}
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)}`
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<AgentConfig> {
@ -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

View file

@ -116,6 +116,112 @@ function mergeMetadata(current: unknown, patch: Record<string, unknown>) {
return { ...(current as Record<string, unknown>), ...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<string, unknown>) : 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<string, unknown> | null = null
let inventory: Record<string, unknown> | null = null
let postureAlerts: Array<Record<string, unknown>> | null = null
let lastPostureAt: number | null = null
if (metadata && typeof metadata === "object") {
const metaRecord = metadata as Record<string, unknown>
@ -446,6 +562,12 @@ export const listByTenant = query({
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
inventory = metaRecord.inventory as Record<string, unknown>
}
if (Array.isArray(metaRecord.postureAlerts)) {
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
}
if (typeof metaRecord.lastPostureAt === "number") {
lastPostureAt = metaRecord.lastPostureAt as number
}
}
return {
@ -476,6 +598,8 @@ export const listByTenant = query({
: null,
metrics,
inventory,
postureAlerts,
lastPostureAt,
}
})
)

View file

@ -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”:

View file

@ -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 pullbased 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 bindmount por imagens construídas no CI (tempo de deploy menor, reprodutibilidade).
- Adicionar cache de dependências pnpm no container de build.

View file

@ -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-<hostname>.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.

46
docs/desktop-build.md Normal file
View file

@ -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 linkeditor 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`.

View file

@ -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`.

View file

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

View file

@ -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<Record<string, unknown>> | 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<string | null>(null)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [osFilter, setOsFilter] = useState<string>("all")
const [companyFilter, setCompanyFilter] = useState<string>("all")
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(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<string>()
machines.forEach((m) => m.osName && set.add(m.osName))
return Array.from(set).sort()
}, [machines])
const companyOptions = useMemo(() => {
const set = new Set<string>()
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 (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,400px)]">
@ -181,6 +248,49 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
</CardHeader>
<CardContent className="overflow-hidden">
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-[220px] flex-1">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar hostname, e-mail, MAC, serial..." />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
<Select value={osFilter} onValueChange={setOsFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Sistema" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos sistemas</SelectItem>
{osOptions.map((os) => (
<SelectItem key={os} value={os}>{os}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={companyFilter} onValueChange={setCompanyFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas empresas</SelectItem>
{companyOptions.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
<label className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-1.5 text-sm">
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</label>
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setOsFilter("all"); setCompanyFilter("all"); setOnlyAlerts(false) }}>Limpar</Button>
</div>
{machines.length === 0 ? (
<EmptyState />
) : (
@ -196,7 +306,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
</TableRow>
</TableHeader>
<TableBody>
{machines.map((machine: MachinesQueryItem) => (
{filteredMachines.map((machine: MachinesQueryItem) => (
<TableRow
key={machine.id}
onClick={() => 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 (
<Card className="border-slate-200">
<CardHeader>
@ -407,7 +565,31 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</div>
) : null}
{network ? (
{Array.isArray(network) ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Rede (interfaces)</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Interface</TableHead>
<TableHead className="text-xs text-slate-500">MAC</TableHead>
<TableHead className="text-xs text-slate-500">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(network as any[]).map((iface, idx) => (
<TableRow key={`iface-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{iface?.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.mac ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.ip ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : network ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Rede</p>
<div className="mt-2 grid gap-1">
@ -444,6 +626,183 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</section>
) : null}
{/* Discos (agente) */}
{disks.length > 0 ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Discos e partições</h4>
<div className="rounded-md border border-slate-200 bg-slate-50/60">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Mount</TableHead>
<TableHead className="text-xs text-slate-500">FS</TableHead>
<TableHead className="text-xs text-slate-500">Capacidade</TableHead>
<TableHead className="text-xs text-slate-500">Livre</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{disks.map((d, idx) => (
<TableRow key={`disk-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{d.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.mountPoint ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.fs ?? "—"}</TableCell>
<TableCell className="text-sm text-foreground">{formatBytes(Number(d.totalBytes))}</TableCell>
<TableCell className="text-sm text-muted-foreground">{formatBytes(Number(d.availableBytes))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : null}
{/* Inventário estendido por SO */}
{extended ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário estendido</h4>
<p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p>
</div>
{/* Linux */}
{linuxExt ? (
<div className="space-y-3">
{Array.isArray(linuxExt.smart) && linuxExt.smart.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-emerald-50/40 p-3 dark:bg-emerald-900/10">
<p className="text-xs font-semibold uppercase text-slate-500">SMART</p>
<div className="mt-2 grid gap-2">
{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 (
<div key={`smart-${idx}`} className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
style={{ borderColor: ok ? "rgba(16,185,129,0.3)" : "rgba(244,63,94,0.35)", backgroundColor: ok ? "rgba(16,185,129,0.08)" : "rgba(244,63,94,0.06)" }}>
<span className="font-medium text-foreground">{model} <span className="text-muted-foreground">({serial})</span></span>
<Badge className={cn("border", ok ? "border-emerald-500/20 bg-emerald-500/15 text-emerald-700" : "border-rose-500/20 bg-rose-500/15 text-rose-700")}>{ok ? "OK" : "ALERTA"}</Badge>
</div>
)
})}
</div>
</div>
) : null}
{linuxExt.lspci ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">PCI</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lspci}</pre>
</div>
) : null}
{linuxExt.lsusb ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">USB</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lsusb}</pre>
</div>
) : null}
</div>
) : null}
{/* Windows */}
{windowsExt ? (
<div className="space-y-3">
{Array.isArray(windowsExt.services) ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Serviços</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Exibição</TableHead>
<TableHead className="text-xs text-slate-500">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(windowsExt.services as any[]).slice(0, 10).map((svc: any, i: number) => (
<TableRow key={`svc-${i}`} className="border-slate-100">
<TableCell className="text-sm">{svc?.Name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{svc?.DisplayName ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{svc?.Status ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
{Array.isArray(windowsExt.software) ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Softwares (amostra)</p>
<ul className="mt-2 grid gap-1 text-xs text-muted-foreground">
{(windowsExt.software as any[]).slice(0, 8).map((s: any, i: number) => (
<li key={`sw-${i}`}>
<span className="font-medium text-foreground">{s?.DisplayName ?? s?.name ?? "—"}</span>
{s?.DisplayVersion ? <span className="ml-1">{s.DisplayVersion}</span> : null}
{s?.Publisher ? <span className="ml-1">· {s.Publisher}</span> : null}
</li>
))}
</ul>
</div>
) : null}
{windowsExt.defender ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Defender</p>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<DetailLine label="Antivirus" value={String(windowsExt.defender?.AntivirusEnabled ?? "—")} />
<DetailLine label="Tempo real" value={String(windowsExt.defender?.RealTimeProtectionEnabled ?? "—")} />
</div>
</div>
) : null}
</div>
) : null}
{/* macOS */}
{macosExt ? (
<div className="space-y-3">
{Array.isArray(macosExt.packages) && macosExt.packages.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Pacotes</p>
<p className="mt-1 text-xs text-muted-foreground">{macosExt.packages.slice(0, 8).join(", ")}</p>
</div>
) : null}
{macosExt.launchctl ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Launchctl</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{macosExt.launchctl}</pre>
</div>
) : null}
</div>
) : null}
</section>
) : null}
{/* Postura/Alertas */}
{Array.isArray(machine?.postureAlerts) && machine?.postureAlerts?.length ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Alertas de postura</h4>
<div className="space-y-2">
{machine?.postureAlerts?.map((a: any, i: number) => (
<div key={`alert-${i}`} className={cn("flex items-center justify-between rounded-md border px-3 py-2 text-sm",
(a?.severity ?? "warning").toLowerCase() === "critical" ? "border-rose-500/20 bg-rose-500/10" : "border-amber-500/20 bg-amber-500/10")
}>
<span className="font-medium text-foreground">{a?.message ?? a?.kind ?? "Alerta"}</span>
<Badge variant="outline">{String(a?.kind ?? "ALERTA")}</Badge>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Última avaliação: {machine?.lastPostureAt ? formatRelativeTime(new Date(machine.lastPostureAt)) : "—"}
</p>
</section>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" onClick={copyInventoryJson}>Copiar JSON</Button>
<Button size="sm" onClick={exportInventoryJson}>Exportar JSON</Button>
</div>
{fleet ? (
<section className="space-y-2 text-sm text-muted-foreground">
<Separator />