import { setLang, tr, LOC, currentLang } from './i18n.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; const $ = (selector) => document.querySelector(selector); const connection = $('#connection'); const connectionText = $('#connectionText'); const version = $('#version'); const updateBtn = $('#updateBtn'); const refreshBtn = $('#refreshBtn'); const datasetPath = $('#datasetPath'); const analyzeBtn = $('#analyzeBtn'); const output = $('#output'); const alertList = $('#alertList'); const alertCount = $('#alertCount'); const alertSort = $('#alertSort'); const alertMin = $('#alertMin'); const minScoreOut = $('#minScoreOut'); const alertSearch = $('#alertSearch'); const pillarFilters = $('#pillarFilters'); const hostSearch = $('#hostSearch'); const hostRows = $('#hostRows'); const hostCount = $('#hostCount'); const hostChart = $('#hostChart'); const hostTip = $('#hostTip'); const pillarChart = $('#pillarChart'); const pillarTip = $('#pillarTip'); const overviewAlerts = $('#overviewAlerts'); const viewTitle = $('#viewTitle'); const viewSub = $('#viewSub'); const lastUpdated = $('#lastUpdated'); const reportSummary = $('#reportSummary'); const reportStatus = $('#reportStatus'); const serviceSummary = $('#serviceSummary'); const gotoSettings = $('#settingsBtn'); const settingsDlg = $('#settingsDlg'); const closeSettings = $('#closeSettings'); const doneSettings = $('#doneSettings'); const langSel = $('#langSel'); const themePick = $('#themePick'); const state = { alerts: [], hosts: [], report: null, status: null, view: 'overview', loaded: false, lastUpdated: null, filters: { pillar: 'all', minScore: 0, query: '', sort: 'score-desc' }, hostQuery: '', expandedAlerts: new Set(), expandedHosts: new Set(), }; const VIEW_META = { overview: { title: 'Visão geral', sub: 'Estado, prioridades e distribuição dos alertas', }, alerts: { title: 'Alertas', sub: 'Filtre, ordene e abra a evidência completa', }, hosts: { title: 'Hosts', sub: 'Prioridade por fusão de evidências entre pilares', }, analysis: { title: 'Análise', sub: 'Ingestão de logs Zeek e relatório da execução', }, }; let themePreference = localStorage.getItem('acaua-theme') || 'auto'; function t(text) { return tr(text); } function message(template, ...values) { let index = 0; return tr(template).replace(/\{n\}/g, () => values[index++] ?? ''); } function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function svgEl(tag, attributes = {}, text) { const node = document.createElementNS(SVG_NS, tag); for (const [key, value] of Object.entries(attributes)) { node.setAttribute(key, String(value)); } if (text !== undefined) node.textContent = text; return node; } function formatNumber(value, digits = 3) { return new Intl.NumberFormat(LOC(), { minimumFractionDigits: digits, maximumFractionDigits: digits, }).format(value); } function formatInt(value) { return new Intl.NumberFormat(LOC(), { maximumFractionDigits: 0 }).format(value); } function formatPercent(value, digits = 1) { return `${formatNumber(value * 100, digits)}%`; } function formatScientific(value) { const n = Number(value); if (!Number.isFinite(n)) return String(value); if (n === 0) return '0'; if (Math.abs(n) < 0.001 || Math.abs(n) >= 10000) { const [mantissa, exponent] = n.toExponential(2).split('e'); return `${formatNumber(Number(mantissa), 2)}e${exponent}`; } return formatNumber(n, 4); } function formatDuration(seconds) { const n = Number(seconds); if (!Number.isFinite(n)) return String(seconds); const abs = Math.abs(n); if (abs < 60) return `${formatNumber(n, abs < 10 ? 1 : 0)} s`; const total = Math.round(abs); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); const secs = total % 60; if (hours > 0) return `${formatInt(hours)} h ${formatInt(minutes)} min`; if (minutes > 0) return `${formatInt(minutes)} min ${formatInt(secs)} s`; return `${formatInt(secs)} s`; } function formatBits(bits) { const n = Number(bits); if (!Number.isFinite(n)) return String(bits); const units = [ [1e9, 'Gbit'], [1e6, 'Mbit'], [1e3, 'kbit'], ]; for (const [divisor, unit] of units) { if (Math.abs(n) >= divisor) { const scaled = n / divisor; return `${formatNumber(scaled, Math.abs(scaled) >= 100 ? 0 : 1)} ${unit}`; } } return `${formatNumber(n, 0)} bit`; } function parseWhen(raw) { if (!raw) return null; const slice = String(raw).slice(0, 19).replace(' ', 'T'); const date = new Date(`${slice}Z`); return Number.isNaN(date.getTime()) ? null : date; } function formatWhen(raw) { const date = parseWhen(raw); if (!date) return String(raw ?? '—'); return new Intl.DateTimeFormat(LOC(), { dateStyle: 'short', timeStyle: 'medium', }).format(date); } function formatEpoch(seconds) { const n = Number(seconds); if (!Number.isFinite(n)) return String(seconds); return new Intl.DateTimeFormat(LOC(), { dateStyle: 'short', timeStyle: 'short', }).format(new Date(n * 1000)); } function timeOnly(date = new Date()) { return new Intl.DateTimeFormat(LOC(), { timeStyle: 'medium' }).format(date); } function alertOrigH(alert) { const evidence = alert?.evidence || {}; if (typeof evidence.orig_h === 'string' && evidence.orig_h) return evidence.orig_h; const summary = String(alert?.summary || ''); const before = summary.split('->')[0]?.trim(); if (before) { const token = before.split(/\s+/).pop() || ''; const host = token.replace(/:(?:any|\d+)$/, ''); if (host) return host; } const fallback = summary.match(/([0-9a-fA-F:.]+)/); return fallback ? fallback[1] : '—'; } function alertDest(alert) { const summary = String(alert?.summary || ''); const after = summary.split('->')[1]; if (!after) return ''; return after.replace(/\s*\(.*$/, '').trim(); } function riskClass(score) { if (score >= 0.7) return 'high'; if (score >= 0.4) return 'medium'; return 'low'; } function pillarName(pillar) { return message('Pilar {n}', pillar); } function renderKpis() { const alerts = state.alerts; const hosts = state.hosts; const pillars = new Set(alerts.map((alert) => alert.pillar)); const multi = hosts.filter( (host) => (host.terms || []).filter((term) => term.m > 0).length > 1, ).length; const top = hosts[0]; const dataset = state.status?.dataset || {}; $('#kpiAlerts').textContent = formatInt(alerts.length); $('#kpiAlertsSub').textContent = message('{n} pilares ativos', pillars.size); $('#kpiHosts').textContent = formatInt(hosts.length); $('#kpiHostsSub').textContent = multi ? message('{n} com múltiplos pilares', multi) : t('ranqueados por fusão'); $('#kpiScore').textContent = top ? formatNumber(top.score, 3) : '—'; $('#kpiScoreSub').textContent = top ? top.orig_h : t('melhor host observado'); $('#kpiRecords').textContent = dataset.records === undefined ? '—' : formatInt(dataset.records); $('#kpiRecordsSub').textContent = dataset.backend ? message('{n} partições · {n}', formatInt(dataset.partitions ?? 0), dataset.backend) : t('armazenamento local'); document.querySelectorAll('[data-pillar-count]').forEach((node) => { const pillar = Number(node.dataset.pillarCount); node.textContent = formatInt(alerts.filter((alert) => alert.pillar === pillar).length); }); const fusionTag = $('#pillarFusionTag'); if (fusionTag) { fusionTag.textContent = hosts.length ? message('{n} hosts', hosts.length) : t('sem hosts'); } } function renderOverviewAlerts() { overviewAlerts.replaceChildren(); const top = [...state.alerts].sort((a, b) => b.score - a.score).slice(0, 4); if (!top.length) { overviewAlerts.append(el('p', 'empty', t('Nenhum alerta ainda.'))); return; } for (const alert of top) { const button = el('button', 'ov-alert'); button.type = 'button'; button.dataset.alertId = alert.id; const badge = el('span', `pillar-badge p${alert.pillar}`, pillarName(alert.pillar)); const text = el('span', 'ov-main'); const summary = el('span', 'ov-summary', alert.summary); summary.setAttribute('data-i18n-skip', ''); const meta = el('span', 'ov-meta'); meta.append( el('span', 'host-chip', alertOrigH(alert)), el('span', 'ov-dest', alertDest(alert)), ); text.append(summary, meta); const score = el('span', `ov-score ${riskClass(alert.score)}`); score.append( el('strong', '', formatNumber(alert.score, 3)), el('span', 'score-track', ''), ); const fill = document.createElement('span'); fill.className = 'score-fill'; fill.style.width = `${Math.min(100, Math.max(0, alert.score * 100))}%`; score.querySelector('.score-track').append(fill); button.append(badge, text, score); overviewAlerts.append(button); } } function flattenEvidence(value, prefix = '', out = []) { if (value && typeof value === 'object' && !Array.isArray(value)) { for (const [key, child] of Object.entries(value)) { const path = prefix ? `${prefix}.${key}` : key; if (child && typeof child === 'object' && !Array.isArray(child)) { flattenEvidence(child, path, out); } else { out.push([path, child]); } } } else { out.push([prefix || 'value', value]); } return out; } const RATIO_KEYS = new Set([ 'score', 'significance', 'regularity', 'pcr', 'nxdomain_ratio', ]); function leafOf(key) { return key.split('.').pop(); } function isDurationKey(leaf) { return leaf.endsWith('_seconds') || leaf.endsWith('_s'); } function isTimestampKey(leaf) { return leaf.endsWith('_ts'); } function isBitsKey(leaf) { return leaf.endsWith('_bits'); } function isRatioKey(leaf) { return RATIO_KEYS.has(leaf) || leaf.endsWith('_ratio') || leaf.endsWith('_pct'); } function formatEvidenceValue(key, value) { const leaf = leafOf(key); if (value === null || value === undefined || value === '') { return { text: '—', title: '' }; } if (typeof value === 'boolean') { return { text: value ? t('sim') : t('não'), title: '' }; } if (typeof value === 'number') { if (isTimestampKey(leaf)) { return { text: formatEpoch(value), title: formatNumber(value, 3) }; } if (leaf === 'entropy_bits_per_char') { return { text: `${formatNumber(value, 3)} bit/char`, title: String(value) }; } if (isBitsKey(leaf)) { return { text: formatBits(value), title: formatNumber(value, 2) }; } if (isDurationKey(leaf)) { return { text: formatDuration(value), title: `${formatNumber(value, 3)} s` }; } if (isRatioKey(leaf)) { return { text: formatPercent(value, 1), title: formatNumber(value, 6) }; } if (leaf === 'p_value') { return { text: formatScientific(value), title: String(value) }; } if (Number.isInteger(value)) { return { text: formatInt(value), title: '' }; } return { text: formatNumber(value, 4), title: String(value) }; } if (Array.isArray(value)) { return { text: value.map((item) => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join(', '), title: '', }; } return { text: String(value), title: '' }; } function evidenceTable(evidence) { const table = el('table', 'kv'); const body = document.createElement('tbody'); const rows = flattenEvidence(evidence || {}); if (!rows.length) { const row = document.createElement('tr'); const cell = el('td', 'empty', t('Sem evidência registrada.')); cell.colSpan = 2; row.append(cell); body.append(row); } for (const [key, value] of rows) { const row = document.createElement('tr'); const head = el('th', 'mono', key); head.scope = 'row'; const formatted = formatEvidenceValue(key, value); const cell = el('td'); cell.textContent = formatted.text; if (formatted.title) cell.title = formatted.title; row.append(head, cell); body.append(row); } table.append(body); return table; } function metricChips(alert) { const evidence = alert?.evidence || {}; const chips = []; const add = (label, value) => { const chip = el('span', 'metric'); chip.append(el('span', 'metric-label', t(label)), el('strong', 'metric-value', value)); chips.push(chip); }; if (alert.pillar === 1) { if (evidence.period_detected_seconds !== undefined) { add('Período', formatDuration(evidence.period_detected_seconds)); } if (evidence.regularity !== undefined) { add('Regularidade', formatPercent(evidence.regularity, 1)); } if (evidence.p_value !== undefined) { add('p-valor', formatScientific(evidence.p_value)); } if (evidence.significance !== undefined) { add('Significância', formatPercent(evidence.significance, 1)); } if (evidence.observations !== undefined) { add('Observações', formatInt(evidence.observations)); } } else if (alert.pillar === 2) { if (evidence.longest_session_seconds !== undefined) { add('Sessão', formatDuration(evidence.longest_session_seconds)); } if (evidence.parts !== undefined) add('Partes', formatInt(evidence.parts)); if (evidence.pcr !== undefined) add('PCR', formatPercent(evidence.pcr, 1)); if (evidence.failed_attempts !== undefined) { add('Falhas', formatInt(evidence.failed_attempts)); } if (evidence.session_start_ts !== undefined) { add('Início', formatEpoch(evidence.session_start_ts)); } } else if (alert.pillar === 3) { if (evidence.information_bits !== undefined) { add('Informação', `${formatBits(evidence.information_bits)}/h`); } if (evidence.entropy_bits_per_char !== undefined) { add('Entropia', `${formatNumber(evidence.entropy_bits_per_char, 2)} bit/char`); } if (evidence.unique_subdomains !== undefined) { add('Únicos', formatInt(evidence.unique_subdomains)); } if (evidence.jsd !== undefined) add('JSD', formatNumber(evidence.jsd, 3)); if (evidence.base_domain !== undefined) add('Domínio', evidence.base_domain); } const wrap = el('div', 'metrics'); wrap.append(...chips); return wrap; } function findAlert(id) { return state.alerts.find((alert) => alert.id === id) || null; } function hostAlerts(host) { return state.alerts.filter((alert) => alertOrigH(alert) === host); } function renderAlerts() { const filters = state.filters; const query = filters.query.trim().toLowerCase(); let alerts = state.alerts.filter((alert) => { if (filters.pillar !== 'all' && alert.pillar !== Number(filters.pillar)) return false; if (Number(alert.score) < filters.minScore) return false; if (!query) return true; const haystack = [ alert.id, alert.summary, alertOrigH(alert), alertDest(alert), alert.evidence?.base_domain || '', ] .join(' ') .toLowerCase(); return haystack.includes(query); }); alerts = [...alerts].sort((a, b) => { switch (filters.sort) { case 'score-asc': return a.score - b.score || String(a.id).localeCompare(String(b.id)); case 'pillar': return a.pillar - b.pillar || b.score - a.score; case 'newest': return String(b.created_at || '').localeCompare(String(a.created_at || '')); case 'score-desc': default: return b.score - a.score || String(a.id).localeCompare(String(b.id)); } }); alertCount.textContent = message('{n} de {n} alertas', alerts.length, state.alerts.length); alertList.replaceChildren(); if (!state.alerts.length) { alertList.append(el('p', 'empty', t('Nenhum alerta ainda. Analise um diretório de logs do Zeek.'))); return; } if (!alerts.length) { alertList.append(el('p', 'empty', t('Nenhum alerta corresponde aos filtros.'))); return; } for (const alert of alerts) { alertList.append(alertCard(alert)); } } function alertCard(alert) { const expanded = state.expandedAlerts.has(alert.id); const card = el('article', `alert-card p${alert.pillar}`); card.dataset.alertId = alert.id; const head = el('button', 'alert-head'); head.type = 'button'; head.setAttribute('aria-expanded', expanded ? 'true' : 'false'); head.setAttribute('aria-controls', `alert-body-${alert.id}`); const badge = el('span', `pillar-badge p${alert.pillar}`, pillarName(alert.pillar)); const main = el('span', 'alert-main'); const summary = el('span', 'alert-summary', alert.summary); summary.setAttribute('data-i18n-skip', ''); const meta = el('span', 'alert-meta'); meta.append( el('span', 'host-chip', alertOrigH(alert)), el('span', 'alert-arrow', '→'), el('span', 'alert-dest', alertDest(alert)), el('span', 'alert-when', formatWhen(alert.created_at)), ); main.append(summary, meta); const score = el('span', `alert-score ${riskClass(alert.score)}`); score.append(el('strong', '', formatNumber(alert.score, 3))); const track = el('span', 'score-track'); const fill = el('span', 'score-fill'); fill.style.width = `${Math.min(100, Math.max(0, alert.score * 100))}%`; track.append(fill); score.append(track); const chevron = el('span', 'chevron', expanded ? '⌄' : '›'); head.append(badge, main, score, chevron); const body = el('div', 'alert-body'); body.id = `alert-body-${alert.id}`; body.hidden = !expanded; body.append(metricChips(alert)); const evidenceHead = el('div', 'evidence-head'); evidenceHead.append( el('h3', '', t('Evidência completa')), el('span', 'mono dim', message('schema {n}', alert.evidence?.formula_version ?? '—')), ); body.append(evidenceHead, evidenceTable(alert.evidence)); const actions = el('div', 'alert-actions'); const idText = el('span', 'mono dim', `#${alert.id}`); const gotoHost = el('button', 'btn small', t('Ver host')); gotoHost.type = 'button'; gotoHost.dataset.action = 'goto-host'; gotoHost.dataset.host = alertOrigH(alert); const copy = el('button', 'btn small', t('Copiar JSON')); copy.type = 'button'; copy.dataset.action = 'copy-json'; actions.append(idText, gotoHost, copy); body.append(actions); card.append(head, body); return card; } function toggleAlert(id, force) { const next = force ?? !state.expandedAlerts.has(id); if (next) state.expandedAlerts.add(id); else state.expandedAlerts.delete(id); const card = alertList.querySelector(`[data-alert-id="${CSS.escape(id)}"]`); if (!card) return; const head = card.querySelector('.alert-head'); const body = card.querySelector('.alert-body'); const chevron = card.querySelector('.chevron'); head?.setAttribute('aria-expanded', next ? 'true' : 'false'); if (body) body.hidden = !next; if (chevron) chevron.textContent = next ? '⌄' : '›'; if (next) { card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } function deriveHosts(alerts) { const byHost = new Map(); for (const alert of alerts) { const host = alertOrigH(alert); if (!byHost.has(host)) { byHost.set(host, { orig_h: host, best: { 1: null, 2: null, 3: null }, count: 0 }); } const entry = byHost.get(host); entry.count += 1; const pillar = Number(alert.pillar); if (!entry.best[pillar]) { entry.best[pillar] = alert; } else { const current = entry.best[pillar]; if ( alert.score > current.score || (alert.score === current.score && String(alert.id) < String(current.id)) ) { entry.best[pillar] = alert; } } } const hosts = []; for (const entry of byHost.values()) { const terms = [1, 2, 3].map((pillar) => { const best = entry.best[pillar]; const m = best ? Number(best.score) : 0; const weighted = 0.75 * m; return { pillar, alert_id: best?.id ?? null, m, w: 0.75, weighted, complement: 1 - weighted, }; }); const product = terms.reduce((acc, term) => acc * term.complement, 1); hosts.push({ orig_h: entry.orig_h, score: Math.min(1, Math.max(0, 1 - product)), terms, product, method: 'weighted-noisy-or', weights: { p1: 0.75, p2: 0.75, p3: 0.75 }, order: [1, 2, 3], formula_version: '1.0', alert_count: entry.count, derived: true, }); } return hosts.sort((a, b) => b.score - a.score || a.orig_h.localeCompare(b.orig_h)); } function hostForTerm(host, term) { if (term.alert_id) { const exact = findAlert(term.alert_id); if (exact) return exact; } return ( state.alerts.find( (alert) => alert.pillar === term.pillar && alertOrigH(alert) === host.orig_h, ) || null ); } function hostDetail(host) { const grid = el('div', 'host-detail-grid'); for (const term of host.terms || []) { const card = el('div', `host-pillar p${term.pillar}`); const head = el('div', 'host-pillar-head'); head.append( el('span', `pillar-badge p${term.pillar}`, pillarName(term.pillar)), el('strong', 'host-m', formatNumber(term.m || 0, 3)), ); card.append(head); card.append( el( 'p', 'formula mono', message('m {n} × w {n} = {n}', formatNumber(term.m || 0, 3), formatNumber(term.w, 2), formatNumber(term.weighted || 0, 3)), ), ); const alert = hostForTerm(host, term); if (!alert) { card.append(el('p', 'empty', t('Sem alerta neste pilar.'))); grid.append(card); continue; } card.append(metricChips(alert)); const evidence = el('div', 'evidence-compact'); evidence.append(evidenceTable(alert.evidence)); card.append(evidence); const open = el('button', 'btn small', t('Abrir alerta')); open.type = 'button'; open.dataset.openAlert = alert.id; card.append(open); grid.append(card); } return grid; } function renderHosts() { const query = state.hostQuery.trim().toLowerCase(); const hosts = state.hosts.filter((host) => !query || host.orig_h.toLowerCase().includes(query)); hostCount.textContent = message('{n} de {n} hosts', hosts.length, state.hosts.length); hostRows.replaceChildren(); if (!state.hosts.length) { const row = document.createElement('tr'); const cell = el('td', 'empty', t('Nenhum host com alerta.')); cell.colSpan = 8; row.append(cell); hostRows.append(row); return; } if (!hosts.length) { const row = document.createElement('tr'); const cell = el('td', 'empty', t('Nenhum host corresponde à busca.')); cell.colSpan = 8; row.append(cell); hostRows.append(row); return; } hosts.forEach((host, index) => { const expanded = state.expandedHosts.has(host.orig_h); const row = document.createElement('tr'); row.className = `host-row${expanded ? ' open' : ''}`; row.dataset.host = host.orig_h; row.tabIndex = 0; const rank = el('td', 'rank', formatInt(index + 1)); const hostCell = el('td'); hostCell.append(el('strong', 'mono host-name', host.orig_h)); const fusion = el('td'); const fusionWrap = el('div', 'fusion-cell'); fusionWrap.append(el('span', 'fusion-score', formatNumber(host.score, 3))); const track = el('span', 'score-track'); const fill = el('span', 'score-fill'); fill.style.width = `${Math.min(100, Math.max(0, host.score * 100))}%`; track.append(fill); fusionWrap.append(track); fusion.append(fusionWrap); const termCell = (pillar) => { const term = (host.terms || []).find((item) => item.pillar === pillar); const m = term?.m ?? 0; const cell = el('td', `m-cell${m > 0 ? ` has p${pillar}` : ''}`); cell.textContent = m > 0 ? formatNumber(m, 3) : '—'; return cell; }; const count = el('td', '', formatInt(host.alert_count ?? hostAlerts(host.orig_h).length)); const chevron = el('td', 'chevron-cell', expanded ? '⌄' : '›'); row.append(rank, hostCell, fusion, termCell(1), termCell(2), termCell(3), count, chevron); hostRows.append(row); if (expanded) { const detail = document.createElement('tr'); detail.className = 'host-detail-row'; const cell = document.createElement('td'); cell.colSpan = 8; cell.append(hostDetail(host)); detail.append(cell); hostRows.append(detail); } }); } function toggleHost(host) { if (!host) return; if (state.expandedHosts.has(host)) state.expandedHosts.delete(host); else state.expandedHosts.add(host); renderHosts(); } function chartEmpty(svg, text) { const viewBox = svg.getAttribute('viewBox') || '0 0 720 300'; const [, , width, height] = viewBox.split(' ').map(Number); svg.replaceChildren(); const label = svgEl( 'text', { x: width / 2, y: height / 2, 'text-anchor': 'middle', class: 'chart-empty', }, text, ); svg.append(label); } function positionTip(tip, event, container) { const rect = container.getBoundingClientRect(); const width = tip.offsetWidth || 200; const height = tip.offsetHeight || 80; let x = event.clientX - rect.left + 14; let y = event.clientY - rect.top + 14; if (x + width + 12 > rect.width) x = Math.max(8, event.clientX - rect.left - width - 14); if (y + height + 12 > rect.height) y = Math.max(8, event.clientY - rect.top - height - 14); tip.style.left = `${x}px`; tip.style.top = `${y}px`; } function fillTip(tip, title, lines) { tip.replaceChildren(); const heading = el('strong', '', title); tip.append(heading); for (const line of lines) tip.append(el('span', '', line)); } function renderHostChart() { const hosts = state.hosts.slice(0, 8); if (!hosts.length) { hostChart.setAttribute('viewBox', '0 0 720 220'); chartEmpty(hostChart, t('Nenhum host com alerta para exibir.')); return; } const width = 720; const rowHeight = 42; const top = 18; const labelWidth = 156; const rightPad = 48; const plotWidth = width - labelWidth - rightPad; const height = top + hosts.length * rowHeight + 30; hostChart.setAttribute('viewBox', `0 0 ${width} ${height}`); hostChart.replaceChildren(); const axis = svgEl('g', { class: 'axis' }); for (const tick of [0, 0.25, 0.5, 0.75, 1]) { const x = labelWidth + plotWidth * tick; axis.append( svgEl('line', { x1: x, x2: x, y1: top - 4, y2: height - 28, class: tick === 0 ? 'axis-line strong' : 'axis-line', }), ); axis.append( svgEl( 'text', { x, y: height - 10, 'text-anchor': 'middle', class: 'axis-label' }, formatNumber(tick, 2), ), ); } hostChart.append(axis); hosts.forEach((host, index) => { const y = top + index * rowHeight; const group = svgEl('g', { class: 'bar-row', 'data-host-index': index, tabindex: '0', role: 'listitem', }); group.append( svgEl( 'text', { x: labelWidth - 12, y: y + 16, 'text-anchor': 'end', class: 'bar-label' }, host.orig_h, ), ); group.append( svgEl('rect', { x: labelWidth, y, width: plotWidth, height: 22, rx: 6, class: 'bar-track', }), ); const dominant = (host.terms || []) .filter((term) => term.m > 0) .sort((a, b) => b.weighted - a.weighted)[0]; const color = dominant ? `p${dominant.pillar}` : 'accent'; group.append( svgEl('rect', { x: labelWidth, y, width: Math.max(2, plotWidth * Math.max(0, Math.min(1, host.score))), height: 22, rx: 6, class: `bar-fill ${color}`, }), ); group.append( svgEl( 'text', { x: labelWidth + plotWidth * host.score + (host.score > 0.82 ? -10 : 10), y: y + 16, 'text-anchor': host.score > 0.82 ? 'end' : 'start', class: 'bar-value', }, formatNumber(host.score, 3), ), ); hostChart.append(group); }); } function renderPillarChart() { const counts = [1, 2, 3].map( (pillar) => state.alerts.filter((alert) => alert.pillar === pillar).length, ); const total = counts.reduce((sum, value) => sum + value, 0); if (!total) { pillarChart.setAttribute('viewBox', '0 0 560 240'); chartEmpty(pillarChart, t('Nenhum alerta para exibir.')); return; } const width = 560; const height = 250; const left = 42; const bottom = 42; const top = 20; const plotHeight = height - top - bottom; const plotWidth = width - left - 20; const max = Math.max(1, ...counts); const step = max <= 4 ? 1 : Math.ceil(max / 4); const topValue = Math.ceil(max / step) * step; pillarChart.setAttribute('viewBox', `0 0 ${width} ${height}`); pillarChart.replaceChildren(); const axis = svgEl('g', { class: 'axis' }); for (let value = 0; value <= topValue; value += step) { const y = top + plotHeight - (value / topValue) * plotHeight; axis.append( svgEl('line', { x1: left, x2: width - 20, y1: y, y2: y, class: value === 0 ? 'axis-line strong' : 'axis-line', }), ); axis.append( svgEl( 'text', { x: left - 8, y: y + 4, 'text-anchor': 'end', class: 'axis-label' }, formatInt(value), ), ); } pillarChart.append(axis); const slot = plotWidth / 3; counts.forEach((count, index) => { const pillar = index + 1; const barWidth = Math.min(64, slot * 0.52); const center = left + slot * index + slot / 2; const x = center - barWidth / 2; const barHeight = (count / topValue) * plotHeight; const y = top + plotHeight - barHeight; const group = svgEl('g', { class: 'pillar-bar-row', 'data-pillar': pillar, tabindex: '0', role: 'button', 'aria-label': message('Pilar {n}: {n} alertas', pillar, count), }); group.append( svgEl('rect', { x, y: top, width: barWidth, height: plotHeight, rx: 8, class: 'bar-track', }), ); group.append( svgEl('rect', { x, y, width: barWidth, height: Math.max(2, barHeight), rx: 8, class: `bar-fill p${pillar}`, }), ); group.append( svgEl( 'text', { x: center, y: Math.max(top + 14, y - 8), 'text-anchor': 'middle', class: 'bar-value' }, formatInt(count), ), ); group.append( svgEl( 'text', { x: center, y: height - 14, 'text-anchor': 'middle', class: 'bar-label' }, message('Pilar {n}', pillar), ), ); pillarChart.append(group); }); } function bindChartTips() { hostChart.addEventListener('pointermove', (event) => { const row = event.target.closest('.bar-row'); if (!row) { hostTip.hidden = true; return; } const host = state.hosts[Number(row.dataset.hostIndex)]; if (!host) return; const lines = (host.terms || []).map((term) => message('Pilar {n}: m {n} · peso {n} · contribuição {n}', term.pillar, formatNumber(term.m, 3), formatNumber(term.w, 2), formatNumber(term.weighted, 3)), ); lines.push(message('Score de fusão: {n}', formatNumber(host.score, 4))); fillTip(hostTip, host.orig_h, lines); hostTip.hidden = false; positionTip(hostTip, event, hostChart.parentElement); }); hostChart.addEventListener('pointerleave', () => { hostTip.hidden = true; }); hostChart.addEventListener('focusin', (event) => { const row = event.target.closest('.bar-row'); if (!row) return; const host = state.hosts[Number(row.dataset.hostIndex)]; if (!host) return; const lines = (host.terms || []).map((term) => message('Pilar {n}: m {n} · contribuição {n}', term.pillar, formatNumber(term.m, 3), formatNumber(term.weighted, 3)), ); lines.push(message('Score de fusão: {n}', formatNumber(host.score, 4))); fillTip(hostTip, host.orig_h, lines); hostTip.hidden = false; const rect = hostChart.getBoundingClientRect(); positionTip(hostTip, { clientX: rect.left + rect.width / 2, clientY: rect.top + 40 }, hostChart.parentElement); }); hostChart.addEventListener('focusout', () => { hostTip.hidden = true; }); pillarChart.addEventListener('pointermove', (event) => { const group = event.target.closest('.pillar-bar-row'); if (!group) { pillarTip.hidden = true; return; } const pillar = Number(group.dataset.pillar); const count = state.alerts.filter((alert) => alert.pillar === pillar).length; const total = state.alerts.length || 1; fillTip(pillarTip, message('Pilar {n}', pillar), [ message('Alertas: {n}', formatInt(count)), message('{n}% do total', formatNumber((count / total) * 100, 1)), ]); pillarTip.hidden = false; positionTip(pillarTip, event, pillarChart.parentElement); }); pillarChart.addEventListener('pointerleave', () => { pillarTip.hidden = true; }); pillarChart.addEventListener('click', (event) => { const group = event.target.closest('.pillar-bar-row'); if (!group) return; setPillarFilter(Number(group.dataset.pillar)); showView('alerts'); }); pillarChart.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const group = event.target.closest('.pillar-bar-row'); if (!group) return; event.preventDefault(); setPillarFilter(Number(group.dataset.pillar)); showView('alerts'); }); pillarChart.addEventListener('focusin', (event) => { const group = event.target.closest('.pillar-bar-row'); if (!group) return; const pillar = Number(group.dataset.pillar); const count = state.alerts.filter((alert) => alert.pillar === pillar).length; fillTip(pillarTip, message('Pilar {n}', pillar), [message('Alertas: {n}', formatInt(count))]); pillarTip.hidden = false; const rect = pillarChart.getBoundingClientRect(); positionTip(pillarTip, { clientX: rect.left + rect.width / 2, clientY: rect.top + 60 }, pillarChart.parentElement); }); pillarChart.addEventListener('focusout', () => { pillarTip.hidden = true; }); } function renderCharts() { renderHostChart(); renderPillarChart(); } function summaryTile(label, value, title) { const tile = el('div', 'summary-tile'); tile.append(el('span', 'summary-label', t(label)), el('strong', 'summary-value', value)); if (title) tile.title = title; return tile; } function renderServiceSummary() { const info = state.status; serviceSummary.replaceChildren(); if (!info) { serviceSummary.append(el('p', 'empty', t('Serviço indisponível.'))); return; } const dataset = info.dataset || {}; serviceSummary.append( summaryTile('Versão', info.version || '—'), summaryTile('Modo', info.mode || '—'), summaryTile('Registros', dataset.records === undefined ? '—' : formatInt(dataset.records)), summaryTile('Partições', dataset.partitions === undefined ? '—' : formatInt(dataset.partitions)), summaryTile('TTL (dias)', dataset.ttl_days === undefined ? '—' : formatInt(dataset.ttl_days)), summaryTile('Backend', dataset.backend || '—'), summaryTile('Status', dataset.status || '—'), ); } function renderReportSummary() { const report = state.report; if (!report) { reportSummary.hidden = true; reportSummary.replaceChildren(); reportStatus.textContent = '—'; output.textContent = t('Nenhuma análise executada.'); $('#gotoAlerts').hidden = true; $('#gotoHosts').hidden = true; return; } reportSummary.hidden = false; reportSummary.replaceChildren(); const hosts = report.hosts?.length ?? 0; reportSummary.append( summaryTile('Diretório', report.directory || '—'), summaryTile('Logs conn', formatInt(report.logs_seen ?? 0)), summaryTile('Logs DNS', formatInt(report.dns_logs_seen ?? 0)), summaryTile('Registros', formatInt(report.records ?? 0)), summaryTile('Alertas', formatInt(report.alerts?.length ?? 0)), summaryTile('Hosts', formatInt(hosts)), summaryTile('Status', report.status || '—'), summaryTile('Schema de evidência', report.evidence_schema_version ?? '—'), ); reportStatus.textContent = report.status || '—'; $('#gotoAlerts').hidden = false; $('#gotoHosts').hidden = false; const lines = [ message( 'Análise concluída: {n} registros, {n} alertas e {n} hosts.', formatInt(report.records ?? 0), formatInt(report.alerts?.length ?? 0), formatInt(hosts), ), `${t('Diretório')}: ${report.directory || '—'}`, `${t('Logs conn')}: ${formatInt(report.logs_seen ?? 0)} · ${t('Logs DNS')}: ${formatInt(report.dns_logs_seen ?? 0)}`, ]; if (report.p1_excluded && typeof report.p1_excluded === 'object') { const excluded = Object.entries(report.p1_excluded) .map(([key, value]) => `${key}=${value}`) .join(', '); lines.push(`${t('P1 excluídos')}: ${excluded}`); } output.textContent = lines.join('\n'); } function renderLastUpdated() { if (!state.lastUpdated) { lastUpdated.textContent = '—'; return; } lastUpdated.textContent = message('Atualizado às {n}', timeOnly(state.lastUpdated)); } function updateLastUpdated() { state.lastUpdated = new Date(); renderLastUpdated(); } function setPillarFilter(pillar) { state.filters.pillar = String(pillar); state.filters.minScore = 0; state.filters.query = ''; state.filters.sort = 'score-desc'; alertSearch.value = ''; alertMin.value = '0'; minScoreOut.textContent = formatNumber(0, 2); alertSort.value = 'score-desc'; pillarFilters.querySelectorAll('.chip').forEach((chip) => { chip.classList.toggle('active', chip.dataset.pillar === String(pillar)); }); renderAlerts(); } function openAlert(id) { const alert = findAlert(id); if (!alert) return; state.filters.pillar = 'all'; state.filters.minScore = 0; state.filters.query = ''; state.filters.sort = 'score-desc'; alertSearch.value = ''; alertMin.value = '0'; minScoreOut.textContent = formatNumber(0, 2); alertSort.value = 'score-desc'; pillarFilters.querySelectorAll('.chip').forEach((chip) => { chip.classList.toggle('active', chip.dataset.pillar === 'all'); }); state.expandedAlerts.add(id); showView('alerts'); renderAlerts(); const card = alertList.querySelector(`[data-alert-id="${CSS.escape(id)}"]`); card?.scrollIntoView({ block: 'center', behavior: 'smooth' }); } function renderAll() { minScoreOut.textContent = formatNumber(state.filters.minScore, 2); renderLastUpdated(); renderKpis(); renderOverviewAlerts(); renderCharts(); renderAlerts(); renderHosts(); renderServiceSummary(); renderReportSummary(); applyViewMeta(); } function applyViewMeta() { const meta = VIEW_META[state.view] || VIEW_META.overview; viewTitle.textContent = t(meta.title); viewSub.textContent = t(meta.sub); document.title = `${t(meta.title)} · Acauã`; } async function fetchJson(path, options = {}) { const response = await fetch(path, options); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || response.statusText); return body; } async function call(command, args = {}) { switch (command) { case 'app_info': return fetchJson('/api/status'); case 'analyze_dataset': return fetchJson('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args), }); case 'list_alerts': return fetchJson('/api/alerts'); case 'update_check': return fetchJson('/api/update'); case 'doctor_report': return fetchJson('/api/health'); default: throw new Error(`unsupported command: ${command}`); } } async function refreshStatus() { try { state.status = await call('app_info'); version.textContent = `v${state.status.version}`; connectionText.textContent = t('Serviço local ativo'); connection.classList.remove('offline'); renderServiceSummary(); renderKpis(); } catch (error) { connectionText.textContent = t('Serviço indisponível'); connection.classList.add('offline'); version.textContent = '—'; state.status = null; renderServiceSummary(); console.error(error); } } async function refreshAlerts() { try { const alerts = await call('list_alerts'); state.alerts = Array.isArray(alerts) ? alerts : []; if (!state.report) { state.hosts = deriveHosts(state.alerts); } else { const ids = state.report.alerts?.map((alert) => alert.id).join('|'); const nextIds = state.alerts.map((alert) => alert.id).join('|'); if (ids !== nextIds) { state.report = null; state.hosts = deriveHosts(state.alerts); } } state.loaded = true; updateLastUpdated(); renderAll(); } catch (error) { state.alerts = []; state.hosts = []; state.loaded = true; renderAll(); console.error(error); } } async function refreshAll() { await Promise.all([refreshStatus(), refreshAlerts()]); updateLastUpdated(); } async function analyzeDataset() { const path = datasetPath.value.trim(); if (!path) { output.textContent = t('Nenhum dataset selecionado.'); return; } localStorage.setItem('acaua-last-path', path); analyzeBtn.disabled = true; output.textContent = t('Analisando…'); try { const report = await call('analyze_dataset', { path }); state.report = report; state.alerts = Array.isArray(report.alerts) ? report.alerts : []; state.hosts = Array.isArray(report.hosts) ? report.hosts : deriveHosts(state.alerts); state.expandedHosts.clear(); state.expandedAlerts.clear(); updateLastUpdated(); renderAll(); await refreshStatus(); } catch (error) { reportSummary.hidden = true; reportStatus.textContent = '—'; output.textContent = message('Falha na análise: {n}', error.message); $('#gotoAlerts').hidden = true; $('#gotoHosts').hidden = true; } finally { analyzeBtn.disabled = false; } } async function checkUpdate() { updateBtn.disabled = true; updateBtn.textContent = t('Verificando atualização…'); try { const status = await call('update_check'); updateBtn.textContent = status.update_available ? message('Atualização {n} disponível.', status.latest_version) : message('Versão {n} é a mais recente.', status.current_version); } catch (error) { updateBtn.title = message('Não foi possível verificar a atualização: {n}', error.message); updateBtn.textContent = t('checar atualização'); } finally { updateBtn.disabled = false; window.setTimeout(() => { updateBtn.textContent = t('checar atualização'); updateBtn.title = ''; }, 6000); } } function applyTheme(preference) { themePreference = preference; localStorage.setItem('acaua-theme', preference); const light = window.matchMedia('(prefers-color-scheme: light)').matches; const effective = preference === 'auto' ? (light ? 'light' : 'dark') : preference; document.documentElement.dataset.theme = effective; themePick.querySelectorAll('[data-theme-pick]').forEach((button) => { button.classList.toggle('active', button.dataset.themePick === preference); }); } function openSettings() { langSel.value = localStorage.getItem('acaua-lang') || 'auto'; settingsDlg.showModal(); } function closeDialog() { settingsDlg.close(); setActiveNav(state.view); } function setActiveNav(target) { document.querySelectorAll('.nav-item').forEach((button) => { const active = button.dataset.target === target; button.classList.toggle('active', active); if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current'); }); } function showView(target, { updateHash = true } = {}) { if (target === 'settings') { openSettings(); return; } const view = document.getElementById(target); if (!view || !view.classList.contains('view')) return; state.view = target; setActiveNav(target); document.querySelectorAll('.view').forEach((node) => { const active = node.id === target; node.classList.toggle('active', active); node.hidden = !active; }); applyViewMeta(); if (updateHash) { history.replaceState(null, '', `#/${target}`); } if (target === 'overview') renderCharts(); if (target === 'alerts') renderAlerts(); if (target === 'hosts') renderHosts(); window.scrollTo({ top: 0, behavior: 'instant' }); } function initViewFromHash() { const target = location.hash.replace(/^#\/?/, ''); return VIEW_META[target] ? target : 'overview'; } function init() { applyTheme(themePreference); langSel.value = localStorage.getItem('acaua-lang') || 'auto'; setLang(langSel.value); datasetPath.value = localStorage.getItem('acaua-last-path') || ''; state.view = initViewFromHash(); showView(state.view, { updateHash: false }); requestAnimationFrame(() => window.scrollTo(0, 0)); bindChartTips(); refreshAll(); analyzeBtn.addEventListener('click', analyzeDataset); datasetPath.addEventListener('keydown', (event) => { if (event.key === 'Enter') analyzeDataset(); }); refreshBtn.addEventListener('click', refreshAll); updateBtn.addEventListener('click', checkUpdate); gotoSettings.addEventListener('click', openSettings); settingsDlg.addEventListener('close', () => setActiveNav(state.view)); closeSettings.addEventListener('click', closeDialog); doneSettings.addEventListener('click', closeDialog); window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (themePreference === 'auto') applyTheme('auto'); }); document.querySelectorAll('.nav-item').forEach((button) => { button.addEventListener('click', () => showView(button.dataset.target)); }); window.addEventListener('hashchange', () => { const target = initViewFromHash(); if (target !== state.view) showView(target, { updateHash: false }); }); pillarFilters.addEventListener('click', (event) => { const chip = event.target.closest('[data-pillar]'); if (!chip) return; state.filters.pillar = chip.dataset.pillar; pillarFilters.querySelectorAll('.chip').forEach((node) => { node.classList.toggle('active', node === chip); }); renderAlerts(); }); alertSort.addEventListener('change', () => { state.filters.sort = alertSort.value; renderAlerts(); }); alertMin.addEventListener('input', () => { state.filters.minScore = Number(alertMin.value); minScoreOut.textContent = formatNumber(state.filters.minScore, 2); renderAlerts(); }); alertSearch.addEventListener('input', () => { state.filters.query = alertSearch.value; renderAlerts(); }); hostSearch.addEventListener('input', () => { state.hostQuery = hostSearch.value; renderHosts(); }); alertList.addEventListener('click', async (event) => { const action = event.target.closest('[data-action]'); if (action) { event.preventDefault(); if (action.dataset.action === 'goto-host') { state.hostQuery = action.dataset.host; hostSearch.value = state.hostQuery; state.expandedHosts.add(state.hostQuery); showView('hosts'); renderHosts(); return; } if (action.dataset.action === 'copy-json') { const card = action.closest('.alert-card'); const alert = findAlert(card?.dataset.alertId); try { await navigator.clipboard.writeText(JSON.stringify(alert?.evidence ?? {}, null, 2)); const previous = action.textContent; action.textContent = t('Copiado'); window.setTimeout(() => { action.textContent = previous; }, 1400); } catch (error) { console.error(error); } return; } } const head = event.target.closest('.alert-head'); if (!head) return; const card = head.closest('.alert-card'); if (card) toggleAlert(card.dataset.alertId); }); overviewAlerts.addEventListener('click', (event) => { const button = event.target.closest('[data-alert-id]'); if (button) openAlert(button.dataset.alertId); }); hostRows.addEventListener('click', (event) => { const open = event.target.closest('[data-open-alert]'); if (open) { openAlert(open.dataset.openAlert); return; } const row = event.target.closest('tr.host-row'); if (row && !event.target.closest('button')) toggleHost(row.dataset.host); }); hostRows.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const row = event.target.closest('tr.host-row'); if (!row) return; event.preventDefault(); toggleHost(row.dataset.host); }); document.querySelectorAll('[data-pillar-card]').forEach((card) => { const activate = () => { const pillar = Number(card.dataset.pillarCard); if (pillar === 4) { showView('hosts'); return; } setPillarFilter(pillar); showView('alerts'); }; card.addEventListener('click', activate); card.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(); } }); }); $('#seeAllAlerts').addEventListener('click', () => showView('alerts')); $('#gotoAlerts').addEventListener('click', () => showView('alerts')); $('#gotoHosts').addEventListener('click', () => showView('hosts')); langSel.addEventListener('change', () => { localStorage.setItem('acaua-lang', langSel.value); setLang(langSel.value); updateBtn.textContent = t('checar atualização'); renderAll(); }); themePick.addEventListener('click', (event) => { const button = event.target.closest('[data-theme-pick]'); if (button) applyTheme(button.dataset.themePick); }); if (!document.querySelector('.view.active')) { showView('overview', { updateHash: false }); } window.acaua = { state, showView, openAlert, toggleHost, refreshAll, renderAll, deriveHosts, }; } init();