/** * Filterable, paginated audit log table with expandable row details and CSV export. * * Uses TanStack Query for data fetching and design system tokens for styling. */ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Search, ClipboardList, } from 'lucide-react' import { auditLogsApi, type AuditLogEntry, type AuditLogParams, } from '@/lib/api' import { cn } from '@/lib/utils' import { DeviceLink } from '@/components/ui/DeviceLink' import { EmptyState } from '@/components/ui/empty-state' // Predefined action types for the filter dropdown const ACTION_TYPES = [ { value: '', label: 'All Actions' }, { value: 'login', label: 'Login' }, { value: 'logout', label: 'Logout' }, { value: 'device_create', label: 'Device Create' }, { value: 'device_update', label: 'Device Update' }, { value: 'device_delete', label: 'Device Delete' }, { value: 'config_browse', label: 'Config Browse' }, { value: 'config_add', label: 'Config Add' }, { value: 'config_set', label: 'Config Set' }, { value: 'config_remove', label: 'Config Remove' }, { value: 'config_execute', label: 'Config Execute' }, { value: 'firmware_upgrade', label: 'Firmware Upgrade' }, { value: 'alert_rule_create', label: 'Alert Rule Create' }, { value: 'alert_rule_update', label: 'Alert Rule Update' }, { value: 'bulk_command', label: 'Bulk Command' }, { value: 'device_adopt', label: 'Device Adopt' }, ] as const const PER_PAGE_OPTIONS = [25, 50, 100] as const /** Formats an ISO timestamp into a human-readable relative time string. */ function formatRelativeTime(iso: string): string { const now = Date.now() const then = new Date(iso).getTime() const diffMs = now - then if (diffMs < 0) return 'just now' const seconds = Math.floor(diffMs / 1000) if (seconds < 60) return 'just now' const minutes = Math.floor(seconds / 60) if (minutes < 60) return `${minutes}m ago` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) if (days < 7) return `${days}d ago` const weeks = Math.floor(days / 7) if (weeks < 4) return `${weeks}w ago` const months = Math.floor(days / 30) return `${months}mo ago` } /** Maps action string to a styled badge color. */ function actionBadgeClasses(action: string): string { if (action.startsWith('config_')) return 'bg-accent/10 text-accent border-accent/20' if (action.startsWith('device_')) return 'bg-info/10 text-info border-info/20' if (action.startsWith('alert_')) return 'bg-warning/10 text-warning border-warning/20' if (action === 'login' || action === 'logout') return 'bg-success/10 text-success border-success/20' if (action.startsWith('firmware')) return 'bg-purple-500/10 text-purple-400 border-purple-500/20' if (action.startsWith('bulk_')) return 'bg-error/10 text-error border-error/20' return 'bg-elevated text-text-secondary border-border' } interface AuditLogTableProps { tenantId: string } export function AuditLogTable({ tenantId }: AuditLogTableProps) { const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(50) const [actionFilter, setActionFilter] = useState('') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [userSearch, setUserSearch] = useState('') const [expandedId, setExpandedId] = useState(null) const [exporting, setExporting] = useState(false) const params: AuditLogParams = { page, per_page: perPage, ...(actionFilter ? { action: actionFilter } : {}), ...(dateFrom ? { date_from: new Date(dateFrom).toISOString() } : {}), ...(dateTo ? { date_to: new Date(dateTo + 'T23:59:59').toISOString() } : {}), } const { data, isLoading, isError } = useQuery({ queryKey: ['audit-logs', tenantId, page, perPage, actionFilter, dateFrom, dateTo], queryFn: () => auditLogsApi.list(tenantId, params), enabled: !!tenantId, }) const totalPages = data ? Math.ceil(data.total / perPage) : 0 // Client-side user email filter (since user search is by text, not UUID) const filteredItems = data?.items.filter((item) => { if (!userSearch) return true return item.user_email?.toLowerCase().includes(userSearch.toLowerCase()) }) ?? [] const handleExport = async () => { setExporting(true) try { await auditLogsApi.exportCsv(tenantId, { ...(actionFilter ? { action: actionFilter } : {}), ...(dateFrom ? { date_from: new Date(dateFrom).toISOString() } : {}), ...(dateTo ? { date_to: new Date(dateTo + 'T23:59:59').toISOString() } : {}), }) } finally { setExporting(false) } } return (
{/* Filter bar */}
{/* Action filter */} {/* Date from */}
From { setDateFrom(e.target.value); setPage(1) }} aria-label="Filter from date" className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" />
{/* Date to */}
To { setDateTo(e.target.value); setPage(1) }} aria-label="Filter to date" className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" />
{/* User search */}
setUserSearch(e.target.value)} aria-label="Filter by user" className="h-8 rounded-md border border-border bg-surface pl-7 pr-2 text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent w-40" />
{/* Spacer */}
{/* Export CSV */}
{/* Table */}
{isLoading ? (

Loading audit logs...

) : isError ? (

Failed to load audit logs.

) : filteredItems.length === 0 ? ( ) : ( {filteredItems.map((item) => ( setExpandedId(expandedId === item.id ? null : item.id) } /> ))}
Expand Timestamp User Action Resource Device IP Address
)}
{/* Pagination */} {data && data.total > 0 && (
Rows per page: {(page - 1) * perPage + 1}-- {Math.min(page * perPage, data.total)} of {data.total}
Page {page} of {totalPages}
)}
) } // --------------------------------------------------------------------------- // Row sub-component // --------------------------------------------------------------------------- interface AuditLogRowProps { item: AuditLogEntry tenantId: string isExpanded: boolean onToggle: () => void } function AuditLogRow({ item, tenantId, isExpanded, onToggle }: AuditLogRowProps) { const hasDetails = item.details && Object.keys(item.details).length > 0 return ( <> { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }} aria-expanded={isExpanded} > {hasDetails ? ( isExpanded ? (