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..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 @@ -359,24 +360,61 @@ 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: + 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 + + 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..fec4a9c0 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`
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.
No entries match this filter.
` + : html`| Time | Status | Method | Path | User |
|---|---|---|---|---|
| ${fmtEpoch(e.ts)} | ${e.status || "?"} | @@ -90,7 +159,8 @@ const AccessCard = ({ access }) => { )}
No config changes have gone through the dashboard yet.
` + ? html`${ + isFiltering(filters) + ? "No entries match this filter." + : "No config changes have gone through the dashboard yet." + }
` : html`| Time (UTC) | User | Action | Outcome | Settings |
|---|