- deploy/vm112-wizard: main-wizard DomainAdmin route, clientSettings, FinishToolbar - deploy/vm122-desk: card Ligbox Datacenter Node VM001, audit sync - Spec 025: secao Passo Concluido CTAs - KB: Portal de gerenciamento reabria wizard Concluido
4062 lines
175 KiB
JavaScript
4062 lines
175 KiB
JavaScript
const API = '/api';
|
||
|
||
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, '>')
|
||
.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 `<code class="session-hash" title="Sessão onboarding VM112">${esc(shown)}</code>`;
|
||
}
|
||
|
||
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,
|
||
};
|
||
|
||
const views = {
|
||
dashboard: document.getElementById('view-dashboard'),
|
||
overview: document.getElementById('view-overview'),
|
||
'overview-home': document.getElementById('view-overview-home'),
|
||
tickets: document.getElementById('view-tickets'),
|
||
events: document.getElementById('view-events'),
|
||
tenants: document.getElementById('view-tenants'),
|
||
'email-migration': document.getElementById('view-email-migration'),
|
||
infra: document.getElementById('view-infra'),
|
||
infra2: document.getElementById('view-infra2'),
|
||
messages: document.getElementById('view-messages'),
|
||
admin: document.getElementById('view-admin'),
|
||
account: document.getElementById('view-account'),
|
||
leads: document.getElementById('view-leads'),
|
||
modules: document.getElementById('view-modules'),
|
||
};
|
||
|
||
function roleLabel(role) {
|
||
return ROLE_LABELS[role] || role;
|
||
}
|
||
|
||
const ROLE_LABELS = {
|
||
super_admin: 'Super Admin',
|
||
ops_lead: 'Chefe Ops',
|
||
technician: 'Suporte',
|
||
noc: 'NOC',
|
||
sales_admin: 'Sales Admin',
|
||
sales_support: 'Sales Support',
|
||
finance: 'Financeiro',
|
||
marketing: 'Marketing',
|
||
seo: 'SEO',
|
||
developer: 'Developer',
|
||
devops: 'DevOps',
|
||
security_analyst: 'Segurança / SOC',
|
||
content_editor: 'Conteúdo / CMS',
|
||
agentic_operator: 'Operador Agentes IA',
|
||
};
|
||
|
||
function statusLabel(status) {
|
||
return {
|
||
pending: 'pendente',
|
||
approved: 'aprovado',
|
||
rejected: 'rejeitado',
|
||
active: 'ativo',
|
||
open: 'aberto',
|
||
escalated: 'escalado',
|
||
assisting: 'assistindo',
|
||
resolved: 'resolvido',
|
||
closed: 'fechado',
|
||
}[status] || status;
|
||
}
|
||
|
||
function normalizeAssistStatus(status) {
|
||
if (status === 'active') return 'assisting';
|
||
return status;
|
||
}
|
||
|
||
function assistStatusLabel(status) {
|
||
return {
|
||
observing: 'observando',
|
||
escalated: 'escalado',
|
||
assisting: 'assistindo',
|
||
handed_off: 'devolvido ao cliente',
|
||
ended: 'assistência encerrada',
|
||
}[status] || status || 'observando';
|
||
}
|
||
|
||
function assistBadge(status) {
|
||
if (!status || status === 'observing') {
|
||
return '<span class="badge assist-observing">observando</span>';
|
||
}
|
||
const cls = status === 'assisting' ? 'assisting'
|
||
: status === 'escalated' ? 'escalated'
|
||
: status === 'handed_off' || status === 'ended' ? 'resolved'
|
||
: status === 'closed' ? 'closed'
|
||
: 'open';
|
||
return `<span class="badge ${cls}">${esc(assistStatusLabel(status))}</span>`;
|
||
}
|
||
|
||
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'
|
||
? '<span class="badge ok">concluído</span>'
|
||
: outcome === 'failed'
|
||
? '<span class="badge escalated">falhou</span>'
|
||
: '';
|
||
const label = latest ? (SOC_EVENT_LABELS[latest] || latest) : '—';
|
||
const sev = latest && typeof socEventSeverity === 'function' ? socEventSeverity(latest) : 'open';
|
||
return `
|
||
<dt>Estado funil</dt><dd><span class="badge ${sev}">${esc(label)}</span> ${outcomeBadge}</dd>
|
||
${showOpened ? `<dt>Abertura ticket</dt><dd class="muted"><code>${esc(opened)}</code></dd>` : ''}`;
|
||
}
|
||
|
||
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 = `
|
||
<strong>${esc(user.display_name || user.username)}</strong>
|
||
<span>${esc(user.username)} · ${esc(label)}</span>`;
|
||
}
|
||
if (header) {
|
||
header.hidden = false;
|
||
header.innerHTML = `<strong>${esc(user.display_name || user.username)}</strong><span>${esc(label)}</span>`;
|
||
}
|
||
if (logoutBtn) {
|
||
logoutBtn.hidden = false;
|
||
logoutBtn.onclick = logout;
|
||
}
|
||
}
|
||
|
||
function applyRoleNav() {
|
||
const user = getUser();
|
||
if (!user) return;
|
||
if (!canRunAudit()) {
|
||
document.getElementById('nav-overview')?.setAttribute('hidden', '');
|
||
document.getElementById('nav-overview-home')?.setAttribute('hidden', '');
|
||
}
|
||
if (user.role === 'noc') {
|
||
document.getElementById('nav-tenants')?.setAttribute('hidden', '');
|
||
const navEvents = document.getElementById('nav-events');
|
||
const navEventsLabel = navEvents?.querySelector('.nav-label');
|
||
if (navEventsLabel) navEventsLabel.textContent = 'Wazuh';
|
||
}
|
||
if (canManageUsers()) {
|
||
document.getElementById('nav-messages')?.removeAttribute('hidden');
|
||
document.getElementById('nav-admin')?.removeAttribute('hidden');
|
||
}
|
||
if (user.role === 'super_admin') {
|
||
document.getElementById('nav-modules')?.removeAttribute('hidden');
|
||
}
|
||
if (canReadLeads()) {
|
||
document.getElementById('nav-leads')?.removeAttribute('hidden');
|
||
document.getElementById('filter-leads')?.removeAttribute('hidden');
|
||
}
|
||
if (typeof canManageVm112Domains === 'function' && canManageVm112Domains()) {
|
||
document.getElementById('events-tab-purges')?.removeAttribute('hidden');
|
||
}
|
||
if (canRunAudit()) {
|
||
document.getElementById('events-tab-security')?.removeAttribute('hidden');
|
||
} else {
|
||
document.getElementById('events-tab-security')?.setAttribute('hidden', '');
|
||
}
|
||
if (canReadTickets()) {
|
||
document.getElementById('events-tab-carbonio')?.removeAttribute('hidden');
|
||
}
|
||
}
|
||
|
||
function setView(name) {
|
||
if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
|
||
name = 'dashboard';
|
||
}
|
||
if (state.view === 'account' && name !== 'account') {
|
||
state.accountLoaded = false;
|
||
}
|
||
state.view = name;
|
||
const titles = {
|
||
dashboard: 'Dashboard',
|
||
overview: 'Audit Overview',
|
||
'overview-home': 'Serviços',
|
||
tickets: 'Tickets',
|
||
events: 'Eventos webhook',
|
||
tenants: 'Tenants',
|
||
infra: 'Infraestrutura',
|
||
infra2: 'SOC — Infra 2',
|
||
messages: 'Mensagens — pedidos de cadastro',
|
||
admin: 'Administradores',
|
||
account: 'Minha conta',
|
||
leads: 'Leads abandonados',
|
||
modules: 'Módulos',
|
||
};
|
||
const subtitles = {
|
||
dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
overview: 'Visão por tenant — cards de auditoria (versão clássica)',
|
||
'overview-home': 'Desk VM122 · Orquestração MOSP',
|
||
tickets: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
events: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
infra: 'VM112, VM104 e integrações — visão técnica',
|
||
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
|
||
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
|
||
};
|
||
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
|
||
const subEl = document.getElementById('page-subtitle');
|
||
if (subEl) subEl.textContent = subtitles[name] || subtitles.dashboard;
|
||
document.querySelectorAll('.nav button').forEach((b) => {
|
||
b.classList.toggle('active', b.dataset.view === name);
|
||
});
|
||
Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name));
|
||
reschedulePoll();
|
||
refresh();
|
||
}
|
||
|
||
let pollTimer = null;
|
||
function reschedulePoll() {
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
if (state.openPanelTestRunning) return;
|
||
const ms = state.view === 'infra2' ? 15000 : 30000;
|
||
pollTimer = setInterval(() => refresh({ poll: true }), ms);
|
||
}
|
||
|
||
async function loadHealth() {
|
||
const el = document.getElementById('global-health');
|
||
try {
|
||
const h = await api('/health');
|
||
el.className = 'status-pill ok';
|
||
el.innerHTML = '<span class="dot"></span> API online';
|
||
return h;
|
||
} catch {
|
||
el.className = 'status-pill err';
|
||
el.innerHTML = '<span class="dot"></span> API offline';
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function renderDashboard() {
|
||
const box = document.getElementById('dashboard-content');
|
||
box.innerHTML = '<p class="loading">Carregando…</p>';
|
||
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 `
|
||
<button type="button" class="session-card clickable ${s.stale ? 'stale' : ''} session-card--${statusCls}" data-session="${esc(s.session_id || '')}">
|
||
<div class="session-card-top">
|
||
<span class="session-card-dot" aria-hidden></span>
|
||
<strong class="session-card-domain">${esc(s.domain || '—')}</strong>
|
||
</div>
|
||
<div class="session-card-stage">${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)} · onboarding VM112</div>
|
||
<div class="session-card-meta">
|
||
${sessionHashHtml(s.session_id)}
|
||
${s.ticket_id ? `<span class="session-card-ticket">#${s.ticket_id}</span>` : ''}
|
||
</div>
|
||
<div class="session-card-badges">
|
||
${assistBadge(status)}
|
||
${s.is_lead || s.crm_track === 'lead' ? '<span class="badge escalated">lead</span>' : ''}
|
||
${['company_validated','webmail_released','completed'].includes(s.current_stage) ? '<span class="badge billing">💳 billing</span>' : ''}
|
||
${s.stale && s.crm_track !== 'lead' && !s.is_lead ? '<span class="badge review">abandonado</span>' : ''}
|
||
</div>
|
||
</button>`;
|
||
}).join('');
|
||
box.innerHTML = `
|
||
<section class="dashboard-top">
|
||
<div class="dashboard-kpis">
|
||
${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' }) : ''}
|
||
</div>
|
||
${dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk })}
|
||
</section>
|
||
<div class="dashboard-ops">
|
||
<div class="card dashboard-funnel">
|
||
<h3>Funil <span class="ticket-meta">48h</span></h3>
|
||
${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)}
|
||
</div>
|
||
<div class="card dashboard-sessions-panel">
|
||
<div class="card-head-row">
|
||
<h3>Sessões ativas</h3>
|
||
<div class="session-legend">
|
||
<span class="session-legend-item"><i class="dot-assisting"></i> Assistindo</span>
|
||
<span class="session-legend-item"><i class="dot-observing"></i> Observando</span>
|
||
<span class="ticket-meta">${sessions.length} total</span>
|
||
</div>
|
||
</div>
|
||
${sessionCards
|
||
? `<div class="session-grid">${sessionCards}</div>`
|
||
: '<p class="loading">Sem sessões recentes</p>'}
|
||
</div>
|
||
${canReadLeads() ? `
|
||
<div class="card dashboard-leads-panel">
|
||
<div class="card-head-row">
|
||
<h3>Leads abandonados</h3>
|
||
<button type="button" class="btn btn-ghost btn-sm" data-view-jump="leads">Ver todos</button>
|
||
</div>
|
||
${(leadsData.leads || []).slice(0, 6).map(leadRowHtml).join('') || '<p class="loading">Nenhum lead — sessões stale viram lead após ${summary.onboard_stale_hours ?? 24}h</p>'}
|
||
</div>` : ''}
|
||
<div class="card dashboard-tickets">
|
||
<h3>Tickets recentes</h3>
|
||
<div class="ticket-list ticket-list-compact">
|
||
${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '<p class="loading">Sem tickets</p>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${canAssist() && (techRanking.ranking || []).length ? `
|
||
<div class="card card-compact ranking-card">
|
||
<div class="card-head-row">
|
||
<h3>Ranking técnicos</h3>
|
||
<span class="ticket-meta">30d · assumidos / movimento</span>
|
||
</div>
|
||
${techRankingHtml(techRanking.ranking)}
|
||
</div>` : ''}`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function sourceBadge(src) {
|
||
if (src === 'desk-registration') return '<span class="badge onboard">desk</span>';
|
||
if (src === 'wazuh') return '<span class="badge wazuh">wazuh</span>';
|
||
if (src === 'vm112-onboard') return '<span class="badge onboard">onboard</span>';
|
||
return src ? `<span class="badge">${esc(src)}</span>` : '';
|
||
}
|
||
|
||
function severityBadge(level) {
|
||
if (level == null) return '';
|
||
const n = Number(level);
|
||
let cls = 'sev-low';
|
||
if (n >= 12) cls = 'sev-critical';
|
||
else if (n >= 10) cls = 'sev-high';
|
||
else if (n >= 7) cls = 'sev-med';
|
||
return `<span class="badge ${cls}">L${n}</span>`;
|
||
}
|
||
|
||
const FUNNEL_LABELS = {
|
||
started: 'Iniciado',
|
||
domain_validated: 'Domínio OK',
|
||
dns_applied: 'DNS aplicado',
|
||
account_created: 'Conta criada',
|
||
infra_synced: 'Infra sync',
|
||
completed: 'Concluído',
|
||
failed: 'Falhou',
|
||
};
|
||
|
||
function funnelBarHtml(stages, total) {
|
||
const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed', 'failed'];
|
||
const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0));
|
||
return order
|
||
.filter((k) => k !== 'failed' || (stages.failed || 0) > 0)
|
||
.map((key) => {
|
||
const n = stages[key] || 0;
|
||
const pct = max ? Math.round((n / max) * 100) : 0;
|
||
return `
|
||
<div class="funnel-row">
|
||
<span class="funnel-label">${FUNNEL_LABELS[key] || key}</span>
|
||
<div class="funnel-bar"><div class="funnel-fill" style="width:${pct}%"></div></div>
|
||
<strong class="funnel-count">${n}</strong>
|
||
</div>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function 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
|
||
? '<span class="badge ok">concluído</span>'
|
||
: `<span class="badge review">em curso</span>`;
|
||
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
|
||
? `<span data-timing-live="accumulated">Σ ${esc(timing.total_duration_label)}</span>`
|
||
: `Σ ${esc(ev.duration_from_start_label || '—')}`;
|
||
return `
|
||
<tr>
|
||
<td>${esc(eventTypeLabel(ev))}</td>
|
||
<td class="timing-cell-time">${fmtDate(ev.created_at || ev.at)}</td>
|
||
<td class="timing-cell-delta">${idx > 0 ? `<strong>+${esc(prev)}</strong>` : '—'}</td>
|
||
<td class="timing-cell-total">${total}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
return `
|
||
<div class="card phase-timing-card" data-timing-live-card
|
||
data-timing-completed="${timing.is_completed ? 'true' : 'false'}"
|
||
data-timing-started-at="${esc(timing.started_at || '')}"
|
||
data-timing-last-at="${esc(timing.last_event_at || lastEv?.created_at || lastEv?.at || timing.started_at || '')}">
|
||
<div class="phase-timing-head">
|
||
<div>
|
||
<h4 style="margin:0">Relógio por fase</h4>
|
||
<p class="ticket-meta">Duração entre etapas do onboarding VM112</p>
|
||
</div>
|
||
${statusBadge}
|
||
</div>
|
||
<div class="phase-timing-kpis">
|
||
<div class="phase-timing-kpi">
|
||
<span class="phase-timing-kpi-label">Tempo total</span>
|
||
<span class="phase-timing-kpi-value${timing.is_completed ? '' : ' phase-timing-kpi-value--live'}"
|
||
${timing.is_completed ? '' : ' data-timing-live="total"'}>${esc(timing.total_duration_label)}</span>
|
||
</div>
|
||
<div class="phase-timing-kpi">
|
||
<span class="phase-timing-kpi-label">Início</span>
|
||
<span class="phase-timing-kpi-value phase-timing-kpi-value--sm">${fmtDate(timing.started_at)}</span>
|
||
</div>
|
||
${timing.is_completed ? `
|
||
<div class="phase-timing-kpi">
|
||
<span class="phase-timing-kpi-label">Concluído</span>
|
||
<span class="phase-timing-kpi-value phase-timing-kpi-value--sm">${fmtDate(timing.completed_at)}</span>
|
||
</div>` : `
|
||
<div class="phase-timing-kpi">
|
||
<span class="phase-timing-kpi-label">Parado há</span>
|
||
<span class="phase-timing-kpi-value${timing.is_completed ? '' : ' phase-timing-kpi-value--live'}"
|
||
data-timing-live="idle">${esc(timing.idle_since_label || '—')}</span>
|
||
</div>`}
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="phase-timing-table">
|
||
<thead><tr><th>Fase</th><th>Registado</th><th>Δ fase</th><th>Acumulado</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function timingSummaryHtml(timing) {
|
||
if (!timing || !window.DeskModules?.isEnabled('funnel-timing')) return '';
|
||
const idle = timing.is_completed ? '' : `<span class="timing-stat">Parado há <strong>${esc(timing.idle_since_label)}</strong></span>`;
|
||
return `
|
||
<div class="timing-summary">
|
||
<span class="timing-stat">Total <strong>${esc(timing.total_duration_label)}</strong></span>
|
||
${idle}
|
||
${timing.completed_at ? `<span class="timing-stat">Concluído ${fmtDate(timing.completed_at)}</span>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function timelineHtml(events, timingMeta, opts = {}) {
|
||
if (!events?.length) return '';
|
||
const showTiming = !opts.compact && window.DeskModules?.isEnabled('funnel-timing');
|
||
return `${!opts.compact ? timingSummaryHtml(timingMeta) : ''}<ol class="timeline">${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 !== '—'
|
||
? `<span class="timing-badge" title="Desde fase anterior">+${esc(e.duration_from_prev_label)}</span>`
|
||
: '';
|
||
const fromStart = showTiming && e.duration_from_start_label
|
||
? `<span class="timing-badge timing-badge--muted" title="Desde início">Σ ${esc(e.duration_from_start_label)}</span>`
|
||
: '';
|
||
return `
|
||
<li class="timeline-item">
|
||
<span class="timeline-dot"></span>
|
||
<div>
|
||
<strong>${esc(evt)}</strong>
|
||
${e.stage ? `<span class="badge open">${esc(e.stage)}</span>` : ''}
|
||
${prevDur}${fromStart}
|
||
<div class="ticket-meta">${fmtDate(at)}</div>
|
||
</div>
|
||
</li>`;
|
||
}
|
||
)
|
||
.join('')}</ol>`;
|
||
}
|
||
|
||
function healthBadge(status) {
|
||
const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' };
|
||
const cls = map[status] || 'open';
|
||
return `<span class="badge ${cls} health-${esc(status)}">${esc(status || 'unknown')}</span>`;
|
||
}
|
||
|
||
function checkStatusBadge(status) {
|
||
const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open';
|
||
return `<span class="badge ${cls}">${esc(status)}</span>`;
|
||
}
|
||
|
||
function leadRowHtml(l) {
|
||
return `
|
||
<button type="button" class="lead-row" data-lead-ticket="${l.ticket_id}" data-lead-session="${esc(l.session_id || '')}">
|
||
<div class="lead-row-top">
|
||
<strong>${esc(l.domain || '—')}</strong>
|
||
<span class="badge escalated">lead</span>
|
||
</div>
|
||
<div class="ticket-meta">${esc(l.email || 'sem e-mail')} · ${esc(FUNNEL_LABELS[l.funnel_stage] || l.funnel_stage || '—')}</div>
|
||
<div class="ticket-meta">#${l.ticket_id} · parado ${fmtDate(l.last_event_at)}</div>
|
||
</button>`;
|
||
}
|
||
|
||
function billingTicketIcon(t) {
|
||
if ((t.subject || '').includes('[billing-validation]') || t.billing_state) return ' 💳';
|
||
return '';
|
||
}
|
||
|
||
function ticketRowHtml(t) {
|
||
const review = t.needs_review ? '<span class="badge review">revisão</span>' : '';
|
||
const verified = t.account_verified ? '<span class="badge ok">verificado</span>' : '';
|
||
const lead = t.crm_track === 'lead' ? '<span class="badge escalated">lead</span>' : '';
|
||
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 `
|
||
<button type="button" class="ticket-row ${state.selectedTicketId === t.id ? 'selected' : ''}" data-id="${t.id}"${t.session_id ? ` data-session="${esc(t.session_id)}"` : ''}>
|
||
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
|
||
<div>
|
||
<div class="ticket-subject">${sub}</div>
|
||
<div class="ticket-meta ticket-meta--hash">${meta}</div>
|
||
</div>
|
||
<div>${lead}${sourceBadge(t.source)}${severityBadge(t.severity)}${review}${verified}</div>
|
||
</button>`;
|
||
}
|
||
|
||
function assistActionsHtml(sessionId, meta, consoleExtra = {}) {
|
||
if (!canAssist() || !sessionId) return '';
|
||
const canAct = meta?.can_escalate;
|
||
const assistStatus = normalizeAssistStatus(meta?.assist_status);
|
||
const ticketStatus = meta?.ticket_status;
|
||
const isAssisting = assistStatus === 'assisting' || ticketStatus === 'assisting';
|
||
const isEscalated = assistStatus === 'escalated' || ticketStatus === 'escalated';
|
||
const status = assistStatus || ticketStatus;
|
||
const deskActions = (consoleExtra.actions || []).map((a) =>
|
||
`<button type="button" class="btn btn-ghost btn-sm${a.danger ? ' btn-danger' : ''}" data-desk-action="${esc(a.id)}" title="${esc(a.label)}">${esc(a.label)}</button>`
|
||
).join('');
|
||
const links = (consoleExtra.links || []).map((l) =>
|
||
`<a class="btn btn-ghost btn-sm" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.label)}</a>`
|
||
).join('');
|
||
const audit = (meta?.actions || []).slice(-8).map((a) =>
|
||
`<li><code>${esc(a.action)}</code> · ${esc(a.actor)} · ${fmtDate(a.created_at)}</li>`
|
||
).join('');
|
||
return `
|
||
<div class="assist-panel">
|
||
<h4>Console de assistência</h4>
|
||
<p class="ticket-meta">${assistBadge(status)}${meta?.assisted_by ? ` · ${esc(meta.assisted_by)}` : ''}</p>
|
||
<div class="actions" style="margin-top:0.75rem;flex-wrap:wrap">
|
||
${!isAssisting && !isEscalated && canAct ? `<button type="button" class="btn btn-ghost btn-sm" data-assist="escalate">Escalar</button>` : ''}
|
||
${canAct && !isAssisting ? `<button type="button" class="btn btn-primary btn-sm" data-assist="takeover">Assumir sessão</button>` : ''}
|
||
${isAssisting ? `<button type="button" class="btn btn-ghost btn-sm" data-assist="handoff">Devolver ao cliente</button>` : ''}
|
||
${!canAct ? '<span class="ticket-meta">Intervenção disponível após domínio validado</span>' : ''}
|
||
</div>
|
||
${deskActions ? `<div class="assist-console-actions"><h5>Acções Desk</h5><div class="actions">${deskActions}</div></div>` : ''}
|
||
${links ? `<div class="assist-console-links"><h5>Links externos</h5><div class="actions">${links}</div></div>` : ''}
|
||
${audit ? `<h5 style="margin-top:0.75rem">Movimento / audit</h5><ul class="audit-mini">${audit}</ul>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
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' || action === 'resume-wizard') && 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 `
|
||
<div class="kpi-card kpi-card--${variant}${click}"${jump}${title}>
|
||
<span class="kpi-card-glow" aria-hidden="true"></span>
|
||
<div class="kpi-card-inner">
|
||
<span class="kpi-value">${value}</span>
|
||
<span class="kpi-label">${esc(label)}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 `
|
||
<div class="pulse-chip pulse-chip--${cls}">
|
||
<span class="pulse-dot" aria-hidden="true"></span>
|
||
<div class="pulse-body">
|
||
<strong>${esc(t.name)}</strong>
|
||
<span>${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks</span>
|
||
</div>
|
||
${healthBadge(t.status)}
|
||
</div>`;
|
||
}).join('');
|
||
return `
|
||
<div class="dashboard-pulse">
|
||
${auditChips}
|
||
<div class="pulse-chip pulse-chip--${vmOk ? 'ok' : 'warn'}">
|
||
<span class="pulse-dot" aria-hidden="true"></span>
|
||
<div class="pulse-body">
|
||
<strong>VM112 Portal</strong>
|
||
<span>${esc(vm112.vm112?.service || vm112.error || '—')}</span>
|
||
</div>
|
||
<span class="badge ${vmOk ? 'ok' : 'review'}">${vmOk ? 'online' : 'check'}</span>
|
||
</div>
|
||
<div class="pulse-chip pulse-chip--${wazuhOk ? 'ok' : 'warn'}">
|
||
<span class="pulse-dot" aria-hidden="true"></span>
|
||
<div class="pulse-body">
|
||
<strong>VM104 Wazuh</strong>
|
||
<span>API ${wazuh.http_status ?? '—'}</span>
|
||
</div>
|
||
<span class="badge ${wazuhOk ? 'ok' : 'review'}">${wazuhOk ? 'online' : 'check'}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function techRankingHtml(ranking) {
|
||
if (!ranking?.length) return '<p class="ticket-meta">Sem movimento no período</p>';
|
||
return `
|
||
<table class="ranking-table">
|
||
<thead><tr><th>#</th><th>Técnico</th><th>Assumidos</th><th>Escalados</th><th>Acções</th><th>Score</th></tr></thead>
|
||
<tbody>
|
||
${ranking.slice(0, 8).map((r, i) => `
|
||
<tr>
|
||
<td>${i + 1}</td>
|
||
<td><strong>${esc(r.username)}</strong></td>
|
||
<td>${r.assumidos}</td>
|
||
<td>${r.escalados}</td>
|
||
<td>${r.acoes}</td>
|
||
<td><strong>${r.score}</strong></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`;
|
||
}
|
||
|
||
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 = `<p class="cf-dns-empty">Carregando detalhes de <strong>${esc(domain)}</strong>…</p>`;
|
||
|
||
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 '<p class="cf-dns-empty">Dados DNS indisponíveis.</p>';
|
||
}
|
||
if (dns.error && !dns.records?.length) {
|
||
return `
|
||
<p class="cf-dns-error">${esc(dns.error)}</p>
|
||
${dns.email_service ? '<p class="cf-dns-meta">Serviço: servidor de e-mail (onboarding)</p>' : ''}`;
|
||
}
|
||
const rows = (dns.records || []).map((r) => `
|
||
<tr class="${r.email_related ? 'dns-email-row' : ''}">
|
||
<td><span class="dns-purpose-badge purpose-${esc(r.purpose || 'other')}">${esc(dnsPurposeLabel(r.purpose))}</span></td>
|
||
<td><code>${esc(r.name)}</code></td>
|
||
<td><strong>${esc(r.type)}</strong></td>
|
||
<td class="dns-content">${esc(r.content)}</td>
|
||
</tr>`).join('');
|
||
const summary = dns.summary || {};
|
||
const zone = dns.zone || {};
|
||
return `
|
||
<div class="cf-dns-inline-summary">
|
||
<div class="cf-metric-stat">
|
||
<strong>${summary.total || 0}</strong>
|
||
<span>registos na zona</span>
|
||
</div>
|
||
<div class="cf-metric-stat">
|
||
<strong>${summary.email_related || 0}</strong>
|
||
<span>para e-mail</span>
|
||
</div>
|
||
<span class="badge ${dns.email_service ? 'onboard' : 'open'}">${dns.email_service ? 'E-mail' : 'DNS'}</span>
|
||
</div>
|
||
<p class="cf-dns-meta">Zona <code>${esc(zone.name || '—')}</code>${dns.error ? ` · ${esc(dns.error)}` : ''}</p>
|
||
<div class="cf-dns-table-wrap">
|
||
<table class="data-table dns-records-table dns-records-table-compact">
|
||
<thead><tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th></tr></thead>
|
||
<tbody>${rows || '<tr><td colspan="4">Sem registos para este domínio.</td></tr>'}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function htmlCloudflareDnsCard(dns) {
|
||
if (!dns) {
|
||
return `
|
||
<div class="modal-section dns-records-section">
|
||
<h4>Apontamentos DNS (Cloudflare)</h4>
|
||
<p class="loading">Dados DNS indisponíveis.</p>
|
||
</div>`;
|
||
}
|
||
if (dns.error && !dns.records?.length) {
|
||
return `
|
||
<div class="modal-section">
|
||
<h4>Apontamentos DNS (Cloudflare)</h4>
|
||
<p class="ticket-meta">${esc(dns.error)}</p>
|
||
${dns.email_service ? '<p class="ticket-meta">Serviço: servidor de e-mail (onboarding)</p>' : ''}
|
||
</div>`;
|
||
}
|
||
const rows = (dns.records || []).map((r) => `
|
||
<tr class="${r.email_related ? 'dns-email-row' : ''}">
|
||
<td><span class="dns-purpose-badge purpose-${esc(r.purpose || 'other')}">${esc(dnsPurposeLabel(r.purpose))}</span></td>
|
||
<td><code>${esc(r.name)}</code></td>
|
||
<td><strong>${esc(r.type)}</strong>${r.priority != null ? ` <span class="ticket-meta">prio ${r.priority}</span>` : ''}</td>
|
||
<td class="dns-content">${esc(r.content)}</td>
|
||
<td class="ticket-meta">${r.proxied ? 'proxy' : 'DNS only'} · TTL ${r.ttl ?? '—'}</td>
|
||
</tr>`).join('');
|
||
const summary = dns.summary || {};
|
||
const zone = dns.zone || {};
|
||
return `
|
||
<div class="modal-section dns-records-section">
|
||
<div class="card-head-row">
|
||
<h4>Apontamentos DNS (Cloudflare)</h4>
|
||
<span class="badge ${dns.email_service ? 'onboard' : 'open'}">${dns.email_service ? 'Servidor de e-mail' : 'DNS geral'}</span>
|
||
</div>
|
||
<p class="ticket-meta" style="margin:0 0 0.65rem">
|
||
Zona <code>${esc(zone.name || '—')}</code> · ${summary.total || 0} registo(s)
|
||
· ${summary.email_related || 0} para e-mail
|
||
${dns.error ? ` · <span class="badge review">${esc(dns.error)}</span>` : ''}
|
||
</p>
|
||
<div class="table-wrap">
|
||
<table class="data-table dns-records-table">
|
||
<thead>
|
||
<tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th><th>Estado</th></tr>
|
||
</thead>
|
||
<tbody>${rows || '<tr><td colspan="5">Sem registos DNS para este domínio na zona Cloudflare.</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 `<span class="badge ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
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) => `<li><code>${esc(i.domain)}</code> · ${esc(i.check_id)} — ${esc(i.message || i.status)}</li>`)
|
||
.join('');
|
||
const apiLabel = t.api_online ? `API online (${t.http_status || '—'})` : 'API offline';
|
||
return `
|
||
<button type="button" class="card health-card health-${esc(t.status)} health-card--click health-card--wazuh" data-open-overview="${t.tenant_id}">
|
||
<div class="health-card-head">
|
||
<div>
|
||
<h3 style="margin:0">${esc(t.name)}</h3>
|
||
<p class="ticket-meta">${esc(t.ip)} · <strong>${t.alerts_total || 0}</strong> alerta(s) · <strong>${t.agents_count || 0}</strong> agente(s)</p>
|
||
</div>
|
||
${healthBadge(t.status)}
|
||
</div>
|
||
<div class="health-score wazuh-score">
|
||
<span>${t.alerts_high || 0} alto (L≥${t.min_ticket_level || 10})</span>
|
||
<span>${t.open_tickets || 0} ticket(s) aberto(s)</span>
|
||
<span class="${t.api_online ? 'wazuh-api-ok' : 'wazuh-api-bad'}">${esc(apiLabel)}</span>
|
||
</div>
|
||
<p class="ticket-meta">Último alerta: ${fmtDate(t.last_alert_at)}</p>
|
||
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem alertas Wazuh registados — integração ativa aguarda eventos.</p>'}
|
||
<p class="health-card-hint">Clique para ver agentes, alertas por nível e tickets SOC</p>
|
||
</button>`;
|
||
}
|
||
|
||
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) => `
|
||
<tr>
|
||
<td><strong>${esc(a.agent)}</strong></td>
|
||
<td>${esc(a.agent_ip || '—')}</td>
|
||
<td>${a.alerts_count}</td>
|
||
<td>L${a.max_level}</td>
|
||
<td>${relativeTimeAgo(a.last_seen)}</td>
|
||
</tr>`).join('');
|
||
|
||
const alertRows = (data.alerts || []).slice(0, 40).map((a) => `
|
||
<tr>
|
||
<td>${severityBadge(a.level)}</td>
|
||
<td>${esc(a.agent)}</td>
|
||
<td>${esc(a.description || '—')}</td>
|
||
<td>${esc(a.srcip || '—')}</td>
|
||
<td>${esc(a.agent_ip || '—')}</td>
|
||
<td>${relativeTimeAgo(a.created_at)}</td>
|
||
</tr>`).join('');
|
||
|
||
const ticketRows = (data.tickets || []).slice(0, 15).map((t) => `
|
||
<button type="button" class="ticket-row" data-open-ticket="${t.id}">
|
||
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
|
||
<div>
|
||
<div class="ticket-subject">${esc(t.subject)}</div>
|
||
<div class="ticket-meta">#${t.id} · ${fmtDate(t.created_at)}</div>
|
||
</div>
|
||
</button>`).join('');
|
||
|
||
body.innerHTML = `
|
||
<div class="overview-summary wazuh-summary">
|
||
<div class="overview-stat"><strong>${s.alerts_total || 0}</strong><span>Alertas</span></div>
|
||
<div class="overview-stat"><strong>${s.alerts_24h || 0}</strong><span>24h</span></div>
|
||
<div class="overview-stat"><strong>${s.agents_total || 0}</strong><span>Agentes</span></div>
|
||
<div class="overview-stat"><strong>${s.level_10_plus || 0}</strong><span>L≥${data.min_ticket_level || 10}</span></div>
|
||
<div class="overview-stat"><strong>${s.open_tickets || 0}</strong><span>Tickets</span></div>
|
||
</div>
|
||
<p class="ticket-meta" style="margin:0 0 0.75rem">
|
||
Monitorização de segurança VM104 — webhooks <code>wazuh.alert</code> com nível ≥ ${data.min_ticket_level || 10} geram ticket na VM122.
|
||
</p>
|
||
<div class="wazuh-modal-grid">
|
||
<div class="modal-section">
|
||
<h4>Agentes monitorados</h4>
|
||
${agentRows ? `
|
||
<div class="wazuh-table-wrap">
|
||
<table class="wazuh-table">
|
||
<thead><tr><th>Agente</th><th>IP</th><th>Alertas</th><th>Máx</th><th>Último</th></tr></thead>
|
||
<tbody>${agentRows}</tbody>
|
||
</table>
|
||
</div>` : '<p class="loading">Nenhum agente com alertas registados.</p>'}
|
||
</div>
|
||
<div class="modal-section">
|
||
<h4>Feed de alertas</h4>
|
||
${alertRows ? `
|
||
<div class="wazuh-table-wrap">
|
||
<table class="wazuh-table">
|
||
<thead><tr><th>Nível</th><th>Agente</th><th>Descrição</th><th>Src IP</th><th>Agent IP</th><th>Hora</th></tr></thead>
|
||
<tbody>${alertRows}</tbody>
|
||
</table>
|
||
</div>` : '<p class="loading">Sem alertas.</p>'}
|
||
</div>
|
||
</div>
|
||
${ticketRows ? `
|
||
<div class="modal-section" style="margin-top:0.75rem">
|
||
<h4>Tickets Wazuh</h4>
|
||
<div class="ticket-list ticket-list-compact">${ticketRows}</div>
|
||
</div>` : ''}`;
|
||
|
||
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 `<span class="badge ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
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 `<span class="ws-threat-level" style="background:${bg}">${esc(label)}</span>`;
|
||
}
|
||
|
||
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 = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${s.color}" stroke-width="16" stroke-dasharray="${len} ${circ - len}" stroke-dashoffset="${-offset}" transform="rotate(-90 ${cx} ${cy})"/>`;
|
||
offset += len;
|
||
return el;
|
||
}).join('');
|
||
return `<svg class="ws-donut" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">${arcs}<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="middle" font-size="18" font-weight="700" fill="#0f172a">${total}</text></svg>`;
|
||
}
|
||
|
||
function wizardSecVBarSvg(items, width = 260, height = 120) {
|
||
if (!items.length) return '<p class="ws-empty-chart">Sem dados</p>';
|
||
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 `
|
||
<rect x="${x}" y="${y}" width="${barW}" height="${bh}" fill="${item.color}" rx="3"/>
|
||
<text x="${x + barW / 2}" y="${height - 6}" text-anchor="middle" font-size="9" fill="#64748b">${esc(item.short || item.label)}</text>
|
||
<text x="${x + barW / 2}" y="${y - 4}" text-anchor="middle" font-size="10" font-weight="600" fill="#0f172a">${item.value}</text>`;
|
||
}).join('');
|
||
return `<svg class="ws-vbar" viewBox="0 0 ${width} ${height}" width="100%" height="${height}" aria-hidden="true">${bars}</svg>`;
|
||
}
|
||
|
||
function wizardSecHBarHtml(items) {
|
||
if (!items.length) return '<p class="ws-empty-chart">Sem dados</p>';
|
||
const max = Math.max(...items.map((i) => i.value), 1);
|
||
return items.map((item) => `
|
||
<div class="ws-hbar-row">
|
||
<span class="ws-hbar-label">${esc(item.label)}</span>
|
||
<div class="ws-hbar-track"><div class="ws-hbar-fill" style="width:${Math.round((item.value / max) * 100)}%;background:${item.color}"></div></div>
|
||
<span class="ws-hbar-val">${item.value}</span>
|
||
</div>`).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) =>
|
||
`<li><code>${esc((ev.client_ip || '—'))}</code> · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : 'sem sessão'}</li>`
|
||
).join('');
|
||
return `
|
||
<button type="button" class="card health-card health-${status} health-card--click ws-access-overview-card" data-open-user-access="1">
|
||
<div class="health-card-head">
|
||
<div>
|
||
<h3 style="margin:0">Acesso Usuário — Cybersecurity</h3>
|
||
<p class="ticket-meta">Portal público · browser · handoff · <strong>não</strong> é o wizard VM112</p>
|
||
</div>
|
||
${healthBadge(status)}
|
||
</div>
|
||
<div class="health-score">${s.total || 0} alerta(s) · ${s.inputs_blocked || 0} bloq · ${s.handoffs_rejected || 0} handoff · ${s.csp_violations || 0} CSP</div>
|
||
<p class="ticket-meta">${s.sessions_with_alerts || 0} sessão(ões) · janela 24h</p>
|
||
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem alertas de acesso nas últimas 24h</p>'}
|
||
<p class="health-card-hint">Clique para ver dashboard de ameaças, guia técnico e relatório</p>
|
||
</button>`;
|
||
}
|
||
|
||
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 `
|
||
<tr class="wizard-sec-row" data-wizard-sec-session="${esc(ev.session_id || '')}">
|
||
<td>${esc(wizardSecurityEventLabel(ev.event_type))}</td>
|
||
<td>${wizardSecRiskCell(score)}</td>
|
||
<td>${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : '—'}</td>
|
||
<td><code>${esc(ev.client_ip || '—')}</code></td>
|
||
<td>${fmtDate(ev.created_at)}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
const accessStatus = wizardSecAccessStatus(s);
|
||
const issueLines = recent.slice(0, 3).map((ev) =>
|
||
`<li><code>${esc(ev.client_ip || '—')}</code> · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? `<code>${esc(ev.session_id.slice(0, 14))}…</code>` : 'sem sessão'}</li>`
|
||
).join('');
|
||
|
||
const dashboardGrid = `
|
||
<div class="ws-dash-grid ws-dash-grid--inner">
|
||
<article class="ws-panel ws-panel--teal">
|
||
<header class="ws-panel-head ws-panel-head--teal">Eventos nas últimas 24h</header>
|
||
<div class="ws-panel-body">${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}</div>
|
||
</article>
|
||
<article class="ws-panel ws-panel--orange">
|
||
<header class="ws-panel-head ws-panel-head--orange">Risco actual</header>
|
||
<div class="ws-panel-body ws-panel-body--donut">
|
||
${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 },
|
||
])}
|
||
<ul class="ws-legend">
|
||
<li><span style="background:${WIZARD_SEC_COLORS.high}"></span>Alto (${severityCounts.high})</li>
|
||
<li><span style="background:${WIZARD_SEC_COLORS.elevated}"></span>Elevado (${severityCounts.warn})</li>
|
||
<li><span style="background:${WIZARD_SEC_COLORS.low}"></span>Baixo (${severityCounts.info})</li>
|
||
</ul>
|
||
</div>
|
||
</article>
|
||
<article class="ws-panel ws-panel--teal">
|
||
<header class="ws-panel-head ws-panel-head--teal">Ameaças por vetor</header>
|
||
<div class="ws-panel-body">${wizardSecVBarSvg(vectorBars)}</div>
|
||
</article>
|
||
<article class="ws-panel ws-panel--orange">
|
||
<header class="ws-panel-head ws-panel-head--orange">IPs com atividade</header>
|
||
<div class="ws-panel-body ws-panel-body--ips">
|
||
${topIps.length ? topIps.map(([ip, n], i) => `
|
||
<div class="ws-ip-row">
|
||
<span class="ws-ip-rank">${i + 1}</span>
|
||
<code class="ws-ip-addr">${esc(ip)}</code>
|
||
<span class="ws-ip-count" style="background:${i === 0 && n > 1 ? WIZARD_SEC_COLORS.high : WIZARD_SEC_COLORS.orange}">${n} evt</span>
|
||
</div>`).join('') : '<p class="ws-empty-chart">Nenhum IP registado</p>'}
|
||
</div>
|
||
</article>
|
||
<article class="ws-panel ws-panel--teal">
|
||
<header class="ws-panel-head ws-panel-head--teal">Risco por categoria</header>
|
||
<div class="ws-panel-body">${wizardSecHBarHtml(riskBars)}</div>
|
||
</article>
|
||
<article class="ws-panel ws-panel--orange">
|
||
<header class="ws-panel-head ws-panel-head--orange">Relatório de ameaças</header>
|
||
<div class="ws-panel-body ws-panel-body--table">
|
||
<table class="ws-threat-table">
|
||
<thead><tr><th>Ameaça</th><th>Nível</th><th>Sessão</th><th>IP</th><th>Hora</th></tr></thead>
|
||
<tbody>${threatRows || '<tr><td colspan="5">Sem ameaças nas últimas 24h</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
</div>`;
|
||
|
||
return `
|
||
<section class="ws-access-zone" id="overview-access-security" aria-label="Acesso de usuário — cibersegurança">
|
||
<div class="ws-zone-banner">
|
||
<span class="ws-zone-tag">Área independente</span>
|
||
<h3 class="ws-zone-title">Acesso de usuário — Cibersegurança</h3>
|
||
<p class="ws-zone-desc">Eventos gerados quando alguém acede ao <strong>portal público</strong>, preenche formulários ou faz login (handoff). Isto é <em>segurança de acesso</em> — não mede DNS, Carbonio, certificados nem progresso do wizard VM112.</p>
|
||
</div>
|
||
|
||
<div class="card health-card health-${accessStatus} ws-access-health-card">
|
||
<div class="health-card-head">
|
||
<div>
|
||
<h3 style="margin:0">Threat tracking — portal & sessões</h3>
|
||
<p class="ticket-meta">Browser · CSP · inputs · handoff · Spec 021</p>
|
||
</div>
|
||
${healthBadge(accessStatus)}
|
||
</div>
|
||
<div class="health-score">${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</div>
|
||
<p class="ticket-meta">Janela ${s.window_hours || 24}h · origem <code>vm112-security</code></p>
|
||
${issueLines ? `<ul class="issue-list">${issueLines}</ul>` : '<p class="ticket-meta">Nenhum incidente de acesso nas últimas 24h</p>'}
|
||
<div class="ws-dash-inner">${dashboardGrid}</div>
|
||
<footer class="ws-dash-foot">
|
||
<button type="button" class="btn btn-ghost btn-sm" data-wizard-sec-goto-events>Ver todos em Eventos → Segurança</button>
|
||
${standalone ? '<button type="button" class="btn btn-ghost btn-sm" data-open-onboard-from-access>Ver VM112 Onboard →</button>' : ''}
|
||
</footer>
|
||
</div>
|
||
|
||
<div class="ws-info-cards-row">
|
||
<article class="card ws-info-card ws-info-card--teal">
|
||
<header class="ws-info-card-head ws-info-card-head--teal">O que monitorizamos</header>
|
||
<div class="ws-info-card-body">
|
||
<p>Este painel cobre apenas o <strong>comportamento de quem acede</strong> ao sistema — visitantes, clientes no portal e tentativas de abuso em formulários públicos.</p>
|
||
<ul class="ws-info-list">
|
||
<li><strong>CSP (browser)</strong> — scripts ou recursos bloqueados no navegador do usuário</li>
|
||
<li><strong>Input audit</strong> — padrões SQL/XSS em campos enviados pelo usuário</li>
|
||
<li><strong>Handoff</strong> — token de login expirado, reutilizado ou inválido</li>
|
||
<li><strong>Auth / sessão</strong> — falhas de autenticação ou anomalias de sessão</li>
|
||
</ul>
|
||
<p class="ws-info-note">${standalone
|
||
? 'Domínios, DNS e Carbonio estão no card <strong>Ligbox Datacenter — Node VM001</strong> — área separada.'
|
||
: '≠ Saúde do wizard VM112 (domínios, e-mail, certificados) — ver secção <em>Onboard</em> abaixo.'}</p>
|
||
</div>
|
||
</article>
|
||
<article class="card ws-info-card ws-info-card--orange">
|
||
<header class="ws-info-card-head ws-info-card-head--orange">Como proceder — guia técnico</header>
|
||
<div class="ws-info-card-body">
|
||
<ol class="ws-info-steps">
|
||
<li><strong>Input bloqueado / CSP</strong> — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.</li>
|
||
<li><strong>Handoff rejeitado</strong> — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).</li>
|
||
<li><strong>Correlacionar</strong> — Tickets → Onboard → hash da sessão. Compare com funil.</li>
|
||
<li><strong>Takeover</strong> — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.</li>
|
||
<li><strong>Falso positivo</strong> — Domínios com caracteres especiais podem gerar <em>input_warn</em>.</li>
|
||
<li><strong>Escalação</strong> — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.</li>
|
||
</ol>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
${standalone ? '' : `
|
||
<div class="ws-zone-divider" role="separator">
|
||
<span>Ligbox Datacenter — Node VM001 — wizard, domínios & infraestrutura</span>
|
||
</div>`}`;
|
||
}
|
||
|
||
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) =>
|
||
`<li><code>${esc(i.check_id)}</code> — ${esc(i.message || i.status)}</li>`
|
||
).join('');
|
||
return `
|
||
<button type="button" class="overview-domain-row" data-overview-domain="${esc(d.domain)}">
|
||
<div class="overview-domain-top">
|
||
<strong>${esc(d.domain)}</strong>
|
||
<div style="display:flex;gap:0.35rem;flex-wrap:wrap">
|
||
${executionStatusBadge(d.execution_status)}
|
||
${healthBadge(d.audit_status)}
|
||
</div>
|
||
</div>
|
||
<div class="overview-domain-meta">
|
||
<span>${esc(d.email || 'sem e-mail')}</span>
|
||
<span>${esc(d.funnel_stage_label || d.funnel_stage || '—')}</span>
|
||
<span>início ${fmtDate(d.started_at)}</span>
|
||
<span>último ${fmtDate(d.last_event_at)}</span>
|
||
${d.timing && window.DeskModules?.isEnabled('funnel-timing') ? `<span>total ${esc(d.timing.total_duration_label)}</span>` : ''}
|
||
<span>IP ${esc(d.client_ip || '—')}</span>
|
||
${d.ticket_id ? `<span>ticket #${d.ticket_id}</span>` : ''}
|
||
</div>
|
||
${issuePreview ? `<ul class="overview-domain-issues">${issuePreview}</ul>` : ''}
|
||
</button>`;
|
||
}).join('');
|
||
body.innerHTML = `
|
||
<section class="overview-tenant-zone" id="overview-tenant-onboard">
|
||
<div class="overview-zone-label">
|
||
<h4>Ligbox Datacenter — Node VM001</h4>
|
||
<p class="ticket-meta">Saúde do wizard, domínios em onboarding, DNS, certificados e Carbonio</p>
|
||
${window.DeskModules?.isEnabled('wizard-security') ? '<button type="button" class="btn btn-ghost btn-sm" data-open-access-from-onboard>← Acesso Usuário — Cybersecurity</button>' : ''}
|
||
</div>
|
||
<div class="overview-summary">
|
||
<div class="overview-stat"><strong>${s.domains_total || 0}</strong><span>Total</span></div>
|
||
<div class="overview-stat"><strong>${s.in_progress || 0}</strong><span>Em execução</span></div>
|
||
<div class="overview-stat"><strong>${s.completed || 0}</strong><span>Concluídos</span></div>
|
||
<div class="overview-stat"><strong>${s.failed || 0}</strong><span>Falharam</span></div>
|
||
<div class="overview-stat"><strong>${s.with_issues || 0}</strong><span>Com erros</span></div>
|
||
</div>
|
||
<p class="ticket-meta" style="margin:0 0 0.75rem">Clique num domínio para ver apontamentos DNS Cloudflare, timeline, checks e IP de acesso.</p>
|
||
${rows || '<p class="loading">Nenhum domínio auditado neste tenant.</p>'}
|
||
</section>`;
|
||
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 = '<p class="loading">Carregando detalhes…</p>';
|
||
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) => `
|
||
<tr>
|
||
<td>${esc(c.label || c.check_id)}</td>
|
||
<td>${checkStatusBadge(c.status)}</td>
|
||
<td>${esc(c.message || '—')}</td>
|
||
<td>${fmtDate(c.checked_at)}</td>
|
||
</tr>`).join('');
|
||
const timelineBlock = d.timeline?.length
|
||
? `${phaseTimingCardHtml(d.timing, d.timeline)}<h4 style="margin:1rem 0 0.5rem">Eventos</h4>${timelineHtml(d.timeline, d.timing, { compact: true })}`
|
||
: '<p class="loading">Sem eventos webhook para este domínio.</p>';
|
||
const ips = (d.client_ips || []).filter(Boolean);
|
||
body.innerHTML = `
|
||
<div class="modal-breadcrumb">
|
||
<button type="button" class="btn btn-ghost btn-sm" data-overview-back>← Voltar à lista</button>
|
||
<span class="ticket-meta">${esc(data.name)}</span>
|
||
</div>
|
||
<div class="health-card-head">
|
||
<div>
|
||
<h3 style="margin:0">${esc(d.domain)}</h3>
|
||
<p class="ticket-meta">${esc(d.email || 'sem e-mail')} · sessão <code>${esc((d.session_id || '—').slice(0, 18))}</code></p>
|
||
</div>
|
||
<div style="display:flex;gap:0.35rem;flex-wrap:wrap">
|
||
${executionStatusBadge(d.execution_status)}
|
||
${healthBadge(d.audit_status)}
|
||
</div>
|
||
</div>
|
||
<dl class="detail-kv">
|
||
<div><dt>Etapa funil</dt><dd>${esc(d.funnel_stage_label || d.funnel_stage || '—')}</dd></div>
|
||
<div><dt>Início</dt><dd>${fmtDate(d.started_at)}</dd></div>
|
||
<div><dt>Último evento</dt><dd>${esc(d.last_event || '—')} · ${fmtDate(d.last_event_at)}</dd></div>
|
||
<div><dt>Último audit</dt><dd>${fmtDate(d.last_audit_at)}</dd></div>
|
||
<div><dt>IP de acesso</dt><dd><code>${esc(d.client_ip || (ips[0] || '—'))}</code></dd></div>
|
||
<div><dt>Ticket</dt><dd>${d.ticket_id ? `#${d.ticket_id} (${esc(d.ticket_status || '—')})` : '—'}</dd></div>
|
||
</dl>
|
||
${ips.length > 1 ? `<p class="ticket-meta">IPs observados: ${ips.map((ip) => `<code>${esc(ip)}</code>`).join(' · ')}</p>` : ''}
|
||
${htmlCloudflareDnsCard(dnsData)}
|
||
<div class="modal-section">
|
||
<h4>Checks de auditoria</h4>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Check</th><th>Status</th><th>Mensagem</th><th>Verificado</th></tr></thead>
|
||
<tbody>${checkRows || '<tr><td colspan="4">Sem checks</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="modal-section">
|
||
<h4>Timeline de execução</h4>
|
||
${timelineBlock}
|
||
</div>
|
||
${d.ticket_id ? `<div class="actions"><button type="button" class="btn btn-primary btn-sm" data-open-ticket="${d.ticket_id}">Abrir ticket #${d.ticket_id}</button></div>` : ''}`;
|
||
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 = '<p class="loading">Carregando detalhes…</p>';
|
||
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 = `
|
||
<p class="loading">Erro: ${esc(e.message)}</p>
|
||
<button type="button" class="btn btn-ghost btn-sm" data-retry-overview-modal>Tentar novamente</button>`;
|
||
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 = '<p class="loading">Carregando segurança de acesso…</p>';
|
||
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 = `
|
||
<p class="loading">Erro ao carregar segurança de acesso: ${esc(e.message)}</p>
|
||
<p class="ticket-meta">Verifique ligação ao Desk e permissões de audit.</p>
|
||
<button type="button" class="btn btn-ghost btn-sm" data-retry-user-access>Tentar novamente</button>`;
|
||
body.querySelector('[data-retry-user-access]')?.addEventListener('click', () => openUserAccessModal());
|
||
}
|
||
}
|
||
|
||
async function renderOverview() {
|
||
const el = document.getElementById('overview-content');
|
||
el.innerHTML = '<p class="loading">Carregando overview…</p>';
|
||
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) => `<li><code>${esc(i.domain)}</code> · ${esc(i.check_id)} — ${esc(i.message || i.status)}</li>`)
|
||
.join('');
|
||
cards.push(`
|
||
<button type="button" class="card health-card health-${esc(t.status)} health-card--click" data-open-overview="${t.tenant_id}">
|
||
<div class="health-card-head">
|
||
<div>
|
||
<h3 style="margin:0">${esc(t.name)}</h3>
|
||
<p class="ticket-meta">${esc(t.ip)} · <strong>${t.domains_count || 0}</strong> domínio(s) · wizard & infra</p>
|
||
</div>
|
||
${healthBadge(t.status)}
|
||
</div>
|
||
<div class="health-score">${t.score?.pass ?? 0}/${t.score?.total ?? 8} pass · ${t.score?.warn ?? 0} warn · ${t.score?.fail ?? 0} fail</div>
|
||
<p class="ticket-meta">Último audit: ${fmtDate(t.last_audit_at)}</p>
|
||
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem issues ou aguarde o 1º ciclo de auditoria</p>'}
|
||
<p class="health-card-hint">Clique para ver domínios, DNS, checks e timeline do onboarding</p>
|
||
</button>`);
|
||
});
|
||
el.innerHTML = cards.length
|
||
? `<div class="health-grid">${cards.join('')}</div>`
|
||
: '<p class="loading">Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.</p>';
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
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 `<svg class="cf-spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true"><polyline fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${pts}"/></svg>`;
|
||
}
|
||
|
||
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 = '<p class="loading">Módulo Serviços não carregado.</p>';
|
||
}
|
||
|
||
async function renderLeads() {
|
||
const el = document.getElementById('leads-content');
|
||
if (!canReadLeads()) {
|
||
el.innerHTML = '<p class="loading">Sem permissão para ver leads</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando leads…</p>';
|
||
try {
|
||
const data = await api('/v1/crm/leads');
|
||
const leads = data.leads || [];
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-head-row">
|
||
<h3>Leads abandonados</h3>
|
||
<span class="ticket-meta">Stale ≥ ${data.stale_hours ?? 24}h sem concluir onboarding</span>
|
||
</div>
|
||
<p class="ticket-meta" style="margin-bottom:1rem">
|
||
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).
|
||
</p>
|
||
${leads.length
|
||
? `<div class="lead-grid">${leads.map(leadRowHtml).join('')}</div>`
|
||
: '<p class="loading">Nenhum lead no momento</p>'}
|
||
</div>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
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 = '<p class="loading">Carregando tickets…</p>';
|
||
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('')
|
||
: '<p class="loading">Nenhum ticket neste filtro</p>';
|
||
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 = '<div class="card detail-panel"><p class="empty">Selecione um ticket ou sessão do funil</p></div>';
|
||
}
|
||
} catch (e) {
|
||
listEl.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function renderSessionDetail() {
|
||
const detailEl = document.getElementById('ticket-detail');
|
||
const sessionId = state.selectedSessionId;
|
||
if (!sessionId) return;
|
||
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando sessão…</p></div>';
|
||
try {
|
||
const meta = await loadAssistMeta(sessionId);
|
||
detailEl.innerHTML = `
|
||
<div class="card detail-panel">
|
||
<h3 style="margin:0">Sessão onboarding</h3>
|
||
<dl class="kv">
|
||
<dt>Domínio</dt><dd>${esc(meta.domain || '—')}</dd>
|
||
<dt>Etapa</dt><dd>${esc(FUNNEL_LABELS[meta.funnel_stage] || meta.funnel_stage || '—')}</dd>
|
||
<dt>Sessão</dt><dd><code>${esc(meta.session_id)}</code></dd>
|
||
${meta.ticket_id ? `<dt>Ticket</dt><dd>#${meta.ticket_id}</dd>` : ''}
|
||
</dl>
|
||
${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)}<h3 style="margin-top:1.25rem">Eventos</h3>${timelineHtml(meta.timeline, meta.timing, { compact: true })}` : ''}
|
||
</div>`;
|
||
bindAssistActions(detailEl, sessionId);
|
||
bindLiveTimingClock(detailEl);
|
||
} catch (e) {
|
||
detailEl.innerHTML = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
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 = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
|
||
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 = `
|
||
<div class="card detail-panel">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">
|
||
<h3 style="margin:0">Ticket #${t.id}</h3>
|
||
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
|
||
</div>
|
||
<dl class="kv">
|
||
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
|
||
<dt>Domínio/Agente</dt><dd>${esc(t.domain || t.agent || '—')}</dd>
|
||
<dt>Email</dt><dd>${esc(t.email || '—')}</dd>
|
||
<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>
|
||
${t.assigned_to ? `<dt>Atribuído</dt><dd>${esc(t.assigned_to)}</dd>` : ''}
|
||
${t.assisted_by ? `<dt>Assistido por</dt><dd>${esc(t.assisted_by)}</dd>` : ''}
|
||
${t.client_paused ? '<dt>Cliente</dt><dd><span class="badge assisting">pausado</span></dd>' : ''}
|
||
${t.ready_for_ops ? '<dt>Ops</dt><dd><span class="badge ok">ready for ops</span></dd>' : ''}
|
||
${t.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
|
||
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</dd>` : ''}
|
||
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
|
||
${t.desk_message ? `<dt>Nota</dt><dd>${esc(t.desk_message)}</dd>` : ''}
|
||
${t.registration_role ? `<dt>Perfil</dt><dd>${esc(roleLabel(t.registration_role))}</dd>` : ''}
|
||
${t.ativation_url ? `<dt>Ativar conta</dt><dd><a class="btn btn-primary btn-sm" href="${esc(t.ativation_url)}" target="_blank" rel="noopener">Abrir link de ativação</a></dd>` : ''}
|
||
<dt>Sessão/Alert ID</dt><dd><code>${esc(t.session_id || '—')}</code></dd>
|
||
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</dd>
|
||
<dt>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
|
||
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
|
||
</dl>
|
||
${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) : ''}
|
||
<div class="actions">
|
||
${canPatchTickets() ? (closeStatuses.includes(t.status)
|
||
? `<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>`
|
||
: `<button type="button" class="btn btn-ghost" data-action="open">Reabrir ticket</button>`) : ''}
|
||
</div>
|
||
${timeline.length ? `${phaseTimingCardHtml(timing, timeline)}<h3 style="margin-top:1.25rem">Eventos</h3>${timelineHtml(timeline, timing, { compact: true })}` : ''}
|
||
<h3 style="margin-top:1.25rem">Payload</h3>
|
||
<pre class="raw">${esc(JSON.stringify(t.payload, null, 2))}</pre>
|
||
</div>`;
|
||
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 = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
async function updateTicketStatus(status) {
|
||
await api(`/v1/desk/tickets/${state.selectedTicketId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
await renderTickets();
|
||
}
|
||
|
||
async function renderEvents() {
|
||
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 = '<p class="loading">Carregando eventos…</p>';
|
||
try {
|
||
const srcQ = state.eventSourceFilter !== 'all' ? `?source=${state.eventSourceFilter}` : '';
|
||
const data = await api(`/v1/webhooks/events${srcQ}`);
|
||
const rows = (data.events || []).map((e) => {
|
||
const p = e.payload || {};
|
||
const dataObj = p.data || {};
|
||
return `<tr>
|
||
<td>${e.id}</td>
|
||
<td>${sourceBadge(e.source)}</td>
|
||
<td><span class="badge open">${esc(e.event_type)}</span> ${severityBadge(dataObj.level || e.severity)}</td>
|
||
<td>${esc(p.domain || '—')}</td>
|
||
<td><code>${esc((p.session_id || '').slice(0, 16))}</code></td>
|
||
<td>${fmtDate(e.created_at)}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
el.innerHTML = `
|
||
<div class="card table-wrap">
|
||
<table>
|
||
<thead><tr><th>ID</th><th>Origem</th><th>Evento</th><th>Agente/Domínio</th><th>Ref</th><th>Data</th></tr></thead>
|
||
<tbody>${rows || '<tr><td colspan="6">Sem eventos</td></tr>'}</tbody>
|
||
</table>
|
||
</div>`;
|
||
} catch (e) {
|
||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
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 `<span class="badge ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
function carbonioReleaseGuideHtml() {
|
||
return `
|
||
<details class="wizard-sec-playbook" style="margin:0.75rem 0">
|
||
<summary>Guia — libertar e-mail ACCOUNT_EXISTS</summary>
|
||
<ol class="wizard-sec-steps">
|
||
<li>O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).</li>
|
||
<li>Confirme o e-mail exacto e a sua <strong>senha Desk</strong> (não a do Carbonio nem root).</li>
|
||
<li>A ação remove apenas a conta Carbonio (<code>zmprov da</code>) — domínio, DNS e portal mantêm-se.</li>
|
||
<li>Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.</li>
|
||
<li>Dois técnicos a resolver em paralelo: só o primeiro consegue; o outro vê «já resolvido».</li>
|
||
</ol>
|
||
</details>`;
|
||
}
|
||
|
||
function carbonioResolveFormHtml(block) {
|
||
if (block.status === 'resolved') {
|
||
return `<p class="ticket-meta">Resolvido por <strong>${esc(block.resolved_by)}</strong> em ${fmtDate(block.resolved_at)}${block.resolution_note ? ` — ${esc(block.resolution_note)}` : ''}</p>`;
|
||
}
|
||
if (!canReadTickets()) return '';
|
||
return `
|
||
<form class="carbonio-resolve-form" data-carbonio-block="${block.id}" style="margin-top:0.75rem">
|
||
<p class="ticket-meta">Confirme o e-mail e a sua senha Desk para executar <code>zmprov da</code> na VM112.</p>
|
||
<div style="display:grid;gap:0.5rem;max-width:24rem">
|
||
<label>E-mail a libertar
|
||
<input type="email" name="confirm_email" required placeholder="${esc(block.email)}" autocomplete="off" class="input">
|
||
</label>
|
||
<label>Sua senha Desk
|
||
<input type="password" name="password" required autocomplete="current-password" class="input">
|
||
</label>
|
||
<button type="submit" class="btn btn-primary btn-sm">Remover conta Carbonio</button>
|
||
</div>
|
||
<p class="carbonio-resolve-msg ticket-meta" hidden style="margin-top:0.5rem"></p>
|
||
</form>`;
|
||
}
|
||
|
||
function carbonioBlockPanelHtml(block) {
|
||
return `
|
||
<div class="card carbonio-release-card" style="margin-top:1rem;border-left:4px solid #e67e22">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">
|
||
<h3 style="margin:0">Bloqueio Carbonio — ACCOUNT_EXISTS</h3>
|
||
${carbonioBlockStatusBadge(block.status)}
|
||
</div>
|
||
<p class="ticket-meta" style="margin:0.5rem 0">
|
||
E-mail <code>${esc(block.email)}</code> · domínio ${esc(block.domain)}
|
||
${block.ticket_id ? ` · bloqueio #${block.id}` : ''}
|
||
</p>
|
||
${block.error_message ? `<p class="ticket-meta" style="opacity:0.85">${esc(block.error_message.slice(0, 240))}</p>` : ''}
|
||
${carbonioReleaseGuideHtml()}
|
||
${carbonioResolveFormHtml(block)}
|
||
</div>`;
|
||
}
|
||
|
||
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 = '<p class="loading">Módulo Bloqueios Carbonio desativado.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando bloqueios Carbonio…</p>';
|
||
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('')
|
||
: '<p class="ticket-meta">Nenhum bloqueio pendente — novos casos aparecem aqui via webhook <code>onboarding.failed</code> + ACCOUNT_EXISTS.</p>';
|
||
const resolvedRows = resolvedBlocks.map((b) => `
|
||
<tr>
|
||
<td>#${b.id}</td>
|
||
<td><code>${esc(b.email)}</code></td>
|
||
<td>${esc(b.domain)}</td>
|
||
<td>${esc(b.resolved_by || '—')}</td>
|
||
<td>${fmtDate(b.resolved_at)}</td>
|
||
<td>${b.ticket_id ? `#${b.ticket_id}` : '—'}</td>
|
||
</tr>`).join('');
|
||
el.innerHTML = `
|
||
<div class="card" style="margin-bottom:1rem">
|
||
<p class="ticket-meta" style="margin:0">
|
||
<strong>${pending.total || pendingBlocks.length}</strong> pendente(s) ·
|
||
<strong>${resolved.total || resolvedBlocks.length}</strong> resolvido(s) recentes
|
||
</p>
|
||
</div>
|
||
${pendingCards}
|
||
<details class="card" style="margin-top:1rem">
|
||
<summary style="cursor:pointer;font-weight:600">Histórico resolvido (${resolvedBlocks.length})</summary>
|
||
<div class="table-wrap" style="margin-top:0.75rem">
|
||
<table>
|
||
<thead><tr><th>ID</th><th>E-mail</th><th>Domínio</th><th>Resolvido por</th><th>Quando</th><th>Ticket</th></tr></thead>
|
||
<tbody>${resolvedRows || '<tr><td colspan="6">Nenhum</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</details>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function renderSecurityEvents() {
|
||
syncEventsToolbar();
|
||
const el = document.getElementById('events-content');
|
||
if (!window.DeskModules?.isEnabled('wizard-security')) {
|
||
el.innerHTML = '<p class="loading">Módulo Segurança Wizard desativado.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando eventos de segurança…</p>';
|
||
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) => `
|
||
<tr class="wizard-sec-row" data-wizard-sec-session="${esc(ev.session_id || '')}" style="cursor:pointer">
|
||
<td>${wizardSecuritySeverityBadge(ev.severity)}</td>
|
||
<td>${esc(wizardSecurityEventLabel(ev.event_type))}</td>
|
||
<td>${ev.session_id ? sessionHashHtml(ev.session_id) : '—'}</td>
|
||
<td>${esc(ev.domain || '—')}</td>
|
||
<td><code>${esc(ev.client_ip || '—')}</code></td>
|
||
<td>${esc(ev.endpoint || ev.reason || '—')}</td>
|
||
<td>${fmtDate(ev.created_at)}</td>
|
||
</tr>`).join('');
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<p class="ticket-meta" style="margin:0 0 0.75rem">
|
||
Últimas 24h: <strong>${summary.csp_violations || 0}</strong> CSP ·
|
||
<strong>${summary.inputs_blocked || 0}</strong> bloqueados ·
|
||
<strong>${summary.handoffs_rejected || 0}</strong> handoffs rejeitados
|
||
</p>
|
||
<details class="wizard-sec-playbook"><summary>Guia rápido para técnicos</summary>
|
||
<ol class="wizard-sec-steps">
|
||
<li>Input bloqueado → anote hash + IP; se repetido, escale.</li>
|
||
<li>Handoff rejeitado → cliente deve refazer login; ticket escalado automático.</li>
|
||
<li>Clique na linha para abrir a sessão em Tickets.</li>
|
||
</ol>
|
||
</details>
|
||
<div class="table-wrap" style="margin-top:0.75rem">
|
||
<table class="wizard-sec-table">
|
||
<thead><tr><th>Nível</th><th>Evento</th><th>Sessão</th><th>Domínio</th><th>IP</th><th>Detalhe</th><th>Quando</th></tr></thead>
|
||
<tbody>${rows || '<tr><td colspan="7">Nenhum evento de segurança</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
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 `<span class="badge ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
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 `<ul class="vm112-purge-timeline purge-history-timeline">${(steps || []).map((step) => {
|
||
const status = step.status || 'pending';
|
||
return `
|
||
<li class="vm112-purge-step vm112-purge-step--${esc(status)}">
|
||
<span class="vm112-purge-step-time">${esc(fmtDate(step.at))}</span>
|
||
<div class="vm112-purge-step-body">
|
||
<strong>${esc(step.label)}</strong>
|
||
${step.detail ? `<span>${esc(step.detail)}</span>` : ''}
|
||
</div>
|
||
</li>`;
|
||
}).join('')}</ul>`;
|
||
}
|
||
|
||
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 = '<p class="loading">Carregando…</p>';
|
||
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 <code>${esc(job.id)}</code>`;
|
||
const desk = job.desk || {};
|
||
const vm112 = job.vm112 || {};
|
||
const deskRows = Object.entries({
|
||
webhook_events: 'Eventos webhook',
|
||
tickets: 'Tickets',
|
||
audit_domains: 'Domínios audit',
|
||
assist_sessions: 'Sessões assist',
|
||
audit_checks: 'Checks audit',
|
||
}).map(([key, label]) => `
|
||
<tr><td>${esc(label)}</td><td>${Number(desk[key] || 0)}</td></tr>`).join('');
|
||
const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : [];
|
||
const timeline = (job.timeline || []).length ? job.timeline : vm112Steps;
|
||
body.innerHTML = `
|
||
<div class="purge-history-grid">
|
||
<div class="card">
|
||
<h4>Removido no Desk (VM122)</h4>
|
||
<table class="purge-history-kv">
|
||
<tbody>${deskRows}</tbody>
|
||
<tfoot><tr><td><strong>Total</strong></td><td><strong>${Object.values(desk).reduce((a, b) => a + Number(b || 0), 0)}</strong></td></tr></tfoot>
|
||
</table>
|
||
</div>
|
||
<div class="card">
|
||
<h4>Removido na VM112</h4>
|
||
<p class="purge-history-vm112-sum">${vm112RemovedSummary(vm112)}</p>
|
||
${job.elapsed_vm112 ? `<p class="ticket-meta">Duração VM112: ${job.elapsed_vm112}s</p>` : ''}
|
||
${job.error ? `<p class="purge-history-error">${esc(job.error)}</p>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-top:1rem">
|
||
<h4>Timeline completa</h4>
|
||
${timeline.length ? renderPurgeTimelineHtml(timeline) : '<p class="loading">Sem passos registados</p>'}
|
||
</div>`;
|
||
})
|
||
.catch((e) => {
|
||
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
});
|
||
document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
|
||
el.onclick = closePurgeHistoryModal;
|
||
});
|
||
}
|
||
|
||
async function renderPurgeHistory() {
|
||
syncEventsToolbar();
|
||
const el = document.getElementById('events-content');
|
||
el.innerHTML = '<p class="loading">Carregando histórico de purges…</p>';
|
||
try {
|
||
const data = await api('/v1/vm112/purge/jobs?limit=200');
|
||
const rows = (data.jobs || []).map((j) => `
|
||
<tr class="purge-history-row" data-purge-job="${esc(j.id)}" tabindex="0" role="button">
|
||
<td><code class="purge-history-link">${esc(j.id)}</code></td>
|
||
<td><strong>${esc(j.domain)}</strong></td>
|
||
<td>${purgeStatusBadge(j.status)}</td>
|
||
<td>${esc(j.by || '—')}</td>
|
||
<td class="purge-history-removed">${esc(deskRemovedSummary(j.desk))}</td>
|
||
<td>${fmtDate(j.created_at)}</td>
|
||
<td>${j.elapsed_vm112 ? `${j.elapsed_vm112}s` : '—'}</td>
|
||
</tr>`).join('');
|
||
el.innerHTML = `
|
||
<div class="card table-wrap">
|
||
<p class="ticket-meta" style="padding:0.75rem 1rem 0">Clique numa linha para ver a timeline completa e o que foi removido em cada serviço.</p>
|
||
<table class="purge-history-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Job</th><th>Domínio</th><th>Status</th><th>Usuário</th>
|
||
<th>Desk removido</th><th>Quando</th><th>VM112</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows || '<tr><td colspan="7">Nenhum purge registado</td></tr>'}</tbody>
|
||
</table>
|
||
${data.total > (data.jobs || []).length ? `<p class="ticket-meta" style="padding:0.5rem 1rem">A mostrar ${(data.jobs || []).length} de ${data.total} purges.</p>` : ''}
|
||
</div>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function renderTenants() {
|
||
const el = document.getElementById('tenants-content');
|
||
el.innerHTML = '<p class="loading">Carregando…</p>';
|
||
try {
|
||
const data = await api('/v1/tenants');
|
||
el.innerHTML = `
|
||
<div class="card table-wrap">
|
||
<table>
|
||
<thead><tr><th>ID</th><th>Nome</th><th>IP</th><th>Papel</th><th>Desde</th></tr></thead>
|
||
<tbody>${(data.tenants || []).map((t) => `
|
||
<tr>
|
||
<td>${t.id}</td>
|
||
<td>${esc(t.name)}</td>
|
||
<td><code>${esc(t.ip)}</code></td>
|
||
<td>${esc(t.role)}</td>
|
||
<td>${fmtDate(t.created_at)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
} catch (e) {
|
||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
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 `<span class="role-badge ${cls}">${esc(roleLabel(role))}</span>`;
|
||
}
|
||
|
||
function mfaBadgeHtml(user) {
|
||
if (user.totp_enabled) {
|
||
const backups = Number(user.backup_codes_remaining || 0);
|
||
const hint = backups > 0 ? ` · ${backups} backup` : '';
|
||
return `<span class="badge ok">2FA${hint}</span>`;
|
||
}
|
||
return '<span class="badge review">sem 2FA</span>';
|
||
}
|
||
|
||
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) => `<option value="${r.value}" ${r.value === selected ? 'selected' : ''}>${esc(r.label)}</option>`)
|
||
.join('');
|
||
return `<optgroup label="${esc(group)}">${opts}</optgroup>`;
|
||
}).join('');
|
||
}
|
||
|
||
function roleSelectHtml(username, current, assignableOnly = true) {
|
||
const options = assignableOnly && current !== 'super_admin'
|
||
? ASSIGNABLE_ROLE_OPTIONS
|
||
: ROLE_OPTIONS;
|
||
const opts = options.map((r) =>
|
||
`<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`
|
||
).join('');
|
||
return `<select class="admin-role" data-user="${esc(username)}">${opts}</select>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="team-drawer-profile">
|
||
<div class="team-avatar team-avatar-lg" aria-hidden="true">${esc(userInitials(user.display_name, user.username))}</div>
|
||
<div>
|
||
<p class="team-drawer-name">${esc(user.display_name || user.username)}</p>
|
||
<p class="ticket-meta">${esc(email)}</p>
|
||
<p class="ticket-meta"><code>${esc(user.username)}</code></p>
|
||
</div>
|
||
</div>
|
||
<dl class="kv team-drawer-meta">
|
||
<dt>Criado</dt><dd>${fmtDate(user.created_at)}</dd>
|
||
<dt>Último login</dt><dd>${fmtRelative(user.last_login_at)}</dd>
|
||
<dt>Segurança</dt><dd>${mfaBadgeHtml(user)}</dd>
|
||
</dl>
|
||
<form id="team-drawer-form" class="team-drawer-form" autocomplete="off">
|
||
<label>Nome de exibição
|
||
<input type="text" id="team-drawer-display" value="${esc(user.display_name || '')}" placeholder="Nome"/>
|
||
</label>
|
||
<label>Perfil
|
||
${roleSelectHtml(user.username, user.role, !isRoot)}
|
||
</label>
|
||
<label>Estado da conta
|
||
<select id="team-drawer-active" ${isRoot ? 'disabled' : ''}>
|
||
<option value="1" ${user.active ? 'selected' : ''}>ativo</option>
|
||
<option value="0" ${!user.active ? 'selected' : ''}>inativo</option>
|
||
</select>
|
||
</label>
|
||
<label>Nova senha
|
||
<input type="password" id="team-drawer-password" placeholder="opcional (mín. 6 caracteres)" minlength="6"/>
|
||
</label>
|
||
${user.totp_enabled ? `
|
||
<div class="team-drawer-danger">
|
||
<p class="ticket-meta">2FA ativo — o usuário pode recuperar no login ou você pode resetar aqui.</p>
|
||
<button type="button" class="btn btn-ghost btn-sm" id="team-reset-2fa">Resetar 2FA</button>
|
||
</div>` : ''}
|
||
<p id="team-drawer-msg" class="admin-msg" hidden></p>
|
||
<div class="team-drawer-actions">
|
||
<button type="submit" class="btn btn-primary">Salvar alterações</button>
|
||
<button type="button" class="btn btn-ghost" data-close-team-drawer>Cancelar</button>
|
||
</div>
|
||
</form>`;
|
||
|
||
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 = '<p class="loading">Sem permissão</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando equipe…</p>';
|
||
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) => `
|
||
<tr class="team-row" data-user="${esc(u.username)}" tabindex="0">
|
||
<td>
|
||
<div class="team-user-cell">
|
||
<div class="team-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
|
||
<div>
|
||
<strong class="team-user-name">${esc(u.display_name || u.username)}</strong>
|
||
<span class="team-user-email">${esc(u.email || u.username)}</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>${roleBadgeHtml(u.role)}</td>
|
||
<td>${mfaBadgeHtml(u)}</td>
|
||
<td class="team-muted">${fmtRelative(u.last_login_at)}</td>
|
||
<td>${u.active ? '<span class="badge ok">ativo</span>' : '<span class="badge closed">inativo</span>'}</td>
|
||
<td class="team-actions">
|
||
<button type="button" class="btn btn-ghost btn-sm team-edit-btn" data-user="${esc(u.username)}">Editar</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
el.innerHTML = `
|
||
<div class="team-admin">
|
||
<header class="team-admin-head">
|
||
<div>
|
||
<h2 class="team-admin-title">Equipe Ligbox</h2>
|
||
<p class="ticket-meta">Gestão de acessos ao Support Desk</p>
|
||
</div>
|
||
<button type="button" class="btn btn-ghost btn-sm" id="team-goto-messages">
|
||
Pedidos de cadastro${pending ? ` <span class="badge review">${pending}</span>` : ''}
|
||
</button>
|
||
</header>
|
||
|
||
<div class="team-kpi-grid">
|
||
<div class="team-kpi card"><span class="team-kpi-val">${users.length}</span><span class="team-kpi-label">membros</span></div>
|
||
<div class="team-kpi card"><span class="team-kpi-val">${activeCount}</span><span class="team-kpi-label">ativos</span></div>
|
||
<div class="team-kpi card"><span class="team-kpi-val">${mfaCount}</span><span class="team-kpi-label">com 2FA</span></div>
|
||
<div class="team-kpi card"><span class="team-kpi-val">${inactiveCount}</span><span class="team-kpi-label">inativos</span></div>
|
||
</div>
|
||
|
||
<div class="team-toolbar card">
|
||
<label class="team-search">
|
||
<span class="sr-only">Buscar</span>
|
||
<input type="search" id="team-filter-q" placeholder="Buscar nome, e-mail ou perfil…" value="${esc(q)}"/>
|
||
</label>
|
||
<label>Perfil
|
||
<select id="team-filter-role">
|
||
<option value="all" ${role === 'all' ? 'selected' : ''}>Todos</option>
|
||
${ROLE_OPTIONS.map((r) => `<option value="${r.value}" ${role === r.value ? 'selected' : ''}>${r.label}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
<label>Estado
|
||
<select id="team-filter-status">
|
||
<option value="all" ${status === 'all' ? 'selected' : ''}>Todos</option>
|
||
<option value="active" ${status === 'active' ? 'selected' : ''}>Ativos</option>
|
||
<option value="inactive" ${status === 'inactive' ? 'selected' : ''}>Inativos</option>
|
||
</select>
|
||
</label>
|
||
<label>2FA
|
||
<select id="team-filter-mfa">
|
||
<option value="all" ${mfa === 'all' ? 'selected' : ''}>Todos</option>
|
||
<option value="on" ${mfa === 'on' ? 'selected' : ''}>Com 2FA</option>
|
||
<option value="off" ${mfa === 'off' ? 'selected' : ''}>Sem 2FA</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="card team-table-wrap">
|
||
<table class="data-table team-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Membro</th>
|
||
<th>Perfil</th>
|
||
<th>Segurança</th>
|
||
<th>Último login</th>
|
||
<th>Estado</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows || '<tr><td colspan="6" class="loading">Nenhum membro encontrado</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
<p class="team-table-foot ticket-meta">${filtered.length} de ${users.length} membros</p>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function renderModules() {
|
||
const el = document.getElementById('modules-content');
|
||
if (!el) return;
|
||
const user = getUser();
|
||
if (user?.role !== 'super_admin') {
|
||
el.innerHTML = '<p class="loading">Apenas Super Admin pode gerenciar módulos.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando módulos…</p>';
|
||
try {
|
||
await DeskModules.load();
|
||
const mods = DeskModules.list;
|
||
el.innerHTML = `
|
||
<div class="card modules-admin-card">
|
||
<h3 style="margin-top:0">Módulos do Desk</h3>
|
||
<p class="ticket-meta">Desativar um módulo remove-o do menu e desliga enriquecimentos na API — o núcleo continua estável.</p>
|
||
<div class="modules-grid">
|
||
${mods.map((m) => `
|
||
<label class="module-row${m.locked ? ' module-row--locked' : ''}">
|
||
<div class="module-row-main">
|
||
<strong>${esc(m.label)}</strong>
|
||
<span class="ticket-meta">${esc(m.description)}</span>
|
||
<code class="module-id">${esc(m.id)}</code>
|
||
${m.locked ? '<span class="badge open">núcleo</span>' : ''}
|
||
</div>
|
||
<input type="checkbox" data-module-toggle="${esc(m.id)}" ${m.enabled ? 'checked' : ''} ${m.locked ? 'disabled' : ''} aria-label="Activar ${esc(m.label)}" />
|
||
</label>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
const REG_ROLE_LABELS = ROLE_LABELS;
|
||
|
||
async function renderMessages() {
|
||
const el = document.getElementById('messages-content');
|
||
if (!canManageUsers()) {
|
||
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando pedidos…</p>';
|
||
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) => `
|
||
<div class="card admin-user-card" data-req="${r.id}">
|
||
<div class="health-card-head">
|
||
<div>
|
||
<h3 style="margin:0">${esc(r.email)}</h3>
|
||
<p class="ticket-meta">${esc(r.display_name || '—')} · ${fmtDate(r.created_at)}</p>
|
||
</div>
|
||
<span class="badge review">pendente</span>
|
||
</div>
|
||
<label>Perfil a atribuir
|
||
<select class="req-role">
|
||
${registrationRoleSelectHtml('technician')}
|
||
</select>
|
||
</label>
|
||
<div class="actions" style="margin-top:0.75rem">
|
||
<button type="button" class="btn btn-primary req-approve">Aprovar</button>
|
||
<button type="button" class="btn btn-ghost req-reject">Rejeitar</button>
|
||
<span class="admin-msg"></span>
|
||
</div>
|
||
</div>`).join('');
|
||
const historyRows = history.map((r) => `
|
||
<tr>
|
||
<td>${esc(r.email)}</td>
|
||
<td><span class="badge ${r.status === 'active' ? 'ok' : r.status === 'rejected' ? 'closed' : 'review'}">${esc(statusLabel(r.status))}</span></td>
|
||
<td>${esc(r.role ? roleLabel(r.role) : '—')}</td>
|
||
<td>${fmtDate(r.updated_at || r.created_at)}</td>
|
||
</tr>`).join('');
|
||
el.innerHTML = `
|
||
<div class="admin-users">
|
||
<h3 style="margin:0 0 0.5rem">Pedidos pendentes <span class="ticket-meta">(${pending.length})</span></h3>
|
||
${pendingCards || '<p class="loading">Nenhum pedido pendente</p>'}
|
||
${history.length ? `
|
||
<h3 style="margin:1.5rem 0 0.5rem">Histórico</h3>
|
||
<div class="card" style="overflow:auto">
|
||
<table class="data-table">
|
||
<thead><tr><th>E-mail</th><th>Estado</th><th>Perfil</th><th>Atualizado</th></tr></thead>
|
||
<tbody>${historyRows}</tbody>
|
||
</table>
|
||
</div>` : ''}
|
||
</div>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function renderAccount(force = false) {
|
||
const el = document.getElementById('account-content');
|
||
if (state.accountLoaded && !force) {
|
||
return;
|
||
}
|
||
const saved = force ? null : readAccountPwdForm();
|
||
el.innerHTML = '<p class="loading">Carregando…</p>';
|
||
try {
|
||
const me = await api('/v1/auth/me');
|
||
const totpOn = Boolean(me.totp_enabled || me.mfa_enabled);
|
||
el.innerHTML = `
|
||
<div class="card account-card" style="max-width:480px">
|
||
<h3 style="margin:0 0 0.75rem">Minha conta</h3>
|
||
<dl class="kv account-kv">
|
||
<dt>E-mail / login</dt><dd><code>${esc(me.email || me.username)}</code></dd>
|
||
<dt>Perfil</dt><dd>${esc(roleLabel(me.role))}</dd>
|
||
<dt>Nome</dt><dd>${esc(me.display_name || '—')}</dd>
|
||
<dt>Último login</dt><dd>${fmtDate(me.last_login_at)}</dd>
|
||
<dt>2FA (app)</dt><dd>${totpOn ? '<span class="badge ok">ativo</span>' : '<span class="badge review">não configurado</span>'}</dd>
|
||
${totpOn ? `<dt>Códigos backup</dt><dd>${Number(me.backup_codes_remaining || 0)} restante(s)</dd>` : ''}
|
||
</dl>
|
||
<hr style="border:none;border-top:1px solid var(--border);margin:1.25rem 0"/>
|
||
<h4 style="margin:0 0 0.5rem">Alterar senha</h4>
|
||
<p class="ticket-meta" style="margin:0 0 1rem">
|
||
${totpOn
|
||
? 'Por segurança, confirme a senha atual e o código do autenticador (sessão aberta). Se perdeu o app, use <a href="/login.html">recuperação no login</a> ou um código de backup.'
|
||
: 'Informe a senha atual e escolha uma nova (mín. 8 caracteres).'}
|
||
</p>
|
||
<form id="account-pwd-form" class="account-pwd-form" autocomplete="off">
|
||
<label for="acct-pwd-current">Senha atual
|
||
<input type="password" id="acct-pwd-current" name="current_password" autocomplete="current-password" required/>
|
||
</label>
|
||
<label for="acct-pwd-new">Nova senha
|
||
<input type="password" id="acct-pwd-new" name="new_password" autocomplete="new-password" required minlength="8"/>
|
||
</label>
|
||
<label for="acct-pwd-new2">Confirmar nova senha
|
||
<input type="password" id="acct-pwd-new2" name="new_password2" autocomplete="off" required minlength="8"/>
|
||
</label>
|
||
${totpOn ? `
|
||
<label for="acct-pwd-totp">Código 2FA (app)
|
||
<input type="text" id="acct-pwd-totp" name="totp_code" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code" required/>
|
||
</label>` : ''}
|
||
<p id="account-pwd-error" class="login-error" hidden></p>
|
||
<p id="account-pwd-ok" class="login-notice" hidden></p>
|
||
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:0.5rem">Salvar nova senha</button>
|
||
</form>
|
||
</div>`;
|
||
restoreAccountPwdForm(saved);
|
||
bindAccountPwdForm(totpOn);
|
||
state.accountLoaded = true;
|
||
} catch (e) {
|
||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
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 `
|
||
<svg class="soc-area-chart" viewBox="0 0 ${width} ${height}" width="100%" height="${height}" aria-hidden="true">
|
||
<defs>
|
||
<linearGradient id="soc-area-grad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.35"/>
|
||
<stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<polygon points="${area}" fill="url(#soc-area-grad)"/>
|
||
<polyline fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${line}"/>
|
||
</svg>`;
|
||
}
|
||
|
||
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 `
|
||
<div class="soc-pipe-row">
|
||
<span class="soc-pipe-label">${esc(FUNNEL_LABELS[key] || key)}</span>
|
||
<div class="soc-pipe-bar"><div class="soc-pipe-fill" style="width:${pct}%"></div></div>
|
||
<span class="soc-pipe-count">${n}</span>
|
||
</div>`;
|
||
}).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 = `
|
||
<div class="soc-test-result">
|
||
<div class="soc-test-status soc-test-status--${ok ? 'ok' : 'fail'}">
|
||
<span class="soc-sev soc-sev--${ok ? 'ok' : 'high'}"></span>
|
||
${esc(result.message || (ok ? 'Integração VM112 → VM122 respondendo corretamente.' : 'Falha ao processar webhook.'))}
|
||
</div>
|
||
<dl class="soc-test-kv">
|
||
<dt>Status</dt><dd>${esc(result.status || '—')}</dd>
|
||
<dt>Evento</dt><dd>${esc(result.event || '—')}</dd>
|
||
<dt>Origem</dt><dd>${esc(result.source || '—')}</dd>
|
||
<dt>Domínio</dt><dd>${esc(result.domain || '—')}</dd>
|
||
<dt>Sessão</dt><dd>${esc(result.session_id || '—')}</dd>
|
||
<dt>Duplicado</dt><dd>${dup ? 'sim' : 'não'}</dd>
|
||
<dt>Ticket criado</dt><dd>${result.ticket_created ? `sim (#${result.ticket_id})` : 'não'}</dd>
|
||
<dt>Disparado por</dt><dd>${esc(result.triggered_by || '—')}</dd>
|
||
</dl>
|
||
<p class="soc-test-hint">
|
||
Este teste simula um evento <code>integration.test</code> no endpoint
|
||
<code>POST /api/v1/webhooks/onboard</code> — 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.
|
||
</p>
|
||
<div class="soc-test-actions">
|
||
<button type="button" class="soc-btn" data-soc-goto-events>Ver em Eventos</button>
|
||
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = `
|
||
<div class="soc-test-result">
|
||
<div class="soc-test-status soc-test-status--fail">
|
||
<span class="soc-sev soc-sev--high"></span>
|
||
${esc(msg)}
|
||
</div>
|
||
${is403 ? `<p class="soc-test-hint">Apenas perfis <strong>super_admin</strong> e <strong>admin</strong> podem executar o teste de webhook.</p>` : ''}
|
||
<p class="soc-test-hint">Verifique se a API está online, se a sessão não expirou e se o usuário tem permissão.</p>
|
||
<div class="soc-test-actions">
|
||
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
|
||
</div>
|
||
</div>`;
|
||
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) => `
|
||
<li class="badge ${s.ok ? 'ok' : 'escalated'}">
|
||
<strong>${esc(s.name)}</strong> — ${esc(s.detail || (s.ok ? 'OK' : 'FAIL'))}
|
||
</li>`).join('');
|
||
|
||
body.innerHTML = `
|
||
<div class="soc-test-result">
|
||
<div class="soc-test-status ${ok ? 'soc-test-status--ok' : 'soc-test-status--fail'}">
|
||
<span class="soc-sev ${ok ? 'soc-sev--low' : 'soc-sev--high'}"></span>
|
||
${esc(result.message || (ok ? 'Multidomínio OK' : 'Falha'))}
|
||
</div>
|
||
<ul class="soc-alerts" style="list-style:none;padding:0;margin:0.75rem 0;display:flex;flex-direction:column;gap:0.35rem">${steps || '<li>—</li>'}</ul>
|
||
<p class="soc-test-hint">
|
||
Suite <code>openpanel-multidomain-api-confirm</code> — usa os nomes que indicou nos campos
|
||
(ou gera automaticamente). Aguarde até 3 minutos sem sair da página.
|
||
Script CLI: <code>scripts/test-openpanel-multidomain-api.sh</code>
|
||
</p>
|
||
<div class="soc-test-actions">
|
||
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
|
||
</div>
|
||
</div>`;
|
||
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 = `
|
||
<div class="soc-test-result">
|
||
<div class="soc-test-status soc-test-status--fail">
|
||
<span class="soc-sev soc-sev--high"></span>
|
||
${esc(msg)}
|
||
</div>
|
||
${is403 ? '<p class="soc-test-hint">Perfis: <strong>super_admin</strong>, <strong>devops</strong>, <strong>developer</strong>.</p>' : ''}
|
||
<div class="soc-test-actions">
|
||
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
|
||
</div>
|
||
</div>`;
|
||
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 = '<p class="loading">Módulo Infra 2 SOC desativado. Active em <strong>Módulos</strong>.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '<p class="loading">Carregando SOC…</p>';
|
||
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 = `
|
||
<div class="soc-console" id="soc-console-root">
|
||
<header class="soc-header">
|
||
<div class="soc-header-left">
|
||
<span class="soc-live-dot ${liveCls}" aria-hidden="true"></span>
|
||
<h3>SOC Operations Center</h3>
|
||
<span class="soc-meta">VM112 → VM122 · atualizado ${esc(nowLabel)} · refresh 15s</span>
|
||
</div>
|
||
<div class="soc-header-actions">
|
||
<select class="soc-select" id="soc-window-select" aria-label="Janela de tempo">
|
||
<option value="24h" ${state.socWindow === '24h' ? 'selected' : ''}>24 horas</option>
|
||
<option value="48h" ${state.socWindow === '48h' ? 'selected' : ''}>48 horas</option>
|
||
<option value="7d" ${state.socWindow === '7d' ? 'selected' : ''}>7 dias</option>
|
||
</select>
|
||
<button type="button" class="soc-btn" id="soc-btn-test">Testar webhook</button>
|
||
<button type="button" class="soc-btn soc-btn--ghost" id="soc-btn-refresh">Atualizar</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="soc-kpi-grid">
|
||
<div class="soc-kpi soc-kpi--${socStatusKpiClass(intStatus)}">
|
||
<span class="soc-kpi-label">Integração</span>
|
||
<span class="soc-kpi-value">${esc(intStatus)}</span>
|
||
<span class="soc-kpi-sub">VM112 onboard</span>
|
||
</div>
|
||
<div class="soc-kpi soc-kpi--${gapMin != null && gapMin > (health.webhook_gap_alert_minutes || 15) ? 'critical' : 'ok'}">
|
||
<span class="soc-kpi-label">Gap webhook</span>
|
||
<span class="soc-kpi-value">${gapMin != null ? `${gapMin}m` : '—'}</span>
|
||
<span class="soc-kpi-sub">limite ${health.webhook_gap_alert_minutes || 15} min</span>
|
||
</div>
|
||
<div class="soc-kpi soc-kpi--info">
|
||
<span class="soc-kpi-label">Eventos</span>
|
||
<span class="soc-kpi-value">${windowEvents.length}</span>
|
||
<span class="soc-kpi-sub">~${eventsPerHour}/h · ${state.socWindow}</span>
|
||
</div>
|
||
<div class="soc-kpi soc-kpi--info">
|
||
<span class="soc-kpi-label">Sessões</span>
|
||
<span class="soc-kpi-value">${funnel.sessions_total || sessions.length}</span>
|
||
<span class="soc-kpi-sub">funil ativo</span>
|
||
</div>
|
||
<div class="soc-kpi soc-kpi--${onboardTicketsOpen > 0 ? 'warn' : 'ok'}">
|
||
<span class="soc-kpi-label">Tickets onboard</span>
|
||
<span class="soc-kpi-value">${onboardTicketsOpen}</span>
|
||
<span class="soc-kpi-sub">abertos agora</span>
|
||
</div>
|
||
<div class="soc-kpi soc-kpi--${alerts.length ? 'warn' : 'ok'}">
|
||
<span class="soc-kpi-label">Alertas</span>
|
||
<span class="soc-kpi-value">${alerts.length}</span>
|
||
<span class="soc-kpi-sub">${lastWh.event ? esc(lastWh.event) : 'sem eventos'}</span>
|
||
</div>
|
||
${window.DeskModules?.isEnabled('wizard-security') ? `
|
||
<div class="soc-kpi soc-kpi--${(secSummary.inputs_blocked || 0) + (secSummary.handoffs_rejected || 0) > 0 ? 'critical' : (secSummary.total || 0) > 0 ? 'warn' : 'ok'}">
|
||
<span class="soc-kpi-label">Segurança wizard</span>
|
||
<span class="soc-kpi-value">${secSummary.total || 0}</span>
|
||
<span class="soc-kpi-sub">CSP ${secSummary.csp_violations || 0} · bloq ${secSummary.inputs_blocked || 0}</span>
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
<div class="soc-topology" aria-label="Topologia de integração">
|
||
<div class="soc-node">
|
||
<span class="soc-node-dot ${vmOk ? 'ok' : 'bad'}"></span>
|
||
VM112 Wizard
|
||
</div>
|
||
<span class="soc-flow"><strong>webhook</strong> POST /onboard →</span>
|
||
<div class="soc-node">
|
||
<span class="soc-node-dot ok"></span>
|
||
VM122 Desk
|
||
</div>
|
||
<span class="soc-flow">←</span>
|
||
<div class="soc-node">
|
||
<span class="soc-node-dot ${wazuhOk ? 'ok' : 'bad'}"></span>
|
||
VM104 Wazuh
|
||
</div>
|
||
<span class="soc-flow"><strong>alertas</strong> level ≥10</span>
|
||
</div>
|
||
|
||
<div class="soc-main-grid">
|
||
<div class="soc-panel">
|
||
<div class="soc-panel-head">
|
||
<h4>Feed ao vivo — VM112 + Segurança</h4>
|
||
<span class="soc-meta">${feedEvents.length} recentes</span>
|
||
</div>
|
||
<div class="soc-panel-body">
|
||
${feedEvents.length ? `
|
||
<table class="soc-feed">
|
||
<thead><tr><th></th><th>Evento</th><th>Domínio</th><th>Hora</th></tr></thead>
|
||
<tbody>
|
||
${feedEvents.map((ev, i) => {
|
||
const p = ev.payload || {};
|
||
const sev = socEventSeverity(ev.event_type);
|
||
const isNew = flashNew && i === 0;
|
||
return `
|
||
<tr class="soc-feed-row${isNew ? ' soc-feed-row--new' : ''}${ev._security ? ' soc-feed-row--security' : ''}">
|
||
<td><span class="soc-sev soc-sev--${sev}" title="${sev}"></span></td>
|
||
<td class="soc-event-name">${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)}</td>
|
||
<td class="soc-event-domain">${esc(p.domain || ev.domain || '—')}</td>
|
||
<td class="soc-event-time">${relativeTimeAgo(ev.created_at)}</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>` : '<p class="soc-empty">Nenhum evento VM112 registrado</p>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="soc-panel">
|
||
<div class="soc-panel-head">
|
||
<h4>Volume & funil</h4>
|
||
<span class="soc-meta">${state.socWindow}</span>
|
||
</div>
|
||
<div class="soc-panel-body">
|
||
<div class="soc-chart-wrap">
|
||
<div class="soc-chart-legend">
|
||
<span>Eventos VM112</span>
|
||
<span>máx ${Math.max(...chartBuckets, 0)}</span>
|
||
</div>
|
||
${socAreaChartSvg(chartBuckets)}
|
||
</div>
|
||
<div class="soc-pipeline">
|
||
${socPipelineHtml(funnel.stages || {}, funnel.sessions_total || 0)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="soc-panel">
|
||
<div class="soc-panel-head">
|
||
<h4>Sessões VM112</h4>
|
||
<span class="soc-meta">${sessions.length} ativas</span>
|
||
</div>
|
||
<div class="soc-panel-body">
|
||
<div class="soc-session-list">
|
||
${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
|
||
? `<span class="timing-badge soc-timing-badge" title="Duração total">Σ ${esc(tmeta.total_duration_label)}</span>`
|
||
: '';
|
||
const idleHint = tmeta && !tmeta.is_completed
|
||
? ` · parado ${esc(tmeta.idle_since_label)}`
|
||
: '';
|
||
return `
|
||
<button type="button" class="soc-session-card${s.stale ? ' stale' : ''}" data-soc-session="${esc(s.session_id || '')}" data-soc-ticket="${s.ticket_id || ''}">
|
||
<span class="soc-session-ring ${ringCls}">${esc(initials)}</span>
|
||
<span class="soc-session-main">
|
||
<strong>${esc(s.domain || 'sem domínio')}</strong>
|
||
<span>${esc(FUNNEL_LABELS[stage] || stage)} · onboarding · ${relativeTimeAgo(s.last_event_at)}${idleHint}</span>
|
||
${s.session_id ? `<span class="soc-session-hash">${sessionHashHtml(s.session_id)}</span>` : ''}
|
||
</span>
|
||
${timingBadge}
|
||
${s.ticket_id ? `<span class="badge ok">#${s.ticket_id}</span>` : '<span class="badge review">—</span>'}
|
||
</button>`;
|
||
}).join('') : '<p class="soc-empty">Sem sessões no período</p>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="soc-bottom-grid">
|
||
<div class="soc-panel" style="min-height:160px">
|
||
<div class="soc-panel-head"><h4>Alertas SOC</h4></div>
|
||
<div class="soc-panel-body">
|
||
<ul class="soc-alert-list">
|
||
${alerts.length ? alerts.map((a) => `
|
||
<li class="soc-alert-item soc-alert-item--${a.level === 'critical' ? 'critical' : 'warn'}">
|
||
<span class="soc-sev soc-sev--${a.level === 'critical' ? 'high' : 'warn'}"></span>
|
||
${esc(a.message)}
|
||
</li>`).join('') : `
|
||
<li class="soc-alert-item soc-alert-item--ok">
|
||
<span class="soc-sev soc-sev--ok"></span>
|
||
Integração saudável — sem alertas ativos
|
||
</li>`}
|
||
${lastWh.domain ? `
|
||
<li class="soc-alert-item">
|
||
<span class="soc-sev soc-sev--info"></span>
|
||
Último: <strong>${esc(lastWh.event)}</strong> · ${esc(lastWh.domain)} · ${relativeTimeAgo(lastWh.created_at)}
|
||
</li>` : ''}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="soc-panel" style="min-height:160px">
|
||
<div class="soc-panel-head"><h4>Health dos nós</h4></div>
|
||
<div class="soc-panel-body">
|
||
<div class="soc-health-grid">
|
||
<div class="soc-health-card">
|
||
<h5>VM112 Portal</h5>
|
||
<dl>
|
||
<dt>HTTP</dt><dd>${vm112.http_status ?? '—'}</dd>
|
||
<dt>Service</dt><dd>${esc(vm112.vm112?.service || vm112.error || '—')}</dd>
|
||
<dt>API</dt><dd>${onboard.vm112_api?.reachable ? 'OK' : 'offline'}</dd>
|
||
</dl>
|
||
</div>
|
||
<div class="soc-health-card">
|
||
<h5>VM122 Desk</h5>
|
||
<dl>
|
||
<dt>Integração</dt><dd>${esc(intStatus)}</dd>
|
||
<dt>Gap</dt><dd>${gapMin != null ? `${gapMin} min` : '—'}</dd>
|
||
<dt>Webhook</dt><dd>${esc(lastWh.event || '—')}</dd>
|
||
</dl>
|
||
</div>
|
||
<div class="soc-health-card">
|
||
<h5>VM104 Wazuh</h5>
|
||
<dl>
|
||
<dt>API</dt><dd>${wazuh.http_status ?? '—'}</dd>
|
||
<dt>Regra</dt><dd>level ≥ 10</dd>
|
||
<dt>Status</dt><dd>${wazuhOk ? 'online' : 'check'}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = `<p class="loading">Erro SOC: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function renderInfra() {
|
||
const el = document.getElementById('infra-content');
|
||
el.innerHTML = '<p class="loading">Verificando…</p>';
|
||
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) =>
|
||
`<li class="badge ${a.level === 'critical' ? 'escalated' : 'assisting'}">${esc(a.message)}</li>`
|
||
).join('') || '<li class="muted">Nenhum alerta</li>';
|
||
el.innerHTML = `
|
||
<div class="card soc-panel">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.75rem;flex-wrap:wrap">
|
||
<h3>SOC — Integração VM112</h3>
|
||
<span class="badge ${statusCls}">${esc(health.status || '—')}</span>
|
||
</div>
|
||
<dl class="kv">
|
||
<dt>Último webhook</dt><dd>${last ? esc(last.event) : '—'}</dd>
|
||
<dt>Domínio</dt><dd>${last?.domain ? esc(last.domain) : '—'}</dd>
|
||
<dt>Há quanto tempo</dt><dd>${gap}</dd>
|
||
<dt>VM112 API</dt><dd>${onboard.vm112_api?.reachable ? 'OK' : esc(onboard.vm112_api?.error || 'offline')}</dd>
|
||
</dl>
|
||
<ul class="soc-alerts" style="list-style:none;padding:0;margin:0.5rem 0;display:flex;flex-direction:column;gap:0.35rem">${alerts}</ul>
|
||
<div class="actions">
|
||
<button type="button" class="btn secondary" id="btn-test-webhook">Testar webhook</button>
|
||
<button type="button" class="btn secondary" id="btn-refresh-health">Atualizar</button>
|
||
</div>
|
||
<p class="health-card-hint">Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h3>OpenPanel API — Re-engenharia Ligbox</h3>
|
||
<p class="health-card-hint">Spec 028 · simule contas/domínios · duração ~1–3 min por conta.</p>
|
||
<div class="openpanel-test-form" style="display:grid;gap:0.65rem;max-width:32rem;margin:0.75rem 0">
|
||
<label><strong>Conta 1</strong> (username OpenPanel, ex: <code>meuteste1</code>)</label>
|
||
<input type="text" id="op-test-user1" placeholder="meuteste1" pattern="[a-z][a-z0-9]{2,15}" autocomplete="off" style="padding:0.45rem 0.6rem;border-radius:6px;border:1px solid var(--border,#444);background:var(--bg-input,#1a1a22);color:inherit;width:100%"/>
|
||
<label><strong>Domínio 1</strong> (ex: <code>meuteste1.ligbox.com.br</code>)</label>
|
||
<input type="text" id="op-test-domain1" placeholder="meuteste1.ligbox.com.br" autocomplete="off" style="padding:0.45rem 0.6rem;border-radius:6px;border:1px solid var(--border,#444);background:var(--bg-input,#1a1a22);color:inherit;width:100%"/>
|
||
<label><strong>Conta 2</strong> (opcional — 2º domínio na plataforma)</label>
|
||
<input type="text" id="op-test-user2" placeholder="meuteste2" autocomplete="off" style="padding:0.45rem 0.6rem;border-radius:6px;border:1px solid var(--border,#444);background:var(--bg-input,#1a1a22);color:inherit;width:100%"/>
|
||
<label><strong>Domínio 2</strong></label>
|
||
<input type="text" id="op-test-domain2" placeholder="meuteste2.ligbox.com.br" autocomplete="off" style="padding:0.45rem 0.6rem;border-radius:6px;border:1px solid var(--border,#444);background:var(--bg-input,#1a1a22);color:inherit;width:100%"/>
|
||
<label><strong>Senha hosting</strong></label>
|
||
<input type="text" id="op-test-password" value="LbOpenTest805353" autocomplete="off" style="padding:0.45rem 0.6rem;border-radius:6px;border:1px solid var(--border,#444);background:var(--bg-input,#1a1a22);color:inherit;width:100%"/>
|
||
<label style="display:flex;align-items:center;gap:0.4rem"><input type="checkbox" id="op-test-auto-names" checked/> Gerar nomes automaticamente se campos vazios</label>
|
||
<label style="display:flex;align-items:center;gap:0.4rem"><input type="checkbox" id="op-test-cleanup" checked/> Apagar contas após teste</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button type="button" class="btn secondary" id="btn-test-openpanel-api">Executar simulação</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>VM112 — Portal Onboard</h3>
|
||
<dl class="kv">
|
||
<dt>HTTP</dt><dd>${vm112.http_status ?? '—'}</dd>
|
||
<dt>Service</dt><dd>${esc(vm112.vm112?.service || vm112.error || '—')}</dd>
|
||
</dl>
|
||
</div>
|
||
<div class="card">
|
||
<h3>VM104 — Wazuh SOC</h3>
|
||
<dl class="kv">
|
||
<dt>API</dt><dd>${wazuh.http_status ?? '—'}</dd>
|
||
<dt>Integração</dt><dd>webhook level ≥ 10 → VM122</dd>
|
||
</dl>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Integrações ativas</h3>
|
||
<pre class="raw">${esc(JSON.stringify(integrations, null, 2))}</pre>
|
||
</div>`;
|
||
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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function refresh(options = {}) {
|
||
const { poll = false } = options;
|
||
await loadHealth();
|
||
if (poll && state.view === 'account') {
|
||
return;
|
||
}
|
||
if (state.view === 'dashboard') await renderDashboard();
|
||
if (state.view === 'email-migration' && window.DeskEmailMigration?.renderPage) await window.DeskEmailMigration.renderPage();
|
||
if (state.view === 'overview') await renderOverview();
|
||
if (state.view === 'overview-home') await renderOverviewHome({ poll });
|
||
if (state.view === 'leads') await renderLeads();
|
||
if (state.view === 'tickets') {
|
||
if (poll && window.TicketsWorkspace?._pageReady) await TicketsWorkspace.softRefresh();
|
||
else await renderTickets({ poll: false });
|
||
}
|
||
if (state.view === 'events') await renderEvents();
|
||
if (state.view === 'tenants') await renderTenants();
|
||
if (state.view === 'infra' && !state.openPanelTestRunning) await renderInfra();
|
||
if (state.view === 'infra2') await renderInfra2();
|
||
if (state.view === 'messages') await renderMessages();
|
||
if (state.view === 'admin') await renderAdmin();
|
||
if (state.view === 'modules') await renderModules();
|
||
if (state.view === 'account') await renderAccount();
|
||
}
|
||
|
||
document.querySelectorAll('.nav button').forEach((btn) => {
|
||
btn.addEventListener('click', () => setView(btn.dataset.view));
|
||
});
|
||
|
||
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.ticketFilter = btn.dataset.filter;
|
||
document.querySelectorAll('.filter-btn[data-filter]').forEach((b) => b.classList.toggle('active', b === btn));
|
||
renderTickets();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.filter-btn[data-source]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const kind = btn.dataset.kind || 'ticket';
|
||
if (kind === 'event') {
|
||
state.eventSourceFilter = btn.dataset.source;
|
||
document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => b.classList.toggle('active', b === btn));
|
||
renderEvents();
|
||
} else {
|
||
state.sourceFilter = btn.dataset.source;
|
||
document.querySelectorAll('.filter-btn[data-kind="ticket"]').forEach((b) => b.classList.toggle('active', b === btn));
|
||
renderTickets();
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('[data-events-tab]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.eventsTab = btn.dataset.eventsTab || 'webhooks';
|
||
document.querySelectorAll('[data-events-tab]').forEach((b) => b.classList.toggle('active', b === btn));
|
||
renderEvents();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
|
||
el.addEventListener('click', closePurgeHistoryModal);
|
||
});
|
||
|
||
document.getElementById('btn-refresh')?.addEventListener('click', () => {
|
||
if (state.view === 'account') {
|
||
state.accountLoaded = false;
|
||
}
|
||
refresh();
|
||
});
|
||
|
||
(async function boot() {
|
||
const dash = document.getElementById('dashboard-content');
|
||
try {
|
||
if (!getToken()) {
|
||
window.location.replace('/login.html');
|
||
return;
|
||
}
|
||
setupSidebarUser();
|
||
await DeskModules.load();
|
||
applyRoleNav();
|
||
DeskModules.applyVisibility();
|
||
bindOverviewModal();
|
||
bindTeamDrawerClose();
|
||
bindSocTestModal();
|
||
setView('dashboard');
|
||
|
||
ensureValidSession().then((valid) => {
|
||
if (!valid) window.location.replace('/login.html');
|
||
else setupSidebarUser();
|
||
});
|
||
|
||
reschedulePoll();
|
||
} catch (err) {
|
||
console.error('boot failed', err);
|
||
if (dash) {
|
||
dash.innerHTML = `<p class="loading">Erro ao iniciar (${esc(err.message)}). <a href="/login.html">Voltar ao login</a></p>`;
|
||
}
|
||
}
|
||
})();
|