mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
+35
-1
@@ -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;
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
function buildBaselines(history: AuditLogEntry[], now: number): Map<string, ActorBaseline> {
|
||||
const baselines = new Map<string, ActorBaseline>();
|
||||
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<string, Set<string>>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): number {
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/audit-log/audit-log-overview.png" alt="Audit Log view showing the action table with search filters, color-coded method badges, and export controls" />
|
||||
</Frame>
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/audit-log/audit-log-expanded.png" alt="Audit Log with an expanded row showing request path, IP address, node ID, and entry ID" />
|
||||
<img src="/images/audit-log/audit-stream.png" alt="Audit Log Stream view showing the signal rail with events, actors, failure rate, and peak hour tiles above a day-banded chronological feed" />
|
||||
</Frame>
|
||||
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/audit-log/audit-log-overview.png" alt="Audit Log Table view showing the action table with search filters, color-coded method badges, and export controls" />
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/audit-log/audit-log-expanded.png" alt="Audit Log Table view with an expanded row showing request path, IP address, node ID, and entry ID" />
|
||||
</Frame>
|
||||
|
||||
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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -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"> </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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user