mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
8d9e6574cc
* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette Turn the "too intense / italic headers hurt / the security graph fights my eyes" feedback into a token-driven Visual style with Calm as the new default and Signature one click back to the prior look. - Heading family routes through a `.font-heading` utility driven by `--font-heading`/`--heading-style`: operational headings render upright in the interface face under Calm and italic Instrument Serif under Signature. Base rule sets family + style only, so each call site keeps its own weight/tracking and Signature stays a true no-op; the Calm lift is a `[data-headings="clean"]` descendant rule. Brand lockup, empty-state heroes, and onboarding stay serif. - Severity charts resolve through `--sev-*` tokens with Muted, Heat, and Signature palettes; FindingsByType routes its series through the severity ramp plus a neutral so no brand-cyan sits next to rose. The risk trend flattens its gradient under Muted/Heat/reduced and keeps the gradient under Signature. - Appearance settings gain Visual style cards, a Security visualization palette, a Readability master toggle, a Motion & effects group, and a "Reset to default" button (restores the Calm axes, disabled while readability is on). Contrast moves under Readability and Ambient glow under Motion & effects. A card is selected only while the stored sub-axes match its preset, so a custom combination de-selects both. - The topbar Theme quick-switch swaps the interface/data font pickers for a Visual style switch and a Readability toggle (text size kept); its footer Settings link jumps straight to Appearance. - Readability is a sticky master that forces the calm resolution and a contrast lift at apply time without mutating the stored sub-axes. - New users default to Calm; any pre-existing persisted appearance state keeps the Signature look. The pre-paint script mirrors the store. - SegmentedControl gains a `disabled` prop and a nullable value (no active segment for a custom combination, with a roving-tabindex keyboard anchor). Adds unit/component coverage for the store, migration, chart shape logic, the disabled control, the reset/de-selection, and the quick-switch. * fix(appearance): migrate Blueprint serif headings and surface readability locks - Migrate the two operational Blueprint headings (catalog tile name, drift-policy option title) from font-serif italic to the .font-heading utility; the first pass only covered font-display, so Calm still left these italic. font-serif and font-display both resolve to the same display face, so this is the same fix. - Lock the Visual style cards under Readability (parity with the topbar switch and the on-screen guidance to turn Readability off to choose a style by hand). - Lock the Border brightness slider under Readability and show its forced +0.03 readout, since Readability overrides the stored value; dragging it previously appeared to do nothing. - Correct the Appearance docs sentence for the topbar quick switch (it listed fonts; the quick switch now carries visual style, readability, and text size).
704 lines
35 KiB
TypeScript
704 lines
35 KiB
TypeScript
import { useState, useEffect, useCallback, Fragment, useMemo, type ReactNode } 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';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
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 { 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';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
|
import { Masthead, MobileSubTabs } from '@/components/mobile/mobile-ui';
|
|
|
|
type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor';
|
|
|
|
interface AuditEntry {
|
|
id: number;
|
|
timestamp: number;
|
|
username: string;
|
|
method: string;
|
|
path: string;
|
|
status_code: number;
|
|
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 = [
|
|
{ value: 'all', label: 'All Methods' },
|
|
{ value: 'POST', label: 'POST' },
|
|
{ value: 'PUT', label: 'PUT' },
|
|
{ value: 'DELETE', label: 'DELETE' },
|
|
{ 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()}`;
|
|
}
|
|
|
|
// These views fetch localOnly, so a 401 is the user's own expired session,
|
|
// which apiFetch already turns into a global logout. Skip the redundant
|
|
// load-failure toast in that case so logout does not stack toasts.
|
|
function isExpiredSession(err: unknown): boolean {
|
|
return err instanceof Error && err.message === 'Unauthorized';
|
|
}
|
|
|
|
interface AuditLogViewProps {
|
|
/** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */
|
|
headerActions?: ReactNode;
|
|
}
|
|
|
|
export function AuditLogView({ headerActions }: AuditLogViewProps = {}) {
|
|
const isMobile = useIsMobile();
|
|
// Community gets the recent-activity stream; CSV/JSON export, the stat
|
|
// tiles, and per-row anomaly annotation are paid. The backend clamps the
|
|
// list to a 14-day window for unpaid tiers, so this flag only governs the
|
|
// export, stats, and anomaly-annotation affordances, not the list itself.
|
|
const { isPaid } = useLicense();
|
|
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);
|
|
if (methodFilter !== 'all') params.set('method', methodFilter);
|
|
if (fromDate) {
|
|
const start = new Date(fromDate);
|
|
start.setHours(0, 0, 0, 0);
|
|
params.set('from', String(start.getTime()));
|
|
}
|
|
if (toDate) {
|
|
const end = new Date(toDate);
|
|
end.setHours(23, 59, 59, 999);
|
|
params.set('to', String(end.getTime()));
|
|
}
|
|
return params;
|
|
}, [searchFilter, methodFilter, fromDate, toDate]);
|
|
|
|
const fetchLogs = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = buildFilterParams();
|
|
params.set('page', String(page));
|
|
params.set('limit', String(limit));
|
|
if (isPaid) params.set('with_anomalies', '1');
|
|
|
|
const res = await apiFetch(`/audit-log?${params}`, { localOnly: true });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setEntries(data.entries);
|
|
setTotal(data.total);
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || 'Failed to load audit log.');
|
|
}
|
|
} catch (err) {
|
|
console.error('[AuditLog] Failed to fetch:', err);
|
|
if (!isExpiredSession(err)) {
|
|
toast.error('Failed to load audit log.');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, buildFilterParams, isPaid]);
|
|
|
|
const fetchStats = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/audit-log/stats', { localOnly: true });
|
|
if (res.ok) {
|
|
setStats(await res.json());
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || 'Failed to load audit stats.');
|
|
}
|
|
} catch (err) {
|
|
console.error('[AuditLog] Failed to fetch stats:', err);
|
|
if (!isExpiredSession(err)) {
|
|
toast.error('Failed to load audit stats.');
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
}, [fetchLogs]);
|
|
|
|
useEffect(() => {
|
|
if (view === 'stream' && isPaid) fetchStats();
|
|
}, [view, fetchStats, isPaid]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
|
|
|
const methodBadgeVariant = (method: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
|
|
switch (method) {
|
|
case 'POST': return 'default';
|
|
case 'PUT': case 'PATCH': return 'secondary';
|
|
case 'DELETE': return 'destructive';
|
|
default: return 'outline';
|
|
}
|
|
};
|
|
|
|
const statusColor = (code: number): string => {
|
|
if (code >= 200 && code < 300) return 'text-success';
|
|
if (code >= 400 && code < 500) return 'text-warning';
|
|
if (code >= 500) return 'text-destructive';
|
|
return 'text-muted-foreground';
|
|
};
|
|
|
|
const handleExport = async (format: 'csv' | 'json') => {
|
|
try {
|
|
const params = buildFilterParams();
|
|
params.set('format', format);
|
|
const res = await apiFetch(`/audit-log/export?${params}`, { localOnly: true });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || 'Export failed.');
|
|
return;
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.${format === 'csv' ? 'csv' : 'json'}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('[AuditLog] Export failed:', err);
|
|
toast.error('Export failed.');
|
|
}
|
|
};
|
|
|
|
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]);
|
|
|
|
if (isMobile) {
|
|
// Derive a health-style state word from the (paid) failure-rate signal,
|
|
// matching the Home/Fleet/Security masthead standard.
|
|
const auditSeverity = isPaid ? stats?.failure_rate.severity : undefined;
|
|
const auditState = !auditSeverity ? 'Activity' : auditSeverity === 'alert' ? 'Alerts' : auditSeverity === 'warn' ? 'Review' : 'Healthy';
|
|
const auditTone = auditSeverity === 'alert' ? 'destructive' : auditSeverity === 'warn' ? 'warning' : auditSeverity === 'ok' ? 'success' : 'brand';
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<Masthead
|
|
kicker="audit · 24h"
|
|
state={auditState}
|
|
stateTone={auditTone}
|
|
live={auditSeverity === 'alert' || auditSeverity === 'warn'}
|
|
meta={`${total} ${total === 1 ? 'entry' : 'entries'}${isPaid && stats ? ` · ${stats.actors_24h.value} actors` : ''}`}
|
|
right={headerActions}
|
|
/>
|
|
<MobileSubTabs
|
|
tabs={[{ value: 'stream' as const, label: 'Stream' }, { value: 'table' as const, label: 'Table' }]}
|
|
active={view}
|
|
onSelect={setView}
|
|
ariaLabel="Audit views"
|
|
/>
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
|
<div className="mb-3 flex justify-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5"
|
|
onClick={() => { fetchLogs(); if (view === 'stream' && isPaid) fetchStats(); }}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
{view === 'stream' ? (
|
|
<StreamView
|
|
stats={stats}
|
|
showStats={isPaid}
|
|
loading={loading && entries.length === 0}
|
|
groups={groupedEntries}
|
|
now={now}
|
|
totalPages={totalPages}
|
|
page={page}
|
|
onPage={setPage}
|
|
/>
|
|
) : (
|
|
<div className="py-16 text-center">
|
|
<p className="font-display italic text-xl text-stat-value">Table view</p>
|
|
<p className="mx-auto mt-2 max-w-[260px] font-mono text-[11px] leading-[17px] text-stat-subtitle">
|
|
The columnar table reads best on a larger screen. The Stream tab shows the same activity, reflowed for mobile.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<ScrollText className="w-5 h-5" strokeWidth={1.5} />
|
|
<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>
|
|
{isPaid && (
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="border-border">
|
|
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
|
Export
|
|
<ChevronDown className="w-3 h-3 ml-1" strokeWidth={1.5} />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => handleExport('csv')}>Export as CSV</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
<Button variant="outline" size="sm" className="border-border" onClick={() => { fetchLogs(); if (view === 'stream' && isPaid) fetchStats(); }} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{view === 'stream' ? (
|
|
<StreamView
|
|
stats={stats}
|
|
showStats={isPaid}
|
|
loading={loading && entries.length === 0}
|
|
groups={groupedEntries}
|
|
now={now}
|
|
totalPages={totalPages}
|
|
page={page}
|
|
onPage={setPage}
|
|
/>
|
|
) : (
|
|
<>
|
|
<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>
|
|
|
|
<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 ?? '-'}
|
|
</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>
|
|
|
|
{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;
|
|
showStats: boolean;
|
|
loading: boolean;
|
|
groups: { key: string; day: number; entries: AuditEntry[] }[];
|
|
now: number;
|
|
page: number;
|
|
totalPages: number;
|
|
onPage: (n: number) => void;
|
|
}
|
|
|
|
function StreamView({ stats, showStats, 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">
|
|
{showStats && (
|
|
<div className="grid grid-cols-1 max-md:grid-cols-2 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-heading 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-[var(--density-cell-y)] 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] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle pt-1 whitespace-nowrap">
|
|
{entry.method} {entry.path.split('?')[0]}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|