diff --git a/backend/src/__tests__/audit-anomaly-service.test.ts b/backend/src/__tests__/audit-anomaly-service.test.ts new file mode 100644 index 00000000..e1db5a33 --- /dev/null +++ b/backend/src/__tests__/audit-anomaly-service.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest'; +import { + annotateEntries, + computeAuditStats, + isUnusualHour, +} from '../services/AuditAnomalyService'; +import type { AuditLogEntry } from '../services/DatabaseService'; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +function entry(overrides: Partial = {}): AuditLogEntry { + return { + id: 0, + timestamp: Date.now(), + username: 'alice', + method: 'POST', + path: '/api/stacks/deploy', + status_code: 200, + node_id: null, + ip_address: '10.0.0.1', + summary: 'Deployed stack web', + ...overrides, + }; +} + +describe('AuditAnomalyService - isUnusualHour', () => { + it('returns false when baseline is too small to trust', () => { + expect(isUnusualHour(3, [9, 10, 11])).toBe(false); + }); + + it('returns false when hour is inside the actor typical range', () => { + const baseline = [9, 10, 10, 11, 11, 12, 13, 14, 15, 16]; + expect(isUnusualHour(11, baseline)).toBe(false); + }); + + it('returns true when hour is well outside the baseline', () => { + const baseline = [9, 10, 10, 11, 11, 12, 13, 14, 15, 16]; + expect(isUnusualHour(3, baseline)).toBe(true); + }); +}); + +describe('AuditAnomalyService - annotateEntries', () => { + it('flags first_seen_actor when the actor has no prior history', () => { + const now = Date.now(); + const current = [entry({ id: 1, timestamp: now, username: 'newbie' })]; + const result = annotateEntries(current, [], now); + expect(result[0].flags).toContain('first_seen_actor'); + }); + + it('does not flag first_seen_actor when actor appears in history', () => { + const now = Date.now(); + const history = [entry({ id: 1, timestamp: now - DAY, username: 'alice' })]; + const current = [entry({ id: 2, timestamp: now, username: 'alice' })]; + const result = annotateEntries(current, history, now); + expect(result[0].flags).not.toContain('first_seen_actor'); + }); + + it('flags new_ip when actor has history but not from this ip', () => { + const now = Date.now(); + const history = Array.from({ length: 6 }, (_, i) => + entry({ id: i + 1, timestamp: now - (i + 1) * HOUR, ip_address: '10.0.0.1' }) + ); + const current = [entry({ id: 99, timestamp: now, ip_address: '45.76.1.2' })]; + const result = annotateEntries(current, history, now); + expect(result[0].flags).toContain('new_ip'); + }); + + it('does not flag new_ip when ip matches historical value', () => { + const now = Date.now(); + const history = [entry({ id: 1, timestamp: now - HOUR, ip_address: '10.0.0.1' })]; + const current = [entry({ id: 2, timestamp: now, ip_address: '10.0.0.1' })]; + const result = annotateEntries(current, history, now); + expect(result[0].flags).not.toContain('new_ip'); + }); + + it('ignores ips older than the 30-day window when scoring new_ip', () => { + const now = Date.now(); + const history = [ + entry({ id: 1, timestamp: now - 45 * DAY, ip_address: '10.0.0.9' }), + entry({ id: 2, timestamp: now - 2 * DAY, ip_address: '10.0.0.1' }), + ]; + const current = [entry({ id: 3, timestamp: now, ip_address: '10.0.0.9' })]; + const result = annotateEntries(current, history, now); + expect(result[0].flags).toContain('new_ip'); + }); + + it('flags unusual_hour when entry falls outside the 7-day hour distribution', () => { + const now = new Date('2026-04-18T03:15:00Z').getTime(); + const history = Array.from({ length: 10 }, (_, i) => { + const ts = new Date('2026-04-15T10:00:00Z').getTime() + i * HOUR * 0.5; + return entry({ id: i + 1, timestamp: ts, ip_address: '10.0.0.1' }); + }); + const current = [entry({ id: 99, timestamp: now })]; + const result = annotateEntries(current, history, now); + expect(result[0].flags).toContain('unusual_hour'); + }); + + it('does not flag unusual_hour when baseline is smaller than the minimum', () => { + const now = Date.now(); + const history = [entry({ id: 1, timestamp: now - HOUR })]; + const current = [entry({ id: 2, timestamp: now })]; + const result = annotateEntries(current, history, now); + expect(result[0].flags).not.toContain('unusual_hour'); + }); + + it('returns empty flags for entries without a username', () => { + const now = Date.now(); + const current = [entry({ id: 1, username: '' })]; + const result = annotateEntries(current, [], now); + expect(result[0].flags).toEqual([]); + }); +}); + +describe('AuditAnomalyService - computeAuditStats', () => { + function buildEntries(now: number): { last24h: AuditLogEntry[]; last7d: AuditLogEntry[]; last30d: AuditLogEntry[] } { + const last24h: AuditLogEntry[] = []; + for (let i = 0; i < 20; i++) { + last24h.push(entry({ + id: i + 1, + timestamp: now - i * HOUR, + status_code: i < 3 ? 500 : 200, + ip_address: i === 5 ? '45.76.1.2' : '10.0.0.1', + })); + } + const older: AuditLogEntry[] = []; + for (let i = 0; i < 60; i++) { + older.push(entry({ + id: 100 + i, + timestamp: now - DAY - i * HOUR, + ip_address: '10.0.0.1', + })); + } + return { + last24h, + last7d: [...last24h, ...older.filter(e => e.timestamp >= now - 7 * DAY)], + last30d: [...last24h, ...older], + }; + } + + it('summarizes events, actors, failures, and peak hour', () => { + const now = new Date('2026-04-18T12:00:00Z').getTime(); + const { last24h, last7d, last30d } = buildEntries(now); + const stats = computeAuditStats({ now, last24h, last7d, last30d }); + expect(stats.events_24h.value).toBe(20); + expect(stats.actors_24h.value).toBe(1); + expect(stats.failure_rate.value).toBe(15); + expect(stats.activity_by_hour).toHaveLength(24); + expect(stats.activity_by_hour.reduce((a, b) => a + b, 0)).toBe(20); + }); + + it('flags the new_ip detail when an actor uses an ip not seen in prior 29 days', () => { + const now = new Date('2026-04-18T12:00:00Z').getTime(); + const { last24h, last7d, last30d } = buildEntries(now); + const stats = computeAuditStats({ now, last24h, last7d, last30d }); + expect(stats.actors_24h.detail).toMatch(/new ip/); + }); + + it('surfaces peak hour when it falls outside working hours', () => { + const now = new Date(2026, 3, 18, 12, 0, 0).getTime(); + const nightBase = new Date(2026, 3, 18, 3, 15, 0).getTime(); + const nightEntries: AuditLogEntry[] = Array.from({ length: 10 }, (_, i) => + entry({ id: i + 1, timestamp: nightBase - i * 5000 }) + ); + const stats = computeAuditStats({ now, last24h: nightEntries, last7d: nightEntries, last30d: nightEntries }); + expect(stats.unusual_hour.severity).toBe('warn'); + expect(stats.unusual_hour.value).toBe(3); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 9658893c..412e6ee3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 => 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 => } }); +app.get('/api/audit-log/stats', async (req: Request, res: Response): Promise => { + 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 => { if (!requireAdmiral(req, res)) return; if (!requirePermission(req, res, 'system:audit')) return; diff --git a/backend/src/services/AuditAnomalyService.ts b/backend/src/services/AuditAnomalyService.ts new file mode 100644 index 00000000..2a6dae73 --- /dev/null +++ b/backend/src/services/AuditAnomalyService.ts @@ -0,0 +1,180 @@ +import type { AuditLogEntry } from './DatabaseService'; + +export type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor'; + +export const HISTORY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +const HOUR_BASELINE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_HOURS_FOR_BASELINE = 5; + +/** + * Returns true when `hour` sits outside the central 90% of the actor's + * typical activity window. Requires a minimum baseline to avoid flagging + * actors whose first few logins happen to be during off-hours. + */ +export function isUnusualHour(hour: number, baselineHours: number[]): boolean { + if (baselineHours.length < MIN_HOURS_FOR_BASELINE) return false; + const sorted = [...baselineHours].sort((a, b) => a - b); + const lo = sorted[Math.floor(sorted.length * 0.05)]; + const hi = sorted[Math.floor(sorted.length * 0.95)]; + return hour < lo || hour > hi; +} + +interface ActorBaseline { + hoursLast7d: number[]; + ipsLast30d: Set; +} + +function buildBaselines(history: AuditLogEntry[], now: number): Map { + const baselines = new Map(); + const hourCutoff = now - HOUR_BASELINE_WINDOW_MS; + const ipCutoff = now - HISTORY_WINDOW_MS; + + for (const entry of history) { + if (!entry.username) continue; + let b = baselines.get(entry.username); + if (!b) { + b = { hoursLast7d: [], ipsLast30d: new Set() }; + baselines.set(entry.username, b); + } + if (entry.timestamp >= hourCutoff) { + b.hoursLast7d.push(new Date(entry.timestamp).getHours()); + } + if (entry.timestamp >= ipCutoff && entry.ip_address) { + b.ipsLast30d.add(entry.ip_address); + } + } + return baselines; +} + +/** + * Annotate a page of entries with anomaly flags computed against strictly + * prior history. The caller is responsible for supplying history entries + * that do NOT overlap with the entries being annotated; typically pull + * entries where `timestamp < min(entries.timestamp)` from the last 30 days. + */ +export interface AuditStatTile { + value: number | null; + label: string; + detail: string | null; + severity: 'ok' | 'warn' | 'alert'; +} + +export interface AuditStats { + events_24h: AuditStatTile; + actors_24h: AuditStatTile; + failure_rate: AuditStatTile; + unusual_hour: AuditStatTile; + activity_by_hour: number[]; + failures_by_hour: number[]; +} + +export function computeAuditStats(input: { + now: number; + last24h: AuditLogEntry[]; + last7d: AuditLogEntry[]; + last30d: AuditLogEntry[]; +}): AuditStats { + const { now, last24h, last7d, last30d } = input; + const events24 = last24h.length; + const prior7d = last7d.length - events24; + const avg7dPerDay = Math.max(0, prior7d) / 6; + const deltaPct = avg7dPerDay > 0 ? Math.round(((events24 - avg7dPerDay) / avg7dPerDay) * 100) : null; + + const actors24 = new Set(last24h.map(e => e.username).filter(Boolean)); + const olderIpByActor = new Map>(); + for (const e of last30d) { + if (!e.username || !e.ip_address) continue; + if (e.timestamp >= now - 24 * 60 * 60 * 1000) continue; + let set = olderIpByActor.get(e.username); + if (!set) { set = new Set(); olderIpByActor.set(e.username, set); } + set.add(e.ip_address); + } + let newIpCount = 0; + let sampleNewIpActor: string | null = null; + for (const e of last24h) { + if (!e.username || !e.ip_address) continue; + const prior = olderIpByActor.get(e.username); + if (prior && prior.size > 0 && !prior.has(e.ip_address)) { + newIpCount++; + if (!sampleNewIpActor) sampleNewIpActor = e.username; + } + } + + const failureCount = last24h.filter(e => e.status_code >= 400).length; + const failureRate = events24 > 0 ? failureCount / events24 : 0; + const failurePct = Math.round(failureRate * 100); + + const activityByHour = Array.from({ length: 24 }, () => 0); + const failuresByHour = Array.from({ length: 24 }, () => 0); + for (const e of last24h) { + const hour = new Date(e.timestamp).getHours(); + activityByHour[hour]++; + if (e.status_code >= 400) failuresByHour[hour]++; + } + const peakHour = activityByHour.reduce( + (best, count, hour) => (count > best.count ? { count, hour } : best), + { count: -1, hour: 0 } + ); + const peakIsOffHours = peakHour.count > 0 && (peakHour.hour < 8 || peakHour.hour >= 18); + + return { + events_24h: { + value: events24, + label: 'events · 24h', + detail: deltaPct === null ? 'no 7d baseline yet' : `${deltaPct >= 0 ? '+' : ''}${deltaPct}% vs 7d avg`, + severity: deltaPct !== null && deltaPct > 150 ? 'warn' : 'ok', + }, + actors_24h: { + value: actors24.size, + label: 'actors', + detail: newIpCount > 0 + ? `${newIpCount} new ip${newIpCount === 1 ? '' : 's'}${sampleNewIpActor ? ` · ${sampleNewIpActor}` : ''}` + : null, + severity: newIpCount > 0 ? 'warn' : 'ok', + }, + failure_rate: { + value: failurePct, + label: 'failure rate', + detail: `${failureCount} of ${events24} request${events24 === 1 ? '' : 's'}`, + severity: failurePct >= 20 ? 'alert' : failurePct >= 5 ? 'warn' : 'ok', + }, + unusual_hour: { + value: peakIsOffHours ? peakHour.hour : null, + label: 'peak hour', + detail: peakIsOffHours + ? `${peakHour.count} event${peakHour.count === 1 ? '' : 's'} at ${String(peakHour.hour).padStart(2, '0')}:00` + : 'inside working hours', + severity: peakIsOffHours ? 'warn' : 'ok', + }, + activity_by_hour: activityByHour, + failures_by_hour: failuresByHour, + }; +} + +export function annotateEntries( + entries: AuditLogEntry[], + history: AuditLogEntry[], + now: number = Date.now() +): (AuditLogEntry & { flags: AnomalyFlag[] })[] { + const baselines = buildBaselines(history, now); + + return entries.map(entry => { + const flags: AnomalyFlag[] = []; + if (!entry.username) return { ...entry, flags }; + + const baseline = baselines.get(entry.username); + if (!baseline) { + flags.push('first_seen_actor'); + } else { + const entryHour = new Date(entry.timestamp).getHours(); + if (isUnusualHour(entryHour, baseline.hoursLast7d)) { + flags.push('unusual_hour'); + } + if (entry.ip_address && baseline.ipsLast30d.size > 0 && !baseline.ipsLast30d.has(entry.ip_address)) { + flags.push('new_ip'); + } + } + + return { ...entry, flags }; + }); +} diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 66ffbc1e..21a7e018 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -2066,6 +2066,12 @@ export class DatabaseService { this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff); } + public getAuditLogsInRange(from: number, to: number): AuditLogEntry[] { + return this.db.prepare( + 'SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC' + ).all(from, to) as AuditLogEntry[]; + } + // --- API Tokens --- public addApiToken(token: Omit): number { diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 35815f57..edec2174 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -61,19 +61,56 @@ Expanding a row reveals additional detail: Navigate to the **Audit** tab in the sidebar. This tab is visible to users with the **Admin** or **Auditor** role on an Admiral license. - - Audit Log view showing the action table with search filters, color-coded method badges, and export controls - +The Audit Log has two views, toggled from the header: **Stream** (default) and **Table**. -Click any row to expand it and see the full request details inline. +### Stream view + +Stream gives you an at-a-glance read on activity. A signal rail at the top summarizes the last 24 hours across four tiles, and the feed below groups entries by day with severity dots, relative times, and inline anomaly callouts. - Audit Log with an expanded row showing request path, IP address, node ID, and entry ID + Audit Log Stream view showing the signal rail with events, actors, failure rate, and peak hour tiles above a day-banded chronological feed -The header displays the total number of matching entries and provides **Refresh** and **Export** controls. +**Signal rail tiles:** -Results are paginated at 50 entries per page. Navigation controls appear at the bottom of the table when there are multiple pages. +| Tile | What it shows | +|------|---------------| +| **Events · 24h** | Count of audit entries in the last 24 hours, with a percent change versus the prior 7-day average | +| **Actors** | Unique users active in the last 24 hours; notes the count of new IP addresses when present | +| **Failure rate** | Share of 24-hour requests with 4xx or 5xx responses, rendered with an inline sparkline of the hourly failure trend | +| **Peak hour** | The hour with the highest activity, flagged in amber when it falls outside 08:00-18:00 | + +**Feed entries:** each row shows the relative time (e.g. `58m ago`), a severity dot (green for 2xx, amber for 3xx, rose for 4xx/5xx), the actor and action summary, a meta line with exact timestamp, node, status code, IP, and any anomaly flags, and the method and path on the right. + +Rows colored in rose or amber indicate failures; the tinting makes spikes of errors visible at a glance. + +### Table view + +Table keeps the full-featured detail grid for power users: exact timestamps, method badges, action summaries, and status codes in sortable columns. Clicking any row expands it to show the full request path, IP address, node ID, and entry ID. + + + Audit Log Table view showing the action table with search filters, color-coded method badges, and export controls + + + + Audit Log Table view with an expanded row showing request path, IP address, node ID, and entry ID + + +The header displays the total number of matching entries and provides **Refresh** and **Export** controls in both views. + +Results are paginated at 50 entries per page. Navigation controls appear at the bottom of the feed or table when there are multiple pages. + +## Anomaly detection + +In Stream view, Sencho annotates individual entries with lightweight anomaly flags that help you spot activity that deviates from an actor's normal pattern. Flags appear inline in the entry's meta line, after the IP address. + +| Flag | When it fires | +|------|---------------| +| **unusual hour** | The entry's hour sits outside the actor's typical activity window, computed from the central 90% of their last 7 days. Requires a baseline of at least 5 prior entries to avoid flagging new users. | +| **new ip** | The IP address on this entry has not been seen for this actor in the last 30 days, and the actor already has prior history. | +| **first seen** | The actor has no prior entries in the 30-day history window. Useful for spotting brand-new service accounts, CLI tools, or compromised sessions. | + +Flags are computed at read time against your existing audit history. No new tables, no per-entry storage overhead, and the logic is cache-friendly, so enabling Stream view does not slow down the audit log endpoint. ## Filtering and search diff --git a/docs/images/audit-log/audit-stream.png b/docs/images/audit-log/audit-stream.png new file mode 100644 index 00000000..add5c26d Binary files /dev/null and b/docs/images/audit-log/audit-stream.png differ diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index 8aa82dce..4bc1ca28 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -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 = { + 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([]); 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(null); const [searchFilter, setSearchFilter] = useState(''); const [methodFilter, setMethodFilter] = useState('all'); const [fromDate, setFromDate] = useState(); const [toDate, setToDate] = useState(); const [expandedId, setExpandedId] = useState(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 (
@@ -137,6 +245,26 @@ export function AuditLogView() { Audit Log
+
+ + +
@@ -161,141 +289,317 @@ export function AuditLogView() {

- {/* Filters */} -
-
- - { setSearchFilter(e.target.value); setPage(1); }} - className="pl-8" - /> -
- { setMethodFilter(v || 'all'); setPage(1); }} - placeholder="Method" - className="w-[140px]" + {view === 'stream' ? ( + - { setFromDate(d); setPage(1); }} - placeholder="From" - className="w-[160px]" - /> - { setToDate(d); setPage(1); }} - placeholder="To" - className="w-[160px]" - /> -
+ ) : ( + <> +
+
+ + { setSearchFilter(e.target.value); setPage(1); }} + className="pl-8" + /> +
+ { setMethodFilter(v || 'all'); setPage(1); }} + placeholder="Method" + className="w-[140px]" + /> + { setFromDate(d); setPage(1); }} + placeholder="From" + className="w-[160px]" + /> + { setToDate(d); setPage(1); }} + placeholder="To" + className="w-[160px]" + /> +
- {/* Table */} -
- - - - Timestamp - User - Method - Action - Status - Node - - - - {loading && entries.length === 0 ? ( - - - Loading... - - - ) : entries.length === 0 ? ( - - - No audit log entries found. - - - ) : ( - entries.map((entry) => ( - - setExpandedId(expandedId === entry.id ? null : entry.id)} - > - - {new Date(entry.timestamp).toLocaleString()} - - - {entry.username} - - - - {entry.method} - - - - {entry.summary} - - - {entry.status_code} - - - {entry.node_id ?? '-'} +
+
+ + + Timestamp + User + Method + Action + Status + Node + + + + {loading && entries.length === 0 ? ( + + + Loading... - {expandedId === entry.id && ( - - -
-
- Request Path - {entry.path} -
-
- IP Address - {entry.ip_address || '-'} -
-
- Node ID - {entry.node_id ?? 'Local'} -
-
- Entry ID - #{entry.id} -
-
-
-
- )} - - )) - )} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Page {page} of {totalPages} -

-
- - + ) : entries.length === 0 ? ( + + + No audit log entries found. + + + ) : ( + entries.map((entry) => ( + + setExpandedId(expandedId === entry.id ? null : entry.id)} + > + + {new Date(entry.timestamp).toLocaleString()} + + + {entry.username} + + + + {entry.method} + + + + {entry.summary} + + + {entry.status_code} + + + {entry.node_id ?? '-'} + + + {expandedId === entry.id && ( + + +
+
+ Request Path + {entry.path} +
+
+ IP Address + {entry.ip_address || '-'} +
+
+ Node ID + {entry.node_id ?? 'Local'} +
+
+ Entry ID + #{entry.id} +
+
+
+
+ )} +
+ )) + )} + +
-
+ + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} + )}
); } + +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 ( +
+
+ {tiles.length === 0 ? ( + Array.from({ length: 4 }).map((_, i) => ( +
+
 
+
+ · +
+
+
+ )) + ) : ( + tiles.map((tile, idx) => ( +
+
+ {tile.label} +
+
+ + {tile.value === null + ? '·' + : idx === 2 + ? `${tile.value}%` + : idx === 3 + ? `${String(tile.value).padStart(2, '0')}:00` + : tile.value} + + {idx === 2 && hasFailurePoints && ( +
+ +
+ )} +
+
+ {tile.detail ?? '\u00a0'} +
+
+ )) + )} +
+ + {loading ? ( +
Loading audit stream...
+ ) : groups.length === 0 ? ( +
No audit log entries found.
+ ) : ( +
+ {groups.map(group => ( +
+
+ + {formatDayBanner(group.day)} + + + {group.entries.length} {group.entries.length === 1 ? 'event' : 'events'} + +
+
+ {group.entries.map(entry => ( + + ))} +
+
+ ))} +
+ )} + + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +} + +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 ( +
+
+ {formatRelative(entry.timestamp, now)} +
+
+
+
+
+
+ {entry.username || 'system'} + {verb.toLowerCase()} + {target || entry.path} +
+
+ {formatClock(entry.timestamp)} + · + {entry.node_id == null ? 'local' : `node ${entry.node_id}`} + · + + {entry.status_code} + + {entry.ip_address && ( + <> + · + {entry.ip_address} + + )} + {flags.map(flag => ( + · {FLAG_LABEL[flag]} + ))} +
+
+
+ {entry.method} {entry.path.split('?')[0]} +
+
+ ); +}