const API = '/api';
async function api(path, options = {}) {
const res = await fetchWithTimeout(`${API}${path}`, {
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
...options,
});
if (res.status === 401) {
logout();
throw new Error('sessão expirada');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const detail = data.detail;
const msg = typeof detail === 'object' ? detail.message || JSON.stringify(detail) : (detail || `${res.status} ${path}`);
throw new Error(msg);
}
return res.json();
}
function fmtDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return iso;
}
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function sessionHashHtml(sessionId, { full = true } = {}) {
const id = (sessionId || '').trim();
if (!id) return '';
const shown = full ? id : `${id.slice(0, 8)}…${id.slice(-4)}`;
return `${esc(shown)}`;
}
let state = {
view: 'dashboard',
ticketFilter: 'all',
sourceFilter: 'all',
eventSourceFilter: 'all',
eventsTab: 'webhooks',
selectedTicketId: null,
selectedSessionId: null,
tickets: [],
summary: null,
scorecardTenant: null,
scorecardDomain: null,
accountLoaded: false,
overviewModal: { tenantId: null, view: 'list', domain: null, data: null, focus: 'onboard' },
overviewHomeWindow: '24h',
overviewHomeTrailFilter: 'all',
overviewHomeDnsDomain: null,
adminUsers: [],
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
adminSelected: null,
socWindow: '24h',
socLastEventId: null,
};
const views = {
dashboard: document.getElementById('view-dashboard'),
overview: document.getElementById('view-overview'),
'overview-home': document.getElementById('view-overview-home'),
tickets: document.getElementById('view-tickets'),
events: document.getElementById('view-events'),
tenants: document.getElementById('view-tenants'),
'email-migration': document.getElementById('view-email-migration'),
infra: document.getElementById('view-infra'),
infra2: document.getElementById('view-infra2'),
messages: document.getElementById('view-messages'),
admin: document.getElementById('view-admin'),
account: document.getElementById('view-account'),
leads: document.getElementById('view-leads'),
modules: document.getElementById('view-modules'),
};
function roleLabel(role) {
return ROLE_LABELS[role] || role;
}
const ROLE_LABELS = {
super_admin: 'Super Admin',
ops_lead: 'Chefe Ops',
technician: 'Suporte',
noc: 'NOC',
sales_admin: 'Sales Admin',
sales_support: 'Sales Support',
finance: 'Financeiro',
marketing: 'Marketing',
seo: 'SEO',
developer: 'Developer',
devops: 'DevOps',
security_analyst: 'Segurança / SOC',
content_editor: 'Conteúdo / CMS',
agentic_operator: 'Operador Agentes IA',
};
function statusLabel(status) {
return {
pending: 'pendente',
approved: 'aprovado',
rejected: 'rejeitado',
active: 'ativo',
open: 'aberto',
escalated: 'escalado',
assisting: 'assistindo',
resolved: 'resolvido',
closed: 'fechado',
}[status] || status;
}
function assistStatusLabel(status) {
return {
observing: 'observando',
escalated: 'escalado',
assisting: 'assistindo',
}[status] || status || 'observando';
}
function assistBadge(status) {
if (!status || status === 'observing') {
return 'observando ';
}
const cls = status === 'assisting' ? 'assisting' : status === 'escalated' ? 'escalated' : 'open';
return `${esc(assistStatusLabel(status))} `;
}
function setupSidebarUser() {
const user = getUser();
const sidebar = document.getElementById('sidebar-user');
const header = document.getElementById('header-user');
const logoutBtn = document.getElementById('btn-logout');
if (!user) return;
const label = roleLabel(user.role);
if (sidebar) {
sidebar.innerHTML = `
${esc(user.display_name || user.username)}
${esc(user.username)} · ${esc(label)} `;
}
if (header) {
header.hidden = false;
header.innerHTML = `${esc(user.display_name || user.username)} ${esc(label)} `;
}
if (logoutBtn) {
logoutBtn.hidden = false;
logoutBtn.onclick = logout;
}
}
function applyRoleNav() {
const user = getUser();
if (!user) return;
if (!canRunAudit()) {
document.getElementById('nav-overview')?.setAttribute('hidden', '');
document.getElementById('nav-overview-home')?.setAttribute('hidden', '');
}
if (user.role === 'noc') {
document.getElementById('nav-tenants')?.setAttribute('hidden', '');
const navEvents = document.getElementById('nav-events');
const navEventsLabel = navEvents?.querySelector('.nav-label');
if (navEventsLabel) navEventsLabel.textContent = 'Wazuh';
}
if (canManageUsers()) {
document.getElementById('nav-messages')?.removeAttribute('hidden');
document.getElementById('nav-admin')?.removeAttribute('hidden');
}
if (user.role === 'super_admin') {
document.getElementById('nav-modules')?.removeAttribute('hidden');
}
if (canReadLeads()) {
document.getElementById('nav-leads')?.removeAttribute('hidden');
document.getElementById('filter-leads')?.removeAttribute('hidden');
}
if (typeof canManageVm112Domains === 'function' && canManageVm112Domains()) {
document.getElementById('events-tab-purges')?.removeAttribute('hidden');
}
if (canRunAudit()) {
document.getElementById('events-tab-security')?.removeAttribute('hidden');
} else {
document.getElementById('events-tab-security')?.setAttribute('hidden', '');
}
if (canReadTickets()) {
document.getElementById('events-tab-carbonio')?.removeAttribute('hidden');
}
}
function setView(name) {
if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
name = 'dashboard';
}
if (state.view === 'account' && name !== 'account') {
state.accountLoaded = false;
}
state.view = name;
const titles = {
dashboard: 'Dashboard',
overview: 'Audit Overview',
'overview-home': 'Serviços IaaS',
tickets: 'Tickets',
events: 'Eventos webhook',
tenants: 'Tenants',
infra: 'INFRA CODE',
infra2: 'SOC — Infra 2',
messages: 'Mensagens — pedidos de cadastro',
admin: 'Administradores',
account: 'Minha conta',
leads: 'Leads abandonados',
modules: 'Módulos',
};
const subtitles = {
dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento',
overview: 'Visão por tenant — cards de auditoria (versão clássica)',
'overview-home': 'Orquestração MOSP · Infra as Code',
tickets: 'Operações Ligbox — onboarding, tickets e monitoramento',
events: 'Operações Ligbox — onboarding, tickets e monitoramento',
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
};
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
const subEl = document.getElementById('page-subtitle');
if (subEl) subEl.textContent = subtitles[name] || subtitles.dashboard;
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));
reschedulePoll();
refresh();
}
let pollTimer = null;
function reschedulePoll() {
if (pollTimer) clearInterval(pollTimer);
const ms = state.view === 'infra2' ? 15000 : 30000;
pollTimer = setInterval(() => refresh({ poll: true }), ms);
}
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 = '
Carregando…
';
try {
const leadsPromise = canReadLeads()
? api('/v1/crm/leads').catch(() => ({ leads: [], total: 0 }))
: Promise.resolve({ leads: [], total: 0 });
const rankingPromise = canAssist()
? api('/v1/assist/technicians/ranking?window_days=30').catch(() => ({ ranking: [] }))
: Promise.resolve({ ranking: [] });
const [summary, funnel, audit, vm112, wazuh, leadsData, techRanking] = await Promise.all([
api('/v1/desk/summary').catch((e) => {
throw new Error(`Resumo indisponível: ${e.message}`);
}),
api('/v1/onboard/funnel').catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })),
canRunAudit() ? api('/v1/audit/overview').catch(() => ({ tenants: [] })) : Promise.resolve({ tenants: [] }),
api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })),
api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })),
leadsPromise,
rankingPromise,
]);
state.summary = summary;
const vmOk = vm112.vm112?.status === 'ok';
const wazuhOk = wazuh.api_online === true || wazuh.http_status === 401 || wazuh.http_status === 200;
const sessions = funnel.active_sessions || [];
const sessionCards = sessions.slice(0, 24).map((s) => {
const status = s.assist_status || 'observing';
const statusCls = status === 'assisting' ? 'assisting' : status === 'escalated' ? 'escalated' : 'observing';
return `
${esc(s.domain || '—')}
${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)} · onboarding VM112
${sessionHashHtml(s.session_id)}
${s.ticket_id ? `#${s.ticket_id} ` : ''}
${assistBadge(status)}
${s.is_lead || s.crm_track === 'lead' ? 'lead ' : ''}
${['company_validated','webmail_released','completed'].includes(s.current_stage) ? '💳 billing ' : ''}
${s.stale && s.crm_track !== 'lead' && !s.is_lead ? 'abandonado ' : ''}
`;
}).join('');
box.innerHTML = `
${kpiCard('Abertos', summary.tickets_open, 'open')}
${kpiCard('Assistindo', summary.tickets_assisting ?? 0, 'assisting')}
${kpiCard('Escalados', summary.tickets_escalated ?? 0, 'escalated')}
${kpiCard('Sessões', funnel.sessions_total || 0, 'sessions', { title: 'Sessões onboarding — 48h' })}
${window.DeskModules?.isEnabled('billing-recurrence') ? kpiCard('Cobrança pendente', summary.billing_pending ?? 0, 'billing-pending', { title: 'Aguardam validação OPS' }) : ''}
${window.DeskModules?.isEnabled('billing-recurrence') ? kpiCard('Recorrência ativa', summary.billing_active ?? 0, 'billing-active', { title: 'Clientes com recorrência' }) : ''}
${canReadLeads() ? kpiCard('Leads', summary.leads_abandoned ?? leadsData.total ?? 0, 'leads', { clickable: true, viewJump: 'leads', title: 'Onboarding abandonado' }) : ''}
${dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk })}
Funil 48h
${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)}
Sessões ativas
Assistindo
Observando
${sessions.length} total
${sessionCards
? `
${sessionCards}
`
: '
Sem sessões recentes
'}
${canReadLeads() ? `
Leads abandonados
Ver todos
${(leadsData.leads || []).slice(0, 6).map(leadRowHtml).join('') || '
Nenhum lead — sessões stale viram lead após ${summary.onboard_stale_hours ?? 24}h
'}
` : ''}
Tickets recentes
${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '
Sem tickets
'}
${canAssist() && (techRanking.ranking || []).length ? `
Ranking técnicos
30d · assumidos / movimento
${techRankingHtml(techRanking.ranking)}
` : ''}`;
box.querySelectorAll('.ticket-row').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.id);
setView('tickets');
});
});
box.querySelectorAll('[data-session]').forEach((btn) => {
btn.addEventListener('click', () => {
const sess = sessions.find((s) => s.session_id === btn.dataset.session);
state.selectedSessionId = btn.dataset.session;
state.selectedTicketId = sess?.ticket_id || null;
setView('tickets');
});
});
box.querySelectorAll('[data-view-jump="leads"]').forEach((el) => {
el.addEventListener('click', () => setView('leads'));
});
box.querySelectorAll('[data-lead-ticket]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.leadTicket);
state.selectedSessionId = btn.dataset.leadSession || null;
setView('tickets');
});
});
} catch (e) {
box.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
function sourceBadge(src) {
if (src === 'desk-registration') return 'desk ';
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 eventTypeLabel(ev) {
const key = ev?.event_type || ev?.event;
return SOC_EVENT_LABELS[key] || key || '—';
}
let _liveTimingTimer = null;
function formatDurationSec(seconds) {
if (seconds == null || Number.isNaN(seconds)) return '—';
const sec = Math.max(0, Math.round(Number(seconds)));
if (sec < 60) return `${sec}s`;
const mins = Math.floor(sec / 60);
const rem = sec % 60;
if (mins < 60) return `${mins}m ${rem}s`;
const hrs = Math.floor(mins / 60);
const m2 = mins % 60;
if (hrs < 48) return `${hrs}h ${m2}m`;
const days = Math.floor(hrs / 24);
const h2 = hrs % 24;
return `${days}d ${h2}h`;
}
function stopLiveTimingClock() {
if (_liveTimingTimer) {
clearInterval(_liveTimingTimer);
_liveTimingTimer = null;
}
}
function bindLiveTimingClock(root = document) {
stopLiveTimingClock();
const card = root.querySelector?.('[data-timing-live-card]');
if (!card || card.dataset.timingCompleted === 'true') return;
const startedAt = card.dataset.timingStartedAt;
const lastAt = card.dataset.timingLastAt || startedAt;
if (!startedAt) return;
const totalEl = card.querySelector('[data-timing-live="total"]');
const idleEl = card.querySelector('[data-timing-live="idle"]');
const accEl = card.querySelector('[data-timing-live="accumulated"]');
const tick = () => {
const now = Date.now();
const startMs = new Date(startedAt).getTime();
const lastMs = new Date(lastAt).getTime();
if (!Number.isNaN(startMs) && totalEl) {
totalEl.textContent = formatDurationSec((now - startMs) / 1000);
}
if (!Number.isNaN(lastMs) && idleEl) {
idleEl.textContent = formatDurationSec((now - lastMs) / 1000);
}
if (!Number.isNaN(startMs) && accEl) {
accEl.textContent = `Σ ${formatDurationSec((now - startMs) / 1000)}`;
}
};
tick();
_liveTimingTimer = setInterval(tick, 1000);
}
function phaseTimingCardHtml(timing, events) {
if (!timing || !window.DeskModules?.isEnabled('funnel-timing') || !events?.length) return '';
const statusBadge = timing.is_completed
? 'concluído '
: `em curso `;
const lastEv = events[events.length - 1];
const rows = events.map((ev, idx) => {
const prev = idx > 0 ? (ev.duration_from_prev_label || '—') : '—';
const isLastLive = !timing.is_completed && idx === events.length - 1;
const total = isLastLive
? `Σ ${esc(timing.total_duration_label)} `
: `Σ ${esc(ev.duration_from_start_label || '—')}`;
return `
${esc(eventTypeLabel(ev))}
${fmtDate(ev.created_at || ev.at)}
${idx > 0 ? `+${esc(prev)} ` : '—'}
${total}
`;
}).join('');
return `
Relógio por fase
Duração entre etapas do onboarding VM112
${statusBadge}
Tempo total
${esc(timing.total_duration_label)}
Início
${fmtDate(timing.started_at)}
${timing.is_completed ? `
Concluído
${fmtDate(timing.completed_at)}
` : `
Parado há
${esc(timing.idle_since_label || '—')}
`}
Fase Registado Δ fase Acumulado
${rows}
`;
}
function timingSummaryHtml(timing) {
if (!timing || !window.DeskModules?.isEnabled('funnel-timing')) return '';
const idle = timing.is_completed ? '' : `Parado há ${esc(timing.idle_since_label)} `;
return `
Total ${esc(timing.total_duration_label)}
${idle}
${timing.completed_at ? `Concluído ${fmtDate(timing.completed_at)} ` : ''}
`;
}
function timelineHtml(events, timingMeta, opts = {}) {
if (!events?.length) return '';
const showTiming = !opts.compact && window.DeskModules?.isEnabled('funnel-timing');
return `${!opts.compact ? timingSummaryHtml(timingMeta) : ''}${events
.map(
(e, idx) => {
const evt = e.event_type || e.event || '—';
const at = e.created_at || e.at;
const prevDur = showTiming && idx > 0 && e.duration_from_prev_label && e.duration_from_prev_label !== '—'
? `+${esc(e.duration_from_prev_label)} `
: '';
const fromStart = showTiming && e.duration_from_start_label
? `Σ ${esc(e.duration_from_start_label)} `
: '';
return `
${esc(evt)}
${e.stage ? `
${esc(e.stage)} ` : ''}
${prevDur}${fromStart}
${fmtDate(at)}
`;
}
)
.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 leadRowHtml(l) {
return `
${esc(l.domain || '—')}
lead
${esc(l.email || 'sem e-mail')} · ${esc(FUNNEL_LABELS[l.funnel_stage] || l.funnel_stage || '—')}
#${l.ticket_id} · parado ${fmtDate(l.last_event_at)}
`;
}
function billingTicketIcon(t) {
if ((t.subject || '').includes('[billing-validation]') || t.billing_state) return ' 💳';
return '';
}
function normalizeAssistStatus(status) {
if (status === 'active') return 'assisting';
return status;
}
function ticketFunnelKvHtml(t) {
const latest = t.latest_funnel_event || t.event;
const opened = t.event_opened || t.event;
const showOpened = opened && latest && opened !== latest;
const outcome = t.onboarding_outcome;
const outcomeBadge = outcome === 'completed'
? 'concluído '
: outcome === 'failed'
? 'falhou '
: '';
const label = latest ? (SOC_EVENT_LABELS[latest] || latest) : '—';
const sev = latest && typeof socEventSeverity === 'function' ? socEventSeverity(latest) : 'open';
return `
Estado funil ${esc(label)} ${outcomeBadge}
${showOpened ? `Abertura ${esc(SOC_EVENT_LABELS[opened] || opened)} ` : ''}`;
}
function ticketRowHtml(t) {
const review = t.needs_review ? 'revisão ' : '';
const verified = t.account_verified ? 'verificado ' : '';
const lead = t.crm_track === 'lead' ? 'lead ' : '';
const isOnboard = t.source === 'vm112-onboard' || t.event?.startsWith?.('onboarding') || t.event === 'session.started';
const sub = t.event === 'wazuh.alert'
? esc(t.description || t.subject)
: isOnboard && !t.domain
? `Onboarding VM112 · ${esc(FUNNEL_LABELS[t.lead_funnel_stage] || t.event || 'iniciado')}`
: esc(t.domain || t.subject);
const metaParts = [];
if (isOnboard && t.session_id) metaParts.push(sessionHashHtml(t.session_id));
if (t.event === 'wazuh.alert') {
metaParts.push(esc(t.agent || t.domain || ''));
} else if (t.email) {
metaParts.push(esc(t.email));
}
metaParts.push(fmtDate(t.created_at));
if (t.assigned_to) metaParts.push(esc(t.assigned_to));
const meta = metaParts.filter(Boolean).join(' · ');
return `
${esc(statusLabel(t.status))}
${lead}${sourceBadge(t.source)}${severityBadge(t.severity)}${review}${verified}
`;
}
function assistActionsHtml(sessionId, meta, consoleExtra = {}) {
if (!canAssist() || !sessionId) return '';
const canAct = meta?.can_escalate;
const status = meta?.assist_status || meta?.ticket_status;
const isAssisting = status === 'assisting';
const isEscalated = status === 'escalated';
const deskActions = (consoleExtra.actions || []).map((a) =>
`${esc(a.label)} `
).join('');
const links = (consoleExtra.links || []).map((l) =>
`${esc(l.label)} `
).join('');
const audit = (meta?.actions || []).slice(-8).map((a) =>
`${esc(a.action)} · ${esc(a.actor)} · ${fmtDate(a.created_at)} `
).join('');
return `
Console de assistência
${assistBadge(status)}${meta?.assisted_by ? ` · ${esc(meta.assisted_by)}` : ''}
${!isAssisting && !isEscalated && canAct ? `Escalar ` : ''}
${canAct && !isAssisting ? `Assumir sessão ` : ''}
${isAssisting ? `Devolver ao cliente ` : ''}
${!canAct ? 'Intervenção disponível após domínio validado ' : ''}
${deskActions ? `
Acções Desk ${deskActions}
` : ''}
${links ? `
` : ''}
${audit ? `
Movimento / audit ` : ''}
`;
}
async function loadAssistMeta(sessionId) {
if (!sessionId) return null;
try {
const [meta, actionsRes, linksRes] = await Promise.all([
api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}`),
api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions`).catch(() => ({ actions: [] })),
api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/links`).catch(() => ({ links: [] })),
]);
return { ...meta, _console: { actions: actionsRes.actions || [], links: linksRes.links || [] } };
} catch {
return null;
}
}
async function runAssistAction(action, sessionId) {
const path = `/v1/assist/sessions/${encodeURIComponent(sessionId)}/${action}`;
const result = await api(path, { method: 'POST' });
if (action === 'takeover' && result.takeover_url) {
window.open(result.takeover_url, '_blank', 'noopener');
}
return result;
}
function bindAssistActions(container, sessionId) {
container.querySelectorAll('[data-assist]').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await runAssistAction(btn.dataset.assist, sessionId);
await renderTickets();
} catch (e) {
alert(e.message || 'Falha na ação de assistência');
} finally {
btn.disabled = false;
}
});
});
container.querySelectorAll('[data-desk-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const actionId = btn.dataset.deskAction;
if (actionId === 'onboarding.abort' && !confirm('Abortar onboarding desta sessão?')) return;
btn.disabled = true;
try {
await api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions/${encodeURIComponent(actionId)}`, { method: 'POST' });
await renderTickets();
} catch (e) {
alert(e.message || 'Falha na ação');
} finally {
btn.disabled = false;
}
});
});
}
function kpiCard(label, value, variant, opts = {}) {
const click = opts.clickable ? ' kpi-card--click' : '';
const jump = opts.viewJump ? ` data-view-jump="${opts.viewJump}"` : '';
const title = opts.title ? ` title="${esc(opts.title)}"` : '';
return `
`;
}
function dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk }) {
const tenants = audit.tenants || [];
const auditChips = tenants.map((t) => {
const cls = t.status === 'healthy' ? 'ok' : t.status === 'degraded' ? 'warn' : 'alert';
return `
${esc(t.name)}
${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks
${healthBadge(t.status)}
`;
}).join('');
return `
${auditChips}
VM112 Portal
${esc(vm112.vm112?.service || vm112.error || '—')}
${vmOk ? 'online' : 'check'}
VM104 Wazuh
API ${wazuh.http_status ?? '—'}
${wazuhOk ? 'online' : 'check'}
`;
}
function techRankingHtml(ranking) {
if (!ranking?.length) return 'Sem movimento no período
';
return `
# Técnico Assumidos Escalados Acções Score
${ranking.slice(0, 8).map((r, i) => `
${i + 1}
${esc(r.username)}
${r.assumidos}
${r.escalados}
${r.acoes}
${r.score}
`).join('')}
`;
}
function dnsPurposeLabel(purpose) {
return {
mx: 'MX',
spf: 'SPF',
dkim: 'DKIM',
dmarc: 'DMARC',
'mail-host': 'Mail host',
autodiscover: 'Autodiscover',
'mail-alias': 'Alias',
other: 'Outro',
}[purpose] || purpose || '—';
}
async function fetchCloudflareDns(domain, emailService) {
try {
return await api(
`/v1/dns/cloudflare/records?domain=${encodeURIComponent(domain)}&email_service=${emailService ? 'true' : 'false'}`
);
} catch (e) {
return {
domain,
records: [],
email_records: [],
summary: { total: 0, email_related: 0 },
error: e.message || 'Falha ao carregar DNS Cloudflare',
};
}
}
function isEmailServiceDomain(tenantId, funnelStage) {
return tenantId === 1 || ['dns_applied', 'account_created', 'infra_synced', 'completed', 'company_validated', 'webmail_released'].includes(funnelStage);
}
async function showOverviewHomeDnsPanel(domain, tenantId, funnelStage, domainMeta = null) {
const panel = document.getElementById('cf-dns-panel-body');
const label = document.getElementById('cf-dns-domain-label');
if (!panel) return;
state.overviewHomeDnsDomain = domain;
if (label) label.textContent = domain;
panel.innerHTML = `Carregando detalhes de ${esc(domain)} …
`;
let timing = domainMeta?.timing;
let timeline = domainMeta?.timeline;
if (window.DeskModules?.isEnabled('funnel-timing') && (!timing || !timeline?.length) && tenantId) {
try {
const details = await api(`/v1/audit/tenants/${tenantId}/details`);
const match = (details.domains || []).find((item) => item.domain === domain);
timing = match?.timing || timing;
timeline = match?.timeline || timeline;
} catch {
/* mantém o que tiver */
}
}
const timingCard = phaseTimingCardHtml(timing, timeline);
const dns = await fetchCloudflareDns(domain, isEmailServiceDomain(tenantId, funnelStage));
panel.innerHTML = `${timingCard}${htmlCloudflareDnsCardInline(dns)}`;
}
function htmlCloudflareDnsCardInline(dns) {
if (!dns) {
return 'Dados DNS indisponíveis.
';
}
if (dns.error && !dns.records?.length) {
return `
${esc(dns.error)}
${dns.email_service ? 'Serviço: servidor de e-mail (onboarding)
' : ''}`;
}
const rows = (dns.records || []).map((r) => `
${esc(dnsPurposeLabel(r.purpose))}
${esc(r.name)}
${esc(r.type)}
${esc(r.content)}
`).join('');
const summary = dns.summary || {};
const zone = dns.zone || {};
return `
${summary.total || 0}
registos na zona
${summary.email_related || 0}
para e-mail
${dns.email_service ? 'E-mail' : 'DNS'}
Zona ${esc(zone.name || '—')}${dns.error ? ` · ${esc(dns.error)}` : ''}
Função Nome Tipo Conteúdo
${rows || 'Sem registos para este domínio. '}
`;
}
function htmlCloudflareDnsCard(dns) {
if (!dns) {
return `
Apontamentos DNS (Cloudflare)
Dados DNS indisponíveis.
`;
}
if (dns.error && !dns.records?.length) {
return `
Apontamentos DNS (Cloudflare)
${esc(dns.error)}
${dns.email_service ? '
Serviço: servidor de e-mail (onboarding)
' : ''}
`;
}
const rows = (dns.records || []).map((r) => `
${esc(dnsPurposeLabel(r.purpose))}
${esc(r.name)}
${esc(r.type)} ${r.priority != null ? ` prio ${r.priority} ` : ''}
${esc(r.content)}
${r.proxied ? 'proxy' : 'DNS only'} · TTL ${r.ttl ?? '—'}
`).join('');
const summary = dns.summary || {};
const zone = dns.zone || {};
return `
Apontamentos DNS (Cloudflare)
${dns.email_service ? 'Servidor de e-mail' : 'DNS geral'}
Zona ${esc(zone.name || '—')} · ${summary.total || 0} registo(s)
· ${summary.email_related || 0} para e-mail
${dns.error ? ` · ${esc(dns.error)} ` : ''}
Função Nome Tipo Conteúdo Estado
${rows || 'Sem registos DNS para este domínio na zona Cloudflare. '}
`;
}
function executionStatusBadge(status) {
const map = {
in_progress: ['assisting', 'em execução'],
completed: ['ok', 'concluído'],
failed: ['escalated', 'falhou'],
registered: ['open', 'registado'],
};
const [cls, label] = map[status] || ['open', status || '—'];
return `${esc(label)} `;
}
function bindOverviewModal() {
document.querySelectorAll('[data-close-overview-modal]').forEach((el) => {
el.addEventListener('click', closeOverviewModal);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeOverviewModal();
});
}
function closeOverviewModal() {
const modal = document.getElementById('overview-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
state.overviewModal = { tenantId: null, view: 'list', domain: null, data: null, focus: 'onboard' };
}
function renderWazuhOverviewCard(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 apiLabel = t.api_online ? `API online (${t.http_status || '—'})` : 'API offline';
return `
${esc(t.name)}
${esc(t.ip)} · ${t.alerts_total || 0} alerta(s) · ${t.agents_count || 0} agente(s)
${healthBadge(t.status)}
${t.alerts_high || 0} alto (L≥${t.min_ticket_level || 10})
${t.open_tickets || 0} ticket(s) aberto(s)
${esc(apiLabel)}
Último alerta: ${fmtDate(t.last_alert_at)}
${issues ? `` : 'Sem alertas Wazuh registados — integração ativa aguarda eventos.
'}
Clique para ver agentes, alertas por nível e tickets SOC
`;
}
function renderWazuhSocModal(data) {
const body = document.getElementById('overview-modal-body');
const title = document.getElementById('overview-modal-title');
const sub = document.getElementById('overview-modal-sub');
if (!body || !title || !sub) return;
const s = data.summary || {};
title.textContent = data.name || 'Wazuh SOC';
sub.textContent = `${data.ip || '—'} · API ${s.api_online ? 'online' : 'offline'} · gerado ${fmtDate(data.generated_at)}`;
const agentRows = (data.agents || []).map((a) => `
${esc(a.agent)}
${esc(a.agent_ip || '—')}
${a.alerts_count}
L${a.max_level}
${relativeTimeAgo(a.last_seen)}
`).join('');
const alertRows = (data.alerts || []).slice(0, 40).map((a) => `
${severityBadge(a.level)}
${esc(a.agent)}
${esc(a.description || '—')}
${esc(a.srcip || '—')}
${esc(a.agent_ip || '—')}
${relativeTimeAgo(a.created_at)}
`).join('');
const ticketRows = (data.tickets || []).slice(0, 15).map((t) => `
${esc(statusLabel(t.status))}
${esc(t.subject)}
#${t.id} · ${fmtDate(t.created_at)}
`).join('');
body.innerHTML = `
${s.alerts_total || 0} Alertas
${s.alerts_24h || 0} 24h
${s.agents_total || 0} Agentes
${s.level_10_plus || 0} L≥${data.min_ticket_level || 10}
${s.open_tickets || 0} Tickets
Monitorização de segurança VM104 — webhooks wazuh.alert com nível ≥ ${data.min_ticket_level || 10} geram ticket na VM122.
Agentes monitorados
${agentRows ? `
Agente IP Alertas Máx Último
${agentRows}
` : '
Nenhum agente com alertas registados.
'}
Feed de alertas
${alertRows ? `
Nível Agente Descrição Src IP Agent IP Hora
${alertRows}
` : '
Sem alertas.
'}
${ticketRows ? `
Tickets Wazuh
${ticketRows}
` : ''}`;
body.querySelectorAll('[data-open-ticket]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.openTicket);
closeOverviewModal();
setView('tickets');
});
});
}
function wizardSecuritySeverityBadge(sev) {
const map = { high: ['escalated', 'Alto'], critical: ['escalated', 'Crítico'], warn: ['review', 'Atenção'], info: ['open', 'Info'] };
const [cls, label] = map[sev] || ['open', sev || '—'];
return `${esc(label)} `;
}
function wizardSecurityEventLabel(ev) {
return SECURITY_EVENT_LABELS[ev] || SOC_EVENT_LABELS[ev] || ev || '—';
}
const SECURITY_EVENT_LABELS = {
'security.csp_violation': 'Violação CSP',
'security.input_warn': 'Input suspeito',
'security.input_blocked': 'Input bloqueado',
'security.rate_limited': 'Rate limit',
'security.handoff_created': 'Handoff criado',
'security.handoff_consumed': 'Handoff consumido',
'security.handoff_rejected': 'Handoff rejeitado',
'security.handoff_expired': 'Handoff expirado',
'security.auth_failed': 'Auth portal falhou',
'security.session_anomaly': 'Anomalia sessão',
};
const WIZARD_SEC_COLORS = {
teal: '#0d9488',
tealLight: '#14b8a6',
orange: '#ea580c',
orangeLight: '#f97316',
severe: '#7f1d1d',
high: '#dc2626',
elevated: '#ea580c',
guarded: '#eab308',
low: '#22c55e',
na: '#94a3b8',
csp: '#0891b2',
input: '#dc2626',
handoff: '#ea580c',
auth: '#7c3aed',
rate: '#64748b',
};
function wizardSecRiskScore(severity, eventType) {
if (severity === 'critical') return 5;
if (severity === 'high' || (eventType || '').includes('blocked') || (eventType || '').includes('rejected')) return 4;
if (severity === 'warn' || (eventType || '').includes('csp')) return 3;
if (severity === 'info') return 2;
return 1;
}
function wizardSecRiskCell(score) {
const map = {
5: ['Severo', WIZARD_SEC_COLORS.severe],
4: ['Alto', WIZARD_SEC_COLORS.high],
3: ['Elevado', WIZARD_SEC_COLORS.elevated],
2: ['Vigiado', WIZARD_SEC_COLORS.guarded],
1: ['Baixo', WIZARD_SEC_COLORS.low],
0: ['N/A', WIZARD_SEC_COLORS.na],
};
const [label, bg] = map[score] || map[0];
return `${esc(label)} `;
}
function wizardSecDonutSvg(segments, size = 130) {
const filtered = segments.filter((s) => s.value > 0);
const total = filtered.reduce((a, s) => a + s.value, 0) || 1;
const r = 42;
const cx = size / 2;
const cy = size / 2;
const circ = 2 * Math.PI * r;
let offset = 0;
const arcs = filtered.map((s) => {
const len = (s.value / total) * circ;
const el = ` `;
offset += len;
return el;
}).join('');
return `${arcs}${total} `;
}
function wizardSecVBarSvg(items, width = 260, height = 120) {
if (!items.length) return 'Sem dados
';
const max = Math.max(...items.map((i) => i.value), 1);
const gap = 10;
const barW = Math.max(18, (width - gap * (items.length + 1)) / items.length);
const bars = items.map((item, i) => {
const bh = Math.max(2, (item.value / max) * (height - 36));
const x = gap + i * (barW + gap);
const y = height - 24 - bh;
return `
${esc(item.short || item.label)}
${item.value} `;
}).join('');
return `${bars} `;
}
function wizardSecHBarHtml(items) {
if (!items.length) return 'Sem dados
';
const max = Math.max(...items.map((i) => i.value), 1);
return items.map((item) => `
${esc(item.label)}
${item.value}
`).join('');
}
function wizardSecVectorBucket(eventType) {
const ev = eventType || '';
if (ev.includes('csp')) return 'csp';
if (ev.includes('input') || ev.includes('rate')) return 'input';
if (ev.includes('handoff')) return 'handoff';
if (ev.includes('auth') || ev.includes('session')) return 'auth';
return 'outro';
}
function wizardSecAccessStatus(s) {
if ((s.inputs_blocked || 0) + (s.handoffs_rejected || 0) > 0) return 'critical';
if ((s.total || 0) > 0) return 'degraded';
return 'healthy';
}
function renderUserAccessOverviewCard(sec) {
if (!window.DeskModules?.isEnabled('wizard-security')) return '';
const s = sec || { total: 0, inputs_blocked: 0, handoffs_rejected: 0, csp_violations: 0, sessions_with_alerts: 0, recent: [] };
const status = wizardSecAccessStatus(s);
const issues = (s.recent || []).slice(0, 3).map((ev) =>
`${esc((ev.client_ip || '—'))} · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : 'sem sessão'} `
).join('');
return `
Acesso Usuário — Cybersecurity
Portal público · browser · handoff · não é o wizard VM112
${healthBadge(status)}
${s.total || 0} alerta(s) · ${s.inputs_blocked || 0} bloq · ${s.handoffs_rejected || 0} handoff · ${s.csp_violations || 0} CSP
${s.sessions_with_alerts || 0} sessão(ões) · janela 24h
${issues ? `` : 'Sem alertas de acesso nas últimas 24h
'}
Clique para ver dashboard de ameaças, guia técnico e relatório
`;
}
function renderWizardSecurityCard(sec, opts = {}) {
const standalone = opts.standalone === true;
if (!window.DeskModules?.isEnabled('wizard-security')) return '';
const s = sec || { total: 0, csp_violations: 0, inputs_blocked: 0, handoffs_rejected: 0, sessions_with_alerts: 0, recent: [], by_event: {} };
const byEvent = s.by_event || {};
const recent = s.recent || [];
const severityCounts = { high: 0, warn: 0, info: 0 };
recent.forEach((ev) => {
const sev = ev.severity || 'info';
if (sev === 'high' || sev === 'critical') severityCounts.high += 1;
else if (sev === 'warn') severityCounts.warn += 1;
else severityCounts.info += 1;
});
const eventBars = Object.entries(byEvent).map(([ev, count]) => ({
label: wizardSecurityEventLabel(ev),
short: wizardSecurityEventLabel(ev).split(' ')[0],
value: count,
color: ev.includes('blocked') || ev.includes('rejected') ? WIZARD_SEC_COLORS.high
: ev.includes('csp') ? WIZARD_SEC_COLORS.csp
: ev.includes('handoff') ? WIZARD_SEC_COLORS.handoff
: WIZARD_SEC_COLORS.teal,
})).slice(0, 6);
const vectors = { csp: 0, input: 0, handoff: 0, auth: 0 };
Object.entries(byEvent).forEach(([ev, count]) => {
const bucket = wizardSecVectorBucket(ev);
if (vectors[bucket] != null) vectors[bucket] += count;
});
const vectorBars = [
{ label: 'CSP Browser', value: vectors.csp, color: WIZARD_SEC_COLORS.csp },
{ label: 'Input audit', value: vectors.input, color: WIZARD_SEC_COLORS.input },
{ label: 'Handoff', value: vectors.handoff, color: WIZARD_SEC_COLORS.handoff },
{ label: 'Auth/Sessão', value: vectors.auth, color: WIZARD_SEC_COLORS.auth },
];
const ipMap = {};
recent.forEach((ev) => {
const ip = ev.client_ip || '—';
ipMap[ip] = (ipMap[ip] || 0) + 1;
});
const topIps = Object.entries(ipMap).sort((a, b) => b[1] - a[1]).slice(0, 5);
const riskBars = [
{ label: 'Input bloqueado', value: s.inputs_blocked || 0, color: WIZARD_SEC_COLORS.high },
{ label: 'Handoff rejeitado', value: s.handoffs_rejected || 0, color: WIZARD_SEC_COLORS.orange },
{ label: 'Violação CSP', value: s.csp_violations || 0, color: WIZARD_SEC_COLORS.csp },
{ label: 'Input suspeito', value: s.inputs_warn || 0, color: WIZARD_SEC_COLORS.guarded },
{ label: 'Rate limit', value: s.rate_limited || 0, color: WIZARD_SEC_COLORS.na },
];
const threatRows = recent.slice(0, 8).map((ev) => {
const score = wizardSecRiskScore(ev.severity, ev.event_type);
return `
${esc(wizardSecurityEventLabel(ev.event_type))}
${wizardSecRiskCell(score)}
${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : '—'}
${esc(ev.client_ip || '—')}
${fmtDate(ev.created_at)}
`;
}).join('');
const accessStatus = wizardSecAccessStatus(s);
const issueLines = recent.slice(0, 3).map((ev) =>
`${esc(ev.client_ip || '—')} · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? `${esc(ev.session_id.slice(0, 14))}…` : 'sem sessão'} `
).join('');
const dashboardGrid = `
${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}
${wizardSecDonutSvg([
{ value: severityCounts.high, color: WIZARD_SEC_COLORS.high },
{ value: severityCounts.warn, color: WIZARD_SEC_COLORS.elevated },
{ value: severityCounts.info, color: WIZARD_SEC_COLORS.low },
])}
Alto (${severityCounts.high})
Elevado (${severityCounts.warn})
Baixo (${severityCounts.info})
${wizardSecVBarSvg(vectorBars)}
${topIps.length ? topIps.map(([ip, n], i) => `
${i + 1}
${esc(ip)}
${n} evt
`).join('') : '
Nenhum IP registado
'}
${wizardSecHBarHtml(riskBars)}
Ameaça Nível Sessão IP Hora
${threatRows || 'Sem ameaças nas últimas 24h '}
`;
return `
Área independente
Acesso de usuário — Cibersegurança
Eventos gerados quando alguém acede ao portal público , preenche formulários ou faz login (handoff). Isto é segurança de acesso — não mede DNS, Carbonio, certificados nem progresso do wizard VM112.
Threat tracking — portal & sessões
Browser · CSP · inputs · handoff · Spec 021
${healthBadge(accessStatus)}
${s.total || 0} alerta(s) · ${s.inputs_blocked || 0} bloq · ${s.handoffs_rejected || 0} handoff · ${s.csp_violations || 0} CSP · ${s.sessions_with_alerts || 0} sessões
Janela ${s.window_hours || 24}h · origem vm112-security
${issueLines ? `
` : '
Nenhum incidente de acesso nas últimas 24h
'}
${dashboardGrid}
Este painel cobre apenas o comportamento de quem acede ao sistema — visitantes, clientes no portal e tentativas de abuso em formulários públicos.
CSP (browser) — scripts ou recursos bloqueados no navegador do usuário
Input audit — padrões SQL/XSS em campos enviados pelo usuário
Handoff — token de login expirado, reutilizado ou inválido
Auth / sessão — falhas de autenticação ou anomalias de sessão
${standalone
? 'Domínios, DNS e Carbonio estão no card VM112 Ligbox Onboard — área separada.'
: '≠ Saúde do wizard VM112 (domínios, e-mail, certificados) — ver secção Onboard abaixo.'}
Como proceder — guia técnico
Input bloqueado / CSP — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.
Handoff rejeitado — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).
Correlacionar — Tickets → Onboard → hash da sessão. Compare com funil.
Takeover — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.
Falso positivo — Domínios com caracteres especiais podem gerar input_warn .
Escalação — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.
${standalone ? '' : `
VM112 Ligbox Onboard — wizard, domínios & infraestrutura
`}`;
}
function bindWizardSecurityCard(root) {
root.querySelector('[data-wizard-sec-goto-events]')?.addEventListener('click', () => {
state.eventsTab = 'security';
state.eventSourceFilter = 'vm112-security';
closeOverviewModal();
setView('events');
});
root.querySelector('[data-open-onboard-from-access]')?.addEventListener('click', () => {
openOverviewModal(1, { focus: 'onboard' });
});
root.querySelectorAll('[data-wizard-sec-session]').forEach((row) => {
const sid = row.dataset.wizardSecSession;
if (!sid) return;
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
state.selectedSessionId = sid;
closeOverviewModal();
setView('tickets');
});
});
}
function renderOverviewModalList(data) {
if (data.kind === 'wazuh_soc' && !window.DeskModules?.isEnabled('wazuh-soc')) {
data = { ...data, kind: 'audit', domains: data.domains || [] };
}
if (data.kind === 'wazuh_soc') {
renderWazuhSocModal(data);
return;
}
const body = document.getElementById('overview-modal-body');
const title = document.getElementById('overview-modal-title');
const sub = document.getElementById('overview-modal-sub');
if (!body || !title || !sub) return;
const s = data.summary || {};
const focus = state.overviewModal?.focus || 'onboard';
const showAccess = focus === 'access' && data.tenant_id === 1 && window.DeskModules?.isEnabled('wizard-security');
if (showAccess) {
const sec = data.security || {};
title.textContent = 'Acesso Usuário — Cybersecurity';
sub.textContent = `Portal & sessões · ${sec.total || 0} alerta(s) 24h · ${sec.sessions_with_alerts || 0} sessão(ões) · gerado ${fmtDate(data.generated_at)}`;
body.innerHTML = renderWizardSecurityCard(sec, { standalone: true });
bindWizardSecurityCard(body);
return;
}
title.textContent = data.name || 'Detalhes do tenant';
sub.textContent = `${data.ip || '—'} · ${s.domains_total || 0} domínio(s) · wizard & infra · gerado ${fmtDate(data.generated_at)}`;
const rows = (data.domains || []).map((d) => {
const issuePreview = (d.issues || []).slice(0, 2).map((i) =>
`${esc(i.check_id)} — ${esc(i.message || i.status)} `
).join('');
return `
${esc(d.domain)}
${executionStatusBadge(d.execution_status)}
${healthBadge(d.audit_status)}
${esc(d.email || 'sem e-mail')}
${esc(d.funnel_stage_label || d.funnel_stage || '—')}
início ${fmtDate(d.started_at)}
último ${fmtDate(d.last_event_at)}
${d.timing && window.DeskModules?.isEnabled('funnel-timing') ? `total ${esc(d.timing.total_duration_label)} ` : ''}
IP ${esc(d.client_ip || '—')}
${d.ticket_id ? `ticket #${d.ticket_id} ` : ''}
${issuePreview ? `` : ''}
`;
}).join('');
body.innerHTML = `
VM112 Ligbox Onboard
Saúde do wizard, domínios em onboarding, DNS, certificados e Carbonio
${window.DeskModules?.isEnabled('wizard-security') ? '
← Acesso Usuário — Cybersecurity ' : ''}
${s.domains_total || 0} Total
${s.in_progress || 0} Em execução
${s.completed || 0} Concluídos
${s.failed || 0} Falharam
${s.with_issues || 0} Com erros
Clique num domínio para ver apontamentos DNS Cloudflare, timeline, checks e IP de acesso.
${rows || 'Nenhum domínio auditado neste tenant.
'}
`;
body.querySelector('[data-open-access-from-onboard]')?.addEventListener('click', () => {
openUserAccessModal();
});
body.querySelectorAll('[data-overview-domain]').forEach((btn) => {
btn.addEventListener('click', () => openOverviewDomainDetail(btn.dataset.overviewDomain));
});
}
async function openOverviewDomainDetail(domain) {
const body = document.getElementById('overview-modal-body');
const data = state.overviewModal.data;
if (!body || !data) return;
const d = (data.domains || []).find((item) => item.domain === domain);
if (!d) return;
state.overviewModal.view = 'domain';
state.overviewModal.domain = domain;
body.innerHTML = 'Carregando detalhes…
';
let checks = d.issues || [];
const isEmailService = isEmailServiceDomain(data.tenant_id, d.funnel_stage);
try {
const sc = await api(`/v1/audit/tenants/${data.tenant_id}/scorecard?domain=${encodeURIComponent(domain)}`);
checks = sc.checks || checks;
} catch {
/* usa issues já carregados */
}
const dnsData = await fetchCloudflareDns(domain, isEmailService);
const checkRows = checks.map((c) => `
${esc(c.label || c.check_id)}
${checkStatusBadge(c.status)}
${esc(c.message || '—')}
${fmtDate(c.checked_at)}
`).join('');
const timelineBlock = d.timeline?.length
? `${phaseTimingCardHtml(d.timing, d.timeline)}Eventos ${timelineHtml(d.timeline, d.timing, { compact: true })}`
: 'Sem eventos webhook para este domínio.
';
const ips = (d.client_ips || []).filter(Boolean);
body.innerHTML = `
← Voltar à lista
${esc(data.name)}
${esc(d.domain)}
${esc(d.email || 'sem e-mail')} · sessão ${esc((d.session_id || '—').slice(0, 18))}
${executionStatusBadge(d.execution_status)}
${healthBadge(d.audit_status)}
Etapa funil ${esc(d.funnel_stage_label || d.funnel_stage || '—')}
Início ${fmtDate(d.started_at)}
Último evento ${esc(d.last_event || '—')} · ${fmtDate(d.last_event_at)}
Último audit ${fmtDate(d.last_audit_at)}
IP de acesso ${esc(d.client_ip || (ips[0] || '—'))}
Ticket ${d.ticket_id ? `#${d.ticket_id} (${esc(d.ticket_status || '—')})` : '—'}
${ips.length > 1 ? `IPs observados: ${ips.map((ip) => `${esc(ip)}`).join(' · ')}
` : ''}
${htmlCloudflareDnsCard(dnsData)}
Checks de auditoria
Check Status Mensagem Verificado
${checkRows || 'Sem checks '}
Timeline de execução
${timelineBlock}
${d.ticket_id ? `Abrir ticket #${d.ticket_id}
` : ''}`;
body.querySelector('[data-overview-back]')?.addEventListener('click', () => renderOverviewModalList(data));
body.querySelector('[data-open-ticket]')?.addEventListener('click', (btn) => {
state.selectedTicketId = Number(btn.target.dataset.openTicket);
closeOverviewModal();
setView('tickets');
});
}
async function openOverviewModal(tenantId, options = {}) {
const modal = document.getElementById('overview-modal');
const body = document.getElementById('overview-modal-body');
if (!modal || !body) return;
const focus = options.focus || 'onboard';
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
body.innerHTML = 'Carregando detalhes…
';
try {
const data = await api(`/v1/audit/tenants/${tenantId}/details`);
state.overviewModal = { tenantId, view: 'list', domain: null, data, focus };
renderOverviewModalList(data);
} catch (e) {
console.error('openOverviewModal', e);
body.innerHTML = `
Erro: ${esc(e.message)}
Tentar novamente `;
body.querySelector('[data-retry-overview-modal]')?.addEventListener('click', () => {
openOverviewModal(tenantId, { focus });
});
}
}
async function openUserAccessModal() {
const modal = document.getElementById('overview-modal');
const body = document.getElementById('overview-modal-body');
const title = document.getElementById('overview-modal-title');
const sub = document.getElementById('overview-modal-sub');
if (!modal || !body) return;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
body.innerHTML = 'Carregando segurança de acesso…
';
try {
const sec = await api('/v1/security/summary?window_hours=24');
const generatedAt = new Date().toISOString();
const data = {
tenant_id: 1,
name: 'Acesso Usuário — Cybersecurity',
generated_at: generatedAt,
security: sec,
};
state.overviewModal = { tenantId: 1, view: 'list', domain: null, data, focus: 'access' };
if (title) title.textContent = 'Acesso Usuário — Cybersecurity';
if (sub) {
sub.textContent = `Portal & sessões · ${sec.total || 0} alerta(s) 24h · ${sec.sessions_with_alerts || 0} sessão(ões) · gerado ${fmtDate(generatedAt)}`;
}
body.innerHTML = renderWizardSecurityCard(sec, { standalone: true });
bindWizardSecurityCard(body);
} catch (e) {
console.error('openUserAccessModal', e);
body.innerHTML = `
Erro ao carregar segurança de acesso: ${esc(e.message)}
Verifique ligação ao Desk e permissões de audit.
Tentar novamente `;
body.querySelector('[data-retry-user-access]')?.addEventListener('click', () => openUserAccessModal());
}
}
async function renderOverview() {
const el = document.getElementById('overview-content');
el.innerHTML = 'Carregando overview…
';
try {
const secPromise = window.DeskModules?.isEnabled('wizard-security')
? api('/v1/security/summary?window_hours=24').catch(() => null)
: Promise.resolve(null);
const [data, secSummary] = await Promise.all([
api('/v1/audit/overview'),
secPromise,
]);
const cards = [];
if (secSummary?.enabled !== false && window.DeskModules?.isEnabled('wizard-security')) {
const accessCard = renderUserAccessOverviewCard(secSummary);
if (accessCard) cards.push(accessCard);
}
(data.tenants || []).forEach((t) => {
if (t.kind === 'wazuh_soc' && window.DeskModules?.isEnabled('wazuh-soc')) {
cards.push(renderWazuhOverviewCard(t));
return;
}
const issues = (t.top_issues || [])
.slice(0, 3)
.map((i) => `${esc(i.domain)} · ${esc(i.check_id)} — ${esc(i.message || i.status)} `)
.join('');
cards.push(`
${esc(t.name)}
${esc(t.ip)} · ${t.domains_count || 0} domínio(s) · wizard & infra
${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 aguarde o 1º ciclo de auditoria
'}
Clique para ver domínios, DNS, checks e timeline do onboarding
`);
});
el.innerHTML = cards.length
? `${cards.join('')}
`
: 'Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.
';
el.querySelectorAll('[data-open-overview]').forEach((btn) => {
btn.addEventListener('click', () => {
openOverviewModal(Number(btn.dataset.openOverview), { focus: 'onboard' });
});
});
el.querySelectorAll('[data-open-user-access]').forEach((btn) => {
btn.addEventListener('click', () => openUserAccessModal());
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
function overviewHomeWindowHours() {
return { '24h': 24, '7d': 168, '30d': 720 }[state.overviewHomeWindow] || 24;
}
function isInWindow(iso, hours) {
if (!iso) return false;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return false;
return Date.now() - t <= hours * 3600000;
}
function relativeTimeAgo(iso) {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
if (diff < 0) return 'agora';
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'agora';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 48) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function sparklineSvg(values, color = '#2f6fed') {
const w = 118;
const h = 34;
const pad = 3;
const data = values?.length ? values : [0, 0, 0, 0, 0, 0];
const max = Math.max(...data, 1);
const pts = data.map((v, i) => {
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - pad * 2);
const y = h - pad - (v / max) * (h - pad * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return ` `;
}
function bucketEvents(events, windowHours, buckets = 12) {
const out = Array(buckets).fill(0);
const now = Date.now();
const start = now - windowHours * 3600000;
for (const ev of events) {
const t = new Date(ev.at || ev.created_at).getTime();
if (Number.isNaN(t) || t < start) continue;
const idx = Math.min(buckets - 1, Math.floor(((t - start) / (windowHours * 3600000)) * buckets));
out[idx] += 1;
}
return out;
}
function domainStatusDot(status) {
if (status === 'healthy') return 'ok';
if (status === 'degraded') return 'warn';
if (status === 'critical') return 'bad';
return 'unknown';
}
function buildOverviewHomeTrail(events, domainsFlat, filter, windowHours) {
const rows = [];
for (const ev of events) {
if (!isInWindow(ev.created_at, windowHours)) continue;
const p = ev.payload || {};
const source = ev.source || p.source || 'unknown';
if (filter === 'onboard' && source !== 'vm112-onboard') continue;
if (filter === 'wazuh' && source !== 'wazuh') continue;
if (filter === 'checks') continue;
const trailDomain = ev.domain || p.domain || '';
const trailDomainMeta = domainsFlat.find((item) => item.domain === trailDomain);
rows.push({
action: ev.event_type || 'event',
target: trailDomain || p.data?.agent || '—',
at: ev.created_at,
source,
tenant_id: trailDomainMeta?.tenant_id || (source === 'wazuh' ? 2 : 1),
funnel_stage: trailDomainMeta?.funnel_stage || '',
kind: 'webhook',
});
}
for (const d of domainsFlat) {
for (const issue of d.issues || []) {
if (!isInWindow(issue.checked_at, windowHours)) continue;
if (filter === 'onboard' || filter === 'wazuh') continue;
rows.push({
action: `check.${issue.status}`,
target: d.domain,
detail: `${issue.check_id} — ${issue.message || issue.status}`,
at: issue.checked_at,
source: 'audit',
tenant_id: d.tenant_id,
funnel_stage: d.funnel_stage || '',
kind: 'check',
domain: d.domain,
});
}
}
rows.sort((a, b) => new Date(b.at) - new Date(a.at));
return rows.slice(0, 40);
}
async function renderOverviewHome(options = {}) {
const el = document.getElementById('overview-home-content');
if (!el) return;
if (window.DeskServices?.renderPage) {
await window.DeskServices.renderPage(el, options);
return;
}
if (window.DeskAccounts?.renderPage) {
await window.DeskAccounts.renderPage(el, options);
return;
}
el.innerHTML = 'Módulo Serviços não carregado.
';
}
async function renderLeads() {
const el = document.getElementById('leads-content');
if (!canReadLeads()) {
el.innerHTML = 'Sem permissão para ver leads
';
return;
}
el.innerHTML = 'Carregando leads…
';
try {
const data = await api('/v1/crm/leads');
const leads = data.leads || [];
el.innerHTML = `
Leads abandonados
Stale ≥ ${data.stale_hours ?? 24}h sem concluir onboarding
Tickets promovidos automaticamente pelo worker quando o cliente para no funil.
Use o e-mail do ticket para recuperação (Spec 012 Fase C — chat).
${leads.length
? `
${leads.map(leadRowHtml).join('')}
`
: '
Nenhum lead no momento
'}
`;
el.querySelectorAll('[data-lead-ticket]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.leadTicket);
state.selectedSessionId = btn.dataset.leadSession || null;
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderTickets(options = {}) {
const { poll = false } = options;
stopLiveTimingClock();
const listEl = document.getElementById('ticket-list');
const detailEl = document.getElementById('ticket-detail');
if (poll && window.TicketsWorkspace?._pageReady) {
await TicketsWorkspace.softRefresh();
return;
}
listEl.innerHTML = 'Carregando tickets…
';
try {
let tickets = [];
if (state.ticketFilter === 'leads') {
const data = await api('/v1/crm/leads');
tickets = (data.leads || []).map((l) => ({
id: l.ticket_id,
subject: l.subject,
domain: l.domain,
email: l.email,
status: l.status,
created_at: l.created_at,
source: 'vm112-onboard',
crm_track: 'lead',
assigned_to: l.assigned_to,
session_id: l.session_id,
lead_funnel_stage: l.funnel_stage,
}));
} else {
let q = '';
const params = [];
if (state.ticketFilter !== 'all' && state.ticketFilter !== 'active') {
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}`);
tickets = data.tickets || [];
if (state.ticketFilter === 'active') {
tickets = tickets.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status));
}
}
if (window.TicketsWorkspace) {
await TicketsWorkspace.renderPage({ listEl, detailEl, tickets });
} else {
state.tickets = 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);
state.selectedSessionId = null;
renderTicketDetail();
listEl.querySelectorAll('.ticket-row').forEach((r) => r.classList.remove('selected'));
btn.classList.add('selected');
});
});
if (state.selectedTicketId) await renderTicketDetail();
else if (state.selectedSessionId) await renderSessionDetail();
else detailEl.innerHTML = 'Selecione um ticket ou sessão do funil
';
}
} catch (e) {
listEl.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderSessionDetail() {
const detailEl = document.getElementById('ticket-detail');
const sessionId = state.selectedSessionId;
if (!sessionId) return;
detailEl.innerHTML = '';
try {
const meta = await loadAssistMeta(sessionId);
detailEl.innerHTML = `
Sessão onboarding
Domínio ${esc(meta.domain || '—')}
Etapa ${esc(FUNNEL_LABELS[meta.funnel_stage] || meta.funnel_stage || '—')}
Sessão ${esc(meta.session_id)}
${meta.ticket_id ? `Ticket #${meta.ticket_id} ` : ''}
${assistActionsHtml(sessionId, {
can_escalate: meta.can_escalate,
assist_status: meta.ticket_status || meta.assist_status,
assisted_by: meta.assisted_by,
actions: meta.actions,
}, meta._console || {})}
${meta.timeline?.length ? `${phaseTimingCardHtml(meta.timing, meta.timeline)}
Eventos ${timelineHtml(meta.timeline, meta.timing, { compact: true })}` : ''}
`;
bindAssistActions(detailEl, sessionId);
bindLiveTimingClock(detailEl);
} catch (e) {
detailEl.innerHTML = ``;
}
}
async function renderTicketDetail() {
const detailEl = document.getElementById('ticket-detail');
if (!state.selectedTicketId) return;
if (window.TicketsDetailPanel) {
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
return;
}
detailEl.innerHTML = '';
try {
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
const sessionId = t.session_id || state.selectedSessionId;
const assistMeta = sessionId && t.source === 'vm112-onboard'
? await loadAssistMeta(sessionId)
: null;
if (sessionId) state.selectedSessionId = sessionId;
let carbonioBlock = null;
if (t.source === 'vm112-onboard' && window.DeskModules?.isEnabled('carbonio-release')) {
try {
const byTicket = await api(`/v1/carbonio-blocks?ticket_id=${t.id}&status=pending&limit=1`);
carbonioBlock = byTicket.blocks?.[0] || null;
if (!carbonioBlock && sessionId) {
const bySession = await api(`/v1/carbonio-blocks?session_id=${encodeURIComponent(sessionId)}&status=pending&limit=1`);
carbonioBlock = bySession.blocks?.[0] || null;
}
} catch {
carbonioBlock = null;
}
}
const timeline = assistMeta?.timeline?.length
? assistMeta.timeline
: (t.timeline || t.related_events || []);
const timing = assistMeta?.timing || t.timing;
const closeStatuses = ['open', 'escalated', 'assisting', 'resolved'];
detailEl.innerHTML = `
Ticket #${t.id}
${esc(statusLabel(t.status))}
Origem ${sourceBadge(t.source)}
Domínio/Agente ${esc(t.domain || t.agent || '—')}
Email ${esc(t.email || '—')}
Evento ${esc(t.event || '—')}
${t.assigned_to ? `Atribuído ${esc(t.assigned_to)} ` : ''}
${t.assisted_by ? `Assistido por ${esc(t.assisted_by)} ` : ''}
${t.client_paused ? 'Cliente pausado ' : ''}
${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.desk_message ? `Nota ${esc(t.desk_message)} ` : ''}
${t.registration_role ? `Perfil ${esc(roleLabel(t.registration_role))} ` : ''}
${t.ativation_url ? `Ativar conta Abrir link de ativação ` : ''}
Sessão/Alert ID ${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)}
${sessionId && t.source === 'vm112-onboard' ? assistActionsHtml(sessionId, {
can_escalate: assistMeta?.can_escalate,
assist_status: assistMeta?.ticket_status || assistMeta?.assist_status,
assisted_by: assistMeta?.assisted_by,
actions: assistMeta?.actions,
}, assistMeta?._console || {}) : ''}
${carbonioBlock ? carbonioBlockPanelHtml(carbonioBlock) : ''}
${canPatchTickets() ? (closeStatuses.includes(t.status)
? `Fechar ticket `
: `Reabrir ticket `) : ''}
${timeline.length ? `${phaseTimingCardHtml(timing, timeline)}
Eventos ${timelineHtml(timeline, timing, { compact: true })}` : ''}
Payload
${esc(JSON.stringify(t.payload, null, 2))}
`;
if (sessionId && t.source === 'vm112-onboard') {
bindAssistActions(detailEl, sessionId);
}
bindCarbonioResolveForms(detailEl);
bindLiveTimingClock(detailEl);
detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed'));
detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open'));
} catch (e) {
detailEl.innerHTML = ``;
}
}
async function updateTicketStatus(status) {
await api(`/v1/desk/tickets/${state.selectedTicketId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
await renderTickets();
}
async function renderEvents() {
syncEventsToolbar();
if (state.eventsTab === 'purges') {
await renderPurgeHistory();
return;
}
if (state.eventsTab === 'security') {
await renderSecurityEvents();
return;
}
if (state.eventsTab === 'carbonio') {
await renderCarbonioBlocks();
return;
}
const el = document.getElementById('events-content');
el.innerHTML = 'Carregando 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 = `
ID Origem Evento Agente/Domínio Ref Data
${rows || 'Sem eventos '}
`;
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
function syncEventsToolbar() {
const isPurges = state.eventsTab === 'purges';
const isSecurity = state.eventsTab === 'security';
const isCarbonio = state.eventsTab === 'carbonio';
document.querySelectorAll('[data-events-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.eventsTab === state.eventsTab);
});
document.querySelectorAll('.events-webhooks-only').forEach((el) => {
el.hidden = isPurges || isSecurity || isCarbonio;
});
document.querySelectorAll('.events-security-only').forEach((el) => {
el.hidden = !isSecurity;
});
const title = document.getElementById('page-title');
const sub = document.getElementById('page-subtitle');
if (state.view === 'events' && title) {
const titles = {
purges: 'Histórico de purges',
security: 'Eventos de segurança wizard',
carbonio: 'Bloqueios Carbonio',
};
title.textContent = titles[state.eventsTab] || 'Eventos webhook';
if (sub) {
const subs = {
purges: 'Purges VM112 persistidos no Desk — timeline, usuário e serviços removidos',
security: 'CSP, inputs bloqueados e handoff — telemetria Spec 021',
carbonio: 'ACCOUNT_EXISTS — remover conta órfã no Carbonio para o cliente repetir o passo',
};
sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento';
}
}
}
function carbonioBlockStatusBadge(status) {
const map = {
pending: ['open', 'Pendente'],
resolved: ['done', 'Resolvido'],
};
const [cls, label] = map[status] || ['open', status || '—'];
return `${esc(label)} `;
}
function carbonioReleaseGuideHtml() {
return `
Guia — libertar e-mail ACCOUNT_EXISTS
O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).
Confirme o e-mail exacto e a sua senha Desk (não a do Carbonio nem root).
A ação remove apenas a conta Carbonio (zmprov da) — domínio, DNS e portal mantêm-se.
Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.
Dois técnicos a resolver em paralelo: só o primeiro consegue; o outro vê «já resolvido».
`;
}
function carbonioResolveFormHtml(block) {
if (block.status === 'resolved') {
return `Resolvido por ${esc(block.resolved_by)} em ${fmtDate(block.resolved_at)}${block.resolution_note ? ` — ${esc(block.resolution_note)}` : ''}
`;
}
if (!canReadTickets()) return '';
return `
`;
}
function carbonioBlockPanelHtml(block) {
return `
Bloqueio Carbonio — ACCOUNT_EXISTS
${carbonioBlockStatusBadge(block.status)}
E-mail ${esc(block.email)} · domínio ${esc(block.domain)}
${block.ticket_id ? ` · bloqueio #${block.id}` : ''}
${block.error_message ? `
${esc(block.error_message.slice(0, 240))}
` : ''}
${carbonioReleaseGuideHtml()}
${carbonioResolveFormHtml(block)}
`;
}
async function resolveCarbonioBlock(blockId, confirmEmail, password) {
return api(`/v1/carbonio-blocks/${blockId}/resolve`, {
method: 'POST',
body: JSON.stringify({ confirm_email: confirmEmail, password }),
});
}
function bindCarbonioResolveForms(root) {
root.querySelectorAll('.carbonio-resolve-form').forEach((form) => {
if (form.dataset.bound) return;
form.dataset.bound = '1';
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const blockId = form.dataset.carbonioBlock;
const fd = new FormData(form);
const msgEl = form.querySelector('.carbonio-resolve-msg');
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
msgEl.hidden = true;
try {
const res = await resolveCarbonioBlock(blockId, fd.get('confirm_email'), fd.get('password'));
msgEl.textContent = res.message || 'Conta removida do Carbonio.';
msgEl.style.color = 'var(--ok, #2ecc71)';
msgEl.hidden = false;
setTimeout(async () => {
if (state.view === 'events') await renderEvents();
else if (state.selectedTicketId) await renderTicketDetail();
}, 1200);
} catch (e) {
msgEl.textContent = e.message;
msgEl.style.color = 'var(--danger, #e74c3c)';
msgEl.hidden = false;
btn.disabled = false;
}
});
});
}
async function renderCarbonioBlocks() {
syncEventsToolbar();
const el = document.getElementById('events-content');
if (!window.DeskModules?.isEnabled('carbonio-release')) {
el.innerHTML = 'Módulo Bloqueios Carbonio desativado.
';
return;
}
el.innerHTML = 'Carregando bloqueios Carbonio…
';
try {
const [pending, resolved] = await Promise.all([
api('/v1/carbonio-blocks?status=pending&limit=100'),
api('/v1/carbonio-blocks?status=resolved&limit=30'),
]);
const pendingBlocks = pending.blocks || [];
const resolvedBlocks = resolved.blocks || [];
const pendingCards = pendingBlocks.length
? pendingBlocks.map((b) => carbonioBlockPanelHtml(b)).join('')
: 'Nenhum bloqueio pendente — novos casos aparecem aqui via webhook onboarding.failed + ACCOUNT_EXISTS.
';
const resolvedRows = resolvedBlocks.map((b) => `
#${b.id}
${esc(b.email)}
${esc(b.domain)}
${esc(b.resolved_by || '—')}
${fmtDate(b.resolved_at)}
${b.ticket_id ? `#${b.ticket_id}` : '—'}
`).join('');
el.innerHTML = `
${pending.total || pendingBlocks.length} pendente(s) ·
${resolved.total || resolvedBlocks.length} resolvido(s) recentes
${pendingCards}
Histórico resolvido (${resolvedBlocks.length})
ID E-mail Domínio Resolvido por Quando Ticket
${resolvedRows || 'Nenhum '}
`;
bindCarbonioResolveForms(el);
el.querySelectorAll('[data-goto-ticket]').forEach((link) => {
link.addEventListener('click', (ev) => {
ev.preventDefault();
state.selectedTicketId = Number(link.dataset.gotoTicket);
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderSecurityEvents() {
syncEventsToolbar();
const el = document.getElementById('events-content');
if (!window.DeskModules?.isEnabled('wizard-security')) {
el.innerHTML = 'Módulo Segurança Wizard desativado.
';
return;
}
el.innerHTML = 'Carregando eventos de segurança…
';
try {
const [data, summary] = await Promise.all([
api('/v1/security/events?limit=200&window_hours=168'),
api('/v1/security/summary?window_hours=24').catch(() => ({})),
]);
const rows = (data.events || []).map((ev) => `
${wizardSecuritySeverityBadge(ev.severity)}
${esc(wizardSecurityEventLabel(ev.event_type))}
${ev.session_id ? sessionHashHtml(ev.session_id) : '—'}
${esc(ev.domain || '—')}
${esc(ev.client_ip || '—')}
${esc(ev.endpoint || ev.reason || '—')}
${fmtDate(ev.created_at)}
`).join('');
el.innerHTML = `
Últimas 24h: ${summary.csp_violations || 0} CSP ·
${summary.inputs_blocked || 0} bloqueados ·
${summary.handoffs_rejected || 0} handoffs rejeitados
Guia rápido para técnicos
Input bloqueado → anote hash + IP; se repetido, escale.
Handoff rejeitado → cliente deve refazer login; ticket escalado automático.
Clique na linha para abrir a sessão em Tickets.
Nível Evento Sessão Domínio IP Detalhe Quando
${rows || 'Nenhum evento de segurança '}
`;
el.querySelectorAll('[data-wizard-sec-session]').forEach((row) => {
const sid = row.dataset.wizardSecSession;
if (!sid) return;
row.addEventListener('click', () => {
state.selectedSessionId = sid;
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
function purgeStatusBadge(status) {
const map = {
done: ['done', 'Concluído'],
error: ['closed', 'Erro'],
running: ['open', 'Em execução'],
queued: ['pending', 'Na fila'],
};
const [cls, label] = map[status] || ['open', status || '—'];
return `${esc(label)} `;
}
function deskRemovedSummary(desk) {
if (!desk || typeof desk !== 'object') return '—';
const labels = {
webhook_events: 'webhooks',
tickets: 'tickets',
audit_domains: 'audit',
assist_sessions: 'assist',
audit_checks: 'checks',
};
const parts = Object.entries(desk)
.filter(([, n]) => Number(n) > 0)
.map(([k, n]) => `${labels[k] || k}: ${n}`);
return parts.length ? parts.join(', ') : 'nenhum no Desk';
}
function vm112RemovedSummary(vm112) {
if (!vm112 || !vm112.ok) return vm112?.error ? esc(vm112.error) : '—';
const r = vm112.result || {};
const parts = [];
if (Array.isArray(r.carbonio_accounts) && r.carbonio_accounts.length) {
parts.push(`Carbonio (${r.carbonio_accounts.length} contas)`);
} else if (r.carbonio_domain) {
parts.push('Carbonio');
}
if (Array.isArray(r.portal_users_removed) && r.portal_users_removed.length) {
parts.push(`portal (${r.portal_users_removed.length})`);
}
if (r.site_folder_removed) parts.push('site');
if (r.cloudflare) parts.push('Cloudflare');
if (r.traefik_sni || r.traefik_routers) parts.push('Traefik');
return parts.length ? esc(parts.join(', ')) : 'VM112 OK';
}
function renderPurgeTimelineHtml(steps) {
return `${(steps || []).map((step) => {
const status = step.status || 'pending';
return `
${esc(fmtDate(step.at))}
${esc(step.label)}
${step.detail ? `${esc(step.detail)} ` : ''}
`;
}).join('')} `;
}
function closePurgeHistoryModal() {
const modal = document.getElementById('purge-history-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function openPurgeHistoryModal(jobId) {
const modal = document.getElementById('purge-history-modal');
const title = document.getElementById('purge-history-modal-title');
const sub = document.getElementById('purge-history-modal-sub');
const body = document.getElementById('purge-history-modal-body');
if (!modal || !body) return;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
title.textContent = 'Detalhe do purge';
sub.textContent = `Job ${jobId}`;
body.innerHTML = 'Carregando…
';
api(`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}`)
.then((job) => {
title.textContent = job.domain || 'Purge';
sub.innerHTML = `${purgeStatusBadge(job.status)} · ${esc(job.by || '—')} · ${fmtDate(job.created_at)} · job ${esc(job.id)}`;
const desk = job.desk || {};
const vm112 = job.vm112 || {};
const deskRows = Object.entries({
webhook_events: 'Eventos webhook',
tickets: 'Tickets',
audit_domains: 'Domínios audit',
assist_sessions: 'Sessões assist',
audit_checks: 'Checks audit',
}).map(([key, label]) => `
${esc(label)} ${Number(desk[key] || 0)} `).join('');
const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : [];
const timeline = (job.timeline || []).length ? job.timeline : vm112Steps;
body.innerHTML = `
Removido no Desk (VM122)
${deskRows}
Total ${Object.values(desk).reduce((a, b) => a + Number(b || 0), 0)}
Removido na VM112
${vm112RemovedSummary(vm112)}
${job.elapsed_vm112 ? `
Duração VM112: ${job.elapsed_vm112}s
` : ''}
${job.error ? `
${esc(job.error)}
` : ''}
Timeline completa
${timeline.length ? renderPurgeTimelineHtml(timeline) : '
Sem passos registados
'}
`;
})
.catch((e) => {
body.innerHTML = `Erro: ${esc(e.message)}
`;
});
document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
el.onclick = closePurgeHistoryModal;
});
}
async function renderPurgeHistory() {
syncEventsToolbar();
const el = document.getElementById('events-content');
el.innerHTML = 'Carregando histórico de purges…
';
try {
const data = await api('/v1/vm112/purge/jobs?limit=200');
const rows = (data.jobs || []).map((j) => `
${esc(j.id)}
${esc(j.domain)}
${purgeStatusBadge(j.status)}
${esc(j.by || '—')}
${esc(deskRemovedSummary(j.desk))}
${fmtDate(j.created_at)}
${j.elapsed_vm112 ? `${j.elapsed_vm112}s` : '—'}
`).join('');
el.innerHTML = `
Clique numa linha para ver a timeline completa e o que foi removido em cada serviço.
Job Domínio Status Usuário
Desk removido Quando VM112
${rows || 'Nenhum purge registado '}
${data.total > (data.jobs || []).length ? `
A mostrar ${(data.jobs || []).length} de ${data.total} purges.
` : ''}
`;
el.querySelectorAll('[data-purge-job]').forEach((row) => {
const open = () => openPurgeHistoryModal(row.dataset.purgeJob);
row.addEventListener('click', open);
row.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
open();
}
});
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderTenants() {
const el = document.getElementById('tenants-content');
el.innerHTML = 'Carregando…
';
try {
const data = await api('/v1/tenants');
el.innerHTML = `
ID Nome IP Papel Desde
${(data.tenants || []).map((t) => `
${t.id}
${esc(t.name)}
${esc(t.ip)}
${esc(t.role)}
${fmtDate(t.created_at)}
`).join('')}
`;
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
function fmtRelative(iso) {
if (!iso) return 'nunca';
const diff = Date.now() - new Date(iso).getTime();
if (Number.isNaN(diff)) return '—';
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'agora';
if (mins < 60) return `há ${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `há ${hours}h`;
const days = Math.floor(hours / 24);
if (days === 1) return 'ontem';
if (days < 7) return `há ${days} dias`;
return fmtDate(iso);
}
function userInitials(displayName, username) {
const src = (displayName || username || '?').trim();
const parts = src.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
if (src.includes('@')) return src[0].toUpperCase();
return src.slice(0, 2).toUpperCase();
}
function roleBadgeHtml(role) {
const cls = {
super_admin: 'role-super',
ops_lead: 'role-lead',
technician: 'role-tech',
noc: 'role-noc',
sales_admin: 'role-sales-admin',
sales_support: 'role-sales-support',
finance: 'role-finance',
marketing: 'role-marketing',
seo: 'role-seo',
developer: 'role-developer',
devops: 'role-devops',
security_analyst: 'role-security',
content_editor: 'role-content',
agentic_operator: 'role-agentic',
}[role] || 'role-default';
return `${esc(roleLabel(role))} `;
}
function mfaBadgeHtml(user) {
if (user.totp_enabled) {
const backups = Number(user.backup_codes_remaining || 0);
const hint = backups > 0 ? ` · ${backups} backup` : '';
return `2FA${hint} `;
}
return 'sem 2FA ';
}
const ROLE_OPTIONS = [
{ value: 'super_admin', label: 'Super Admin', group: 'Ops' },
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops' },
{ value: 'technician', label: 'Suporte', group: 'Ops' },
{ value: 'noc', label: 'NOC', group: 'Ops' },
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial' },
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial' },
{ value: 'finance', label: 'Financeiro', group: 'Negócio' },
{ value: 'marketing', label: 'Marketing', group: 'Negócio' },
{ value: 'seo', label: 'SEO', group: 'Negócio' },
{ value: 'developer', label: 'Developer', group: 'Plataforma' },
{ value: 'devops', label: 'DevOps', group: 'Plataforma' },
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma' },
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma' },
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma' },
];
const ASSIGNABLE_ROLE_OPTIONS = ROLE_OPTIONS.filter((r) => r.value !== 'super_admin');
function registrationRoleSelectHtml(selected = 'technician') {
const groups = [...new Set(ASSIGNABLE_ROLE_OPTIONS.map((r) => r.group))];
return groups.map((group) => {
const opts = ASSIGNABLE_ROLE_OPTIONS.filter((r) => r.group === group)
.map((r) => `${esc(r.label)} `)
.join('');
return `${opts} `;
}).join('');
}
function roleSelectHtml(username, current, assignableOnly = true) {
const options = assignableOnly && current !== 'super_admin'
? ASSIGNABLE_ROLE_OPTIONS
: ROLE_OPTIONS;
const opts = options.map((r) =>
`${r.label} `
).join('');
return `${opts} `;
}
async function saveUser(username, payload, msgEl) {
if (msgEl) msgEl.textContent = 'Salvando…';
try {
await api(`/v1/auth/users/${encodeURIComponent(username)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
if (msgEl) {
msgEl.textContent = 'Salvo';
msgEl.className = 'admin-msg ok';
}
closeTeamDrawer();
await renderAdmin();
} catch (e) {
if (msgEl) {
msgEl.textContent = e.message;
msgEl.className = 'admin-msg err';
}
throw e;
}
}
function filterAdminUsers(users) {
const { q, role, status, mfa } = state.adminFilter;
const query = (q || '').trim().toLowerCase();
return users.filter((u) => {
if (role !== 'all' && u.role !== role) return false;
if (status === 'active' && !u.active) return false;
if (status === 'inactive' && u.active) return false;
if (mfa === 'on' && !u.totp_enabled) return false;
if (mfa === 'off' && u.totp_enabled) return false;
if (!query) return true;
const hay = [
u.username,
u.email,
u.display_name,
roleLabel(u.role),
].join(' ').toLowerCase();
return hay.includes(query);
});
}
function closeTeamDrawer() {
const drawer = document.getElementById('team-drawer');
if (!drawer) return;
drawer.classList.add('hidden');
drawer.setAttribute('aria-hidden', 'true');
state.adminSelected = null;
}
function bindTeamDrawerClose() {
document.querySelectorAll('[data-close-team-drawer]').forEach((el) => {
el.onclick = closeTeamDrawer;
});
}
function openTeamDrawer(username) {
const user = state.adminUsers.find((u) => u.username === username);
if (!user) return;
state.adminSelected = username;
const drawer = document.getElementById('team-drawer');
const body = document.getElementById('team-drawer-body');
const title = document.getElementById('team-drawer-title');
if (!drawer || !body) return;
const email = user.email || (user.username.includes('@') ? user.username : '—');
const isRoot = user.username === 'root';
title.textContent = user.display_name || user.username;
body.innerHTML = `
${esc(userInitials(user.display_name, user.username))}
${esc(user.display_name || user.username)}
${esc(email)}
${esc(user.username)}
Criado ${fmtDate(user.created_at)}
Último login ${fmtRelative(user.last_login_at)}
Segurança ${mfaBadgeHtml(user)}
`;
drawer.classList.remove('hidden');
drawer.setAttribute('aria-hidden', 'false');
bindTeamDrawerClose();
body.querySelector('#team-drawer-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const msgEl = body.querySelector('#team-drawer-msg');
msgEl.hidden = false;
const payload = {
display_name: body.querySelector('#team-drawer-display')?.value?.trim() || null,
role: body.querySelector('.admin-role')?.value,
active: body.querySelector('#team-drawer-active')?.value === '1',
};
const pwd = body.querySelector('#team-drawer-password')?.value;
if (pwd && pwd.length >= 6) payload.password = pwd;
try {
await saveUser(user.username, payload, msgEl);
} catch {
/* msg shown */
}
});
body.querySelector('#team-reset-2fa')?.addEventListener('click', async () => {
const msgEl = body.querySelector('#team-drawer-msg');
if (!window.confirm(`Resetar 2FA de ${user.username}? O usuário entrará só com senha até reconfigurar.`)) return;
msgEl.hidden = false;
msgEl.textContent = 'Resetando…';
msgEl.className = 'admin-msg';
try {
await api(`/v1/auth/users/${encodeURIComponent(user.username)}/reset-2fa`, { method: 'POST' });
msgEl.textContent = '2FA resetado';
msgEl.className = 'admin-msg ok';
closeTeamDrawer();
await renderAdmin();
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'admin-msg err';
}
});
}
async function renderAdmin() {
const el = document.getElementById('admin-content');
if (!canManageUsers()) {
el.innerHTML = 'Sem permissão
';
return;
}
el.innerHTML = 'Carregando equipe…
';
try {
const [usersData, regData] = await Promise.all([
api('/v1/auth/users'),
api('/v1/auth/registration-requests').catch(() => ({ pending_count: 0 })),
]);
state.adminUsers = usersData.users || [];
const users = state.adminUsers;
const filtered = filterAdminUsers(users);
const activeCount = users.filter((u) => u.active).length;
const mfaCount = users.filter((u) => u.totp_enabled).length;
const inactiveCount = users.length - activeCount;
const pending = regData.pending_count || 0;
const { q, role, status, mfa } = state.adminFilter;
const rows = filtered.map((u) => `
${esc(userInitials(u.display_name, u.username))}
${esc(u.display_name || u.username)}
${esc(u.email || u.username)}
${roleBadgeHtml(u.role)}
${mfaBadgeHtml(u)}
${fmtRelative(u.last_login_at)}
${u.active ? 'ativo ' : 'inativo '}
Editar
`).join('');
el.innerHTML = `
${users.length} membros
${activeCount} ativos
${mfaCount} com 2FA
${inactiveCount} inativos
Buscar
Perfil
Todos
${ROLE_OPTIONS.map((r) => `${r.label} `).join('')}
Estado
Todos
Ativos
Inativos
2FA
Todos
Com 2FA
Sem 2FA
Membro
Perfil
Segurança
Último login
Estado
${rows || 'Nenhum membro encontrado '}
`;
const applyFilters = () => {
state.adminFilter = {
q: document.getElementById('team-filter-q')?.value || '',
role: document.getElementById('team-filter-role')?.value || 'all',
status: document.getElementById('team-filter-status')?.value || 'all',
mfa: document.getElementById('team-filter-mfa')?.value || 'all',
};
renderAdmin();
};
document.getElementById('team-filter-q')?.addEventListener('input', () => {
clearTimeout(state._teamSearchTimer);
state._teamSearchTimer = setTimeout(applyFilters, 200);
});
['team-filter-role', 'team-filter-status', 'team-filter-mfa'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', applyFilters);
});
document.getElementById('team-goto-messages')?.addEventListener('click', () => setView('messages'));
el.querySelectorAll('.team-edit-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
openTeamDrawer(btn.dataset.user);
});
});
el.querySelectorAll('.team-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
openTeamDrawer(row.dataset.user);
});
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamDrawer(row.dataset.user);
}
});
});
if (state.adminSelected) {
openTeamDrawer(state.adminSelected);
}
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderModules() {
const el = document.getElementById('modules-content');
if (!el) return;
const user = getUser();
if (user?.role !== 'super_admin') {
el.innerHTML = 'Apenas Super Admin pode gerenciar módulos.
';
return;
}
el.innerHTML = 'Carregando módulos…
';
try {
await DeskModules.load();
const mods = DeskModules.list;
el.innerHTML = `
Módulos do Desk
Desativar um módulo remove-o do menu e desliga enriquecimentos na API — o núcleo continua estável.
${mods.map((m) => `
${esc(m.label)}
${esc(m.description)}
${esc(m.id)}
${m.locked ? 'núcleo ' : ''}
`).join('')}
`;
el.querySelectorAll('[data-module-toggle]').forEach((input) => {
input.addEventListener('change', async () => {
const id = input.dataset.moduleToggle;
input.disabled = true;
try {
await api(`/v1/modules/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: input.checked }),
});
await DeskModules.load();
applyRoleNav();
DeskModules.applyVisibility();
if (!DeskModules.isViewEnabled(state.view)) setView('dashboard');
else refresh();
} catch (e) {
input.checked = !input.checked;
alert(e.message || 'Falha ao actualizar módulo');
} finally {
input.disabled = false;
}
});
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
const REG_ROLE_LABELS = ROLE_LABELS;
async function renderMessages() {
const el = document.getElementById('messages-content');
if (!canManageUsers()) {
el.innerHTML = 'Sem permissão
';
return;
}
el.innerHTML = 'Carregando pedidos…
';
try {
const data = await api('/v1/auth/registration-requests');
const items = data.requests || [];
const pending = items.filter((r) => r.status === 'pending');
const history = items.filter((r) => r.status !== 'pending');
const pendingCards = pending.map((r) => `
${esc(r.email)}
${esc(r.display_name || '—')} · ${fmtDate(r.created_at)}
pendente
Perfil a atribuir
${registrationRoleSelectHtml('technician')}
Aprovar
Rejeitar
`).join('');
const historyRows = history.map((r) => `
${esc(r.email)}
${esc(statusLabel(r.status))}
${esc(r.role ? roleLabel(r.role) : '—')}
${fmtDate(r.updated_at || r.created_at)}
`).join('');
el.innerHTML = `
Pedidos pendentes (${pending.length})
${pendingCards || '
Nenhum pedido pendente
'}
${history.length ? `
Histórico
E-mail Estado Perfil Atualizado
${historyRows}
` : ''}
`;
el.querySelectorAll('[data-req]').forEach((card) => {
const id = card.dataset.req;
const msgEl = card.querySelector('.admin-msg');
card.querySelector('.req-approve')?.addEventListener('click', async () => {
msgEl.textContent = '…';
try {
const role = card.querySelector('.req-role')?.value;
await api(`/v1/auth/registration-requests/${id}/approve`, { method: 'POST', body: JSON.stringify({ role }) });
msgEl.textContent = 'Aprovado — email enviado';
msgEl.className = 'admin-msg ok';
await renderMessages();
} catch (e) {
msgEl.textContent = e.message;
msgEl.className = 'admin-msg err';
}
});
card.querySelector('.req-reject')?.addEventListener('click', async () => {
const reason = window.prompt('Motivo da rejeição (opcional):') || '';
msgEl.textContent = '…';
try {
await api(`/v1/auth/registration-requests/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason || null }),
});
msgEl.textContent = 'Rejeitado';
msgEl.className = 'admin-msg ok';
await renderMessages();
} catch (e) {
msgEl.textContent = e.message;
msgEl.className = 'admin-msg err';
}
});
});
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderAccount(force = false) {
const el = document.getElementById('account-content');
if (state.accountLoaded && !force) {
return;
}
const saved = force ? null : readAccountPwdForm();
el.innerHTML = 'Carregando…
';
try {
const me = await api('/v1/auth/me');
const totpOn = Boolean(me.totp_enabled || me.mfa_enabled);
el.innerHTML = `
Minha conta
E-mail / login ${esc(me.email || me.username)}
Perfil ${esc(roleLabel(me.role))}
Nome ${esc(me.display_name || '—')}
Último login ${fmtDate(me.last_login_at)}
2FA (app) ${totpOn ? 'ativo ' : 'não configurado '}
${totpOn ? `Códigos backup ${Number(me.backup_codes_remaining || 0)} restante(s) ` : ''}
Alterar senha
${totpOn
? 'Por segurança, confirme a senha atual e o código do autenticador (sessão aberta). Se perdeu o app, use recuperação no login ou um código de backup.'
: 'Informe a senha atual e escolha uma nova (mín. 8 caracteres).'}
`;
restoreAccountPwdForm(saved);
bindAccountPwdForm(totpOn);
state.accountLoaded = true;
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
state.accountLoaded = false;
}
}
function readAccountPwdForm() {
const form = document.getElementById('account-pwd-form');
if (!form) return null;
const get = (id) => document.getElementById(id)?.value ?? '';
const hasValue = ['acct-pwd-current', 'acct-pwd-new', 'acct-pwd-new2', 'acct-pwd-totp']
.some((id) => get(id));
if (!hasValue) return null;
return {
current: get('acct-pwd-current'),
neu: get('acct-pwd-new'),
neu2: get('acct-pwd-new2'),
totp: get('acct-pwd-totp'),
};
}
function restoreAccountPwdForm(saved) {
if (!saved) return;
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val) el.value = val;
};
set('acct-pwd-current', saved.current);
set('acct-pwd-new', saved.neu);
set('acct-pwd-new2', saved.neu2);
set('acct-pwd-totp', saved.totp);
}
function bindAccountPwdForm(totpOn) {
const form = document.getElementById('account-pwd-form');
const errEl = document.getElementById('account-pwd-error');
const okEl = document.getElementById('account-pwd-ok');
if (!form || form.dataset.bound === '1') return;
form.dataset.bound = '1';
form.addEventListener('submit', async (e) => {
e.preventDefault();
errEl.hidden = true;
okEl.hidden = true;
const cur = document.getElementById('acct-pwd-current')?.value ?? '';
const neu = document.getElementById('acct-pwd-new')?.value ?? '';
const neu2 = document.getElementById('acct-pwd-new2')?.value ?? '';
if (neu !== neu2) {
errEl.textContent = 'As senhas não coincidem';
errEl.hidden = false;
return;
}
const payload = { current_password: cur, new_password: neu };
if (totpOn) {
payload.totp_code = (document.getElementById('acct-pwd-totp')?.value ?? '').trim();
}
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
await api('/v1/auth/change-password', {
method: 'POST',
body: JSON.stringify(payload),
});
okEl.textContent = 'Senha alterada com sucesso.';
okEl.hidden = false;
form.reset();
} catch (ex) {
errEl.textContent = ex.message;
errEl.hidden = false;
} finally {
btn.disabled = false;
}
});
}
const SOC_EVENT_LABELS = {
'session.started': 'Sessão iniciada',
'domain.validated': 'Domínio validado',
'dns.applied': 'DNS aplicado',
'onboarding.started': 'Onboarding iniciado',
'account.created': 'Conta criada',
'infra.synced': 'Infra sincronizada',
'onboarding.completed': 'Onboarding concluído',
'onboarding.failed': 'Onboarding falhou',
'integration.test': 'Teste integração',
...SECURITY_EVENT_LABELS,
};
function socWindowHours() {
return { '24h': 24, '48h': 48, '7d': 168 }[state.socWindow] || 24;
}
function socEventSeverity(eventType) {
if (eventType?.startsWith('security.')) {
if (eventType.includes('blocked') || eventType.includes('rejected') || eventType.includes('anomaly')) return 'high';
if (eventType.includes('csp') || eventType.includes('rate')) return 'warn';
return 'info';
}
if (eventType === 'onboarding.failed') return 'high';
if (eventType === 'onboarding.started' || eventType === 'session.started') return 'warn';
if (eventType === 'onboarding.completed' || eventType === 'account.created') return 'ok';
return 'info';
}
function socAreaChartSvg(values, width = 320, height = 88) {
const data = values?.length ? values : [0, 0, 0, 0, 0, 0];
const max = Math.max(...data, 1);
const padX = 4;
const padY = 6;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const pts = data.map((v, i) => {
const x = padX + (i / Math.max(data.length - 1, 1)) * innerW;
const y = padY + innerH - (v / max) * innerH;
return [x, y];
});
const line = pts.map((p) => p.join(',')).join(' ');
const area = `${padX},${padY + innerH} ${line} ${padX + innerW},${padY + innerH}`;
return `
`;
}
function socPipelineHtml(stages, total) {
const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed'];
const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0));
return order.map((key) => {
const n = stages[key] || 0;
const pct = max ? Math.round((n / max) * 100) : 0;
return `
${esc(FUNNEL_LABELS[key] || key)}
${n}
`;
}).join('');
}
function socStatusKpiClass(status) {
if (status === 'ok') return 'ok';
if (status === 'critical') return 'critical';
return 'warn';
}
function socSessionRingStage(stage) {
if (stage === 'completed' || stage === 'failed') return stage;
return 'active';
}
function closeSocTestModal() {
const modal = document.getElementById('soc-test-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function bindSocTestModal() {
document.querySelectorAll('[data-close-soc-test-modal]').forEach((el) => {
el.addEventListener('click', closeSocTestModal);
});
}
function showSocWebhookTestResult(result) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const ok = result.accepted && result.status === 'accepted';
const dup = result.duplicate === true;
title.textContent = ok ? (dup ? 'Webhook OK (duplicado)' : 'Webhook OK') : 'Webhook com problema';
sub.textContent = fmtDate(result.tested_at || new Date().toISOString());
body.innerHTML = `
${esc(result.message || (ok ? 'Integração VM112 → VM122 respondendo corretamente.' : 'Falha ao processar webhook.'))}
Status ${esc(result.status || '—')}
Evento ${esc(result.event || '—')}
Origem ${esc(result.source || '—')}
Domínio ${esc(result.domain || '—')}
Sessão ${esc(result.session_id || '—')}
Duplicado ${dup ? 'sim' : 'não'}
Ticket criado ${result.ticket_created ? `sim (#${result.ticket_id})` : 'não'}
Disparado por ${esc(result.triggered_by || '—')}
Este teste simula um evento integration.test no endpoint
POST /api/v1/webhooks/onboard — o mesmo caminho usado pela VM112.
Não cria ticket de onboarding; apenas valida que a API grava o evento e o SOC consegue lê-lo.
Ver em Eventos
Fechar
`;
body.querySelector('[data-soc-goto-events]')?.addEventListener('click', () => {
closeSocTestModal();
state.eventSourceFilter = 'vm112-onboard';
document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => {
b.classList.toggle('active', b.dataset.source === 'vm112-onboard');
});
setView('events');
});
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function showSocWebhookTestError(err) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const msg = err?.message || String(err);
const is403 = /403|insufficient permissions|permiss/i.test(msg);
title.textContent = 'Falha no teste';
sub.textContent = 'Não foi possível completar o teste';
body.innerHTML = `
${esc(msg)}
${is403 ? `
Apenas perfis super_admin e admin podem executar o teste de webhook.
` : ''}
Verifique se a API está online, se a sessão não expirou e se o usuário tem permissão.
Fechar
`;
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function showOpenPanelTestResult(result) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const ok = result.ok === true;
title.textContent = ok ? 'OpenPanel API — confirmado' : 'OpenPanel API — falha';
sub.textContent = `Spec 028 · ${result.steps_passed || 0}/${result.steps_total || 0} passos · ${result.duration_sec || '—'}s`;
const steps = (result.steps || []).map((s) => `
${esc(s.name)} — ${esc(s.detail || (s.ok ? 'OK' : 'FAIL'))}
`).join('');
body.innerHTML = `
${esc(result.message || (ok ? 'Multidomínio OK' : 'Falha'))}
Suite openpanel-multidomain-api-confirm — provisiona 2 contas temporárias
(2 domínios na plataforma), valida listagem e remove. Pode executar quantas vezes quiser.
Script CLI: scripts/test-openpanel-multidomain-api.sh
Fechar
`;
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function showOpenPanelTestError(err) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const msg = err?.message || String(err);
const is403 = /403|permiss/i.test(msg);
title.textContent = 'OpenPanel API — erro';
sub.textContent = 'Teste não concluído';
body.innerHTML = `
${esc(msg)}
${is403 ? '
Perfis: super_admin , devops , developer .
' : ''}
Fechar
`;
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
async function runOpenPanelApiTest() {
const btn = document.getElementById('btn-test-openpanel-api');
const prevLabel = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = 'Testando…';
}
try {
const r = await api('/v1/vm123/openpanel/test-confirm', { method: 'POST' });
showOpenPanelTestResult(r);
} catch (ex) {
showOpenPanelTestError(ex);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel || 'Testar multidomínio';
}
}
}
async function runWebhookIntegrationTest(refreshView) {
const btn = document.getElementById('soc-btn-test') || document.getElementById('btn-test-webhook');
const prevLabel = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = 'Testando…';
}
try {
const r = await api('/v1/integrations/onboard/test', { method: 'POST' });
showSocWebhookTestResult(r);
if (refreshView === 'infra2') await renderInfra2();
else if (refreshView === 'infra') await renderInfra();
} catch (ex) {
showSocWebhookTestError(ex);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel || 'Testar webhook';
}
}
}
async function renderInfra2() {
const el = document.getElementById('infra2-content');
if (!el) return;
if (window.DeskModules?.loaded && !DeskModules.isEnabled('infra2-soc')) {
el.innerHTML = 'Módulo Infra 2 SOC desativado. Active em Módulos .
';
return;
}
el.innerHTML = 'Carregando SOC…
';
const windowHours = socWindowHours();
try {
const [health, vm112, wazuh, funnel, eventsRes, secRes, summary] = await Promise.all([
api('/v1/integrations/health').catch(() => ({ status: 'unknown', alerts: [], vm112_onboard: {} })),
api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })),
api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })),
api(`/v1/onboard/funnel?window_hours=${windowHours}`).catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })),
api('/v1/webhooks/events?source=vm112-onboard').catch(() => ({ events: [] })),
window.DeskModules?.isEnabled('wizard-security')
? api('/v1/security/summary?window_hours=24').catch(() => ({}))
: Promise.resolve({}),
api('/v1/desk/summary').catch(() => ({ tickets_open: 0, recent_tickets: [] })),
]);
const onboard = health.vm112_onboard || {};
const lastWh = onboard.last_webhook || {};
const gapMin = onboard.gap_minutes != null ? Math.round(onboard.gap_minutes) : null;
const alerts = health.alerts || [];
const vmOk = vm112.vm112?.status === 'ok';
const wazuhOk = wazuh.api_online === true || wazuh.http_status === 401 || wazuh.http_status === 200;
const intStatus = health.status || 'unknown';
const liveCls = intStatus === 'ok' ? '' : intStatus === 'critical' ? 'critical' : 'warn';
const secSummary = secRes || {};
const secRecent = (secSummary.recent || []).map((ev) => ({
id: `sec-${ev.id}`,
event_type: ev.event_type,
created_at: ev.created_at,
payload: { domain: ev.domain, session_id: ev.session_id },
domain: ev.domain,
_security: true,
}));
const allEvents = (eventsRes.events || []).map((ev) => ({
...ev,
payload: typeof ev.payload === 'object' ? ev.payload : {},
}));
const windowEvents = allEvents.filter((ev) => isInWindow(ev.created_at, windowHours));
const chartBuckets = bucketEvents(windowEvents, windowHours, 24);
const eventsPerHour = windowHours ? Math.round((windowEvents.length / windowHours) * 10) / 10 : 0;
const feedEvents = [...allEvents, ...secRecent]
.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
.slice(0, 18);
const sessions = (funnel.active_sessions || [])
.filter((s) => s.domain || s.session_id)
.sort((a, b) => new Date(b.last_event_at || 0) - new Date(a.last_event_at || 0));
const sessionTimings = {};
if (window.DeskModules?.isEnabled('funnel-timing')) {
const tops = sessions.slice(0, 8).filter((s) => s.session_id);
const timingResults = await Promise.all(
tops.map((s) => api(`/v1/onboard/sessions/${encodeURIComponent(s.session_id)}/timeline`).catch(() => null))
);
tops.forEach((s, i) => {
if (timingResults[i]?.timing) sessionTimings[s.session_id] = timingResults[i].timing;
});
}
const newestId = feedEvents[0]?.id;
const flashNew = state.socLastEventId && newestId && newestId > state.socLastEventId;
state.socLastEventId = newestId || state.socLastEventId;
const onboardTicketsOpen = (summary.recent_tickets || []).filter(
(t) => (t.source === 'vm112-onboard' || String(t.subject || '').includes('[onboarding]')) && t.status !== 'closed'
).length;
const nowLabel = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
el.innerHTML = `
Integração
${esc(intStatus)}
VM112 onboard
Gap webhook
${gapMin != null ? `${gapMin}m` : '—'}
limite ${health.webhook_gap_alert_minutes || 15} min
Eventos
${windowEvents.length}
~${eventsPerHour}/h · ${state.socWindow}
Sessões
${funnel.sessions_total || sessions.length}
funil ativo
Tickets onboard
${onboardTicketsOpen}
abertos agora
Alertas
${alerts.length}
${lastWh.event ? esc(lastWh.event) : 'sem eventos'}
${window.DeskModules?.isEnabled('wizard-security') ? `
Segurança wizard
${secSummary.total || 0}
CSP ${secSummary.csp_violations || 0} · bloq ${secSummary.inputs_blocked || 0}
` : ''}
VM112 Wizard
webhook POST /onboard →
VM122 Desk
←
VM104 Wazuh
alertas level ≥10
Feed ao vivo — VM112 + Segurança
${feedEvents.length} recentes
${feedEvents.length ? `
Evento Domínio Hora
${feedEvents.map((ev, i) => {
const p = ev.payload || {};
const sev = socEventSeverity(ev.event_type);
const isNew = flashNew && i === 0;
return `
${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)}
${esc(p.domain || ev.domain || '—')}
${relativeTimeAgo(ev.created_at)}
`;
}).join('')}
` : '
Nenhum evento VM112 registrado
'}
Volume & funil
${state.socWindow}
Eventos VM112
máx ${Math.max(...chartBuckets, 0)}
${socAreaChartSvg(chartBuckets)}
${socPipelineHtml(funnel.stages || {}, funnel.sessions_total || 0)}
Sessões VM112
${sessions.length} ativas
${sessions.length ? sessions.slice(0, 10).map((s) => {
const stage = s.current_stage || 'started';
const ringCls = socSessionRingStage(stage);
const initials = (s.domain || '??').slice(0, 2).toUpperCase();
const tmeta = sessionTimings[s.session_id];
const timingBadge = tmeta
? `
Σ ${esc(tmeta.total_duration_label)} `
: '';
const idleHint = tmeta && !tmeta.is_completed
? ` · parado ${esc(tmeta.idle_since_label)}`
: '';
return `
${esc(initials)}
${esc(s.domain || 'sem domínio')}
${esc(FUNNEL_LABELS[stage] || stage)} · onboarding · ${relativeTimeAgo(s.last_event_at)}${idleHint}
${s.session_id ? `${sessionHashHtml(s.session_id)} ` : ''}
${timingBadge}
${s.ticket_id ? `#${s.ticket_id} ` : '— '}
`;
}).join('') : '
Sem sessões no período
'}
Alertas SOC
${alerts.length ? alerts.map((a) => `
${esc(a.message)}
`).join('') : `
Integração saudável — sem alertas ativos
`}
${lastWh.domain ? `
Último: ${esc(lastWh.event)} · ${esc(lastWh.domain)} · ${relativeTimeAgo(lastWh.created_at)}
` : ''}
Health dos nós
VM112 Portal
HTTP ${vm112.http_status ?? '—'}
Service ${esc(vm112.vm112?.service || vm112.error || '—')}
API ${onboard.vm112_api?.reachable ? 'OK' : 'offline'}
VM122 Desk
Integração ${esc(intStatus)}
Gap ${gapMin != null ? `${gapMin} min` : '—'}
Webhook ${esc(lastWh.event || '—')}
VM104 Wazuh
API ${wazuh.http_status ?? '—'}
Regra level ≥ 10
Status ${wazuhOk ? 'online' : 'check'}
`;
document.getElementById('soc-window-select')?.addEventListener('change', (e) => {
state.socWindow = e.target.value;
renderInfra2();
});
document.getElementById('soc-btn-refresh')?.addEventListener('click', () => renderInfra2());
document.getElementById('soc-btn-test')?.addEventListener('click', () => runWebhookIntegrationTest('infra2'));
el.querySelectorAll('[data-soc-session]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedSessionId = btn.dataset.socSession;
const tid = btn.dataset.socTicket;
state.selectedTicketId = tid ? Number(tid) : null;
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `Erro SOC: ${esc(e.message)}
`;
}
}
function infraKvHtml(items) {
return `${items.map(([label, value]) =>
`
${esc(label)} ${value} `
).join('')} `;
}
const PROC_CARD_ICONS = {
soc: '📡',
openpanel: '🎛️',
purge: '🔐',
vm112: '🌐',
wazuh: '🛡️',
integrations: '🔗',
};
function procCardHtml(opts) {
const {
id,
icon,
accent = 'teal',
title,
spec,
desc,
statusLabel,
statusCls = 'review',
actions = [],
} = opts;
const acts = actions.map((a) =>
`${esc(a.label)} `
).join('');
return `
${esc(statusLabel)}
${esc(title)}
${desc}
`;
}
function closeInfraProcessModal() {
const modal = document.getElementById('infra-process-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function openInfraProcessModal(title, sub, bodyHtml) {
const modal = document.getElementById('infra-process-modal');
const titleEl = document.getElementById('infra-process-modal-title');
const subEl = document.getElementById('infra-process-modal-sub');
const body = document.getElementById('infra-process-modal-body');
if (!modal || !body) return;
if (titleEl) titleEl.textContent = title;
if (subEl) subEl.textContent = sub || '';
body.innerHTML = bodyHtml;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function bindInfraProcessModal() {
document.querySelectorAll('[data-close-infra-process-modal]').forEach((el) => {
el.addEventListener('click', closeInfraProcessModal);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeInfraProcessModal();
});
}
function findStackService(id) {
const stack = state.infraSnapshot?.stack;
if (!stack) return null;
for (const vm of stack.vms || []) {
for (const svc of vm.services || []) {
if (svc.id === id) return { vm, svc };
}
}
return null;
}
function stackServiceActions(svcId) {
if (svcId === 'vm122-purge-auth') {
return [{ label: 'Gerir códigos', action: 'purge-manage', primary: true }];
}
const actions = [{ label: 'Detalhes', action: 'detail', primary: true }];
if (svcId === 'vm122-webhook-soc') actions.push({ label: 'Testar', action: 'test-webhook', primary: false });
if (svcId === 'vm123-openpanel-bridge') actions.push({ label: 'Testar', action: 'test-openpanel', primary: false });
return actions;
}
function stackServiceStatusCls(svc) {
if (svc.ok) return 'ok';
if (svc.status === 'check') return 'review';
return 'escalated';
}
function bindStackCardActions(root) {
root?.querySelectorAll('[data-stack-action]').forEach((btn) => {
btn.addEventListener('click', () => {
const action = btn.dataset.stackAction;
const id = btn.dataset.stackId;
if (action === 'detail') openStackServiceDetail(id);
else if (action === 'test-webhook') runWebhookIntegrationTest('infra');
else if (action === 'test-openpanel') runOpenPanelApiTest();
else if (action === 'purge-manage') openInfraProcessDetail('purge');
});
});
}
function openStackServiceDetail(svcId) {
const hit = findStackService(svcId);
if (!hit) {
if (svcId === 'integrations-json') openInfraProcessDetail('integrations');
return;
}
const { vm, svc } = hit;
const specLabel = svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : svc.kind || 'stack';
openInfraProcessModal(
svc.title,
`${vm.vm_label} · ${specLabel}`,
`${infraKvHtml([
['VM', `${vm.vm} · ${esc(vm.ip)}`],
['Tipo', esc(svc.kind || '—')],
['URL', `${esc(svc.url || '—')}`],
['Status', esc(svc.status || '—')],
['HTTP', svc.http_status != null ? String(svc.http_status) : '—'],
['Detalhe', esc(svc.detail || '—')],
])}
${svc.url && svc.url.startsWith('http') ? `
Abrir URL ` : ''}
`
);
}
function openInfraProcessDetail(procId) {
const snap = state.infraSnapshot || {};
const health = snap.health || {};
const integrations = snap.integrations;
const onboard = health.vm112_onboard || {};
const last = onboard.last_webhook;
const gap = onboard.gap_minutes != null ? `${Math.round(onboard.gap_minutes)} min` : '—';
const vmOk = onboard.vm112_api?.reachable;
const alerts = (health.alerts || []).map((a) =>
`${esc(a.message)} `
).join('') || 'Nenhum alerta activo ';
if (procId === 'soc' || procId === 'vm122-webhook-soc') {
openInfraProcessModal(
'SOC — Integração VM112',
'Webhook onboard · alertas de gap',
`${infraKvHtml([
['Último evento', last ? esc(last.event) : '—'],
['Domínio', last?.domain ? esc(last.domain) : '—'],
['Há quanto tempo', gap],
['VM112 API', vmOk ? 'OK' : esc(onboard.vm112_api?.error || 'offline')],
['Status integração', esc(health.status || '—')],
])}
Testar webhook
Atualizar
Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.
`
);
document.getElementById('btn-test-webhook-modal')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
document.getElementById('btn-refresh-health-modal')?.addEventListener('click', () => {
closeInfraProcessModal();
renderInfra();
});
return;
}
if (procId === 'openpanel' || procId === 'vm123-openpanel-bridge') {
const vm123Health = snap.vm123Health;
const op = vm123Health?.openpanel || findStackService('vm123-openpanel-bridge')?.svc || {};
const opOk = Boolean(op.ok);
const bridgeOk = Boolean(op.bridge);
openInfraProcessModal(
'OpenPanel API — Re-engenharia Ligbox',
'Spec 028 · VM123 bridge :18087',
`Multidomínio · conta temporária com cleanup automático.
${infraKvHtml([
['OpenPanel', opOk ? 'OK' : esc(op.error || op.detail || 'offline')],
['Bridge API', bridgeOk ? 'OK' : 'offline'],
['Bridge URL', esc(op.bridge_url || op.url || '—')],
])}
Suite openpanel-multidomain-api-confirm
`
);
document.getElementById('btn-test-openpanel-modal')?.addEventListener('click', () => runOpenPanelApiTest());
return;
}
if (procId === 'purge' || procId === 'vm122-purge-auth') {
openInfraProcessModal(
'Códigos purge — autorização extra',
'Spec 032 · domínios protegidos',
''
);
renderPurgeAuthPanel(document.getElementById('purge-auth-modal-panel'));
return;
}
if (procId === 'integrations') {
openInfraProcessModal(
'Integrações activas',
'Snapshot JSON · Desk API',
`${esc(JSON.stringify(integrations || {}, null, 2))} `
);
}
}
async function renderInfra() {
const el = document.getElementById('infra-content');
el.innerHTML = 'Verificando stack…
';
try {
const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([
api('/v1/infra/stack/status'),
api('/v1/integrations').catch(() => null),
api('/v1/integrations/health').catch(() => ({})),
api('/v1/vm123/health').catch(() => null),
api('/v1/infra/purge-auth-domains').catch(() => ({ domains: [], can_generate: false })),
]);
state.infraSnapshot = { stack, integrations, health, vm123Health, purgeMeta };
const summary = stack.summary || {};
const okCls = summary.ok === summary.total ? 'ok' : summary.ok > 0 ? 'assisting' : 'escalated';
let sections = (stack.vms || []).map((vm) => {
const cards = (vm.services || []).map((svc) =>
procCardHtml({
id: svc.id,
icon: svc.icon || '⚙️',
accent: svc.accent || 'teal',
title: svc.title,
spec: svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : String(svc.kind || 'stack').toUpperCase(),
desc: esc(svc.detail || svc.url || ''),
statusLabel: svc.status || (svc.ok ? 'online' : 'down'),
statusCls: stackServiceStatusCls(svc),
actions: stackServiceActions(svc.id),
})
).join('');
return `
${esc(vm.vm_label)} ${esc(vm.ip)}
${cards}
`;
}).join('');
const integrationsCard = integrations
? procCardHtml({
id: 'integrations-json',
icon: '🔗',
accent: 'violet',
title: 'Integrações Desk',
spec: 'JSON',
desc: 'Registry onboard + Wazuh · snapshot API',
statusLabel: 'activas',
statusCls: 'open',
actions: [{ label: 'Ver JSON', action: 'detail', primary: true }],
})
: '';
el.innerHTML = `
${summary.ok ?? 0}/${summary.total ?? 0} online
Stack Ligbox · VMs 112 · 114 · 122 · 123 · 130 · Infra as Code · ${fmtDate(stack.generated_at)}
Atualizar stack
${sections}
${integrationsCard ? `
Integrações · Desk API ${integrationsCard}
` : ''}
`;
document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra());
bindStackCardActions(el);
} catch (e) {
el.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderPurgeAuthPanel(panel) {
if (!panel) return;
try {
const meta = await api('/v1/infra/purge-auth-domains');
const domainChips = (meta.domains || []).map((d) =>
`${esc(d)} `
).join('') || 'Nenhum ';
const canGen = meta.can_generate && typeof canManageUsers === 'function' && canManageUsers();
let codesHtml = '';
if (canGen) {
const data = await api('/v1/infra/purge-auth-codes?limit=20');
const rows = (data.codes || []).map((c) => `
${esc(c.domain)}
${esc(c.note || '—')}
${fmtDate(c.expires_at)}
${esc(c.created_by || '—')}
`).join('');
codesHtml = `
Gere código com senha Root — use na conferência antes do purge em Serviços.
${domainChips}
Códigos activos
Domínio Nota Expira Por
${rows || 'Nenhum código activo '}
`;
} else {
codesHtml = `
${domainChips}
Apenas super_admin (root) gera códigos. Peça o código ao root antes do purge em Serviços.
`;
}
panel.innerHTML = codesHtml;
const form = panel.querySelector('#purge-auth-generate-form');
if (form) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const msg = panel.querySelector('#purge-auth-gen-msg');
const out = panel.querySelector('#purge-auth-generated');
const domain = panel.querySelector('#purge-auth-domain')?.value?.trim() || '';
const note = panel.querySelector('#purge-auth-note')?.value?.trim() || '';
const ttl = parseInt(panel.querySelector('#purge-auth-ttl')?.value || '24', 10);
const rootPwd = panel.querySelector('#purge-auth-root-pwd')?.value || '';
if (!domain || !rootPwd) {
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
return;
}
if (msg) msg.textContent = 'A gerar…';
try {
const res = await api('/v1/infra/purge-auth-codes', {
method: 'POST',
body: JSON.stringify({
domain,
root_password: rootPwd,
note,
ttl_hours: ttl,
}),
});
if (msg) msg.textContent = 'Código gerado — copie agora (não será mostrado de novo).';
if (out) {
out.classList.remove('hidden');
out.innerHTML = `
Código: ${esc(res.code)}
Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}
`;
}
panel.querySelector('#purge-auth-root-pwd').value = '';
} catch (e) {
if (msg) msg.textContent = e.message || 'Falha ao gerar código';
}
});
}
} catch (e) {
panel.innerHTML = `Erro: ${esc(e.message)}
`;
}
}
async function renderPurgeAuthInfraPanel() {
await renderPurgeAuthPanel(document.getElementById('purge-auth-infra-panel'));
}
async function refresh(options = {}) {
const { poll = false } = options;
await loadHealth();
if (poll && state.view === 'account') {
return;
}
if (state.view === 'dashboard') await renderDashboard();
if (state.view === 'email-migration' && window.DeskEmailMigration?.renderPage) await window.DeskEmailMigration.renderPage();
if (state.view === 'overview') await renderOverview();
if (state.view === 'overview-home') await renderOverviewHome({ poll });
if (state.view === 'leads') await renderLeads();
if (state.view === 'tickets') {
if (poll && window.TicketsWorkspace?._pageReady) await TicketsWorkspace.softRefresh();
else await renderTickets({ poll: false });
}
if (state.view === 'events') await renderEvents();
if (state.view === 'tenants') await renderTenants();
if (state.view === 'infra') await renderInfra();
if (state.view === 'infra2') await renderInfra2();
if (state.view === 'messages') await renderMessages();
if (state.view === 'admin') await renderAdmin();
if (state.view === 'modules') await renderModules();
if (state.view === 'account') await renderAccount();
}
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.querySelectorAll('[data-events-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.eventsTab = btn.dataset.eventsTab || 'webhooks';
document.querySelectorAll('[data-events-tab]').forEach((b) => b.classList.toggle('active', b === btn));
renderEvents();
});
});
document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
el.addEventListener('click', closePurgeHistoryModal);
});
document.getElementById('btn-refresh')?.addEventListener('click', () => {
if (state.view === 'account') {
state.accountLoaded = false;
}
refresh();
});
(async function boot() {
const dash = document.getElementById('dashboard-content');
try {
if (!getToken()) {
window.location.replace('/login.html');
return;
}
setupSidebarUser();
await DeskModules.load();
applyRoleNav();
DeskModules.applyVisibility();
bindOverviewModal();
bindInfraProcessModal();
bindTeamDrawerClose();
bindSocTestModal();
setView('dashboard');
ensureValidSession().then((valid) => {
if (!valid) window.location.replace('/login.html');
else setupSidebarUser();
});
reschedulePoll();
} catch (err) {
console.error('boot failed', err);
if (dash) {
dash.innerHTML = `Erro ao iniciar (${esc(err.message)}). Voltar ao login
`;
}
}
})();