Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
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);
|