From 345b38d0d4a82617b6ff886532a378d813178065 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:40:18 +0000 Subject: [PATCH 1/7] ci: weekly CVE sweep + Dependabot coverage of compose-pinned images (#833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two proactive-update gaps from the appliance update-strategy plan (#833): - dependabot.yml: new docker-compose ecosystem entry so the third-party digests pinned directly in docker-compose.yml (Tari node + wallet, docker-socket-proxy, caddy) get the same weekly digest/patch tracking as the build/* base images. Same major/minor ignore policy — those are deliberate migrations, not CVE fixes. - ci.yml: weekly schedule trigger that rebuilds and Trivy-scans every build/* image on develop, so a CVE disclosed between PRs — or fixed in the apt archive without a new base digest, which Dependabot never sees — surfaces in the Actions tab instead of reddening the next unrelated PR. Every other job skips scheduled runs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MFc4Z74jLpMoJMX3L3DaGP --- .github/dependabot.yml | 17 +++++++++++++++++ .github/workflows/ci.yml | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c5484ae4..f7576f0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -47,3 +47,20 @@ updates: # migrations, not security updates — keep Dependabot to digest + patch within the pinned tag. - dependency-name: "*" update-types: ["version-update:semver-major", "version-update:semver-minor"] + + # Third-party image digests pinned directly in docker-compose.yml (Tari node + wallet, + # docker-socket-proxy, caddy) — outside build/*, so the `docker` entry above never sees them, + # and they feed both channels: pulled on the DIY stack, baked into the appliance image (#833). + # The pithead-* image lines interpolate env vars and carry no digest; Dependabot skips them. + - package-ecosystem: "docker-compose" + directory: "/" + schedule: + interval: "weekly" + groups: + compose: + patterns: ["*"] + ignore: + # Same policy as the docker entry: digest + patch within the pinned tag. A Tari or Caddy + # major/minor is a deliberate migration (compose flags, config compatibility), not a CVE fix. + - dependency-name: "*" + update-types: ["version-update:semver-major", "version-update:semver-minor"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7049f0d4..329d90d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,14 @@ on: push: branches: [main, develop] pull_request: + # Weekly CVE sweep (#833): rebuild + Trivy-scan every image even when no PR is open, so a CVE + # disclosed during a quiet week — or fixed in the apt archive without a new base digest, which + # Dependabot never sees — surfaces here instead of reddening the next unrelated PR. Only + # `build-images` runs on the schedule (every other job gates on the event); the run going red + # in the Actions tab is the alert, the lychee.yml posture. Scheduled runs use the default + # branch (develop). + schedule: + - cron: "0 5 * * 1" # Mondays 05:00 UTC # Least privilege (#282): every job here only reads the repo — none push commits, comment, or # publish packages. Narrowing the default GITHUB_TOKEN limits the blast radius of a compromised step. @@ -19,6 +27,7 @@ concurrency: jobs: dashboard: name: Dashboard tests (pytest + coverage) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -50,6 +59,7 @@ jobs: frontend: name: Frontend logic tests (node --test) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -67,6 +77,7 @@ jobs: dashboard-image: name: Dashboard image (Docker test stage) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -109,6 +120,7 @@ jobs: hadolint: name: Dockerfile lint (hadolint) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -126,6 +138,7 @@ jobs: python-lint: name: Python lint + format (ruff) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -149,6 +162,7 @@ jobs: gitleaks: name: Secret scan (gitleaks) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -166,6 +180,7 @@ jobs: zizmor: name: Workflow audit (zizmor) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -187,6 +202,7 @@ jobs: shell: name: Shell tests (shellcheck + pithead suite) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -224,6 +240,7 @@ jobs: compose: name: Compose config + security hardening + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -235,6 +252,7 @@ jobs: lint-surfaces: name: Lint per-surface (biome, yaml, markdown, proto, toml) + if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833) runs-on: ubuntu-latest timeout-minutes: 15 env: From 4ebbf3c5b14a9f919d58d4c1bbe00ece8cacf5e1 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 20:53:14 -0500 Subject: [PATCH 2/7] =?UTF-8?q?feat(dashboard):=20log=20navigation=20?= =?UTF-8?q?=E2=80=94=20range=20presets,=20date=20jumps,=20and=20search=20f?= =?UTF-8?q?or=20both=20security=20logs=20(#823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Security panels gain one shared control row: the chart's range presets (24 Hr / 1 Wk / 1 Mo / All) for following a live log, two native date inputs for jumping to a specific day or span (the 'to' date covers its whole day via a half-open next-midnight bound), and a debounced search box matching any field. Filters compose. Filtering is SERVER-side (?from&to&q on /api/access and /api/audit) so a match deeper than the glance tail is still found: one filter helper serves both surfaces by normalizing each entry's ts (epoch seconds on access rows, the canonical UTC ISO string on audit rows) onto one epoch axis; an undatable row matches no time window but still matches a pure text search. Malformed bounds read as absent — a bad query must never 500 a log view; q is trimmed and length-capped and only ever compared, never stored or echoed unsanitized. The access read deepens to a bounded 1000-row tail only while filtering, and its failed-login counters always describe the whole tail. A filtered view lifts the 20-row glance cap and says 'No entries match this filter' honestly. Verified live in the harness: preset refetch narrows to the window and lifts the cap, search composes with the active range (including the honest empty intersection), the audit card filters independently. Closes #823 Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/service/audit_service.py | 42 ++++++ .../dashboard/mining_dashboard/web/server.py | 42 +++++- .../mining_dashboard/web/static/dashboard.css | 20 +++ .../web/static/securityview.mjs | 140 ++++++++++++++++-- .../tests/frontend/securityview.test.mjs | 64 +++++++- .../tests/service/test_audit_service.py | 57 +++++++ build/dashboard/tests/web/test_server.py | 67 +++++++++ docs/dashboard.md | 9 ++ 8 files changed, 424 insertions(+), 17 deletions(-) diff --git a/build/dashboard/mining_dashboard/service/audit_service.py b/build/dashboard/mining_dashboard/service/audit_service.py index 98dcfef8..56e33b01 100644 --- a/build/dashboard/mining_dashboard/service/audit_service.py +++ b/build/dashboard/mining_dashboard/service/audit_service.py @@ -14,6 +14,7 @@ ``control_audit`` and Caddy rolls its own access log — this module only ever reads a tail. """ +import calendar import json import os import re @@ -93,6 +94,47 @@ def recent_changes(limit=50): return entries[::-1][:limit] +# Log-navigation filters (#823). One helper serves BOTH log surfaces even though their timestamps +# differ — access entries carry epoch seconds, audit entries the canonical "YYYY-MM-DDTHH:MM:SSZ" +# string — by normalizing each entry's ts to epoch at the comparison. The window is half-open +# [frm, to) so a "to" built from a date input's next midnight includes that whole day exactly +# once; an entry whose ts cannot be read matches NO window (filtering means placing entries in +# time — an undatable row has no place) but still matches a pure text search. + + +def _entry_epoch(ts): + """``ts`` as epoch seconds, or None when unreadable. Accepts the two shapes the log surfaces + actually emit: a number (access log) or the canonical UTC ISO string (audit trail).""" + if isinstance(ts, (int, float)): + return float(ts) + if isinstance(ts, str): + try: + return float(calendar.timegm(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ"))) + except ValueError: + return None + return None + + +def filter_log_entries(entries, frm=None, to=None, q=None): + """``entries`` narrowed to the [frm, to) epoch window and/or a case-insensitive substring + ``q`` across every field value. Filters compose; None means "don't filter on this axis".""" + ql = (q or "").lower() + out = [] + for e in entries: + if frm is not None or to is not None: + ep = _entry_epoch(e.get("ts")) + if ep is None: + continue + if frm is not None and ep < frm: + continue + if to is not None and ep >= to: + continue + if ql and not any(ql in str(v).lower() for v in e.values()): + continue + out.append(e) + return out + + def access_summary(limit=50, now=None): """Recent dashboard accesses plus the rotate-signal: 401s in the last 24 h. diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index 04a18559..72a0083f 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -359,24 +359,58 @@ def _merged_audit_entries(state_mgr): return sorted(merged.values(), key=lambda e: e.get("ts", ""), reverse=True) +def _log_filters(request): + """The #823 navigation params, parsed defensively: ``from``/``to`` as epoch seconds (anything + non-numeric reads as absent — a malformed bound must never 500 a log view) and ``q`` trimmed + and length-capped (it's compared, never stored or echoed unsanitized).""" + + def _num(name): + v = request.query.get(name) + if v in (None, ""): + return None + try: + return float(v) + except ValueError: + return None + + q = (request.query.get("q") or "").strip()[:200] + return _num("from"), _num("to"), q or None + + 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 — - the log is a #33 artifact and the out-of-band watchers only run when it's on.""" + 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: state_mgr = request.app["state_manager"] - return web.json_response({"entries": _merged_audit_entries(state_mgr)}) + frm, to, q = _log_filters(request) + entries = audit_service.filter_log_entries(_merged_audit_entries(state_mgr), frm, to, q) + return web.json_response({"entries": entries}) except Exception: logger.exception("Error reading the control audit log") return web.json_response({"error": "Failed to read the audit log."}, status=500) +# How deep the access log is read when the operator is NAVIGATING it (#823) vs the default +# glance. The tail read is byte-bounded either way (audit_service._TAIL_BYTES) — this only stops +# a filtered view from being quietly truncated to the glance depth before the filter even runs. +_ACCESS_NAV_LIMIT = 1000 + + async def handle_access_log(request): """Recent dashboard accesses + failed-login count, from Caddy's JSON access log. Always - registered (Caddy always writes the log); behind the same Caddy basic_auth as every route.""" + registered (Caddy always writes the log); behind the same Caddy basic_auth as every route. + Accepts the #823 navigation params; the failure counters always describe the whole tail, + never the filtered slice.""" try: - return web.json_response(audit_service.access_summary()) + frm, to, q = _log_filters(request) + filtering = frm is not None or to is not None or q is not None + summary = audit_service.access_summary(limit=_ACCESS_NAV_LIMIT if filtering else 50) + if filtering: + summary["entries"] = audit_service.filter_log_entries(summary["entries"], frm, to, q) + return web.json_response(summary) except Exception: logger.exception("Error reading the access log") return web.json_response({"error": "Failed to read the access log."}, status=500) diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index e6b03348..dec2c6e9 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -491,6 +491,26 @@ tr:last-child td { .est-scroll { overflow-x: auto; } +/* Log navigation (#823): the Security cards' shared filter row — the chart-controls preset + * idiom plus native date inputs and a search box, left-aligned to read as part of its card. */ +.log-controls { + justify-content: flex-start; + margin-bottom: 10px; +} +.log-controls input[type="date"], +.log-controls input[type="search"] { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 6px; + font-size: 0.8rem; +} +.log-controls input[type="search"] { + flex: 1; + min-width: 110px; +} + /* Expected-vs-actual card (#808/#817): three labelled rows, not a shared-precision numeric * grid — values wrap instead of panning (this card must never scroll in either view) and the * table fills the card. table-layout fixed, because in auto layout a cell's min-content width diff --git a/build/dashboard/mining_dashboard/web/static/securityview.mjs b/build/dashboard/mining_dashboard/web/static/securityview.mjs index b79cad92..a04cf98d 100644 --- a/build/dashboard/mining_dashboard/web/static/securityview.mjs +++ b/build/dashboard/mining_dashboard/web/static/securityview.mjs @@ -13,6 +13,66 @@ 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 +// "following" a live log, two native date inputs for jumping to a specific time, and a search +// box. Filtering is SERVER-side (?from&to&q on /api/access and /api/audit) so a match outside +// the glance tail is still found; the server owns sanitation, this file only builds the query. + +export const LOG_PRESETS = [ + { id: "24h", label: "24 Hr", secs: 86_400 }, + { id: "7d", label: "1 Wk", secs: 7 * 86_400 }, + { id: "30d", label: "1 Mo", secs: 30 * 86_400 }, + { id: "all", label: "All", secs: null }, +]; + +// UI filter state -> the endpoint query string. Preset and explicit dates are mutually +// exclusive (the controls clear one when the other is picked); an explicit "to" date means +// "through that whole day", so it maps to the NEXT midnight as the half-open upper bound. +// Date inputs parse as UTC midnight (the audit trail is displayed in UTC) — close enough for +// day-granularity jumps either way, and stable across viewer timezones. +export function buildLogQuery({ preset = "all", fromDate = "", toDate = "", q = "" } = {}, now) { + const p = new URLSearchParams(); + const nowSec = now !== undefined ? now : Date.now() / 1000; + const chosen = LOG_PRESETS.find((x) => x.id === preset); + if (fromDate || toDate) { + const from = Date.parse(fromDate) / 1000; + const to = Date.parse(toDate) / 1000; + if (Number.isFinite(from)) p.set("from", String(from)); + if (Number.isFinite(to)) p.set("to", String(to + 86_400)); + } else if (chosen && chosen.secs !== null) { + p.set("from", String(nowSec - chosen.secs)); + } + const qq = q.trim(); + if (qq) p.set("q", qq); + const s = p.toString(); + return s ? `?${s}` : ""; +} + +const LogControls = ({ label, filters, onChange }) => { + const set = (patch) => onChange({ ...filters, ...patch }); + return html`
+ ${LOG_PRESETS.map( + (p) => html``, + )} + set({ fromDate: e.target.value })} /> + + set({ toDate: e.target.value })} /> + set({ q: e.target.value })} /> +
`; +}; + +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. @@ -49,7 +109,7 @@ export function fmtEpoch(ts) { return new Date(ts * 1000).toLocaleString(); } -const AccessCard = ({ access }) => { +const AccessCard = ({ access, filters, onFilters }) => { if (!access) return null; if (!access.available) { return html`
@@ -59,8 +119,14 @@ const AccessCard = ({ access }) => {
`; } 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); return html`

Access log

+ <${LogControls} label="Access log filter" filters=${filters} onChange=${onFilters} />

0 ? "status-warn" : "status-ok"}> ${failures} failed login${failures === 1 ? "" : "s"} in the last 24 h${ access.last_failure_ts ? html` — last at ${fmtEpoch(access.last_failure_ts)}` : "" @@ -75,11 +141,14 @@ const AccessCard = ({ access }) => { ./pithead rotate-dashboard-onion.

` : null } -
+ ${ + shown.length === 0 && isFiltering(filters) + ? html`

No entries match this filter.

` + : html`
- ${(access.entries || []).slice(0, ACCESS_LIMIT_SHOWN).map( + ${shown.map( (e) => html` @@ -90,7 +159,8 @@ const AccessCard = ({ access }) => { )}
TimeStatusMethodPathUser
${fmtEpoch(e.ts)} ${e.status || "?"}
-
+
` + }
`; }; @@ -104,14 +174,14 @@ const AuditRow = (e) => html` ${e.keys} `; -const AuditCard = ({ audit, group, onGroupChange }) => { +const AuditCard = ({ audit, group, onGroupChange, filters, onFilters }) => { // null = control channel off (the /api/audit route 404s) — no card at all. if (!audit) return null; return html`

Recent config changes

${ - audit.length > 0 + audit.length > 0 || isFiltering(filters) ? html` { + const NOW = 1_760_000_000; + // Preset -> a trailing from-window; All -> no params at all. + assert.equal(buildLogQuery({ preset: '24h', fromDate: '', toDate: '', q: '' }, NOW), + `?from=${NOW - 86_400}`); + assert.equal(buildLogQuery({ preset: 'all', fromDate: '', toDate: '', q: '' }, NOW), ''); + // Explicit dates OVERRIDE the preset, and "to" covers that whole day (next-midnight bound). + const from = Date.parse('2026-07-10') / 1000; + const to = Date.parse('2026-07-11') / 1000 + 86_400; + assert.equal( + buildLogQuery({ preset: '24h', fromDate: '2026-07-10', toDate: '2026-07-11', q: '' }, NOW), + `?from=${from}&to=${to}`); + // Search rides along URL-encoded; blank search adds nothing. + assert.equal(buildLogQuery({ preset: 'all', fromDate: '', toDate: '', q: 'api state' }, NOW), + '?q=api+state'); + assert.equal(buildLogQuery({ preset: 'all', fromDate: '', toDate: '', q: ' ' }, NOW), ''); +}); + +test('both cards render the shared filter controls with the active preset marked', () => { + const html = renderPanel({ + access: access(), + audit: [{ ts: "2026-07-10T12:00:00Z", actor: "admin", action: "commit", status: "applied", keys: "X" }], + accessFilters: { preset: '24h', fromDate: '', toDate: '', q: '' }, + auditFilters: { preset: 'all', fromDate: '', toDate: '', q: '' }, + }); + assert.match(html, /aria-label="Access log filter"/); + assert.match(html, /aria-label="Config-change filter"/); + assert.match(html, /class="btn-range active"[^>]*>24 Hr { + // Filtered: the 20-row glance cap is lifted (server already bounded the read). + 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 + // No matches under a filter says so, instead of the no-changes-yet copy. + const empty = renderPanel({ + access: access({ entries: [] }), + accessFilters: { preset: 'all', fromDate: '', toDate: '', q: 'zzz' }, + audit: [], + auditFilters: { preset: 'all', fromDate: '', toDate: '', q: 'zzz' }, + }); + assert.match(empty, /No entries match this filter/); + assert.doesNotMatch(empty, /No config changes have gone through/); +}); diff --git a/build/dashboard/tests/service/test_audit_service.py b/build/dashboard/tests/service/test_audit_service.py index 20736b79..bc11f352 100644 --- a/build/dashboard/tests/service/test_audit_service.py +++ b/build/dashboard/tests/service/test_audit_service.py @@ -170,3 +170,60 @@ def test_entries_newest_first_and_limited(self, access_log): entries = audit_service.access_summary(limit=50, now=100.0)["entries"] assert len(entries) == 50 assert entries[0]["uri"] == "/p59" + + +class TestFilterLogEntries: + # Log navigation (#823): one filter helper for BOTH surfaces — access entries carry epoch ts, + # audit entries the canonical UTC ISO string. Window is half-open [frm, to). + + ENTRIES = [ + {"ts": 100.0, "status": 401, "uri": "/api/state", "user": "admin"}, + {"ts": 200.0, "status": 200, "uri": "/static/app.js", "user": "vijit"}, + {"ts": "2026-08-01T12:00:00Z", "actor": "admin", "action": "commit", "status": "applied"}, + {"ts": "garbage", "actor": "release-smoke", "action": "upgrade"}, + ] + + def test_no_filters_passes_everything_through(self): + assert audit_service.filter_log_entries(self.ENTRIES) == self.ENTRIES + + def test_entry_epoch_reads_only_the_two_real_shapes(self): + # A ts that is neither a number nor a string (a missing key's None, a corrupt row's + # dict) is undatable — no exception, no guess. + assert audit_service._entry_epoch(None) is None + assert audit_service._entry_epoch({"nested": 1}) is None + + def test_window_is_half_open_and_reads_both_ts_shapes(self): + # frm inclusive, to exclusive; the ISO entry's epoch (2026-08-01T12:00Z) sits far above + # the numeric ones, so a tight numeric window keeps only the 200.0 row... + assert audit_service.filter_log_entries(self.ENTRIES, frm=200.0, to=200.1) == [ + self.ENTRIES[1] + ] + # ...and a window around the ISO instant keeps only the audit row — proof both shapes + # normalize onto one axis. + iso_epoch = audit_service._entry_epoch("2026-08-01T12:00:00Z") + got = audit_service.filter_log_entries(self.ENTRIES, frm=iso_epoch, to=iso_epoch + 1) + assert got == [self.ENTRIES[2]] + # to is exclusive: a window ENDING exactly on an entry's ts drops it. + assert audit_service.filter_log_entries(self.ENTRIES, frm=100.0, to=200.0) == [ + self.ENTRIES[0] + ] + + def test_undatable_ts_matches_no_window_but_still_searches(self): + # Filtering by time means placing entries in time — the "garbage"-ts row has no place in + # any window, but a pure text search still finds it. + assert audit_service.filter_log_entries(self.ENTRIES, frm=0.0) == self.ENTRIES[:3] + assert audit_service.filter_log_entries(self.ENTRIES, q="release-smoke") == [ + self.ENTRIES[3] + ] + + def test_search_is_case_insensitive_across_every_field(self): + assert audit_service.filter_log_entries(self.ENTRIES, q="ADMIN") == [ + self.ENTRIES[0], + self.ENTRIES[2], + ] + # Numeric field values participate too (status 401 as text). + assert audit_service.filter_log_entries(self.ENTRIES, q="401") == [self.ENTRIES[0]] + + def test_filters_compose(self): + got = audit_service.filter_log_entries(self.ENTRIES, frm=0.0, to=300.0, q="vijit") + assert got == [self.ENTRIES[1]] diff --git a/build/dashboard/tests/web/test_server.py b/build/dashboard/tests/web/test_server.py index b79c1cca..9b44e3ea 100644 --- a/build/dashboard/tests/web/test_server.py +++ b/build/dashboard/tests/web/test_server.py @@ -474,6 +474,73 @@ async def test_audit_route_serves_sanitized_entries( assert body["entries"][0]["keys"] == "XVB_ENABLED" assert "<" not in json.dumps(body) and ">" not in json.dumps(body) + async def test_access_route_navigation_params_filter_entries( + self, client, tmp_path, monkeypatch + ): + # #823: from/to (epoch seconds, half-open) and q narrow the served entries; the failure + # counters keep describing the whole tail; malformed bounds read as absent, never a 500. + log = tmp_path / "access.log" + rows = [ + { + "ts": 100.0, + "status": 200, + "user_id": "admin", + "request": {"method": "GET", "uri": "/api/state"}, + }, + { + "ts": 200.0, + "status": 401, + "user_id": "guess", + "request": {"method": "GET", "uri": "/login"}, + }, + ] + log.write_text("".join(json.dumps(r) + "\n" for r in rows)) + monkeypatch.setattr(audit_service.config, "ACCESS_LOG_PATH", str(log)) + body = await (await client.get("/api/access?from=150")).json() + assert [e["ts"] for e in body["entries"]] == [200.0] + body = await (await client.get("/api/access?q=api/state")).json() + assert [e["ts"] for e in body["entries"]] == [100.0] + # to is exclusive; and the 401 counter is window-independent (whole-tail semantics). + body = await (await client.get("/api/access?from=100&to=200")).json() + assert [e["ts"] for e in body["entries"]] == [100.0] + assert "failures_24h" in body + # Malformed bounds degrade to unfiltered, HTTP 200. + resp = await client.get("/api/access?from=notanumber&to=&q=") + assert resp.status == 200 + assert len((await resp.json())["entries"]) == 2 + + async def test_audit_route_navigation_params_filter_entries( + self, control_client, tmp_path, monkeypatch + ): + # #823 on the audit side: ISO timestamps land on the same epoch axis, and q searches + # the sanitized fields. + log = tmp_path / "control.log" + rows = [ + { + "ts": "2026-07-10T12:00:00Z", + "id": "11111111-1111-4111-8111-111111111111", + "actor": "admin", + "action": "commit", + "status": "applied", + "keys": "XVB_ENABLED", + }, + { + "ts": "2026-07-20T12:00:00Z", + "id": "22222222-2222-4222-8222-222222222222", + "actor": "release-smoke", + "action": "upgrade", + "status": "upgraded", + "keys": "", + }, + ] + log.write_text("".join(json.dumps(r) + "\n" for r in rows)) + monkeypatch.setattr(audit_service.config, "CONTROL_AUDIT_LOG", str(log)) + cutoff = audit_service._entry_epoch("2026-07-15T00:00:00Z") + body = await (await control_client.get(f"/api/audit?from={cutoff}")).json() + assert [e["actor"] for e in body["entries"]] == ["release-smoke"] + body = await (await control_client.get("/api/audit?q=xvb_enabled")).json() + assert [e["actor"] for e in body["entries"]] == ["admin"] + async def test_audit_route_missing_log_is_empty(self, control_client, monkeypatch): monkeypatch.setattr(audit_service.config, "CONTROL_AUDIT_LOG", "/nonexistent/control.log") resp = await control_client.get("/api/audit") diff --git a/docs/dashboard.md b/docs/dashboard.md index efb05dfe..409c3731 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -811,6 +811,15 @@ Below the form, the Configuration view shows two read-only security panels that changed. Values are never recorded (several are secrets). Shown only when `dashboard.control.enabled` is on. +Each panel carries the same navigation row: range presets (**24 Hr / 1 Wk / 1 Mo / All**, the +chart's idiom) for following a live log, two date fields for jumping to a specific day or span — +the "to" date covers that whole day — and a search box that matches any field: a user, an action, +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. + 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 stripped to a safe character set before it is served. See From 105a6128234175ce108dc0327e8eaa16845d9906 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 21:02:51 -0500 Subject: [PATCH 3/7] fix(dashboard): harden the log-navigation edges (verifier findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Non-finite from/to bounds (inf/nan parse via float()) now read as absent — nan compares False with everything and would silently warp the window; the malformed-bounds contract is now true, and tested. - A per-surface sequence guard stops a slow stale response from overwriting a fresher filter's view. - Pending search debounce timers are cleared on unmount. Co-Authored-By: Claude Fable 5 --- .../dashboard/mining_dashboard/web/server.py | 6 +++++- .../web/static/securityview.mjs | 20 ++++++++++++++++--- build/dashboard/tests/web/test_server.py | 10 ++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index 72a0083f..05148eee 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -1,4 +1,5 @@ import logging +import math import mimetypes import os import re @@ -369,9 +370,12 @@ def _num(name): if v in (None, ""): return None try: - return float(v) + f = float(v) except ValueError: return None + # float() happily parses "inf"/"nan", which would silently warp the window comparisons + # (nan compares False with everything) — a non-finite bound is malformed, so it's absent. + return f if math.isfinite(f) else None q = (request.query.get("q") or "").strip()[:200] return _num("from"), _num("to"), q or None diff --git a/build/dashboard/mining_dashboard/web/static/securityview.mjs b/build/dashboard/mining_dashboard/web/static/securityview.mjs index a04cf98d..fec4a9c0 100644 --- a/build/dashboard/mining_dashboard/web/static/securityview.mjs +++ b/build/dashboard/mining_dashboard/web/static/securityview.mjs @@ -253,20 +253,34 @@ export class SecurityPanel extends Component { } async refetch(which, filters) { + // Sequence guard: two quick filter changes can land responses out of order — only the + // NEWEST request for a surface may write state, or a slow stale response would overwrite + // the fresher view the operator is already looking at. + if (!this._seq) this._seq = {}; + this._seq[which] = (this._seq[which] || 0) + 1; + const seq = this._seq; + const mine = seq[which]; try { const qs = buildLogQuery(filters); if (which === "access") { const res = await fetch("/api/access" + qs); - if (res.ok) this.setState({ access: await res.json() }); + if (res.ok && seq[which] === mine) this.setState({ access: await res.json() }); } else { const res = await fetch("/api/audit" + qs); - if (res.ok) this.setState({ audit: (await res.json()).entries || [] }); + if (res.ok && seq[which] === mine) + this.setState({ audit: (await res.json()).entries || [] }); } } catch (e) { - this.setState({ error: String(e) }); + if (seq[which] === mine) this.setState({ error: String(e) }); } } + componentWillUnmount() { + // A pending search debounce firing after unmount would setState on a dead component. + for (const t of Object.values(this._debounce || {})) clearTimeout(t); + this._debounce = {}; + } + async componentDidMount() { try { const res = await fetch("/api/access"); diff --git a/build/dashboard/tests/web/test_server.py b/build/dashboard/tests/web/test_server.py index 9b44e3ea..282941c6 100644 --- a/build/dashboard/tests/web/test_server.py +++ b/build/dashboard/tests/web/test_server.py @@ -504,10 +504,12 @@ async def test_access_route_navigation_params_filter_entries( body = await (await client.get("/api/access?from=100&to=200")).json() assert [e["ts"] for e in body["entries"]] == [100.0] assert "failures_24h" in body - # Malformed bounds degrade to unfiltered, HTTP 200. - resp = await client.get("/api/access?from=notanumber&to=&q=") - assert resp.status == 200 - assert len((await resp.json())["entries"]) == 2 + # Malformed bounds degrade to unfiltered, HTTP 200 — including the float()-parseable + # non-finite spellings, which would otherwise warp the comparisons (nan is never <). + for bad in ("notanumber", "inf", "-inf", "nan", ""): + resp = await client.get(f"/api/access?from={bad}&to={bad}&q=") + assert resp.status == 200 + assert len((await resp.json())["entries"]) == 2 async def test_audit_route_navigation_params_filter_entries( self, control_client, tmp_path, monkeypatch From 8222dc92bc8c4e25107f75bedac583c4b3df70c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:07:06 +0000 Subject: [PATCH 4/7] build(deps): bump the compose group with 2 updates Bumps the compose group with 2 updates: [tarilabs/minotari_console_wallet](https://github.com/tari-project/tari) and caddy. Updates `tarilabs/minotari_console_wallet` from v5.3.1-mainnet to v5.3.1-mainnet - [Release notes](https://github.com/tari-project/tari/releases) - [Changelog](https://github.com/tari-project/tari/blob/development/changelog-development.md) - [Commits](https://github.com/tari-project/tari/compare/v5.3.1...v5.3.1) Updates `caddy` from 2.11.4 to 2.11.4 --- updated-dependencies: - dependency-name: tarilabs/minotari_console_wallet dependency-version: v5.3.1-mainnet dependency-type: direct:production dependency-group: compose - dependency-name: caddy dependency-version: 2.11.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: compose ... Signed-off-by: dependabot[bot] --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c5294f18..8afba72c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -297,7 +297,7 @@ services: tari-wallet: profiles: ["tari_payout_confirm"] # Pinned by digest (#135), same registry/tag as the Tari node so the version-match holds. - image: quay.io/tarilabs/minotari_console_wallet:v5.3.1-mainnet@sha256:886ce60b1cf2a28bd01fb9ce21533bb3be834215e5bbe918533869e3d2a43622 + image: quay.io/tarilabs/minotari_console_wallet:v5.3.1-mainnet@sha256:31b3cd7b2b390da33c279fd1a5cd457eb254aeea17a5a230ff4c7bfea79a47eb container_name: tari-wallet # Run non-root (#255). Pulled image, so set uid via compose to match the uid the wallet named # volume is created under; the wrapper only writes the base-path (the volume) and /tmp. @@ -858,7 +858,7 @@ services: # Bridges the local 127.0.0.1 application binding to the LAN # Serves HTTPS or HTTP depending on configuration caddy: - image: caddy:2.11.4@sha256:cfeb0b281bc44a5a51fecde39e9e577c60d863c0b6196e6bbdf58fd00960887f + image: caddy:2.11.4@sha256:844f60b64e4724a5aa8245e019dace0d3f199f7433ce6c57676cb30a920dbad9 container_name: caddy # Memory ceiling (#132 — see monerod). Reverse proxy + local TLS only (~13 MiB observed). mem_limit: 128m From d310e62d8ee67d51584bba2797d1570f66fe9b11 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 21:10:03 -0500 Subject: [PATCH 5/7] =?UTF-8?q?feat(dashboard):=20log=20pager=20=E2=80=94?= =?UTF-8?q?=20match=20count,=20rows-per-page,=20prev/next;=20grouping=20dr?= =?UTF-8?q?opdown=20retired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grouping dropdown answered a strictly weaker question once range presets, date jumps and search owned the time navigation — it becomes a pager: ' entries · page X of Y', a 5/10/20/50/100 rows-per-page select, and Prev/Next. Paging is client-side over the server-filtered, bounded result set; pageFor clamps so a shrinking result set never strands the view past the end, and the page resets to 0 on any filter change (a new question starts at its first page). Both cards page identically — the access card's special filtered-vs-glance cap split dies with the grouping code (bucketKey, groupAuditEntries, the group-header rows and their CSS). Co-Authored-By: Claude Fable 5 --- .../dashboard/mining_dashboard/web/server.py | 4 +- .../mining_dashboard/web/static/dashboard.css | 21 ++- .../web/static/securityview.mjs | 135 ++++++++---------- .../tests/frontend/securityview.test.mjs | 125 ++++++---------- docs/dashboard.md | 5 +- 5 files changed, 124 insertions(+), 166 deletions(-) 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..2240e976 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,38 @@ 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) { + const total = entries.length; + const pages = Math.max(1, Math.ceil(total / size)); + const p = Math.min(Math.max(0, page), pages - 1); + return { slice: entries.slice(p * size, (p + 1) * size), 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 +112,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 +131,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 +154,7 @@ const AccessCard = ({ access, filters, onFilters }) => { )}
TimeStatusMethodPathUser
${fmtEpoch(e.ts)} ${e.status || "?"}
+
` }
`; @@ -174,45 +170,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 +204,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 +279,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..d770fb43 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,53 @@ 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. +// Pagination (#823 follow-up): the grouping dropdown became a rows-per-page control. -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("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]); }); -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)", () => { - const out = renderPanel({ - access: { available: true, entries: [] }, - audit: [{ ts: "2026-07-20T12:00:00Z", actor: "admin", action: "commit", status: "applied", keys: "XVB_ENABLED" }], - }); - 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", () => { +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" }], - auditGroup: "day", + accessPager: { page: 1, 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 From 1ef6732ed2cc7a6cad748c6abdcd7a937781a292 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 21:19:14 -0500 Subject: [PATCH 6/7] fix(dashboard): guard pageFor inputs; assert edge disabling for real (verifier findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pageFor falls back to the default page size on a 0/NaN/negative size and a non-finite page — a hostile value reaching the division would render 'page NaN of Infinity'. The clamp is the safety net behind every pager callback: a stale page lands on the last real page. - The edge-disabling test now asserts the disabled attribute's presence/absence per pager (scoped by aria-label), plus a last-page case — previously it only matched button text. Co-Authored-By: Claude Fable 5 --- .../web/static/securityview.mjs | 9 ++++-- .../tests/frontend/securityview.test.mjs | 28 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/static/securityview.mjs b/build/dashboard/mining_dashboard/web/static/securityview.mjs index 2240e976..ec298373 100644 --- a/build/dashboard/mining_dashboard/web/static/securityview.mjs +++ b/build/dashboard/mining_dashboard/web/static/securityview.mjs @@ -78,10 +78,13 @@ const isFiltering = (f) => f.preset !== "all" || !!f.fromDate || !!f.toDate || ! export const PAGE_SIZES = [5, 10, 20, 50, 100]; 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 / size)); - const p = Math.min(Math.max(0, page), pages - 1); - return { slice: entries.slice(p * size, (p + 1) * size), page: p, pages, total }; + 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`
diff --git a/build/dashboard/tests/frontend/securityview.test.mjs b/build/dashboard/tests/frontend/securityview.test.mjs index d770fb43..f77b6527 100644 --- a/build/dashboard/tests/frontend/securityview.test.mjs +++ b/build/dashboard/tests/frontend/securityview.test.mjs @@ -127,6 +127,16 @@ test("pageFor: slices, clamps past-the-end pages, and reports totals", () => { // 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", () => { @@ -140,13 +150,27 @@ test("cards render the pager: count, rows-per-page select, prev/next with edge d assert.match(out, /aria-label="Access log: rows per page"/); assert.match(out, /‹ Prev/); assert.match(out, /Next ›/); - // Middle page: neither edge disabled on the access pager; the single-page audit pager - // disables both (vnode walker serializes boolean true as a bare attribute). + // 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(lastPage, /disabled aria-label="Access log: next page"/); + assert.doesNotMatch(lastPage, /disabled aria-label="Access log: previous page"/); }); test("audit card renders flat rows only — grouping artifacts are gone", () => { From 3cbbc1c54b3997feb1bef6d26f13741f9816130a Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 21:39:38 -0500 Subject: [PATCH 7/7] =?UTF-8?q?chore(release):=20v1.17.0=20prep=20?= =?UTF-8?q?=E2=80=94=20version=20bump=20+=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 +++++++++++++++++++ VERSION | 2 +- build/dashboard/pyproject.toml | 2 +- build/dashboard/uv.lock | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5086d87..d4d66beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ Pithead ships as **one product, one version** — the version lives in the top-l [`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md). +## [1.17.0] - 2026-08-02 + +### Added + +- **Log navigation for the Security panels (#823).** The access log and the config-change audit + trail share one control row: range presets (24 Hr / 1 Wk / 1 Mo / All, the chart's idiom) for + following a live log, two date fields for jumping to a specific day or span, and a search box + that matches any field and narrows as you type. Filters compose, 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. Below the row, a pager reports how many entries matched and walks + them a page at a time — 5 to 100 rows per page with Prev/Next — replacing the audit trail's + hour/day/month grouping dropdown, whose job the date controls now do better. A filter with no + matches says so; the failed-login counter always describes the whole log. + +### Dependencies + +- Compose-pinned third-party images, now under Dependabot's watch: refreshed digests for the + Tari console wallet (within v5.3.1-mainnet) and Caddy (within 2.11.4). + ## [1.16.1] - 2026-08-01 ### Fixed diff --git a/VERSION b/VERSION index 41c11ffb..092afa15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.1 +1.17.0 diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index f95491ce..3f4ca47b 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -7,7 +7,7 @@ name = "mining-dashboard" # Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version # (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version # from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only. -version = "1.16.1" +version = "1.17.0" description = "Monitoring dashboard and XvB switching engine for Pithead" readme = "README.md" requires-python = ">=3.11" diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock index b03fd5c3..821f65bf 100644 --- a/build/dashboard/uv.lock +++ b/build/dashboard/uv.lock @@ -786,7 +786,7 @@ wheels = [ [[package]] name = "mining-dashboard" -version = "1.16.1" +version = "1.17.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },