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) => `
${esc(s.domain || '—')}
${esc((s.session_id || '').slice(0, 12))} · ${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)}
${s.stale ? 'inactivo' : ''}${s.ticket_id ? `#${s.ticket_id}` : ''}
`).join(''); const auditCards = (audit.tenants || []).map((t) => `
${esc(t.name)} ${healthBadge(t.status)}
${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks OK
${t.domains_count || 0} domínio(s) · ${fmtDate(t.last_audit_at)}
`).join(''); box.innerHTML = `
${summary.tickets_open}
${summary.tickets_closed}
${funnel.sessions_total || 0}
${summary.webhook_events}
${auditCards ? `
${auditCards}
` : ''}

Funil onboarding 48h

${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)} ${sessionRows ? `

Sessões activas

${sessionRows}
` : '

Sem sessões recentes

'}

Tickets recentes

${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '

Sem tickets

'}

Infra

VM112 Portal

${vm112.vm112?.service || vm112.error || '—'}

${vmOk ? 'online' : 'check'}
VM104 Wazuh

API ${wazuh.http_status ?? '—'}

${wazuhOk ? 'online' : 'check'}
`; box.querySelectorAll('.ticket-row').forEach((btn) => { btn.addEventListener('click', () => { state.selectedTicketId = Number(btn.dataset.id); setView('tickets'); }); }); } catch (e) { box.innerHTML = `

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 `
${FUNNEL_LABELS[key] || key}
${n}
`; }) .join(''); } function timelineHtml(events) { if (!events?.length) return ''; return `
    ${events .map( (e) => `
  1. ${esc(e.event_type)} ${e.stage ? `${esc(e.stage)}` : ''}
    ${fmtDate(e.created_at)}
  2. ` ) .join('')}
`; } function healthBadge(status) { const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' }; const cls = map[status] || 'open'; return `${esc(status || 'unknown')}`; } function checkStatusBadge(status) { const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open'; return `${esc(status)}`; } function ticketRowHtml(t) { const review = t.needs_review ? 'revisão' : ''; const verified = t.account_verified ? 'verificado' : ''; const sub = t.event === 'wazuh.alert' ? esc(t.description || t.subject) : esc(t.domain || t.subject); const meta = t.event === 'wazuh.alert' ? `${esc(t.agent || t.domain || '')} · ${fmtDate(t.created_at)}` : `${esc(t.email || '')} · ${fmtDate(t.created_at)}`; return ` `; } async function renderOverview() { const el = document.getElementById('overview-content'); const panel = document.getElementById('scorecard-panel'); el.innerHTML = '

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)}
  • `) .join(''); const domains = [...new Set((t.top_issues || []).map((i) => i.domain).filter(Boolean))]; const domainBtns = domains.map((d) => `` ).join(' '); return `

    ${esc(t.name)}

    ${esc(t.ip)} · ${t.domains_count || 0} domínio(s)

    ${healthBadge(t.status)}
    ${t.score?.pass ?? 0}/${t.score?.total ?? 8} pass · ${t.score?.warn ?? 0} warn · ${t.score?.fail ?? 0} fail

    Último audit: ${fmtDate(t.last_audit_at)}

    ${issues ? `` : '

    Sem issues ou aguardar 1.º ciclo audit

    '}
    ${domainBtns || ``}
    `; }).join(''); el.innerHTML = cards ? `
    ${cards}
    ` : '

    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) => ` ${esc(c.label || c.check_id)} ${checkStatusBadge(c.status)} ${esc(c.message || '—')} ${fmtDate(c.checked_at)} `).join(''); panel.innerHTML = `

    Scorecard — ${esc(domain)}

    ${healthBadge(sc.overall_status)}

    Tenant #${tenantId} · ${fmtDate(sc.checked_at)}

    ${rows || ''}
    CheckStatusMensagemVerificado
    Sem checks
    `; } catch (e) { panel.innerHTML = `

    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

    '; } catch (e) { listEl.innerHTML = `

    Erro: ${esc(e.message)}

    `; } } async function renderTicketDetail() { const detailEl = document.getElementById('ticket-detail'); if (!state.selectedTicketId) return; detailEl.innerHTML = '

    A carregar…

    '; try { const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`); const timeline = t.timeline || t.related_events || []; detailEl.innerHTML = `

    Ticket #${t.id}

    ${esc(t.status)}
    Origem
    ${sourceBadge(t.source)}
    Domínio/Agente
    ${esc(t.domain || t.agent || '—')}
    Email
    ${esc(t.email || '—')}
    Evento
    ${esc(t.event || '—')}
    ${t.ready_for_ops ? '
    Ops
    ready for ops
    ' : ''} ${t.severity != null ? `
    Severidade
    ${severityBadge(t.severity)}
    ` : ''} ${t.rule_id ? `
    Regra
    ${esc(t.rule_id)}
    ` : ''} ${t.description ? `
    Descrição
    ${esc(t.description)}
    ` : ''} ${t.billing_state ? `
    Billing
    ${esc(t.billing_state)}
    ` : ''} ${t.webmail_released != null ? `
    Webmail
    ${t.webmail_released ? 'Liberado' : 'Pendente'}
    ` : ''}
    ${t.source === 'wazuh' ? 'Alert ID' : 'Sessão onboarding'}
    ${esc(t.session_id || '—')}
    Verificado
    ${t.account_verified ? 'Sim' : 'Não'}
    Revisão
    ${t.needs_review ? 'Necessária' : 'Não'}
    Criado
    ${fmtDate(t.created_at)}
    ${t.status === 'open' ? `` : ``}
    ${timeline.length ? `

    Timeline onboarding

    ${timelineHtml(timeline)}` : ''}

    Payload

    ${esc(JSON.stringify(t.payload, null, 2))}
    `; detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed')); detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open')); } catch (e) { detailEl.innerHTML = `

    Erro: ${esc(e.message)}

    `; } } async function updateTicketStatus(status) { await api(`/v1/desk/tickets/${state.selectedTicketId}`, { method: 'PATCH', body: JSON.stringify({ status }), }); await renderTickets(); } async function renderEvents() { const el = document.getElementById('events-content'); el.innerHTML = '

    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 ` ${e.id} ${sourceBadge(e.source)} ${esc(e.event_type)} ${severityBadge(dataObj.level || e.severity)} ${esc(p.domain || '—')} ${esc((p.session_id || '').slice(0, 16))} ${fmtDate(e.created_at)} `; }).join(''); el.innerHTML = `
    ${rows || ''}
    IDOrigemEventoAgente/DomínioRefData
    Sem eventos
    `; } catch (e) { el.innerHTML = `

    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 = `
    ${(data.tenants || []).map((t) => ` `).join('')}
    IDNomeIPPapelDesde
    ${t.id} ${esc(t.name)} ${esc(t.ip)} ${esc(t.role)} ${fmtDate(t.created_at)}
    `; } catch (e) { el.innerHTML = `

    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 = `

    VM112 — Portal Onboard

    HTTP
    ${vm112.http_status ?? '—'}
    Service
    ${esc(vm112.vm112?.service || vm112.error || '—')}

    VM104 — Wazuh SOC

    API
    ${wazuh.http_status ?? '—'}
    Integração
    webhook level ≥ 10 → VM122

    Integrações activas

    ${esc(JSON.stringify(integrations, null, 2))}
    `; } catch (e) { el.innerHTML = `

    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);