ligbox-ops-platform/assets/app.js
Ligbox Spec Hub 3a2c64834b Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)
Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
2026-06-19 17:26:41 +00:00

552 lines
22 KiB
JavaScript

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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = '<span class="dot"></span> API online';
return h;
} catch {
el.className = 'status-pill err';
el.innerHTML = '<span class="dot"></span> API offline';
return null;
}
}
async function renderDashboard() {
const box = document.getElementById('dashboard-content');
box.innerHTML = '<p class="loading">A carregar…</p>';
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) => `
<div class="funnel-session ${s.stale ? 'stale' : ''}">
<div>
<strong>${esc(s.domain || '—')}</strong>
<div class="ticket-meta"><code>${esc((s.session_id || '').slice(0, 12))}</code> · ${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)}</div>
</div>
<div>${s.stale ? '<span class="badge review">inactivo</span>' : ''}${s.ticket_id ? `<span class="badge open">#${s.ticket_id}</span>` : ''}</div>
</div>`).join('');
const auditCards = (audit.tenants || []).map((t) => `
<div class="health-card health-${esc(t.status)}">
<div class="health-card-head">
<strong>${esc(t.name)}</strong>
${healthBadge(t.status)}
</div>
<div class="health-score">${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks OK</div>
<div class="ticket-meta">${t.domains_count || 0} domínio(s) · ${fmtDate(t.last_audit_at)}</div>
</div>`).join('');
box.innerHTML = `
<div class="stats">
<div class="stat"><label>Abertos</label><strong>${summary.tickets_open}</strong></div>
<div class="stat"><label>Fechados</label><strong>${summary.tickets_closed}</strong></div>
<div class="stat"><label>Sessões funil</label><strong>${funnel.sessions_total || 0}</strong></div>
<div class="stat"><label>Eventos</label><strong>${summary.webhook_events}</strong></div>
</div>
${auditCards ? `<div class="health-grid" style="margin-bottom:1rem">${auditCards}</div>` : ''}
<div class="grid-2">
<div class="card">
<h3>Funil onboarding <span class="ticket-meta">48h</span></h3>
${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)}
${sessionRows ? `<h4 style="margin:1rem 0 0.5rem;font-size:0.85rem">Sessões activas</h4><div class="funnel-sessions">${sessionRows}</div>` : '<p class="loading" style="margin-top:1rem">Sem sessões recentes</p>'}
</div>
<div class="card">
<h3>Tickets recentes</h3>
<div class="ticket-list">
${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '<p class="loading">Sem tickets</p>'}
</div>
</div>
</div>
<div class="card" style="margin-top:1rem">
<h3>Infra</h3>
<div class="grid-2" style="gap:0.75rem">
<div class="infra-card">
<div><strong>VM112 Portal</strong><p class="ticket-meta">${vm112.vm112?.service || vm112.error || '—'}</p></div>
<span class="badge ${vmOk ? 'ok' : 'review'}">${vmOk ? 'online' : 'check'}</span>
</div>
<div class="infra-card">
<div><strong>VM104 Wazuh</strong><p class="ticket-meta">API ${wazuh.http_status ?? '—'}</p></div>
<span class="badge ${wazuhOk ? 'ok' : 'review'}">${wazuhOk ? 'online' : 'check'}</span>
</div>
</div>
</div>`;
box.querySelectorAll('.ticket-row').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.id);
setView('tickets');
});
});
} catch (e) {
box.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function sourceBadge(src) {
if (src === 'wazuh') return '<span class="badge wazuh">wazuh</span>';
if (src === 'vm112-onboard') return '<span class="badge onboard">onboard</span>';
return src ? `<span class="badge">${esc(src)}</span>` : '';
}
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 `<span class="badge ${cls}">L${n}</span>`;
}
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 `
<div class="funnel-row">
<span class="funnel-label">${FUNNEL_LABELS[key] || key}</span>
<div class="funnel-bar"><div class="funnel-fill" style="width:${pct}%"></div></div>
<strong class="funnel-count">${n}</strong>
</div>`;
})
.join('');
}
function timelineHtml(events) {
if (!events?.length) return '';
return `<ol class="timeline">${events
.map(
(e) => `
<li class="timeline-item">
<span class="timeline-dot"></span>
<div>
<strong>${esc(e.event_type)}</strong>
${e.stage ? `<span class="badge open">${esc(e.stage)}</span>` : ''}
<div class="ticket-meta">${fmtDate(e.created_at)}</div>
</div>
</li>`
)
.join('')}</ol>`;
}
function healthBadge(status) {
const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' };
const cls = map[status] || 'open';
return `<span class="badge ${cls} health-${esc(status)}">${esc(status || 'unknown')}</span>`;
}
function checkStatusBadge(status) {
const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open';
return `<span class="badge ${cls}">${esc(status)}</span>`;
}
function ticketRowHtml(t) {
const review = t.needs_review ? '<span class="badge review">revisão</span>' : '';
const verified = t.account_verified ? '<span class="badge ok">verificado</span>' : '';
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 `
<button type="button" class="ticket-row ${state.selectedTicketId === t.id ? 'selected' : ''}" data-id="${t.id}">
<span class="badge ${t.status}">${esc(t.status)}</span>
<div>
<div class="ticket-subject">${sub}</div>
<div class="ticket-meta">${meta}</div>
</div>
<div>${sourceBadge(t.source)}${severityBadge(t.severity)}${review}${verified}</div>
</button>`;
}
async function renderOverview() {
const el = document.getElementById('overview-content');
const panel = document.getElementById('scorecard-panel');
el.innerHTML = '<p class="loading">A carregar overview…</p>';
try {
const data = await api('/v1/audit/overview');
const cards = (data.tenants || []).map((t) => {
const issues = (t.top_issues || [])
.slice(0, 3)
.map((i) => `<li><code>${esc(i.domain)}</code> · ${esc(i.check_id)}${esc(i.message || i.status)}</li>`)
.join('');
const domains = [...new Set((t.top_issues || []).map((i) => i.domain).filter(Boolean))];
const domainBtns = domains.map((d) =>
`<button type="button" class="btn btn-ghost btn-sm" data-tenant="${t.tenant_id}" data-domain="${esc(d)}">${esc(d)}</button>`
).join(' ');
return `
<div class="card health-card health-${esc(t.status)}">
<div class="health-card-head">
<div>
<h3 style="margin:0">${esc(t.name)}</h3>
<p class="ticket-meta">${esc(t.ip)} · ${t.domains_count || 0} domínio(s)</p>
</div>
${healthBadge(t.status)}
</div>
<div class="health-score">${t.score?.pass ?? 0}/${t.score?.total ?? 8} pass · ${t.score?.warn ?? 0} warn · ${t.score?.fail ?? 0} fail</div>
<p class="ticket-meta">Último audit: ${fmtDate(t.last_audit_at)}</p>
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem issues ou aguardar 1.º ciclo audit</p>'}
<div class="actions" style="margin-top:0.75rem">${domainBtns || `<button type="button" class="btn btn-ghost btn-sm" data-run-audit="${t.tenant_id}">Correr audit cycle</button>`}</div>
</div>`;
}).join('');
el.innerHTML = cards
? `<div class="health-grid">${cards}</div>`
: '<p class="loading">Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.</p>';
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
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 = '<p class="loading">A carregar scorecard…</p>';
try {
const sc = await api(`/v1/audit/tenants/${tenantId}/scorecard?domain=${encodeURIComponent(domain)}`);
const rows = (sc.checks || []).map((c) => `
<tr>
<td>${esc(c.label || c.check_id)}</td>
<td>${checkStatusBadge(c.status)}</td>
<td>${esc(c.message || '—')}</td>
<td>${fmtDate(c.checked_at)}</td>
</tr>`).join('');
panel.innerHTML = `
<div class="health-card-head">
<h3 style="margin:0">Scorecard — ${esc(domain)}</h3>
${healthBadge(sc.overall_status)}
</div>
<p class="ticket-meta">Tenant #${tenantId} · ${fmtDate(sc.checked_at)}</p>
<div class="table-wrap" style="margin-top:0.75rem">
<table>
<thead><tr><th>Check</th><th>Status</th><th>Mensagem</th><th>Verificado</th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">Sem checks</td></tr>'}</tbody>
</table>
</div>`;
} catch (e) {
panel.innerHTML = `<p class="loading">Erro scorecard: ${esc(e.message)}</p>`;
}
}
async function renderTickets() {
const listEl = document.getElementById('ticket-list');
const detailEl = document.getElementById('ticket-detail');
listEl.innerHTML = '<p class="loading">A carregar tickets…</p>';
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('')
: '<p class="loading">Nenhum ticket neste filtro</p>';
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 = '<div class="card detail-panel"><p class="empty">Seleccione um ticket</p></div>';
} catch (e) {
listEl.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderTicketDetail() {
const detailEl = document.getElementById('ticket-detail');
if (!state.selectedTicketId) return;
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">A carregar…</p></div>';
try {
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
const timeline = t.timeline || t.related_events || [];
detailEl.innerHTML = `
<div class="card detail-panel">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">
<h3 style="margin:0">Ticket #${t.id}</h3>
<span class="badge ${t.status}">${esc(t.status)}</span>
</div>
<dl class="kv">
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
<dt>Domínio/Agente</dt><dd>${esc(t.domain || t.agent || '—')}</dd>
<dt>Email</dt><dd>${esc(t.email || '—')}</dd>
<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>
${t.ready_for_ops ? '<dt>Ops</dt><dd><span class="badge ok">ready for ops</span></dd>' : ''}
${t.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</dd>` : ''}
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
${t.billing_state ? `<dt>Billing</dt><dd><span class="badge warn">${esc(t.billing_state)}</span></dd>` : ''}
${t.webmail_released != null ? `<dt>Webmail</dt><dd>${t.webmail_released ? 'Liberado' : 'Pendente'}</dd>` : ''}
<dt>${t.source === 'wazuh' ? 'Alert ID' : 'Sessão onboarding'}</dt><dd><code>${esc(t.session_id || '—')}</code></dd>
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</dd>
<dt>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
</dl>
<div class="actions">
${t.status === 'open'
? `<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>`
: `<button type="button" class="btn btn-ghost" data-action="open">Reabrir ticket</button>`}
</div>
${timeline.length ? `<h3 style="margin-top:1.25rem">Timeline onboarding</h3>${timelineHtml(timeline)}` : ''}
<h3 style="margin-top:1.25rem">Payload</h3>
<pre class="raw">${esc(JSON.stringify(t.payload, null, 2))}</pre>
</div>`;
detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed'));
detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open'));
} catch (e) {
detailEl.innerHTML = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
}
}
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 = '<p class="loading">A carregar eventos…</p>';
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 `<tr>
<td>${e.id}</td>
<td>${sourceBadge(e.source)}</td>
<td><span class="badge open">${esc(e.event_type)}</span> ${severityBadge(dataObj.level || e.severity)}</td>
<td>${esc(p.domain || '—')}</td>
<td><code>${esc((p.session_id || '').slice(0, 16))}</code></td>
<td>${fmtDate(e.created_at)}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card table-wrap">
<table>
<thead><tr><th>ID</th><th>Origem</th><th>Evento</th><th>Agente/Domínio</th><th>Ref</th><th>Data</th></tr></thead>
<tbody>${rows || '<tr><td colspan="6">Sem eventos</td></tr>'}</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderTenants() {
const el = document.getElementById('tenants-content');
el.innerHTML = '<p class="loading">A carregar…</p>';
try {
const data = await api('/v1/tenants');
el.innerHTML = `
<div class="card table-wrap">
<table>
<thead><tr><th>ID</th><th>Nome</th><th>IP</th><th>Papel</th><th>Desde</th></tr></thead>
<tbody>${(data.tenants || []).map((t) => `
<tr>
<td>${t.id}</td>
<td>${esc(t.name)}</td>
<td><code>${esc(t.ip)}</code></td>
<td>${esc(t.role)}</td>
<td>${fmtDate(t.created_at)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderInfra() {
const el = document.getElementById('infra-content');
el.innerHTML = '<p class="loading">A verificar…</p>';
try {
const [vm112, wazuh, integrations] = await Promise.all([
api('/v1/infra/vm112/status'),
api('/v1/infra/wazuh/status'),
api('/v1/integrations'),
]);
el.innerHTML = `
<div class="card">
<h3>VM112 — Portal Onboard</h3>
<dl class="kv">
<dt>HTTP</dt><dd>${vm112.http_status ?? '—'}</dd>
<dt>Service</dt><dd>${esc(vm112.vm112?.service || vm112.error || '—')}</dd>
</dl>
</div>
<div class="card">
<h3>VM104 — Wazuh SOC</h3>
<dl class="kv">
<dt>API</dt><dd>${wazuh.http_status ?? '—'}</dd>
<dt>Integração</dt><dd>webhook level ≥ 10 → VM122</dd>
</dl>
</div>
<div class="card">
<h3>Integrações activas</h3>
<pre class="raw">${esc(JSON.stringify(integrations, null, 2))}</pre>
</div>`;
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
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);