Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build/dashboard/mining_dashboard/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 15 additions & 6 deletions build/dashboard/mining_dashboard/web/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
138 changes: 64 additions & 74 deletions build/dashboard/mining_dashboard/web/static/securityview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`<div class="log-pager">
<span class="text-muted text-xs">${total} entr${total === 1 ? "y" : "ies"}${pages > 1 ? html` · page ${page + 1} of ${pages}` : ""}</span>
<select aria-label=${label + ": rows per page"} value=${String(size)}
onChange=${(e) => onSize(Number(e.target.value))}>
${PAGE_SIZES.map((n) => html`<option value=${String(n)}>${n} rows</option>`)}
</select>
<button type="button" class="btn-range" disabled=${page <= 0} aria-label=${label + ": previous page"}
onClick=${() => onPage(page - 1)}>‹ Prev</button>
<button type="button" class="btn-range" disabled=${page >= pages - 1} aria-label=${label + ": next page"}
onClick=${() => onPage(page + 1)}>Next ›</button>
</div>`;

// 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`<div class="card">
Expand All @@ -119,11 +115,7 @@ const AccessCard = ({ access, filters, onFilters }) => {
</div>`;
}
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`<div class="card">
<h3>Access log</h3>
<${LogControls} label="Access log filter" filters=${filters} onChange=${onFilters} />
Expand All @@ -142,13 +134,19 @@ const AccessCard = ({ access, filters, onFilters }) => {
: null
}
${
shown.length === 0 && isFiltering(filters)
? html`<p class="text-muted">No entries match this filter.</p>`
: html`<div class="table-scroll">
pg.total === 0
? html`<p class="text-muted">${
isFiltering(filters) ? "No entries match this filter." : "No requests logged yet."
}</p>`
: html`<div>
<${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 })} />
<div class="table-scroll">
<table>
<thead><tr><th>Time</th><th>Status</th><th>Method</th><th>Path</th><th>User</th></tr></thead>
<tbody>
${shown.map(
${pg.slice.map(
(e) => html`<tr>
<td>${fmtEpoch(e.ts)}</td>
<td class=${e.status === 401 ? "status-bad" : ""}>${e.status || "?"}</td>
Expand All @@ -159,6 +157,7 @@ const AccessCard = ({ access, filters, onFilters }) => {
)}
</tbody>
</table>
</div>
</div>`
}
</div>`;
Expand All @@ -174,45 +173,32 @@ const AuditRow = (e) => html`<tr>
<td class="font-mono">${e.keys}</td>
</tr>`;

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`<div class="card">
<div class="card-header-row">
<h3>Recent config changes</h3>
${
audit.length > 0 || isFiltering(filters)
? html`<select aria-label="Group audit trail by" value=${group} onChange=${(e) => onGroupChange(e.target.value)}>
<option value="flat">All (newest first)</option>
<option value="hour">Group by hour</option>
<option value="day">Group by day</option>
<option value="month">Group by month</option>
</select>`
: null
}
</div>
<h3>Recent config changes</h3>
<${LogControls} label="Config-change filter" filters=${filters} onChange=${onFilters} />
${
audit.length === 0
pg.total === 0
? html`<p class="text-muted">${
isFiltering(filters)
? "No entries match this filter."
: "No config changes have gone through the dashboard yet."
}</p>`
: html`<div class="table-scroll">
: html`<div>
<${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 })} />
<div class="table-scroll">
<table>
<thead><tr><th>Time (UTC)</th><th>User</th><th>Action</th><th>Outcome</th><th>Settings</th></tr></thead>
<tbody>
${groupAuditEntries(audit, group).flatMap((g) => [
g.bucket !== null
? html`<tr class="audit-group-header">
<td colspan="5">${g.bucket} (${g.entries.length})</td>
</tr>`
: null,
...g.entries.map(AuditRow),
])}
${pg.slice.map(AuditRow)}
</tbody>
</table>
</div>
</div>`
}
</div>`;
Expand All @@ -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) {
Expand Down Expand Up @@ -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`<div class="card"><p class="status-bad">${error}</p></div>`;
return html`<div class="grid">
<${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} />
</div>`;
}
}
Loading