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', tickets: 'Tickets', events: 'Eventos webhook', tenants: 'Tenants', infra: 'Infraestrutura', 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': 'Desk VM122 · Orquestração MOSP', 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: 'VM112, VM104 e integrações — visão técnica', 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 ` `; }).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

${(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 || '—')}
`}
${rows}
FaseRegistadoΔ faseAcumulado
`; } 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 `
  1. ${esc(evt)} ${e.stage ? `${esc(e.stage)}` : ''} ${prevDur}${fromStart}
    ${fmtDate(at)}
  2. `; } ) .join('')}
`; } function healthBadge(status) { const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' }; const cls = map[status] || 'open'; return `${esc(status || 'unknown')}`; } function checkStatusBadge(status) { const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open'; return `${esc(status)}`; } function leadRowHtml(l) { return ` `; } 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 ` `; } 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) => `` ).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 ? `` : ''} ${canAct && !isAssisting ? `` : ''} ${isAssisting ? `` : ''} ${!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 `
    ${value} ${esc(label)}
    `; } 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 ` ${ranking.slice(0, 8).map((r, i) => ` `).join('')}
    #TécnicoAssumidosEscaladosAcçõesScore
    ${i + 1} ${esc(r.username)} ${r.assumidos} ${r.escalados} ${r.acoes} ${r.score}
    `; } 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)}` : ''}

    ${rows || ''}
    FunçãoNomeTipoConteúdo
    Sem registos para este domínio.
    `; } function htmlCloudflareDnsCard(dns) { if (!dns) { return ` `; } if (dns.error && !dns.records?.length) { return ` `; } 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 ` `; } 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 ` `; } 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) => ` `).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.

    ${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 ``; } 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 ``; } 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 ` `; } 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 = `
    Eventos nas últimas 24h
    ${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}
    Risco actual
    ${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})
    Ameaças por vetor
    ${wizardSecVBarSvg(vectorBars)}
    IPs com atividade
    ${topIps.length ? topIps.map(([ip, n], i) => `
    ${i + 1} ${esc(ip)} ${n} evt
    `).join('') : '

    Nenhum IP registado

    '}
    Risco por categoria
    ${wizardSecHBarHtml(riskBars)}
    Relatório de ameaças
    ${threatRows || ''}
    AmeaçaNívelSessãoIPHora
    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 ? `
      ${issueLines}
    ` : '

    Nenhum incidente de acesso nas últimas 24h

    '}
    ${dashboardGrid}
    ${standalone ? '' : ''}
    O que monitorizamos

    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
    1. Input bloqueado / CSP — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.
    2. Handoff rejeitado — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).
    3. Correlacionar — Tickets → Onboard → hash da sessão. Compare com funil.
    4. Takeover — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.
    5. Falso positivo — Domínios com caracteres especiais podem gerar input_warn.
    6. Escalação — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.
    ${standalone ? '' : ` `}`; } 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 ` `; }).join(''); body.innerHTML = `

    VM112 Ligbox Onboard

    Saúde do wizard, domínios em onboarding, DNS, certificados e Carbonio

    ${window.DeskModules?.isEnabled('wizard-security') ? '' : ''}
    ${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 = `

    ${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)} ${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)}

    `; 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.

    `; 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(` `); }); 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 = '

    Carregando sessão…

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

    Erro: ${esc(e.message)}

    `; } } 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 = '

    Carregando…

    '; 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) ? `` : ``) : ''}
    ${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 = `

    Erro: ${esc(e.message)}

    `; } } async function updateTicketStatus(status) { await api(`/v1/desk/tickets/${state.selectedTicketId}`, { method: 'PATCH', body: JSON.stringify({ status }), }); await renderTickets(); } async function renderEvents() { 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 = `
    ${rows || ''}
    IDOrigemEventoAgente/DomínioRefData
    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
    1. O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).
    2. Confirme o e-mail exacto e a sua senha Desk (não a do Carbonio nem root).
    3. A ação remove apenas a conta Carbonio (zmprov da) — domínio, DNS e portal mantêm-se.
    4. Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.
    5. 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 `

    Confirme o e-mail e a sua senha Desk para executar zmprov da na VM112.

    `; } 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})
    ${resolvedRows || ''}
    IDE-mailDomínioResolvido porQuandoTicket
    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
    1. Input bloqueado → anote hash + IP; se repetido, escale.
    2. Handoff rejeitado → cliente deve refazer login; ticket escalado automático.
    3. Clique na linha para abrir a sessão em Tickets.
    ${rows || ''}
    NívelEventoSessãoDomínioIPDetalheQuando
    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 ``; } 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.

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

    Erro: ${esc(e.message)}

    `; } } 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) => ``) .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) => `` ).join(''); return ``; } 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(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)}
    ${user.totp_enabled ? `

    2FA ativo — o usuário pode recuperar no login ou você pode resetar aqui.

    ` : ''}
    `; 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(u.display_name || u.username)} ${esc(u.email || u.username)}
    ${roleBadgeHtml(u.role)} ${mfaBadgeHtml(u)} ${fmtRelative(u.last_login_at)} ${u.active ? 'ativo' : 'inativo'} `).join(''); el.innerHTML = `

    Equipe Ligbox

    Gestão de acessos ao Support Desk

    ${users.length}membros
    ${activeCount}ativos
    ${mfaCount}com 2FA
    ${inactiveCount}inativos
    ${rows || ''}
    Membro Perfil Segurança Último login Estado
    Nenhum membro encontrado

    ${filtered.length} de ${users.length} membros

    `; 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) => ` `).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
    `).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

    ${historyRows}
    E-mailEstadoPerfilAtualizado
    ` : ''}
    `; 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 = ` `; 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.

    `; 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.

    `; 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'))}
      ${steps || '
    • '}

    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

    `; 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.

    ' : ''}
    `; 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 = `

    SOC Operations Center

    VM112 → VM122 · atualizado ${esc(nowLabel)} · refresh 15s
    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 ? ` ${feedEvents.map((ev, i) => { const p = ev.payload || {}; const sev = socEventSeverity(ev.event_type); const isNew = flashNew && i === 0; return ` `; }).join('')}
    EventoDomínioHora
    ${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)} ${esc(p.domain || ev.domain || '—')} ${relativeTimeAgo(ev.created_at)}
    ` : '

    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 ` `; }).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)}

    `; } } async function renderInfra() { const el = document.getElementById('infra-content'); el.innerHTML = '

    Verificando…

    '; try { const [vm112, wazuh, integrations, health] = await Promise.all([ api('/v1/infra/vm112/status'), api('/v1/infra/wazuh/status'), api('/v1/integrations'), api('/v1/integrations/health'), ]); const onboard = health.vm112_onboard || {}; const last = onboard.last_webhook; const gap = onboard.gap_minutes != null ? `${Math.round(onboard.gap_minutes)} min` : '—'; const statusCls = health.status === 'ok' ? 'ok' : health.status === 'critical' ? 'escalated' : 'assisting'; const alerts = (health.alerts || []).map((a) => `
  • ${esc(a.message)}
  • ` ).join('') || '
  • Nenhum alerta
  • '; el.innerHTML = `

    SOC — Integração VM112

    ${esc(health.status || '—')}
    Último webhook
    ${last ? esc(last.event) : '—'}
    Domínio
    ${last?.domain ? esc(last.domain) : '—'}
    Há quanto tempo
    ${gap}
    VM112 API
    ${onboard.vm112_api?.reachable ? 'OK' : esc(onboard.vm112_api?.error || 'offline')}
      ${alerts}

    Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.

    OpenPanel API — Re-engenharia Ligbox

    Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup automático.

    VM112 — Portal Onboard

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

    VM104 — Wazuh SOC

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

    Integrações ativas

    ${esc(JSON.stringify(integrations, null, 2))}
    `; document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra()); document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra')); document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest()); } catch (e) { el.innerHTML = `

    Erro: ${esc(e.message)}

    `; } } 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(); 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

    `; } } })();