feat(audit-log): signal rail, day-banded stream, anomaly detection (#682)

Add a Stream view to the Audit Log that leads with a four-tile signal
rail (events, actors, failure rate with inline sparkline, peak hour)
and presents the feed grouped by day with severity dots, relative
times, and inline anomaly callouts. The existing Table view is
preserved behind a toggle for power users.

Anomaly flags are computed at read time against strictly prior history
and returned on demand via ?with_anomalies=1:
- unusual_hour: hour outside the actor's central 7-day window
- new_ip: IP unseen for this actor in the last 30 days
- first_seen_actor: no prior history in the 30-day window

New /audit-log/stats endpoint returns the signal-rail aggregates over
24h/7d/30d windows; stats are derived from a single 30-day scan.
This commit is contained in:
Anso
2026-04-18 18:24:27 -04:00
committed by GitHub
parent 95278843cf
commit 591dc75d1e
7 changed files with 868 additions and 138 deletions
+35 -1
View File
@@ -27,6 +27,7 @@ import { AutoHealService } from './services/AutoHealService';
import { DockerEventManager } from './services/DockerEventManager';
import { ImageUpdateService } from './services/ImageUpdateService';
import { UpdatePreviewService } from './services/UpdatePreviewService';
import { annotateEntries, computeAuditStats, HISTORY_WINDOW_MS } from './services/AuditAnomalyService';
import { templateService } from './services/TemplateService';
import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
@@ -6238,11 +6239,25 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
const withAnomalies = req.query.with_anomalies === '1';
if (isDebugEnabled()) {
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${username || '-'} method=${method || '-'} search=${search || '-'}`);
}
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search });
const db = DatabaseService.getInstance();
const result = db.getAuditLogs({ page, limit, username, method, from, to, search });
if (withAnomalies && result.entries.length > 0) {
const now = Date.now();
const historyFrom = now - HISTORY_WINDOW_MS;
const oldestInPage = result.entries.reduce(
(min, e) => Math.min(min, e.timestamp),
result.entries[0].timestamp
);
const history = db.getAuditLogsInRange(historyFrom, oldestInPage);
res.json({ ...result, entries: annotateEntries(result.entries, history, now) });
return;
}
res.json(result);
} catch (error) {
console.error('[AuditLog] Failed to fetch audit log:', error);
@@ -6250,6 +6265,25 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
}
});
app.get('/api/audit-log/stats', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const now = Date.now();
const db = DatabaseService.getInstance();
const cutoff24h = now - 24 * 60 * 60 * 1000;
const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
const last30d = db.getAuditLogsInRange(now - HISTORY_WINDOW_MS, now);
const last7d = last30d.filter(e => e.timestamp >= cutoff7d);
const last24h = last7d.filter(e => e.timestamp >= cutoff24h);
res.json(computeAuditStats({ now, last24h, last7d, last30d }));
} catch (error) {
console.error('[AuditLog] Failed to compute audit stats:', error);
res.status(500).json({ error: 'Failed to compute audit stats' });
}
});
app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;