const API = '/api'; async function api(path, options = {}) { const res = await fetch(`${API}${path}`, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }); if (!res.ok) throw new Error(`${res.status} ${path}`); return res.json(); } function fmtDate(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString('pt-PT', { dateStyle: 'short', timeStyle: 'short' }); } catch { return iso; } } function esc(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } let state = { view: 'dashboard', ticketFilter: 'all', sourceFilter: 'all', eventSourceFilter: 'all', selectedTicketId: null, tickets: [], summary: null, scorecardTenant: null, scorecardDomain: null, }; const views = { dashboard: document.getElementById('view-dashboard'), overview: document.getElementById('view-overview'), tickets: document.getElementById('view-tickets'), events: document.getElementById('view-events'), tenants: document.getElementById('view-tenants'), infra: document.getElementById('view-infra'), }; function setView(name) { state.view = name; const titles = { dashboard: 'Dashboard', overview: 'Audit Overview', tickets: 'Tickets', events: 'Eventos webhook', tenants: 'Tenants', infra: 'Infraestrutura', }; document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops'; document.querySelectorAll('.nav button').forEach((b) => { b.classList.toggle('active', b.dataset.view === name); }); Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name)); refresh(); } async function loadHealth() { const el = document.getElementById('global-health'); try { const h = await api('/health'); el.className = 'status-pill ok'; el.innerHTML = ' API online'; return h; } catch { el.className = 'status-pill err'; el.innerHTML = ' API offline'; return null; } } async function renderDashboard() { const box = document.getElementById('dashboard-content'); box.innerHTML = '
A carregar…
'; try { const [summary, funnel, audit, vm112, wazuh] = await Promise.all([ api('/v1/desk/summary'), api('/v1/onboard/funnel').catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })), api('/v1/audit/overview').catch(() => ({ tenants: [] })), api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })), api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })), ]); state.summary = summary; const vmOk = vm112.vm112?.status === 'ok'; const wazuhOk = wazuh.http_status === 401 || wazuh.http_status === 200; const sessions = funnel.active_sessions || []; const sessionRows = sessions.slice(0, 8).map((s) => `Sem sessões recentes
'}Sem tickets
'}Erro: ${esc(e.message)}
`; } } function sourceBadge(src) { if (src === 'wazuh') return 'wazuh'; if (src === 'vm112-onboard') return 'onboard'; return src ? `${esc(src)}` : ''; } function severityBadge(level) { if (level == null) return ''; const n = Number(level); let cls = 'sev-low'; if (n >= 12) cls = 'sev-critical'; else if (n >= 10) cls = 'sev-high'; else if (n >= 7) cls = 'sev-med'; return `L${n}`; } const FUNNEL_LABELS = { started: 'Iniciado', domain_validated: 'Domínio OK', dns_applied: 'DNS aplicado', account_created: 'Conta criada', infra_synced: 'Infra sync', completed: 'Concluído', failed: 'Falhou', }; function funnelBarHtml(stages, total) { const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed', 'failed']; const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0)); return order .filter((k) => k !== 'failed' || (stages.failed || 0) > 0) .map((key) => { const n = stages[key] || 0; const pct = max ? Math.round((n / max) * 100) : 0; return `A carregar overview…
'; try { const data = await api('/v1/audit/overview'); const cards = (data.tenants || []).map((t) => { const issues = (t.top_issues || []) .slice(0, 3) .map((i) => `${esc(i.domain)} · ${esc(i.check_id)} — ${esc(i.message || i.status)}Sem issues ou aguardar 1.º ciclo audit
'}Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.
'; el.querySelectorAll('[data-domain]').forEach((btn) => { btn.addEventListener('click', () => loadScorecard(Number(btn.dataset.tenant), btn.dataset.domain)); }); el.querySelectorAll('[data-run-audit]').forEach((btn) => { btn.addEventListener('click', async () => { btn.disabled = true; try { await api('/v1/audit/cycle', { method: 'POST' }); await renderOverview(); } finally { btn.disabled = false; } }); }); if (state.scorecardTenant && state.scorecardDomain) { await loadScorecard(state.scorecardTenant, state.scorecardDomain, panel); } else { panel.style.display = 'none'; } } catch (e) { el.innerHTML = `Erro: ${esc(e.message)}
`; panel.style.display = 'none'; } } async function loadScorecard(tenantId, domain, panelEl) { const panel = panelEl || document.getElementById('scorecard-panel'); panel.style.display = 'block'; state.scorecardTenant = tenantId; state.scorecardDomain = domain; panel.innerHTML = 'A carregar scorecard…
'; try { const sc = await api(`/v1/audit/tenants/${tenantId}/scorecard?domain=${encodeURIComponent(domain)}`); const rows = (sc.checks || []).map((c) => `| Check | Status | Mensagem | Verificado |
|---|---|---|---|
| Sem checks | |||
Erro scorecard: ${esc(e.message)}
`; } } async function renderTickets() { const listEl = document.getElementById('ticket-list'); const detailEl = document.getElementById('ticket-detail'); listEl.innerHTML = 'A carregar tickets…
'; try { let q = ''; const params = []; if (state.ticketFilter !== 'all') params.push(`status=${state.ticketFilter}`); if (state.sourceFilter !== 'all') params.push(`source=${state.sourceFilter}`); if (params.length) q = '?' + params.join('&'); const data = await api(`/v1/desk/tickets${q}`); state.tickets = data.tickets || []; listEl.innerHTML = state.tickets.length ? state.tickets.map(ticketRowHtml).join('') : 'Nenhum ticket neste filtro
'; listEl.querySelectorAll('.ticket-row').forEach((btn) => { btn.addEventListener('click', () => { state.selectedTicketId = Number(btn.dataset.id); renderTicketDetail(); listEl.querySelectorAll('.ticket-row').forEach((r) => r.classList.remove('selected')); btn.classList.add('selected'); }); }); if (state.selectedTicketId) await renderTicketDetail(); else detailEl.innerHTML = 'Seleccione um ticket
Erro: ${esc(e.message)}
`; } } async function renderTicketDetail() { const detailEl = document.getElementById('ticket-detail'); if (!state.selectedTicketId) return; detailEl.innerHTML = 'A carregar…
${esc(t.session_id || '—')}${esc(JSON.stringify(t.payload, null, 2))}
Erro: ${esc(e.message)}
A carregar eventos…
'; try { const srcQ = state.eventSourceFilter !== 'all' ? `?source=${state.eventSourceFilter}` : ''; const data = await api(`/v1/webhooks/events${srcQ}`); const rows = (data.events || []).map((e) => { const p = e.payload || {}; const dataObj = p.data || {}; return `${esc((p.session_id || '').slice(0, 16))}| ID | Origem | Evento | Agente/Domínio | Ref | Data |
|---|---|---|---|---|---|
| Sem eventos | |||||
Erro: ${esc(e.message)}
`; } } async function renderTenants() { const el = document.getElementById('tenants-content'); el.innerHTML = 'A carregar…
'; try { const data = await api('/v1/tenants'); el.innerHTML = `| ID | Nome | IP | Papel | Desde |
|---|---|---|---|---|
| ${t.id} | ${esc(t.name)} | ${esc(t.ip)} |
${esc(t.role)} | ${fmtDate(t.created_at)} |
Erro: ${esc(e.message)}
`; } } async function renderInfra() { const el = document.getElementById('infra-content'); el.innerHTML = 'A verificar…
'; try { const [vm112, wazuh, integrations] = await Promise.all([ api('/v1/infra/vm112/status'), api('/v1/infra/wazuh/status'), api('/v1/integrations'), ]); el.innerHTML = `${esc(JSON.stringify(integrations, null, 2))}
Erro: ${esc(e.message)}
`; } } async function refresh() { await loadHealth(); if (state.view === 'dashboard') await renderDashboard(); if (state.view === 'overview') await renderOverview(); if (state.view === 'tickets') await renderTickets(); if (state.view === 'events') await renderEvents(); if (state.view === 'tenants') await renderTenants(); if (state.view === 'infra') await renderInfra(); } document.querySelectorAll('.nav button').forEach((btn) => { btn.addEventListener('click', () => setView(btn.dataset.view)); }); document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => { btn.addEventListener('click', () => { state.ticketFilter = btn.dataset.filter; document.querySelectorAll('.filter-btn[data-filter]').forEach((b) => b.classList.toggle('active', b === btn)); renderTickets(); }); }); document.querySelectorAll('.filter-btn[data-source]').forEach((btn) => { btn.addEventListener('click', () => { const kind = btn.dataset.kind || 'ticket'; if (kind === 'event') { state.eventSourceFilter = btn.dataset.source; document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => b.classList.toggle('active', b === btn)); renderEvents(); } else { state.sourceFilter = btn.dataset.source; document.querySelectorAll('.filter-btn[data-kind="ticket"]').forEach((b) => b.classList.toggle('active', b === btn)); renderTickets(); } }); }); document.getElementById('btn-refresh')?.addEventListener('click', refresh); setView('dashboard'); setInterval(refresh, 30000);