552 lines
22 KiB
JavaScript
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, '<')
|
|
.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 = '<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);
|