From 8b991b2cc591d24687738ee3d18909903e08f525 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 13 Jun 2026 14:08:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20AUD1T=20=E2=80=94=20privileg?= =?UTF-8?q?ed-actions=20audit=20trail=20as=20a=20LOGS=20sub-tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an AUDIT sub-tab to /logs.html (alongside BUILD / AGENT / SYSTEM), consuming GET /api/audit-log ({ entries, total }). A read-only filterable table: when / agent / action / target / outcome / detail, newest-first, with a 'latest 500 of N' header from total and a client-side substring filter. Outcome badges colour ok green / err red, with an err whose detail starts 'denied:' rendered amber + labelled 'denied' (capability refusal reads distinct from an execution failure). Lazy-fetched on tab show (like SYSTEM); a 30s ticker keeps the relative timestamps honest. The audit_log store + endpoint landed in the prior audit-log backend work; this is the operator-visible surface for it. Resolves #1647. --- frontend/packages/dashboard/src/index.html | 2 +- frontend/packages/dashboard/src/logs.css | 63 ++++++++++ frontend/packages/dashboard/src/logs.html | 19 +++ frontend/packages/dashboard/src/logs.js | 132 ++++++++++++++++++++- 4 files changed, 212 insertions(+), 4 deletions(-) diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 590624df..5c3c5932 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -49,7 +49,7 @@ Logs - build · agent · system logs + build · agent · system logs · privileged-action audit diff --git a/frontend/packages/dashboard/src/logs.css b/frontend/packages/dashboard/src/logs.css index fdaeef92..576c699d 100644 --- a/frontend/packages/dashboard/src/logs.css +++ b/frontend/packages/dashboard/src/logs.css @@ -175,3 +175,66 @@ body.logs-shell { .build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; } .build-logs-runtime { font-size: 0.85em; color: var(--muted); } + +/* ─── AUDIT tab — privileged-actions audit trail table ──────────────── */ +.audit-filter { + flex: 1 1 18em; + min-width: 0; + padding: 0.35em 0.6em; + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + font-family: inherit; + font-size: 0.9em; +} +.audit-table-wrap { overflow-x: auto; } +.audit-table { + width: 100%; + border-collapse: collapse; + font-size: 0.88em; +} +.audit-table th, +.audit-table td { + text-align: left; + padding: 0.4em 0.7em; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.audit-table th { + color: var(--muted); + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.82em; + white-space: nowrap; +} +.audit-table tbody tr:hover { + background: color-mix(in srgb, var(--bg-elev) 45%, transparent); +} +.audit-when, .audit-when-th { white-space: nowrap; } +.audit-agent { font-weight: bold; white-space: nowrap; } +.audit-action { font-family: monospace; white-space: nowrap; } +.audit-target { white-space: nowrap; } +.audit-detail { word-break: break-word; } +.audit-outcome-th, .audit-outcome-td { white-space: nowrap; } +.audit-outcome { + display: inline-block; + padding: 0 0.5em; + border-radius: 999px; + font-size: 0.82em; + font-weight: bold; + white-space: nowrap; +} +.audit-outcome-ok { + background: color-mix(in srgb, var(--green) 22%, transparent); + color: var(--green); +} +.audit-outcome-err { + background: color-mix(in srgb, var(--red) 22%, transparent); + color: var(--red); +} +.audit-outcome-denied { + background: color-mix(in srgb, var(--yellow) 22%, transparent); + color: var(--yellow); +} diff --git a/frontend/packages/dashboard/src/logs.html b/frontend/packages/dashboard/src/logs.html index 6ce72586..9754e09d 100644 --- a/frontend/packages/dashboard/src/logs.html +++ b/frontend/packages/dashboard/src/logs.html @@ -30,6 +30,10 @@ aria-controls="logs-pane-system" data-tab="system"> SYSTEM + + AUDIT + @@ -78,6 +82,21 @@
loading…
+ +
+
+ + + +
+

loading…

+
+ diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js index 56ddd793..0ab06764 100644 --- a/frontend/packages/dashboard/src/logs.js +++ b/frontend/packages/dashboard/src/logs.js @@ -1,9 +1,11 @@ -// /logs.html entry point: log viewer with three sub-tabs (BUILD, AGENT, SYSTEM). +// /logs.html entry point: log viewer with four sub-tabs (BUILD, AGENT, +// SYSTEM, AUDIT). // BUILD — all-agents build log history, backed by GET /api/build-logs // AGENT — per-container journald viewer, backed by GET /api/journal/{name} // SYSTEM — host service logs, backed by GET /api/journal-host +// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log // -// Tab routing via URL hash (#build, #agent, #system). Default: #build. +// Tab routing via URL hash (#build, #agent, #system, #audit). Default: #build. // // Live improvements: // - Live build output auto-scrolls to the bottom (sticky-bottom) unless @@ -368,6 +370,125 @@ import { createTabStrip } from '@hive/shared/tabs.js'; if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem); if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem); + // ─── AUDIT tab ────────────────────────────────────────────────────── + // Operator-visible trail of agent-initiated privileged actions, backed + // by GET /api/audit-log → { entries: [...], total: N } (entries + // newest-first, server-clamped to 500; `total` drives "latest 500 of N"). + // Per-entry: { id, ts_unix (secs), agent, action, target, outcome, detail }. + // outcome is 'ok' | 'err'; a capability denial is 'err' with detail + // starting "denied:" — coloured amber to read apart from an execution + // failure. Lazy-fetched on tab show (like SYSTEM); filter is a + // client-side substring on the cached rows. + + const auditList = $('audit-list'); + const auditFilter = $('audit-filter'); + const auditRefresh = $('audit-refresh'); + const auditCount = $('audit-count'); + + let auditEntries = []; + let auditTotal = 0; + let auditFetching = false; + + // ts_unix is unix seconds — fmtAgeSecs wants an age in seconds. + function auditFmtWhen(tsUnix) { + if (!tsUnix) return ''; + const age = Math.floor(Date.now() / 1000) - tsUnix; + return fmtAgeSecs(Math.max(0, age)) + ' ago'; + } + + // outcome → badge. 'ok' green; an 'err' whose detail starts "denied:" is a + // capability refusal (amber, labelled "denied"); other 'err' red. The + // literal outcome is the fallback label so a new value still renders. + function auditOutcomeBadge(outcome, detail) { + const denied = outcome === 'err' + && typeof detail === 'string' && detail.startsWith('denied:'); + const cls = outcome === 'ok' + ? 'audit-outcome audit-outcome-ok' + : denied + ? 'audit-outcome audit-outcome-denied' + : 'audit-outcome audit-outcome-err'; + return el('span', { class: cls }, denied ? 'denied' : (outcome || '?')); + } + + function auditMatches(e, q) { + if (!q) return true; + return `${e.agent || ''} ${e.action || ''} ${e.target || ''} ${e.detail || ''}` + .toLowerCase().includes(q); + } + + function renderAudit() { + if (!auditList) return; + const q = (auditFilter ? auditFilter.value : '').trim().toLowerCase(); + const rows = auditEntries.filter((e) => auditMatches(e, q)); + + if (auditCount) { + const shown = auditEntries.length; + const clamped = auditTotal > shown; + let txt = clamped + ? `latest ${shown} of ${auditTotal}` + : `${shown} entr${shown === 1 ? 'y' : 'ies'}`; + if (q) txt += ` · ${rows.length} match${rows.length === 1 ? '' : 'es'}`; + auditCount.textContent = txt; + } + + auditList.replaceChildren(); + if (rows.length === 0) { + auditList.append(el('p', { class: 'meta' }, + q ? '(no matching entries)' : '(no privileged actions recorded yet)')); + return; + } + + const table = el('table', { class: 'audit-table' }); + table.append(el('thead', {}, + el('tr', {}, + el('th', { class: 'audit-when-th' }, 'when'), + el('th', {}, 'agent'), + el('th', {}, 'action'), + el('th', {}, 'target'), + el('th', { class: 'audit-outcome-th' }, 'outcome'), + el('th', {}, 'detail'), + ))); + const tbody = el('tbody', {}); + for (const e of rows) { + tbody.append(el('tr', {}, + el('td', { + class: 'audit-when meta', + title: e.ts_unix ? new Date(e.ts_unix * 1000).toISOString() : '', + }, auditFmtWhen(e.ts_unix)), + el('td', { class: 'audit-agent' }, e.agent || ''), + el('td', { class: 'audit-action' }, e.action || ''), + el('td', { class: 'audit-target' }, e.target || ''), + el('td', { class: 'audit-outcome-td' }, auditOutcomeBadge(e.outcome, e.detail)), + el('td', { class: 'audit-detail meta' }, e.detail || ''), + )); + } + table.append(tbody); + const wrap = el('div', { class: 'audit-table-wrap' }); + wrap.append(table); + auditList.append(wrap); + } + + async function fetchAudit() { + if (!auditList || auditFetching) return; + auditFetching = true; + try { + const resp = await fetch('/api/audit-log'); + if (!resp.ok) throw new Error('http ' + resp.status); + const data = await resp.json(); + auditEntries = Array.isArray(data.entries) ? data.entries : []; + auditTotal = typeof data.total === 'number' ? data.total : auditEntries.length; + renderAudit(); + } catch (err) { + auditList.replaceChildren(); + auditList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); + } finally { + auditFetching = false; + } + } + + if (auditRefresh) auditRefresh.addEventListener('click', fetchAudit); + if (auditFilter) auditFilter.addEventListener('input', renderAudit); + // ─── init ───────────────────────────────────────────────────────────── // Wire the shared tab strip now that fetchSystem + the element refs it @@ -375,7 +496,10 @@ import { createTabStrip } from '@hive/shared/tabs.js'; // the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow. logTabs = createTabStrip(document.getElementById('logs-tabbar'), { defaultId: 'build', - onShow: (id) => { if (id === 'system') fetchSystem(); }, + onShow: (id) => { + if (id === 'system') fetchSystem(); + else if (id === 'audit') fetchAudit(); + }, }); loadAgentList(); fetchBuild(); @@ -389,6 +513,8 @@ import { createTabStrip } from '@hive/shared/tabs.js'; if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) { systemFetchTs.textContent = fmtFetchTs(systemLastFetch); } + // Keep the audit "ago" column honest while that tab is in view. + if (auditEntries.length && logTabs.active() === 'audit') renderAudit(); }, 30_000); })();