From aef9864034f933f35a61f94cb7ac7a9969360332 Mon Sep 17 00:00:00 2001 From: Ligbox Spec Hub Date: Fri, 26 Jun 2026 21:29:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(desk):=20promover=20modern=20UI=20v0.12.2?= =?UTF-8?q?=20para=20produ=C3=A7=C3=A3o=20desk.ligbox.com.br?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge staging validado: topnav por zonas, desk-modern.css, app unificado (Escopo OPS, blocklist, SSO Console), backup legacy e scripts promote/rollback. Co-authored-by: Cursor --- BACKLOG.md | 3 +- .../promote-staging-to-prod.sh | 46 + .../rollback-prod-to-legacy.sh | 26 + .../20260626_DESK_STAGING_MODERN_PROCESS.md | 18 + projects/ops-desk/frontend/assets/app.js | 126 +- .../ops-desk/frontend/assets/app.legacy.js | 4622 +++++++++++++++++ projects/ops-desk/frontend/index.html | 235 +- projects/ops-desk/frontend/index.legacy.html | 496 ++ 8 files changed, 5443 insertions(+), 129 deletions(-) create mode 100755 deploy/desk-staging-modern/promote-staging-to-prod.sh create mode 100755 deploy/desk-staging-modern/rollback-prod-to-legacy.sh create mode 100644 projects/ops-desk/frontend/assets/app.legacy.js create mode 100644 projects/ops-desk/frontend/index.legacy.html diff --git a/BACKLOG.md b/BACKLOG.md index 1acf436..70c7692 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1,6 +1,6 @@ # Backlog — Ligbox Ops Platform (VM122) -**Última atualização:** 2026-06-25 (Console v2 F1 · Spec 019) +**Última atualização:** 2026-06-26 (Desk modern v0.12.2 prod · Spec 018/019) **Projeto:** `ligbox-ops-platform` **VM122:** `ligbox-ops` · `10.10.10.122` · SSH WAN `:2522` **VM112:** Portal/Wizard — integração **API + webhooks** (fora do compose) @@ -111,6 +111,7 @@ | **OPS-3** | P0 | `tenant-registry` (VM112 = 1º nó) | ✅ | | **OPS-7** | P1 | VM123 finance stack (Spec 024) | ✅ | | **OPS-8** | P1 | Console v2 F1 — home unificada `console.ligbox.com.br` (Spec 019) | ✅ 2026-06-25 | +| **OPS-9** | P1 | Desk modern UI v0.12.2 — topnav + Serviços IaaS (`desk.ligbox.com.br`) | ✅ 2026-06-26 | | **AUD-1** | P0 | Collectors: Carbonio, DNS, nginx | 🔄 parcial | | **AUD-2** | P0 | UI `/ops/overview` + API scorecard | 🔄 parcial | | **AUD-3** | P1 | Scorecard por domínio (8 checks) | 🔄 | diff --git a/deploy/desk-staging-modern/promote-staging-to-prod.sh b/deploy/desk-staging-modern/promote-staging-to-prod.sh new file mode 100755 index 0000000..721a68f --- /dev/null +++ b/deploy/desk-staging-modern/promote-staging-to-prod.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Promove Desk modern UI (staging validado) → produção desk.ligbox.com.br +# Rollback: index.legacy.html + app.legacy.js no repo + +set -euo pipefail + +HOST="${1:-root@10.10.10.122}" +PASS="${DESK_SSH_PASS:-805353}" +CONTAINER="ligbox-ops-platform_frontend_1" +ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)" +HTML="${ROOT}/assets" + +echo "==> Desk modern → PRODUÇÃO" +echo "==> Container: ${CONTAINER}" + +sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p /tmp/desk-prod-deploy" + +sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ + "${ROOT}/index.html" \ + "${HTML}/topnav.js" \ + "${HTML}/desk-modern.css" \ + "${HTML}/app.js" \ + "${HTML}/styles.css" \ + "${HTML}/servicos.js" \ + "${HTML}/auth.js" \ + "${HOST}:/tmp/desk-prod-deploy/" + +sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s </dev/null | tail -1 +docker exec \$C test -f /usr/share/nginx/html/assets/desk-modern.css && echo "desk-modern.css OK" +EOF + +echo "" +echo "Produção actualizada: https://desk.ligbox.com.br/?desk=1" +echo "Rollback: ./rollback-prod-to-legacy.sh" diff --git a/deploy/desk-staging-modern/rollback-prod-to-legacy.sh b/deploy/desk-staging-modern/rollback-prod-to-legacy.sh new file mode 100755 index 0000000..19162a4 --- /dev/null +++ b/deploy/desk-staging-modern/rollback-prod-to-legacy.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Rollback produção Desk → layout legado (index.legacy.html) + +set -euo pipefail + +HOST="${1:-root@10.10.10.122}" +PASS="${DESK_SSH_PASS:-805353}" +CONTAINER="ligbox-ops-platform_frontend_1" +ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)" + +echo "==> ROLLBACK Desk produção → legacy" + +sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p /tmp/desk-prod-rollback" + +sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ + "${ROOT}/index.legacy.html" \ + "${ROOT}/assets/app.legacy.js" \ + "${HOST}:/tmp/desk-prod-rollback/" + +sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s < ({})); + if (res.ok && data.access_token) { + setSession(data.access_token, { + username: data.username, + role: data.role, + display_name: data.display_name, + }, { maxAgeSec: data.expires_in }); + params.delete('desk_handoff'); + const qs = params.toString(); + window.history.replaceState({}, '', `${window.location.pathname}${qs ? `?${qs}` : ''}`); + return true; + } + } catch (err) { + console.warn('consumeSsoFromUrl:', err?.name || err); + } + return false; +} + +async function setupConsoleReturnLink() { + const link = document.getElementById('nav-console-return'); + if (!link) return; + let consoleUrl = CONSOLE_BASE_URL; + try { + const f = await fetch(`${API}/v1/desk/features`); + if (f.ok) { + const data = await f.json(); + if (data.console_url) consoleUrl = data.console_url; + } + } catch { + /* fallback */ + } + link.href = consoleHomeUrl(consoleUrl); + link.addEventListener('click', (e) => { + e.preventDefault(); + window.location.href = consoleHomeUrl(consoleUrl); + }); +} + +function setupDeskNavBreadcrumb() { + const params = new URLSearchParams(window.location.search); + if (params.get('from') !== 'console' && !params.get('desk_handoff')) return; + const toolbar = document.getElementById('page-toolbar'); + if (!toolbar || document.getElementById('desk-console-crumb')) return; + const crumb = document.createElement('a'); + crumb.id = 'desk-console-crumb'; + crumb.className = 'btn btn-ghost btn-sm desk-console-crumb'; + crumb.textContent = '← Console'; + crumb.href = '#'; + crumb.addEventListener('click', (e) => { + e.preventDefault(); + const link = document.getElementById('nav-console-return'); + if (link?.href) window.location.href = link.href; + }); + toolbar.insertBefore(crumb, toolbar.firstChild); +} + +async function maybeConsoleCutover() { + const params = new URLSearchParams(window.location.search); + if (params.get('desk') === '1') return false; + if (/login|register|activate|matriz/.test(window.location.pathname)) return false; + try { + const res = await fetch(`${API}/v1/desk/features`); + if (!res.ok) return false; + const f = await res.json(); + if (f.console_cutover && f.console_url) { + window.location.replace(f.console_url); + return true; + } + } catch (err) { + console.warn('console cutover', err); + } + return false; +} + +function resolveBootView() { + const params = new URLSearchParams(window.location.search); + const requested = params.get('view'); + const fallback = window.DeskModules?.isViewEnabled?.('agentic-ops') ? 'agentic-ops' : resolveDefaultView(); + if (!requested || requested === 'tenants') { + if (requested === 'tenants') { + window.open(`${CONSOLE_BASE_URL}/#infra-nodes`, '_blank', 'noopener'); + } + return fallback; + } + if (window.DeskModules?.isViewEnabled?.(requested)) return requested; + return fallback; +} async function api(path, options = {}) { const res = await fetchWithTimeout(`${API}${path}`, { @@ -64,6 +168,7 @@ let state = { adminUsers: [], adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' }, adminSelected: null, + adminPanel: 'team', features: { access_matrix_ui: false }, socWindow: '24h', socLastEventId: null, @@ -231,6 +336,7 @@ function setView(name) { state.accountLoaded = false; } state.view = name; + if (name === 'admin') state.adminPanel = 'team'; const titles = { dashboard: 'Dashboard', overview: 'Audit Overview', @@ -271,6 +377,13 @@ function setView(name) { document.querySelectorAll('.nav button').forEach((b) => { b.classList.toggle('active', b.dataset.view === name); }); + document.querySelectorAll('[data-desk-nav]').forEach((b) => { + b.classList.toggle('active', b.dataset.view === name); + if (b.dataset.view === name) b.setAttribute('aria-current', 'page'); + else b.removeAttribute('aria-current'); + }); + window.DeskTopnav?.updateTopnavActive?.(name); + window.DeskTopnav?.updateContextSidebar?.(name); Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name)); reschedulePoll(); refresh(); @@ -4529,6 +4642,10 @@ document.querySelectorAll('.nav button').forEach((btn) => { btn.addEventListener('click', () => setView(btn.dataset.view)); }); +document.querySelectorAll('[data-desk-nav]').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; @@ -4591,21 +4708,26 @@ document.getElementById('nav-console')?.addEventListener('click', (ev) => { (async function boot() { const dash = document.getElementById('dashboard-content'); try { + if (await maybeConsoleCutover()) return; if (!getToken()) { window.location.replace('/login.html'); return; } + await consumeSsoFromUrl(); setupSidebarUser(); + window.DeskTopnav?.initTopnavDropdowns?.(); + await setupConsoleReturnLink(); + setupDeskNavBreadcrumb(); await loadDeskFeatures(); await DeskModules.load(); applyRoleNav(); - DeskModules.applyVisibility(); applyAccessMatrixNav(); + DeskModules.applyVisibility(); bindOverviewModal(); bindInfraProcessModal(); bindTeamDrawerClose(); bindSocTestModal(); - setView(DEFAULT_VIEW); + setView(resolveBootView()); ensureValidSession().then((valid) => { if (!valid) window.location.replace('/login.html'); diff --git a/projects/ops-desk/frontend/assets/app.legacy.js b/projects/ops-desk/frontend/assets/app.legacy.js new file mode 100644 index 0000000..bf210c6 --- /dev/null +++ b/projects/ops-desk/frontend/assets/app.legacy.js @@ -0,0 +1,4622 @@ +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)}`; +} + +const DEFAULT_VIEW = 'agentic-ops'; + +let state = { + view: DEFAULT_VIEW, + 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, + features: { access_matrix_ui: false }, + 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'), + 'agentic-ops': document.getElementById('view-agentic-ops'), + messages: document.getElementById('view-messages'), + admin: document.getElementById('view-admin'), + 'access-matrix': document.getElementById('view-access-matrix'), + 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'); + } +} + +async function loadDeskFeatures() { + try { + const data = await api('/v1/config/features'); + state.features = { ...state.features, ...data }; + } catch { + state.features = { access_matrix_ui: false }; + } +} + +function applyAccessMatrixNav() { + const nav = document.getElementById('nav-access-matrix'); + if (!nav) return; + const user = getUser(); + const show = !!state.features?.access_matrix_ui && user?.role === 'super_admin'; + if (show) nav.removeAttribute('hidden'); + else nav.setAttribute('hidden', ''); +} + +function resolveDefaultView() { + if (window.DeskModules?.loaded && DeskModules.isViewEnabled(DEFAULT_VIEW)) return DEFAULT_VIEW; + return 'dashboard'; +} + +function setView(name) { + if (name === 'access-matrix' && !state.features?.access_matrix_ui) { + name = resolveDefaultView(); + } else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) { + name = resolveDefaultView(); + } + if (state.view === 'account' && name !== 'account') { + state.accountLoaded = false; + } + state.view = name; + const titles = { + dashboard: 'Dashboard', + overview: 'Audit Overview', + 'overview-home': 'Serviços IaaS', + tickets: 'Tickets', + events: 'Eventos webhook', + tenants: 'Tenants', + infra: 'INFRA CODE', + infra2: 'SOC — Infra 2', + 'agentic-ops': 'Agentic Ops', + messages: 'Mensagens — pedidos de cadastro', + admin: 'Administradores', + 'access-matrix': 'Matriz de Acessos', + account: 'Minha conta', + leads: 'Leads abandonados', + modules: 'Módulos', + }; + const subtitles = { + dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento', + overview: 'Visão por tenant — cards de auditoria (versão clássica)', + 'overview-home': 'Orquestração MOSP · Infra as Code', + tickets: 'Operações Ligbox — onboarding, tickets e monitoramento', + events: 'Operações Ligbox — onboarding, tickets e monitoramento', + tenants: 'Operações Ligbox — onboarding, tickets e monitoramento', + infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130', + infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real', + 'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)', + messages: 'Operações Ligbox — onboarding, tickets e monitoramento', + admin: 'Operações Ligbox — onboarding, tickets e monitoramento', + 'access-matrix': 'Preview read-only — Spec 027 · funções × VM112/122/123 (estilo Odoo groups)', + 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(); +} + +/** Navegação cross-módulo (Spec 018 — cards Escopo OPS). */ +window.DeskNavigate = { + go(view, opts = {}) { + if (opts.eventsTab) state.eventsTab = opts.eventsTab; + if (opts.ticketFilter) state.ticketFilter = opts.ticketFilter; + setView(view); + if (view === 'events' && opts.eventsTab) { + document.querySelectorAll('[data-events-tab]').forEach((b) => { + b.classList.toggle('active', b.dataset.eventsTab === opts.eventsTab); + }); + renderEvents(); + } + if (view === 'tickets' && opts.ticketFilter) { + document.querySelectorAll('.filter-btn[data-filter]').forEach((b) => { + b.classList.toggle('active', b.dataset.filter === opts.ticketFilter); + }); + renderTickets(); + } + }, +}; + +let pollTimer = null; +let socRenderInFlight = false; +function reschedulePoll() { + if (pollTimer) clearInterval(pollTimer); + let ms = 30000; + if (state.view === 'infra2') ms = 15000; + // VM112 /admin/domains ~10–15s — poll menos agressivo (Spec 018 Serviços IaaS) + if (state.view === 'overview-home') ms = 90000; + pollTimer = setInterval(() => refresh({ poll: true }), ms); +} + +async function loadHealth() { + const el = document.getElementById('global-health'); + if (!el) return null; + try { + await api('/health'); + const html = ' API online'; + if (!el.classList.contains('ok') || el.innerHTML !== html) { + el.className = 'status-pill ok'; + el.innerHTML = html; + } + return true; + } catch { + const html = ' API offline'; + if (!el.classList.contains('err') || el.innerHTML !== html) { + el.className = 'status-pill err'; + el.innerHTML = html; + } + return null; + } +} + +/** Evita flash "Carregando…" quando o poll refresca uma vista já montada */ +function viewHasContent(el, selector) { + return Boolean(el?.querySelector(selector)); +} + +async function renderDashboard(options = {}) { + const { poll = false } = options; + const box = document.getElementById('dashboard-content'); + if (!viewHasContent(box, '.dashboard-top')) { + box.innerHTML = '

Carregando…

'; + } else if (poll) { + box.classList.add('view--refreshing'); + } + 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)}

`; + } finally { + box?.classList.remove('view--refreshing'); + } +} + +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
      ${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) { + if (window.DnsViewer?.fetchDomainDns) { + return window.DnsViewer.fetchDomainDns(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}${window.DnsViewer?.renderPanel ? window.DnsViewer.renderPanel(dns, { compact: true }) : 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. +
    3. Handoff rejeitado — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).
    4. +
    5. Correlacionar — Tickets → Onboard → hash da sessão. Compare com funil.
    6. +
    7. Takeover — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.
    8. +
    9. Falso positivo — Domínios com caracteres especiais podem gerar input_warn.
    10. +
    11. Escalação — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.
    12. +
    +
    +
    +
    +
    + ${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(' · ')}

    ` : ''} + ${window.DnsViewer?.renderPanel ? window.DnsViewer.renderPanel(dnsData) : 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(options = {}) { + const { poll = false } = options; + const el = document.getElementById('overview-content'); + if (!viewHasContent(el, '.health-grid')) { + el.innerHTML = '

    Carregando overview…

    '; + } else if (poll) { + el.classList.add('view--refreshing'); + } + 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)}

    `; + } finally { + el?.classList.remove('view--refreshing'); + } +} + +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(options = {}) { + const { poll = false } = options; + const el = document.getElementById('leads-content'); + if (!canReadLeads()) { + el.innerHTML = '

    Sem permissão para ver leads

    '; + return; + } + if (!viewHasContent(el, '.lead-grid')) { + el.innerHTML = '

    Carregando leads…

    '; + } else if (poll) { + el.classList.add('view--refreshing'); + } + 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)}

    `; + } finally { + el?.classList.remove('view--refreshing'); + } +} + +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(options = {}) { + const { poll = false } = options; + 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'); + if (!viewHasContent(el, '.table-wrap')) { + el.innerHTML = '

    Carregando eventos…

    '; + } else if (poll) { + el.classList.add('view--refreshing'); + } + 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 || {}; + const domain = p.domain || e.domain || '—'; + const ref = (p.session_id || p.job_id || '').slice(0, 16); + return ` + ${e.id} + ${sourceBadge(e.source)} + ${esc(e.event_type)} ${severityBadge(dataObj.level || e.severity)} + ${esc(domain)} + ${esc(ref || '—')} + ${fmtDate(e.created_at)} + `; + }).join(''); + el.innerHTML = ` +
    + + + ${rows || ''} +
    IDOrigemEventoAgente/DomínioRefData
    Sem eventos
    +
    `; + el.querySelectorAll('.event-auditor-row').forEach((row) => { + const open = () => openEventAuditorModal(Number(row.dataset.eventId)); + 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)}

    `; + } finally { + el?.classList.remove('view--refreshing'); + } +} + +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. +
    3. Confirme o e-mail exacto e a sua senha Desk (não a do Carbonio nem root).
    4. +
    5. A ação remove apenas a conta Carbonio (zmprov da) — domínio, DNS e portal mantêm-se.
    6. +
    7. Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.
    8. +
    9. Dois técnicos a resolver em paralelo: só o primeiro consegue; o outro vê «já resolvido».
    10. +
    +
    `; +} + +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. +
    3. Handoff rejeitado → cliente deve refazer login; ticket escalado automático.
    4. +
    5. Clique na linha para abrir a sessão em Tickets.
    6. +
    +
    +
    + + + ${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 `
      ${(steps || []).map((step) => { + const status = step.status || 'pending'; + return ` +
    • + ${esc(fmtDate(step.at))} +
      + ${esc(step.label)} + ${step.detail ? `${esc(step.detail)}` : ''} +
      +
    • `; + }).join('')}
    `; +} + +const PURGE_DESK_LABELS = { + webhook_events_purged_marker: 'Marcador domain.purged (histórico preservado)', + domain_console_scenarios: 'Cenários Domain Console', + tickets: 'Tickets', + audit_domains: 'Domínios audit', + assist_sessions: 'Sessões assist', + audit_checks: 'Checks audit', +}; + +function purgeDeskRowsHtml(desk) { + const keys = Object.keys(PURGE_DESK_LABELS); + const extra = Object.keys(desk || {}).filter((k) => !keys.includes(k) && k !== 'purge_source'); + const allKeys = [...keys, ...extra]; + const rows = allKeys.map((key) => { + const n = Number(desk?.[key] || 0); + if (!n && !keys.includes(key)) return ''; + const label = PURGE_DESK_LABELS[key] || key; + return `${esc(label)}${n}`; + }).filter(Boolean).join(''); + const total = Object.entries(desk || {}) + .filter(([k]) => k !== 'purge_source') + .reduce((a, [, v]) => a + Number(v || 0), 0); + return { rows, total }; +} + +function closeEventAuditorModal() { + const modal = document.getElementById('event-auditor-modal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); +} + +function renderDomainPurgedAuditor(ev) { + const p = ev.payload || {}; + const desk = p.desk_removed || {}; + const deskRows = Object.entries(desk).map(([k, v]) => + `${esc(PURGE_DESK_LABELS[k] || k)}${Number(v)}`, + ).join(''); + const jobLink = p.job_id + ? `` + : ''; + return ` +
    +

    Expurgo de domínio

    +
    +
    Domínio
    ${esc(p.domain || ev.domain || '—')}
    +
    Utilizador
    ${esc(p.by_user || '—')}
    +
    Ferramenta
    ${esc(p.purge_tool || p.purge_source || '—')}
    +
    Expurgado em
    ${fmtDate(p.purged_at || ev.created_at)}
    +
    VM112
    ${p.vm112_ok === false ? `falhou ${esc(p.vm112_error || '')}` : 'OK'}
    + ${jobLink ? `
    Job purge
    ${jobLink}
    ` : ''} +
    + ${p.message ? `

    ${esc(p.message)}

    ` : ''} +
    + ${deskRows ? `

    Removido no Desk

    ${deskRows}
    ` : ''} +

    Payload completo

    +
    ${esc(JSON.stringify(p, null, 2))}
    `; +} + +function openEventAuditorModal(eventId) { + const modal = document.getElementById('event-auditor-modal'); + const title = document.getElementById('event-auditor-modal-title'); + const sub = document.getElementById('event-auditor-modal-sub'); + const body = document.getElementById('event-auditor-modal-body'); + if (!modal || !body) return; + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + title.textContent = 'Auditor de Eventos'; + sub.textContent = `webhook · ${eventId}`; + body.innerHTML = '

    Carregando…

    '; + api(`/v1/webhooks/events/${encodeURIComponent(eventId)}`) + .then(async (data) => { + const ev = data.event || {}; + sub.textContent = `${esc(ev.source || 'webhook')} · ${ev.id}`; + if (ev.event_type === 'domain.purged') { + body.innerHTML = renderDomainPurgedAuditor(ev); + body.querySelector('[data-open-purge-job]')?.addEventListener('click', (btn) => { + closeEventAuditorModal(); + openPurgeHistoryModal(btn.currentTarget.dataset.openPurgeJob); + }); + return; + } + const p = ev.payload || {}; + const sid = (p.session_id || ev.session_id || '').trim(); + let timelineBlock = ''; + if (sid && ev.source === 'vm112-onboard') { + try { + const tl = await api(`/v1/onboard/sessions/${encodeURIComponent(sid)}/timeline`); + const events = tl.events || tl.timeline || []; + if (events.length) { + timelineBlock = ` +

    Timeline onboard (${esc(sid.slice(0, 16))}…)

    + ${timelineHtml(events, null, { compact: true })}`; + } + } catch { + /* timeline opcional */ + } + } + body.innerHTML = ` +
    +
    +
    Tipo
    ${esc(ev.event_type)}
    +
    Origem
    ${sourceBadge(ev.source)}
    +
    Domínio
    ${esc(p.domain || ev.domain || '—')}
    +
    Sessão
    ${esc(sid || '—')}
    +
    Data
    ${fmtDate(ev.created_at)}
    +
    +
    + ${timelineBlock} +

    Payload

    +
    ${esc(JSON.stringify(p, null, 2))}
    `; + }) + .catch((e) => { + body.innerHTML = `

    Erro: ${esc(e.message)}

    `; + }); + document.querySelectorAll('[data-close-event-auditor-modal]').forEach((el) => { + el.onclick = closeEventAuditorModal; + }); +} + +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 { rows: deskRows, total: deskTotal } = purgeDeskRowsHtml(desk); + const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : []; + const timeline = (job.timeline || []).length ? job.timeline : vm112Steps; + const recoverBtn = job.status === 'error' + ? `` + : ''; + body.innerHTML = ` +
    +
    +

    Removido no Desk (VM122)

    + + ${deskRows || ''} + +
    Nenhum registo Desk removido
    Total${deskTotal}
    +
    +
    +

    Removido na VM112

    +

    ${vm112RemovedSummary(vm112)}

    + ${job.elapsed_vm112 ? `

    Duração VM112: ${job.elapsed_vm112}s

    ` : ''} + ${job.error ? `

    ${esc(job.error)}

    ` : ''} + ${recoverBtn} +
    +
    +
    +

    Timeline completa

    + ${timeline.length ? renderPurgeTimelineHtml(timeline) : '

    Sem passos registados

    '} +
    `; + body.querySelector('#purge-history-recover-btn')?.addEventListener('click', async () => { + const btn = body.querySelector('#purge-history-recover-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'A recuperar…'; } + try { + const recovered = await api( + `/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}/recover`, + { method: 'POST' }, + ); + openPurgeHistoryModal(recovered.id || jobId); + await renderPurgeHistory(); + } catch (e) { + if (btn) { btn.disabled = false; btn.textContent = 'Recuperar purge (VM112 já limpa?)'; } + alert(e.message || 'Recuperação falhou'); + } + }); + }) + .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árioDesk 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 || ''} + +
    MembroPerfilSegurançaÚltimo loginEstado
    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(resolveDefaultView()); + 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({ force: true }); + else if (refreshView === 'infra') await renderInfra(); + } catch (ex) { + showSocWebhookTestError(ex); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = prevLabel || 'Testar webhook'; + } + } +} + +function bindSocEventsOnce() { + if (state._socEventsBound) return; + const host = document.getElementById('infra2-content'); + if (!host) return; + host.addEventListener('change', (e) => { + if (e.target.id !== 'soc-window-select') return; + state.socWindow = e.target.value; + renderInfra2({ force: true }); + }); + host.addEventListener('click', (e) => { + if (e.target.closest('#soc-btn-refresh')) { + e.preventDefault(); + renderInfra2({ force: true }); + return; + } + if (e.target.closest('#soc-btn-test')) { + e.preventDefault(); + runWebhookIntegrationTest('infra2'); + return; + } + const card = e.target.closest('[data-soc-session]'); + if (card) { + state.selectedSessionId = card.dataset.socSession; + const tid = card.dataset.socTicket; + state.selectedTicketId = tid ? Number(tid) : null; + setView('tickets'); + } + }); + state._socEventsBound = true; +} + +async function renderInfra2(options = {}) { + const { poll = false, force = false } = options; + 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; + } + + const hasShell = !!document.getElementById('soc-console-root'); + const soft = poll && hasShell && !force; + + if (socRenderInFlight) { + if (poll) return; + while (socRenderInFlight) { + await new Promise((r) => setTimeout(r, 50)); + } + } + + socRenderInFlight = true; + const root = document.getElementById('soc-console-root'); + if (soft && root) { + root.classList.add('soc-console--refreshing'); + } else if (!hasShell || force) { + 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 newestKey = newestId != null ? String(newestId) : null; + const flashNew = newestKey != null && newestKey !== state.socLastEventId; + if (newestKey != null) state.socLastEventId = newestKey; + + 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'}
    +
    +
    +
    +
    +
    +
    +
    `; + + bindSocEventsOnce(); + } catch (e) { + el.innerHTML = `

    Erro SOC: ${esc(e.message)}

    `; + } finally { + socRenderInFlight = false; + document.getElementById('soc-console-root')?.classList.remove('soc-console--refreshing'); + } +} + +function infraKvHtml(items) { + return `
    ${items.map(([label, value]) => + `
    ${esc(label)}
    ${value}
    ` + ).join('')}
    `; +} + +const PROC_CARD_ICONS = { + soc: '📡', + openpanel: '🎛️', + purge: '🔐', + vm112: '🌐', + wazuh: '🛡️', + integrations: '🔗', +}; + +function procCardHtml(opts) { + const { + id, + icon, + accent = 'teal', + title, + spec, + desc, + statusLabel, + statusCls = 'review', + actions = [], + } = opts; + const acts = actions.map((a) => + `` + ).join(''); + return ` +
    + ${esc(statusLabel)} +
    + + ${esc(spec)} +
    +

    ${esc(title)}

    +

    ${desc}

    +
    ${acts}
    +
    `; +} + +function closeInfraProcessModal() { + const modal = document.getElementById('infra-process-modal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); +} + +function openInfraProcessModal(title, sub, bodyHtml) { + const modal = document.getElementById('infra-process-modal'); + const titleEl = document.getElementById('infra-process-modal-title'); + const subEl = document.getElementById('infra-process-modal-sub'); + const body = document.getElementById('infra-process-modal-body'); + if (!modal || !body) return; + if (titleEl) titleEl.textContent = title; + if (subEl) subEl.textContent = sub || ''; + body.innerHTML = bodyHtml; + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); +} + +function bindInfraProcessModal() { + document.querySelectorAll('[data-close-infra-process-modal]').forEach((el) => { + el.addEventListener('click', closeInfraProcessModal); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeInfraProcessModal(); + }); +} + +function findStackService(id) { + const stack = state.infraSnapshot?.stack; + if (!stack) return null; + for (const vm of stack.vms || []) { + for (const svc of vm.services || []) { + if (svc.id === id) return { vm, svc }; + } + } + return null; +} + +function stackServiceActions(svcId) { + if (svcId === 'vm122-purge-auth') { + return [{ label: 'Gerir códigos', action: 'purge-manage', primary: true }]; + } + const actions = [{ label: 'Detalhes', action: 'detail', primary: true }]; + if (svcId === 'vm122-webhook-soc') actions.push({ label: 'Testar', action: 'test-webhook', primary: false }); + if (svcId === 'vm123-openpanel-bridge') actions.push({ label: 'Testar', action: 'test-openpanel', primary: false }); + return actions; +} + +function stackServiceStatusCls(svc) { + if (svc.ok) return 'ok'; + if (svc.status === 'check') return 'review'; + return 'escalated'; +} + +function bindStackCardActions(root) { + root?.querySelectorAll('[data-stack-action]').forEach((btn) => { + btn.addEventListener('click', () => { + const action = btn.dataset.stackAction; + const id = btn.dataset.stackId; + if (action === 'detail') openStackServiceDetail(id); + else if (action === 'test-webhook') runWebhookIntegrationTest('infra'); + else if (action === 'test-openpanel') runOpenPanelApiTest(); + else if (action === 'purge-manage') openInfraProcessDetail('purge'); + }); + }); +} + +function openStackServiceDetail(svcId) { + const hit = findStackService(svcId); + if (!hit) { + if (svcId === 'integrations-json') openInfraProcessDetail('integrations'); + return; + } + const { vm, svc } = hit; + const specLabel = svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : svc.kind || 'stack'; + openInfraProcessModal( + svc.title, + `${vm.vm_label} · ${specLabel}`, + `${infraKvHtml([ + ['VM', `${vm.vm} · ${esc(vm.ip)}`], + ['Tipo', esc(svc.kind || '—')], + ['URL', `${esc(svc.url || '—')}`], + ['Status', esc(svc.status || '—')], + ['HTTP', svc.http_status != null ? String(svc.http_status) : '—'], + ['Detalhe', esc(svc.detail || '—')], + ])} +
    + ${svc.url && svc.url.startsWith('http') ? `Abrir URL` : ''} +
    ` + ); +} + +function openInfraProcessDetail(procId) { + const snap = state.infraSnapshot || {}; + const health = snap.health || {}; + const integrations = snap.integrations; + const onboard = health.vm112_onboard || {}; + const last = onboard.last_webhook; + const gap = onboard.gap_minutes != null ? `${Math.round(onboard.gap_minutes)} min` : '—'; + const vmOk = onboard.vm112_api?.reachable; + const alerts = (health.alerts || []).map((a) => + `
  • ${esc(a.message)}
  • ` + ).join('') || '
  • Nenhum alerta activo
  • '; + + if (procId === 'soc' || procId === 'vm122-webhook-soc') { + openInfraProcessModal( + 'SOC — Integração VM112', + 'Webhook onboard · alertas de gap', + `${infraKvHtml([ + ['Último evento', last ? esc(last.event) : '—'], + ['Domínio', last?.domain ? esc(last.domain) : '—'], + ['Há quanto tempo', gap], + ['VM112 API', vmOk ? 'OK' : esc(onboard.vm112_api?.error || 'offline')], + ['Status integração', esc(health.status || '—')], + ])} +
      ${alerts}
    +
    + + +
    +

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

    ` + ); + document.getElementById('btn-test-webhook-modal')?.addEventListener('click', () => runWebhookIntegrationTest('infra')); + document.getElementById('btn-refresh-health-modal')?.addEventListener('click', () => { + closeInfraProcessModal(); + renderInfra(); + }); + return; + } + if (procId === 'openpanel' || procId === 'vm123-openpanel-bridge') { + const vm123Health = snap.vm123Health; + const op = vm123Health?.openpanel || findStackService('vm123-openpanel-bridge')?.svc || {}; + const opOk = Boolean(op.ok); + const bridgeOk = Boolean(op.bridge); + openInfraProcessModal( + 'OpenPanel API — Re-engenharia Ligbox', + 'Spec 028 · VM123 bridge :18087', + `

    Multidomínio · conta temporária com cleanup automático.

    + ${infraKvHtml([ + ['OpenPanel', opOk ? 'OK' : esc(op.error || op.detail || 'offline')], + ['Bridge API', bridgeOk ? 'OK' : 'offline'], + ['Bridge URL', esc(op.bridge_url || op.url || '—')], + ])} +
    + + OpenPanel UI +
    +

    Suite openpanel-multidomain-api-confirm

    ` + ); + document.getElementById('btn-test-openpanel-modal')?.addEventListener('click', () => runOpenPanelApiTest()); + return; + } + if (procId === 'purge' || procId === 'vm122-purge-auth') { + openInfraProcessModal( + 'Códigos purge — autorização extra', + 'Spec 032 · domínios protegidos', + '

    A carregar…

    ' + ); + renderPurgeAuthPanel(document.getElementById('purge-auth-modal-panel')); + return; + } + if (procId === 'integrations') { + openInfraProcessModal( + 'Integrações activas', + 'Snapshot JSON · Desk API', + `
    ${esc(JSON.stringify(integrations || {}, null, 2))}
    ` + ); + } +} + +async function renderInfra(options = {}) { + const { poll = false } = options; + const el = document.getElementById('infra-content'); + if (!viewHasContent(el, '.infra-vm-section')) { + el.innerHTML = '

    Verificando stack…

    '; + } else if (poll) { + el.classList.add('view--refreshing'); + } + try { + const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([ + api('/v1/infra/stack/status'), + api('/v1/integrations').catch(() => null), + api('/v1/integrations/health').catch(() => ({})), + api('/v1/vm123/health').catch(() => null), + api('/v1/infra/purge-auth-domains').catch(() => ({ domains: [], can_generate: false })), + ]); + state.infraSnapshot = { stack, integrations, health, vm123Health, purgeMeta }; + const summary = stack.summary || {}; + const okCls = summary.ok === summary.total ? 'ok' : summary.ok > 0 ? 'assisting' : 'escalated'; + let sections = (stack.vms || []).map((vm) => { + const cards = (vm.services || []).map((svc) => + procCardHtml({ + id: svc.id, + icon: svc.icon || '⚙️', + accent: svc.accent || 'teal', + title: svc.title, + spec: svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : String(svc.kind || 'stack').toUpperCase(), + desc: esc(svc.detail || svc.url || ''), + statusLabel: svc.status || (svc.ok ? 'online' : 'down'), + statusCls: stackServiceStatusCls(svc), + actions: stackServiceActions(svc.id), + }) + ).join(''); + return ` +
    +

    ${esc(vm.vm_label)} ${esc(vm.ip)}

    +
    ${cards}
    +
    `; + }).join(''); + + const integrationsCard = integrations + ? procCardHtml({ + id: 'integrations-json', + icon: '🔗', + accent: 'violet', + title: 'Integrações Desk', + spec: 'JSON', + desc: 'Registry onboard + Wazuh · snapshot API', + statusLabel: 'activas', + statusCls: 'open', + actions: [{ label: 'Ver JSON', action: 'detail', primary: true }], + }) + : ''; + + el.innerHTML = ` +
    +
    + ${summary.ok ?? 0}/${summary.total ?? 0} online + Stack Ligbox · VMs 112 · 114 · 122 · 123 · 130 · Infra as Code · ${fmtDate(stack.generated_at)} + +
    + ${sections} + ${integrationsCard ? `

    Integrações · Desk API

    ${integrationsCard}
    ` : ''} +
    `; + document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra({ force: true })); + bindStackCardActions(el); + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } finally { + el?.classList.remove('view--refreshing'); + } +} + +async function renderPurgeAuthPanel(panel) { + if (!panel) return; + try { + const meta = await api('/v1/infra/purge-auth-domains'); + const domainChips = (meta.domains || []).map((d) => + `${esc(d)}` + ).join('') || 'Nenhum'; + const canGen = meta.can_generate && typeof canManageUsers === 'function' && canManageUsers(); + let codesHtml = ''; + if (canGen) { + const data = await api('/v1/infra/purge-auth-codes?limit=20'); + const rows = (data.codes || []).map((c) => ` + + ${esc(c.domain)} + ${esc(c.note || '—')} + ${fmtDate(c.expires_at)} + ${esc(c.created_by || '—')} + `).join(''); + codesHtml = ` +

    Gere código com senha Root — use na conferência antes do purge em Serviços.

    +
    ${domainChips}
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +

    + +

    Códigos activos

    +
    + + + ${rows || ''} +
    DomínioNotaExpiraPor
    Nenhum código activo
    +
    `; + } else { + codesHtml = ` +
    ${domainChips}
    +

    Apenas super_admin (root) gera códigos. Peça o código ao root antes do purge em Serviços.

    `; + } + panel.innerHTML = codesHtml; + const form = panel.querySelector('#purge-auth-generate-form'); + if (form) { + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const msg = panel.querySelector('#purge-auth-gen-msg'); + const out = panel.querySelector('#purge-auth-generated'); + const domain = panel.querySelector('#purge-auth-domain')?.value?.trim() || ''; + const note = panel.querySelector('#purge-auth-note')?.value?.trim() || ''; + const ttl = parseInt(panel.querySelector('#purge-auth-ttl')?.value || '24', 10); + const rootPwd = panel.querySelector('#purge-auth-root-pwd')?.value || ''; + if (!domain || !rootPwd) { + if (msg) msg.textContent = 'Preencha domínio e senha Root.'; + return; + } + if (msg) msg.textContent = 'A gerar…'; + try { + const res = await api('/v1/infra/purge-auth-codes', { + method: 'POST', + body: JSON.stringify({ + domain, + root_password: rootPwd, + note, + ttl_hours: ttl, + }), + }); + if (msg) msg.textContent = 'Código gerado — copie agora (não será mostrado de novo).'; + if (out) { + out.classList.remove('hidden'); + out.innerHTML = ` +

    Código: ${esc(res.code)}

    +

    Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}

    `; + } + panel.querySelector('#purge-auth-root-pwd').value = ''; + } catch (e) { + if (msg) msg.textContent = e.message || 'Falha ao gerar código'; + } + }); + } + } catch (e) { + panel.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +async function renderPurgeAuthInfraPanel() { + await renderPurgeAuthPanel(document.getElementById('purge-auth-infra-panel')); +} + +async function refresh(options = {}) { + const { poll = false } = options; + void loadHealth(); + if (poll && state.view === 'account') { + return; + } + // Agentic Ops tem poll próprio — evita duplo refresh e flash "Carregando…" + if (poll && state.view === 'agentic-ops') { + return; + } + const p = poll ? { poll: true } : {}; + if (state.view === 'dashboard') await renderDashboard(p); + if (state.view === 'email-migration' && window.DeskEmailMigration?.renderPage) await window.DeskEmailMigration.renderPage(); + if (state.view === 'overview') await renderOverview(p); + if (state.view === 'overview-home') await renderOverviewHome({ poll }); + if (state.view === 'leads') await renderLeads(p); + if (state.view === 'tickets') { + if (poll && window.TicketsWorkspace?._pageReady) await TicketsWorkspace.softRefresh(); + else await renderTickets({ poll: false }); + } + if (state.view === 'events') await renderEvents(p); + if (state.view === 'tenants') await renderTenants(); + if (state.view === 'infra') await renderInfra(p); + if (state.view === 'infra2') await renderInfra2({ poll }); + if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps(p); + if (state.view === 'messages') await renderMessages(); + if (state.view === 'admin') await renderAdmin(); + if (state.view === 'access-matrix' && window.renderAccessMatrix) await window.renderAccessMatrix(); + 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.querySelectorAll('[data-close-event-auditor-modal]').forEach((el) => { + el.addEventListener('click', closeEventAuditorModal); +}); + +document.getElementById('btn-refresh')?.addEventListener('click', () => { + if (state.view === 'account') { + state.accountLoaded = false; + } + refresh(); +}); + +document.addEventListener('click', (ev) => { + const btn = ev.target.closest('.js-console-dns'); + if (!btn || typeof openConsoleDnsViewer !== 'function') return; + ev.preventDefault(); + openConsoleDnsViewer(btn.dataset.domain || ''); +}); + +document.getElementById('nav-console')?.addEventListener('click', (ev) => { + if (typeof openConsolePath !== 'function') return; + ev.preventDefault(); + openConsolePath('/admin/dominio'); +}); + +(async function boot() { + const dash = document.getElementById('dashboard-content'); + try { + if (!getToken()) { + window.location.replace('/login.html'); + return; + } + setupSidebarUser(); + await loadDeskFeatures(); + await DeskModules.load(); + applyRoleNav(); + DeskModules.applyVisibility(); + applyAccessMatrixNav(); + bindOverviewModal(); + bindInfraProcessModal(); + bindTeamDrawerClose(); + bindSocTestModal(); + setView(DEFAULT_VIEW); + + 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

    `; + } + } +})(); diff --git a/projects/ops-desk/frontend/index.html b/projects/ops-desk/frontend/index.html index 5e43cdc..0c37447 100644 --- a/projects/ops-desk/frontend/index.html +++ b/projects/ops-desk/frontend/index.html @@ -4,9 +4,13 @@ Ligbox Ops — Support Desk - + + + + + - + @@ -180,114 +184,97 @@ -
    - - -
    - + +
    + + +
    +
    +
    -
    - Console v2 — KPIs e ranking técnico migram para - console.ligbox.com.br. - Este painel permanece até cutover Fase 4. -

    Carregando…

    -
    - Saúde migrada — scorecard e domínios com atenção estão no - Console unificado. -

    Carregando…

    @@ -300,10 +287,6 @@
    -
    - Chamados CH-* — investigação unificada em - console.ligbox.com.br/chamados. -
    @@ -325,10 +308,6 @@
    -
    - Discover — feed de eventos Wazuh/onboard em - console.ligbox.com.br/discover. -
    @@ -358,8 +337,8 @@

    Carregando SOC…

    -
    -

    Carregando Agentic Ops…

    +
    +

    Carregando Agent Squad…

    @@ -381,8 +360,11 @@

    Carregando…

    -
    +
    +
    + + + - - + - - + + + diff --git a/projects/ops-desk/frontend/index.legacy.html b/projects/ops-desk/frontend/index.legacy.html new file mode 100644 index 0000000..5e43cdc --- /dev/null +++ b/projects/ops-desk/frontend/index.legacy.html @@ -0,0 +1,496 @@ + + + + + + Ligbox Ops — Support Desk + + + + + + + +
    + + +
    + + +
    +
    + Console v2 — KPIs e ranking técnico migram para + console.ligbox.com.br. + Este painel permanece até cutover Fase 4. +
    +

    Carregando…

    +
    + +
    +
    + Saúde migrada — scorecard e domínios com atenção estão no + Console unificado. +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +
    + Chamados CH-* — investigação unificada em + console.ligbox.com.br/chamados. +
    +
    + + + + + + + | + + + +
    +
    +

    Carregando…

    +
    +

    Selecione um ticket para ver detalhes

    +
    +
    +
    + +
    +
    + Discover — feed de eventos Wazuh/onboard em + console.ligbox.com.br/discover. +
    +
    + + + + + | + + + + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + + +
    +

    Carregando…

    +
    + +
    +

    Carregando SOC…

    +
    + +
    +

    Carregando Agentic Ops…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando matriz…

    +
    + +
    +

    Carregando…

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + +