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:
parent
c2050f311a
commit
479c66d52c
18 changed files with 1205 additions and 38 deletions
66
.github/workflows/desktop-release.yml
vendored
Normal file
66
.github/workflows/desktop-release.yml
vendored
Normal 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
|
||||||
|
|
||||||
25
agents.md
25
agents.md
|
|
@ -23,6 +23,24 @@
|
||||||
5. `pnpm convex:dev`
|
5. `pnpm convex:dev`
|
||||||
6. Em outro terminal: `pnpm 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)
|
## 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`.
|
- 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`.
|
- 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`
|
- SLA CSV: `/api/reports/sla.csv`
|
||||||
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
|
- 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
|
## Rotina antes de abrir PR
|
||||||
- `pnpm lint`
|
- `pnpm lint`
|
||||||
- `pnpm build --turbopack`
|
- `pnpm build --turbopack`
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
# Copie para `apps/desktop/.env` e ajuste.
|
# Copie para `apps/desktop/.env` e ajuste.
|
||||||
|
|
||||||
# URL da aplicação web (Next.js) que será carregada dentro do app desktop.
|
# 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
|
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
|
# Opcional: IP do host para desenvolvimento com HMR fora do localhost
|
||||||
# Ex.: 192.168.0.10
|
# Ex.: 192.168.0.10
|
||||||
TAURI_DEV_HOST=
|
TAURI_DEV_HOST=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-keyring": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2"
|
"@tauri-apps/plugin-store": "^2"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
125
apps/desktop/src-tauri/Cargo.lock
generated
125
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -71,6 +71,7 @@ dependencies = [
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-keyring",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|
@ -544,6 +545,16 @@ dependencies = [
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -567,7 +578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -580,7 +591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -718,6 +729,27 @@ dependencies = [
|
||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
|
|
@ -1955,6 +1987,21 @@ dependencies = [
|
||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.8-speedreader"
|
version = "0.8.8-speedreader"
|
||||||
|
|
@ -2003,6 +2050,15 @@ version = "0.2.176"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
|
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]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
|
|
@ -3417,6 +3473,42 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
|
|
@ -3866,7 +3958,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
|
|
@ -4047,6 +4139,19 @@ dependencies = [
|
||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -5691,6 +5796,20 @@ name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
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]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
|
||||||
tauri = { version = "2", features = ["wry"] }
|
tauri = { version = "2", features = ["wry"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-store = "2.4"
|
tauri-plugin-store = "2.4"
|
||||||
|
tauri-plugin-keyring = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use parking_lot::Mutex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sysinfo::{Networks, System};
|
use sysinfo::{Networks, System};
|
||||||
|
use std::collections::HashMap;
|
||||||
use tauri::async_runtime::{self, JoinHandle};
|
use tauri::async_runtime::{self, JoinHandle};
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
|
@ -131,13 +132,30 @@ fn collect_serials() -> Vec<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_network_addrs() -> Vec<serde_json::Value> {
|
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();
|
let mut entries = Vec::new();
|
||||||
if let Ok(ifaces) = get_if_addrs::get_if_addrs() {
|
if let Ok(ifaces) = get_if_addrs::get_if_addrs() {
|
||||||
for iface in ifaces {
|
for iface in ifaces {
|
||||||
let name = iface.name;
|
let name = iface.name.clone();
|
||||||
let mac = iface.mac.map(|m| m.to_string());
|
|
||||||
let addr = iface.ip();
|
let addr = iface.ip();
|
||||||
let ip = addr.to_string();
|
let ip = addr.to_string();
|
||||||
|
let mac = mac_by_name.get(&name).cloned();
|
||||||
entries.push(json!({
|
entries.push(json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"mac": mac,
|
"mac": mac,
|
||||||
|
|
@ -148,12 +166,14 @@ fn collect_network_addrs() -> Vec<serde_json::Value> {
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_disks(system: &System) -> Vec<serde_json::Value> {
|
fn collect_disks(_system: &System) -> Vec<serde_json::Value> {
|
||||||
|
// API de discos mudou no sysinfo 0.31: usamos Disks diretamente
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
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 name = d.name().to_string_lossy().to_string();
|
||||||
let mount = d.mount_point().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 total = d.total_space();
|
||||||
let avail = d.available_space();
|
let avail = d.available_space();
|
||||||
out.push(json!({
|
out.push(json!({
|
||||||
|
|
@ -196,6 +216,28 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value {
|
||||||
if let Some(obj) = inventory.as_object_mut() {
|
if let Some(obj) = inventory.as_object_mut() {
|
||||||
obj.insert("services".into(), services);
|
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 })
|
json!({ "inventory": inventory })
|
||||||
|
|
@ -276,6 +318,146 @@ fn collect_services_linux() -> serde_json::Value {
|
||||||
json!([])
|
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 {
|
fn collect_system() -> System {
|
||||||
let mut system = System::new_all();
|
let mut system = System::new_all();
|
||||||
system.refresh_all();
|
system.refresh_all();
|
||||||
|
|
@ -403,7 +585,6 @@ async fn post_heartbeat(base_url: &str, token: &str, status: Option<String>) ->
|
||||||
struct HeartbeatHandle {
|
struct HeartbeatHandle {
|
||||||
token: String,
|
token: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
status: Option<String>,
|
|
||||||
stop_signal: Arc<Notify>,
|
stop_signal: Arc<Notify>,
|
||||||
join_handle: JoinHandle<()>,
|
join_handle: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +670,6 @@ impl AgentRuntime {
|
||||||
let handle = HeartbeatHandle {
|
let handle = HeartbeatHandle {
|
||||||
token,
|
token,
|
||||||
base_url: sanitized_base,
|
base_url: sanitized_base,
|
||||||
status,
|
|
||||||
stop_signal,
|
stop_signal,
|
||||||
join_handle,
|
join_handle,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ mod agent;
|
||||||
|
|
||||||
use agent::{collect_profile, AgentRuntime, MachineProfile};
|
use agent::{collect_profile, AgentRuntime, MachineProfile};
|
||||||
use tauri_plugin_store::Builder as StorePluginBuilder;
|
use tauri_plugin_store::Builder as StorePluginBuilder;
|
||||||
|
use tauri_plugin_keyring as keyring;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn collect_machine_profile() -> Result<MachineProfile, String> {
|
fn collect_machine_profile() -> Result<MachineProfile, String> {
|
||||||
|
|
@ -33,6 +34,7 @@ pub fn run() {
|
||||||
.manage(AgentRuntime::new())
|
.manage(AgentRuntime::new())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(StorePluginBuilder::default().build())
|
.plugin(StorePluginBuilder::default().build())
|
||||||
|
.plugin(keyring::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
collect_machine_profile,
|
collect_machine_profile,
|
||||||
start_machine_agent,
|
start_machine_agent,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
|
import { getPassword, setPassword, deletePassword } from "@tauri-apps/plugin-keyring"
|
||||||
|
|
||||||
type MachineOs = {
|
type MachineOs = {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -47,7 +48,6 @@ type MachineRegisterResponse = {
|
||||||
|
|
||||||
type AgentConfig = {
|
type AgentConfig = {
|
||||||
machineId: string
|
machineId: string
|
||||||
machineToken: string
|
|
||||||
tenantId?: string | null
|
tenantId?: string | null
|
||||||
companySlug?: string | null
|
companySlug?: string | null
|
||||||
machineEmail?: string | null
|
machineEmail?: string | null
|
||||||
|
|
@ -70,7 +70,11 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORE_FILENAME = "machine-agent.json"
|
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) {
|
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
|
||||||
const trimmed = (value ?? fallback).trim()
|
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 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 alertElement = document.getElementById("alert-container") as HTMLDivElement | null
|
||||||
const contentElement = document.getElementById("content") as HTMLDivElement | null
|
const contentElement = document.getElementById("content") as HTMLDivElement | null
|
||||||
|
|
@ -107,6 +114,9 @@ function setStatus(message: string) {
|
||||||
|
|
||||||
let storeInstance: Store | null = null
|
let storeInstance: Store | null = null
|
||||||
|
|
||||||
|
const KEYRING_SERVICE = "sistema-de-chamados"
|
||||||
|
const KEYRING_ACCOUNT = "machine-token"
|
||||||
|
|
||||||
async function ensureStoreLoaded(): Promise<Store> {
|
async function ensureStoreLoaded(): Promise<Store> {
|
||||||
if (!storeInstance) {
|
if (!storeInstance) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -141,6 +151,24 @@ async function clearConfig() {
|
||||||
const store = await ensureStoreLoaded()
|
const store = await ensureStoreLoaded()
|
||||||
await store.delete("config")
|
await store.delete("config")
|
||||||
await store.save()
|
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> {
|
async function collectMachineProfile(): Promise<MachineProfile> {
|
||||||
|
|
@ -148,9 +176,11 @@ async function collectMachineProfile(): Promise<MachineProfile> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startHeartbeat(config: AgentConfig) {
|
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", {
|
await invoke("start_machine_agent", {
|
||||||
baseUrl: config.apiBaseUrl,
|
baseUrl: config.apiBaseUrl,
|
||||||
token: config.machineToken,
|
token,
|
||||||
status: "online",
|
status: "online",
|
||||||
intervalSeconds: 300,
|
intervalSeconds: 300,
|
||||||
})
|
})
|
||||||
|
|
@ -348,9 +378,10 @@ async function handleRegister(profile: MachineProfile, form: HTMLFormElement) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as MachineRegisterResponse
|
const data = (await response.json()) as MachineRegisterResponse
|
||||||
|
// Guarda token com segurança no Keyring
|
||||||
|
await setMachineToken(data.machineToken)
|
||||||
const config: AgentConfig = {
|
const config: AgentConfig = {
|
||||||
machineId: data.machineId,
|
machineId: data.machineId,
|
||||||
machineToken: data.machineToken,
|
|
||||||
tenantId: data.tenantId ?? null,
|
tenantId: data.tenantId ?? null,
|
||||||
companySlug: data.companySlug ?? null,
|
companySlug: data.companySlug ?? null,
|
||||||
machineEmail: data.machineEmail ?? null,
|
machineEmail: data.machineEmail ?? null,
|
||||||
|
|
@ -387,8 +418,16 @@ async function handleRegister(profile: MachineProfile, form: HTMLFormElement) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToApp(config: AgentConfig) {
|
function redirectToApp(config: AgentConfig) {
|
||||||
const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(config.machineToken)}`
|
const perform = async () => {
|
||||||
window.location.replace(url)
|
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> {
|
async function ensureHeartbeat(config: AgentConfig): Promise<AgentConfig> {
|
||||||
|
|
@ -411,7 +450,8 @@ async function bootstrap() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stored = await loadConfig()
|
const stored = await loadConfig()
|
||||||
if (stored?.machineToken) {
|
const token = await getMachineToken()
|
||||||
|
if (stored && token) {
|
||||||
const updated = await ensureHeartbeat(stored)
|
const updated = await ensureHeartbeat(stored)
|
||||||
renderRegistered(updated)
|
renderRegistered(updated)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,112 @@ function mergeMetadata(current: unknown, patch: Record<string, unknown>) {
|
||||||
return { ...(current as Record<string, unknown>), ...patch }
|
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({
|
export const register = mutation({
|
||||||
args: {
|
args: {
|
||||||
provisioningSecret: v.string(),
|
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 {
|
return {
|
||||||
machineId,
|
machineId,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|
@ -360,6 +470,10 @@ export const heartbeat = mutation({
|
||||||
expiresAt: now + getTokenTtlMs(),
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
machineId: machine._id,
|
machineId: machine._id,
|
||||||
|
|
@ -437,6 +551,8 @@ export const listByTenant = query({
|
||||||
|
|
||||||
let metrics: Record<string, unknown> | null = null
|
let metrics: Record<string, unknown> | null = null
|
||||||
let inventory: 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") {
|
if (metadata && typeof metadata === "object") {
|
||||||
const metaRecord = metadata as Record<string, unknown>
|
const metaRecord = metadata as Record<string, unknown>
|
||||||
|
|
@ -446,6 +562,12 @@ export const listByTenant = query({
|
||||||
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
|
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
|
||||||
inventory = metaRecord.inventory as Record<string, unknown>
|
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 {
|
return {
|
||||||
|
|
@ -476,6 +598,8 @@ export const listByTenant = query({
|
||||||
: null,
|
: null,
|
||||||
metrics,
|
metrics,
|
||||||
inventory,
|
inventory,
|
||||||
|
postureAlerts,
|
||||||
|
lastPostureAt,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
### Dashboard (opcional)
|
||||||
Você pode expor o painel do Convex para inspeção em produção.
|
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:
|
- `MAILER_SENDER_EMAIL` com erro de parsing:
|
||||||
- Adicionar aspas no `.env`.
|
- Adicionar aspas no `.env`.
|
||||||
- `pnpm` reclama de workspace:
|
- `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:
|
- Portainer erro de bind relativo:
|
||||||
- Usar caminho absoluto `/srv/apps/sistema:/app` no stack (feito).
|
- Usar caminho absoluto `/srv/apps/sistema:/app` no stack (feito).
|
||||||
- Prisma CLI “not found”:
|
- Prisma CLI “not found”:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
- `.github/workflows/ci-cd-web-desktop.yml` — pipeline de deploy web + desktop + deploy do Convex.
|
- `.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/OPERACAO-PRODUCAO.md` — runbook de operação (deploy, seeds, CI/CD, troubleshooting).
|
||||||
- `docs/SETUP-HISTORICO.md` — este histórico.
|
- `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).
|
- `scripts/deploy-from-git.sh` — fallback de deploy pull‑based na VPS (sem Actions).
|
||||||
|
|
||||||
## Gestão de .env
|
## Gestão de .env
|
||||||
|
|
@ -60,8 +60,8 @@
|
||||||
- Solução: `NPM_CONFIG_PRODUCTION=false` e `pnpm install --prod=false` no container de build.
|
- Solução: `NPM_CONFIG_PRODUCTION=false` e `pnpm install --prod=false` no container de build.
|
||||||
|
|
||||||
5) Lockfile/Workspace quebrando CI
|
5) Lockfile/Workspace quebrando CI
|
||||||
- Causa: incluir `apps/desktop` no workspace.
|
- Causa: conflito de versões quando o desktop entrou no workspace.
|
||||||
- Solução: `pnpm-workspace.yaml` com `packages: ['.']`.
|
- 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
|
6) Bind relativo no Swarm/Portainer
|
||||||
- Causa: `./:/app` vira path inválido.
|
- 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.
|
- 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).
|
- Substituir bind‑mount por imagens construídas no CI (tempo de deploy menor, reprodutibilidade).
|
||||||
- Adicionar cache de dependências pnpm no container de build.
|
- Adicionar cache de dependências pnpm no container de build.
|
||||||
|
|
||||||
|
|
|
||||||
31
docs/admin-inventory-ui.md
Normal file
31
docs/admin-inventory-ui.md
Normal 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
46
docs/desktop-build.md
Normal 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 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`.
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
- Web atual permanece operacional com login por usuário/senha.
|
- Web atual permanece operacional com login por usuário/senha.
|
||||||
- Novas features serão adições compatíveis (machine login opcional).
|
- 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.
|
- 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
|
## Marcos & Progresso
|
||||||
| Macro-entrega | Status | Observações |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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.
|
- **Infra extra:** Endpoints públicos para updater do Tauri, armazenamento de inventário seguro, certificados para assinatura de builds.
|
||||||
|
|
||||||
## Próximos Passos Imediatos
|
## 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`).
|
1. Finalizar coletores específicos para Windows/macOS (ajustes finos e parse de dados).
|
||||||
2. Detalhar fluxo de provisioning de máquina no Convex e atualizar este documento.
|
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)
|
## Notas de Implementação (Atual)
|
||||||
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.
|
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.
|
||||||
|
|
|
||||||
111
src/app/api/machines/inventory/route.ts
Normal file
111
src/app/api/machines/inventory/route.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -10,6 +10,9 @@ import { ClipboardCopy, ServerCog } from "lucide-react"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -59,6 +62,28 @@ type MachineInventory = {
|
||||||
detailUpdatedAt?: string
|
detailUpdatedAt?: string
|
||||||
osqueryVersion?: 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 = {
|
type MachinesQueryItem = {
|
||||||
|
|
@ -87,6 +112,8 @@ type MachinesQueryItem = {
|
||||||
} | null
|
} | null
|
||||||
metrics: MachineMetrics
|
metrics: MachineMetrics
|
||||||
inventory: MachineInventory | null
|
inventory: MachineInventory | null
|
||||||
|
postureAlerts?: Array<Record<string, unknown>> | null
|
||||||
|
lastPostureAt?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
||||||
|
|
@ -158,6 +185,11 @@ function getStatusVariant(status?: string | null) {
|
||||||
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||||
const machines = useMachinesQuery(tenantId)
|
const machines = useMachinesQuery(tenantId)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (machines.length === 0) {
|
if (machines.length === 0) {
|
||||||
|
|
@ -171,7 +203,42 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||||
}
|
}
|
||||||
}, [machines, selectedId])
|
}, [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 (
|
return (
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,400px)]">
|
<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>
|
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-hidden">
|
<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 ? (
|
{machines.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -196,7 +306,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{machines.map((machine: MachinesQueryItem) => (
|
{filteredMachines.map((machine: MachinesQueryItem) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={machine.id}
|
key={machine.id}
|
||||||
onClick={() => setSelectedId(machine.id)}
|
onClick={() => setSelectedId(machine.id)}
|
||||||
|
|
@ -276,6 +386,11 @@ function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
const software = metadata?.software ?? null
|
const software = metadata?.software ?? null
|
||||||
const labels = metadata?.labels ?? null
|
const labels = metadata?.labels ?? null
|
||||||
const fleet = metadata?.fleet ?? 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 lastHeartbeatDate = machine?.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
|
||||||
const tokenExpiry = machine?.token?.expiresAt ? new Date(machine.token.expiresAt) : 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 (
|
return (
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -375,11 +533,11 @@ function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{metrics && typeof metrics === "object" ? (
|
{metrics && typeof metrics === "object" ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||||
<MetricsGrid metrics={metrics} />
|
<MetricsGrid metrics={metrics} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hardware || network || (labels && labels.length > 0) ? (
|
{hardware || network || (labels && labels.length > 0) ? (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|
@ -407,7 +565,31 @@ function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<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>
|
<p className="text-xs font-semibold uppercase text-slate-500">Rede</p>
|
||||||
<div className="mt-2 grid gap-1">
|
<div className="mt-2 grid gap-1">
|
||||||
|
|
@ -444,6 +626,183 @@ function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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 ? (
|
{fleet ? (
|
||||||
<section className="space-y-2 text-sm text-muted-foreground">
|
<section className="space-y-2 text-sm text-muted-foreground">
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue