feat(desktop): tabs UI (Resumo/Inventário/Diagnóstico/Configurações) + enviar inventário agora; docs: admin inventory UI + release CI notes + roadmap

This commit is contained in:
Esdras Renan 2025-10-09 22:22:24 -03:00
parent 479c66d52c
commit 335accb596
7 changed files with 263 additions and 41 deletions

View file

@ -5,7 +5,8 @@ Cliente desktop (Tauri v2 + Vite) que:
- Registra a máquina com um código de provisionamento. - Registra a máquina com um código de provisionamento.
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`). - Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
- Redireciona para a UI web do sistema após provisionamento. - 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 ## 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. 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. 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. 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 ## Segurança do token
- O `machineToken` é salvo no cofre nativo do SO via plugin Keyring (Linux Secret Service, Windows Credential Manager, macOS Keychain). - O `machineToken` é salvo no cofre nativo do SO via plugin Keyring (Linux Secret Service, Windows Credential Manager, macOS Keychain).

View file

@ -243,6 +243,15 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value {
json!({ "inventory": inventory }) 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")] #[cfg(target_os = "linux")]
fn collect_software_linux() -> serde_json::Value { fn collect_software_linux() -> serde_json::Value {
use std::process::Command; use std::process::Command;

View file

@ -1,6 +1,6 @@
mod agent; 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_store::Builder as StorePluginBuilder;
use tauri_plugin_keyring as keyring; use tauri_plugin_keyring as keyring;
@ -9,6 +9,11 @@ fn collect_machine_profile() -> Result<MachineProfile, String> {
collect_profile().map_err(|error| error.to_string()) collect_profile().map_err(|error| error.to_string())
} }
#[tauri::command]
fn collect_machine_inventory() -> Result<serde_json::Value, String> {
Ok(collect_inventory_plain())
}
#[tauri::command] #[tauri::command]
fn start_machine_agent( fn start_machine_agent(
state: tauri::State<AgentRuntime>, state: tauri::State<AgentRuntime>,
@ -37,6 +42,7 @@ pub fn run() {
.plugin(keyring::init()) .plugin(keyring::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
collect_machine_profile, collect_machine_profile,
collect_machine_inventory,
start_machine_agent, start_machine_agent,
stop_machine_agent stop_machine_agent
]) ])

View file

@ -175,6 +175,10 @@ async function collectMachineProfile(): Promise<MachineProfile> {
return await invoke<MachineProfile>("collect_machine_profile") return await invoke<MachineProfile>("collect_machine_profile")
} }
async function collectMachineInventory(): Promise<Record<string, unknown>> {
return await invoke<Record<string, unknown>>("collect_machine_inventory")
}
async function startHeartbeat(config: AgentConfig) { async function startHeartbeat(config: AgentConfig) {
const token = await getMachineToken() const token = await getMachineToken()
if (!token) throw new Error("Token da máquina ausente no cofre seguro") 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") await invoke("stop_machine_agent")
} }
function renderTabsShell() {
if (!contentElement) return
contentElement.innerHTML = `
<div class="tabs">
<div class="tab-list">
<button class="active" data-tab="overview">Resumo</button>
<button data-tab="inventory">Inventário</button>
<button data-tab="diagnostics">Diagnóstico</button>
<button data-tab="settings">Configurações</button>
</div>
<section id="tab-overview" class="tab-panel active"></section>
<section id="tab-inventory" class="tab-panel"></section>
<section id="tab-diagnostics" class="tab-panel"></section>
<section id="tab-settings" class="tab-panel"></section>
</div>
`
const buttons = contentElement.querySelectorAll<HTMLButtonElement>(".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<HTMLElement>(".tab-panel")
panels.forEach((p) => p.classList.remove("active"))
const panel = contentElement.querySelector<HTMLElement>(`#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 = `
<p>Máquina provisionada e com heartbeat ativo.</p>
${summary}
`
}
function renderInventoryPanel(inv: Record<string, unknown>, 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 = `
<div class="machine-summary">
<div><strong>Rede (interfaces)</strong></div>
${(network as any[])
.map((iface) => `<div>${iface.name ?? "iface"} · ${iface.mac ?? "—"} · ${iface.ip ?? "—"}</div>`)
.join("")}
</div>
`
}
let disksHtml = ""
if (disks.length > 0) {
disksHtml = `
<div class="machine-summary">
<div><strong>Discos e partições</strong></div>
${disks
.map(
(d) =>
`<div>${d.name ?? "disco"} · ${d.mountPoint ?? "—"} · ${d.fs ?? "?"} · ${formatBytes(Number(d.totalBytes))} (${formatBytes(Number(d.availableBytes))} livre)</div>`
)
.join("")}
</div>
`
}
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
? `<div class="machine-summary"><div><strong>Softwares (amostra)</strong></div>${software
.slice(0, 8)
.map((s, i) => `<div>${s.name ?? s.DisplayName ?? `Software ${i + 1}`} ${s.version ?? s.DisplayVersion ?? ""}</div>`)
.join("")}${software.length > 8 ? `<div class="text-xs">+${software.length - 8} itens</div>` : ""}</div>`
: ""
const servicesHtml = services.length
? `<div class="machine-summary"><div><strong>Serviços (amostra)</strong></div>${services
.slice(0, 8)
.map((svc) => `<div>${svc.name ?? svc.Name ?? "serviço"} · ${svc.status ?? svc.Status ?? "?"}</div>`)
.join("")}${services.length > 8 ? `<div class="text-xs">+${services.length - 8} itens</div>` : ""}</div>`
: ""
panel.innerHTML = `
${networkHtml}
${disksHtml}
${softwareHtml}
${servicesHtml}
<div class="actions"><button id="refresh-inventory" class="secondary">Atualizar inventário</button></div>
`
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 = `
<div class="machine-summary">
<div><strong>CPU</strong> ${formatPercent(m.cpuUsagePercent)}</div>
<div><strong>Memória</strong> ${formatBytes(m.memoryUsedBytes)} / ${formatBytes(m.memoryTotalBytes)} (${formatPercent(m.memoryUsedPercent)})</div>
<div><strong>Uptime</strong> ${m.uptimeSeconds}s</div>
<div><strong>Load</strong> ${[m.loadAverageOne, m.loadAverageFive, m.loadAverageFifteen]
.map((v) => (v ? v.toFixed(2) : "—"))
.join(" / ")}</div>
</div>
`
}
function renderSettingsPanel(config: AgentConfig) {
const panel = document.getElementById("tab-settings")
if (!panel) return
panel.innerHTML = `
<div class="machine-summary">
<div><strong>Ambiente</strong> ${config.appUrl}</div>
<div><strong>API</strong> ${config.apiBaseUrl}</div>
<div><strong>Criado em</strong> ${formatDate(config.createdAt)}</div>
<div><strong>Última sync</strong> ${formatDate(config.lastSyncedAt ?? null)}</div>
</div>
<div class="actions">
<button id="send-inventory">Enviar inventário agora</button>
<button id="open-app-settings" class="secondary">Abrir sistema</button>
</div>
<div class="actions">
<button id="reset-agent-settings" class="secondary">Reprovisionar</button>
</div>
`
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) { function formatBytes(bytes: number) {
if (!bytes || Number.isNaN(bytes)) return "—" if (!bytes || Number.isNaN(bytes)) return "—"
const units = ["B", "KB", "MB", "GB", "TB"] 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 if (!contentElement) return
const summaryHtml = ` renderTabsShell()
<div class="machine-summary"> try {
<div><strong>ID da máquina:</strong> ${config.machineId}</div> const [profile, inv] = await Promise.all([collectMachineProfile(), collectMachineInventory()])
<div><strong>Email vinculado:</strong> ${config.machineEmail ?? "—"}</div> renderOverviewPanel(profile)
<div><strong>Tenant:</strong> ${config.tenantId ?? "padrão"}</div> renderInventoryPanel(inv, profile)
<div><strong>Empresa:</strong> ${config.companySlug ?? "não vinculada"}</div> renderDiagnosticsPanel(profile)
<div><strong>Token expira em:</strong> ${formatDate(config.expiresAt)}</div> renderSettingsPanel(config)
<div><strong>Última sincronização:</strong> ${formatDate(config.lastSyncedAt)}</div> setStatus("Máquina provisionada.")
<div><strong>Ambiente:</strong> ${config.appUrl}</div> } catch (error) {
</div> console.error("[agent] Falha ao preparar as abas", error)
` setAlert("Não foi possível preparar a interface de inventário.", "error")
}
contentElement.innerHTML = `
<p>Esta máquina está provisionada e com heartbeat ativo.</p>
${summaryHtml}
<div class="actions">
<button id="open-app">Abrir sistema</button>
<button class="secondary" id="reset-agent">Reprovisionar</button>
</div>
`
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)
} }
function renderProvisionForm(profile: MachineProfile) { function renderProvisionForm(profile: MachineProfile) {
@ -453,7 +617,7 @@ async function bootstrap() {
const token = await getMachineToken() const token = await getMachineToken()
if (stored && token) { if (stored && token) {
const updated = await ensureHeartbeat(stored) const updated = await ensureHeartbeat(stored)
renderRegistered(updated) await renderRegistered(updated)
return return
} }
} catch (error) { } catch (error) {

View file

@ -161,6 +161,38 @@ button:hover:not(:disabled) {
color: #334155; 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 { .spinner {
width: 18px; width: 18px;
height: 18px; height: 18px;

View file

@ -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): - 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_CREATE_TICKETS=true|false` (padrão: true)
- `MACHINE_ALERTS_TICKET_REQUESTER_EMAIL=admin@sistema.dev` (usuário solicitante dos tickets automáticos) - `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) ### Dashboard (opcional)

View file

@ -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. - **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. Finalizar coletores específicos para Windows/macOS (ajustes finos e parse de dados). 1. Desktop: finalizar UX das abas (mais detalhes em Diagnóstico e Configurações) e gráficos leves.
2. Adicionar UI administrativa para visualizar inventário estendido e alertas de postura por máquina. 2. Coletores Windows/macOS: normalizar campos de software/serviços (nome/versão/fonte/status) e whitelists.
3. Refinar regras (janela temporal para CPU alta, whitelists de serviços, severidades por SMART). 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) ## 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`.