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.
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -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 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue