Release / Plan (reuse the published image?) (push) Successful in 4s
CI / Docker image (build and smoke test) (push) Skipped
CI / Format and clippy (push) Successful in 16s
CI / Dependency audit (push) Successful in 17s
CI / Tests (Linux) (push) Successful in 34s
Release / ci (push) Successful in 35s
Release / Build image (arm64) (push) Successful in 4m55s
Release / Build image (amd64) (push) Failing after 11m10s
Release / Publish tags (push) Skipped
Release / Gitea release (push) Skipped
The sidebar nav (Overview/Alerts/Analysis) only did scrollIntoView on a
single stacked page, so clicking it produced no visible change. The
Alerts/Analysis panels were also placeholder shells with no real data
exploration despite the API already exposing rich per-pillar evidence.
- app.js/app.css/index.html: real panel switching via showView()/currentView,
toggling an .active class with `.view:not(.active) { display: none }`;
settings keeps opening its own dialog without disturbing the active view.
- New Hosts panel: table of observed hosts ranked by fusion score, with
per-pillar (P1/P2/P3) columns, search, and drill-down into each pillar's
evidence. Documents the known limitation that /api/alerts only lists hosts
with at least one alert (no /api/hosts endpoint exists yet), so a benign
host with no alert won't appear.
- Alerts panel: filterable by pillar and minimum score, sortable, with
full evidence rendered as a formatted key/value table instead of raw JSON.
- Overview panel: KPI tiles (alert count, hosts with alerts, max score,
dataset records) plus two interactive SVG charts (top hosts by fusion
score, alert distribution by pillar) with hover tooltips.
- i18n-en.js: English strings for all new UI text.
No Rust files changed; this is purely apps/gui/ui/ consuming the existing
/api/alerts, /api/analyze and /api/status endpoints.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
1592 lines
49 KiB
JavaScript
1592 lines
49 KiB
JavaScript
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();
|