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:
parent
479c66d52c
commit
335accb596
7 changed files with 263 additions and 41 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<MachineProfile, 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]
|
||||
fn start_machine_agent(
|
||||
state: tauri::State<AgentRuntime>,
|
||||
|
|
@ -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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -175,6 +175,10 @@ async function collectMachineProfile(): Promise<MachineProfile> {
|
|||
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) {
|
||||
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 = `
|
||||
<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) {
|
||||
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 = `
|
||||
<div class="machine-summary">
|
||||
<div><strong>ID da máquina:</strong> ${config.machineId}</div>
|
||||
<div><strong>Email vinculado:</strong> ${config.machineEmail ?? "—"}</div>
|
||||
<div><strong>Tenant:</strong> ${config.tenantId ?? "padrão"}</div>
|
||||
<div><strong>Empresa:</strong> ${config.companySlug ?? "não vinculada"}</div>
|
||||
<div><strong>Token expira em:</strong> ${formatDate(config.expiresAt)}</div>
|
||||
<div><strong>Última sincronização:</strong> ${formatDate(config.lastSyncedAt)}</div>
|
||||
<div><strong>Ambiente:</strong> ${config.appUrl}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
contentElement.innerHTML = `
|
||||
<p>Esta máquina já 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)
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue