import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getAuditEvents } from '../api/client'; import PageHeader from '../components/PageHeader'; import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; import ErrorState from '../components/ErrorState'; import { formatDateTime } from '../api/utils'; import type { AuditEvent } from '../api/types'; const actionColors: Record = { certificate_created: 'text-emerald-600', renewal_triggered: 'text-brand-500', renewal_job_created: 'text-brand-500', renewal_completed: 'text-emerald-600', deployment_completed: 'text-emerald-600', deployment_failed: 'text-red-600', expiration_alert_sent: 'text-amber-600', agent_registered: 'text-brand-500', policy_violated: 'text-red-600', certificate_revoked: 'text-red-600', }; const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer']; const TIME_RANGES = [ { label: 'All time', value: '' }, { label: 'Last hour', value: '1h' }, { label: 'Last 24h', value: '24h' }, { label: 'Last 7 days', value: '7d' }, { label: 'Last 30 days', value: '30d' }, ]; function downloadFile(content: string, filename: string, type: string) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function exportCSV(events: AuditEvent[]) { const headers = ['ID', 'Action', 'Actor', 'Actor Type', 'Resource Type', 'Resource ID', 'Details', 'Timestamp']; const rows = events.map(e => [ e.id, e.action, e.actor, e.actor_type, e.resource_type, e.resource_id, JSON.stringify(e.details || {}), e.timestamp, ]); const csv = [headers, ...rows].map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n'); downloadFile(csv, `audit-trail-${new Date().toISOString().slice(0, 10)}.csv`, 'text/csv'); } function exportJSON(events: AuditEvent[]) { const json = JSON.stringify(events, null, 2); downloadFile(json, `audit-trail-${new Date().toISOString().slice(0, 10)}.json`, 'application/json'); } // Bundle 1 Phase 8 + Phase 10 — event_category filter exposed via the // category query param. Allowed values match the server's CHECK // constraint; the auditor role uses category=auth to surface only // authentication / authorization rows. const CATEGORIES = [ { label: 'All categories', value: '' }, { label: 'Cert lifecycle', value: 'cert_lifecycle' }, { label: 'Auth', value: 'auth' }, { label: 'Config', value: 'config' }, ]; export default function AuditPage() { const [resourceType, setResourceType] = useState(''); const [actorFilter, setActorFilter] = useState(''); const [timeRange, setTimeRange] = useState(''); const [actionFilter, setActionFilter] = useState(''); const [category, setCategory] = useState(''); const params: Record = {}; if (resourceType) params.resource_type = resourceType; if (actorFilter) params.actor = actorFilter; if (actionFilter) params.action = actionFilter; if (category) params.category = category; // P-H2 closure (frontend-design-audit 2026-05-14): translate the // TIME_RANGES dropdown selection into an RFC3339 `since` server // param. Pre-P-H2 this filter was applied client-side AFTER fetching // the entire event window, throwing 99% of rows away in JS; the // server-side handler now accepts `since` (and `until`) and the // audit_events table has a (event_category, timestamp DESC) // composite index that makes the predicate hit an index scan. // // We send only `since`; the "last N units" semantic is implicit // (until=now), so the operator gets a rolling window from the // selected age until the moment the server reads the param. if (timeRange) { const hours = timeRange === '1h' ? 1 : timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720; params.since = new Date(Date.now() - hours * 3600 * 1000).toISOString(); } const { data, isLoading, error, refetch } = useQuery({ queryKey: ['audit', params], queryFn: () => getAuditEvents(params), refetchInterval: 30000, }); // P-H2: server now applies the time-range predicate. data.data IS // the filtered set; no client-side trimming needed. The pre-P-H2 // `filtered` block (commented out below for diff-clarity) used to // walk every row and discard 99% — that's the bug P-H2 closes. const filtered = data?.data || []; const columns: Column[] = [ { key: 'action', label: 'Action', render: (e) => ( {e.action.replace(/_/g, ' ')} ), }, { key: 'actor', label: 'Actor', render: (e) => (
{e.actor}
{e.actor_type}
), }, { key: 'resource', label: 'Resource', render: (e) => (
{e.resource_type}
{e.resource_id}
), }, { key: 'details', label: 'Details', render: (e) => { if (!e.details || Object.keys(e.details).length === 0) return ; return ( {JSON.stringify(e.details).slice(0, 60)} ); }, }, { key: 'time', label: 'Time', render: (e) => {formatDateTime(e.timestamp)} }, ]; const hasFilters = resourceType || actorFilter || timeRange || actionFilter || category; return ( <> 0 ? (
) : undefined } />
setActorFilter(e.target.value)} className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 w-40" /> setActionFilter(e.target.value)} className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 w-40" /> {hasFilters && ( )}
{error ? ( refetch()} /> ) : ( )}
); }