diff --git a/deploy/desk-staging-modern/deploy-staging-only.sh b/deploy/desk-staging-modern/deploy-staging-only.sh new file mode 100755 index 0000000..8e8c632 --- /dev/null +++ b/deploy/desk-staging-modern/deploy-staging-only.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Desk modern UI — deploy APENAS staging (desk-staging.ligbox.com.br) +# NÃO toca em ligbox-ops-platform_frontend_1 (produção desk.ligbox.com.br) +# +# Uso: ./deploy-staging-only.sh [VM122_HOST] +# Spec / processo: docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md + +set -euo pipefail + +HOST="${1:-root@10.10.10.122}" +PASS="${DESK_SSH_PASS:-805353}" +CONTAINER="ligbox-ops-platform-staging_frontend-staging_1" +ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)" +HTML="${ROOT}/assets" + +echo "==> Desk staging modern — fonte: ${ROOT}" +echo "==> Container: ${CONTAINER} (NÃO produção)" + +sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ + "${ROOT}/index.staging.html" \ + "${HTML}/topnav.js" \ + "${HTML}/desk-modern.css" \ + "${HTML}/app.staging.js" \ + "${HTML}/styles.css" \ + "${HTML}/servicos.js" \ + "${HTML}/auth.js" \ + "${HOST}:/tmp/desk-staging-deploy/" + +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}`, { + 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, + adminPanel: 'team', + 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; + if (name === 'admin') state.adminPanel = 'team'; + 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); + }); + 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(); +} + +/** 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
    ` : ''} +
    `; +} + +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('[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; + 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 (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(); + applyAccessMatrixNav(); + DeskModules.applyVisibility(); + bindOverviewModal(); + bindInfraProcessModal(); + bindTeamDrawerClose(); + bindSocTestModal(); + setView(resolveBootView()); + + 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/assets/desk-modern.css b/projects/ops-desk/frontend/assets/desk-modern.css new file mode 100644 index 0000000..e5e739d --- /dev/null +++ b/projects/ops-desk/frontend/assets/desk-modern.css @@ -0,0 +1,421 @@ +/* ── Desk chrome v0.12 — glass header, tabs rectas (sem pills) ── */ +.shell--v2 { + display: flex; + flex-direction: column; + min-height: 100vh; + grid-template-columns: unset; + background: var(--bg); + font-family: 'Inter', 'DM Sans', system-ui, -apple-system, sans-serif; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + -webkit-font-smoothing: antialiased; +} + +.shell--v2 .sidebar { + display: none; +} + +.app-chrome--modern { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255, 253, 249, 0.78); + backdrop-filter: blur(16px) saturate(1.35); + -webkit-backdrop-filter: blur(16px) saturate(1.35); + border-bottom: 1px solid rgba(92, 46, 46, 0.08); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.65) inset, + 0 8px 32px rgba(92, 46, 46, 0.06); +} + +.app-chrome__inner { + display: flex; + align-items: center; + gap: 1rem; + min-height: 58px; + padding: 0.45rem 1.25rem; + max-width: 100%; +} + +.app-chrome__brand { + display: flex; + align-items: center; + gap: 0.55rem; + flex-shrink: 0; +} + +.app-chrome__mark { + width: 9px; + height: 9px; + border-radius: 0; + background: linear-gradient(135deg, #ff5c8a 0%, #5c2e2e 100%); + box-shadow: none; +} + +.app-chrome__brand h1 { + margin: 0; + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.035em; + color: #2b2622; + line-height: 1; +} + +.app-chrome__brand .brand-ops { + color: var(--accent); + font-weight: 700; +} + +.app-chrome__nav { + display: flex; + align-items: center; + gap: 0.45rem; + flex: 1; + min-width: 0; + padding: 0.15rem 0.25rem; + overflow-x: auto; + scrollbar-width: none; + mask-image: linear-gradient(90deg, transparent, #000 12px, #000 calc(100% - 12px), transparent); +} + +.app-chrome__nav::-webkit-scrollbar { + display: none; +} + +.nav-zone { + display: flex; + flex-direction: column; + gap: 0.22rem; + flex-shrink: 0; + padding: 0.2rem 0.35rem 0.15rem; + border-radius: 0; + transition: background 0.2s ease; +} + +.nav-zone.active { + background: rgba(92, 46, 46, 0.04); +} + +.nav-zone__label { + font-size: 0.56rem; + font-weight: 650; + letter-spacing: 0.11em; + text-transform: uppercase; + color: #9a928a; + line-height: 1; + padding-left: 0.35rem; +} + +.nav-zone__links { + display: flex; + align-items: center; + gap: 0; + padding: 0; + border-radius: 0; + background: rgba(92, 46, 46, 0.05); + border: 1px solid rgba(92, 46, 46, 0.1); +} + +.nav-link { + appearance: none; + border: none; + background: transparent; + color: #5f5852; + font: inherit; + font-size: 0.8125rem; + font-weight: 520; + padding: 0.42rem 0.75rem; + border-radius: 0; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + border-right: 1px solid rgba(92, 46, 46, 0.08); + transition: + color 0.16s ease, + background 0.16s ease; +} + +.nav-zone__links .nav-link:last-child { + border-right: none; +} + +.nav-link:hover { + color: #2b2622; + background: rgba(255, 255, 255, 0.55); +} + +.nav-link.active { + color: var(--accent); + font-weight: 650; + background: #fff; + box-shadow: inset 0 -2px 0 var(--accent); +} + +.nav-link[hidden] { + display: none !important; +} + +.nav-zone--console { + text-decoration: none; + color: inherit; + align-self: center; + padding: 0.15rem 0.25rem; +} + +.nav-zone--console .nav-zone__label { + color: #64748b; +} + +.nav-link--console { + display: inline-block; + color: #0f4c81; + font-weight: 620; + border: 1px solid rgba(14, 165, 233, 0.28); + background: #f0f9ff; + margin-top: 0.12rem; + border-radius: 0; + border-right: none; +} + +.nav-link--console:hover { + color: #0c4a6e; + background: #e0f2fe; + box-shadow: none; + transform: none; +} + +.nav-zone__sep { + width: 1px; + align-self: center; + height: 28px; + margin: 0 0.1rem; + background: linear-gradient(180deg, transparent, rgba(92, 46, 46, 0.12), transparent); + flex-shrink: 0; +} + +.app-chrome__meta { + display: flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + padding-left: 0.35rem; +} + +.app-chrome__meta .header-user { + display: flex; + align-items: center; + gap: 0.55rem; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.2; + margin-right: 0.15rem; +} + +.header-user__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0; + background: linear-gradient(145deg, #5c2e2e, #8b3a42); + color: #fff; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.02em; + box-shadow: none; +} + +.header-user__text strong { + display: block; + color: #2b2622; + font-size: 0.8125rem; + font-weight: 620; +} + +.header-user__text > span { + display: block; + font-size: 0.72rem; + color: #9a928a; +} + +.app-chrome__meta .status-pill { + font-size: 0.72rem; + padding: 0.28rem 0.55rem; + border-radius: 0; +} + +.app-chrome__meta .btn-ghost { + border-radius: 0; + font-size: 0.78rem; + font-weight: 550; + border-color: rgba(92, 46, 46, 0.1); + color: #5f5852; +} + +.app-chrome__meta .btn-ghost:hover { + background: rgba(92, 46, 46, 0.06); + color: var(--accent); +} + +.workspace { + display: grid; + grid-template-columns: 0 1fr; + flex: 1; + min-height: 0; +} + +.shell--v2:not(.shell--no-context) .workspace { + grid-template-columns: 232px 1fr; +} + +.workspace-main { + min-width: 0; +} + +.shell--v2 .main { + padding: 1.1rem 1.5rem 2rem; +} + +.context-sidebar { + background: rgba(255, 253, 249, 0.92); + backdrop-filter: blur(8px); + border-right: 1px solid rgba(92, 46, 46, 0.08); + padding: 1rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + min-height: calc(100vh - 58px); +} + +.context-sidebar__head { + padding: 0 0.35rem 0.75rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.75rem; +} + +.context-sidebar__label { + margin: 0; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.context-sidebar__title { + margin: 0.2rem 0 0; + font-size: 1.02rem; + font-weight: 700; + color: #3a3530; + letter-spacing: -0.02em; +} + +.context-nav { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.context-nav .toolbar, +.context-nav .team-admin-tabs { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: stretch; + margin: 0; + padding: 0; + background: transparent; + border: none; +} + +.context-nav .toolbar-sep { + display: none; +} + +.context-nav .filter-btn, +.context-nav .team-admin-tab { + width: 100%; + text-align: left; + justify-content: flex-start; + border-radius: 0; + padding: 0.48rem 0.65rem; + font-size: 0.82rem; +} + +.context-nav .filter-btn.active, +.context-nav .team-admin-tab.active { + background: var(--accent-soft); + border-color: rgba(92, 46, 46, 0.25); + color: var(--accent); + font-weight: 600; +} + +.context-nav .am-role-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + border: none; + background: transparent; + min-width: 0; +} + +.context-nav .am-role-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.context-nav .am-role-btn { + width: 100%; + text-align: left; +} + +.context-sidebar__foot { + margin-top: auto; + padding-top: 0.75rem; + font-size: 0.68rem; + color: var(--muted); +} + +.shell--v2 .page-header { + margin-bottom: 0.85rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--border); +} + +.shell--v2 .page-header h2 { + font-size: 1.35rem; + letter-spacing: -0.02em; +} + +.shell--v2 .page-toolbar:empty { + display: none; +} + +.shell--v2 #access-matrix-content .am-layout { + grid-template-columns: 1fr; +} + +@media (max-width: 960px) { + .app-chrome__inner { + flex-wrap: wrap; + padding: 0.55rem 0.75rem; + gap: 0.65rem; + } + .app-chrome__nav { + order: 3; + flex-basis: 100%; + mask-image: none; + } + .app-chrome__meta .header-user__text { + display: none; + } + .shell--v2:not(.shell--no-context) .workspace { + grid-template-columns: 1fr; + } + .context-sidebar { + min-height: auto; + border-right: none; + border-bottom: 1px solid var(--border); + } +} diff --git a/projects/ops-desk/frontend/assets/topnav.js b/projects/ops-desk/frontend/assets/topnav.js new file mode 100644 index 0000000..f3dd048 --- /dev/null +++ b/projects/ops-desk/frontend/assets/topnav.js @@ -0,0 +1,128 @@ +/** Desk chrome nav — tabs planas por zona + sidebar contextual (v0.11). */ + +const TOPNAV_VIEW_GROUP = { + 'agentic-ops': 'operacoes', + dashboard: 'operacoes', + tickets: 'operacoes', + events: 'operacoes', + 'overview-home': 'comercial', + leads: 'comercial', + infra: 'infra', + infra2: 'infra', + 'email-migration': 'infra', + overview: 'sistema', + account: 'sistema', + messages: 'sistema', + admin: 'sistema', + 'access-matrix': 'sistema', + modules: 'sistema', +}; + +const CONTEXT_TOOLBARS = { + tickets: { title: 'Tickets', host: '#view-tickets', label: 'Filas' }, + events: { title: 'Eventos', host: '#view-events', label: 'Vistas' }, +}; + +function initTopnavDropdowns() { + /* v0.11 — tabs planas; sem dropdowns */ +} + +function parkContextToolbars() { + Object.values(CONTEXT_TOOLBARS).forEach(({ host }) => { + const view = document.querySelector(host); + const toolbar = view?.querySelector('.toolbar'); + if (toolbar && view && toolbar.parentElement !== view) { + view.insertBefore(toolbar, view.firstChild); + } + }); + const matrixRoleNav = document.getElementById('context-matrix-roles'); + if (matrixRoleNav && matrixRoleNav.dataset.parkedFrom) { + const slot = document.querySelector(matrixRoleNav.dataset.parkedFrom); + if (slot) slot.appendChild(matrixRoleNav); + delete matrixRoleNav.dataset.parkedFrom; + } + const adminHost = document.getElementById('context-admin-tabs'); + if (adminHost) adminHost.innerHTML = ''; +} + +function updateTopnavActive(view) { + document.querySelectorAll('[data-desk-nav]').forEach((btn) => { + const active = btn.dataset.view === view; + btn.classList.toggle('active', active); + if (active) btn.setAttribute('aria-current', 'page'); + else btn.removeAttribute('aria-current'); + }); + document.querySelectorAll('.nav-zone[data-topnav-group]').forEach((zone) => { + zone.classList.toggle('active', TOPNAV_VIEW_GROUP[view] === zone.dataset.topnavGroup); + }); +} + +function mountMatrixRoleSidebar() { + const roleNav = document.querySelector('#access-matrix-content .am-role-list'); + if (!roleNav) return false; + const nav = document.getElementById('context-nav'); + if (!nav) return false; + roleNav.dataset.parkedFrom = '#access-matrix-content .am-layout'; + nav.appendChild(roleNav); + return true; +} + +function updateContextSidebar(view) { + const shell = document.querySelector('.shell'); + const sidebar = document.getElementById('context-sidebar'); + const nav = document.getElementById('context-nav'); + const titleEl = document.getElementById('context-sidebar-title'); + const labelEl = document.getElementById('context-sidebar-label'); + if (!shell || !sidebar || !nav) return; + + parkContextToolbars(); + nav.innerHTML = ''; + + if (view === 'admin' && typeof renderAdminTabs === 'function') { + sidebar.hidden = false; + shell.classList.remove('shell--no-context'); + if (titleEl) titleEl.textContent = 'Administradores'; + if (labelEl) labelEl.textContent = 'Secções'; + nav.innerHTML = `
    ${renderAdminTabs()}
    `; + if (typeof bindAdminTabs === 'function') bindAdminTabs(); + return; + } + + if (view === 'access-matrix') { + sidebar.hidden = false; + shell.classList.remove('shell--no-context'); + if (titleEl) titleEl.textContent = 'Matriz de Acesso'; + if (labelEl) labelEl.textContent = 'Funções'; + requestAnimationFrame(() => mountMatrixRoleSidebar()); + return; + } + + const cfg = CONTEXT_TOOLBARS[view]; + if (!cfg) { + sidebar.hidden = true; + shell.classList.add('shell--no-context'); + return; + } + + const viewEl = document.querySelector(cfg.host); + const toolbar = viewEl?.querySelector('.toolbar'); + if (!toolbar) { + sidebar.hidden = true; + shell.classList.add('shell--no-context'); + return; + } + + sidebar.hidden = false; + shell.classList.remove('shell--no-context'); + if (titleEl) titleEl.textContent = cfg.title; + if (labelEl) labelEl.textContent = cfg.label; + toolbar.classList.add('context-toolbar'); + nav.appendChild(toolbar); +} + +window.DeskTopnav = { + initTopnavDropdowns, + updateTopnavActive, + updateContextSidebar, + remountMatrixRoles: mountMatrixRoleSidebar, +}; diff --git a/projects/ops-desk/frontend/index.staging.html b/projects/ops-desk/frontend/index.staging.html new file mode 100644 index 0000000..b4af175 --- /dev/null +++ b/projects/ops-desk/frontend/index.staging.html @@ -0,0 +1,438 @@ + + + + + + Ligbox Ops — Support Desk + + + + + + + + + + +
    +
    +
    +
    + +

    Ligbox Ops

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

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +
    + + + + + + + | + + + +
    +
    +

    Carregando…

    +
    +

    Selecione um ticket para ver detalhes

    +
    +
    +
    + +
    +
    + + + + + | + + + +
    +

    Carregando…

    +
    + + +
    +

    Carregando…

    +
    + +
    +

    Carregando SOC…

    +
    + +
    +

    Carregando Agent Squad…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando matriz…

    +
    + +
    +

    Carregando…

    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + diff --git a/projects/ops-desk/frontend/staging-snapshot/stg-app.js b/projects/ops-desk/frontend/staging-snapshot/stg-app.js new file mode 100644 index 0000000..8d05db1 --- /dev/null +++ b/projects/ops-desk/frontend/staging-snapshot/stg-app.js @@ -0,0 +1,4234 @@ +const API = '/api'; +const CONSOLE_BASE_URL = 'https://console.ligbox.com.br'; +const DESK_MENU_CATALOG_VERSION = '2026-06-25-menu-v1'; + +async function api(path, options = {}, timeoutMs) { + const res = await fetchWithTimeout(`${API}${path}`, { + headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }), + ...options, + }, timeoutMs); + 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(); +} + +/** Requisições longas (OpenPanel provision) — sem AbortController. */ +async function apiLongRunning(path, options = {}) { + const res = await fetch(`${API}${path}`, { + headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }), + ...options, + }); + if (res.status === 401) { + logout(); + throw new Error('sessão expirada'); + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const detail = data.detail; + const msg = typeof detail === 'object' ? detail.message || JSON.stringify(detail) : (detail || `${res.status} ${path}`); + throw new Error(msg); + } + return res.json(); +} + +function fmtDate(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }); + } catch { + return iso; + } +} + +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function sessionHashHtml(sessionId, { full = true } = {}) { + const id = (sessionId || '').trim(); + if (!id) return ''; + const shown = full ? id : `${id.slice(0, 8)}…${id.slice(-4)}`; + return `${esc(shown)}`; +} + +let state = { + view: 'dashboard', + ticketFilter: 'all', + sourceFilter: 'all', + eventSourceFilter: 'all', + eventsTab: 'webhooks', + selectedTicketId: null, + selectedSessionId: null, + tickets: [], + summary: null, + scorecardTenant: null, + scorecardDomain: null, + accountLoaded: false, + overviewModal: { tenantId: null, view: 'list', domain: null, data: null, focus: 'onboard' }, + overviewHomeWindow: '24h', + overviewHomeTrailFilter: 'all', + overviewHomeDnsDomain: null, + adminUsers: [], + adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' }, + adminSelected: null, + socWindow: '24h', + socLastEventId: null, + openPanelTestRunning: false, + features: { access_matrix_ui: false, access_matrix_edit: false, access_matrix_crud: false }, + adminPanel: 'team', +}; + +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'), + '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'), + account: document.getElementById('view-account'), + leads: document.getElementById('view-leads'), + modules: document.getElementById('view-modules'), + 'access-matrix': document.getElementById('view-access-matrix'), +}; + +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; + const name = user.display_name || user.username; + const initials = String(name) + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() || '') + .join('') || '?'; + header.innerHTML = ` + + + ${esc(name)} + ${esc(label)} + `; + } + if (logoutBtn) { + logoutBtn.hidden = false; + logoutBtn.onclick = logout; + } +} + +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 CONSOLE_BASE_URL */ + } + 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('sso')) 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 loadDeskFeatures() { + try { + const data = await api('/v1/config/features'); + state.features = { + access_matrix_ui: !!data.access_matrix_ui, + access_matrix_edit: !!data.access_matrix_edit, + access_matrix_crud: !!data.access_matrix_crud, + access_matrix_groups_sync: !!data.access_matrix_groups_sync, + }; + } catch { + state.features = { access_matrix_ui: false, access_matrix_edit: false, access_matrix_crud: false }; + } +} + +function applyAccessMatrixNav() { + const user = getUser(); + const nav = document.getElementById('nav-access-matrix'); + if (!nav || !user) return; + if (state.features?.access_matrix_ui && user.role === 'super_admin') { + nav.removeAttribute('hidden'); + } else { + nav.setAttribute('hidden', ''); + } +} + +/** Spec 019 — Tenants integrado no Console; menu oculto no Desk (catálogo versionado). */ +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 = DeskModules.isViewEnabled('agentic-ops') ? 'agentic-ops' : 'dashboard'; + if (!requested || requested === 'tenants') { + if (requested === 'tenants') { + window.open(`${CONSOLE_BASE_URL}/#infra-nodes`, '_blank', 'noopener'); + } + return fallback; + } + if (DeskModules.isViewEnabled(requested)) return requested; + return fallback; +} + +function renderAdminTabs() { + if (!state.features?.access_matrix_ui || getUser()?.role !== 'super_admin') return ''; + const teamActive = state.adminPanel !== 'matrix'; + return ` + `; +} + +function adminTabsInContent() { + return document.querySelector('.shell--v2') ? '' : renderAdminTabs(); +} + +function bindAdminTabs() { + document.querySelectorAll('[data-admin-tab]').forEach((btn) => { + btn.onclick = () => { + state.adminPanel = btn.dataset.adminTab === 'matrix' ? 'matrix' : 'team'; + renderAdmin(); + window.DeskTopnav?.updateContextSidebar?.('admin'); + }; + }); +} + +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') { + const navEvents = document.getElementById('nav-events'); + if (navEvents) navEvents.textContent = 'Wazuh'; + } + if (canManageUsers()) { + document.getElementById('nav-messages')?.removeAttribute('hidden'); + document.getElementById('nav-admin')?.removeAttribute('hidden'); + } + if (user.role === 'super_admin') { + document.getElementById('nav-modules')?.removeAttribute('hidden'); + } + if (canReadLeads()) { + document.getElementById('nav-leads')?.removeAttribute('hidden'); + document.getElementById('filter-leads')?.removeAttribute('hidden'); + } + if (typeof canManageVm112Domains === 'function' && canManageVm112Domains()) { + document.getElementById('events-tab-purges')?.removeAttribute('hidden'); + } + if (canRunAudit()) { + document.getElementById('events-tab-security')?.removeAttribute('hidden'); + } else { + document.getElementById('events-tab-security')?.setAttribute('hidden', ''); + } + if (canReadTickets()) { + document.getElementById('events-tab-carbonio')?.removeAttribute('hidden'); + } +} + +function setView(name) { + if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) { + name = 'dashboard'; + } + if (state.view === 'account' && name !== 'account') { + state.accountLoaded = false; + } + state.view = name; + if (name === 'admin') state.adminPanel = 'team'; + const titles = { + dashboard: 'Dashboard', + overview: 'Audit Overview', + 'overview-home': 'Serviços', + tickets: 'Tickets', + events: 'Eventos webhook', + infra: 'Infraestrutura', + infra2: 'SOC — Infra 2', + 'agentic-ops': 'Agentic Ops', + messages: 'Mensagens — pedidos de cadastro', + admin: 'Administradores', + 'access-matrix': 'Matriz de Acesso', + account: 'Minha conta', + leads: 'Leads abandonados', + modules: 'Módulos', + }; + const subtitles = { + dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento', + overview: 'Visão por tenant — cards de auditoria (versão clássica)', + 'overview-home': 'Desk VM122 · Orquestração MOSP', + tickets: 'Operações Ligbox — onboarding, tickets e monitoramento', + events: 'Operações Ligbox — onboarding, tickets e monitoramento', + infra: 'VM112, VM104 e integrações — visão técnica', + infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real', + 'agentic-ops': 'Squad IA A0–A7 — findings, incidentes, chat e runbooks (Spec 029)', + messages: 'Operações Ligbox — onboarding, tickets e monitoramento', + admin: 'Operações Ligbox — onboarding, tickets e monitoramento', + 'access-matrix': 'Spec 027 — funções × módulos Desk · VM112 · VM123 · agentes', + 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('[data-desk-nav]').forEach((b) => { + b.classList.toggle('active', b.dataset.view === name); + }); + window.DeskTopnav?.updateTopnavActive?.(name); + window.DeskTopnav?.updateContextSidebar?.(name); + Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name)); + reschedulePoll(); + refresh(); +} + +let pollTimer = null; +function reschedulePoll() { + if (pollTimer) clearInterval(pollTimer); + if (state.openPanelTestRunning) return; + const ms = state.view === 'infra2' || state.view === 'agentic-ops' ? 15000 : 30000; + pollTimer = setInterval(() => refresh({ poll: true }), ms); +} + +async function loadHealth() { + const el = document.getElementById('global-health'); + try { + const h = await api('/health'); + el.className = 'status-pill ok'; + el.innerHTML = ' API online'; + return h; + } catch { + el.className = 'status-pill err'; + el.innerHTML = ' API offline'; + return null; + } +} + +async function renderDashboard() { + const box = document.getElementById('dashboard-content'); + box.innerHTML = '

    Carregando…

    '; + try { + const leadsPromise = canReadLeads() + ? api('/v1/crm/leads').catch(() => ({ leads: [], total: 0 })) + : Promise.resolve({ leads: [], total: 0 }); + const rankingPromise = canAssist() + ? api('/v1/assist/technicians/ranking?window_days=30').catch(() => ({ ranking: [] })) + : Promise.resolve({ ranking: [] }); + const [summary, funnel, audit, vm112, wazuh, leadsData, techRanking] = await Promise.all([ + api('/v1/desk/summary').catch((e) => { + throw new Error(`Resumo indisponível: ${e.message}`); + }), + api('/v1/onboard/funnel').catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })), + canRunAudit() ? api('/v1/audit/overview').catch(() => ({ tenants: [] })) : Promise.resolve({ tenants: [] }), + api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })), + api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })), + leadsPromise, + rankingPromise, + ]); + state.summary = summary; + const vmOk = vm112.vm112?.status === 'ok'; + const wazuhOk = wazuh.api_online === true || wazuh.http_status === 401 || wazuh.http_status === 200; + const sessions = funnel.active_sessions || []; + const sessionCards = sessions.slice(0, 24).map((s) => { + const status = s.assist_status || 'observing'; + const statusCls = status === 'assisting' ? 'assisting' : status === 'escalated' ? 'escalated' : 'observing'; + return ` + `; + }).join(''); + box.innerHTML = ` +
    +
    + ${kpiCard('Abertos', summary.tickets_open, 'open')} + ${kpiCard('Assistindo', summary.tickets_assisting ?? 0, 'assisting')} + ${kpiCard('Escalados', summary.tickets_escalated ?? 0, 'escalated')} + ${kpiCard('Sessões', funnel.sessions_total || 0, 'sessions', { title: 'Sessões onboarding — 48h' })} + ${window.DeskModules?.isEnabled('billing-recurrence') ? kpiCard('Cobrança pendente', summary.billing_pending ?? 0, 'billing-pending', { title: 'Aguardam validação OPS' }) : ''} + ${window.DeskModules?.isEnabled('billing-recurrence') ? kpiCard('Recorrência ativa', summary.billing_active ?? 0, 'billing-active', { title: 'Clientes com recorrência' }) : ''} + ${canReadLeads() ? kpiCard('Leads', summary.leads_abandoned ?? leadsData.total ?? 0, 'leads', { clickable: true, viewJump: 'leads', title: 'Onboarding abandonado' }) : ''} +
    + ${dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk })} +
    +
    +
    +

    Funil 48h

    + ${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)} +
    +
    +
    +

    Sessões ativas

    +
    + Assistindo + Observando + ${sessions.length} total +
    +
    + ${sessionCards + ? `
    ${sessionCards}
    ` + : '

    Sem sessões recentes

    '} +
    + ${canReadLeads() ? ` +
    +
    +

    Leads abandonados

    + +
    + ${(leadsData.leads || []).slice(0, 6).map(leadRowHtml).join('') || '

    Nenhum lead — sessões stale viram lead após ${summary.onboard_stale_hours ?? 24}h

    '} +
    ` : ''} +
    +

    Tickets recentes

    +
    + ${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '

    Sem tickets

    '} +
    +
    +
    + ${canAssist() && (techRanking.ranking || []).length ? ` +
    +
    +

    Ranking técnicos

    + 30d · assumidos / movimento +
    + ${techRankingHtml(techRanking.ranking)} +
    ` : ''}`; + box.querySelectorAll('.ticket-row').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedTicketId = Number(btn.dataset.id); + setView('tickets'); + }); + }); + box.querySelectorAll('[data-session]').forEach((btn) => { + btn.addEventListener('click', () => { + const sess = sessions.find((s) => s.session_id === btn.dataset.session); + state.selectedSessionId = btn.dataset.session; + state.selectedTicketId = sess?.ticket_id || null; + setView('tickets'); + }); + }); + box.querySelectorAll('[data-view-jump="leads"]').forEach((el) => { + el.addEventListener('click', () => setView('leads')); + }); + box.querySelectorAll('[data-lead-ticket]').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedTicketId = Number(btn.dataset.leadTicket); + state.selectedSessionId = btn.dataset.leadSession || null; + setView('tickets'); + }); + }); + } catch (e) { + box.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +function sourceBadge(src) { + if (src === 'desk-registration') return 'desk'; + if (src === 'wazuh') return 'wazuh'; + if (src === 'vm112-onboard') return 'onboard'; + return src ? `${esc(src)}` : ''; +} + +function severityBadge(level) { + if (level == null) return ''; + const n = Number(level); + let cls = 'sev-low'; + if (n >= 12) cls = 'sev-critical'; + else if (n >= 10) cls = 'sev-high'; + else if (n >= 7) cls = 'sev-med'; + return `L${n}`; +} + +const FUNNEL_LABELS = { + started: 'Iniciado', + domain_validated: 'Domínio OK', + dns_applied: 'DNS aplicado', + account_created: 'Conta criada', + infra_synced: 'Infra sync', + completed: 'Concluído', + failed: 'Falhou', +}; + +function funnelBarHtml(stages, total) { + const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed', 'failed']; + const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0)); + return order + .filter((k) => k !== 'failed' || (stages.failed || 0) > 0) + .map((key) => { + const n = stages[key] || 0; + const pct = max ? Math.round((n / max) * 100) : 0; + return ` +
    + ${FUNNEL_LABELS[key] || key} +
    + ${n} +
    `; + }) + .join(''); +} + +function eventTypeLabel(ev) { + const key = ev?.event_type || ev?.event; + return SOC_EVENT_LABELS[key] || key || '—'; +} + +let _liveTimingTimer = null; + +function formatDurationSec(seconds) { + if (seconds == null || Number.isNaN(seconds)) return '—'; + const sec = Math.max(0, Math.round(Number(seconds))); + if (sec < 60) return `${sec}s`; + const mins = Math.floor(sec / 60); + const rem = sec % 60; + if (mins < 60) return `${mins}m ${rem}s`; + const hrs = Math.floor(mins / 60); + const m2 = mins % 60; + if (hrs < 48) return `${hrs}h ${m2}m`; + const days = Math.floor(hrs / 24); + const h2 = hrs % 24; + return `${days}d ${h2}h`; +} + +function stopLiveTimingClock() { + if (_liveTimingTimer) { + clearInterval(_liveTimingTimer); + _liveTimingTimer = null; + } +} + +function bindLiveTimingClock(root = document) { + stopLiveTimingClock(); + const card = root.querySelector?.('[data-timing-live-card]'); + if (!card || card.dataset.timingCompleted === 'true') return; + const startedAt = card.dataset.timingStartedAt; + const lastAt = card.dataset.timingLastAt || startedAt; + if (!startedAt) return; + const totalEl = card.querySelector('[data-timing-live="total"]'); + const idleEl = card.querySelector('[data-timing-live="idle"]'); + const accEl = card.querySelector('[data-timing-live="accumulated"]'); + const tick = () => { + const now = Date.now(); + const startMs = new Date(startedAt).getTime(); + const lastMs = new Date(lastAt).getTime(); + if (!Number.isNaN(startMs) && totalEl) { + totalEl.textContent = formatDurationSec((now - startMs) / 1000); + } + if (!Number.isNaN(lastMs) && idleEl) { + idleEl.textContent = formatDurationSec((now - lastMs) / 1000); + } + if (!Number.isNaN(startMs) && accEl) { + accEl.textContent = `Σ ${formatDurationSec((now - startMs) / 1000)}`; + } + }; + tick(); + _liveTimingTimer = setInterval(tick, 1000); +} + +function phaseTimingCardHtml(timing, events) { + if (!timing || !window.DeskModules?.isEnabled('funnel-timing') || !events?.length) return ''; + const statusBadge = timing.is_completed + ? 'concluído' + : `em curso`; + const lastEv = events[events.length - 1]; + const rows = events.map((ev, idx) => { + const prev = idx > 0 ? (ev.duration_from_prev_label || '—') : '—'; + const isLastLive = !timing.is_completed && idx === events.length - 1; + const total = isLastLive + ? `Σ ${esc(timing.total_duration_label)}` + : `Σ ${esc(ev.duration_from_start_label || '—')}`; + return ` + + ${esc(eventTypeLabel(ev))} + ${fmtDate(ev.created_at || ev.at)} + ${idx > 0 ? `+${esc(prev)}` : '—'} + ${total} + `; + }).join(''); + return ` +
    +
    +
    +

    Relógio por fase

    +

    Duração entre etapas do onboarding VM112

    +
    + ${statusBadge} +
    +
    +
    + Tempo total + ${esc(timing.total_duration_label)} +
    +
    + Início + ${fmtDate(timing.started_at)} +
    + ${timing.is_completed ? ` +
    + Concluído + ${fmtDate(timing.completed_at)} +
    ` : ` +
    + Parado há + ${esc(timing.idle_since_label || '—')} +
    `} +
    +
    + + + ${rows} +
    FaseRegistadoΔ faseAcumulado
    +
    +
    `; +} + +function timingSummaryHtml(timing) { + if (!timing || !window.DeskModules?.isEnabled('funnel-timing')) return ''; + const idle = timing.is_completed ? '' : `Parado há ${esc(timing.idle_since_label)}`; + return ` +
    + Total ${esc(timing.total_duration_label)} + ${idle} + ${timing.completed_at ? `Concluído ${fmtDate(timing.completed_at)}` : ''} +
    `; +} + +function timelineHtml(events, timingMeta, opts = {}) { + if (!events?.length) return ''; + const showTiming = !opts.compact && window.DeskModules?.isEnabled('funnel-timing'); + return `${!opts.compact ? timingSummaryHtml(timingMeta) : ''}
      ${events + .map( + (e, idx) => { + const evt = e.event_type || e.event || '—'; + const at = e.created_at || e.at; + const prevDur = showTiming && idx > 0 && e.duration_from_prev_label && e.duration_from_prev_label !== '—' + ? `+${esc(e.duration_from_prev_label)}` + : ''; + const fromStart = showTiming && e.duration_from_start_label + ? `Σ ${esc(e.duration_from_start_label)}` + : ''; + return ` +
    1. + +
      + ${esc(evt)} + ${e.stage ? `${esc(e.stage)}` : ''} + ${prevDur}${fromStart} +
      ${fmtDate(at)}
      +
      +
    2. `; + } + ) + .join('')}
    `; +} + +function healthBadge(status) { + const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' }; + const cls = map[status] || 'open'; + return `${esc(status || 'unknown')}`; +} + +function checkStatusBadge(status) { + const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open'; + return `${esc(status)}`; +} + +function leadRowHtml(l) { + return ` + `; +} + +function billingTicketIcon(t) { + if ((t.subject || '').includes('[billing-validation]') || t.billing_state) return ' 💳'; + return ''; +} + +function 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) { + try { + return await api( + `/v1/dns/cloudflare/records?domain=${encodeURIComponent(domain)}&email_service=${emailService ? 'true' : 'false'}` + ); + } catch (e) { + return { + domain, + records: [], + email_records: [], + summary: { total: 0, email_related: 0 }, + error: e.message || 'Falha ao carregar DNS Cloudflare', + }; + } +} + +function isEmailServiceDomain(tenantId, funnelStage) { + return tenantId === 1 || ['dns_applied', 'account_created', 'infra_synced', 'completed', 'company_validated', 'webmail_released'].includes(funnelStage); +} + +async function showOverviewHomeDnsPanel(domain, tenantId, funnelStage, domainMeta = null) { + const panel = document.getElementById('cf-dns-panel-body'); + const label = document.getElementById('cf-dns-domain-label'); + if (!panel) return; + state.overviewHomeDnsDomain = domain; + if (label) label.textContent = domain; + panel.innerHTML = `

    Carregando detalhes de ${esc(domain)}

    `; + + let timing = domainMeta?.timing; + let timeline = domainMeta?.timeline; + if (window.DeskModules?.isEnabled('funnel-timing') && (!timing || !timeline?.length) && tenantId) { + try { + const details = await api(`/v1/audit/tenants/${tenantId}/details`); + const match = (details.domains || []).find((item) => item.domain === domain); + timing = match?.timing || timing; + timeline = match?.timeline || timeline; + } catch { + /* mantém o que tiver */ + } + } + + const timingCard = phaseTimingCardHtml(timing, timeline); + const dns = await fetchCloudflareDns(domain, isEmailServiceDomain(tenantId, funnelStage)); + panel.innerHTML = `${timingCard}${htmlCloudflareDnsCardInline(dns)}`; +} + +function htmlCloudflareDnsCardInline(dns) { + if (!dns) { + return '

    Dados DNS indisponíveis.

    '; + } + if (dns.error && !dns.records?.length) { + return ` +

    ${esc(dns.error)}

    + ${dns.email_service ? '

    Serviço: servidor de e-mail (onboarding)

    ' : ''}`; + } + const rows = (dns.records || []).map((r) => ` + + ${esc(dnsPurposeLabel(r.purpose))} + ${esc(r.name)} + ${esc(r.type)} + ${esc(r.content)} + `).join(''); + const summary = dns.summary || {}; + const zone = dns.zone || {}; + return ` +
    +
    + ${summary.total || 0} + registos na zona +
    +
    + ${summary.email_related || 0} + para e-mail +
    + ${dns.email_service ? 'E-mail' : 'DNS'} +
    +

    Zona ${esc(zone.name || '—')}${dns.error ? ` · ${esc(dns.error)}` : ''}

    +
    + + + ${rows || ''} +
    FunçãoNomeTipoConteúdo
    Sem registos para este domínio.
    +
    `; +} + +function htmlCloudflareDnsCard(dns) { + if (!dns) { + return ` + `; + } + if (dns.error && !dns.records?.length) { + return ` + `; + } + const rows = (dns.records || []).map((r) => ` + + ${esc(dnsPurposeLabel(r.purpose))} + ${esc(r.name)} + ${esc(r.type)}${r.priority != null ? ` prio ${r.priority}` : ''} + ${esc(r.content)} + ${r.proxied ? 'proxy' : 'DNS only'} · TTL ${r.ttl ?? '—'} + `).join(''); + const summary = dns.summary || {}; + const zone = dns.zone || {}; + return ` + `; +} + +function executionStatusBadge(status) { + const map = { + in_progress: ['assisting', 'em execução'], + completed: ['ok', 'concluído'], + failed: ['escalated', 'falhou'], + registered: ['open', 'registado'], + }; + const [cls, label] = map[status] || ['open', status || '—']; + return `${esc(label)}`; +} + +function bindOverviewModal() { + document.querySelectorAll('[data-close-overview-modal]').forEach((el) => { + el.addEventListener('click', closeOverviewModal); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeOverviewModal(); + }); +} + +function closeOverviewModal() { + const modal = document.getElementById('overview-modal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); + state.overviewModal = { tenantId: null, view: 'list', domain: null, data: null, focus: 'onboard' }; +} + +function renderWazuhOverviewCard(t) { + const issues = (t.top_issues || []) + .slice(0, 3) + .map((i) => `
  • ${esc(i.domain)} · ${esc(i.check_id)} — ${esc(i.message || i.status)}
  • `) + .join(''); + const apiLabel = t.api_online ? `API online (${t.http_status || '—'})` : 'API offline'; + return ` + `; +} + +function renderWazuhSocModal(data) { + const body = document.getElementById('overview-modal-body'); + const title = document.getElementById('overview-modal-title'); + const sub = document.getElementById('overview-modal-sub'); + if (!body || !title || !sub) return; + const s = data.summary || {}; + title.textContent = data.name || 'Wazuh SOC'; + sub.textContent = `${data.ip || '—'} · API ${s.api_online ? 'online' : 'offline'} · gerado ${fmtDate(data.generated_at)}`; + + const agentRows = (data.agents || []).map((a) => ` + + ${esc(a.agent)} + ${esc(a.agent_ip || '—')} + ${a.alerts_count} + L${a.max_level} + ${relativeTimeAgo(a.last_seen)} + `).join(''); + + const alertRows = (data.alerts || []).slice(0, 40).map((a) => ` + + ${severityBadge(a.level)} + ${esc(a.agent)} + ${esc(a.description || '—')} + ${esc(a.srcip || '—')} + ${esc(a.agent_ip || '—')} + ${relativeTimeAgo(a.created_at)} + `).join(''); + + const ticketRows = (data.tickets || []).slice(0, 15).map((t) => ` + `).join(''); + + body.innerHTML = ` +
    +
    ${s.alerts_total || 0}Alertas
    +
    ${s.alerts_24h || 0}24h
    +
    ${s.agents_total || 0}Agentes
    +
    ${s.level_10_plus || 0}L≥${data.min_ticket_level || 10}
    +
    ${s.open_tickets || 0}Tickets
    +
    +

    + Monitorização de segurança VM104 — webhooks wazuh.alert com nível ≥ ${data.min_ticket_level || 10} geram ticket na VM122. +

    +
    + + +
    + ${ticketRows ? ` + ` : ''}`; + + body.querySelectorAll('[data-open-ticket]').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedTicketId = Number(btn.dataset.openTicket); + closeOverviewModal(); + setView('tickets'); + }); + }); +} + +function wizardSecuritySeverityBadge(sev) { + const map = { high: ['escalated', 'Alto'], critical: ['escalated', 'Crítico'], warn: ['review', 'Atenção'], info: ['open', 'Info'] }; + const [cls, label] = map[sev] || ['open', sev || '—']; + return `${esc(label)}`; +} + +function wizardSecurityEventLabel(ev) { + return SECURITY_EVENT_LABELS[ev] || SOC_EVENT_LABELS[ev] || ev || '—'; +} + +const SECURITY_EVENT_LABELS = { + 'security.csp_violation': 'Violação CSP', + 'security.input_warn': 'Input suspeito', + 'security.input_blocked': 'Input bloqueado', + 'security.rate_limited': 'Rate limit', + 'security.handoff_created': 'Handoff criado', + 'security.handoff_consumed': 'Handoff consumido', + 'security.handoff_rejected': 'Handoff rejeitado', + 'security.handoff_expired': 'Handoff expirado', + 'security.auth_failed': 'Auth portal falhou', + 'security.session_anomaly': 'Anomalia sessão', +}; + +const WIZARD_SEC_COLORS = { + teal: '#0d9488', + tealLight: '#14b8a6', + orange: '#ea580c', + orangeLight: '#f97316', + severe: '#7f1d1d', + high: '#dc2626', + elevated: '#ea580c', + guarded: '#eab308', + low: '#22c55e', + na: '#94a3b8', + csp: '#0891b2', + input: '#dc2626', + handoff: '#ea580c', + auth: '#7c3aed', + rate: '#64748b', +}; + +function wizardSecRiskScore(severity, eventType) { + if (severity === 'critical') return 5; + if (severity === 'high' || (eventType || '').includes('blocked') || (eventType || '').includes('rejected')) return 4; + if (severity === 'warn' || (eventType || '').includes('csp')) return 3; + if (severity === 'info') return 2; + return 1; +} + +function wizardSecRiskCell(score) { + const map = { + 5: ['Severo', WIZARD_SEC_COLORS.severe], + 4: ['Alto', WIZARD_SEC_COLORS.high], + 3: ['Elevado', WIZARD_SEC_COLORS.elevated], + 2: ['Vigiado', WIZARD_SEC_COLORS.guarded], + 1: ['Baixo', WIZARD_SEC_COLORS.low], + 0: ['N/A', WIZARD_SEC_COLORS.na], + }; + const [label, bg] = map[score] || map[0]; + return `${esc(label)}`; +} + +function wizardSecDonutSvg(segments, size = 130) { + const filtered = segments.filter((s) => s.value > 0); + const total = filtered.reduce((a, s) => a + s.value, 0) || 1; + const r = 42; + const cx = size / 2; + const cy = size / 2; + const circ = 2 * Math.PI * r; + let offset = 0; + const arcs = filtered.map((s) => { + const len = (s.value / total) * circ; + const el = ``; + offset += len; + return el; + }).join(''); + return ``; +} + +function wizardSecVBarSvg(items, width = 260, height = 120) { + if (!items.length) return '

    Sem dados

    '; + const max = Math.max(...items.map((i) => i.value), 1); + const gap = 10; + const barW = Math.max(18, (width - gap * (items.length + 1)) / items.length); + const bars = items.map((item, i) => { + const bh = Math.max(2, (item.value / max) * (height - 36)); + const x = gap + i * (barW + gap); + const y = height - 24 - bh; + return ` + + ${esc(item.short || item.label)} + ${item.value}`; + }).join(''); + return ``; +} + +function wizardSecHBarHtml(items) { + if (!items.length) return '

    Sem dados

    '; + const max = Math.max(...items.map((i) => i.value), 1); + return items.map((item) => ` +
    + ${esc(item.label)} +
    + ${item.value} +
    `).join(''); +} + +function wizardSecVectorBucket(eventType) { + const ev = eventType || ''; + if (ev.includes('csp')) return 'csp'; + if (ev.includes('input') || ev.includes('rate')) return 'input'; + if (ev.includes('handoff')) return 'handoff'; + if (ev.includes('auth') || ev.includes('session')) return 'auth'; + return 'outro'; +} + +function wizardSecAccessStatus(s) { + if ((s.inputs_blocked || 0) + (s.handoffs_rejected || 0) > 0) return 'critical'; + if ((s.total || 0) > 0) return 'degraded'; + return 'healthy'; +} + +function renderUserAccessOverviewCard(sec) { + if (!window.DeskModules?.isEnabled('wizard-security')) return ''; + const s = sec || { total: 0, inputs_blocked: 0, handoffs_rejected: 0, csp_violations: 0, sessions_with_alerts: 0, recent: [] }; + const status = wizardSecAccessStatus(s); + const issues = (s.recent || []).slice(0, 3).map((ev) => + `
  • ${esc((ev.client_ip || '—'))} · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : 'sem sessão'}
  • ` + ).join(''); + return ` + `; +} + +function renderWizardSecurityCard(sec, opts = {}) { + const standalone = opts.standalone === true; + if (!window.DeskModules?.isEnabled('wizard-security')) return ''; + const s = sec || { total: 0, csp_violations: 0, inputs_blocked: 0, handoffs_rejected: 0, sessions_with_alerts: 0, recent: [], by_event: {} }; + const byEvent = s.by_event || {}; + const recent = s.recent || []; + + const severityCounts = { high: 0, warn: 0, info: 0 }; + recent.forEach((ev) => { + const sev = ev.severity || 'info'; + if (sev === 'high' || sev === 'critical') severityCounts.high += 1; + else if (sev === 'warn') severityCounts.warn += 1; + else severityCounts.info += 1; + }); + + const eventBars = Object.entries(byEvent).map(([ev, count]) => ({ + label: wizardSecurityEventLabel(ev), + short: wizardSecurityEventLabel(ev).split(' ')[0], + value: count, + color: ev.includes('blocked') || ev.includes('rejected') ? WIZARD_SEC_COLORS.high + : ev.includes('csp') ? WIZARD_SEC_COLORS.csp + : ev.includes('handoff') ? WIZARD_SEC_COLORS.handoff + : WIZARD_SEC_COLORS.teal, + })).slice(0, 6); + + const vectors = { csp: 0, input: 0, handoff: 0, auth: 0 }; + Object.entries(byEvent).forEach(([ev, count]) => { + const bucket = wizardSecVectorBucket(ev); + if (vectors[bucket] != null) vectors[bucket] += count; + }); + const vectorBars = [ + { label: 'CSP Browser', value: vectors.csp, color: WIZARD_SEC_COLORS.csp }, + { label: 'Input audit', value: vectors.input, color: WIZARD_SEC_COLORS.input }, + { label: 'Handoff', value: vectors.handoff, color: WIZARD_SEC_COLORS.handoff }, + { label: 'Auth/Sessão', value: vectors.auth, color: WIZARD_SEC_COLORS.auth }, + ]; + + const ipMap = {}; + recent.forEach((ev) => { + const ip = ev.client_ip || '—'; + ipMap[ip] = (ipMap[ip] || 0) + 1; + }); + const topIps = Object.entries(ipMap).sort((a, b) => b[1] - a[1]).slice(0, 5); + + const riskBars = [ + { label: 'Input bloqueado', value: s.inputs_blocked || 0, color: WIZARD_SEC_COLORS.high }, + { label: 'Handoff rejeitado', value: s.handoffs_rejected || 0, color: WIZARD_SEC_COLORS.orange }, + { label: 'Violação CSP', value: s.csp_violations || 0, color: WIZARD_SEC_COLORS.csp }, + { label: 'Input suspeito', value: s.inputs_warn || 0, color: WIZARD_SEC_COLORS.guarded }, + { label: 'Rate limit', value: s.rate_limited || 0, color: WIZARD_SEC_COLORS.na }, + ]; + + const threatRows = recent.slice(0, 8).map((ev) => { + const score = wizardSecRiskScore(ev.severity, ev.event_type); + return ` + + ${esc(wizardSecurityEventLabel(ev.event_type))} + ${wizardSecRiskCell(score)} + ${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : '—'} + ${esc(ev.client_ip || '—')} + ${fmtDate(ev.created_at)} + `; + }).join(''); + + const accessStatus = wizardSecAccessStatus(s); + const issueLines = recent.slice(0, 3).map((ev) => + `
  • ${esc(ev.client_ip || '—')} · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? `${esc(ev.session_id.slice(0, 14))}…` : 'sem sessão'}
  • ` + ).join(''); + + const dashboardGrid = ` +
    +
    +
    Eventos nas últimas 24h
    +
    ${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}
    +
    +
    +
    Risco actual
    +
    + ${wizardSecDonutSvg([ + { value: severityCounts.high, color: WIZARD_SEC_COLORS.high }, + { value: severityCounts.warn, color: WIZARD_SEC_COLORS.elevated }, + { value: severityCounts.info, color: WIZARD_SEC_COLORS.low }, + ])} +
      +
    • Alto (${severityCounts.high})
    • +
    • Elevado (${severityCounts.warn})
    • +
    • Baixo (${severityCounts.info})
    • +
    +
    +
    +
    +
    Ameaças por vetor
    +
    ${wizardSecVBarSvg(vectorBars)}
    +
    +
    +
    IPs com atividade
    +
    + ${topIps.length ? topIps.map(([ip, n], i) => ` +
    + ${i + 1} + ${esc(ip)} + ${n} evt +
    `).join('') : '

    Nenhum IP registado

    '} +
    +
    +
    +
    Risco por categoria
    +
    ${wizardSecHBarHtml(riskBars)}
    +
    +
    +
    Relatório de ameaças
    +
    + + + ${threatRows || ''} +
    AmeaçaNívelSessãoIPHora
    Sem ameaças nas últimas 24h
    +
    +
    +
    `; + + return ` +
    +
    + Área independente +

    Acesso de usuário — Cibersegurança

    +

    Eventos gerados quando alguém acede ao portal público, preenche formulários ou faz login (handoff). Isto é segurança de acesso — não mede DNS, Carbonio, certificados nem progresso do wizard VM112.

    +
    + +
    +
    +
    +

    Threat tracking — portal & sessões

    +

    Browser · CSP · inputs · handoff · Spec 021

    +
    + ${healthBadge(accessStatus)} +
    +
    ${s.total || 0} alerta(s) · ${s.inputs_blocked || 0} bloq · ${s.handoffs_rejected || 0} handoff · ${s.csp_violations || 0} CSP · ${s.sessions_with_alerts || 0} sessões
    +

    Janela ${s.window_hours || 24}h · origem vm112-security

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

    Nenhum incidente de acesso nas últimas 24h

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

    Este painel cobre apenas o comportamento de quem acede ao sistema — visitantes, clientes no portal e tentativas de abuso em formulários públicos.

    +
      +
    • CSP (browser) — scripts ou recursos bloqueados no navegador do usuário
    • +
    • Input audit — padrões SQL/XSS em campos enviados pelo usuário
    • +
    • Handoff — token de login expirado, reutilizado ou inválido
    • +
    • Auth / sessão — falhas de autenticação ou anomalias de sessão
    • +
    +

    ${standalone + ? 'Domínios, DNS e Carbonio estão no card VM112 Ligbox Onboard — área separada.' + : '≠ Saúde do wizard VM112 (domínios, e-mail, certificados) — ver secção Onboard abaixo.'}

    +
    +
    +
    +
    Como proceder — guia técnico
    +
    +
      +
    1. Input bloqueado / CSP — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.
    2. +
    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(' · ')}

    ` : ''} + ${htmlCloudflareDnsCard(dnsData)} + + + ${d.ticket_id ? `
    ` : ''}`; + body.querySelector('[data-overview-back]')?.addEventListener('click', () => renderOverviewModalList(data)); + body.querySelector('[data-open-ticket]')?.addEventListener('click', (btn) => { + state.selectedTicketId = Number(btn.target.dataset.openTicket); + closeOverviewModal(); + setView('tickets'); + }); +} + +async function openOverviewModal(tenantId, options = {}) { + const modal = document.getElementById('overview-modal'); + const body = document.getElementById('overview-modal-body'); + if (!modal || !body) return; + const focus = options.focus || 'onboard'; + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + body.innerHTML = '

    Carregando detalhes…

    '; + try { + const data = await api(`/v1/audit/tenants/${tenantId}/details`); + state.overviewModal = { tenantId, view: 'list', domain: null, data, focus }; + renderOverviewModalList(data); + } catch (e) { + console.error('openOverviewModal', e); + body.innerHTML = ` +

    Erro: ${esc(e.message)}

    + `; + body.querySelector('[data-retry-overview-modal]')?.addEventListener('click', () => { + openOverviewModal(tenantId, { focus }); + }); + } +} + +async function openUserAccessModal() { + const modal = document.getElementById('overview-modal'); + const body = document.getElementById('overview-modal-body'); + const title = document.getElementById('overview-modal-title'); + const sub = document.getElementById('overview-modal-sub'); + if (!modal || !body) return; + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + body.innerHTML = '

    Carregando segurança de acesso…

    '; + try { + const sec = await api('/v1/security/summary?window_hours=24'); + const generatedAt = new Date().toISOString(); + const data = { + tenant_id: 1, + name: 'Acesso Usuário — Cybersecurity', + generated_at: generatedAt, + security: sec, + }; + state.overviewModal = { tenantId: 1, view: 'list', domain: null, data, focus: 'access' }; + if (title) title.textContent = 'Acesso Usuário — Cybersecurity'; + if (sub) { + sub.textContent = `Portal & sessões · ${sec.total || 0} alerta(s) 24h · ${sec.sessions_with_alerts || 0} sessão(ões) · gerado ${fmtDate(generatedAt)}`; + } + body.innerHTML = renderWizardSecurityCard(sec, { standalone: true }); + bindWizardSecurityCard(body); + } catch (e) { + console.error('openUserAccessModal', e); + body.innerHTML = ` +

    Erro ao carregar segurança de acesso: ${esc(e.message)}

    +

    Verifique ligação ao Desk e permissões de audit.

    + `; + body.querySelector('[data-retry-user-access]')?.addEventListener('click', () => openUserAccessModal()); + } +} + +async function renderOverview() { + const el = document.getElementById('overview-content'); + el.innerHTML = '

    Carregando overview…

    '; + try { + const secPromise = window.DeskModules?.isEnabled('wizard-security') + ? api('/v1/security/summary?window_hours=24').catch(() => null) + : Promise.resolve(null); + const [data, secSummary] = await Promise.all([ + api('/v1/audit/overview'), + secPromise, + ]); + const cards = []; + if (secSummary?.enabled !== false && window.DeskModules?.isEnabled('wizard-security')) { + const accessCard = renderUserAccessOverviewCard(secSummary); + if (accessCard) cards.push(accessCard); + } + (data.tenants || []).forEach((t) => { + if (t.kind === 'wazuh_soc' && window.DeskModules?.isEnabled('wazuh-soc')) { + cards.push(renderWazuhOverviewCard(t)); + return; + } + const issues = (t.top_issues || []) + .slice(0, 3) + .map((i) => `
  • ${esc(i.domain)} · ${esc(i.check_id)} — ${esc(i.message || i.status)}
  • `) + .join(''); + cards.push(` + `); + }); + el.innerHTML = cards.length + ? `
    ${cards.join('')}
    ` + : '

    Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.

    '; + el.querySelectorAll('[data-open-overview]').forEach((btn) => { + btn.addEventListener('click', () => { + openOverviewModal(Number(btn.dataset.openOverview), { focus: 'onboard' }); + }); + }); + el.querySelectorAll('[data-open-user-access]').forEach((btn) => { + btn.addEventListener('click', () => openUserAccessModal()); + }); + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +function overviewHomeWindowHours() { + return { '24h': 24, '7d': 168, '30d': 720 }[state.overviewHomeWindow] || 24; +} + +function isInWindow(iso, hours) { + if (!iso) return false; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return false; + return Date.now() - t <= hours * 3600000; +} + +function relativeTimeAgo(iso) { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 0) return 'agora'; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'agora'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 48) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function sparklineSvg(values, color = '#2f6fed') { + const w = 118; + const h = 34; + const pad = 3; + const data = values?.length ? values : [0, 0, 0, 0, 0, 0]; + const max = Math.max(...data, 1); + const pts = data.map((v, i) => { + const x = pad + (i / Math.max(data.length - 1, 1)) * (w - pad * 2); + const y = h - pad - (v / max) * (h - pad * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ``; +} + +function bucketEvents(events, windowHours, buckets = 12) { + const out = Array(buckets).fill(0); + const now = Date.now(); + const start = now - windowHours * 3600000; + for (const ev of events) { + const t = new Date(ev.at || ev.created_at).getTime(); + if (Number.isNaN(t) || t < start) continue; + const idx = Math.min(buckets - 1, Math.floor(((t - start) / (windowHours * 3600000)) * buckets)); + out[idx] += 1; + } + return out; +} + +function domainStatusDot(status) { + if (status === 'healthy') return 'ok'; + if (status === 'degraded') return 'warn'; + if (status === 'critical') return 'bad'; + return 'unknown'; +} + +function buildOverviewHomeTrail(events, domainsFlat, filter, windowHours) { + const rows = []; + for (const ev of events) { + if (!isInWindow(ev.created_at, windowHours)) continue; + const p = ev.payload || {}; + const source = ev.source || p.source || 'unknown'; + if (filter === 'onboard' && source !== 'vm112-onboard') continue; + if (filter === 'wazuh' && source !== 'wazuh') continue; + if (filter === 'checks') continue; + const trailDomain = ev.domain || p.domain || ''; + const trailDomainMeta = domainsFlat.find((item) => item.domain === trailDomain); + rows.push({ + action: ev.event_type || 'event', + target: trailDomain || p.data?.agent || '—', + at: ev.created_at, + source, + tenant_id: trailDomainMeta?.tenant_id || (source === 'wazuh' ? 2 : 1), + funnel_stage: trailDomainMeta?.funnel_stage || '', + kind: 'webhook', + }); + } + for (const d of domainsFlat) { + for (const issue of d.issues || []) { + if (!isInWindow(issue.checked_at, windowHours)) continue; + if (filter === 'onboard' || filter === 'wazuh') continue; + rows.push({ + action: `check.${issue.status}`, + target: d.domain, + detail: `${issue.check_id} — ${issue.message || issue.status}`, + at: issue.checked_at, + source: 'audit', + tenant_id: d.tenant_id, + funnel_stage: d.funnel_stage || '', + kind: 'check', + domain: d.domain, + }); + } + } + rows.sort((a, b) => new Date(b.at) - new Date(a.at)); + return rows.slice(0, 40); +} + +async function renderOverviewHome(options = {}) { + const el = document.getElementById('overview-home-content'); + if (!el) return; + if (window.DeskServices?.renderPage) { + await window.DeskServices.renderPage(el, options); + return; + } + if (window.DeskAccounts?.renderPage) { + await window.DeskAccounts.renderPage(el, options); + return; + } + el.innerHTML = '

    Módulo Serviços não carregado.

    '; +} + +async function renderLeads() { + const el = document.getElementById('leads-content'); + if (!canReadLeads()) { + el.innerHTML = '

    Sem permissão para ver leads

    '; + return; + } + el.innerHTML = '

    Carregando leads…

    '; + try { + const data = await api('/v1/crm/leads'); + const leads = data.leads || []; + el.innerHTML = ` +
    +
    +

    Leads abandonados

    + Stale ≥ ${data.stale_hours ?? 24}h sem concluir onboarding +
    +

    + Tickets promovidos automaticamente pelo worker quando o cliente para no funil. + Use o e-mail do ticket para recuperação (Spec 012 Fase C — chat). +

    + ${leads.length + ? `
    ${leads.map(leadRowHtml).join('')}
    ` + : '

    Nenhum lead no momento

    '} +
    `; + el.querySelectorAll('[data-lead-ticket]').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedTicketId = Number(btn.dataset.leadTicket); + state.selectedSessionId = btn.dataset.leadSession || null; + setView('tickets'); + }); + }); + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +async function renderTickets() { + stopLiveTimingClock(); + const listEl = document.getElementById('ticket-list'); + const detailEl = document.getElementById('ticket-detail'); + 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, + })); + } else { + let q = ''; + const params = []; + if (state.ticketFilter !== 'all') params.push(`status=${state.ticketFilter}`); + if (state.sourceFilter !== 'all') params.push(`source=${state.sourceFilter}`); + if (params.length) q = '?' + params.join('&'); + const data = await api(`/v1/desk/tickets${q}`); + tickets = data.tickets || []; + } + 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; + detailEl.innerHTML = '

    Carregando…

    '; + try { + const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`); + const sessionId = t.session_id || state.selectedSessionId; + const assistMeta = sessionId && t.source === 'vm112-onboard' + ? await loadAssistMeta(sessionId) + : null; + if (sessionId) state.selectedSessionId = sessionId; + let carbonioBlock = null; + if (t.source === 'vm112-onboard' && window.DeskModules?.isEnabled('carbonio-release')) { + try { + const byTicket = await api(`/v1/carbonio-blocks?ticket_id=${t.id}&status=pending&limit=1`); + carbonioBlock = byTicket.blocks?.[0] || null; + if (!carbonioBlock && sessionId) { + const bySession = await api(`/v1/carbonio-blocks?session_id=${encodeURIComponent(sessionId)}&status=pending&limit=1`); + carbonioBlock = bySession.blocks?.[0] || null; + } + } catch { + carbonioBlock = null; + } + } + const timeline = assistMeta?.timeline?.length + ? assistMeta.timeline + : (t.timeline || t.related_events || []); + const timing = assistMeta?.timing || t.timing; + const closeStatuses = ['open', 'escalated', 'assisting', 'resolved']; + detailEl.innerHTML = ` +
    +
    +

    Ticket #${t.id}

    + ${esc(statusLabel(t.status))} +
    +
    +
    Origem
    ${sourceBadge(t.source)}
    +
    Domínio/Agente
    ${esc(t.domain || t.agent || '—')}
    +
    Email
    ${esc(t.email || '—')}
    +
    Evento
    ${esc(t.event || '—')}
    + ${t.assigned_to ? `
    Atribuído
    ${esc(t.assigned_to)}
    ` : ''} + ${t.assisted_by ? `
    Assistido por
    ${esc(t.assisted_by)}
    ` : ''} + ${t.client_paused ? '
    Cliente
    pausado
    ' : ''} + ${t.ready_for_ops ? '
    Ops
    ready for ops
    ' : ''} + ${t.severity != null ? `
    Severidade
    ${severityBadge(t.severity)}
    ` : ''} + ${t.rule_id ? `
    Regra
    ${esc(t.rule_id)}
    ` : ''} + ${t.description ? `
    Descrição
    ${esc(t.description)}
    ` : ''} + ${t.desk_message ? `
    Nota
    ${esc(t.desk_message)}
    ` : ''} + ${t.registration_role ? `
    Perfil
    ${esc(roleLabel(t.registration_role))}
    ` : ''} + ${t.ativation_url ? `
    Ativar conta
    Abrir link de ativação
    ` : ''} +
    Sessão/Alert ID
    ${esc(t.session_id || '—')}
    +
    Verificado
    ${t.account_verified ? 'Sim' : 'Não'}
    +
    Revisão
    ${t.needs_review ? 'Necessária' : 'Não'}
    +
    Criado
    ${fmtDate(t.created_at)}
    +
    + ${sessionId && t.source === 'vm112-onboard' ? assistActionsHtml(sessionId, { + can_escalate: assistMeta?.can_escalate, + assist_status: assistMeta?.ticket_status || assistMeta?.assist_status, + assisted_by: assistMeta?.assisted_by, + actions: assistMeta?.actions, + }, assistMeta?._console || {}) : ''} + ${carbonioBlock ? carbonioBlockPanelHtml(carbonioBlock) : ''} +
    + ${canPatchTickets() ? (closeStatuses.includes(t.status) + ? `` + : ``) : ''} +
    + ${timeline.length ? `${phaseTimingCardHtml(timing, timeline)}

    Eventos

    ${timelineHtml(timeline, timing, { compact: true })}` : ''} +

    Payload

    +
    ${esc(JSON.stringify(t.payload, null, 2))}
    +
    `; + if (sessionId && t.source === 'vm112-onboard') { + bindAssistActions(detailEl, sessionId); + } + bindCarbonioResolveForms(detailEl); + bindLiveTimingClock(detailEl); + detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed')); + detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open')); + } catch (e) { + detailEl.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +async function updateTicketStatus(status) { + await api(`/v1/desk/tickets/${state.selectedTicketId}`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); + await renderTickets(); +} + +async function renderEvents() { + syncEventsToolbar(); + if (state.eventsTab === 'purges') { + await renderPurgeHistory(); + return; + } + if (state.eventsTab === 'security') { + await renderSecurityEvents(); + return; + } + if (state.eventsTab === 'carbonio') { + await renderCarbonioBlocks(); + return; + } + const el = document.getElementById('events-content'); + el.innerHTML = '

    Carregando eventos…

    '; + try { + const srcQ = state.eventSourceFilter !== 'all' ? `?source=${state.eventSourceFilter}` : ''; + const data = await api(`/v1/webhooks/events${srcQ}`); + const rows = (data.events || []).map((e) => { + const p = e.payload || {}; + const dataObj = p.data || {}; + return ` + ${e.id} + ${sourceBadge(e.source)} + ${esc(e.event_type)} ${severityBadge(dataObj.level || e.severity)} + ${esc(p.domain || '—')} + ${esc((p.session_id || '').slice(0, 16))} + ${fmtDate(e.created_at)} + + `; + }).join(''); + el.innerHTML = ` +
    + + + ${rows || ''} +
    IDOrigemEventoAgente/DomínioRefDataAuditar
    Sem eventos
    +
    `; + bindAuditButtons(el); + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +function syncEventsToolbar() { + const isPurges = state.eventsTab === 'purges'; + const isSecurity = state.eventsTab === 'security'; + const isCarbonio = state.eventsTab === 'carbonio'; + document.querySelectorAll('[data-events-tab]').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.eventsTab === state.eventsTab); + }); + document.querySelectorAll('.events-webhooks-only').forEach((el) => { + el.hidden = isPurges || isSecurity || isCarbonio; + }); + document.querySelectorAll('.events-security-only').forEach((el) => { + el.hidden = !isSecurity; + }); + const title = document.getElementById('page-title'); + const sub = document.getElementById('page-subtitle'); + if (state.view === 'events' && title) { + const titles = { + purges: 'Histórico de purges', + security: 'Eventos de segurança wizard', + carbonio: 'Bloqueios Carbonio', + }; + title.textContent = titles[state.eventsTab] || 'Eventos webhook'; + if (sub) { + const subs = { + purges: 'Purges VM112 persistidos no Desk — timeline, usuário e serviços removidos', + security: 'CSP, inputs bloqueados e handoff — telemetria Spec 021', + carbonio: 'ACCOUNT_EXISTS — remover conta órfã no Carbonio para o cliente repetir o passo', + }; + sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento'; + } + } +} + +function carbonioBlockStatusBadge(status) { + const map = { + pending: ['open', 'Pendente'], + resolved: ['done', 'Resolvido'], + }; + const [cls, label] = map[status] || ['open', status || '—']; + return `${esc(label)}`; +} + +function carbonioReleaseGuideHtml() { + return ` +
    + Guia — libertar e-mail ACCOUNT_EXISTS +
      +
    1. O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).
    2. +
    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('')}
    `; +} + +function closeEventAuditorModal() { + const modal = document.getElementById('event-auditor-modal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); +} + +function closePurgeHistoryModal() { + closeEventAuditorModal(); +} + +function bindAuditButtons(root) { + (root || document).querySelectorAll('[data-audit-type]').forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + openEventAuditor(btn.dataset.auditType, btn.dataset.auditId); + }); + }); +} + +function auditStatusBadge(status) { + const map = { + done: ['done', 'Concluído'], + error: ['closed', 'Erro'], + running: ['open', 'Em execução'], + queued: ['pending', 'Na fila'], + info: ['open', 'Info'], + }; + const [cls, label] = map[status] || ['open', status || '—']; + return `${esc(label)}`; +} + +function renderAuditTimelineHtml(steps) { + return ` + + ${(steps || []).map((step) => { + const st = step.status || 'pending'; + const cls = st === 'done' || st === 'ok' ? 'done' : (st === 'error' || st === 'fail' ? 'error' : st); + return ` + + + + + + + `; + }).join('')} +
    QuandoPassoActorHostStatusDetalhe
    ${esc(fmtDate(step.finished_at || step.started_at || step.at))}${esc(step.label)}${esc(step.actor || '—')}${esc(step.host || '—')}${auditStatusBadge(st === 'ok' ? 'done' : st)}${esc(step.detail || '')}
    `; +} + +function renderEventAuditorBody(record) { + const inv = record.inventory_before || {}; + const carbonio = inv.carbonio || {}; + const accounts = (carbonio.accounts || []).map((a) => esc(a.email || a)).join(', ') || '—'; + const op = inv.openpanel || {}; + const pmg = inv.pmg || {}; + const foss = record.foss_fiscal || {}; + const summaryBullets = (record.summary?.bullets || []).map((b) => `
  • ${esc(b)}
  • `).join(''); + const links = (record.links || []).map((l) => + `
  • ${esc(l.label)} ${l.active === false ? 'inactivo' : ''}
  • ` + ).join(''); + const actors = (record.actors || []).map((a) => + `${esc(a.label || a.id)} ${esc(a.host || '')}` + ).join(' '); + const requested = record.requested_by + ? `${esc(record.requested_by.username)} (${esc(record.requested_by.role || '—')})` + : '—'; + + return ` +
    +
    Início
    ${fmtDate(record.started_at)}
    +
    Fim
    ${fmtDate(record.finished_at || '—')}
    +
    Status
    ${auditStatusBadge(record.status)}
    +
    Solicitado por
    ${requested}
    +
    +

    Agentes: ${actors || '—'}

    +
    +
    + Resumo — transição histórica +
      ${summaryBullets || '
    • Sem resumo
    • '}
    + ${record.error ? `

    ${esc(record.error)}

    ` : ''} +
    +
    + Timeline (${(record.timeline || []).length} passos) + ${(record.timeline || []).length ? renderAuditTimelineHtml(record.timeline) : '

    Sem passos

    '} +
    + ${inv.domain ? ` +
    + Inventário pré-operação + + + + + + + + + + +
    Domínio${esc(inv.domain)}
    Mail host${esc(inv.mail_host || '—')}
    Contas Carbonio${carbonio.account_count || 0} — ${accounts}
    OpenPanel user${esc(op.user || op.record?.openpanel_user || '—')}
    Registos DNS${(op.zone_records || []).length} registo(s)
    PMG domains${esc(pmg.domains_line || '—')}
    PMG transport${esc(pmg.transport_line || '—')}
    + ${links ? `
    Links
      ${links}
    ` : ''} +
    ` : ''} + ${foss.invoice_ids?.length ? ` +
    + FOSS — fiscal preservado +

    Cliente FOSS #${esc(String(foss.client_id || '—'))} · Invoices: ${foss.invoice_ids.join(', ')}

    +
    ${esc(foss.note_text || '')}
    +
    ` : ''} +
    + Raw JSON +
    ${esc(JSON.stringify(record.raw || record, null, 2))}
    +
    +
    `; +} + +function openEventAuditor(eventType, 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 = `${eventType} · ${eventId}`; + body.innerHTML = '

    Carregando auditoria…

    '; + const path = eventType === 'purge' + ? `/v1/events/audit/purge/${encodeURIComponent(eventId)}` + : `/v1/events/audit/${encodeURIComponent(eventType)}/${encodeURIComponent(eventId)}`; + api(path) + .then((record) => { + title.textContent = record.title || 'Auditor de Eventos'; + sub.innerHTML = `${auditStatusBadge(record.status)} · job ${esc(record.id)} · ${fmtDate(record.started_at)}`; + body.innerHTML = renderEventAuditorBody(record); + }) + .catch((e) => { + body.innerHTML = `

    Erro: ${esc(e.message)}

    `; + }); + document.querySelectorAll('[data-close-event-auditor]').forEach((el) => { + el.onclick = closeEventAuditorModal; + }); +} + +function openPurgeHistoryModal(jobId) { + openEventAuditor('purge', jobId); +} + +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 ou em Auditar para abrir o Auditor de Eventos (Spec 031).

    + + + + + + + + ${rows || ''} +
    JobDomínioStatusUsuárioDesk removidoQuandoVM112Auditar
    Nenhum purge registado
    + ${data.total > (data.jobs || []).length ? `

    A mostrar ${(data.jobs || []).length} de ${data.total} purges.

    ` : ''} +
    `; + bindAuditButtons(el); + 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)}

    `; + } +} + +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: 'partner_success', label: 'Partner Success', 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; + } + const showMatrixTab = state.features?.access_matrix_ui && getUser()?.role === 'super_admin'; + if (showMatrixTab && state.adminPanel === 'matrix') { + el.innerHTML = `${adminTabsInContent()}

    Carregando matriz…

    `; + bindAdminTabs(); + if (typeof window.renderAccessMatrix === 'function') await window.renderAccessMatrix(); + 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 = ` + ${adminTabsInContent()} +
    +
    +
    +

    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

    +
    +
    `; + + bindAdminTabs(); + const applyFilters = () => { + state.adminFilter = { + q: document.getElementById('team-filter-q')?.value || '', + role: document.getElementById('team-filter-role')?.value || 'all', + status: document.getElementById('team-filter-status')?.value || 'all', + mfa: document.getElementById('team-filter-mfa')?.value || 'all', + }; + renderAdmin(); + }; + + document.getElementById('team-filter-q')?.addEventListener('input', () => { + clearTimeout(state._teamSearchTimer); + state._teamSearchTimer = setTimeout(applyFilters, 200); + }); + ['team-filter-role', 'team-filter-status', 'team-filter-mfa'].forEach((id) => { + document.getElementById(id)?.addEventListener('change', applyFilters); + }); + document.getElementById('team-goto-messages')?.addEventListener('click', () => setView('messages')); + + el.querySelectorAll('.team-edit-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + openTeamDrawer(btn.dataset.user); + }); + }); + el.querySelectorAll('.team-row').forEach((row) => { + row.addEventListener('click', (e) => { + if (e.target.closest('button')) return; + openTeamDrawer(row.dataset.user); + }); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openTeamDrawer(row.dataset.user); + } + }); + }); + + if (state.adminSelected) { + openTeamDrawer(state.adminSelected); + } + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +async function renderModules() { + const el = document.getElementById('modules-content'); + if (!el) return; + const user = getUser(); + if (user?.role !== 'super_admin') { + el.innerHTML = '

    Apenas Super Admin pode gerenciar módulos.

    '; + return; + } + el.innerHTML = '

    Carregando módulos…

    '; + try { + await DeskModules.load(); + const mods = DeskModules.list; + el.innerHTML = ` +
    +

    Módulos do Desk

    +

    Desativar um módulo remove-o do menu e desliga enriquecimentos na API — o núcleo continua estável.

    +
    + ${mods.map((m) => ` + `).join('')} +
    +
    `; + el.querySelectorAll('[data-module-toggle]').forEach((input) => { + input.addEventListener('change', async () => { + const id = input.dataset.moduleToggle; + input.disabled = true; + try { + await api(`/v1/modules/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify({ enabled: input.checked }), + }); + await DeskModules.load(); + applyRoleNav(); + DeskModules.applyVisibility(); + if (!DeskModules.isViewEnabled(state.view)) setView('dashboard'); + else refresh(); + } catch (e) { + input.checked = !input.checked; + alert(e.message || 'Falha ao actualizar módulo'); + } finally { + input.disabled = false; + } + }); + }); + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +const REG_ROLE_LABELS = ROLE_LABELS; + +async function renderMessages() { + const el = document.getElementById('messages-content'); + if (!canManageUsers()) { + el.innerHTML = '

    Sem permissão

    '; + return; + } + el.innerHTML = '

    Carregando pedidos…

    '; + try { + const data = await api('/v1/auth/registration-requests'); + const items = data.requests || []; + const pending = items.filter((r) => r.status === 'pending'); + const history = items.filter((r) => r.status !== 'pending'); + const pendingCards = pending.map((r) => ` +
    +
    +
    +

    ${esc(r.email)}

    +

    ${esc(r.display_name || '—')} · ${fmtDate(r.created_at)}

    +
    + pendente +
    + +
    + + + +
    +
    `).join(''); + const historyRows = history.map((r) => ` + + ${esc(r.email)} + ${esc(statusLabel(r.status))} + ${esc(r.role ? roleLabel(r.role) : '—')} + ${fmtDate(r.updated_at || r.created_at)} + `).join(''); + el.innerHTML = ` +
    +

    Pedidos pendentes (${pending.length})

    + ${pendingCards || '

    Nenhum pedido pendente

    '} + ${history.length ? ` +

    Histórico

    +
    + + + ${historyRows} +
    E-mailEstadoPerfilAtualizado
    +
    ` : ''} +
    `; + el.querySelectorAll('[data-req]').forEach((card) => { + const id = card.dataset.req; + const msgEl = card.querySelector('.admin-msg'); + card.querySelector('.req-approve')?.addEventListener('click', async () => { + msgEl.textContent = '…'; + try { + const role = card.querySelector('.req-role')?.value; + await api(`/v1/auth/registration-requests/${id}/approve`, { method: 'POST', body: JSON.stringify({ role }) }); + msgEl.textContent = 'Aprovado — email enviado'; + msgEl.className = 'admin-msg ok'; + await renderMessages(); + } catch (e) { + msgEl.textContent = e.message; + msgEl.className = 'admin-msg err'; + } + }); + card.querySelector('.req-reject')?.addEventListener('click', async () => { + const reason = window.prompt('Motivo da rejeição (opcional):') || ''; + msgEl.textContent = '…'; + try { + await api(`/v1/auth/registration-requests/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason: reason || null }), + }); + msgEl.textContent = 'Rejeitado'; + msgEl.className = 'admin-msg ok'; + await renderMessages(); + } catch (e) { + msgEl.textContent = e.message; + msgEl.className = 'admin-msg err'; + } + }); + }); + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } +} + +async function renderAccount(force = false) { + const el = document.getElementById('account-content'); + if (state.accountLoaded && !force) { + return; + } + const saved = force ? null : readAccountPwdForm(); + el.innerHTML = '

    Carregando…

    '; + try { + const me = await api('/v1/auth/me'); + const totpOn = Boolean(me.totp_enabled || me.mfa_enabled); + el.innerHTML = ` + `; + restoreAccountPwdForm(saved); + bindAccountPwdForm(totpOn); + state.accountLoaded = true; + } catch (e) { + el.innerHTML = `

    Erro: ${esc(e.message)}

    `; + state.accountLoaded = false; + } +} + +function readAccountPwdForm() { + const form = document.getElementById('account-pwd-form'); + if (!form) return null; + const get = (id) => document.getElementById(id)?.value ?? ''; + const hasValue = ['acct-pwd-current', 'acct-pwd-new', 'acct-pwd-new2', 'acct-pwd-totp'] + .some((id) => get(id)); + if (!hasValue) return null; + return { + current: get('acct-pwd-current'), + neu: get('acct-pwd-new'), + neu2: get('acct-pwd-new2'), + totp: get('acct-pwd-totp'), + }; +} + +function restoreAccountPwdForm(saved) { + if (!saved) return; + const set = (id, val) => { + const el = document.getElementById(id); + if (el && val) el.value = val; + }; + set('acct-pwd-current', saved.current); + set('acct-pwd-new', saved.neu); + set('acct-pwd-new2', saved.neu2); + set('acct-pwd-totp', saved.totp); +} + +function bindAccountPwdForm(totpOn) { + const form = document.getElementById('account-pwd-form'); + const errEl = document.getElementById('account-pwd-error'); + const okEl = document.getElementById('account-pwd-ok'); + if (!form || form.dataset.bound === '1') return; + form.dataset.bound = '1'; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errEl.hidden = true; + okEl.hidden = true; + const cur = document.getElementById('acct-pwd-current')?.value ?? ''; + const neu = document.getElementById('acct-pwd-new')?.value ?? ''; + const neu2 = document.getElementById('acct-pwd-new2')?.value ?? ''; + if (neu !== neu2) { + errEl.textContent = 'As senhas não coincidem'; + errEl.hidden = false; + return; + } + const payload = { current_password: cur, new_password: neu }; + if (totpOn) { + payload.totp_code = (document.getElementById('acct-pwd-totp')?.value ?? '').trim(); + } + const btn = form.querySelector('button[type="submit"]'); + btn.disabled = true; + try { + await api('/v1/auth/change-password', { + method: 'POST', + body: JSON.stringify(payload), + }); + okEl.textContent = 'Senha alterada com sucesso.'; + okEl.hidden = false; + form.reset(); + } catch (ex) { + errEl.textContent = ex.message; + errEl.hidden = false; + } finally { + btn.disabled = false; + } + }); +} + +const SOC_EVENT_LABELS = { + 'session.started': 'Sessão iniciada', + 'domain.validated': 'Domínio validado', + 'dns.applied': 'DNS aplicado', + 'onboarding.started': 'Onboarding iniciado', + 'account.created': 'Conta criada', + 'infra.synced': 'Infra sincronizada', + 'onboarding.completed': 'Onboarding concluído', + 'onboarding.failed': 'Onboarding falhou', + 'integration.test': 'Teste integração', + ...SECURITY_EVENT_LABELS, +}; + +function socWindowHours() { + return { '24h': 24, '48h': 48, '7d': 168 }[state.socWindow] || 24; +} + +function socEventSeverity(eventType) { + if (eventType?.startsWith('security.')) { + if (eventType.includes('blocked') || eventType.includes('rejected') || eventType.includes('anomaly')) return 'high'; + if (eventType.includes('csp') || eventType.includes('rate')) return 'warn'; + return 'info'; + } + if (eventType === 'onboarding.failed') return 'high'; + if (eventType === 'onboarding.started' || eventType === 'session.started') return 'warn'; + if (eventType === 'onboarding.completed' || eventType === 'account.created') return 'ok'; + return 'info'; +} + +function socAreaChartSvg(values, width = 320, height = 88) { + const data = values?.length ? values : [0, 0, 0, 0, 0, 0]; + const max = Math.max(...data, 1); + const padX = 4; + const padY = 6; + const innerW = width - padX * 2; + const innerH = height - padY * 2; + const pts = data.map((v, i) => { + const x = padX + (i / Math.max(data.length - 1, 1)) * innerW; + const y = padY + innerH - (v / max) * innerH; + return [x, y]; + }); + const line = pts.map((p) => p.join(',')).join(' '); + const area = `${padX},${padY + innerH} ${line} ${padX + innerW},${padY + innerH}`; + return ` + `; +} + +function socPipelineHtml(stages, total) { + const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed']; + const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0)); + return order.map((key) => { + const n = stages[key] || 0; + const pct = max ? Math.round((n / max) * 100) : 0; + return ` +
    + ${esc(FUNNEL_LABELS[key] || key)} +
    + ${n} +
    `; + }).join(''); +} + +function socStatusKpiClass(status) { + if (status === 'ok') return 'ok'; + if (status === 'critical') return 'critical'; + return 'warn'; +} + +function socSessionRingStage(stage) { + if (stage === 'completed' || stage === 'failed') return stage; + return 'active'; +} + +function closeSocTestModal() { + const modal = document.getElementById('soc-test-modal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); +} + +function bindSocTestModal() { + document.querySelectorAll('[data-close-soc-test-modal]').forEach((el) => { + el.addEventListener('click', closeSocTestModal); + }); +} + +function showSocWebhookTestResult(result) { + const modal = document.getElementById('soc-test-modal'); + const title = document.getElementById('soc-test-modal-title'); + const sub = document.getElementById('soc-test-modal-sub'); + const body = document.getElementById('soc-test-modal-body'); + if (!modal || !body) return; + + const ok = result.accepted && result.status === 'accepted'; + const dup = result.duplicate === true; + title.textContent = ok ? (dup ? 'Webhook OK (duplicado)' : 'Webhook OK') : 'Webhook com problema'; + sub.textContent = fmtDate(result.tested_at || new Date().toISOString()); + + body.innerHTML = ` +
    +
    + + ${esc(result.message || (ok ? 'Integração VM112 → VM122 respondendo corretamente.' : 'Falha ao processar webhook.'))} +
    +
    +
    Status
    ${esc(result.status || '—')}
    +
    Evento
    ${esc(result.event || '—')}
    +
    Origem
    ${esc(result.source || '—')}
    +
    Domínio
    ${esc(result.domain || '—')}
    +
    Sessão
    ${esc(result.session_id || '—')}
    +
    Duplicado
    ${dup ? 'sim' : 'não'}
    +
    Ticket criado
    ${result.ticket_created ? `sim (#${result.ticket_id})` : 'não'}
    +
    Disparado por
    ${esc(result.triggered_by || '—')}
    +
    +

    + Este teste simula um evento integration.test no endpoint + POST /api/v1/webhooks/onboard — o mesmo caminho usado pela VM112. + Não cria ticket de onboarding; apenas valida que a API grava o evento e o SOC consegue lê-lo. +

    +
    + + +
    +
    `; + + body.querySelector('[data-soc-goto-events]')?.addEventListener('click', () => { + closeSocTestModal(); + state.eventSourceFilter = 'vm112-onboard'; + document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => { + b.classList.toggle('active', b.dataset.source === 'vm112-onboard'); + }); + setView('events'); + }); + body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal); + + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); +} + +function showSocWebhookTestError(err) { + const modal = document.getElementById('soc-test-modal'); + const title = document.getElementById('soc-test-modal-title'); + const sub = document.getElementById('soc-test-modal-sub'); + const body = document.getElementById('soc-test-modal-body'); + if (!modal || !body) return; + + const msg = err?.message || String(err); + const is403 = /403|insufficient permissions|permiss/i.test(msg); + title.textContent = 'Falha no teste'; + sub.textContent = 'Não foi possível completar o teste'; + + body.innerHTML = ` +
    +
    + + ${esc(msg)} +
    + ${is403 ? `

    Apenas perfis super_admin e admin podem executar o teste de webhook.

    ` : ''} +

    Verifique se a API está online, se a sessão não expirou e se o usuário tem permissão.

    +
    + +
    +
    `; + body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal); + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); +} + +function showOpenPanelTestResult(result) { + const modal = document.getElementById('soc-test-modal'); + const title = document.getElementById('soc-test-modal-title'); + const sub = document.getElementById('soc-test-modal-sub'); + const body = document.getElementById('soc-test-modal-body'); + if (!modal || !body) return; + + const ok = result.ok === true; + title.textContent = ok ? 'OpenPanel API — confirmado' : 'OpenPanel API — falha'; + sub.textContent = `Spec 028 · ${result.steps_passed || 0}/${result.steps_total || 0} passos · ${result.duration_sec || '—'}s`; + + const steps = (result.steps || []).map((s) => ` +
  • + ${esc(s.name)} — ${esc(s.detail || (s.ok ? 'OK' : 'FAIL'))} +
  • `).join(''); + + body.innerHTML = ` +
    +
    + + ${esc(result.message || (ok ? 'Multidomínio OK' : 'Falha'))} +
    +
      ${steps || '
    • '}
    +

    + Suite openpanel-multidomain-api-confirm — usa os nomes que indicou nos campos + (ou gera automaticamente). Aguarde até 3 minutos sem sair da página. + 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; + const user1 = (document.getElementById('op-test-user1')?.value || '').trim().toLowerCase(); + const domain1 = (document.getElementById('op-test-domain1')?.value || '').trim().toLowerCase(); + const user2 = (document.getElementById('op-test-user2')?.value || '').trim().toLowerCase(); + const domain2 = (document.getElementById('op-test-domain2')?.value || '').trim().toLowerCase(); + const password = (document.getElementById('op-test-password')?.value || 'LbOpenTest805353').trim(); + const cleanup = document.getElementById('op-test-cleanup')?.checked !== false; + const autoNames = document.getElementById('op-test-auto-names')?.checked !== false; + + const accounts = []; + if (user1 || domain1) accounts.push({ username: user1, domain: domain1 }); + if (user2 || domain2) accounts.push({ username: user2, domain: domain2 }); + + state.openPanelTestRunning = true; + if (pollTimer) clearInterval(pollTimer); + + if (btn) { + btn.disabled = true; + btn.textContent = 'Simulando… aguarde (~2 min)'; + } + try { + const r = await apiLongRunning('/v1/vm123/openpanel/test-confirm', { + method: 'POST', + body: JSON.stringify({ + accounts, + password, + cleanup, + auto_names: autoNames && accounts.length === 0, + check_reference: true, + }), + }); + showOpenPanelTestResult(r); + } catch (ex) { + const msg = ex?.name === 'AbortError' || /aborted/i.test(ex?.message || '') + ? 'Requisição interrompida — aguarde até 3 minutos sem mudar de página.' + : (ex?.message || String(ex)); + showOpenPanelTestError({ message: msg }); + } finally { + state.openPanelTestRunning = false; + reschedulePoll(); + if (btn) { + btn.disabled = false; + btn.textContent = prevLabel || 'Executar simulação'; + } + } +} + +async function runWebhookIntegrationTest(refreshView) { + const btn = document.getElementById('soc-btn-test') || document.getElementById('btn-test-webhook'); + const prevLabel = btn?.textContent; + if (btn) { + btn.disabled = true; + btn.textContent = 'Testando…'; + } + try { + const r = await api('/v1/integrations/onboard/test', { method: 'POST' }); + showSocWebhookTestResult(r); + if (refreshView === 'infra2') await renderInfra2(); + else if (refreshView === 'infra') await renderInfra(); + } catch (ex) { + showSocWebhookTestError(ex); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = prevLabel || 'Testar webhook'; + } + } +} + +async function renderInfra2() { + const el = document.getElementById('infra2-content'); + if (!el) return; + if (window.DeskModules?.loaded && !DeskModules.isEnabled('infra2-soc')) { + el.innerHTML = '

    Módulo Infra 2 SOC desativado. Active em Módulos.

    '; + return; + } + el.innerHTML = '

    Carregando SOC…

    '; + const windowHours = socWindowHours(); + try { + const [health, vm112, wazuh, funnel, eventsRes, secRes, summary] = await Promise.all([ + api('/v1/integrations/health').catch(() => ({ status: 'unknown', alerts: [], vm112_onboard: {} })), + api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })), + api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })), + api(`/v1/onboard/funnel?window_hours=${windowHours}`).catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })), + api('/v1/webhooks/events?source=vm112-onboard').catch(() => ({ events: [] })), + window.DeskModules?.isEnabled('wizard-security') + ? api('/v1/security/summary?window_hours=24').catch(() => ({})) + : Promise.resolve({}), + api('/v1/desk/summary').catch(() => ({ tickets_open: 0, recent_tickets: [] })), + ]); + + const onboard = health.vm112_onboard || {}; + const lastWh = onboard.last_webhook || {}; + const gapMin = onboard.gap_minutes != null ? Math.round(onboard.gap_minutes) : null; + const alerts = health.alerts || []; + const vmOk = vm112.vm112?.status === 'ok'; + const wazuhOk = wazuh.api_online === true || wazuh.http_status === 401 || wazuh.http_status === 200; + const intStatus = health.status || 'unknown'; + const liveCls = intStatus === 'ok' ? '' : intStatus === 'critical' ? 'critical' : 'warn'; + + const secSummary = secRes || {}; + const secRecent = (secSummary.recent || []).map((ev) => ({ + id: `sec-${ev.id}`, + event_type: ev.event_type, + created_at: ev.created_at, + payload: { domain: ev.domain, session_id: ev.session_id }, + domain: ev.domain, + _security: true, + })); + + const allEvents = (eventsRes.events || []).map((ev) => ({ + ...ev, + payload: typeof ev.payload === 'object' ? ev.payload : {}, + })); + const windowEvents = allEvents.filter((ev) => isInWindow(ev.created_at, windowHours)); + const chartBuckets = bucketEvents(windowEvents, windowHours, 24); + const eventsPerHour = windowHours ? Math.round((windowEvents.length / windowHours) * 10) / 10 : 0; + + const feedEvents = [...allEvents, ...secRecent] + .sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)) + .slice(0, 18); + + const sessions = (funnel.active_sessions || []) + .filter((s) => s.domain || s.session_id) + .sort((a, b) => new Date(b.last_event_at || 0) - new Date(a.last_event_at || 0)); + + const sessionTimings = {}; + if (window.DeskModules?.isEnabled('funnel-timing')) { + const tops = sessions.slice(0, 8).filter((s) => s.session_id); + const timingResults = await Promise.all( + tops.map((s) => api(`/v1/onboard/sessions/${encodeURIComponent(s.session_id)}/timeline`).catch(() => null)) + ); + tops.forEach((s, i) => { + if (timingResults[i]?.timing) sessionTimings[s.session_id] = timingResults[i].timing; + }); + } + + const newestId = feedEvents[0]?.id; + const flashNew = state.socLastEventId && newestId && newestId > state.socLastEventId; + state.socLastEventId = newestId || state.socLastEventId; + + const onboardTicketsOpen = (summary.recent_tickets || []).filter( + (t) => (t.source === 'vm112-onboard' || String(t.subject || '').includes('[onboarding]')) && t.status !== 'closed' + ).length; + + const nowLabel = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + el.innerHTML = ` +
    +
    +
    + +

    SOC Operations Center

    + VM112 → VM122 · atualizado ${esc(nowLabel)} · refresh 15s +
    +
    + + + +
    +
    + +
    +
    + Integração + ${esc(intStatus)} + VM112 onboard +
    +
    + Gap webhook + ${gapMin != null ? `${gapMin}m` : '—'} + limite ${health.webhook_gap_alert_minutes || 15} min +
    +
    + Eventos + ${windowEvents.length} + ~${eventsPerHour}/h · ${state.socWindow} +
    +
    + Sessões + ${funnel.sessions_total || sessions.length} + funil ativo +
    +
    + Tickets onboard + ${onboardTicketsOpen} + abertos agora +
    +
    + Alertas + ${alerts.length} + ${lastWh.event ? esc(lastWh.event) : 'sem eventos'} +
    + ${window.DeskModules?.isEnabled('wizard-security') ? ` +
    + Segurança wizard + ${secSummary.total || 0} + CSP ${secSummary.csp_violations || 0} · bloq ${secSummary.inputs_blocked || 0} +
    ` : ''} +
    + +
    +
    + + VM112 Wizard +
    + webhook POST /onboard → +
    + + VM122 Desk +
    + +
    + + VM104 Wazuh +
    + alertas level ≥10 +
    + +
    +
    +
    +

    Feed ao vivo — VM112 + Segurança

    + ${feedEvents.length} recentes +
    +
    + ${feedEvents.length ? ` + + + + ${feedEvents.map((ev, i) => { + const p = ev.payload || {}; + const sev = socEventSeverity(ev.event_type); + const isNew = flashNew && i === 0; + return ` + + + + + + `; + }).join('')} + +
    EventoDomínioHora
    ${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)}${esc(p.domain || ev.domain || '—')}${relativeTimeAgo(ev.created_at)}
    ` : '

    Nenhum evento VM112 registrado

    '} +
    +
    + +
    +
    +

    Volume & funil

    + ${state.socWindow} +
    +
    +
    +
    + Eventos VM112 + máx ${Math.max(...chartBuckets, 0)} +
    + ${socAreaChartSvg(chartBuckets)} +
    +
    + ${socPipelineHtml(funnel.stages || {}, funnel.sessions_total || 0)} +
    +
    +
    + +
    +
    +

    Sessões VM112

    + ${sessions.length} ativas +
    +
    +
    + ${sessions.length ? sessions.slice(0, 10).map((s) => { + const stage = s.current_stage || 'started'; + const ringCls = socSessionRingStage(stage); + const initials = (s.domain || '??').slice(0, 2).toUpperCase(); + const tmeta = sessionTimings[s.session_id]; + const timingBadge = tmeta + ? `Σ ${esc(tmeta.total_duration_label)}` + : ''; + const idleHint = tmeta && !tmeta.is_completed + ? ` · parado ${esc(tmeta.idle_since_label)}` + : ''; + return ` + `; + }).join('') : '

    Sem sessões no período

    '} +
    +
    +
    +
    + +
    +
    +

    Alertas SOC

    +
    +
      + ${alerts.length ? alerts.map((a) => ` +
    • + + ${esc(a.message)} +
    • `).join('') : ` +
    • + + Integração saudável — sem alertas ativos +
    • `} + ${lastWh.domain ? ` +
    • + + Último: ${esc(lastWh.event)} · ${esc(lastWh.domain)} · ${relativeTimeAgo(lastWh.created_at)} +
    • ` : ''} +
    +
    +
    + +
    +

    Health dos nós

    +
    +
    +
    +
    VM112 Portal
    +
    +
    HTTP
    ${vm112.http_status ?? '—'}
    +
    Service
    ${esc(vm112.vm112?.service || vm112.error || '—')}
    +
    API
    ${onboard.vm112_api?.reachable ? 'OK' : 'offline'}
    +
    +
    +
    +
    VM122 Desk
    +
    +
    Integração
    ${esc(intStatus)}
    +
    Gap
    ${gapMin != null ? `${gapMin} min` : '—'}
    +
    Webhook
    ${esc(lastWh.event || '—')}
    +
    +
    +
    +
    VM104 Wazuh
    +
    +
    API
    ${wazuh.http_status ?? '—'}
    +
    Regra
    level ≥ 10
    +
    Status
    ${wazuhOk ? 'online' : 'check'}
    +
    +
    +
    +
    +
    +
    +
    `; + + document.getElementById('soc-window-select')?.addEventListener('change', (e) => { + state.socWindow = e.target.value; + renderInfra2(); + }); + document.getElementById('soc-btn-refresh')?.addEventListener('click', () => renderInfra2()); + document.getElementById('soc-btn-test')?.addEventListener('click', () => runWebhookIntegrationTest('infra2')); + el.querySelectorAll('[data-soc-session]').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedSessionId = btn.dataset.socSession; + const tid = btn.dataset.socTicket; + state.selectedTicketId = tid ? Number(tid) : null; + setView('tickets'); + }); + }); + } catch (e) { + el.innerHTML = `

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

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

    Verificando…

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

    SOC — Integração VM112

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

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

    +
    +
    +

    OpenPanel API — Re-engenharia Ligbox

    +

    Spec 028 · simule contas/domínios · duração ~1–3 min por conta.

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

    VM112 — Portal Onboard

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

    VM104 — Wazuh SOC

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

    Integrações ativas

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

    Erro: ${esc(e.message)}

    `; + } +} + +async function refresh(options = {}) { + const { poll = false } = options; + await loadHealth(); + if (poll && state.view === 'account') { + return; + } + if (state.view === 'dashboard') await renderDashboard(); + if (state.view === 'email-migration' && window.DeskEmailMigration?.renderPage) await window.DeskEmailMigration.renderPage(); + if (state.view === 'overview') await renderOverview(); + if (state.view === 'overview-home') await renderOverviewHome({ poll }); + if (state.view === 'leads') await renderLeads(); + if (state.view === 'tickets') await renderTickets(); + if (state.view === 'events') await renderEvents(); + if (state.view === 'infra' && !state.openPanelTestRunning) await renderInfra(); + if (state.view === 'infra2') await renderInfra2(); + if (state.view === 'agentic-ops' && typeof window.renderAgenticOps === 'function') { + await window.renderAgenticOps({ force: !poll, soft: poll }); + } + if (state.view === 'messages') await renderMessages(); + if (state.view === 'admin') await renderAdmin(); + if (state.view === 'access-matrix' && typeof window.renderAccessMatrix === 'function') { + await window.renderAccessMatrix(); + } + if (state.view === 'modules') await renderModules(); + if (state.view === 'account') await renderAccount(); +} + +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; + 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-event-auditor]').forEach((el) => { + el.addEventListener('click', closeEventAuditorModal); +}); + +document.getElementById('btn-refresh')?.addEventListener('click', () => { + if (state.view === 'account') { + state.accountLoaded = false; + } + refresh(); +}); + +(async function boot() { + const dash = document.getElementById('dashboard-content'); + try { + if (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(); + applyAccessMatrixNav(); + DeskModules.applyVisibility(); + bindOverviewModal(); + bindTeamDrawerClose(); + bindSocTestModal(); + setView(resolveBootView()); + + 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/staging-snapshot/stg-index.html b/projects/ops-desk/frontend/staging-snapshot/stg-index.html new file mode 100644 index 0000000..50af141 --- /dev/null +++ b/projects/ops-desk/frontend/staging-snapshot/stg-index.html @@ -0,0 +1,437 @@ + + + + + + Ligbox Ops — Support Desk + + + + + + + + + +
    +
    +
    +
    + +

    Ligbox Ops

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

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +
    + + + + + + + | + + + +
    +
    +

    Carregando…

    +
    +

    Selecione um ticket para ver detalhes

    +
    +
    +
    + +
    +
    + + + + + | + + + +
    +

    Carregando…

    +
    + + +
    +

    Carregando…

    +
    + +
    +

    Carregando SOC…

    +
    + +
    +

    Carregando Agent Squad…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando…

    +
    + +
    +

    Carregando matriz…

    +
    + +
    +

    Carregando…

    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + diff --git a/projects/ops-desk/frontend/staging-snapshot/stg-servicos.js b/projects/ops-desk/frontend/staging-snapshot/stg-servicos.js new file mode 100644 index 0000000..aeb42b7 --- /dev/null +++ b/projects/ops-desk/frontend/staging-snapshot/stg-servicos.js @@ -0,0 +1,926 @@ +/** + * Serviços — Orquestração multi-produto (Spec 018) + * Fase 1: clientes derivados VM112 + tiles cPanel + purge Spec 017. + */ +const DeskServices = (() => { + const API_BASE = '/api'; + let _domains = []; + let _clients = []; + let _query = ''; + let _selectedClientId = null; + let _selectedServiceId = 'email_tenant'; + let _billingByDomain = {}; + + const OPS_SCOPES = [ + { id: 'carbonio', label: 'Carbonio', desc: 'Domínio e contas de e-mail (zmprov)' }, + { id: 'site', label: 'ligbox-sites', desc: 'Pasta do tenant em /opt/ligbox-sites/domains/' }, + { id: 'portal', label: 'Portal users', desc: 'Contas Self-Service ligadas ao domínio' }, + { id: 'cloudflare', label: 'Cloudflare', desc: 'Zona DNS na conta Ibytera' }, + { id: 'traefik', label: 'Traefik / SNI', desc: 'Routers mail.* no CT114' }, + { id: 'desk', label: 'Desk OPS', desc: 'Webhooks, tickets e audit_domains' }, + ]; + + const SERVICE_CATALOG = [ + { + id: 'email_tenant', + label: 'E-mail Tenant', + desc: 'Domínio partilhado no Carbonio VM112', + icon: '✉', + wizard: 'vm112-mail', + wizardHost: 'VM112', + deliveryModel: 'saas', + category: 'apps', + phase: 'active', + }, + { + id: 'site_cms', + label: 'Site / CMS', + desc: 'Pasta ligbox-sites do domínio', + icon: '🌐', + wizard: 'vm112-mail', + wizardHost: 'VM112', + deliveryModel: 'saas', + category: 'apps', + phase: 'active', + }, + { + id: 'mail_dedicated', + label: 'Servidor E-mail Dedicado', + desc: 'VM ou CT exclusivo — wizard próprio (Proxmox)', + icon: '🖥', + wizard: null, + wizardHost: 'futuro', + deliveryModel: 'saas', + category: 'apps', + phase: 'planned', + }, + { + id: 'firewall', + label: 'Firewall', + desc: 'pfSense — NAT, regras, WAN', + icon: '🛡', + wizard: 'wizard-iaas-fw', + wizardHost: 'futuro', + deliveryModel: 'iaas', + category: 'security', + phase: 'planned', + }, + { + id: 'cloud', + label: 'Cloud / IaaS', + desc: 'VPS gerenciado no Proxmox', + icon: '☁', + wizard: 'wizard-iaas-vps', + wizardHost: 'futuro', + deliveryModel: 'iaas', + category: 'infra', + phase: 'planned', + }, + { + id: 'wazuh_domain', + label: 'Wazuh por domínio', + desc: 'SOC e agentes — wizard próprio', + icon: '👁', + wizard: 'wizard-soc-wazuh', + wizardHost: 'futuro', + deliveryModel: 'saas', + category: 'security', + phase: 'planned', + }, + ]; + + const CATEGORY_LABELS = { + apps: 'Aplicações (MOSP)', + infra: 'Infraestrutura', + security: 'Segurança', + }; + + const DELIVERY_LABELS = { + saas: 'SaaS', + iaas: 'IaaS', + paas: 'PaaS', + traditional: 'Suporte', + }; + + function canAccess() { + if (window.DeskModules?.loaded && !window.DeskModules.isEnabled('overview-home')) return false; + return typeof canManageVm112Domains === 'function' && canManageVm112Domains(); + } + + function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatFetchError(err) { + const msg = String(err?.message || err || ''); + if (err?.name === 'AbortError' || msg.includes('aborted') || msg.includes('Failed to fetch')) { + return 'VM112 não respondeu a tempo — o wizard pode estar sobrecarregado ou a reiniciar. Aguarde 1–2 min e clique «Tentar de novo».'; + } + return msg; + } + + async function apiFetch(path, options = {}, timeoutMs = 60000) { + let res; + try { + res = await fetchWithTimeout(`${API_BASE}${path}`, { + headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }), + ...options, + }, timeoutMs); + } catch (err) { + throw new Error(formatFetchError(err)); + } + if (res.status === 401) { logout(); throw new Error('sessão expirada'); } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const detail = data.detail; + let errText = typeof detail === 'string' ? detail : JSON.stringify(detail || `${res.status}`); + if (res.status === 504) { + errText = '504 Gateway Timeout — o purge pode demorar vários minutos. Verifique na VM112 se concluiu antes de repetir.'; + } else if (res.status === 409) { + errText = typeof detail === 'string' + ? detail + : 'Purge já em curso para este domínio — aguarde a conclusão no drawer lateral.'; + } else if (res.status === 503) { + errText = typeof detail === 'string' + ? detail + : 'Servidor ocupado — aguarde alguns segundos e tente novamente.'; + } else if (res.status === 500 && errText === '500') { + errText = 'Erro interno ao iniciar purge — verifique logs da API Desk (VM122).'; + } + throw new Error(errText); + } + return res.json(); + } + + function buildClients(domains) { + return domains.map((d) => ({ + id: d.domain, + domain: d.domain, + displayName: d.domain, + subtitle: d.portal_admin_email || 'sem admin portal', + health: d.carbonio_exists ? 'ok' : 'warn', + raw: d, + })); + } + + async function loadBillingMap() { + if (!window.DeskModules?.isEnabled('billing-recurrence')) return; + try { + const data = await apiFetch('/v1/billing/accounts?limit=500'); + _billingByDomain = {}; + for (const a of data.accounts || []) _billingByDomain[a.domain] = a; + } catch { _billingByDomain = {}; } + } + + async function loadDomains() { + const data = await apiFetch('/v1/vm112/domains', {}, 120000); + _domains = data.domains || []; + await loadBillingMap(); + _clients = buildClients(_domains); + if (_selectedClientId && !_clients.some((c) => c.id === _selectedClientId)) { + _selectedClientId = null; + } + return _domains; + } + + function filteredClients() { + const q = _query.trim().toLowerCase(); + if (!q) return _clients; + return _clients.filter((c) => { + const blob = [ + c.domain, + c.subtitle, + c.raw.mail_host, + (c.raw.portal_users || []).map((u) => u.login_id).join(' '), + ].join(' ').toLowerCase(); + return blob.includes(q); + }); + } + + function selectedClient() { + return _clients.find((c) => c.id === _selectedClientId) || null; + } + + function serviceStatus(serviceId, client) { + if (!client) return 'inactive'; + const d = client.raw; + if (serviceId === 'email_tenant') { + if (d.carbonio_exists || d.site_folder_exists || (d.portal_users || []).length) return 'active'; + return 'inactive'; + } + if (serviceId === 'site_cms') { + return d.site_folder_exists ? 'active' : 'inactive'; + } + const cat = SERVICE_CATALOG.find((s) => s.id === serviceId); + return cat?.phase === 'planned' ? 'planned' : 'inactive'; + } + + function statusLabel(status) { + if (status === 'active') return 'Activo'; + if (status === 'planned') return 'Em breve'; + return 'Não contratado'; + } + + function activeOfferCount(client) { + return SERVICE_CATALOG.filter((s) => serviceStatus(s.id, client) === 'active').length; + } + + function statsHtml() { + const billingActive = Object.values(_billingByDomain).filter((a) => a.recurrence_active).length; + const total = _clients.length; + const emailActive = _clients.filter((c) => serviceStatus('email_tenant', c) === 'active').length; + const sites = _clients.filter((c) => c.raw.site_folder_exists).length; + const logins = _clients.reduce((n, c) => n + (c.raw.portal_users || []).length, 0); + return ` +
    ${total}clientes
    +
    ${emailActive}tenant e-mail ativo
    +
    ${sites}tenant site CMS
    +
    ${logins}logins portal
    +
    ${billingActive}recorrências
    `; + } + + function clientRow(c) { + const sel = _selectedClientId === c.id ? ' servicos-client-row--selected' : ''; + const emailSt = serviceStatus('email_tenant', c); + const offers = activeOfferCount(c); + return ` + `; + } + + function clientsListHtml() { + const rows = filteredClients(); + return rows.length + ? rows.map(clientRow).join('') + : '

    Nenhum cliente encontrado.

    '; + } + + function serviceTile(service, client) { + const status = client ? serviceStatus(service.id, client) : 'inactive'; + const clickable = status === 'active' && service.id === 'email_tenant'; + const sel = _selectedServiceId === service.id ? ' servicos-tile--selected' : ''; + const tag = statusLabel(status); + const dm = DELIVERY_LABELS[service.deliveryModel] || service.deliveryModel; + const wizNote = service.wizard + ? `wizard: ${service.wizard} (${service.wizardHost})` + : (service.phase === 'planned' ? 'wizard próprio — planeamento MOSP' : ''); + return ` +
    + ${esc(dm)} + +

    ${esc(service.label)}

    +

    ${esc(service.desc)}

    + ${tag} + ${wizNote ? `${esc(wizNote)}` : ''} + ${clickable ? 'Gerir / Purge →' : ''} +
    `; + } + + function servicesGridHtml() { + const client = selectedClient(); + if (!client) { + return '
    Seleccione um cliente à esquerda para ver os tenants de oferta.
    '; + } + const byCat = {}; + for (const s of SERVICE_CATALOG) { + const cat = s.category || 'apps'; + if (!byCat[cat]) byCat[cat] = []; + byCat[cat].push(s); + } + const sections = Object.keys(CATEGORY_LABELS).map((cat) => { + const items = byCat[cat]; + if (!items?.length) return ''; + return ` +
    +

    ${esc(CATEGORY_LABELS[cat])}

    +
    + ${items.map((s) => serviceTile(s, client)).join('')} +
    +
    `; + }).join(''); + return ` +
    + ${esc(client.displayName)} + ${esc(client.subtitle)} · ${esc(client.raw.mail_host || '')} +
    + ${sections} +

    Cada oferta MOSP terá wizard próprio (VM112 = só e-mail). Orquestração aqui no Desk VM122 — sem alterar a Hero do portal.

    `; + } + + function scopesHtml() { + const client = selectedClient(); + if (!client) { + return '

    Escolha um cliente e o serviço E-mail Tenant para ver o escopo de purge OPS.

    '; + } + if (_selectedServiceId !== 'email_tenant') { + return `

    Escopo OPS detalhado disponível para E-mail Tenant. Serviço actual: ${esc(SERVICE_CATALOG.find((s) => s.id === _selectedServiceId)?.label || _selectedServiceId)}.

    `; + } + return OPS_SCOPES.map((s) => ` +
    + ${esc(s.label)} + ${esc(s.desc)} +
    `).join(''); + } + + function pageHtml() { + return ` +
    +
    +
    +

    Orquestração de Serviços

    +

    Desk VM122 · Orquestração MOSP

    +

    Clientes e tenants de oferta — cada produto com wizard próprio; aqui gere estado OPS e purge de teste.

    +
    + +
    +
    ${statsHtml()}
    +
    +
    +
    +

    Clientes ${filteredClients().length}

    + +
    +
    ${clientsListHtml()}
    +
    +
    +

    Tenants de Oferta

    +
    ${servicesGridHtml()}
    +
    + +
    +
    `; + } + + function refreshPanels(root) { + const list = root.querySelector('#servicos-client-list'); + const count = root.querySelector('#servicos-client-count'); + const services = root.querySelector('#servicos-services'); + const scopes = root.querySelector('#servicos-scopes'); + const stats = root.querySelector('#servicos-stats'); + if (list) list.innerHTML = clientsListHtml(); + if (count) count.textContent = String(filteredClients().length); + if (services) services.innerHTML = servicesGridHtml(); + if (scopes) scopes.innerHTML = scopesHtml(); + if (stats) stats.innerHTML = statsHtml(); + bindClientClicks(root); + bindServiceClicks(root); + } + + function bindPage(root) { + root.querySelector('#servicos-refresh')?.addEventListener('click', async () => { + root.querySelector('#servicos-services').innerHTML = '

    A actualizar…

    '; + await loadDomains(); + refreshPanels(root); + }); + root.querySelector('#servicos-search')?.addEventListener('input', (e) => { + _query = e.target.value; + refreshPanels(root); + }); + bindClientClicks(root); + bindServiceClicks(root); + } + + function bindClientClicks(root) { + root.querySelectorAll('[data-client-id]').forEach((btn) => { + btn.addEventListener('click', () => { + _selectedClientId = btn.dataset.clientId; + _selectedServiceId = 'email_tenant'; + refreshPanels(root); + }); + }); + } + + function bindServiceClicks(root) { + root.querySelectorAll('[data-service-id]').forEach((tile) => { + tile.addEventListener('click', () => { + _selectedServiceId = tile.dataset.serviceId; + const client = selectedClient(); + if (tile.dataset.clickable === '1' && client) { + openModal(client.domain); + } + refreshPanels(root); + }); + }); + } + + function formatTs(iso) { + if (!iso) return '—'; + try { + const d = new Date(iso); + return d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return String(iso); + } + } + + const PURGE_WAIT_STEPS = [ + ...OPS_SCOPES.map((s) => s.label), + 'Desk — webhook_events', + 'Desk — tickets', + 'Desk — audit_domains', + 'Desk — assist_sessions', + 'Desk — audit_checks', + 'Purge concluído', + ]; + + let _purgeElapsedTimer = null; + let _purgeLiveSteps = []; + + function upsertPurgeStep(step) { + if (!step) return; + const runVm112 = _purgeLiveSteps.findIndex( + (s) => s.status === 'running' && String(s.label).includes('VM112') + ); + if (runVm112 >= 0 && step.status === 'ok' && String(step.label).includes('VM112')) { + _purgeLiveSteps.splice(runVm112, 1); + } + const sameLabel = _purgeLiveSteps.findIndex((s) => s.label === step.label); + if (sameLabel >= 0) { + _purgeLiveSteps[sameLabel] = step; + } else { + _purgeLiveSteps.push(step); + } + renderPurgeTimeline(_purgeLiveSteps, { + running: _purgeLiveSteps.some((s) => s.status === 'running'), + }); + } + + function onPurgeHeartbeat(ev) { + const idx = _purgeLiveSteps.findIndex( + (s) => s.status === 'running' && String(s.label).includes('VM112') + ); + if (idx < 0) return; + _purgeLiveSteps[idx] = { + ..._purgeLiveSteps[idx], + detail: `Em curso na VM112 (${ev.elapsed || 0}s)`, + }; + renderPurgeTimeline(_purgeLiveSteps, { running: true }); + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function isNetworkFetchError(err) { + const msg = String(err?.message || err || ''); + return msg === 'Failed to fetch' + || err?.name === 'AbortError' + || msg.includes('NetworkError') + || msg.includes('network'); + } + + async function recoverPurgeJob(domain, jobId) { + const q = domain ? `?domain=${encodeURIComponent(domain)}` : ''; + return apiFetch(`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}/recover${q}`, { + method: 'POST', + body: '{}', + }, 60000); + } + + function applyPurgeJobToTimeline(job) { + if (!job) return; + const steps = Array.isArray(job.timeline) ? job.timeline : []; + if (!steps.length) { + if (job.status === 'running') { + upsertPurgeStep({ + at: new Date().toISOString(), + label: 'Purge em execução', + status: 'running', + detail: 'A aguardar passos da VM112…', + }); + } + return; + } + for (const step of steps) upsertPurgeStep(step); + renderPurgeTimeline(_purgeLiveSteps, { running: job.status === 'running' }); + } + + async function showPurgeSuccess(done, domain) { + applyPurgeJobToTimeline(done); + const deskTotal = Object.values(done?.desk || {}).reduce((a, b) => a + Number(b || 0), 0); + upsertPurgeStep({ + at: new Date().toISOString(), + label: 'Purge concluído', + status: 'ok', + detail: deskTotal ? `Desk: ${deskTotal} registo(s) removido(s)` : (done?.recovered ? 'Recuperação automática' : 'Concluído'), + }); + renderPurgeTimeline(_purgeLiveSteps, { running: false }); + const msg = document.getElementById('vm112-purge-msg'); + const btn = document.getElementById('vm112-purge-btn'); + if (msg) { + msg.textContent = `✓ Purge concluído com sucesso.${deskTotal ? ` (${deskTotal} registo(s) Desk)` : ''}`; + msg.classList.add('vm112-purge-success'); + } + if (btn) { btn.textContent = 'Concluído ✓'; btn.disabled = true; } + _domains = _domains.filter((d) => d.domain !== domain); + await loadBillingMap(); + _clients = buildClients(_domains); + if (_selectedClientId === domain) _selectedClientId = null; + setTimeout(() => { + const el = document.getElementById('overview-home-content'); + if (el) renderPage(el); + closeModal(); + }, 8000); + } + + async function tryRecoverPurge(domain, jobId) { + try { + const job = await recoverPurgeJob(domain, jobId); + applyPurgeJobToTimeline(job); + return job?.status === 'done' ? job : null; + } catch { + return null; + } + } + + async function pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode = '') { + const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, { + method: 'POST', + body: JSON.stringify({ + confirm_domain: confirmDomain, + root_password: rootPassword, + purge_auth_code: purgeAuthCode || '', + }), + }, 60000); + const jobId = start.job_id; + if (!jobId) throw new Error('Job purge não iniciado'); + _lastPurgeJobId = jobId; + let networkErrors = 0; + while (true) { + let job; + try { + job = await apiFetch(`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}`, {}, 60000); + networkErrors = 0; + } catch (e) { + const msg = String(e.message || ''); + if (msg.includes('não encontrado') || msg.includes('404') || msg === '500' || msg.includes('502') || msg.includes('503')) { + const recovered = await tryRecoverPurge(domain, jobId); + if (recovered) return recovered; + } + if (isNetworkFetchError(e)) { + networkErrors += 1; + upsertPurgeStep({ + at: new Date().toISOString(), + label: 'Ligação ao servidor', + status: 'running', + detail: `Reconectando… (tentativa ${networkErrors})`, + }); + if (networkErrors >= 2) { + const recovered = await tryRecoverPurge(domain, jobId); + if (recovered) return recovered; + } + await sleep(2500); + continue; + } + throw e; + } + applyPurgeJobToTimeline(job); + if (job.elapsed_vm112) { + const el = document.getElementById('vm112-purge-elapsed'); + if (el) el.textContent = `${job.elapsed_vm112}s`; + } + if (job.status === 'done') { + return job; + } + if (job.status === 'error') { + throw new Error(job.error || job.timeline?.find((s) => s.status === 'fail')?.detail || 'Purge falhou'); + } + await sleep(2000); + } + } + + async function consumePurgeStream(domain, confirmDomain, rootPassword) { + const res = await fetchWithTimeout( + `${API_BASE}/v1/vm112/domains/${encodeURIComponent(domain)}/purge/stream`, + { + method: 'POST', + headers: authHeaders({ + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }), + body: JSON.stringify({ + confirm_domain: confirmDomain, + root_password: rootPassword, + }), + }, + 600000 + ); + if (res.status === 401) { logout(); throw new Error('sessão expirada'); } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const detail = data.detail; + throw new Error(typeof detail === 'string' ? detail : `HTTP ${res.status}`); + } + const reader = res.body?.getReader(); + if (!reader) throw new Error('Stream indisponível no browser'); + const decoder = new TextDecoder(); + let buf = ''; + let donePayload = null; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop() || ''; + for (const part of parts) { + const line = part.split('\n').find((l) => l.startsWith('data: ')); + if (!line) continue; + let ev; + try { + ev = JSON.parse(line.slice(6)); + } catch { + continue; + } + if (ev.type === 'step') upsertPurgeStep(ev.step); + else if (ev.type === 'heartbeat') onPurgeHeartbeat(ev); + else if (ev.type === 'error') { + upsertPurgeStep(ev.step || { at: new Date().toISOString(), label: 'Purge falhou', status: 'fail', detail: 'erro' }); + throw new Error(ev.step?.detail || 'Purge falhou'); + } else if (ev.type === 'done') { + upsertPurgeStep(ev.step); + donePayload = ev; + } + } + } + return donePayload; + } + + function stopPurgeElapsed() { + if (_purgeElapsedTimer) { + clearInterval(_purgeElapsedTimer); + _purgeElapsedTimer = null; + } + } + + function openPurgeDrawer() { + const shell = document.getElementById('vm112-modal-shell'); + const drawer = document.getElementById('vm112-purge-drawer'); + if (shell) shell.classList.add('vm112-modal-shell--purge-open'); + if (drawer) drawer.setAttribute('aria-hidden', 'false'); + } + + function closePurgeDrawer() { + stopPurgeElapsed(); + const shell = document.getElementById('vm112-modal-shell'); + const drawer = document.getElementById('vm112-purge-drawer'); + const list = document.getElementById('vm112-purge-timeline'); + const elapsed = document.getElementById('vm112-purge-elapsed'); + if (shell) shell.classList.remove('vm112-modal-shell--purge-open'); + if (drawer) drawer.setAttribute('aria-hidden', 'true'); + if (list) list.innerHTML = ''; + if (elapsed) elapsed.textContent = '—'; + } + + function renderPurgeTimeline(steps, { running = false } = {}) { + const list = document.getElementById('vm112-purge-timeline'); + if (!list) return; + list.innerHTML = (steps || []).map((step, i) => { + const status = step.status || 'pending'; + const isRun = running && status === 'running'; + return ` +
  • + ${esc(formatTs(step.at))} +
    + ${esc(step.label)} + ${step.detail ? `${esc(step.detail)}` : ''} +
    +
  • `; + }).join(''); + list.scrollTop = list.scrollHeight; + } + + function startPurgeElapsed() { + const el = document.getElementById('vm112-purge-elapsed'); + const t0 = Date.now(); + stopPurgeElapsed(); + const tick = () => { + const sec = Math.floor((Date.now() - t0) / 1000); + if (el) el.textContent = `${sec}s`; + }; + tick(); + _purgeElapsedTimer = setInterval(tick, 1000); + } + + function initPurgeTimelineRunning() { + _purgeLiveSteps = []; + _lastPurgeJobId = null; + openPurgeDrawer(); + startPurgeElapsed(); + upsertPurgeStep({ + at: new Date().toISOString(), + label: 'A iniciar purge', + status: 'running', + detail: 'A validar credenciais…', + }); + } + + async function runPurge(domain) { + const msg = document.getElementById('vm112-purge-msg'); + const confirmDomain = document.getElementById('vm112-purge-confirm')?.value?.trim() || ''; + const rootPassword = document.getElementById('vm112-purge-root-pwd')?.value || ''; + const purgeAuthCode = document.getElementById('vm112-purge-auth-code')?.value?.trim() || ''; + const needsAuthCode = Boolean(document.getElementById('vm112-purge-auth-code')); + if (!confirmDomain || !rootPassword) { + if (msg) msg.textContent = 'Preencha domínio e senha Root.'; + return; + } + if (needsAuthCode && !purgeAuthCode) { + if (msg) msg.textContent = 'Este domínio exige código de autorização — gere em Infra (root).'; + return; + } + if (!window.confirm(`PURGE definitivo de ${domain}?`)) return; + const btn = document.getElementById('vm112-purge-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'A apagar…'; } + if (msg) { msg.textContent = 'A executar purge…'; msg.classList.remove('vm112-purge-success'); } + initPurgeTimelineRunning(); + try { + const done = await pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode); + stopPurgeElapsed(); + showPurgeSuccess(done, domain); + return; + } catch (e) { + stopPurgeElapsed(); + if (isNetworkFetchError(e) && _purgeLiveSteps.some((s) => s.status === 'ok')) { + const recovered = _lastPurgeJobId + ? await tryRecoverPurge(domain, _lastPurgeJobId).catch(() => null) + : null; + if (recovered) { + showPurgeSuccess(recovered, domain); + return; + } + showPurgeSuccess({ status: 'done', desk: {}, timeline: _purgeLiveSteps, recovered: true }, domain); + return; + } + if (!_purgeLiveSteps.length) { + const failAt = new Date().toISOString(); + renderPurgeTimeline([ + { at: failAt, label: 'Purge falhou', status: 'fail', detail: e.message || 'erro' }, + ]); + } + const errMsg = isNetworkFetchError(e) + ? 'Ligação interrompida durante o purge — verifique se o domínio já saiu da lista' + : (e.message || 'Purge falhou — verifique se o domínio já foi removido na lista'); + if (msg) msg.textContent = errMsg; + if (btn) { btn.disabled = false; btn.textContent = 'Apagar domínio e todos os dados'; } + } + } + + function closeModal() { + closePurgeDrawer(); + const modal = document.getElementById('vm112-domain-modal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); + } + + function openModal(domain) { + const modal = document.getElementById('vm112-domain-modal'); + const body = document.getElementById('vm112-domain-modal-body'); + const title = document.getElementById('vm112-domain-modal-title'); + const sub = document.getElementById('vm112-domain-modal-sub'); + if (!modal || !body) return; + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + title.textContent = domain; + sub.textContent = 'E-mail Tenant · a carregar…'; + body.innerHTML = '

    A carregar detalhe VM112…

    '; + loadModal(domain); + modal.querySelectorAll('[data-close-vm112-modal]').forEach((el) => { + el.onclick = closeModal; + }); + } + + async function loadModal(domain) { + const body = document.getElementById('vm112-domain-modal-body'); + const sub = document.getElementById('vm112-domain-modal-sub'); + try { + const d = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}`, {}, 180000); + const accN = (d.accounts || []).length; + sub.textContent = `E-mail Tenant · ${accN} conta(s) · ${d.mail_host || ''}`; + const steps = (d.infra_status?.steps || []) + .map((s) => `
  • ${esc(s.label)} — ${esc(s.message)}
  • `) + .join(''); + const accounts = (d.accounts || []) + .map((a) => `
  • ${esc(a)}
  • `).join('') || '
  • Nenhuma
  • '; + const cf = d.cloudflare_zone; + const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera'; + const extraAuth = Boolean(d.purge_extra_auth_required); + const authCodeField = extraAuth + ? ` +

    Domínio protegido — exige código de autorização gerado em Infra (root).

    + + + ` + : ''; + body.innerHTML = ` + + + + `; + body.querySelector('#vm112-purge-btn')?.addEventListener('click', () => runPurge(domain)); + } catch (e) { + body.innerHTML = `

    Erro: ${esc(e.message)}

    `; + } + } + + function setPollStatus(root, message, isError = false) { + if (!root) return; + let el = root.querySelector('#servicos-poll-status'); + if (!message) { + el?.remove(); + return; + } + if (!el) { + el = document.createElement('p'); + el.id = 'servicos-poll-status'; + el.className = 'servicos-poll-status'; + root.querySelector('.servicos-header')?.appendChild(el); + } + el.className = `servicos-poll-status${isError ? ' servicos-poll-status--err' : ''}`; + el.textContent = message; + } + + async function renderPage(container, options = {}) { + const { poll = false } = options; + if (!container) return; + if (!canAccess()) { + container.innerHTML = '

    Sem permissão — perfil Admin (super_admin ou ops_lead) necessário.

    '; + return; + } + const hasPage = Boolean(container.querySelector('.servicos-page')); + if (poll && hasPage) { + try { + await loadDomains(); + refreshPanels(container); + setPollStatus(container, ''); + } catch (e) { + setPollStatus(container, `Actualização falhou: ${e.message}`, true); + } + return; + } + if (!hasPage) { + container.innerHTML = '

    A carregar clientes e serviços VM112…

    '; + } + try { + await loadDomains(); + if (_clients.length && !_selectedClientId) { + _selectedClientId = _clients[0].id; + } + container.innerHTML = pageHtml(); + bindPage(container); + setPollStatus(container, ''); + } catch (e) { + if (hasPage) { + setPollStatus(container, `Erro ao carregar VM112: ${e.message}`, true); + return; + } + container.innerHTML = `

    Erro ao carregar VM112: ${esc(e.message)}

    `; + container.querySelector('#servicos-retry')?.addEventListener('click', () => renderPage(container)); + } + } + + return { renderPage, loadDomains, openModal, canAccess, SERVICE_CATALOG }; +})(); + +window.DeskServices = DeskServices; +window.DeskAccounts = DeskServices; diff --git a/projects/ops-desk/frontend/staging-snapshot/stg-styles.css b/projects/ops-desk/frontend/staging-snapshot/stg-styles.css new file mode 100644 index 0000000..9fa76d4 --- /dev/null +++ b/projects/ops-desk/frontend/staging-snapshot/stg-styles.css @@ -0,0 +1,4814 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap'); + +:root { + --bg: #f5f0e8; + --card: #fffdf9; + --accent: #5c2e2e; + --accent-hover: #3d1f1f; + --accent-soft: #f3e8e8; + --muted: #6b6560; + --border: #e0d5c8; + --ok: #2d6a4f; + --ok-bg: #e8f5ee; + --warn: #b5651d; + --warn-bg: #fef3e8; + --danger: #9b2226; + --sidebar-w: 308px; + --nav-icon-col: 64px; + --nav-text-gap: 16px; + --sidebar-bg: #2e1218; + --sidebar-active-bar: #ff5c8a; + --shadow: 0 4px 24px rgba(92, 46, 46, 0.08); +} + +* { box-sizing: border-box; } + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: linear-gradient(145deg, #f5f0e8 0%, #ebe3d6 100%); +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 2rem; + box-shadow: var(--shadow); +} + +.login-card label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 1rem; + color: #3a3530; +} + +.login-card input { + display: block; + width: 100%; + margin-top: 0.35rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; +} + +.login-error { + color: var(--danger); + font-size: 0.85rem; + margin: 0.5rem 0 0; +} + +.login-hint { + margin: 1.25rem 0 0; + font-size: 0.75rem; + color: var(--muted); + text-align: center; +} + +.btn-linkish { + font-size: 0.8rem; + color: var(--accent); +} + +.backup-codes-panel { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(0,0,0,0.15); +} + +.backup-codes-list { + list-style: none; + padding: 0; + margin: 0.75rem 0 1rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem 0.75rem; +} + +.backup-codes-list code { + font-size: 0.85rem; + letter-spacing: 0.05em; +} + +.sidebar-user { + padding: 0.75rem 1.25rem; + border-top: 1px solid rgba(255,255,255,0.12); + font-size: 0.8rem; +} + +.sidebar-user strong { + display: block; + font-size: 0.9rem; +} + +.sidebar-user .btn { + margin-top: 0.5rem; + width: 100%; + font-size: 0.8rem; + padding: 0.4rem 0.6rem; +} + +.admin-users { + display: grid; + gap: 1rem; +} + +.admin-user-card label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); +} + +.admin-user-card input, +.admin-user-card select { + display: block; + width: 100%; + margin-top: 0.35rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; +} + +.admin-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} + +.admin-msg { + font-size: 0.85rem; + margin-left: 0.5rem; + color: var(--muted); +} + +.admin-msg.ok { color: var(--ok); } +.admin-msg.err { color: var(--danger); } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ——— Equipa / Administradores (Opção A) ——— */ +.team-admin { + display: grid; + gap: 1rem; +} + +.team-admin-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.team-admin-title { + margin: 0; + font-size: 1.35rem; + color: var(--accent); +} + +.team-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; +} + +.team-kpi { + padding: 0.85rem 1rem; + text-align: center; +} + +.team-kpi-val { + display: block; + font-size: 1.6rem; + font-weight: 700; + color: var(--accent); + line-height: 1.1; +} + +.team-kpi-label { + display: block; + margin-top: 0.2rem; + font-size: 0.75rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.team-toolbar { + display: grid; + grid-template-columns: 1.5fr repeat(3, minmax(120px, 1fr)); + gap: 0.75rem; + padding: 0.85rem 1rem; + align-items: end; +} + +.team-toolbar label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); +} + +.team-toolbar input, +.team-toolbar select { + display: block; + width: 100%; + margin-top: 0.3rem; + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; +} + +.team-table-wrap { + overflow: auto; + padding: 0; +} + +.team-table { + margin: 0; +} + +.team-table tbody tr.team-row { + cursor: pointer; + transition: background 0.12s ease; +} + +.team-table tbody tr.team-row:hover { + background: rgba(90, 58, 42, 0.04); +} + +.team-user-cell { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 200px; +} + +.team-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 0.78rem; + font-weight: 700; + color: #fff; + background: linear-gradient(135deg, #8b4b3a, #c76b52); + flex-shrink: 0; +} + +.team-avatar-lg { + width: 52px; + height: 52px; + font-size: 1rem; +} + +.team-user-name { + display: block; + font-size: 0.92rem; +} + +.team-user-email { + display: block; + font-size: 0.78rem; + color: var(--muted); +} + +.team-muted { + color: var(--muted); + font-size: 0.85rem; + white-space: nowrap; +} + +.team-actions { + text-align: right; + white-space: nowrap; +} + +.team-table-foot { + padding: 0.65rem 1rem; + margin: 0; + border-top: 1px solid var(--border); +} + +.role-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.role-super { background: #3d2a4a; color: #f3e8ff; } +.role-lead { background: #4a3528; color: #ffe8d9; } +.role-tech { background: #2a3d4a; color: #dff3ff; } +.role-noc { background: #2f3d2a; color: #e8f5df; } +.role-default { background: #eee; color: #444; } + +.team-drawer { + position: fixed; + inset: 0; + z-index: 1300; +} + +.team-drawer.hidden { + display: none !important; +} + +.team-drawer-backdrop { + position: absolute; + inset: 0; + background: rgba(20, 12, 14, 0.45); + backdrop-filter: blur(2px); +} + +.team-drawer-panel { + position: absolute; + top: 0; + right: 0; + width: min(100%, 420px); + height: 100%; + background: var(--card); + border-left: 1px solid var(--border); + box-shadow: -12px 0 40px rgba(42, 37, 32, 0.15); + display: flex; + flex-direction: column; + animation: teamDrawerIn 0.2s ease; +} + +@keyframes teamDrawerIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.team-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-bottom: 1px solid var(--border); + background: #fffdf9; +} + +.team-drawer-header h3 { + margin: 0; + font-size: 1.05rem; + color: var(--accent); +} + +.team-drawer-body { + padding: 1rem 1.1rem 1.5rem; + overflow: auto; + flex: 1; +} + +.team-drawer-profile { + display: flex; + gap: 0.85rem; + align-items: center; + margin-bottom: 1rem; +} + +.team-drawer-name { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.team-drawer-meta { + margin: 0 0 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.team-drawer-form label { + display: block; + margin-bottom: 0.85rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); +} + +.team-drawer-form input, +.team-drawer-form select { + display: block; + width: 100%; + margin-top: 0.35rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; +} + +.team-drawer-danger { + margin: 0.5rem 0 1rem; + padding: 0.75rem; + border-radius: 8px; + background: rgba(180, 60, 50, 0.06); + border: 1px solid rgba(180, 60, 50, 0.15); +} + +.team-drawer-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +@media (max-width: 900px) { + .team-kpi-grid { + grid-template-columns: repeat(2, 1fr); + } + .team-toolbar { + grid-template-columns: 1fr 1fr; + } + .team-search { + grid-column: 1 / -1; + } +} + +@media (max-width: 600px) { + .team-toolbar { + grid-template-columns: 1fr; + } + .team-drawer-panel { + width: 100%; + } +} + +body { + margin: 0; + font-family: 'DM Sans', system-ui, sans-serif; + background: var(--bg); + color: #2a2520; + min-height: 100vh; +} + +.shell { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr; + min-height: 100vh; +} + +.sidebar { + background: + radial-gradient(circle at top left, rgba(255, 92, 138, 0.1), transparent 32%), + radial-gradient(circle at bottom right, rgba(255, 255, 255, 0.04), transparent 26%), + linear-gradient(180deg, #34131a 0%, #250d14 100%); + color: #fff; + padding: 1.75rem 0 1rem; + display: flex; + flex-direction: column; + box-shadow: inset -1px 0 0 rgba(255,255,255,0.04); +} + +.brand { + padding: 0 1.75rem 1.55rem; + border-bottom: 1px solid rgba(255,255,255,0.1); + margin-bottom: 0.9rem; +} + +.brand h1 { + margin: 0; + font-size: 2.05rem; + font-weight: 700; + letter-spacing: -0.05em; + line-height: 1.2; +} + +.brand-ops { + color: var(--sidebar-active-bar); +} + +.brand p { + margin: 0.75rem 0 0; + font-size: 0.98rem; + color: rgba(255,255,255,0.68); +} + +.nav { + list-style: none; + margin: 0; + padding: 0.18rem 1rem; + flex: 1; +} + +.nav-section-label--desk { + margin: 0.35rem 1.15rem 0.25rem; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255,255,255,0.45); +} + +a.nav-item-console { + display: flex; + align-items: center; + gap: var(--nav-text-gap); + margin: 0 1rem 0.65rem; + padding: 0.62rem 1rem; + min-height: 48px; + border-radius: 14px; + text-decoration: none; + color: #e0f2fe; + background: linear-gradient(135deg, rgba(14, 165, 233, 0.28), rgba(56, 189, 248, 0.12)); + border: 1px solid rgba(125, 211, 252, 0.35); + font-weight: 600; + font-size: 0.95rem; + transition: background 0.15s, border-color 0.15s, transform 0.12s; +} + +a.nav-item-console:hover { + background: linear-gradient(135deg, rgba(14, 165, 233, 0.42), rgba(56, 189, 248, 0.2)); + border-color: rgba(186, 230, 253, 0.55); + color: #fff; + text-decoration: none; + transform: translateX(-2px); +} + +.nav-icon-console { + font-size: 0.85rem; + opacity: 0.9; +} + +.desk-console-crumb { + margin-right: 0.5rem; + border: 1px solid rgba(14, 165, 233, 0.35); + color: #0369a1 !important; + font-weight: 600; +} + +.nav button { + position: relative; + width: 100%; + text-align: left; + border: none; + background: transparent; + color: rgba(255,255,255,0.88); + padding: 0.54rem 1.15rem; + font: inherit; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: var(--nav-text-gap); + min-height: 56px; + margin-bottom: 0.02rem; + border-radius: 14px; + transition: background 0.15s, color 0.15s, box-shadow 0.15s, transform 0.15s; +} + +.nav-icon-wrap { + width: var(--nav-icon-col); + height: var(--nav-icon-col); + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 var(--nav-icon-col); + overflow: visible; + pointer-events: none; + border-radius: 16px; + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.nav-icon-svg { + width: 54px; + height: 54px; + display: block; + overflow: visible; + filter: url(#nav-shadow); +} + +.nav-label { + flex: 1 1 auto; + min-width: 0; + line-height: 1.2; + font-size: 0.99rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav button:hover { + background: rgba(255,255,255,0.05); + color: #fff; + transform: translateX(1px); +} + +.nav button.active { + background: linear-gradient(180deg, rgba(255, 119, 159, 0.18), rgba(255, 255, 255, 0.07)); + color: #fff; + font-weight: 700; + box-shadow: + inset 0 0 0 1px rgba(255,255,255,0.06), + 0 10px 28px rgba(0,0,0,0.14); +} + +.nav button.active .nav-icon-wrap { + background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.08)); + box-shadow: + inset 0 0 0 1px rgba(255,255,255,0.18), + 0 8px 22px rgba(0,0,0,0.16); +} + +.nav button:hover .nav-icon-wrap { + transform: translateY(-1px); +} + +.nav-item-dashboard .nav-icon-svg { width: 52px; height: 52px; } +.nav-item-overview .nav-icon-svg { width: 50px; height: 50px; } +.nav-item-tickets .nav-icon-svg { width: 52px; height: 52px; } +.nav-item-leads .nav-icon-svg { width: 54px; height: 54px; } +.nav-item-events .nav-icon-svg { width: 52px; height: 52px; } +.nav-item-tenants .nav-icon-svg { width: 53px; height: 53px; } +.nav-item-infra .nav-icon-svg { width: 51px; height: 51px; } +.nav-item-account .nav-icon-svg { width: 54px; height: 54px; } +.nav-item-messages .nav-icon-svg { width: 54px; height: 54px; } +.nav-item-admin .nav-icon-svg { width: 54px; height: 54px; } +.nav-item-agentic .nav-icon-agentes { + width: 54px; + height: 54px; + display: block; + object-fit: cover; + object-position: center 36%; + border-radius: 12px; + filter: drop-shadow(0 3px 2.5px rgba(0, 0, 0, 0.28)); +} + +.sidebar-footer { + padding: 1rem 1.25rem; + font-size: 0.72rem; + opacity: 0.65; + border-top: 1px solid rgba(255,255,255,0.12); +} + +.main { padding: 1.5rem 1.75rem 2rem; overflow-x: auto; } + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.page-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--accent); + font-weight: 700; +} + +.page-header p { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.9rem; } + +.page-toolbar { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.header-user { + display: inline-flex; + flex-direction: column; + align-items: flex-end; + gap: 0.1rem; + padding: 0.25rem 0.6rem; + border-radius: 8px; + background: var(--card); + border: 1px solid var(--border); + font-size: 0.75rem; + color: var(--muted); + line-height: 1.2; +} + +.header-user strong { + font-size: 0.85rem; + color: var(--accent); +} + +.btn-sm { + font-size: 0.8rem; + padding: 0.35rem 0.65rem; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid var(--border); +} + +.data-table th { + color: var(--muted); + font-weight: 600; +} + +.login-notice { + margin: 0 0 1rem; + padding: 0.65rem 0.75rem; + border-radius: 8px; + background: var(--ok-bg); + color: var(--ok); + font-size: 0.85rem; + line-height: 1.45; +} + +.login-notice code { + font-size: 0.8rem; + word-break: break-all; +} + +.register-done { + text-align: center; + padding: 0.5rem 0; +} + +.activate-page { + align-items: flex-start; + padding: 1.25rem; +} + +.activate-card { + width: 100%; + max-width: 960px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.5rem 1.75rem 1.25rem; + box-shadow: var(--shadow); +} + +.activate-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem 1.5rem; + margin-bottom: 1rem; +} + +.activate-header h1 { + margin: 0; + font-size: 1.35rem; + color: var(--accent); +} + +.activate-sub { + margin: 0.25rem 0 0; + font-size: 0.85rem; + color: var(--muted); +} + +.activate-account { + margin: 0; + padding: 0.45rem 0.75rem; + border-radius: 8px; + background: var(--accent-soft); + font-size: 0.8rem; + color: var(--accent); + white-space: nowrap; +} + +.factor-progress { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.factor-progress-bar { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.factor-progress-bar span { + display: block; + height: 100%; + width: 0; + background: linear-gradient(90deg, var(--ok), #40916c); + border-radius: 999px; + transition: width 0.25s ease; +} + +#factor-progress-text { + font-size: 0.78rem; + font-weight: 600; + color: var(--muted); + white-space: nowrap; +} + +.factor-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.85rem; +} + +.factor-tile { + display: flex; + flex-direction: column; + gap: 0.5rem; + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.85rem; + background: #fff; + min-height: 0; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.factor-tile-done { + border-color: #b7dfc9; + background: var(--ok-bg); + box-shadow: inset 0 0 0 1px rgba(45, 106, 79, 0.15); +} + +.factor-tile-head { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.factor-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-weight: 700; + font-size: 0.75rem; + flex-shrink: 0; +} + +.factor-desc { + margin: 0.15rem 0 0; + font-size: 0.72rem; + color: var(--muted); + line-height: 1.3; +} + +.factor-check { + margin-left: auto; + color: var(--ok); + font-weight: 700; + font-size: 1rem; +} + +.factor-label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: #3a3530; + margin: 0; +} + +.factor-label input { + display: block; + width: 100%; + margin-top: 0.3rem; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + font-size: 0.9rem; + text-align: center; + letter-spacing: 0.15em; +} + +.factor-tile-totp .factor-label input { + letter-spacing: 0.2em; +} + +.qr-panel { + text-align: center; +} + +.qr-label { + margin: 0 0 0.35rem; + font-size: 0.72rem; + font-weight: 600; + color: var(--accent); +} + +.qr-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 148px; + padding: 0.35rem; + background: #fff; + border-radius: 10px; + border: 1px dashed var(--border); +} + +.qr-placeholder { + margin: 0; + font-size: 0.75rem; + color: var(--muted); +} + +.qr-error { + margin: 0.25rem 0 0; + font-size: 0.72rem; + color: var(--danger); +} + +.ntfy-box { + padding: 0.4rem 0.5rem; + border-radius: 8px; + background: #f8f6f2; + border: 1px solid var(--border); + font-size: 0.72rem; +} + +.ntfy-title { + margin: 0 0 0.2rem; + color: var(--muted); + font-weight: 600; +} + +.ntfy-link { + color: var(--accent); + font-weight: 600; + word-break: break-all; +} + +.btn-block { + width: 100%; +} + +.activate-submit { + margin-top: 0.25rem; +} + +.activate-feedback { + margin: 0.75rem 0 0; +} + +.activate-footer { + margin-top: 1rem; + text-align: center; +} + +@media (max-width: 860px) { + .factor-grid { + grid-template-columns: 1fr; + } + + .activate-card { + padding: 1.25rem; + } +} + +.btn-sm { + font-size: 0.8rem; + padding: 0.35rem 0.65rem; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + background: var(--card); + border: 1px solid var(--border); +} + +.status-pill.ok { background: var(--ok-bg); color: var(--ok); border-color: #b7dfc9; } +.status-pill.err { background: #fde8e8; color: var(--danger); border-color: #f5c2c2; } + +.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; } + +/* Dashboard — KPI strip premium compacto */ +.dashboard-top { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-bottom: 1rem; +} + +.dashboard-kpis { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: stretch; +} + +.kpi-card { + position: relative; + flex: 1 1 0; + min-width: 108px; + max-width: 160px; + background: linear-gradient(160deg, #fffdf9 0%, #faf6f0 100%); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.55rem 0.7rem 0.5rem; + box-shadow: 0 1px 3px rgba(92, 46, 46, 0.05), 0 4px 14px rgba(92, 46, 46, 0.04); + overflow: hidden; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.kpi-card--click { + cursor: pointer; +} +.kpi-card--click:hover { + transform: translateY(-1px); + border-color: #c9a8a8; + box-shadow: 0 6px 18px rgba(92, 46, 46, 0.1); +} + +.kpi-card-glow { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + opacity: 0.85; +} + +.kpi-card--open .kpi-card-glow { background: linear-gradient(90deg, #5c2e2e, #8b5a5a); } +.kpi-card--assisting .kpi-card-glow { background: linear-gradient(90deg, #1d4e89, #4a7ab8); } +.kpi-card--escalated .kpi-card-glow { background: linear-gradient(90deg, #9b2226, #c45c5c); } +.kpi-card--sessions .kpi-card-glow { background: linear-gradient(90deg, #b5651d, #d4a574); } +.kpi-card--leads .kpi-card-glow { background: linear-gradient(90deg, #7c3aed, #a78bfa); } + +.kpi-card-inner { + display: flex; + flex-direction: column; + gap: 0.1rem; + line-height: 1.15; +} + +.kpi-value { + font-size: 1.35rem; + font-weight: 700; + color: var(--accent); + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.kpi-label { + font-size: 0.68rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.dashboard-pulse { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: stretch; +} + +.pulse-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex: 1 1 auto; + min-width: min(100%, 200px); + max-width: 100%; + padding: 0.45rem 0.65rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.78rem; + box-shadow: 0 1px 2px rgba(92, 46, 46, 0.04); +} + +.pulse-chip--ok { border-left: 3px solid var(--ok); } +.pulse-chip--warn { border-left: 3px solid var(--warn); } +.pulse-chip--alert { border-left: 3px solid var(--danger); } + +.pulse-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); +} +.pulse-chip--ok .pulse-dot { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); } +.pulse-chip--warn .pulse-dot { background: var(--warn); } +.pulse-chip--alert .pulse-dot { background: var(--danger); } + +.pulse-body { + display: flex; + flex-direction: column; + gap: 0.05rem; + min-width: 0; + flex: 1; +} +.pulse-body strong { + font-size: 0.78rem; + font-weight: 600; + color: #3a3530; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pulse-body span { + font-size: 0.7rem; + color: var(--muted); +} + +.card-compact { + padding: 0.85rem 1rem; + margin-bottom: 0.75rem; +} +.card-compact h3 { margin-bottom: 0.65rem; font-size: 0.92rem; } +.ranking-card .ranking-table { font-size: 0.8rem; } +.ranking-card .ranking-table th, +.ranking-card .ranking-table td { padding: 0.35rem 0.45rem; } + +@media (max-width: 720px) { + .dashboard-kpis { display: grid; grid-template-columns: repeat(3, 1fr); } + .kpi-card { max-width: none; min-width: 0; } +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1.15rem 1.25rem; + box-shadow: var(--shadow); + margin-bottom: 1rem; +} + +.card h3 { + margin: 0 0 1rem; + font-size: 1rem; + color: var(--accent); +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 340px; + gap: 1rem; + align-items: start; +} + +@media (max-width: 960px) { + .shell { grid-template-columns: 1fr; } + .sidebar { flex-direction: column; padding: 1rem 0 0.75rem; } + .brand { padding: 0 1rem 1rem; margin: 0 0 0.45rem; } + .brand h1 { font-size: 1.8rem; } + .brand p { font-size: 0.92rem; margin-top: 0.55rem; } + .nav { display: block; padding: 0.35rem 0.75rem; } + .nav button { + width: 100%; + min-height: 64px; + padding: 0.75rem 0.95rem; + border-radius: 12px; + } + .nav-icon-wrap { width: 56px; height: 56px; flex-basis: 56px; } + .nav-icon-svg { width: 46px; height: 46px; } + .sidebar-footer { display: none; } + .grid-2 { grid-template-columns: 1fr; } +} + +.toolbar { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.filter-btn { + border: 1px solid var(--border); + background: var(--card); + color: var(--muted); + padding: 0.4rem 0.85rem; + border-radius: 999px; + font: inherit; + font-size: 0.82rem; + cursor: pointer; +} + +.filter-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.ticket-list { display: flex; flex-direction: column; gap: 0.65rem; } + +.ticket-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.85rem; + align-items: center; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: 12px; + background: #fff; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + text-align: left; + width: 100%; + font: inherit; + color: inherit; +} + +.ticket-row:hover { border-color: var(--accent); box-shadow: var(--shadow); } +.ticket-row.selected { border-color: var(--accent); background: var(--accent-soft); } + +.badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 6px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badge.open { background: var(--warn-bg); color: var(--warn); } +.badge.closed { background: #eee; color: var(--muted); } +.badge.escalated { background: #fde8e8; color: #9b2226; } +.badge.assisting { background: #e8f0fd; color: #1d4e89; } +.badge.resolved { background: var(--ok-bg); color: var(--ok); } +.badge.assist-observing { background: #f0ebe3; color: var(--muted); } + + +.assist-panel { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #faf7f2; +} +.assist-panel h4 { + margin: 0 0 0.75rem; + font-size: 0.95rem; +} +.badge.review { background: #fde8e8; color: var(--danger); } +.badge.wazuh { background: #ede9fe; color: #5b21b6; } +.badge.onboard { background: #e8f5ee; color: var(--ok); } +.badge.sev-critical { background: #fde8e8; color: #9b2226; } +.badge.sev-high { background: #fef3e8; color: var(--warn); } +.badge.sev-med { background: #fff8e6; color: #a16207; } +.badge.sev-low { background: #eee; color: var(--muted); } +.toolbar-sep { color: var(--border); padding: 0 0.25rem; } + +.ticket-meta { font-size: 0.78rem; color: var(--muted); margin-top: 0.2rem; } +.ticket-meta--hash { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; +} +.session-hash { + font-size: 0.68rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + background: #f0ebe3; + color: #5c5348; + padding: 0.1rem 0.35rem; + border-radius: 4px; + word-break: break-all; +} +.ticket-subject { font-weight: 600; font-size: 0.92rem; color: #2a2520; } + +.detail-panel { position: sticky; top: 1rem; } + +.detail-panel .empty { + color: var(--muted); + font-size: 0.9rem; + text-align: center; + padding: 2rem 1rem; +} + +.kv { display: grid; grid-template-columns: 110px 1fr; gap: 0.35rem 0.75rem; font-size: 0.88rem; margin-bottom: 1rem; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; word-break: break-word; } + +.btn { + border: none; + border-radius: 8px; + padding: 0.55rem 1rem; + font: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); } +.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } + +.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; } + +.event-list { list-style: none; margin: 0; padding: 0; } +.event-list li { + padding: 0.65rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; +} +.event-list li:last-child { border-bottom: none; } + +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +th, td { text-align: left; padding: 0.65rem 0.5rem; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; } + +.hidden { display: none !important; } +.loading { color: var(--muted); font-size: 0.9rem; padding: 1rem; } + +.infra-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +pre.raw { + background: #1e293b; + color: #e2e8f0; + padding: 0.85rem; + border-radius: 8px; + font-size: 0.75rem; + overflow: auto; + max-height: 200px; +} + +.view { display: none; } +.view.active { display: block; } + +.funnel-row { + display: grid; + grid-template-columns: 7.5rem 1fr 2rem; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.45rem; + font-size: 0.85rem; +} + +.funnel-label { color: var(--muted); } +.funnel-bar { + height: 8px; + background: var(--border); + border-radius: 999px; + overflow: hidden; +} +.funnel-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), #8b4513); + border-radius: 999px; + min-width: 4px; +} +.funnel-count { text-align: right; font-size: 0.85rem; } + +/* Dashboard — layout 3 colunas (funil | sessões | tickets) */ +.dashboard-ops { + display: grid; + grid-template-columns: minmax(180px, 210px) minmax(0, 1fr) minmax(190px, 230px) minmax(190px, 230px); + grid-template-areas: "funnel sessions leads tickets"; + gap: 1rem; + align-items: stretch; + min-height: 340px; + margin-bottom: 0.75rem; +} + +.dashboard-funnel { grid-area: funnel; } +.dashboard-sessions-panel { grid-area: sessions; } +.dashboard-leads-panel { grid-area: leads; } +.dashboard-tickets { grid-area: tickets; } + +.dashboard-ops:not(:has(.dashboard-leads-panel)) { + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) minmax(220px, 280px); + grid-template-areas: "funnel sessions tickets"; +} + +.dashboard-leads-panel .lead-row, +.lead-grid .lead-row { + display: block; + width: 100%; + text-align: left; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem 0.7rem; + margin-bottom: 0.5rem; + background: #fff8f8; + cursor: pointer; + font: inherit; +} +.dashboard-leads-panel .lead-row:hover, +.lead-grid .lead-row:hover { + border-color: #c45c5c; +} +.lead-row-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.2rem; +} +.lead-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.65rem; +} + +.assist-console-actions, +.assist-console-links { + margin-top: 0.85rem; + padding-top: 0.65rem; + border-top: 1px dashed var(--border); +} +.assist-console-actions h5, +.assist-console-links h5 { + margin: 0 0 0.45rem; + font-size: 0.78rem; + color: var(--muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.btn-danger { color: #b91c1c; border-color: #fecaca; } +.audit-mini { + margin: 0.35rem 0 0; + padding-left: 1.1rem; + font-size: 0.78rem; + color: var(--muted); +} +.ranking-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.ranking-table th, +.ranking-table td { + padding: 0.45rem 0.55rem; + border-bottom: 1px solid var(--border); + text-align: left; +} +.ranking-table th { color: var(--muted); font-weight: 600; font-size: 0.75rem; } + +.dashboard-ops > .card { + margin-bottom: 0; + display: flex; + flex-direction: column; + min-height: 0; +} + +.dashboard-funnel h3 { flex-shrink: 0; } + +.dashboard-sessions-panel { + flex: 1; + min-width: 0; +} + +.dashboard-tickets .ticket-list-compact { + flex: 1; + overflow-y: auto; + max-height: 420px; + padding-right: 0.15rem; +} + +.card-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; + flex-shrink: 0; +} +.card-head-row h3 { margin: 0; } + +.session-legend { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; + font-size: 0.72rem; + color: var(--muted); +} +.session-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.dot-assisting, .dot-observing { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; +} +.dot-assisting { background: #1d4e89; } +.dot-observing { background: var(--warn); } + +.session-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: 0.65rem; + flex: 1; + overflow-y: auto; + align-content: start; + max-height: 420px; + padding-right: 0.15rem; +} + +.session-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.3rem; + padding: 0.7rem 0.75rem; + border: 1px solid var(--border); + border-radius: 10px; + background: #fffdf9; + text-align: left; + font: inherit; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s; + min-height: 108px; +} + +.session-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +.session-card.stale { opacity: 0.72; } + +.session-card--assisting { + border-color: #93b4e0; + background: linear-gradient(145deg, #f8fbff 0%, #fffdf9 100%); +} +.session-card--escalated { + border-color: #e8b4b4; + background: linear-gradient(145deg, #fff8f8 0%, #fffdf9 100%); +} + +.session-card-top { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} + +.session-card-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); +} +.session-card--assisting .session-card-dot { background: #1d4e89; box-shadow: 0 0 0 3px #e8f0fd; } +.session-card--escalated .session-card-dot { background: #9b2226; box-shadow: 0 0 0 3px #fde8e8; } +.session-card--observing .session-card-dot { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); } + +.session-card-domain { + font-size: 0.92rem; + color: var(--accent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-card-stage { + font-size: 0.8rem; + color: var(--muted); + font-weight: 500; +} + +.session-card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + color: var(--muted); +} +.session-card-meta code, +.session-card-meta .session-hash { + font-size: 0.65rem; + background: #f0ebe3; + padding: 0.1rem 0.35rem; + border-radius: 4px; + word-break: break-all; + line-height: 1.35; +} +.session-card-ticket { + font-weight: 600; + color: var(--warn); +} + +.session-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: auto; + padding-top: 0.25rem; +} + +@media (max-width: 1200px) { + .dashboard-ops { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "funnel tickets" + "sessions leads" + "sessions sessions"; + } + .dashboard-funnel { grid-area: funnel; } + .dashboard-sessions-panel { grid-area: sessions; } + .dashboard-leads-panel { grid-area: leads; } + .dashboard-tickets { grid-area: tickets; } + .session-grid { max-height: 320px; } +} + +@media (max-width: 720px) { + .dashboard-ops { + grid-template-columns: 1fr; + grid-template-areas: "funnel" "sessions" "leads" "tickets"; + } + .session-grid { + grid-template-columns: repeat(2, 1fr); + max-height: none; + } +} + +.timeline { + list-style: none; + margin: 0; + padding: 0 0 0 0.5rem; + border-left: 2px solid var(--border); +} +.timeline-item { + position: relative; + padding: 0 0 1rem 1.25rem; +} +.timeline-dot { + position: absolute; + left: -0.45rem; + top: 0.35rem; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.health-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.health-card { + border-left: 4px solid var(--border); +} +.health-card.health-healthy { border-left-color: var(--ok); } +.health-card.health-degraded { border-left-color: var(--warn); } +.health-card.health-critical { border-left-color: var(--danger); } + +.health-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.health-score { + font-size: 1.1rem; + font-weight: 600; + margin: 0.35rem 0; +} + +.issue-list { + margin: 0.5rem 0 0; + padding-left: 1.1rem; + font-size: 0.85rem; + color: var(--muted); +} + +.btn-sm { + padding: 0.35rem 0.65rem; + font-size: 0.8rem; +} + +.account-pwd-form label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: #3a3530; +} + +.account-pwd-form input { + display: block; + width: 100%; + margin-top: 0.35rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; +} + +.account-kv { + margin: 0; + font-size: 0.85rem; +} + +.account-kv dt { + color: var(--muted); + font-weight: 600; + margin-top: 0.5rem; +} + +.account-kv dd { + margin: 0.15rem 0 0; +} + +button.health-card { + width: 100%; + text-align: left; + font: inherit; + color: inherit; +} + +.health-card--click { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.health-card--click:hover { + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(92, 46, 46, 0.12); +} + +.health-card-hint { + margin: 0.65rem 0 0; + font-size: 0.72rem; + color: var(--muted); +} + +.modal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; +} + +.modal.hidden { + display: none !important; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(20, 12, 14, 0.55); + backdrop-filter: blur(2px); +} + +.modal-panel { + position: relative; + width: min(100%, 720px); + max-height: min(88vh, 900px); + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(42, 37, 32, 0.22); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-panel-lg { + width: min(100%, 980px); +} + +.vm112-modal-shell { + position: relative; + display: flex; + align-items: stretch; + gap: 0; + max-width: min(96vw, 1320px); + max-height: min(88vh, 900px); +} +.vm112-modal-shell .modal-panel { + flex: 1 1 auto; + min-width: 0; + max-height: min(88vh, 900px); +} +.vm112-purge-drawer { + flex: 0 0 0; + width: 0; + opacity: 0; + overflow: hidden; + background: #fffdf9; + border: 1px solid var(--border); + border-left: none; + border-radius: 0 16px 16px 0; + display: flex; + flex-direction: column; + transition: width 0.35s ease, opacity 0.3s ease, flex-basis 0.35s ease; +} +.vm112-modal-shell--purge-open .vm112-purge-drawer { + flex: 0 0 min(340px, 38vw); + width: min(340px, 38vw); + opacity: 1; + border-left: 1px solid var(--border); + box-shadow: -8px 0 24px rgba(42, 37, 32, 0.08); +} +.vm112-purge-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--border); + background: #faf8f4; +} +.vm112-purge-drawer-head h4 { + margin: 0; + font-size: 0.9rem; + color: var(--accent); +} +.vm112-purge-elapsed { + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + color: var(--muted, #6b7280); + background: #f1f5f9; + padding: 0.15rem 0.45rem; + border-radius: 6px; +} +.vm112-purge-timeline { + list-style: none; + margin: 0; + padding: 0.75rem 0.85rem 1rem; + overflow-y: auto; + flex: 1; +} +.vm112-purge-step { + display: grid; + grid-template-columns: 4.5rem 1fr; + gap: 0.5rem 0.65rem; + padding: 0.5rem 0; + border-left: 2px solid #e8e4dc; + margin-left: 0.35rem; + padding-left: 0.75rem; + position: relative; +} +.vm112-purge-step::before { + content: ''; + position: absolute; + left: -5px; + top: 0.65rem; + width: 8px; + height: 8px; + border-radius: 50%; + background: #cbd5e1; +} +.vm112-purge-step--ok::before { background: #16a34a; } +.vm112-purge-step--fail::before { background: #dc2626; } +.vm112-purge-step--running::before { background: #2f6fed; } +.vm112-purge-step--pulse::before { + animation: vm112-purge-pulse 1s ease infinite; +} +@keyframes vm112-purge-pulse { + 0%, 100% { box-shadow: 0 0 0 0 #2f6fed66; } + 50% { box-shadow: 0 0 0 6px #2f6fed00; } +} +.vm112-purge-step-time { + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + color: var(--muted, #6b7280); + padding-top: 0.1rem; +} +.vm112-purge-step-body strong { + display: block; + font-size: 0.8rem; + line-height: 1.3; +} +.vm112-purge-step-body span { + display: block; + font-size: 0.72rem; + color: var(--muted, #6b7280); + margin-top: 0.15rem; +} +@media (max-width: 900px) { + .vm112-modal-shell { + flex-direction: column; + max-height: 92vh; + } + .vm112-modal-shell--purge-open .vm112-purge-drawer { + flex: 0 0 auto; + width: 100%; + max-height: 42vh; + border-left: none; + border-top: 1px solid var(--border); + border-radius: 0 0 16px 16px; + } +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.1rem 1.25rem; + border-bottom: 1px solid var(--border); + background: #fffdf9; +} + +.modal-header h3 { + margin: 0; + font-size: 1.1rem; + color: var(--accent); +} + +.modal-body { + padding: 1rem 1.25rem 1.25rem; + overflow: auto; +} + +.overview-summary { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.overview-stat { + flex: 1 1 120px; + min-width: 110px; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; +} + +.overview-stat strong { + display: block; + font-size: 1.2rem; + color: var(--accent); +} + +.overview-stat span { + font-size: 0.72rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.overview-domain-row { + width: 100%; + text-align: left; + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem 0.85rem; + margin-bottom: 0.5rem; + background: #fff; + cursor: pointer; + font: inherit; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.overview-domain-row:hover { + border-color: var(--accent); + box-shadow: var(--shadow); +} + +.overview-domain-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.overview-domain-top strong { + font-size: 0.95rem; + color: var(--accent); +} + +.overview-domain-meta { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin-top: 0.35rem; + font-size: 0.78rem; + color: var(--muted); +} + +.overview-domain-issues { + margin: 0.45rem 0 0; + padding-left: 1rem; + font-size: 0.78rem; + color: var(--muted); +} + +.modal-breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.85rem; + font-size: 0.82rem; +} + +.modal-section { + margin-top: 1rem; +} + +.modal-section h4 { + margin: 0 0 0.55rem; + font-size: 0.88rem; + color: var(--accent); +} + +.detail-kv { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.55rem 0.85rem; + margin-bottom: 0.75rem; +} + +.detail-kv div { + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + font-size: 0.82rem; +} + +.detail-kv dt { + display: block; + color: var(--muted); + font-size: 0.72rem; + margin-bottom: 0.15rem; +} + +.detail-kv dd { + margin: 0; + word-break: break-word; +} + +.timeline-compact .timeline-item { + padding-bottom: 0.75rem; +} + +.timeline-compact .timeline-dot { + width: 8px; + height: 8px; + left: -0.4rem; +} + +.nav-badge-new { + display: inline-block; + margin-left: 0.35rem; + padding: 0.08rem 0.38rem; + border-radius: 999px; + background: rgba(47, 111, 237, 0.18); + color: #93c5fd; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + vertical-align: middle; +} + +.nav-item-overview-home .nav-icon-svg { + width: 50px; + height: 50px; +} + +/* ── Overview Home (Cloudflare Account Home style) ── */ +.cf-home { + --cf-bg: #f6f8fb; + --cf-card: #ffffff; + --cf-border: #e2e8f0; + --cf-text: #1e293b; + --cf-muted: #64748b; + --cf-blue: #2f6fed; + --cf-blue-soft: #eef4ff; + --cf-red: #dc2626; + --cf-green: #16a34a; + color: var(--cf-text); +} + +.cf-home-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.cf-home-toolbar-left { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.cf-select { + border: 1px solid var(--cf-border); + border-radius: 8px; + padding: 0.45rem 0.65rem; + font: inherit; + font-size: 0.85rem; + background: var(--cf-card); + color: var(--cf-text); +} + +.cf-metrics-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.85rem; + margin-bottom: 0.85rem; +} + +.cf-metric-card { + background: var(--cf-card); + border: 1px solid var(--cf-border); + border-radius: 10px; + padding: 0.95rem 1rem; + min-height: 132px; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.cf-metric-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.cf-metric-title { + font-size: 0.78rem; + font-weight: 700; + color: var(--cf-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cf-metric-body { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.35rem 0.75rem; + align-items: end; +} + +.cf-metric-stat strong { + display: block; + font-size: 1.35rem; + line-height: 1.1; + color: var(--cf-text); +} + +.cf-metric-stat span { + display: block; + font-size: 0.78rem; + color: var(--cf-muted); + margin-top: 0.15rem; +} + +.cf-trend-up { color: var(--cf-green); font-size: 0.75rem; font-weight: 600; } +.cf-trend-down { color: var(--cf-red); font-size: 0.75rem; font-weight: 600; } +.cf-trend-neutral { color: var(--cf-muted); font-size: 0.75rem; font-weight: 600; } + +.cf-spark-wrap { + grid-row: span 2; + align-self: center; +} + +.cf-spark { + display: block; +} + +.cf-grid-2 { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 0.85rem; + margin-bottom: 0.85rem; +} + +.cf-panel { + background: var(--cf-card); + border: 1px solid var(--cf-border); + border-radius: 10px; + overflow: hidden; +} + +.cf-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--cf-border); +} + +.cf-panel-head h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: var(--cf-text); +} + +.cf-panel-actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.cf-icon-btn { + border: 1px solid var(--cf-border); + background: #fff; + color: var(--cf-muted); + border-radius: 8px; + width: 30px; + height: 30px; + cursor: pointer; + font: inherit; + font-size: 0.95rem; + line-height: 1; +} + +.cf-icon-btn:hover { + border-color: var(--cf-blue); + color: var(--cf-blue); +} + +.cf-panel-body { + padding: 0.35rem 0; +} + +.cf-domain-row, +.cf-trail-row, +.cf-node-row, +.cf-step-row { + width: 100%; + text-align: left; + border: none; + background: transparent; + font: inherit; + color: inherit; + cursor: pointer; + display: grid; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid #f1f5f9; + transition: background 0.12s; +} + +.cf-domain-row:hover, +.cf-trail-row:hover, +.cf-node-row:hover, +.cf-step-row:hover { + background: #f8fafc; +} + +.cf-domain-row:last-child, +.cf-trail-row:last-child, +.cf-node-row:last-child, +.cf-step-row:last-child { + border-bottom: none; +} + +.cf-domain-row { + grid-template-columns: auto 1fr auto auto; +} + +.cf-domain-status { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.cf-domain-status.ok { background: var(--cf-green); } +.cf-domain-status.warn { background: #f59e0b; } +.cf-domain-status.bad { background: var(--cf-red); } +.cf-domain-status.unknown { background: #94a3b8; } + +.cf-domain-main strong { + display: block; + font-size: 0.88rem; + color: var(--cf-text); +} + +.cf-domain-main span { + display: block; + font-size: 0.75rem; + color: var(--cf-muted); + margin-top: 0.12rem; +} + +.cf-domain-metric { + font-size: 0.82rem; + font-weight: 600; + color: var(--cf-blue); + min-width: 2.5rem; + text-align: right; +} + +.cf-trail-row { + grid-template-columns: 1fr auto auto; +} + +.cf-trail-action { + font-size: 0.84rem; + color: var(--cf-text); +} + +.cf-trail-target { + font-size: 0.78rem; + color: var(--cf-blue); +} + +.cf-trail-time { + font-size: 0.75rem; + color: var(--cf-muted); + white-space: nowrap; +} + +.cf-tabs { + display: flex; + gap: 0.25rem; +} + +.cf-tab { + border: none; + background: transparent; + color: var(--cf-muted); + font: inherit; + font-size: 0.78rem; + font-weight: 600; + padding: 0.35rem 0.55rem; + border-radius: 6px; + cursor: pointer; +} + +.cf-tab.active { + background: var(--cf-blue-soft); + color: var(--cf-blue); +} + +.cf-node-row { + grid-template-columns: 1fr auto; +} + +.cf-node-row strong { + display: block; + font-size: 0.88rem; +} + +.cf-node-row span { + display: block; + font-size: 0.75rem; + color: var(--cf-muted); + margin-top: 0.12rem; +} + +.cf-step-row { + grid-template-columns: 1fr auto; + cursor: default; +} + +.cf-step-row button { + cursor: pointer; +} + +.cf-step-row strong { + font-size: 0.84rem; +} + +.cf-step-row span { + display: block; + font-size: 0.75rem; + color: var(--cf-muted); + margin-top: 0.12rem; +} + +.cf-empty { + padding: 1.25rem 1rem; + font-size: 0.85rem; + color: var(--cf-muted); +} + +@media (max-width: 1100px) { + .cf-metrics-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cf-grid-2 { grid-template-columns: 1fr; } +} + +@media (max-width: 720px) { + .cf-metrics-row { grid-template-columns: 1fr; } + .cf-domain-row { + grid-template-columns: auto 1fr; + } + .cf-domain-metric, + .cf-domain-row .badge { + grid-column: 2; + } +} + +.dns-purpose-badge { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 5px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + background: #eef4ff; + color: #2f6fed; +} + +.dns-purpose-badge.purpose-mx { background: #e8f5ee; color: #2d6a4f; } +.dns-purpose-badge.purpose-spf { background: #fef3e8; color: #b5651d; } +.dns-purpose-badge.purpose-dkim { background: #ede9fe; color: #5b21b6; } +.dns-purpose-badge.purpose-dmarc { background: #fde8e8; color: #9b2226; } +.dns-purpose-badge.purpose-mail-host { background: #e8f0fd; color: #1d4e89; } + +.dns-records-table td code { + font-size: 0.76rem; + word-break: break-all; +} + +.dns-records-table .dns-content { + max-width: 280px; + word-break: break-word; + font-size: 0.8rem; +} + +.cf-dns-metric-card { + min-height: 132px; +} + +.cf-dns-metric-card .cf-metric-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.cf-dns-metric-card .cf-metric-title { + font-size: 0.72rem; + line-height: 1.2; +} + +.cf-dns-panel-body { + flex: 1; + min-height: 0; + overflow: auto; + max-height: 200px; +} + +.cf-dns-empty, +.cf-dns-error, +.cf-dns-meta { + margin: 0; + font-size: 0.78rem; + color: var(--cf-muted, #64748b); + line-height: 1.45; +} + +.cf-dns-error { + color: #b91c1c; +} + +.cf-dns-inline-summary { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.45rem; +} + +.cf-dns-inline-summary .cf-metric-stat strong { + font-size: 1.15rem; +} + +.cf-dns-inline-summary .cf-metric-stat span { + font-size: 0.68rem; +} + +.cf-dns-table-wrap { + overflow: auto; + max-height: 120px; +} + +.dns-records-table-compact { + font-size: 0.72rem; +} + +.dns-records-table-compact th, +.dns-records-table-compact td { + padding: 0.25rem 0.35rem; +} + +.dns-records-table-compact .dns-content { + max-width: 140px; +} + +.modal-dns-highlight, +.dns-records-section { + border: 1px solid #dbe4f4; + border-radius: 10px; + padding: 0.85rem 1rem; + background: #f8fbff; +} + +/* ── Infra 2 — SOC Operations Console (dark, scoped) ── */ +.soc-console { + --soc-bg: #0b0f14; + --soc-surface: #121820; + --soc-surface-2: #1a2230; + --soc-border: rgba(56, 189, 248, 0.14); + --soc-border-strong: rgba(56, 189, 248, 0.28); + --soc-text: #e8edf4; + --soc-muted: #8b9cb3; + --soc-cyan: #22d3ee; + --soc-blue: #38bdf8; + --soc-green: #34d399; + --soc-amber: #fbbf24; + --soc-red: #f87171; + --soc-purple: #a78bfa; + font-family: 'DM Sans', system-ui, sans-serif; + background: radial-gradient(ellipse 120% 80% at 50% -20%, rgba(34, 211, 238, 0.08), transparent 55%), + linear-gradient(180deg, #0d1219 0%, var(--soc-bg) 100%); + border: 1px solid var(--soc-border); + border-radius: 14px; + padding: 1rem 1.1rem 1.25rem; + color: var(--soc-text); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.soc-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--soc-border); +} + +.soc-header-left { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.soc-header-left h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--soc-text); +} + +.soc-live-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--soc-green); + box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6); + animation: soc-pulse 2s ease-in-out infinite; +} + +.soc-live-dot.warn { background: var(--soc-amber); box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5); animation-name: soc-pulse-warn; } +.soc-live-dot.critical { background: var(--soc-red); box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.55); animation-name: soc-pulse-critical; } + +@keyframes soc-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.55); } + 50% { box-shadow: 0 0 0 8px rgba(52, 211, 153, 0); } +} +@keyframes soc-pulse-warn { + 0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); } + 50% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); } +} +@keyframes soc-pulse-critical { + 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.5); } + 50% { box-shadow: 0 0 0 10px rgba(248, 113, 113, 0); } +} + +.soc-meta { + font-size: 0.72rem; + color: var(--soc-muted); + font-variant-numeric: tabular-nums; +} + +.soc-header-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.soc-select { + background: var(--soc-surface-2); + border: 1px solid var(--soc-border); + color: var(--soc-text); + border-radius: 8px; + padding: 0.4rem 0.55rem; + font-size: 0.78rem; + font: inherit; +} + +.soc-btn { + background: rgba(56, 189, 248, 0.12); + border: 1px solid var(--soc-border-strong); + color: var(--soc-cyan); + border-radius: 8px; + padding: 0.4rem 0.7rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + font: inherit; +} +.soc-btn:hover { background: rgba(56, 189, 248, 0.22); } +.soc-btn--ghost { + background: transparent; + border-color: var(--soc-border); + color: var(--soc-muted); +} + +.soc-kpi-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.55rem; + margin-bottom: 0.85rem; +} + +.soc-kpi { + background: var(--soc-surface); + border: 1px solid var(--soc-border); + border-radius: 10px; + padding: 0.65rem 0.75rem; + position: relative; + overflow: hidden; +} +.soc-kpi::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: var(--soc-kpi-accent, var(--soc-blue)); + opacity: 0.85; +} +.soc-kpi--ok { --soc-kpi-accent: var(--soc-green); } +.soc-kpi--warn { --soc-kpi-accent: var(--soc-amber); } +.soc-kpi--critical { --soc-kpi-accent: var(--soc-red); } +.soc-kpi--info { --soc-kpi-accent: var(--soc-cyan); } + +.soc-kpi-label { + display: block; + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--soc-muted); + margin-bottom: 0.25rem; +} +.soc-kpi-value { + font-size: 1.35rem; + font-weight: 700; + line-height: 1.1; + font-variant-numeric: tabular-nums; + color: var(--soc-text); +} +.soc-kpi-sub { + font-size: 0.68rem; + color: var(--soc-muted); + margin-top: 0.15rem; +} + +.soc-topology { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.35rem 0.5rem; + padding: 0.65rem 0.85rem; + margin-bottom: 0.85rem; + background: var(--soc-surface); + border: 1px solid var(--soc-border); + border-radius: 10px; + font-size: 0.75rem; +} + +.soc-node { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.65rem; + background: var(--soc-surface-2); + border: 1px solid var(--soc-border); + border-radius: 8px; + font-weight: 600; +} +.soc-node-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--soc-muted); +} +.soc-node-dot.ok { background: var(--soc-green); box-shadow: 0 0 6px rgba(52, 211, 153, 0.6); } +.soc-node-dot.warn { background: var(--soc-amber); } +.soc-node-dot.bad { background: var(--soc-red); box-shadow: 0 0 6px rgba(248, 113, 113, 0.55); } + +.soc-flow { + color: var(--soc-muted); + font-size: 0.68rem; + letter-spacing: 0.04em; +} +.soc-flow strong { color: var(--soc-cyan); font-weight: 600; } + +.soc-main-grid { + display: grid; + grid-template-columns: 1.15fr 1fr 0.95fr; + gap: 0.65rem; + margin-bottom: 0.65rem; +} + +.soc-panel { + background: var(--soc-surface); + border: 1px solid var(--soc-border); + border-radius: 10px; + display: flex; + flex-direction: column; + min-height: 280px; + overflow: hidden; +} + +.soc-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--soc-border); + background: rgba(0, 0, 0, 0.15); +} +.soc-panel-head h4 { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--soc-muted); +} +.soc-panel-body { + flex: 1; + padding: 0.55rem 0.65rem; + overflow: auto; + min-height: 0; +} + +.soc-feed { + width: 100%; + border-collapse: collapse; + font-size: 0.72rem; +} +.soc-feed th { + text-align: left; + color: var(--soc-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.62rem; + padding: 0.35rem 0.4rem; + border-bottom: 1px solid var(--soc-border); + position: sticky; + top: 0; + background: var(--soc-surface); +} +.soc-feed td { + padding: 0.4rem 0.4rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} +.soc-feed tr:hover td { background: rgba(56, 189, 248, 0.06); } +.soc-feed tr.soc-feed-row--new td { animation: soc-row-flash 1.2s ease-out; } + +@keyframes soc-row-flash { + from { background: rgba(34, 211, 238, 0.18); } + to { background: transparent; } +} + +.soc-sev { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 0.35rem; + vertical-align: middle; +} +.soc-sev--info { background: var(--soc-cyan); } +.soc-sev--warn { background: var(--soc-amber); } +.soc-sev--high { background: var(--soc-red); } +.soc-sev--ok { background: var(--soc-green); } + +.soc-event-name { color: var(--soc-text); font-weight: 500; } +.soc-event-domain { color: var(--soc-blue); font-family: ui-monospace, monospace; font-size: 0.68rem; } +.soc-event-time { color: var(--soc-muted); font-variant-numeric: tabular-nums; white-space: nowrap; } + +.soc-chart-wrap { + padding: 0.35rem 0.25rem 0.15rem; +} +.soc-chart-legend { + display: flex; + justify-content: space-between; + font-size: 0.65rem; + color: var(--soc-muted); + padding: 0 0.25rem 0.35rem; +} + +.soc-area-chart { + width: 100%; + height: auto; + display: block; +} + +.soc-pipeline { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.5rem; +} +.soc-pipe-row { + display: grid; + grid-template-columns: 5.5rem 1fr 1.75rem; + align-items: center; + gap: 0.4rem; + font-size: 0.68rem; +} +.soc-pipe-label { color: var(--soc-muted); } +.soc-pipe-bar { + height: 7px; + background: rgba(255, 255, 255, 0.06); + border-radius: 99px; + overflow: hidden; +} +.soc-pipe-fill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, var(--soc-blue), var(--soc-cyan)); + transition: width 0.4s ease; +} +.soc-pipe-count { + text-align: right; + font-weight: 700; + color: var(--soc-text); + font-variant-numeric: tabular-nums; +} + +.soc-session-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.soc-session-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.45rem; + align-items: center; + padding: 0.45rem 0.55rem; + background: var(--soc-surface-2); + border: 1px solid var(--soc-border); + border-radius: 8px; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + width: 100%; +} +.soc-session-card:hover { border-color: var(--soc-border-strong); background: rgba(56, 189, 248, 0.08); } +.soc-session-card.stale { opacity: 0.72; border-style: dashed; } + +.soc-session-ring { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--soc-cyan); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.55rem; + font-weight: 700; + color: var(--soc-cyan); +} +.soc-session-ring.completed { border-color: var(--soc-green); color: var(--soc-green); } +.soc-session-ring.failed { border-color: var(--soc-red); color: var(--soc-red); } + +.soc-session-main strong { + display: block; + font-size: 0.78rem; + color: var(--soc-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.soc-session-main span { + font-size: 0.65rem; + color: var(--soc-muted); +} +.soc-session-hash { + display: block; + margin-top: 0.2rem; +} +.soc-session-hash .session-hash { + font-size: 0.6rem; + background: rgba(255, 255, 255, 0.06); + color: var(--soc-muted); +} + +.soc-bottom-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; +} + +.soc-alert-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.soc-alert-item { + display: flex; + align-items: flex-start; + gap: 0.45rem; + padding: 0.45rem 0.55rem; + border-radius: 8px; + font-size: 0.72rem; + border: 1px solid var(--soc-border); + background: var(--soc-surface-2); +} +.soc-alert-item--critical { border-color: rgba(248, 113, 113, 0.35); background: rgba(248, 113, 113, 0.08); } +.soc-alert-item--warn { border-color: rgba(251, 191, 36, 0.3); background: rgba(251, 191, 36, 0.06); } +.soc-alert-item--ok { border-color: rgba(52, 211, 153, 0.25); color: var(--soc-muted); } + +.soc-health-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; +} +.soc-health-card { + padding: 0.55rem 0.65rem; + background: var(--soc-surface-2); + border: 1px solid var(--soc-border); + border-radius: 8px; +} +.soc-health-card h5 { + margin: 0 0 0.35rem; + font-size: 0.72rem; + color: var(--soc-text); +} +.soc-health-card dl { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 0.15rem 0.5rem; + font-size: 0.68rem; +} +.soc-health-card dt { color: var(--soc-muted); } +.soc-health-card dd { margin: 0; color: var(--soc-text); text-align: right; font-variant-numeric: tabular-nums; } + +.soc-empty { + color: var(--soc-muted); + font-size: 0.75rem; + text-align: center; + padding: 1.5rem 0.5rem; +} + +.soc-console .badge { + font-size: 0.62rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; +} + +@media (max-width: 1200px) { + .soc-kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .soc-main-grid { grid-template-columns: 1fr; } + .soc-bottom-grid { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .soc-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .soc-health-grid { grid-template-columns: 1fr; } + .soc-topology { font-size: 0.68rem; } +} + +/* Modal resultado teste webhook (SOC) */ +.soc-test-modal-panel { + max-width: 520px; + background: #121820; + border: 1px solid rgba(56, 189, 248, 0.22); + color: #e8edf4; +} +.soc-test-modal-panel .modal-header { + border-bottom-color: rgba(56, 189, 248, 0.14); + background: rgba(0, 0, 0, 0.2); +} +.soc-test-modal-panel .modal-header h3 { + color: #e8edf4; +} +.soc-test-modal-panel .ticket-meta { + color: #8b9cb3; +} +.soc-test-result { + display: flex; + flex-direction: column; + gap: 0.85rem; +} +.soc-test-status { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.65rem 0.75rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.88rem; +} +.soc-test-status--ok { + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.35); + color: #6ee7b7; +} +.soc-test-status--fail { + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); + color: #fca5a5; +} +.soc-test-kv { + display: grid; + grid-template-columns: 8.5rem 1fr; + gap: 0.35rem 0.65rem; + margin: 0; + font-size: 0.78rem; +} +.soc-test-kv dt { + color: #8b9cb3; + margin: 0; +} +.soc-test-kv dd { + margin: 0; + color: #e8edf4; + font-family: ui-monospace, monospace; + font-size: 0.72rem; + word-break: break-all; +} +.soc-test-hint { + margin: 0; + font-size: 0.75rem; + color: #8b9cb3; + line-height: 1.45; + padding: 0.55rem 0.65rem; + background: rgba(56, 189, 248, 0.06); + border-radius: 8px; + border: 1px solid rgba(56, 189, 248, 0.12); +} +.soc-test-actions { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; + margin-top: 0.25rem; +} +.health-card--wazuh { + border-left: 3px solid #7c3aed; +} + +.wazuh-score { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + font-size: 0.78rem; + font-weight: 600; +} + +.wazuh-api-ok { color: var(--ok); } +.wazuh-api-bad { color: var(--danger); } + +.wazuh-modal-grid { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 0.75rem; +} + +.wazuh-table-wrap { + overflow: auto; + max-height: 220px; +} + +.wazuh-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.wazuh-table th, +.wazuh-table td { + text-align: left; + padding: 0.35rem 0.45rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.wazuh-table th { + color: var(--muted); + font-weight: 600; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Spec 014 — relógio por fase */ +.timing-summary { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; + margin: 0 0 0.75rem; + padding: 0.55rem 0.75rem; + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 8px; + font-size: 0.82rem; +} + +.timing-stat strong { + color: var(--accent, #38bdf8); +} + +.timing-badge { + display: inline-block; + margin-left: 0.35rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + background: rgba(34, 197, 94, 0.15); + color: #4ade80; + vertical-align: middle; +} + +.timing-badge--muted { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; +} + +.soc-timing-badge { + flex-shrink: 0; + align-self: center; +} + +/* Spec 015 — módulos */ +.modules-grid { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; +} + +.module-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} + +.module-row--locked { + opacity: 0.72; + cursor: default; +} + +.module-row-main { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.module-id { + font-size: 0.7rem; + color: var(--muted); +} + +.module-row input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; + flex-shrink: 0; +} + +.modules-admin-card { + max-width: 52rem; +} + +/* Card relógio por fase */ +.phase-timing-card { + margin-bottom: 1rem; + border: 1px solid rgba(56, 189, 248, 0.35); + background: linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(30, 41, 59, 0.88)); +} + +.phase-timing-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.phase-timing-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.65rem; + margin-bottom: 0.85rem; +} + +.phase-timing-kpi { + padding: 0.55rem 0.7rem; + border-radius: 8px; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.18); +} + +.phase-timing-kpi-label { + display: block; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin-bottom: 0.2rem; +} + +.phase-timing-kpi-value { + font-size: 1.15rem; + font-weight: 700; + color: #38bdf8; +} + +.phase-timing-kpi-value--sm { + font-size: 0.82rem; + font-weight: 600; +} + +.phase-timing-kpi-value--live { + font-variant-numeric: tabular-nums; + animation: timing-live-pulse 2s ease-in-out infinite; +} + +@keyframes timing-live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.72; } +} + +.phase-timing-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.phase-timing-table th, +.phase-timing-table td { + text-align: left; + padding: 0.4rem 0.5rem; + border-bottom: 1px solid var(--border); +} + +.phase-timing-table th { + color: var(--muted); + font-size: 0.68rem; + text-transform: uppercase; +} + +.timing-cell-delta strong { + color: #4ade80; +} + +.timing-cell-total { + color: #94a3b8; + font-size: 0.78rem; +} + +.cf-dns-panel-body .phase-timing-card { + margin-bottom: 0.85rem; +} + + +@media (max-width: 900px) { + .wazuh-modal-grid { grid-template-columns: 1fr; } +} + +/* Spec 017 — Domínios VM112 (Account Home) */ +.vm112-domains-panel { margin-bottom: 1.25rem; } +.vm112-domains-search { min-width: 14rem; max-width: 100%; } +.vm112-domains-foot { padding: 0.5rem 1rem 0.75rem; margin: 0; } +.vm112-domain-badges { display: flex; flex-wrap: wrap; gap: 0.25rem; justify-content: flex-end; } +.vm112-infra-steps { margin: 0; padding-left: 1.2rem; } +.vm112-infra-steps li.ok { color: var(--ok, #16a34a); } +.vm112-infra-steps li.fail { color: #b91c1c; } +.vm112-purge-zone { + border: 1px solid #fecaca; + background: #fff5f5; + border-radius: 8px; + padding: 1rem; + margin-top: 1rem; +} +.vm112-purge-warn { color: #7f1d1d; font-size: 0.9rem; margin-bottom: 0.75rem; } +.vm112-purge-input { + display: block; + width: 100%; + max-width: 24rem; + margin: 0.35rem 0 0.75rem; + padding: 0.45rem 0.6rem; + border: 1px solid #e5e7eb; + border-radius: 6px; +} +.vm112-purge-zone .btn-danger { + background: #fef2f2; + margin-top: 0.25rem; +} +.vm112-purge-zone .btn-danger:hover:not(:disabled) { + background: #fee2e2; +} + +/* Contas — Orquestração VM112 (Spec 017) */ +.contas-page { + max-width: 1200px; + margin: 0 auto; + padding: 0.25rem 0 2rem; +} +.contas-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.25rem; +} +.contas-eyebrow { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, #6b7280); + margin: 0 0 0.25rem; +} +.contas-title { + margin: 0; + font-size: 1.65rem; + color: var(--text, #1a1a2e); +} +.contas-sub { + margin: 0.35rem 0 0; + color: var(--muted, #6b7280); + font-size: 0.92rem; +} +.contas-stats-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + margin-bottom: 1.25rem; +} +.contas-stat { + background: #fff; + border: 1px solid #e8e4dc; + border-radius: 10px; + padding: 0.85rem 1rem; +} +.contas-stat strong { + display: block; + font-size: 1.5rem; + line-height: 1.1; +} +.contas-stat span { + font-size: 0.78rem; + color: var(--muted, #6b7280); +} +.contas-layout { + display: grid; + grid-template-columns: 1fr 280px; + gap: 1rem; + align-items: start; +} +.contas-panel { + background: #fff; + border: 1px solid #e8e4dc; + border-radius: 12px; + overflow: hidden; +} +.contas-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid #eee8dc; + background: #faf8f4; +} +.contas-panel-head h3 { + margin: 0; + font-size: 1rem; +} +.contas-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.4rem; + margin-left: 0.35rem; + border-radius: 999px; + background: #2f6fed; + color: #fff; + font-size: 0.75rem; + font-weight: 600; +} +.contas-search { + flex: 1; + max-width: 16rem; + padding: 0.4rem 0.65rem; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 0.88rem; +} +.contas-panel-body { + padding: 0.5rem; + max-height: min(70vh, 560px); + overflow-y: auto; +} +.contas-domain-row { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 0.75rem; + width: 100%; + text-align: left; + padding: 0.75rem 0.85rem; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font: inherit; + color: inherit; +} +.contas-domain-row:hover { + background: #f3f6fb; +} +.contas-domain-row--selected { + background: #e8f0fe; + outline: 1px solid #2f6fed44; +} +.contas-domain-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.contas-domain-dot.ok { background: #16a34a; } +.contas-domain-dot.warn { background: #d97706; } +.contas-domain-main strong { + display: block; + font-size: 0.95rem; +} +.contas-domain-main span { + display: block; + font-size: 0.78rem; + color: var(--muted, #6b7280); + margin-top: 0.15rem; +} +.contas-domain-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + justify-content: flex-end; +} +.contas-domain-tags .tag { + font-size: 0.68rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; + background: #f1f5f9; + color: #475569; +} +.contas-domain-tags .tag-ok { background: #dcfce7; color: #166534; } +.contas-domain-tags .tag-warn { background: #fef3c7; color: #92400e; } +.contas-domain-tags .tag-muted { background: #f1f5f9; color: #94a3b8; } +.contas-domain-action { + font-size: 0.78rem; + color: #2f6fed; + white-space: nowrap; +} +.contas-scopes .contas-scope-item { + padding: 0.65rem 0.85rem; + border-bottom: 1px solid #f0ebe3; +} +.contas-scopes .contas-scope-item:last-child { border-bottom: none; } +.contas-scopes strong { + display: block; + font-size: 0.85rem; +} +.contas-scopes span { + font-size: 0.75rem; + color: var(--muted, #6b7280); +} +.contas-scope-note { + margin: 0; + padding: 0.65rem 0.85rem; + font-size: 0.72rem; + color: #7f1d1d; + background: #fff8f8; + border-top: 1px solid #fecaca; +} +.contas-empty { + padding: 2rem 1rem; + text-align: center; + color: var(--muted, #6b7280); +} +@media (max-width: 900px) { + .contas-stats-row { grid-template-columns: repeat(2, 1fr); } + .contas-layout { grid-template-columns: 1fr; } + .contas-domain-row { grid-template-columns: auto 1fr; } + .contas-domain-tags, .contas-domain-action { grid-column: 2; } +} + +/* Serviços — Orquestração multi-produto (Spec 018) */ +.servicos-page { + max-width: 1280px; + margin: 0 auto; + padding: 0.25rem 0 2rem; +} +.servicos-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} +.servicos-poll-status { + flex: 1 1 100%; + margin: 0.35rem 0 0; + font-size: 0.78rem; + color: var(--muted, #6b7280); +} +.servicos-poll-status--err { + color: #b91c1c; +} +.servicos-eyebrow { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, #6b7280); + margin: 0 0 0.25rem; +} +.servicos-title { + margin: 0; + font-size: 1.65rem; + color: var(--text, #1a1a2e); +} +.servicos-sub { + margin: 0.35rem 0 0; + color: var(--muted, #6b7280); + font-size: 0.95rem; + font-weight: 500; +} +.servicos-lead { + margin: 0.5rem 0 0; + color: var(--muted, #6b7280); + font-size: 0.88rem; + line-height: 1.45; +} +.servicos-stats-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + margin-bottom: 1.25rem; +} +.servicos-stat { + background: #fff; + border: 1px solid #e8e4dc; + border-radius: 10px; + padding: 0.85rem 1rem; +} +.servicos-stat strong { + display: block; + font-size: 1.5rem; + line-height: 1.1; +} +.servicos-stat span { + font-size: 0.78rem; + color: var(--muted, #6b7280); +} +.servicos-layout { + display: grid; + grid-template-columns: minmax(220px, 280px) 1fr minmax(200px, 260px); + gap: 1rem; + align-items: start; +} +.servicos-panel { + background: #fff; + border: 1px solid #e8e4dc; + border-radius: 12px; + overflow: hidden; +} +.servicos-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid #eee8dc; + background: #faf8f4; +} +.servicos-panel-head h3 { + margin: 0; + font-size: 0.95rem; +} +.servicos-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.4rem; + margin-left: 0.35rem; + border-radius: 999px; + background: #2f6fed; + color: #fff; + font-size: 0.75rem; + font-weight: 600; +} +.servicos-search { + flex: 1; + max-width: 100%; + padding: 0.4rem 0.65rem; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 0.85rem; +} +.servicos-panel--clients .servicos-panel-head { + flex-wrap: wrap; +} +.servicos-panel-body { + padding: 0.5rem; + max-height: min(72vh, 620px); + overflow-y: auto; +} +.servicos-client-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.65rem; + width: 100%; + text-align: left; + padding: 0.7rem 0.75rem; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font: inherit; + color: inherit; +} +.servicos-client-row:hover { background: #f3f6fb; } +.servicos-client-row--selected { + background: #e8f0fe; + outline: 1px solid #2f6fed44; +} +.servicos-client-dot { + width: 10px; + height: 10px; + border-radius: 50%; +} +.servicos-client-dot.ok { background: #16a34a; } +.servicos-client-dot.warn { background: #d97706; } +.servicos-client-main strong { + display: block; + font-size: 0.9rem; + word-break: break-word; +} +.servicos-client-main span { + display: block; + font-size: 0.72rem; + color: var(--muted, #6b7280); + margin-top: 0.1rem; +} +.servicos-client-badge { + font-size: 0.65rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + white-space: nowrap; +} +.servicos-client-badge--active { background: #dcfce7; color: #166534; } +.servicos-client-badge--inactive { background: #f1f5f9; color: #64748b; } +.servicos-client-badge--planned { background: #fef3c7; color: #92400e; } +.servicos-client-banner { + padding: 0.65rem 0.85rem 0.85rem; + border-bottom: 1px solid #eee8dc; + margin: -0.5rem -0.5rem 0.75rem; + background: #f8fafc; +} +.servicos-client-banner strong { display: block; font-size: 1rem; } +.servicos-client-banner span { + font-size: 0.78rem; + color: var(--muted, #6b7280); +} +.servicos-tiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; + padding: 0.25rem; +} +.servicos-tile { + position: relative; + border: 1px solid #e8e4dc; + border-radius: 10px; + padding: 0.85rem 0.75rem 2.2rem; + background: #fafafa; + min-height: 130px; +} +.servicos-tile--clickable { + cursor: pointer; + transition: box-shadow 0.15s, border-color 0.15s; +} +.servicos-tile--clickable:hover { + border-color: #2f6fed; + box-shadow: 0 2px 8px #2f6fed22; +} +.servicos-tile--selected { + border-color: #2f6fed; + background: #f0f6ff; +} +.servicos-tile--active { border-left: 3px solid #16a34a; } +.servicos-tile--inactive { opacity: 0.85; } +.servicos-tile--planned { opacity: 0.7; border-style: dashed; } +.servicos-tile-tier { + position: absolute; + top: 0.5rem; + right: 0.5rem; + font-size: 0.6rem; + font-weight: 700; + padding: 0.1rem 0.35rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.servicos-tile-tier--saas { background: #dbeafe; color: #1e40af; } +.servicos-tile-tier--iaas { background: #fef3c7; color: #92400e; } +.servicos-tile-tier--paas { background: #ede9fe; color: #5b21b6; } +.servicos-tiles-section { margin-bottom: 1rem; } +.servicos-tiles-section-title { + margin: 0 0 0.5rem; + padding: 0 0.25rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted, #6b7280); +} +.servicos-wizard-note { + margin: 0.75rem 0.25rem 0.25rem; + padding: 0.65rem 0.75rem; + font-size: 0.75rem; + color: #475569; + background: #f8fafc; + border-radius: 8px; + border: 1px dashed #cbd5e1; +} + font-size: 1.35rem; + margin-bottom: 0.35rem; +} +.servicos-tile-title { + margin: 0 0 0.25rem; + font-size: 0.88rem; +} +.servicos-tile-desc { + margin: 0; + font-size: 0.72rem; + color: var(--muted, #6b7280); + line-height: 1.35; +} +.servicos-tile-status { + position: absolute; + bottom: 0.55rem; + left: 0.75rem; + font-size: 0.65rem; + padding: 0.12rem 0.4rem; + border-radius: 4px; +} +.servicos-tile-status--active { background: #dcfce7; color: #166534; } +.servicos-tile-status--inactive { background: #f1f5f9; color: #64748b; } +.servicos-tile-status--planned { background: #fef3c7; color: #92400e; } +.servicos-tile-wizard { + display: block; + font-size: 0.62rem; + color: #94a3b8; + margin-top: 0.35rem; +} +.servicos-tile-delete { + position: absolute; + bottom: 0.45rem; + right: 0.65rem; + font-size: 0.68rem; + padding: 0.2rem 0.55rem; + line-height: 1.2; +} +.servicos-scopes .servicos-scope-item { + padding: 0.65rem 0.85rem; + border-bottom: 1px solid #f0ebe3; +} +.servicos-scopes .servicos-scope-item:last-child { border-bottom: none; } +.servicos-scopes strong { display: block; font-size: 0.85rem; } +.servicos-scopes span { font-size: 0.75rem; color: var(--muted, #6b7280); } +.servicos-scope-hint { + padding: 0.85rem; + font-size: 0.82rem; + color: var(--muted, #6b7280); + margin: 0; +} +.servicos-scope-note { + margin: 0; + padding: 0.65rem 0.85rem; + font-size: 0.72rem; + color: #7f1d1d; + background: #fff8f8; + border-top: 1px solid #fecaca; +} +.servicos-empty { + padding: 2rem 1rem; + text-align: center; + color: var(--muted, #6b7280); +} +.servicos-empty--center { + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; +} +@media (max-width: 1024px) { + .servicos-layout { grid-template-columns: 1fr; } + .servicos-stats-row { grid-template-columns: repeat(2, 1fr); } +} + +.purge-history-table tbody tr.purge-history-row { + cursor: pointer; +} +.purge-history-table tbody tr.purge-history-row:hover { + background: rgba(47, 111, 237, 0.06); +} +.purge-history-link { + color: var(--accent, #2f6fed); + font-size: 0.85rem; +} +.purge-history-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} +@media (max-width: 768px) { + .purge-history-grid { grid-template-columns: 1fr; } +} +.purge-history-kv { + width: 100%; + font-size: 0.9rem; +} +.purge-history-kv td { + padding: 0.35rem 0; + border-bottom: 1px solid var(--border, #e5e7eb); +} +.purge-history-kv td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} +.purge-history-vm112-sum { + margin: 0.5rem 0; + font-size: 0.95rem; +} +.purge-history-error { + color: #dc2626; + font-size: 0.9rem; +} +.purge-history-timeline { + max-height: 420px; + overflow-y: auto; + margin-top: 0.75rem; +} +.purge-history-removed { + font-size: 0.85rem; + color: var(--muted, #6b7280); + max-width: 14rem; +} + +/* Spec 031 — Auditor de Eventos */ +.modal-panel-xl { + max-width: min(960px, 96vw); + max-height: 92vh; +} +.event-auditor-panel .modal-body { + max-height: calc(92vh - 5rem); + overflow-y: auto; +} +.event-auditor-header-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem 1rem; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; +} +.event-auditor-k { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted, #6b7280); +} +.event-auditor-actors { + margin: 0 0 0.75rem; +} +.event-auditor-actor-chip { + display: inline-block; + margin: 0.15rem 0.35rem 0.15rem 0; + padding: 0.2rem 0.5rem; + border-radius: 4px; + background: var(--surface-2, #f3f4f6); + font-size: 0.85rem; +} +.event-auditor-section { + margin-bottom: 0.75rem; + padding: 0.5rem 0.75rem; +} +.event-auditor-section summary { + cursor: pointer; + font-weight: 600; + margin-bottom: 0.5rem; +} +.event-auditor-bullets { + margin: 0.5rem 0 0 1.25rem; +} +.event-auditor-timeline-table { + width: 100%; + font-size: 0.85rem; + border-collapse: collapse; +} +.event-auditor-timeline-table th, +.event-auditor-timeline-table td { + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--border, #e5e7eb); + vertical-align: top; +} +.event-auditor-ts { + font-size: 0.78rem; + white-space: nowrap; +} +.event-auditor-detail { + max-width: 280px; + word-break: break-word; +} +.event-auditor-step--error td { + background: rgba(239, 68, 68, 0.06); +} +.event-auditor-note, +.event-auditor-raw { + font-size: 0.8rem; + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + background: var(--surface-2, #f9fafb); + padding: 0.75rem; + border-radius: 4px; +} +.btn-audit { + font-weight: 600; +} + +/* Spec 021 — Acesso utilizador (separado do VM112 Onboard) */ +.ws-access-zone { + margin-bottom: 1.25rem; + padding: 1rem; + border-radius: 12px; + background: linear-gradient(180deg, #f0fdfa 0%, #fff 100%); + border: 1px solid #99f6e4; +} +.ws-zone-banner { margin-bottom: 1rem; } +.ws-zone-tag { + display: inline-block; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #0d9488; + background: #ccfbf1; + padding: 0.2rem 0.5rem; + border-radius: 4px; + margin-bottom: 0.35rem; +} +.ws-zone-title { + margin: 0 0 0.35rem; + font-size: 1.15rem; + font-weight: 800; + color: #0f172a; +} +.ws-zone-desc { + margin: 0; + font-size: 0.85rem; + color: #475569; + line-height: 1.5; + max-width: 52rem; +} + +.ws-access-health-card { + margin-bottom: 0.75rem; + border-left-width: 4px; +} +.ws-access-health-card .ws-dash-inner { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px dashed #e2e8f0; +} +.ws-dash-grid--inner { margin-bottom: 0; } + +.ws-info-cards-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} +@media (max-width: 720px) { + .ws-info-cards-row { grid-template-columns: 1fr; } +} +.ws-info-card { + overflow: hidden; + padding: 0; + border: 1px solid #e2e8f0; +} +.ws-info-card-head { + padding: 0.55rem 0.75rem; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: center; + color: #fff; +} +.ws-info-card-head--teal { background: linear-gradient(90deg, #0d9488, #14b8a6); } +.ws-info-card-head--orange { background: linear-gradient(90deg, #ea580c, #f97316); } +.ws-info-card-body { + padding: 0.75rem; + font-size: 0.85rem; + color: #334155; + line-height: 1.5; +} +.ws-info-card-body p { margin: 0 0 0.5rem; } +.ws-info-list, .ws-info-steps { + margin: 0.5rem 0 0; + padding-left: 1.15rem; + font-size: 0.82rem; +} +.ws-info-list li, .ws-info-steps li { margin-bottom: 0.4rem; } +.ws-info-note { + margin-top: 0.65rem !important; + padding: 0.45rem 0.55rem; + background: #fef3c7; + border-radius: 6px; + font-size: 0.78rem !important; + color: #92400e !important; +} + +.ws-zone-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0 1rem; + color: #64748b; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.ws-zone-divider::before, +.ws-zone-divider::after { + content: ''; + flex: 1; + height: 2px; + background: linear-gradient(90deg, transparent, #cbd5e1, transparent); +} +.ws-zone-divider span { + white-space: nowrap; + padding: 0.35rem 0.85rem; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 999px; + color: #9a3412; +} + +.overview-tenant-zone { + padding: 0.75rem; + border-radius: 10px; + background: #fafaf9; + border: 1px solid var(--border, #e5e7eb); +} +.overview-zone-label { + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border, #e5e7eb); +} +.overview-zone-label h4 { + margin: 0 0 0.2rem; + font-size: 1rem; + color: #3a3530; +} +.overview-zone-label .btn { + margin-top: 0.35rem; +} + +.ws-access-overview-card { + border-left-width: 4px; +} +.ws-access-overview-card .health-card-head h3 { + color: #0f766e; +} + +.ws-dash-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-bottom: 0.75rem; +} +@media (max-width: 960px) { + .ws-dash-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 560px) { + .ws-dash-grid { grid-template-columns: 1fr; } +} + +.ws-panel { + border-radius: 8px; + overflow: hidden; + background: #fff; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + display: flex; + flex-direction: column; + min-height: 11rem; +} +.ws-panel-head { + padding: 0.55rem 0.75rem; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: center; + color: #fff; +} +.ws-panel-head--teal { background: linear-gradient(90deg, #0d9488, #14b8a6); } +.ws-panel-head--orange { background: linear-gradient(90deg, #ea580c, #f97316); } +.ws-panel-body { + flex: 1; + padding: 0.65rem 0.75rem; + font-size: 0.85rem; +} +.ws-panel-body--donut { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.ws-panel-body--table { padding: 0; overflow-x: auto; } +.ws-panel-body--ips { display: flex; flex-direction: column; gap: 0.35rem; } + +.ws-legend { + list-style: none; + margin: 0; + padding: 0; + font-size: 0.75rem; + color: #475569; +} +.ws-legend li { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.25rem; +} +.ws-legend span { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.ws-hbar-row { + display: grid; + grid-template-columns: 7rem 1fr 1.5rem; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; + font-size: 0.78rem; +} +.ws-hbar-label { color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ws-hbar-track { + height: 14px; + background: #f1f5f9; + border-radius: 4px; + overflow: hidden; +} +.ws-hbar-fill { height: 100%; border-radius: 4px; min-width: 2px; } +.ws-hbar-val { text-align: right; font-weight: 700; color: #0f172a; } + +.ws-ip-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0; + border-bottom: 1px solid #f1f5f9; +} +.ws-ip-rank { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background: #f1f5f9; + font-size: 0.7rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; +} +.ws-ip-addr { flex: 1; font-size: 0.8rem; } +.ws-ip-count { + font-size: 0.7rem; + font-weight: 700; + color: #fff; + padding: 0.15rem 0.45rem; + border-radius: 999px; +} + +.ws-threat-table { + width: 100%; + font-size: 0.78rem; + border-collapse: collapse; +} +.ws-threat-table th { + background: #fff7ed; + color: #9a3412; + font-weight: 700; + padding: 0.4rem 0.5rem; + text-align: left; + border-bottom: 2px solid #fed7aa; +} +.ws-threat-table td { + padding: 0.35rem 0.5rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} +.ws-threat-table tbody tr.wizard-sec-row { cursor: pointer; } +.ws-threat-table tbody tr.wizard-sec-row:hover { background: #fff7ed; } +.ws-threat-level { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 4px; + color: #fff; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.ws-empty-chart { + margin: 0; + text-align: center; + color: #94a3b8; + font-size: 0.8rem; + padding: 1.5rem 0; +} + +.ws-dash-foot { + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.65rem 0 0; + margin-top: 0.5rem; + border-top: 1px solid #e2e8f0; +} + +.overview-domains-section { + margin-top: 0.5rem; +} + +.wizard-sec-table tbody tr.wizard-sec-row:hover { + background: rgba(47, 111, 237, 0.06); +} +.soc-feed-row--security td.soc-event-name { color: #b45309; } + +.badge.billing { background: #f59e0b33; color: #fbbf24; font-size: 0.65rem; } +.kpi-card--billing-pending .kpi-value { color: #f59e0b; } +.kpi-card--billing-active .kpi-value { color: #22c55e; } +.servicos-billing-badge { cursor: pointer; margin-left: 0.35rem; font-size: 0.85rem; opacity: 0.55; } +.servicos-billing-badge:hover { opacity: 1; } +.servicos-billing-badge--active { opacity: 1; filter: drop-shadow(0 0 4px #22c55e88); } +.servicos-scope-item--billing { + padding: 0.75rem; + margin-bottom: 0.75rem; + background: #f0f7ff; + border: 1px solid #c7ddfc; + border-radius: 8px; +} +.servicos-scope-item--billing .servicos-client-account-btn { margin-top: 0.5rem; } +.vm123-links a { margin-right: 0.5rem; font-weight: 600; } +.vm123-links-label { margin: 0.75rem 0 0.25rem; font-weight: 600; } +.migration-job-row { display: flex; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid var(--border); } +.migration-gate-ready { color: #22c55e; } +.migration-gate-blocked { color: #ef4444; } +.migration-gate-warning { color: #f59e0b; } +.billing-modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: flex; align-items: center; justify-content: center; padding: 1rem; } +.billing-modal { background: var(--card-bg, #fff); color: var(--text, #1e293b); border: 1px solid var(--border, #e2e8f0); border-radius: 10px; padding: 1.25rem 1.35rem; max-width: 520px; width: 100%; box-shadow: 0 12px 40px #0003; } +.billing-modal-title { margin: 0 0 1rem; font-size: 1.1rem; } +.billing-modal-hint { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.85rem 1rem; margin-bottom: 1rem; } +.billing-modal-hint p { margin: 0 0 0.35rem; } +.billing-modal-hint p:last-child { margin-bottom: 0; } +.billing-kv-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-bottom: 1rem; } +.billing-kv-table th { text-align: left; color: var(--muted, #64748b); font-weight: 500; padding: 0.35rem 0.75rem 0.35rem 0; width: 38%; vertical-align: top; } +.billing-kv-table td { padding: 0.35rem 0; color: var(--text, #1e293b); font-weight: 500; word-break: break-word; } +.billing-vm123-heading { margin: 0 0 0.65rem; font-size: 0.95rem; color: var(--muted, #64748b); text-transform: uppercase; letter-spacing: 0.04em; } +.billing-vm123-cards { display: grid; gap: 0.5rem; margin-bottom: 1rem; } +.billing-vm123-card { display: block; padding: 0.75rem 0.9rem; border: 1px solid #c7ddfc; border-radius: 8px; background: #f0f7ff; text-decoration: none; color: inherit; transition: background 0.15s, border-color 0.15s; } +.billing-vm123-card:hover { background: #dbeafe; border-color: #93c5fd; } +.billing-vm123-card strong { display: block; color: #1d4ed8; margin-bottom: 0.2rem; } +.billing-vm123-card span { display: block; font-size: 0.82rem; color: #64748b; } +.billing-vm123-card--muted { background: #f8fafc; border-color: #e2e8f0; } +.billing-vm123-card--muted strong { color: #475569; } +.billing-modal-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; } + +/* ── Desk chrome v0.12 — glass header, tabs rectas (sem pills) ── */ +.shell--v2 { + display: flex; + flex-direction: column; + min-height: 100vh; + grid-template-columns: unset; + background: var(--bg); + font-family: 'Inter', 'DM Sans', system-ui, -apple-system, sans-serif; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + -webkit-font-smoothing: antialiased; +} + +.shell--v2 .sidebar { + display: none; +} + +.app-chrome--modern { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255, 253, 249, 0.78); + backdrop-filter: blur(16px) saturate(1.35); + -webkit-backdrop-filter: blur(16px) saturate(1.35); + border-bottom: 1px solid rgba(92, 46, 46, 0.08); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.65) inset, + 0 8px 32px rgba(92, 46, 46, 0.06); +} + +.app-chrome__inner { + display: flex; + align-items: center; + gap: 1rem; + min-height: 58px; + padding: 0.45rem 1.25rem; + max-width: 100%; +} + +.app-chrome__brand { + display: flex; + align-items: center; + gap: 0.55rem; + flex-shrink: 0; +} + +.app-chrome__mark { + width: 9px; + height: 9px; + border-radius: 0; + background: linear-gradient(135deg, #ff5c8a 0%, #5c2e2e 100%); + box-shadow: none; +} + +.app-chrome__brand h1 { + margin: 0; + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.035em; + color: #2b2622; + line-height: 1; +} + +.app-chrome__brand .brand-ops { + color: var(--accent); + font-weight: 700; +} + +.app-chrome__nav { + display: flex; + align-items: center; + gap: 0.45rem; + flex: 1; + min-width: 0; + padding: 0.15rem 0.25rem; + overflow-x: auto; + scrollbar-width: none; + mask-image: linear-gradient(90deg, transparent, #000 12px, #000 calc(100% - 12px), transparent); +} + +.app-chrome__nav::-webkit-scrollbar { + display: none; +} + +.nav-zone { + display: flex; + flex-direction: column; + gap: 0.22rem; + flex-shrink: 0; + padding: 0.2rem 0.35rem 0.15rem; + border-radius: 0; + transition: background 0.2s ease; +} + +.nav-zone.active { + background: rgba(92, 46, 46, 0.04); +} + +.nav-zone__label { + font-size: 0.56rem; + font-weight: 650; + letter-spacing: 0.11em; + text-transform: uppercase; + color: #9a928a; + line-height: 1; + padding-left: 0.35rem; +} + +.nav-zone__links { + display: flex; + align-items: center; + gap: 0; + padding: 0; + border-radius: 0; + background: rgba(92, 46, 46, 0.05); + border: 1px solid rgba(92, 46, 46, 0.1); +} + +.nav-link { + appearance: none; + border: none; + background: transparent; + color: #5f5852; + font: inherit; + font-size: 0.8125rem; + font-weight: 520; + padding: 0.42rem 0.75rem; + border-radius: 0; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + border-right: 1px solid rgba(92, 46, 46, 0.08); + transition: + color 0.16s ease, + background 0.16s ease; +} + +.nav-zone__links .nav-link:last-child { + border-right: none; +} + +.nav-link:hover { + color: #2b2622; + background: rgba(255, 255, 255, 0.55); +} + +.nav-link.active { + color: var(--accent); + font-weight: 650; + background: #fff; + box-shadow: inset 0 -2px 0 var(--accent); +} + +.nav-link[hidden] { + display: none !important; +} + +.nav-zone--console { + text-decoration: none; + color: inherit; + align-self: center; + padding: 0.15rem 0.25rem; +} + +.nav-zone--console .nav-zone__label { + color: #64748b; +} + +.nav-link--console { + display: inline-block; + color: #0f4c81; + font-weight: 620; + border: 1px solid rgba(14, 165, 233, 0.28); + background: #f0f9ff; + margin-top: 0.12rem; + border-radius: 0; + border-right: none; +} + +.nav-link--console:hover { + color: #0c4a6e; + background: #e0f2fe; + box-shadow: none; + transform: none; +} + +.nav-zone__sep { + width: 1px; + align-self: center; + height: 28px; + margin: 0 0.1rem; + background: linear-gradient(180deg, transparent, rgba(92, 46, 46, 0.12), transparent); + flex-shrink: 0; +} + +.app-chrome__meta { + display: flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + padding-left: 0.35rem; +} + +.app-chrome__meta .header-user { + display: flex; + align-items: center; + gap: 0.55rem; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.2; + margin-right: 0.15rem; +} + +.header-user__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0; + background: linear-gradient(145deg, #5c2e2e, #8b3a42); + color: #fff; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.02em; + box-shadow: none; +} + +.header-user__text strong { + display: block; + color: #2b2622; + font-size: 0.8125rem; + font-weight: 620; +} + +.header-user__text > span { + display: block; + font-size: 0.72rem; + color: #9a928a; +} + +.app-chrome__meta .status-pill { + font-size: 0.72rem; + padding: 0.28rem 0.55rem; + border-radius: 0; +} + +.app-chrome__meta .btn-ghost { + border-radius: 0; + font-size: 0.78rem; + font-weight: 550; + border-color: rgba(92, 46, 46, 0.1); + color: #5f5852; +} + +.app-chrome__meta .btn-ghost:hover { + background: rgba(92, 46, 46, 0.06); + color: var(--accent); +} + +.workspace { + display: grid; + grid-template-columns: 0 1fr; + flex: 1; + min-height: 0; +} + +.shell--v2:not(.shell--no-context) .workspace { + grid-template-columns: 232px 1fr; +} + +.workspace-main { + min-width: 0; +} + +.shell--v2 .main { + padding: 1.1rem 1.5rem 2rem; +} + +.context-sidebar { + background: rgba(255, 253, 249, 0.92); + backdrop-filter: blur(8px); + border-right: 1px solid rgba(92, 46, 46, 0.08); + padding: 1rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + min-height: calc(100vh - 58px); +} + +.context-sidebar__head { + padding: 0 0.35rem 0.75rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.75rem; +} + +.context-sidebar__label { + margin: 0; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.context-sidebar__title { + margin: 0.2rem 0 0; + font-size: 1.02rem; + font-weight: 700; + color: #3a3530; + letter-spacing: -0.02em; +} + +.context-nav { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.context-nav .toolbar, +.context-nav .team-admin-tabs { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: stretch; + margin: 0; + padding: 0; + background: transparent; + border: none; +} + +.context-nav .toolbar-sep { + display: none; +} + +.context-nav .filter-btn, +.context-nav .team-admin-tab { + width: 100%; + text-align: left; + justify-content: flex-start; + border-radius: 0; + padding: 0.48rem 0.65rem; + font-size: 0.82rem; +} + +.context-nav .filter-btn.active, +.context-nav .team-admin-tab.active { + background: var(--accent-soft); + border-color: rgba(92, 46, 46, 0.25); + color: var(--accent); + font-weight: 600; +} + +.context-nav .am-role-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + border: none; + background: transparent; + min-width: 0; +} + +.context-nav .am-role-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.context-nav .am-role-btn { + width: 100%; + text-align: left; +} + +.context-sidebar__foot { + margin-top: auto; + padding-top: 0.75rem; + font-size: 0.68rem; + color: var(--muted); +} + +.shell--v2 .page-header { + margin-bottom: 0.85rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--border); +} + +.shell--v2 .page-header h2 { + font-size: 1.35rem; + letter-spacing: -0.02em; +} + +.shell--v2 .page-toolbar:empty { + display: none; +} + +.shell--v2 #access-matrix-content .am-layout { + grid-template-columns: 1fr; +} + +@media (max-width: 960px) { + .app-chrome__inner { + flex-wrap: wrap; + padding: 0.55rem 0.75rem; + gap: 0.65rem; + } + .app-chrome__nav { + order: 3; + flex-basis: 100%; + mask-image: none; + } + .app-chrome__meta .header-user__text { + display: none; + } + .shell--v2:not(.shell--no-context) .workspace { + grid-template-columns: 1fr; + } + .context-sidebar { + min-height: auto; + border-right: none; + border-bottom: 1px solid var(--border); + } +} diff --git a/projects/ops-desk/frontend/staging-snapshot/stg-topnav.js b/projects/ops-desk/frontend/staging-snapshot/stg-topnav.js new file mode 100644 index 0000000..f3dd048 --- /dev/null +++ b/projects/ops-desk/frontend/staging-snapshot/stg-topnav.js @@ -0,0 +1,128 @@ +/** Desk chrome nav — tabs planas por zona + sidebar contextual (v0.11). */ + +const TOPNAV_VIEW_GROUP = { + 'agentic-ops': 'operacoes', + dashboard: 'operacoes', + tickets: 'operacoes', + events: 'operacoes', + 'overview-home': 'comercial', + leads: 'comercial', + infra: 'infra', + infra2: 'infra', + 'email-migration': 'infra', + overview: 'sistema', + account: 'sistema', + messages: 'sistema', + admin: 'sistema', + 'access-matrix': 'sistema', + modules: 'sistema', +}; + +const CONTEXT_TOOLBARS = { + tickets: { title: 'Tickets', host: '#view-tickets', label: 'Filas' }, + events: { title: 'Eventos', host: '#view-events', label: 'Vistas' }, +}; + +function initTopnavDropdowns() { + /* v0.11 — tabs planas; sem dropdowns */ +} + +function parkContextToolbars() { + Object.values(CONTEXT_TOOLBARS).forEach(({ host }) => { + const view = document.querySelector(host); + const toolbar = view?.querySelector('.toolbar'); + if (toolbar && view && toolbar.parentElement !== view) { + view.insertBefore(toolbar, view.firstChild); + } + }); + const matrixRoleNav = document.getElementById('context-matrix-roles'); + if (matrixRoleNav && matrixRoleNav.dataset.parkedFrom) { + const slot = document.querySelector(matrixRoleNav.dataset.parkedFrom); + if (slot) slot.appendChild(matrixRoleNav); + delete matrixRoleNav.dataset.parkedFrom; + } + const adminHost = document.getElementById('context-admin-tabs'); + if (adminHost) adminHost.innerHTML = ''; +} + +function updateTopnavActive(view) { + document.querySelectorAll('[data-desk-nav]').forEach((btn) => { + const active = btn.dataset.view === view; + btn.classList.toggle('active', active); + if (active) btn.setAttribute('aria-current', 'page'); + else btn.removeAttribute('aria-current'); + }); + document.querySelectorAll('.nav-zone[data-topnav-group]').forEach((zone) => { + zone.classList.toggle('active', TOPNAV_VIEW_GROUP[view] === zone.dataset.topnavGroup); + }); +} + +function mountMatrixRoleSidebar() { + const roleNav = document.querySelector('#access-matrix-content .am-role-list'); + if (!roleNav) return false; + const nav = document.getElementById('context-nav'); + if (!nav) return false; + roleNav.dataset.parkedFrom = '#access-matrix-content .am-layout'; + nav.appendChild(roleNav); + return true; +} + +function updateContextSidebar(view) { + const shell = document.querySelector('.shell'); + const sidebar = document.getElementById('context-sidebar'); + const nav = document.getElementById('context-nav'); + const titleEl = document.getElementById('context-sidebar-title'); + const labelEl = document.getElementById('context-sidebar-label'); + if (!shell || !sidebar || !nav) return; + + parkContextToolbars(); + nav.innerHTML = ''; + + if (view === 'admin' && typeof renderAdminTabs === 'function') { + sidebar.hidden = false; + shell.classList.remove('shell--no-context'); + if (titleEl) titleEl.textContent = 'Administradores'; + if (labelEl) labelEl.textContent = 'Secções'; + nav.innerHTML = `
    ${renderAdminTabs()}
    `; + if (typeof bindAdminTabs === 'function') bindAdminTabs(); + return; + } + + if (view === 'access-matrix') { + sidebar.hidden = false; + shell.classList.remove('shell--no-context'); + if (titleEl) titleEl.textContent = 'Matriz de Acesso'; + if (labelEl) labelEl.textContent = 'Funções'; + requestAnimationFrame(() => mountMatrixRoleSidebar()); + return; + } + + const cfg = CONTEXT_TOOLBARS[view]; + if (!cfg) { + sidebar.hidden = true; + shell.classList.add('shell--no-context'); + return; + } + + const viewEl = document.querySelector(cfg.host); + const toolbar = viewEl?.querySelector('.toolbar'); + if (!toolbar) { + sidebar.hidden = true; + shell.classList.add('shell--no-context'); + return; + } + + sidebar.hidden = false; + shell.classList.remove('shell--no-context'); + if (titleEl) titleEl.textContent = cfg.title; + if (labelEl) labelEl.textContent = cfg.label; + toolbar.classList.add('context-toolbar'); + nav.appendChild(toolbar); +} + +window.DeskTopnav = { + initTopnavDropdowns, + updateTopnavActive, + updateContextSidebar, + remountMatrixRoles: mountMatrixRoleSidebar, +};