187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
|
|
import { useState, useMemo } from 'react'
|
||
|
|
import { AdminLayout } from '../Admin'
|
||
|
|
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||
|
|
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
|
||
|
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||
|
|
import { Select } from '../../../design-system/primitives/Select/Select'
|
||
|
|
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||
|
|
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
|
||
|
|
import { Pagination } from '../../../design-system/primitives/Pagination/Pagination'
|
||
|
|
import type { DateRange } from '../../../design-system/utils/timePresets'
|
||
|
|
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
|
||
|
|
import styles from './AuditLog.module.css'
|
||
|
|
|
||
|
|
const CATEGORIES = [
|
||
|
|
{ value: '', label: 'All categories' },
|
||
|
|
{ value: 'INFRA', label: 'INFRA' },
|
||
|
|
{ value: 'AUTH', label: 'AUTH' },
|
||
|
|
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||
|
|
{ value: 'CONFIG', label: 'CONFIG' },
|
||
|
|
]
|
||
|
|
|
||
|
|
const PAGE_SIZE = 10
|
||
|
|
|
||
|
|
function formatTimestamp(iso: string): string {
|
||
|
|
return new Date(iso).toLocaleString('en-GB', {
|
||
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||
|
|
hour12: false,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const now = Date.now()
|
||
|
|
const INITIAL_RANGE: DateRange = {
|
||
|
|
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
|
||
|
|
to: new Date(now).toISOString().slice(0, 16),
|
||
|
|
}
|
||
|
|
|
||
|
|
export function AuditLog() {
|
||
|
|
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
|
||
|
|
const [userFilter, setUserFilter] = useState('')
|
||
|
|
const [categoryFilter, setCategoryFilter] = useState('')
|
||
|
|
const [searchFilter, setSearchFilter] = useState('')
|
||
|
|
const [page, setPage] = useState(1)
|
||
|
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
const from = new Date(dateRange.from).getTime()
|
||
|
|
const to = new Date(dateRange.to).getTime()
|
||
|
|
return AUDIT_EVENTS.filter((e) => {
|
||
|
|
const ts = new Date(e.timestamp).getTime()
|
||
|
|
if (ts < from || ts > to) return false
|
||
|
|
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
|
||
|
|
if (categoryFilter && e.category !== categoryFilter) return false
|
||
|
|
if (searchFilter) {
|
||
|
|
const q = searchFilter.toLowerCase()
|
||
|
|
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
}, [dateRange, userFilter, categoryFilter, searchFilter])
|
||
|
|
|
||
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||
|
|
const pageEvents = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminLayout title="Audit Log">
|
||
|
|
<div className={styles.header}>
|
||
|
|
<h2 className={styles.title}>Audit Log</h2>
|
||
|
|
<Badge label={`${filtered.length} events`} color="primary" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.filters}>
|
||
|
|
<DateRangePicker
|
||
|
|
value={dateRange}
|
||
|
|
onChange={(r) => { setDateRange(r); setPage(1) }}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
placeholder="Filter by user..."
|
||
|
|
value={userFilter}
|
||
|
|
onChange={(e) => { setUserFilter(e.target.value); setPage(1) }}
|
||
|
|
onClear={() => { setUserFilter(''); setPage(1) }}
|
||
|
|
className={styles.filterInput}
|
||
|
|
/>
|
||
|
|
<Select
|
||
|
|
options={CATEGORIES}
|
||
|
|
value={categoryFilter}
|
||
|
|
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
|
||
|
|
className={styles.filterSelect}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
placeholder="Search action or target..."
|
||
|
|
value={searchFilter}
|
||
|
|
onChange={(e) => { setSearchFilter(e.target.value); setPage(1) }}
|
||
|
|
onClear={() => { setSearchFilter(''); setPage(1) }}
|
||
|
|
className={styles.filterInput}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.tableWrap}>
|
||
|
|
<table className={styles.table}>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th className={styles.th} style={{ width: 170 }}>Timestamp</th>
|
||
|
|
<th className={styles.th}>User</th>
|
||
|
|
<th className={styles.th} style={{ width: 100 }}>Category</th>
|
||
|
|
<th className={styles.th}>Action</th>
|
||
|
|
<th className={styles.th}>Target</th>
|
||
|
|
<th className={styles.th} style={{ width: 80 }}>Result</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{pageEvents.map((event) => (
|
||
|
|
<EventRow
|
||
|
|
key={event.id}
|
||
|
|
event={event}
|
||
|
|
expanded={expandedId === event.id}
|
||
|
|
onToggle={() => setExpandedId(expandedId === event.id ? null : event.id)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{pageEvents.length === 0 && (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={6} className={styles.empty}>No events match the current filters.</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{totalPages > 1 && (
|
||
|
|
<div className={styles.pagination}>
|
||
|
|
<Pagination
|
||
|
|
page={page}
|
||
|
|
totalPages={totalPages}
|
||
|
|
onPageChange={setPage}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</AdminLayout>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) {
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<tr className={styles.row} onClick={onToggle}>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<MonoText size="xs">{formatTimestamp(event.timestamp)}</MonoText>
|
||
|
|
</td>
|
||
|
|
<td className={`${styles.td} ${styles.userCell}`}>{event.username}</td>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<Badge label={event.category} color="auto" />
|
||
|
|
</td>
|
||
|
|
<td className={styles.td}>{event.action}</td>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<span className={styles.target}>{event.target}</span>
|
||
|
|
</td>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<Badge
|
||
|
|
label={event.result}
|
||
|
|
color={event.result === 'SUCCESS' ? 'success' : 'error'}
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{expanded && (
|
||
|
|
<tr className={styles.detailRow}>
|
||
|
|
<td colSpan={6} className={styles.detailCell}>
|
||
|
|
<div className={styles.detailGrid}>
|
||
|
|
<div className={styles.detailField}>
|
||
|
|
<span className={styles.detailLabel}>IP Address</span>
|
||
|
|
<MonoText size="xs">{event.ipAddress}</MonoText>
|
||
|
|
</div>
|
||
|
|
<div className={styles.detailField}>
|
||
|
|
<span className={styles.detailLabel}>User Agent</span>
|
||
|
|
<span className={styles.detailValue}>{event.userAgent}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className={styles.detailJson}>
|
||
|
|
<span className={styles.detailLabel}>Detail</span>
|
||
|
|
<CodeBlock content={JSON.stringify(event.detail, null, 2)} language="json" />
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|