Files
Charon/frontend/src/pages/AuditLogs.tsx

404 lines
13 KiB
TypeScript

import { useState } from 'react'
import { format } from 'date-fns'
import { Download, Filter, X } from 'lucide-react'
import { PageShell } from '../components/layout/PageShell'
import { useAuditLogs, type AuditLogFilters, type AuditLog } from '../hooks/useAuditLogs'
import { exportAuditLogsCSV } from '../api/auditLogs'
import { DataTable, type Column } from '../components/ui/DataTable'
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Badge,
Input,
} from '../components/ui'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../components/ui/Dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/Select'
import { toast } from '../utils/toast'
/** Audit log detail modal */
function AuditLogDetailModal({
log,
isOpen,
onClose,
}: {
log: AuditLog | null
isOpen: boolean
onClose: () => void
}) {
if (!log) return null
const parsedDetails: Record<string, unknown> = (() => {
try {
return JSON.parse(log.details)
} catch {
return { raw: log.details }
}
})()
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Audit Log Details</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-secondary">UUID</label>
<p className="text-sm text-content-primary font-mono">{log.uuid}</p>
</div>
<div>
<label className="text-sm font-medium text-content-secondary">Timestamp</label>
<p className="text-sm text-content-primary">
{format(new Date(log.created_at), 'PPpp')}
</p>
</div>
<div>
<label className="text-sm font-medium text-content-secondary">Actor</label>
<p className="text-sm text-content-primary">{log.actor}</p>
</div>
<div>
<label className="text-sm font-medium text-content-secondary">Action</label>
<Badge variant="outline">{log.action}</Badge>
</div>
<div>
<label className="text-sm font-medium text-content-secondary">Category</label>
<Badge variant="primary">{log.event_category}</Badge>
</div>
{log.resource_uuid && (
<div>
<label className="text-sm font-medium text-content-secondary">Resource UUID</label>
<p className="text-sm text-content-primary font-mono">{log.resource_uuid}</p>
</div>
)}
{log.ip_address && (
<div>
<label className="text-sm font-medium text-content-secondary">IP Address</label>
<p className="text-sm text-content-primary">{log.ip_address}</p>
</div>
)}
{log.user_agent && (
<div className="col-span-2">
<label className="text-sm font-medium text-content-secondary">User Agent</label>
<p className="text-sm text-content-primary break-all">{log.user_agent}</p>
</div>
)}
</div>
<div>
<label className="text-sm font-medium text-content-secondary">Details</label>
<pre className="mt-2 p-3 bg-surface-subtle rounded-lg text-xs overflow-auto max-h-96">
{JSON.stringify(parsedDetails, null, 2)}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function AuditLogs() {
const [page, setPage] = useState(1)
const [limit] = useState(50)
const [filters, setFilters] = useState<AuditLogFilters>({})
const [showFilters, setShowFilters] = useState(false)
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null)
const [isExporting, setIsExporting] = useState(false)
const { data, isLoading } = useAuditLogs(filters, page, limit)
const handleFilterChange = (key: keyof AuditLogFilters, value: string) => {
setFilters((prev) => ({
...prev,
[key]: value || undefined,
}))
setPage(1) // Reset to first page when filters change
}
const handleClearFilters = () => {
setFilters({})
setPage(1)
}
const handleExport = async () => {
setIsExporting(true)
try {
const csv = await exportAuditLogsCSV(filters)
const blob = new Blob([csv], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-logs-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success('Audit logs exported successfully')
} catch (error) {
toast.error('Failed to export audit logs')
console.error('Export error:', error)
} finally {
setIsExporting(false)
}
}
const columns: Column<AuditLog>[] = [
{
key: 'created_at',
header: 'Timestamp',
sortable: true,
width: '200px',
cell: (log) => (
<span className="text-sm">
{format(new Date(log.created_at), 'MMM d, yyyy HH:mm:ss')}
</span>
),
},
{
key: 'actor',
header: 'Actor',
sortable: true,
cell: (log) => <span className="text-sm font-medium">{log.actor}</span>,
},
{
key: 'action',
header: 'Action',
sortable: true,
cell: (log) => <Badge variant="outline">{log.action}</Badge>,
},
{
key: 'event_category',
header: 'Category',
sortable: true,
cell: (log) => <Badge variant="primary">{log.event_category}</Badge>,
},
{
key: 'resource_uuid',
header: 'Resource',
cell: (log) =>
log.resource_uuid ? (
<span className="text-sm font-mono text-content-muted">{log.resource_uuid.slice(0, 8)}...</span>
) : (
<span className="text-sm text-content-muted"></span>
),
},
{
key: 'ip_address',
header: 'IP Address',
cell: (log) =>
log.ip_address ? (
<span className="text-sm">{log.ip_address}</span>
) : (
<span className="text-sm text-content-muted"></span>
),
},
]
const hasActiveFilters = Object.values(filters).some((v) => v !== undefined && v !== '')
const headerActions = (
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="relative"
>
<Filter className="w-4 h-4 mr-2" />
Filters
{hasActiveFilters && (
<span className="absolute -top-1 -right-1 h-4 w-4 bg-brand-500 rounded-full text-[10px] text-white flex items-center justify-center">
{Object.values(filters).filter((v) => v).length}
</span>
)}
</Button>
<Button
variant="secondary"
onClick={handleExport}
disabled={isExporting || !data?.logs.length}
>
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
)
return (
<PageShell
title="Audit Logs"
description="View and filter security audit events"
actions={headerActions}
>
{/* Filters Card */}
{showFilters && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Filters</CardTitle>
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
<X className="w-4 h-4 mr-1" />
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Category</label>
<Select
value={filters.event_category || 'all'}
onValueChange={(value) => handleFilterChange('event_category', value === 'all' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
<SelectItem value="dns_provider">DNS Provider</SelectItem>
<SelectItem value="certificate">Certificate</SelectItem>
<SelectItem value="proxy_host">Proxy Host</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Actor</label>
<Input
type="text"
placeholder="Filter by actor..."
value={filters.actor || ''}
onChange={(e) => handleFilterChange('actor', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Action</label>
<Input
type="text"
placeholder="Filter by action..."
value={filters.action || ''}
onChange={(e) => handleFilterChange('action', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Start Date</label>
<Input
type="datetime-local"
value={filters.start_date || ''}
onChange={(e) => handleFilterChange('start_date', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">End Date</label>
<Input
type="datetime-local"
value={filters.end_date || ''}
onChange={(e) => handleFilterChange('end_date', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Resource UUID</label>
<Input
type="text"
placeholder="Filter by resource..."
value={filters.resource_uuid || ''}
onChange={(e) => handleFilterChange('resource_uuid', e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
)}
{/* Audit Logs Table */}
<Card>
<CardContent className="p-0">
<DataTable
data={data?.logs || []}
columns={columns}
rowKey={(log) => log.uuid}
isLoading={isLoading}
onRowClick={(log) => setSelectedLog(log)}
emptyState={
<div className="text-center py-12">
<p className="text-content-muted">No audit logs found</p>
{hasActiveFilters && (
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="mt-4"
>
Clear Filters
</Button>
)}
</div>
}
/>
{/* Pagination */}
{data && data.total > 0 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<div className="text-sm text-content-secondary">
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, data.total)} of {data.total} entries
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</Button>
<span className="text-sm text-content-secondary">
Page {page} of {Math.ceil(data.total / limit)}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page * limit >= data.total}
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Detail Modal */}
<AuditLogDetailModal
log={selectedLog}
isOpen={!!selectedLog}
onClose={() => setSelectedLog(null)}
/>
</PageShell>
)
}