diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cef0ec2..ecf8383 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -5,7 +5,8 @@ Cliente desktop (Tauri v2 + Vite) que: - 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). +- Armazena o token da máquina com segurança no cofre do SO (Keyring). + - Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”. ## URLs e ambiente @@ -39,6 +40,7 @@ Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/` (AppImage/de 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. +5) Pelas abas, é possível revisar inventário local e disparar sincronização manual. ## Segurança do token - O `machineToken` é salvo no cofre nativo do SO via plugin Keyring (Linux Secret Service, Windows Credential Manager, macOS Keychain). diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index e25b38e..c1bb2c4 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -243,6 +243,15 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value { json!({ "inventory": inventory }) } +pub fn collect_inventory_plain() -> serde_json::Value { + let system = collect_system(); + let meta = build_inventory_metadata(&system); + match meta.get("inventory") { + Some(value) => value.clone(), + None => json!({}), + } +} + #[cfg(target_os = "linux")] fn collect_software_linux() -> serde_json::Value { use std::process::Command; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1a6553a..25ce5c4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ mod agent; -use agent::{collect_profile, AgentRuntime, MachineProfile}; +use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; use tauri_plugin_store::Builder as StorePluginBuilder; use tauri_plugin_keyring as keyring; @@ -9,6 +9,11 @@ fn collect_machine_profile() -> Result { collect_profile().map_err(|error| error.to_string()) } +#[tauri::command] +fn collect_machine_inventory() -> Result { + Ok(collect_inventory_plain()) +} + #[tauri::command] fn start_machine_agent( state: tauri::State, @@ -37,6 +42,7 @@ pub fn run() { .plugin(keyring::init()) .invoke_handler(tauri::generate_handler![ collect_machine_profile, + collect_machine_inventory, start_machine_agent, stop_machine_agent ]) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 269dd86..34886f4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -175,6 +175,10 @@ async function collectMachineProfile(): Promise { return await invoke("collect_machine_profile") } +async function collectMachineInventory(): Promise> { + return await invoke>("collect_machine_inventory") +} + async function startHeartbeat(config: AgentConfig) { const token = await getMachineToken() if (!token) throw new Error("Token da máquina ausente no cofre seguro") @@ -190,6 +194,188 @@ async function stopHeartbeat() { await invoke("stop_machine_agent") } +function renderTabsShell() { + if (!contentElement) return + contentElement.innerHTML = ` +
+
+ + + + +
+
+
+
+
+
+ ` + + const buttons = contentElement.querySelectorAll(".tab-list button") + buttons.forEach((btn) => { + btn.addEventListener("click", () => { + buttons.forEach((b) => b.classList.remove("active")) + btn.classList.add("active") + const tab = btn.getAttribute("data-tab") + const panels = contentElement.querySelectorAll(".tab-panel") + panels.forEach((p) => p.classList.remove("active")) + const panel = contentElement.querySelector(`#tab-${tab}`) + if (panel) panel.classList.add("active") + }) + }) +} + +function renderOverviewPanel(profile: MachineProfile) { + const panel = document.getElementById("tab-overview") + if (!panel) return + const summary = renderMachineSummary(profile) ?? "" + panel.innerHTML = ` +

Máquina provisionada e com heartbeat ativo.

+ ${summary} + ` +} + +function renderInventoryPanel(inv: Record, profile: MachineProfile) { + const panel = document.getElementById("tab-inventory") + if (!panel) return + const disks = Array.isArray((inv as any).disks) ? ((inv as any).disks as any[]) : [] + const network = (inv as any).network + + let networkHtml = "" + if (Array.isArray(network)) { + networkHtml = ` +
+
Rede (interfaces)
+ ${(network as any[]) + .map((iface) => `
${iface.name ?? "iface"} · ${iface.mac ?? "—"} · ${iface.ip ?? "—"}
`) + .join("")} +
+ ` + } + + let disksHtml = "" + if (disks.length > 0) { + disksHtml = ` +
+
Discos e partições
+ ${disks + .map( + (d) => + `
${d.name ?? "disco"} · ${d.mountPoint ?? "—"} · ${d.fs ?? "?"} · ${formatBytes(Number(d.totalBytes))} (${formatBytes(Number(d.availableBytes))} livre)
` + ) + .join("")} +
+ ` + } + + const software: any[] = Array.isArray((inv as any).software) ? ((inv as any).software as any[]) : [] + const services: any[] = Array.isArray((inv as any).services) ? ((inv as any).services as any[]) : [] + const softwareHtml = software.length + ? `
Softwares (amostra)
${software + .slice(0, 8) + .map((s, i) => `
${s.name ?? s.DisplayName ?? `Software ${i + 1}`} ${s.version ?? s.DisplayVersion ?? ""}
`) + .join("")}${software.length > 8 ? `
+${software.length - 8} itens
` : ""}
` + : "" + const servicesHtml = services.length + ? `
Serviços (amostra)
${services + .slice(0, 8) + .map((svc) => `
${svc.name ?? svc.Name ?? "serviço"} · ${svc.status ?? svc.Status ?? "?"}
`) + .join("")}${services.length > 8 ? `
+${services.length - 8} itens
` : ""}
` + : "" + + panel.innerHTML = ` + ${networkHtml} + ${disksHtml} + ${softwareHtml} + ${servicesHtml} +
+ ` + + const refresh = document.getElementById("refresh-inventory") + refresh?.addEventListener("click", async () => { + setStatus("Atualizando inventário…") + try { + const [p, i] = await Promise.all([collectMachineProfile(), collectMachineInventory()]) + renderOverviewPanel(p) + renderInventoryPanel(i, p) + renderDiagnosticsPanel(p) + setStatus("Inventário atualizado.") + } catch { + setAlert("Falha ao atualizar inventário.", "error") + } + }) +} + +function renderDiagnosticsPanel(profile: MachineProfile) { + const panel = document.getElementById("tab-diagnostics") + if (!panel) return + const m = profile.metrics + panel.innerHTML = ` +
+
CPU ${formatPercent(m.cpuUsagePercent)}
+
Memória ${formatBytes(m.memoryUsedBytes)} / ${formatBytes(m.memoryTotalBytes)} (${formatPercent(m.memoryUsedPercent)})
+
Uptime ${m.uptimeSeconds}s
+
Load ${[m.loadAverageOne, m.loadAverageFive, m.loadAverageFifteen] + .map((v) => (v ? v.toFixed(2) : "—")) + .join(" / ")}
+
+ ` +} + +function renderSettingsPanel(config: AgentConfig) { + const panel = document.getElementById("tab-settings") + if (!panel) return + panel.innerHTML = ` +
+
Ambiente ${config.appUrl}
+
API ${config.apiBaseUrl}
+
Criado em ${formatDate(config.createdAt)}
+
Última sync ${formatDate(config.lastSyncedAt ?? null)}
+
+
+ + +
+
+ +
+ ` + + document.getElementById("open-app-settings")?.addEventListener("click", () => redirectToApp(config)) + document.getElementById("reset-agent-settings")?.addEventListener("click", async () => { + await stopHeartbeat().catch(() => undefined) + await clearConfig() + setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success") + setTimeout(() => window.location.reload(), 600) + }) + document.getElementById("send-inventory")?.addEventListener("click", async () => { + setStatus("Enviando inventário…") + try { + const token = await getMachineToken() + if (!token) throw new Error("Token ausente no cofre do SO") + const [p, inv] = await Promise.all([collectMachineProfile(), collectMachineInventory()]) + const payload = { + machineToken: token, + hostname: p.hostname, + os: p.os, + metrics: p.metrics as any, + inventory: inv, + } + const response = await fetch(`${apiBaseUrl}/api/machines/inventory`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!response.ok) throw new Error(`Falha HTTP ${response.status}`) + setAlert("Inventário enviado com sucesso.", "success") + setStatus("Inventário sincronizado.") + } catch (error) { + console.error("[agent] Falha ao enviar inventário", error) + setAlert("Falha ao enviar inventário agora.", "error") + } + }) +} + function formatBytes(bytes: number) { if (!bytes || Number.isNaN(bytes)) return "—" const units = ["B", "KB", "MB", "GB", "TB"] @@ -236,42 +422,20 @@ function renderMachineSummary(profile: MachineProfile) { ` } -function renderRegistered(config: AgentConfig) { +async function renderRegistered(config: AgentConfig) { if (!contentElement) return - const summaryHtml = ` -
-
ID da máquina: ${config.machineId}
-
Email vinculado: ${config.machineEmail ?? "—"}
-
Tenant: ${config.tenantId ?? "padrão"}
-
Empresa: ${config.companySlug ?? "não vinculada"}
-
Token expira em: ${formatDate(config.expiresAt)}
-
Última sincronização: ${formatDate(config.lastSyncedAt)}
-
Ambiente: ${config.appUrl}
-
- ` - - contentElement.innerHTML = ` -

Esta máquina já está provisionada e com heartbeat ativo.

- ${summaryHtml} -
- - -
- ` - - const openButton = document.getElementById("open-app") - const resetButton = document.getElementById("reset-agent") - - openButton?.addEventListener("click", () => redirectToApp(config)) - resetButton?.addEventListener("click", async () => { - await stopHeartbeat().catch(() => undefined) - await clearConfig() - setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success") - setTimeout(() => window.location.reload(), 600) - }) - - setStatus("Máquina provisionada. Redirecionando para a interface web…") - setTimeout(() => redirectToApp(config), 1500) + renderTabsShell() + try { + const [profile, inv] = await Promise.all([collectMachineProfile(), collectMachineInventory()]) + renderOverviewPanel(profile) + renderInventoryPanel(inv, profile) + renderDiagnosticsPanel(profile) + renderSettingsPanel(config) + setStatus("Máquina provisionada.") + } catch (error) { + console.error("[agent] Falha ao preparar as abas", error) + setAlert("Não foi possível preparar a interface de inventário.", "error") + } } function renderProvisionForm(profile: MachineProfile) { @@ -453,7 +617,7 @@ async function bootstrap() { const token = await getMachineToken() if (stored && token) { const updated = await ensureHeartbeat(stored) - renderRegistered(updated) + await renderRegistered(updated) return } } catch (error) { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 80776e1..435884f 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -161,6 +161,38 @@ button:hover:not(:disabled) { color: #334155; } +/* Tabs (desktop app shell) */ +.tabs { + margin-top: 18px; +} +.tab-list { + display: flex; + gap: 8px; + border-bottom: 1px solid rgba(148, 163, 184, 0.4); + padding-bottom: 8px; +} +.tab-list button { + background: none; + color: #334155; + border: 1px solid transparent; + border-bottom: 2px solid transparent; + padding: 8px 12px; + border-radius: 10px 10px 0 0; +} +.tab-list button.active { + background-color: rgba(241, 245, 249, 0.8); + border-color: rgba(148, 163, 184, 0.6); + border-bottom-color: #2563eb; + color: #0f172a; +} +.tab-panel { + display: none; + padding-top: 12px; +} +.tab-panel.active { + display: block; +} + .spinner { width: 18px; height: 18px; diff --git a/docs/OPERACAO-PRODUCAO.md b/docs/OPERACAO-PRODUCAO.md index 32cc4d4..b3f1279 100644 --- a/docs/OPERACAO-PRODUCAO.md +++ b/docs/OPERACAO-PRODUCAO.md @@ -146,6 +146,13 @@ Observação: o CI já força `docker service update --force` após `stack deplo - 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) + +### CI de Release do Desktop +- Workflow: `.github/workflows/desktop-release.yml` (build Linux/Windows/macOS). +- Preencha os Secrets no repositório (Settings > Secrets > Actions): + - `TAURI_PRIVATE_KEY` + - `TAURI_KEY_PASSWORD` +- Disparo: tag `desktop-v*` ou via `workflow_dispatch`. ### Dashboard (opcional) diff --git a/docs/plano-app-desktop-maquinas.md b/docs/plano-app-desktop-maquinas.md index 0aa3d45..f140d48 100644 --- a/docs/plano-app-desktop-maquinas.md +++ b/docs/plano-app-desktop-maquinas.md @@ -40,9 +40,11 @@ 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. 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). +1. Desktop: finalizar UX das abas (mais detalhes em Diagnóstico e Configurações) e gráficos leves. +2. Coletores Windows/macOS: normalizar campos de software/serviços (nome/versão/fonte/status) e whitelists. +3. Regras: janela temporal real para CPU (dados de 5 min), whitelists por tenant, mais sinais SMART (temperatura e contadores). +4. Admin UI: diálogo “Inventário completo” com busca em JSON, export CSV de softwares/serviços, badges no grid com contagens. +5. Release: ativar secrets de assinatura e publicar binários por SO. ## Notas de Implementação (Atual) - Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.