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
+434 -130
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, Fragment } from 'react';
import { useState, useEffect, useCallback, Fragment, useMemo } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -7,10 +7,13 @@ import { DatePicker } from '@/components/ui/date-picker';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown } from 'lucide-react';
import { Sparkline } from '@/components/ui/sparkline';
import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown, Activity, Table2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor';
interface AuditEntry {
id: number;
timestamp: number;
@@ -21,6 +24,23 @@ interface AuditEntry {
node_id: number | null;
ip_address: string;
summary: string;
flags?: AnomalyFlag[];
}
interface AuditStatTile {
value: number | null;
label: string;
detail: string | null;
severity: 'ok' | 'warn' | 'alert';
}
interface AuditStats {
events_24h: AuditStatTile;
actors_24h: AuditStatTile;
failure_rate: AuditStatTile;
unusual_hour: AuditStatTile;
activity_by_hour: number[];
failures_by_hour: number[];
}
const methodOptions = [
@@ -31,18 +51,76 @@ const methodOptions = [
{ value: 'PATCH', label: 'PATCH' },
];
const SEVERITY_DOT: Record<'ok' | 'warn' | 'alert', string> = {
ok: 'bg-success',
warn: 'bg-warning',
alert: 'bg-destructive',
};
const SEVERITY_TEXT: Record<'ok' | 'warn' | 'alert', string> = {
ok: 'text-stat-value',
warn: 'text-warning',
alert: 'text-destructive',
};
const FLAG_LABEL: Record<AnomalyFlag, string> = {
unusual_hour: 'unusual hour',
new_ip: 'new ip',
first_seen_actor: 'first seen',
};
function entrySeverity(statusCode: number): 'ok' | 'warn' | 'alert' {
if (statusCode >= 400) return 'alert';
if (statusCode >= 300) return 'warn';
return 'ok';
}
function formatRelative(ts: number, now: number): string {
const diff = now - ts;
if (diff < 60_000) return 'now';
const mins = Math.round(diff / 60_000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatDayBanner(ts: number): string {
const d = new Date(ts);
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function formatClock(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
}
function dayKey(ts: number): string {
const d = new Date(ts);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
export function AuditLogView() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<'stream' | 'table'>('stream');
const [stats, setStats] = useState<AuditStats | null>(null);
const [searchFilter, setSearchFilter] = useState('');
const [methodFilter, setMethodFilter] = useState('all');
const [fromDate, setFromDate] = useState<Date | undefined>();
const [toDate, setToDate] = useState<Date | undefined>();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());
const limit = 50;
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);
const buildFilterParams = useCallback(() => {
const params = new URLSearchParams();
if (searchFilter) params.set('search', searchFilter);
@@ -66,6 +144,7 @@ export function AuditLogView() {
const params = buildFilterParams();
params.set('page', String(page));
params.set('limit', String(limit));
params.set('with_anomalies', '1');
const res = await apiFetch(`/audit-log?${params}`, { localOnly: true });
if (res.ok) {
@@ -80,10 +159,25 @@ export function AuditLogView() {
}
}, [page, buildFilterParams]);
const fetchStats = useCallback(async () => {
try {
const res = await apiFetch('/audit-log/stats', { localOnly: true });
if (res.ok) {
setStats(await res.json());
}
} catch (err) {
console.error('[AuditLog] Failed to fetch stats:', err);
}
}, []);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
if (view === 'stream') fetchStats();
}, [view, fetchStats]);
const totalPages = Math.max(1, Math.ceil(total / limit));
const methodBadgeVariant = (method: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
@@ -127,6 +221,20 @@ export function AuditLogView() {
}
};
const groupedEntries = useMemo(() => {
const groups: { key: string; day: number; entries: AuditEntry[] }[] = [];
for (const entry of entries) {
const key = dayKey(entry.timestamp);
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.entries.push(entry);
} else {
groups.push({ key, day: entry.timestamp, entries: [entry] });
}
}
return groups;
}, [entries]);
return (
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
<Card className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
@@ -137,6 +245,26 @@ export function AuditLogView() {
<CardTitle>Audit Log</CardTitle>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-md border border-card-border bg-card/40 p-0.5 mr-1">
<Button
variant={view === 'stream' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2 gap-1.5 text-xs"
onClick={() => setView('stream')}
>
<Activity className="w-3.5 h-3.5" strokeWidth={1.5} />
Stream
</Button>
<Button
variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2 gap-1.5 text-xs"
onClick={() => setView('table')}
>
<Table2 className="w-3.5 h-3.5" strokeWidth={1.5} />
Table
</Button>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="border-border">
@@ -150,7 +278,7 @@ export function AuditLogView() {
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" className="border-border" onClick={fetchLogs} disabled={loading}>
<Button variant="outline" size="sm" className="border-border" onClick={() => { fetchLogs(); if (view === 'stream') fetchStats(); }} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh
</Button>
@@ -161,141 +289,317 @@ export function AuditLogView() {
</p>
</CardHeader>
<CardContent>
{/* Filters */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search actions, paths, users..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(1); }}
className="pl-8"
/>
</div>
<Combobox
options={methodOptions}
value={methodFilter}
onValueChange={(v) => { setMethodFilter(v || 'all'); setPage(1); }}
placeholder="Method"
className="w-[140px]"
{view === 'stream' ? (
<StreamView
stats={stats}
loading={loading && entries.length === 0}
groups={groupedEntries}
now={now}
totalPages={totalPages}
page={page}
onPage={setPage}
/>
<DatePicker
value={fromDate}
onChange={(d) => { setFromDate(d); setPage(1); }}
placeholder="From"
className="w-[160px]"
/>
<DatePicker
value={toDate}
onChange={(d) => { setToDate(d); setPage(1); }}
placeholder="To"
className="w-[160px]"
/>
</div>
) : (
<>
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search actions, paths, users..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(1); }}
className="pl-8"
/>
</div>
<Combobox
options={methodOptions}
value={methodFilter}
onValueChange={(v) => { setMethodFilter(v || 'all'); setPage(1); }}
placeholder="Method"
className="w-[140px]"
/>
<DatePicker
value={fromDate}
onChange={(d) => { setFromDate(d); setPage(1); }}
placeholder="From"
className="w-[160px]"
/>
<DatePicker
value={toDate}
onChange={(d) => { setToDate(d); setPage(1); }}
placeholder="To"
className="w-[160px]"
/>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[170px]">Timestamp</TableHead>
<TableHead className="w-[110px]">User</TableHead>
<TableHead className="w-[80px]">Method</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-[70px]">Status</TableHead>
<TableHead className="w-[70px]">Node</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No audit log entries found.
</TableCell>
</TableRow>
) : (
entries.map((entry) => (
<Fragment key={entry.id}>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
>
<TableCell className="text-xs text-muted-foreground font-mono tabular-nums">
{new Date(entry.timestamp).toLocaleString()}
</TableCell>
<TableCell className="font-medium text-sm">
{entry.username}
</TableCell>
<TableCell>
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
{entry.method}
</Badge>
</TableCell>
<TableCell className="text-sm">
{entry.summary}
</TableCell>
<TableCell className={`text-sm font-mono tabular-nums ${statusColor(entry.status_code)}`}>
{entry.status_code}
</TableCell>
<TableCell className="text-sm text-muted-foreground font-mono tabular-nums">
{entry.node_id ?? '-'}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[170px]">Timestamp</TableHead>
<TableHead className="w-[110px]">User</TableHead>
<TableHead className="w-[80px]">Method</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-[70px]">Status</TableHead>
<TableHead className="w-[70px]">Node</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Loading...
</TableCell>
</TableRow>
{expandedId === entry.id && (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30 px-6 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground text-xs block">Request Path</span>
<span className="font-mono text-xs">{entry.path}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">IP Address</span>
<span className="font-mono text-xs tabular-nums">{entry.ip_address || '-'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Node ID</span>
<span className="font-mono text-xs tabular-nums">{entry.node_id ?? 'Local'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Entry ID</span>
<span className="font-mono text-xs tabular-nums">#{entry.id}</span>
</div>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground font-mono tabular-nums">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
) : entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No audit log entries found.
</TableCell>
</TableRow>
) : (
entries.map((entry) => (
<Fragment key={entry.id}>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
>
<TableCell className="text-xs text-muted-foreground font-mono tabular-nums">
{new Date(entry.timestamp).toLocaleString()}
</TableCell>
<TableCell className="font-medium text-sm">
{entry.username}
</TableCell>
<TableCell>
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
{entry.method}
</Badge>
</TableCell>
<TableCell className="text-sm">
{entry.summary}
</TableCell>
<TableCell className={`text-sm font-mono tabular-nums ${statusColor(entry.status_code)}`}>
{entry.status_code}
</TableCell>
<TableCell className="text-sm text-muted-foreground font-mono tabular-nums">
{entry.node_id ?? '-'}
</TableCell>
</TableRow>
{expandedId === entry.id && (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30 px-6 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground text-xs block">Request Path</span>
<span className="font-mono text-xs">{entry.path}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">IP Address</span>
<span className="font-mono text-xs tabular-nums">{entry.ip_address || '-'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Node ID</span>
<span className="font-mono text-xs tabular-nums">{entry.node_id ?? 'Local'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Entry ID</span>
<span className="font-mono text-xs tabular-nums">#{entry.id}</span>
</div>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
))
)}
</TableBody>
</Table>
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground font-mono tabular-nums">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}
interface StreamViewProps {
stats: AuditStats | null;
loading: boolean;
groups: { key: string; day: number; entries: AuditEntry[] }[];
now: number;
page: number;
totalPages: number;
onPage: (n: number) => void;
}
function StreamView({ stats, loading, groups, now, page, totalPages, onPage }: StreamViewProps) {
const tiles: AuditStatTile[] = stats
? [stats.events_24h, stats.actors_24h, stats.failure_rate, stats.unusual_hour]
: [];
const failures = stats?.failures_by_hour ?? [];
const hasFailurePoints = failures.some(f => f > 0);
return (
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-0 rounded-md border border-card-border bg-card/40 overflow-hidden">
{tiles.length === 0 ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="px-4 py-3 border-r last:border-r-0 border-card-border">
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle mb-1">&nbsp;</div>
<div className="h-8 flex items-center">
<span className="text-stat-subtitle text-xs font-mono">·</span>
</div>
<div className="h-3.5" />
</div>
))
) : (
tiles.map((tile, idx) => (
<div key={tile.label} className="px-4 py-3 border-b last:border-b-0 sm:border-b-0 sm:[&:nth-child(-n+2)]:border-b lg:[&:nth-child(-n+2)]:border-b-0 border-r last:border-r-0 border-card-border">
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle mb-1">
{tile.label}
</div>
<div className="flex items-end gap-2">
<span className={`font-display italic text-3xl leading-none tabular-nums ${SEVERITY_TEXT[tile.severity]}`}>
{tile.value === null
? '·'
: idx === 2
? `${tile.value}%`
: idx === 3
? `${String(tile.value).padStart(2, '0')}:00`
: tile.value}
</span>
{idx === 2 && hasFailurePoints && (
<div className="w-14 h-6 ml-auto">
<Sparkline
points={failures}
stroke={tile.severity === 'alert' ? 'var(--destructive)' : tile.severity === 'warn' ? 'var(--warning)' : 'var(--brand)'}
fill={tile.severity === 'alert' ? 'var(--destructive)' : tile.severity === 'warn' ? 'var(--warning)' : 'var(--brand)'}
showPeak={false}
/>
</div>
)}
</div>
<div className="mt-1 text-[11px] font-mono text-stat-subtitle">
{tile.detail ?? '\u00a0'}
</div>
</div>
))
)}
</div>
{loading ? (
<div className="py-10 text-center text-muted-foreground text-sm">Loading audit stream...</div>
) : groups.length === 0 ? (
<div className="py-10 text-center text-muted-foreground text-sm">No audit log entries found.</div>
) : (
<div className="space-y-5">
{groups.map(group => (
<div key={group.key} className="space-y-1">
<div className="flex items-baseline gap-2 pb-1 border-b border-card-border">
<span className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle">
{formatDayBanner(group.day)}
</span>
<span className="text-[10px] font-mono text-stat-subtitle/70">
{group.entries.length} {group.entries.length === 1 ? 'event' : 'events'}
</span>
</div>
<div className="divide-y divide-card-border/40">
{group.entries.map(entry => (
<StreamRow key={entry.id} entry={entry} now={now} />
))}
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-muted-foreground font-mono tabular-nums">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => onPage(Math.max(1, page - 1))} disabled={page <= 1}>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={() => onPage(Math.min(totalPages, page + 1))} disabled={page >= totalPages}>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
)}
</div>
);
}
interface StreamRowProps {
entry: AuditEntry;
now: number;
}
function StreamRow({ entry, now }: StreamRowProps) {
const severity = entrySeverity(entry.status_code);
const [verb, ...rest] = entry.summary.split(' ');
const target = rest.join(' ');
const flags = entry.flags ?? [];
const rowTint =
severity === 'alert' ? 'bg-destructive/4' :
severity === 'warn' ? 'bg-warning/4' :
'';
return (
<div className={`grid grid-cols-[72px_10px_1fr_auto] gap-3 items-start px-2 py-2 rounded-sm ${rowTint}`}>
<div className="text-[11px] font-mono text-stat-subtitle tabular-nums leading-4 pt-0.5">
{formatRelative(entry.timestamp, now)}
</div>
<div className="pt-2">
<div className={`w-2 h-2 rounded-full ${SEVERITY_DOT[severity]}`} />
</div>
<div className="min-w-0">
<div className="text-sm leading-snug">
<span className="font-semibold">{entry.username || 'system'}</span>
<span className="text-muted-foreground"> {verb.toLowerCase()} </span>
<span className="font-semibold">{target || entry.path}</span>
</div>
<div className="text-[11px] font-mono text-stat-subtitle mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="tabular-nums">{formatClock(entry.timestamp)}</span>
<span>·</span>
<span>{entry.node_id == null ? 'local' : `node ${entry.node_id}`}</span>
<span>·</span>
<span className={severity === 'alert' ? 'text-destructive' : severity === 'warn' ? 'text-warning' : ''}>
{entry.status_code}
</span>
{entry.ip_address && (
<>
<span>·</span>
<span className="tabular-nums">{entry.ip_address}</span>
</>
)}
{flags.map(flag => (
<span key={flag} className="text-warning">· {FLAG_LABEL[flag]}</span>
))}
</div>
</div>
<div className="text-[10px] font-mono uppercase tracking-wider text-stat-subtitle pt-1 whitespace-nowrap">
{entry.method} {entry.path.split('?')[0]}
</div>
</div>
);
}