diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index b6295c1..505c54b 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -11,6 +11,12 @@ --accent: #45d6b0; --accent-2: #f0b35b; --danger: #f27878; + --p1: #45d6b0; + --p2: #f0b35b; + --p3: #7aa7ff; + --p1-soft: rgba(69, 214, 176, 0.14); + --p2-soft: rgba(240, 179, 91, 0.14); + --p3-soft: rgba(122, 167, 255, 0.14); --shadow: 0 24px 60px rgba(0, 0, 0, 0.32); --radius: 16px; --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; @@ -30,6 +36,12 @@ html[data-theme="light"] { --accent: #087f68; --accent-2: #a05b09; --danger: #b42323; + --p1: #087f68; + --p2: #a05b09; + --p3: #2456a8; + --p1-soft: rgba(8, 127, 104, 0.12); + --p2-soft: rgba(160, 91, 9, 0.12); + --p3-soft: rgba(36, 86, 168, 0.12); --shadow: 0 20px 50px rgba(20, 58, 64, 0.12); } @@ -184,6 +196,10 @@ html[data-theme="light"] .card { background: linear-gradient(160deg, var(--panel .card p { color: var(--muted); font-size: 0.83rem; line-height: 1.5; } .tag { margin-top: auto; align-self: flex-start; color: var(--accent-2); font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; } +.view:not(.active) { + display: none !important; +} + .panel { margin-top: 16px; padding: 22px; @@ -302,3 +318,382 @@ dialog menu { display: flex; justify-content: flex-end; gap: 8px; margin: 20px 0 .panel-head { flex-direction: column; } .foot { flex-direction: column; } } + +/* ------------------------------------------------------------------ dashboard */ + +[hidden] { display: none !important; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.top-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } +.last-updated { white-space: nowrap; } + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} +.kpi { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 128px; + padding: 18px 20px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(160deg, var(--panel), rgba(15, 30, 43, 0.32)); + box-shadow: var(--shadow); +} +html[data-theme="light"] .kpi { background: linear-gradient(160deg, var(--panel), rgba(255, 255, 255, 0.5)); } +.kpi-label { color: var(--muted); font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; } +.kpi-value { margin-top: auto; font-family: var(--mono); font-size: clamp(1.6rem, 2.6vw, 2.2rem); line-height: 1; letter-spacing: -0.04em; } +.kpi-sub { color: var(--muted); font-size: 0.76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.charts-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); + gap: 16px; + margin-top: 16px; +} +.chart-panel { margin-top: 0; } +.panel-tag { + align-self: flex-start; + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: var(--mono); + font-size: 0.68rem; + white-space: nowrap; +} +.chart-wrap { position: relative; width: 100%; min-height: 220px; } +.chart { display: block; width: 100%; height: auto; overflow: visible; } +.axis-line { stroke: var(--line); stroke-width: 1; } +.axis-line.strong { stroke: var(--line-strong); } +.axis-label { fill: var(--muted); font-family: var(--mono); font-size: 11px; } +.bar-track { fill: rgba(255, 255, 255, 0.04); } +html[data-theme="light"] .bar-track { fill: rgba(20, 58, 64, 0.06); } +.bar-fill { transition: filter 140ms ease, opacity 140ms ease; } +.bar-fill.p1 { fill: var(--p1); } +.bar-fill.p2 { fill: var(--p2); } +.bar-fill.p3 { fill: var(--p3); } +.bar-fill.accent { fill: var(--accent); } +.bar-label { fill: var(--text); font-family: var(--mono); font-size: 12px; } +.bar-value { fill: var(--text); font-family: var(--mono); font-size: 12px; font-weight: 700; } +.bar-row, .pillar-bar-row { outline: none; } +.bar-row:hover .bar-fill, .bar-row:focus .bar-fill { filter: brightness(1.16); } +.pillar-bar-row { cursor: pointer; } +.pillar-bar-row:hover .bar-fill, .pillar-bar-row:focus .bar-fill { filter: brightness(1.2); } +.pillar-bar-row:hover .bar-track, .pillar-bar-row:focus .bar-track { fill: rgba(255, 255, 255, 0.08); } +.chart-empty { fill: var(--muted); font-family: var(--sans); font-size: 13px; } +.chart-tip { + position: absolute; + z-index: 8; + display: grid; + gap: 4px; + max-width: 320px; + padding: 10px 12px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--panel-solid); + box-shadow: var(--shadow); + color: var(--text); + font-size: 0.74rem; + line-height: 1.35; + pointer-events: none; +} +.chart-tip strong { font-family: var(--mono); font-size: 0.78rem; } +.chart-tip span { color: var(--muted); } + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 14px; + padding-bottom: 16px; + border-bottom: 1px solid var(--line); + margin-bottom: 14px; +} +.toolbar-slim { align-items: center; justify-content: space-between; } +.field.compact { flex: 0 0 auto; min-width: 130px; } +.field.compact > span { font-size: 0.68rem; } +.field.compact input, .field.compact select { padding: 8px 10px; } +.range-field { min-width: 178px; } +.range-field > span { display: flex; justify-content: space-between; gap: 12px; } +.range-field output { color: var(--text); font-family: var(--mono); } +.range-field input[type="range"] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); } +.search-field { flex: 1 1 230px; } +.chips { display: flex; gap: 6px; flex-wrap: wrap; padding-bottom: 1px; } +.chip { + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 0.76rem; + font-weight: 600; +} +.chip:hover { border-color: var(--line-strong); color: var(--text); } +.chip.active { border-color: var(--accent); background: rgba(69, 214, 176, 0.12); color: var(--text); } +.legend { display: flex; gap: 14px; align-items: center; color: var(--muted); font-size: 0.74rem; } +.legend-item { display: inline-flex; align-items: center; gap: 6px; } +.swatch { display: inline-block; width: 9px; height: 9px; border-radius: 2px; } +.swatch.p1 { background: var(--p1); } +.swatch.p2 { background: var(--p2); } +.swatch.p3 { background: var(--p3); } + +.pillars { margin-top: 16px; } +.card[data-pillar-card] { cursor: pointer; outline: none; transition: 160ms ease; } +.card[data-pillar-card]:hover, .card[data-pillar-card]:focus-visible { border-color: var(--line-strong); transform: translateY(-2px); } +.card h2 { display: flex; align-items: center; gap: 8px; } +.card-count { + margin-left: auto; + padding: 1px 7px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: var(--mono); + font-size: 0.66rem; + font-weight: 600; +} + +.overview-alerts { display: grid; gap: 8px; } +.ov-alert { + display: grid; + grid-template-columns: auto minmax(0, 1fr) 112px; + gap: 14px; + align-items: center; + width: 100%; + padding: 12px 14px; + border: 1px solid var(--line); + border-left: 3px solid var(--line-strong); + border-radius: 11px; + background: rgba(255, 255, 255, 0.02); + color: inherit; + text-align: left; +} +.ov-alert:hover { border-color: var(--line-strong); background: rgba(69, 214, 176, 0.05); } +.ov-alert:has(.pillar-badge.p1) { border-left-color: var(--p1); } +.ov-alert:has(.pillar-badge.p2) { border-left-color: var(--p2); } +.ov-alert:has(.pillar-badge.p3) { border-left-color: var(--p3); } +.ov-main { display: grid; gap: 4px; min-width: 0; } +.ov-summary { font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ov-meta { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 0.72rem; min-width: 0; } +.ov-dest { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ov-score { display: grid; gap: 5px; text-align: right; } +.ov-score strong { font-family: var(--mono); font-size: 0.88rem; } + +.pillar-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: 6px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; +} +.pillar-badge.p1 { color: var(--p1); background: var(--p1-soft); border-color: color-mix(in srgb, var(--p1) 40%, transparent); } +.pillar-badge.p2 { color: var(--p2); background: var(--p2-soft); border-color: color-mix(in srgb, var(--p2) 40%, transparent); } +.pillar-badge.p3 { color: var(--p3); background: var(--p3-soft); border-color: color-mix(in srgb, var(--p3) 40%, transparent); } +.host-chip { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); + font-family: var(--mono); + font-size: 0.72rem; + white-space: nowrap; +} + +.score-track { display: block; width: 100%; height: 5px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden; } +html[data-theme="light"] .score-track { background: rgba(20, 58, 64, 0.1); } +.score-fill { display: block; height: 100%; border-radius: inherit; background: var(--accent); } +.alert-score.low .score-fill, .ov-score.low .score-fill { background: var(--accent); } +.alert-score.medium .score-fill, .ov-score.medium .score-fill { background: var(--accent-2); } +.alert-score.high .score-fill, .ov-score.high .score-fill { background: var(--danger); } + +.alert-cards { display: grid; gap: 10px; } +.alert-card { + border: 1px solid var(--line); + border-left: 3px solid var(--line-strong); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} +.alert-card.p1 { border-left-color: var(--p1); } +.alert-card.p2 { border-left-color: var(--p2); } +.alert-card.p3 { border-left-color: var(--p3); } +.alert-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) 104px 16px; + gap: 14px; + align-items: center; + width: 100%; + padding: 14px 16px; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} +.alert-head:hover { background: rgba(69, 214, 176, 0.045); } +.alert-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.alert-main { display: grid; gap: 5px; min-width: 0; } +.alert-summary { font-size: 0.86rem; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.alert-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; color: var(--muted); font-size: 0.72rem; min-width: 0; } +.alert-arrow { color: var(--accent); } +.alert-dest { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.alert-when { margin-left: auto; white-space: nowrap; } +.alert-score { display: grid; gap: 5px; justify-items: end; } +.alert-score strong { font-family: var(--mono); font-size: 0.92rem; } +.chevron { color: var(--muted); font-size: 1.1rem; text-align: center; } +.alert-body { display: grid; gap: 14px; padding: 16px; border-top: 1px solid var(--line); background: rgba(0, 0, 0, 0.1); } +html[data-theme="light"] .alert-body { background: rgba(20, 58, 64, 0.025); } +.metrics { display: flex; flex-wrap: wrap; gap: 8px; } +.metric { + display: grid; + gap: 2px; + min-width: 92px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); +} +.metric-label { color: var(--muted); font-size: 0.64rem; letter-spacing: 0.08em; text-transform: uppercase; } +.metric-value { font-family: var(--mono); font-size: 0.78rem; font-weight: 600; overflow-wrap: anywhere; } +.evidence-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; } +.evidence-head h3 { margin: 0; font-size: 0.82rem; letter-spacing: 0.02em; } +.dim { color: var(--muted); } +.kv { width: 100%; border-collapse: collapse; font-size: 0.78rem; } +.kv th { + width: 44%; + padding: 6px 10px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.72rem; + font-weight: 500; + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} +.kv td { padding: 6px 10px; border-top: 1px solid var(--line); overflow-wrap: anywhere; } +.kv tr:first-child th, .kv tr:first-child td { border-top: 0; } +.alert-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.alert-actions .mono { margin-right: auto; } +.btn.small { padding: 7px 11px; font-size: 0.74rem; } + +.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 12px; } +.host-table { width: 100%; border-collapse: collapse; min-width: 720px; } +.host-table thead th { + position: sticky; + top: 0; + z-index: 1; + padding: 10px 12px; + border-bottom: 1px solid var(--line); + background: var(--panel-solid); + color: var(--muted); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.1em; + text-align: left; + text-transform: uppercase; +} +.host-table td { padding: 11px 12px; border-top: 1px solid var(--line); font-size: 0.82rem; vertical-align: middle; } +.host-table tbody tr:first-child td { border-top: 0; } +.host-row { cursor: pointer; outline: none; transition: background 120ms ease; } +.host-row:hover, .host-row:focus-visible { background: rgba(69, 214, 176, 0.06); } +.host-row.open { background: rgba(69, 214, 176, 0.05); } +.host-row .rank { color: var(--muted); font-family: var(--mono); font-size: 0.72rem; width: 42px; } +.host-name { font-size: 0.84rem; color: var(--text); } +.fusion-cell { display: grid; gap: 5px; min-width: 106px; } +.fusion-score { font-family: var(--mono); font-size: 0.8rem; } +.m-cell { font-family: var(--mono); font-size: 0.8rem; color: var(--muted); white-space: nowrap; } +.m-cell.has.p1 { color: var(--p1); } +.m-cell.has.p2 { color: var(--p2); } +.m-cell.has.p3 { color: var(--p3); } +.chevron-cell { color: var(--muted); text-align: right; width: 36px; } +.host-detail-row td { padding: 0; background: rgba(0, 0, 0, 0.1); } +html[data-theme="light"] .host-detail-row td { background: rgba(20, 58, 64, 0.03); } +.host-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); align-items: start; gap: 12px; padding: 14px; } +.host-pillar { + display: flex; + flex-direction: column; + gap: 9px; + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-top: 2px solid var(--line-strong); + border-radius: 10px; + background: var(--panel); +} +.host-pillar.p1 { border-top-color: var(--p1); } +.host-pillar.p2 { border-top-color: var(--p2); } +.host-pillar.p3 { border-top-color: var(--p3); } +.host-pillar-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.host-m { font-family: var(--mono); font-size: 0.92rem; } +.formula { color: var(--muted); font-size: 0.7rem; } +.evidence-compact { max-height: 300px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; } +.evidence-compact .kv th, .evidence-compact .kv td { padding: 5px 8px; font-size: 0.72rem; } +.host-pillar > .btn { align-self: flex-start; } +.footnote { margin-top: 12px; color: var(--muted); font-size: 0.74rem; line-height: 1.5; } + +.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 18px; } +.summary-grid-wide { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); margin-top: 0; } +.summary-tile { + display: grid; + gap: 4px; + min-width: 0; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(255, 255, 255, 0.02); +} +.summary-label { color: var(--muted); font-size: 0.66rem; letter-spacing: 0.08em; text-transform: uppercase; } +.summary-value { font-family: var(--mono); font-size: 0.8rem; font-weight: 600; overflow-wrap: anywhere; } +.report-detail { display: grid; gap: 10px; } +.report-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.empty { color: var(--muted); font-size: 0.84rem; line-height: 1.5; margin: 0; padding: 14px; border: 1px dashed var(--line); border-radius: 10px; } + +@media (max-width: 1280px) { + .charts-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 1120px) { + .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 820px) { + .toolbar-slim { align-items: flex-end; } + .alert-head { grid-template-columns: auto minmax(0, 1fr) 92px 16px; gap: 10px; } + .alert-when { display: none; } + .last-updated { display: none; } +} + +@media (max-width: 640px) { + .kpi-grid { grid-template-columns: 1fr; } + .summary-grid { grid-template-columns: 1fr; } + .alert-head { grid-template-columns: auto minmax(0, 1fr) 16px; } + .alert-score { display: none; } + .ov-alert { grid-template-columns: auto minmax(0, 1fr); } + .ov-score { display: none; } + .alert-summary { white-space: normal; } + .toolbar { gap: 10px; } + .field.compact, .range-field, .search-field { flex: 1 1 100%; min-width: 0; } + .legend { display: none; } + .top-actions { justify-content: flex-start; } +} diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 3b2ff2b..61885d2 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -1,26 +1,1240 @@ -import { setLang, tr } from './i18n.js'; +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 refreshBtn = $('#refreshBtn'); 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 settingsBtn = $('#settingsBtn'); const closeSettings = $('#closeSettings'); const doneSettings = $('#doneSettings'); const langSel = $('#langSel'); const themePick = $('#themePick'); -let lastAlerts = []; +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(() => ({})); @@ -49,80 +1263,80 @@ async function call(command, args = {}) { } } -function message(template, ...values) { - let index = 0; - return tr(template).replace(/\{n\}/g, () => values[index++] ?? ''); -} - -function renderAlerts(alerts) { - lastAlerts = Array.isArray(alerts) ? alerts : []; - alertList.replaceChildren(); - if (!lastAlerts.length) { - const empty = document.createElement('li'); - empty.className = 'empty'; - empty.textContent = tr('Nenhum alerta ainda.'); - alertList.append(empty); - return; - } - for (const alert of lastAlerts) { - const item = document.createElement('li'); - const meta = document.createElement('div'); - meta.className = 'alert-meta'; - const pillar = document.createElement('strong'); - pillar.textContent = message('Pilar {n}', alert.pillar); - const score = document.createElement('span'); - score.className = 'mono'; - score.textContent = Number(alert.score).toFixed(3); - meta.append(pillar, score); - const summary = document.createElement('div'); - summary.className = 'alert-summary'; - summary.setAttribute('data-i18n-skip', ''); - summary.textContent = alert.summary; - item.append(meta, summary); - alertList.append(item); - } -} - async function refreshStatus() { try { - const info = await call('app_info'); - version.textContent = `v${info.version}`; - connectionText.textContent = tr('Serviço local ativo'); + 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 = tr('Serviço indisponível'); + connectionText.textContent = t('Serviço indisponível'); connection.classList.add('offline'); version.textContent = '—'; + state.status = null; + renderServiceSummary(); console.error(error); } } async function refreshAlerts() { try { - renderAlerts(await call('list_alerts')); + 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 = tr('Nenhum dataset selecionado.'); + output.textContent = t('Nenhum dataset selecionado.'); return; } + localStorage.setItem('acaua-last-path', path); analyzeBtn.disabled = true; - output.textContent = tr('Analisando…'); + output.textContent = t('Analisando…'); try { const report = await call('analyze_dataset', { path }); - output.textContent = message( - 'Análise concluída: {n} registros e {n} alertas.', - report.records, - report.alerts.length, - ); - await refreshAlerts(); + 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; } @@ -130,7 +1344,7 @@ async function analyzeDataset() { async function checkUpdate() { updateBtn.disabled = true; - updateBtn.textContent = tr('Verificando atualização…'); + updateBtn.textContent = t('Verificando atualização…'); try { const status = await call('update_check'); updateBtn.textContent = status.update_available @@ -138,11 +1352,11 @@ async function checkUpdate() { : 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 = tr('checar atualização'); + updateBtn.textContent = t('checar atualização'); } finally { updateBtn.disabled = false; window.setTimeout(() => { - updateBtn.textContent = tr('checar atualização'); + updateBtn.textContent = t('checar atualização'); updateBtn.title = ''; }, 6000); } @@ -166,55 +1380,212 @@ function openSettings() { function closeDialog() { settingsDlg.close(); + setActiveNav(state.view); } function setActiveNav(target) { document.querySelectorAll('.nav-item').forEach((button) => { - button.classList.toggle('active', button.dataset.target === target); + 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); - refreshStatus(); - refreshAlerts(); + 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', async () => { - await refreshStatus(); - await refreshAlerts(); - }); + refreshBtn.addEventListener('click', refreshAll); updateBtn.addEventListener('click', checkUpdate); - settingsBtn.addEventListener('click', openSettings); + 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); - refreshStatus(); - renderAlerts(lastAlerts); - updateBtn.textContent = tr('checar atualização'); + 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); }); - window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { - if (themePreference === 'auto') applyTheme('auto'); - }); - document.querySelectorAll('.nav-item').forEach((button) => { - button.addEventListener('click', () => { - const target = button.dataset.target; - setActiveNav(target); - if (target === 'settings') openSettings(); - else document.getElementById(target)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); - }); + + if (!document.querySelector('.view.active')) { + showView('overview', { updateHash: false }); + } + + window.acaua = { + state, + showView, + openAlert, + toggleHost, + refreshAll, + renderAll, + deriveHosts, + }; } init(); diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index 79fc6fe..c92f9db 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -63,4 +63,105 @@ export const EN = { 'Nenhum alerta ainda.': 'No alerts yet.', 'Alertas carregados: {n}': 'Alerts loaded: {n}', 'versão {n}': 'version {n}', + 'estado e prioridades': 'status and priorities', + 'filtros e evidências': 'filters and evidence', + 'fusão por evidência': 'fusion by evidence', + 'Hosts': 'Hosts', + 'Estado, prioridades e distribuição dos alertas': 'Status, priorities and alert distribution', + 'Filtre, ordene e abra a evidência completa': 'Filter, sort and open the full evidence', + 'Prioridade por fusão de evidências entre pilares': 'Priority by cross-pillar evidence fusion', + 'Ingestão de logs Zeek e relatório da execução': 'Zeek log ingestion and run report', + 'carregando…': 'loading…', + 'Hosts com alerta': 'Hosts with alerts', + 'ranqueados por fusão': 'ranked by fusion', + 'melhor host observado': 'top observed host', + 'Registros no dataset': 'Dataset records', + 'armazenamento local': 'local storage', + 'Top hosts por fusão': 'Top hosts by fusion', + 'Score ponderado de evidências (0 a 1). Passe o mouse para ver os pilares.': 'Weighted evidence score (0 to 1). Hover to see the pillars.', + 'Alertas por pilar': 'Alerts per pillar', + 'Distribuição observada. Clique em uma barra para filtrar os alertas.': 'Observed distribution. Click a bar to filter alerts.', + 'Alertas prioritários': 'Priority alerts', + 'Os alertas com maior score individual. Abra a lista completa para filtrar.': 'The alerts with the highest individual score. Open the full list to filter.', + 'Ver todos os alertas': 'View all alerts', + 'sem hosts': 'no hosts', + 'Filtre, ordene e expanda cada alerta para ver a evidência completa como tabela.': 'Filter, sort and expand each alert to see the full evidence as a table.', + 'Ordenar': 'Sort', + 'Maior score': 'Highest score', + 'Menor score': 'Lowest score', + 'Mais recentes': 'Newest', + 'Pilar': 'Pillar', + 'Todos': 'All', + 'Score mínimo': 'Minimum score', + 'Buscar': 'Search', + 'IP, host, domínio ou ID': 'IP, host, domain or ID', + 'Ranking por score de fusão (desc). Clique em uma linha para ver a evidência de cada pilar.': 'Ranked by fusion score (desc). Click a row to see each pillar’s evidence.', + 'Buscar host': 'Search host', + 'Fusão': 'Fusion', + 'Nenhum host carregado.': 'No hosts loaded.', + 'Detalhes': 'Details', + 'A API de fusão lista apenas hosts com pelo menos um alerta. Hosts sem alerta não aparecem nesta tabela.': 'The fusion API only lists hosts with at least one alert. Hosts without alerts do not appear in this table.', + 'Ver alertas': 'View alerts', + 'Ver hosts': 'View hosts', + 'Estado do serviço': 'Service status', + 'Versão, dataset e armazenamento informados pela API local.': 'Version, dataset and storage reported by the local API.', + '{n} pilares ativos': '{n} active pillars', + '{n} com múltiplos pilares': '{n} with multiple pillars', + '{n} partições · {n}': '{n} partitions · {n}', + '{n} de {n} alertas': '{n} of {n} alerts', + '{n} hosts': '{n} hosts', + 'Sem evidência registrada.': 'No evidence recorded.', + 'Período': 'Period', + 'Regularidade': 'Regularity', + 'p-valor': 'p-value', + 'Significância': 'Significance', + 'Observações': 'Observations', + 'Sessão': 'Session', + 'Partes': 'Parts', + 'Falhas': 'Failures', + 'Início': 'Start', + 'Informação': 'Information', + 'Entropia': 'Entropy', + 'Únicos': 'Unique', + 'Domínio': 'Domain', + 'Contribuição': 'Contribution', + 'Evidência completa': 'Full evidence', + 'Nenhum alerta corresponde aos filtros.': 'No alerts match the filters.', + 'Copiar JSON': 'Copy JSON', + 'Copiado': 'Copied', + 'Ver host': 'View host', + 'Sem alerta neste pilar.': 'No alert in this pillar.', + 'Abrir alerta': 'Open alert', + 'Nenhum host com alerta.': 'No hosts with alerts.', + 'Nenhum host corresponde à busca.': 'No hosts match the search.', + 'Nenhum host com alerta para exibir.': 'No hosts with alerts to display.', + 'Nenhum alerta para exibir.': 'No alerts to display.', + 'Pilar {n}: m {n} · peso {n} · contribuição {n}': 'Pillar {n}: m {n} · weight {n} · contribution {n}', + 'Pilar {n}: m {n} · contribuição {n}': 'Pillar {n}: m {n} · contribution {n}', + 'Score de fusão: {n}': 'Fusion score: {n}', + 'Alertas: {n}': 'Alerts: {n}', + '{n}% do total': '{n}% of total', + 'Pilar {n}: {n} alertas': 'Pillar {n}: {n} alerts', + 'Serviço indisponível.': 'Service unavailable.', + 'Versão': 'Version', + 'Modo': 'Mode', + 'Registros': 'Records', + 'Partições': 'Partitions', + 'TTL (dias)': 'TTL (days)', + 'Backend': 'Backend', + 'Status': 'Status', + 'Diretório': 'Directory', + 'Logs conn': 'conn logs', + 'Logs DNS': 'DNS logs', + 'Schema de evidência': 'Evidence schema', + 'P1 excluídos': 'P1 excluded', + 'Análise concluída: {n} registros, {n} alertas e {n} hosts.': 'Analysis complete: {n} records, {n} alerts and {n} hosts.', + 'Atualizado às {n}': 'Updated at {n}', + 'sim': 'yes', + 'não': 'no', + 'PCR': 'PCR', + 'JSD': 'JSD', + 'Hosts por score de fusão': 'Hosts by fusion score', + 'Distribuição de alertas por pilar': 'Alert distribution per pillar', + 'Filtrar por pilar': 'Filter by pillar', }; diff --git a/apps/gui/ui/index.html b/apps/gui/ui/index.html index 7084832..a041a02 100644 --- a/apps/gui/ui/index.html +++ b/apps/gui/ui/index.html @@ -20,13 +20,17 @@