diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index 05148eee..a4b31d1d 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -383,8 +383,8 @@ def _num(name): async def handle_audit_log(request): """Config-change audit entries — the #33 control-channel log plus the out-of-band host-edit / - rig-edit detections (#530), merged and persisted so the Security panel can group by hour/day/ - month deeper than the log's own trimmed tail. Registered only alongside the control channel — + rig-edit detections (#530), merged and persisted so the Security panel can filter and page + deeper than the log's own trimmed tail. Registered only alongside the control channel — the log is a #33 artifact and the out-of-band watchers only run when it's on. Accepts the #823 navigation params (``from``/``to`` epoch seconds, ``q`` substring).""" try: diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index dec2c6e9..a86ea51a 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -610,12 +610,21 @@ tr:last-child td { margin: 0; } -/* Audit-trail time-bucket header row (#530), between groups when the operator picks - * hour/day/month grouping. A raised, muted divider — readable, not another data row. */ -.audit-group-header td { - background: var(--elevated); - color: var(--text-muted); - font-weight: 600; +/* Log pager (#823 follow-up): match count, rows-per-page select, prev/next — the grouping + * dropdown's replacement now that presets/dates/search own the time navigation. */ +.log-pager { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.log-pager select { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 6px; + font-size: 0.8rem; } /* Components */ diff --git a/build/dashboard/mining_dashboard/web/static/securityview.mjs b/build/dashboard/mining_dashboard/web/static/securityview.mjs index fec4a9c0..ec298373 100644 --- a/build/dashboard/mining_dashboard/web/static/securityview.mjs +++ b/build/dashboard/mining_dashboard/web/static/securityview.mjs @@ -11,8 +11,6 @@ import { Component, html } from "./preact.mjs"; -const ACCESS_LIMIT_SHOWN = 20; - // --- Log navigation (#823) ------------------------------------------------------------- // // Both cards share one control row: the chart's preset idiom (24 Hr / 1 Wk / 1 Mo / All) for @@ -73,43 +71,41 @@ const LogControls = ({ label, filters, onChange }) => { const EMPTY_FILTERS = { preset: "all", fromDate: "", toDate: "", q: "" }; const isFiltering = (f) => f.preset !== "all" || !!f.fromDate || !!f.toDate || !!f.q.trim(); -// Audit entries share one ts format across every source (control.log's own writer and the #530 -// watchers both emit "YYYY-MM-DDTHH:MM:SSZ", see data_service._iso_now), so a bucket key is a -// plain string slice — no date parsing, no timezone math. -export function bucketKey(ts, granularity) { - if (typeof ts !== "string") return ""; - if (granularity === "hour") return ts.slice(0, 13); - if (granularity === "month") return ts.slice(0, 7); - return ts.slice(0, 10); // "day" -} +// Pagination (#823 follow-up): the grouping dropdown became a page-size control — with range +// presets, date jumps and search doing the time navigation, bucketed grouping answered a +// strictly weaker question. Paging is CLIENT-side over the (server-filtered, bounded) result +// set; pageFor clamps so a shrinking result set never strands the view on a page past the end. +export const PAGE_SIZES = [5, 10, 20, 50, 100]; -// Group already newest-first ``entries`` into contiguous {bucket, entries} runs for -// ``granularity`` ("hour"|"day"|"month"), or one ungrouped run for "flat"/anything else — the -// drill: pick "month" to scan a year at a glance, "hour" to pin down one incident. -export function groupAuditEntries(entries, granularity) { - if (granularity !== "hour" && granularity !== "day" && granularity !== "month") { - return [{ bucket: null, entries }]; - } - const groups = []; - let current = null; - for (const e of entries) { - const key = bucketKey(e.ts, granularity); - if (!current || current.bucket !== key) { - current = { bucket: key, entries: [] }; - groups.push(current); - } - current.entries.push(e); - } - return groups; +export function pageFor(entries, page, size) { + // Guarded, not trusted: size comes from a fixed select in practice, but a 0/NaN reaching the + // division would render "page NaN of Infinity" — fall back to the default page size instead. + const s = Number.isFinite(size) && size >= 1 ? Math.floor(size) : 20; + const total = entries.length; + const pages = Math.max(1, Math.ceil(total / s)); + const p = Math.min(Math.max(0, Number.isFinite(page) ? page : 0), pages - 1); + return { slice: entries.slice(p * s, (p + 1) * s), page: p, pages, total }; } +const Pager = ({ label, total, page, pages, size, onPage, onSize }) => html`
+ ${total} entr${total === 1 ? "y" : "ies"}${pages > 1 ? html` · page ${page + 1} of ${pages}` : ""} + + + +
`; + // Epoch seconds -> local "YYYY-MM-DD HH:MM:SS"-style string; blank for a missing/zero ts. export function fmtEpoch(ts) { if (!Number.isFinite(ts) || ts <= 0) return ""; return new Date(ts * 1000).toLocaleString(); } -const AccessCard = ({ access, filters, onFilters }) => { +const AccessCard = ({ access, filters, onFilters, pager, onPager }) => { if (!access) return null; if (!access.available) { return html`
@@ -119,11 +115,7 @@ const AccessCard = ({ access, filters, onFilters }) => {
`; } const failures = access.failures_24h || 0; - // A filtered view shows every match the server returned (its read is already bounded); only - // the unfiltered glance keeps the short tail so the card stays a glance. - const shown = isFiltering(filters) - ? access.entries || [] - : (access.entries || []).slice(0, ACCESS_LIMIT_SHOWN); + const pg = pageFor(access.entries || [], pager.page, pager.size); return html`

Access log

<${LogControls} label="Access log filter" filters=${filters} onChange=${onFilters} /> @@ -142,13 +134,19 @@ const AccessCard = ({ access, filters, onFilters }) => { : null } ${ - shown.length === 0 && isFiltering(filters) - ? html`

No entries match this filter.

` - : html`
+ pg.total === 0 + ? html`

${ + isFiltering(filters) ? "No entries match this filter." : "No requests logged yet." + }

` + : html`
+ <${Pager} label="Access log" total=${pg.total} page=${pg.page} pages=${pg.pages} + size=${pager.size} onPage=${(page) => onPager({ ...pager, page })} + onSize=${(size) => onPager({ size, page: 0 })} /> +
- ${shown.map( + ${pg.slice.map( (e) => html` @@ -159,6 +157,7 @@ const AccessCard = ({ access, filters, onFilters }) => { )}
TimeStatusMethodPathUser
${fmtEpoch(e.ts)} ${e.status || "?"}
+
` }
`; @@ -174,45 +173,32 @@ const AuditRow = (e) => html` ${e.keys} `; -const AuditCard = ({ audit, group, onGroupChange, filters, onFilters }) => { +const AuditCard = ({ audit, filters, onFilters, pager, onPager }) => { // null = control channel off (the /api/audit route 404s) — no card at all. if (!audit) return null; + const pg = pageFor(audit, pager.page, pager.size); return html`
-
-

Recent config changes

- ${ - audit.length > 0 || isFiltering(filters) - ? html`` - : null - } -
+

Recent config changes

<${LogControls} label="Config-change filter" filters=${filters} onChange=${onFilters} /> ${ - audit.length === 0 + pg.total === 0 ? html`

${ isFiltering(filters) ? "No entries match this filter." : "No config changes have gone through the dashboard yet." }

` - : html`
+ : html`
+ <${Pager} label="Config changes" total=${pg.total} page=${pg.page} pages=${pg.pages} + size=${pager.size} onPage=${(page) => onPager({ ...pager, page })} + onSize=${(size) => onPager({ size, page: 0 })} /> +
- ${groupAuditEntries(audit, group).flatMap((g) => [ - g.bucket !== null - ? html` - - ` - : null, - ...g.entries.map(AuditRow), - ])} + ${pg.slice.map(AuditRow)}
Time (UTC)UserActionOutcomeSettings
${g.bucket} (${g.entries.length})
+
` }
`; @@ -221,28 +207,30 @@ const AuditCard = ({ audit, group, onGroupChange, filters, onFilters }) => { export class SecurityPanel extends Component { constructor(props) { super(props); - // auditGroup: "flat" (today's plain newest-first list) is the default so existing behavior - // doesn't change until the operator opts into grouping (#530). Each card carries its own - // #823 filter state — following the access log and pinning down one config change are - // different investigations. + // Each card carries its own #823 filter state — following the access log and pinning down + // one config change are different investigations — plus its own pager (page resets to 0 + // whenever the filter changes; a new question starts at its first page). this.state = { access: null, audit: null, - auditGroup: "flat", accessFilters: { ...EMPTY_FILTERS }, auditFilters: { ...EMPTY_FILTERS }, + accessPager: { page: 0, size: 20 }, + auditPager: { page: 0, size: 20 }, error: null, }; - this.setAuditGroup = (group) => this.setState({ auditGroup: group }); // Search keystrokes debounce (300 ms) so each letter doesn't hit the endpoint; preset and // date changes apply immediately — they are single deliberate clicks. this.setAccessFilters = (f) => this.applyFilters("access", "accessFilters", f); this.setAuditFilters = (f) => this.applyFilters("audit", "auditFilters", f); + this.setAccessPager = (pager) => this.setState({ accessPager: pager }); + this.setAuditPager = (pager) => this.setState({ auditPager: pager }); } applyFilters(which, key, filters) { const prev = this.state[key]; - this.setState({ [key]: filters }); + const pagerKey = which === "access" ? "accessPager" : "auditPager"; + this.setState({ [key]: filters, [pagerKey]: { ...this.state[pagerKey], page: 0 } }); clearTimeout(this._debounce?.[which]); const run = () => this.refetch(which, filters); if (filters.q !== prev.q) { @@ -294,12 +282,14 @@ export class SecurityPanel extends Component { } render() { - const { access, audit, auditGroup, accessFilters, auditFilters, error } = this.state; + const { access, audit, accessFilters, auditFilters, accessPager, auditPager, error } = + this.state; if (error) return html`

${error}

`; return html`
- <${AccessCard} access=${access} filters=${accessFilters} onFilters=${this.setAccessFilters} /> - <${AuditCard} audit=${audit} group=${auditGroup} onGroupChange=${this.setAuditGroup} - filters=${auditFilters} onFilters=${this.setAuditFilters} /> + <${AccessCard} access=${access} filters=${accessFilters} onFilters=${this.setAccessFilters} + pager=${accessPager} onPager=${this.setAccessPager} /> + <${AuditCard} audit=${audit} filters=${auditFilters} onFilters=${this.setAuditFilters} + pager=${auditPager} onPager=${this.setAuditPager} />
`; } } diff --git a/build/dashboard/tests/frontend/securityview.test.mjs b/build/dashboard/tests/frontend/securityview.test.mjs index fc265c02..f77b6527 100644 --- a/build/dashboard/tests/frontend/securityview.test.mjs +++ b/build/dashboard/tests/frontend/securityview.test.mjs @@ -12,10 +12,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { - bucketKey, buildLogQuery, fmtEpoch, - groupAuditEntries, + pageFor, SecurityPanel, } from "../../mining_dashboard/web/static/securityview.mjs"; import { renderToString } from "./helpers/render.mjs"; @@ -25,9 +24,11 @@ import { renderToString } from "./helpers/render.mjs"; function renderPanel(state) { const panel = new SecurityPanel({}); panel.state = { - access: null, audit: null, auditGroup: "flat", error: null, + access: null, audit: null, error: null, accessFilters: { preset: "all", fromDate: "", toDate: "", q: "" }, auditFilters: { preset: "all", fromDate: "", toDate: "", q: "" }, + accessPager: { page: 0, size: 20 }, + auditPager: { page: 0, size: 20 }, ...state, }; return renderToString(panel.render()); @@ -112,93 +113,77 @@ test("fetch error surfaces as a message, not a blank panel", () => { assert.match(out, /fetch failed/); }); -// #530: hour/day/month grouping for the audit trail. - -test("bucketKey: hour/day/month slice the shared ts format; unknown granularity ignored by the caller", () => { - const ts = "2026-07-20T14:35:00Z"; - assert.equal(bucketKey(ts, "hour"), "2026-07-20T14"); - assert.equal(bucketKey(ts, "day"), "2026-07-20"); - assert.equal(bucketKey(ts, "month"), "2026-07"); - assert.equal(bucketKey(42, "day"), ""); // non-string ts never throws -}); - -test("groupAuditEntries: flat/unknown granularity returns one ungrouped run", () => { - const entries = [{ ts: "2026-07-20T00:00:00Z" }, { ts: "2026-07-19T00:00:00Z" }]; - assert.deepEqual(groupAuditEntries(entries, "flat"), [{ bucket: null, entries }]); - assert.deepEqual(groupAuditEntries(entries, undefined), [{ bucket: null, entries }]); -}); - -test("groupAuditEntries: contiguous same-day entries collapse into one bucket", () => { - const entries = [ - { ts: "2026-07-20T14:00:00Z", actor: "a" }, - { ts: "2026-07-20T09:00:00Z", actor: "b" }, - { ts: "2026-07-19T23:00:00Z", actor: "c" }, - ]; - const groups = groupAuditEntries(entries, "day"); - assert.equal(groups.length, 2); - assert.equal(groups[0].bucket, "2026-07-20"); - assert.equal(groups[0].entries.length, 2); - assert.equal(groups[1].bucket, "2026-07-19"); - assert.equal(groups[1].entries.length, 1); -}); - -test("groupAuditEntries: month grouping spans multiple days in one bucket", () => { - const entries = [ - { ts: "2026-07-20T00:00:00Z" }, - { ts: "2026-07-01T00:00:00Z" }, - { ts: "2026-06-30T00:00:00Z" }, - ]; - const groups = groupAuditEntries(entries, "month"); - assert.deepEqual( - groups.map((g) => [g.bucket, g.entries.length]), - [ - ["2026-07", 2], - ["2026-06", 1], - ], - ); -}); - -test("audit card: default flat grouping shows no group-header row (unchanged row output)", () => { +// Pagination (#823 follow-up): the grouping dropdown became a rows-per-page control. + +test("pageFor: slices, clamps past-the-end pages, and reports totals", () => { + const entries = Array.from({ length: 23 }, (_, i) => ({ n: i })); + const p0 = pageFor(entries, 0, 10); + assert.deepEqual([p0.page, p0.pages, p0.total, p0.slice.length], [0, 3, 23, 10]); + const last = pageFor(entries, 2, 10); + assert.equal(last.slice.length, 3); // the short final page + // A page past the end (result set shrank under a filter) clamps to the last real page. + const clamped = pageFor(entries, 9, 10); + assert.equal(clamped.page, 2); + // Empty set: one empty page, never NaN/negative. + const empty = pageFor([], 0, 10); + assert.deepEqual([empty.page, empty.pages, empty.total], [0, 1, 0]); + // Hostile size/page inputs fall back instead of dividing into NaN/Infinity. + for (const badSize of [0, -5, NaN, undefined]) { + const g = pageFor(entries, 0, badSize); + assert.deepEqual([g.pages, g.slice.length], [2, 20], `size=${badSize} must fall back to 20`); + } + assert.equal(pageFor(entries, NaN, 10).page, 0); + assert.equal(pageFor(entries, -999, 10).page, 0); + // The clamp is the safety net behind every pager callback: a stale page (result set shrank, + // size grew, a reset that never fired) lands on the last REAL page, never off the end. + assert.equal(pageFor(entries, 5, 100).page, 0); +}); + +test("cards render the pager: count, rows-per-page select, prev/next with edge disabling", () => { const out = renderPanel({ - access: { available: true, entries: [] }, + access: { available: true, entries: Array.from({ length: 23 }, (_, i) => ({ + ts: 1000 + i, status: 200, method: "GET", uri: `/p/${i}`, user: "u" })) }, audit: [{ ts: "2026-07-20T12:00:00Z", actor: "admin", action: "commit", status: "applied", keys: "XVB_ENABLED" }], + accessPager: { page: 1, size: 10 }, }); - assert.doesNotMatch(out, /audit-group-header/); - assert.match(out, /XVB_ENABLED/); -}); - -test("audit card: grouping select appears once there are entries, with the current group selected", () => { - const out = renderPanel({ - access: { available: true, entries: [] }, - audit: [{ ts: "2026-07-20T12:00:00Z", actor: "admin", action: "commit", status: "applied", keys: "XVB_ENABLED" }], - auditGroup: "day", + assert.match(out, /23 entries · page 2 of 3/); + assert.match(out, /aria-label="Access log: rows per page"/); + assert.match(out, /‹ Prev/); + assert.match(out, /Next ›/); + // Edge disabling, actually asserted (vnode walker serializes boolean true as a bare + // attribute and DROPS false), scoped per pager via its aria-label: the middle-page access + // pager disables neither button, the single-page audit pager disables both. + assert.doesNotMatch(out, /disabled aria-label="Access log: previous page"/); + assert.doesNotMatch(out, /disabled aria-label="Access log: next page"/); + assert.match(out, /disabled aria-label="Config changes: previous page"/); + assert.match(out, /disabled aria-label="Config changes: next page"/); + assert.match(out, /1 entry(?! · page)/); // audit count, no page suffix on one page + // Page 2 of 3 shows rows 10-19. + assert.match(out, /\/p\/10/); + assert.doesNotMatch(out, /\/p\/9 ({ + ts: 1000 + i, status: 200, method: "GET", uri: `/p/${i}`, user: "u" })) }, + audit: null, + accessPager: { page: 2, size: 10 }, }); - assert.match(out, / { - // Filtered: the 20-row glance cap is lifted (server already bounded the read). +test('filtered results page like everything else, with honest empty messages (#823)', () => { + // 30 matches at 20/page: page 1 shows rows 0-19, the pager owns the rest — no silent cap. const many = access({ entries: Array.from({ length: 30 }, (_, i) => ({ ts: 1000 + i, status: 200, method: 'GET', uri: `/p/${i}`, user: 'u' })) }); const filtered = renderPanel({ access: many, accessFilters: { preset: '7d', fromDate: '', toDate: '', q: '' }, }); - assert.match(filtered, /\/p\/29/); // the 30th row renders under a filter - const glance = renderPanel({ access: many }); - assert.doesNotMatch(glance, /\/p\/29/); // unfiltered keeps the glance cap + assert.match(filtered, /30 entries · page 1 of 2/); + assert.match(filtered, /\/p\/19/); + assert.doesNotMatch(filtered, /\/p\/29/); // page 2's rows wait behind Next // No matches under a filter says so, instead of the no-changes-yet copy. const empty = renderPanel({ access: access({ entries: [] }), diff --git a/docs/dashboard.md b/docs/dashboard.md index 409c3731..25f89975 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -817,8 +817,9 @@ the "to" date covers that whole day — and a search box that matches any field: a path fragment, a status, a settings name. Filters compose (a search inside a range searches only that range), the search narrows as you type, and filtering happens on the server, so a match deeper than the on-screen tail is still found — the access log's read stays size-bounded either -way. A filter with no matches says so; the failed-login counter always describes the whole log, -never the filtered slice. +way. Below the row, a pager reports how many entries matched and walks them a page at a time — +pick 5 to 100 rows per page, step with Prev/Next. A filter with no matches says so; the +failed-login counter always describes the whole log, never the filtered slice. Both panels read host-written files through read-only mounts, and the dashboard treats every field in them as hostile input — a request path is attacker-chosen bytes — so each string is