Files
acaua/apps/gui/ui/app.js
T
Lucas RayanandClaude Sonnet 5 be47b607df
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
feat: build a real dashboard and fix dead panel navigation in the web UI
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
2026-11-01 08:28:08 -03:00

1592 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();