diff --git a/src/features/audit/AuditTimeline.tsx b/src/features/audit/AuditTimeline.tsx index 62cdea6..e11227e 100644 --- a/src/features/audit/AuditTimeline.tsx +++ b/src/features/audit/AuditTimeline.tsx @@ -1,6 +1,15 @@ 'use client'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table'; import { Search, FileJson, @@ -13,19 +22,26 @@ import { Bot, User, X, + ArrowDown, + ArrowUp, + ChevronLeft, + ChevronRight, + Calendar, + Filter, } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Dialog } from '@/components/ui/dialog'; -import { formatCurrency, formatRelativeTime, formatDate } from '@/lib/format'; +import { Button } from '@/components/ui/button'; +import { formatCurrency, formatRelativeTime, formatDateTime } from '@/lib/format'; import { MOCK_AUDIT_EVENTS } from './mock-data'; -import type { AuditEvent, AuditEventType, AuditLogLevel } from './types'; +import type { AuditEvent, AuditEventType, AuditLogLevel, AuditTimeRange } from './types'; const LEVEL_ICONS: Record = { - info: , - success: , - warning: , - error: , + info: , + success: , + warning: , + error: , }; const LEVEL_BADGE_VARIANTS: Record = { @@ -44,18 +60,52 @@ const EVENT_TYPE_LABELS: Record = { budget_cap: 'Budget Cap', }; +const TIME_RANGE_LABELS: Record = { + all: 'All Time', + '24h': 'Last 24 Hours', + '7d': 'Last 7 Days', + '30d': 'Last 30 Days', +}; + +function getTimeRangeCutoff(timeRange: AuditTimeRange): number | null { + if (timeRange === 'all') return null; + const now = Date.now(); + switch (timeRange) { + case '24h': + return now - 1000 * 60 * 60 * 24; + case '7d': + return now - 1000 * 60 * 60 * 24 * 7; + case '30d': + return now - 1000 * 60 * 60 * 24 * 30; + default: + return null; + } +} + export function AuditTimeline() { const [events] = useState(MOCK_AUDIT_EVENTS); const [searchQuery, setSearchQuery] = useState(''); - const [selectedEventType, setSelectedEventType] = useState('all'); - const [selectedLogLevel, setSelectedLogLevel] = useState('all'); - const [selectedTimeRange, setSelectedTimeRange] = useState('all'); + const [selectedEventType, setSelectedEventType] = useState('all'); + const [selectedLogLevel, setSelectedLogLevel] = useState('all'); + const [selectedTimeRange, setSelectedTimeRange] = useState('all'); + const [selectedActorId, setSelectedActorId] = useState('all'); const [inspectEvent, setInspectEvent] = useState(null); + const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + + const uniqueActors = useMemo(() => { + const actorMap = new Map(); + events.forEach((evt) => { + if (!actorMap.has(evt.actorId)) { + actorMap.set(evt.actorId, evt.actorName); + } + }); + return Array.from(actorMap.entries()).map(([id, name]) => ({ id, name })); + }, [events]); - // Filtered dataset const filteredEvents = useMemo(() => { + const cutoff = getTimeRangeCutoff(selectedTimeRange); return events.filter((evt) => { - // Search query matching summary, details, or actor if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); const matches = @@ -66,32 +116,24 @@ export function AuditTimeline() { (evt.project && evt.project.toLowerCase().includes(q)); if (!matches) return false; } - - // Event Type filter if (selectedEventType !== 'all' && evt.eventType !== selectedEventType) { return false; } - - // Log Level filter if (selectedLogLevel !== 'all' && evt.level !== selectedLogLevel) { return false; } - - // Time range filter - if (selectedTimeRange !== 'all') { - const now = Date.now(); + if (selectedActorId !== 'all' && evt.actorId !== selectedActorId) { + return false; + } + if (cutoff !== null) { const evtTime = new Date(evt.timestamp).getTime(); - if (selectedTimeRange === '24h' && now - evtTime > 1000 * 60 * 60 * 24) return false; - if (selectedTimeRange === '7d' && now - evtTime > 1000 * 60 * 60 * 24 * 7) return false; - if (selectedTimeRange === '30d' && now - evtTime > 1000 * 60 * 60 * 24 * 30) return false; + if (evtTime < cutoff) return false; } - return true; }); - }, [events, searchQuery, selectedEventType, selectedLogLevel, selectedTimeRange]); + }, [events, searchQuery, selectedEventType, selectedLogLevel, selectedTimeRange, selectedActorId]); - // Export JSON - const handleExportJSON = () => { + const handleExportJSON = useCallback(() => { const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent( JSON.stringify(filteredEvents, null, 2) )}`; @@ -101,10 +143,9 @@ export function AuditTimeline() { document.body.appendChild(downloadAnchor); downloadAnchor.click(); downloadAnchor.remove(); - }; + }, [filteredEvents]); - // Export CSV - const handleExportCSV = () => { + const handleExportCSV = useCallback(() => { const headers = ['ID', 'Timestamp', 'Level', 'Event Type', 'Actor', 'Summary', 'Details', 'Project', 'Amount', 'Asset']; const rows = filteredEvents.map((e) => [ e.id, @@ -118,7 +159,6 @@ export function AuditTimeline() { e.amount || '', e.asset || '', ]); - const csvContent = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -128,177 +168,458 @@ export function AuditTimeline() { document.body.appendChild(downloadAnchor); downloadAnchor.click(); downloadAnchor.remove(); - }; + URL.revokeObjectURL(url); + }, [filteredEvents]); + + const handleClearFilters = useCallback(() => { + setSearchQuery(''); + setSelectedEventType('all'); + setSelectedLogLevel('all'); + setSelectedTimeRange('all'); + setSelectedActorId('all'); + }, []); + + const hasActiveFilters = searchQuery !== '' || selectedEventType !== 'all' || selectedLogLevel !== 'all' || selectedTimeRange !== 'all' || selectedActorId !== 'all'; + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'timestamp', + header: 'Timestamp', + cell: ({ row }) => ( +
+ {formatDateTime(row.original.timestamp)} + {formatRelativeTime(row.original.timestamp)} +
+ ), + sortingFn: 'datetime', + meta: { className: 'min-w-[180px]' }, + }, + { + accessorKey: 'level', + header: 'Severity', + cell: ({ row }) => ( +
+ {LEVEL_ICONS[row.original.level]} + + {row.original.level} + +
+ ), + meta: { className: 'min-w-[120px]' }, + }, + { + accessorKey: 'eventType', + header: 'Event Type', + cell: () => null, + filterFn: (row, _columnId, filterValue) => { + if (filterValue === 'all') return true; + return row.original.eventType === filterValue; + }, + }, + { + id: 'eventTypeDisplay', + header: 'Transaction Type', + cell: ({ row }) => ( + + {EVENT_TYPE_LABELS[row.original.eventType]} + + ), + meta: { className: 'min-w-[160px]' }, + }, + { + accessorKey: 'actorName', + header: 'Actor', + cell: ({ row }) => { + const evt = row.original; + return ( +
+ {evt.actorType === 'agent' ? ( + + ) : ( + + )} +
+ {evt.actorName} + {evt.actorId} +
+
+ ); + }, + meta: { className: 'min-w-[180px]' }, + }, + { + accessorKey: 'summary', + header: 'Summary', + cell: ({ row }) => { + const evt = row.original; + return ( +
+ {evt.summary} + {evt.details} + {evt.project && ( + + {evt.project} + + )} +
+ ); + }, + meta: { className: 'min-w-[280px]' }, + }, + { + accessorKey: 'amount', + header: 'Amount', + cell: ({ row }) => { + const evt = row.original; + if (evt.amount && evt.asset) { + return ( + + {formatCurrency(evt.amount, evt.asset)} + + ); + } + return ; + }, + meta: { className: 'text-right min-w-[100px]' }, + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( + + ), + enableSorting: false, + meta: { className: 'min-w-[100px]' }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredEvents, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + manualFiltering: true, + }); + + const pageCount = table.getPageCount(); + const canPaginate = pageCount > 1; return ( -
- {/* Filter Toolbar & Actions */} -
-
- {/* Search Input */} -
- +
+
+
+

+ Agent Activity Audit Log +

+

+ Chronological timeline of all agent actions with advanced filtering +

+
+
+ + +
+
+ + +
+
+ + setSearchQuery(e.target.value)} - className="w-full rounded-button border border-border bg-surface pl-9 pr-3 py-1.5 text-xs text-foreground placeholder:text-foreground-muted focus:border-gold focus:outline-none" + className="w-full h-9 rounded-button border border-border bg-surface pl-9 pr-8 py-1.5 text-sm text-foreground placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" /> {searchQuery && ( )}
- {/* Event Type Filter */} - - - {/* Log Level Filter */} - - - {/* Date Range Filter */} - -
+
+ + Filters: +
- {/* Export Buttons */} -
- - -
-
+
+ + +
- {/* Timeline Results Counter */} -
- Showing {filteredEvents.length} of {events.length} audit trail records -
+
+ + +
- {/* Timeline Stream View */} -
- {filteredEvents.length === 0 ? ( - - No audit log records match the current filter criteria. - - ) : ( - filteredEvents.map((evt) => ( -
- {/* Timeline Indicator Dot */} -
- {LEVEL_ICONS[evt.level]} -
+
+ + +
- {/* Event Card */} - -
-
-
- - {evt.level} - - - {EVENT_TYPE_LABELS[evt.eventType]} - - {evt.project && ( - - {evt.project} - - )} - - {formatRelativeTime(evt.timestamp)} - -
+
+ + + + + +
-

{evt.summary}

-

{evt.details}

-
-
+ {hasActiveFilters && ( + + )} +
+ -
- {/* Actor details */} -
- {evt.actorType === 'agent' ? ( - - ) : ( - - )} - {evt.actorName} - ({evt.actorId}) -
+
+ + Showing {filteredEvents.length} of {events.length} audit trail records + + {hasActiveFilters && ( + + Filters active + + )} +
- {/* Financial amount if present */} - {evt.amount && evt.asset && ( -
- {formatCurrency(evt.amount, evt.asset)} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as { className?: string } | undefined; + const canSort = header.column.getCanSort(); + return ( + + ); + })} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + - - Inspect Payload - - - + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+ {header.isPlaceholder ? null : ( + + )} +
+
+ No audit log records match the current filter criteria.
- )} - - {/* Payload inspector action */} -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + {canPaginate && ( +
+

+ Page {table.getState().pagination.pageIndex + 1} of {pageCount} +

+
+ + + + {table.getState().pagination.pageIndex + 1} / {pageCount} + + +
- )) +
)}
- {/* Detailed Event Payload Modal */} {inspectEvent && (
Timestamp:{' '} - {formatDate(inspectEvent.timestamp)} + {formatDateTime(inspectEvent.timestamp)}
Actor:{' '} @@ -328,20 +649,24 @@ export function AuditTimeline() { {inspectEvent.xdrHash && (
-
); } + +export default AuditTimeline; diff --git a/src/features/audit/index.ts b/src/features/audit/index.ts new file mode 100644 index 0000000..b9e565d --- /dev/null +++ b/src/features/audit/index.ts @@ -0,0 +1,2 @@ +export { default as AuditTimeline } from './AuditTimeline'; +export * from './types'; diff --git a/src/features/audit/types.ts b/src/features/audit/types.ts index e756c9a..12c00ae 100644 --- a/src/features/audit/types.ts +++ b/src/features/audit/types.ts @@ -1,26 +1 @@ -export type AuditEventType = - | 'action' - | 'policy_change' - | 'approval' - | 'signature' - | 'key_rotation' - | 'budget_cap'; - -export type AuditLogLevel = 'info' | 'success' | 'warning' | 'error'; - -export interface AuditEvent { - id: string; - timestamp: string; - eventType: AuditEventType; - level: AuditLogLevel; - actorId: string; - actorName: string; - actorType: 'agent' | 'user' | 'system'; - summary: string; - details: string; - project?: string; - amount?: number; - asset?: string; - xdrHash?: string; - rawPayload: Record; -} +export * from '@/types/audit'; diff --git a/src/types/audit.ts b/src/types/audit.ts new file mode 100644 index 0000000..95ec430 --- /dev/null +++ b/src/types/audit.ts @@ -0,0 +1,36 @@ +export type AuditEventType = + | 'action' + | 'policy_change' + | 'approval' + | 'signature' + | 'key_rotation' + | 'budget_cap'; + +export type AuditLogLevel = 'info' | 'success' | 'warning' | 'error'; + +export interface AuditEvent { + id: string; + timestamp: string; + eventType: AuditEventType; + level: AuditLogLevel; + actorId: string; + actorName: string; + actorType: 'agent' | 'user' | 'system'; + summary: string; + details: string; + project?: string; + amount?: number; + asset?: string; + xdrHash?: string; + rawPayload: Record; +} + +export type AuditTimeRange = 'all' | '24h' | '7d' | '30d'; + +export interface AuditFilters { + searchQuery: string; + eventType: AuditEventType | 'all'; + logLevel: AuditLogLevel | 'all'; + timeRange: AuditTimeRange; + actorId: string | 'all'; +} diff --git a/src/types/index.ts b/src/types/index.ts index ab9131a..51055d9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,3 @@ export * from './api'; export * from './domain'; +export * from './audit';