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`
`;
}
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`