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 = { 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([]); 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); 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 (
{view === 'stream' ? ( ) : (

Table view

The columnar table reads best on a larger screen. The Stream tab shows the same activity, reflowed for mobile.

)}
); } return (
Audit Log
{isPaid && ( handleExport('csv')}>Export as CSV handleExport('json')}>Export as JSON )}

Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`}

{view === 'stream' ? ( ) : ( <>
{ 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]" />
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 ?? '-'} {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; 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 (
{showStats && (
{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]}
); }