Files
cameleer-server/ui/src/pages/Admin/AuditLogPage.tsx

161 lines
5.4 KiB
TypeScript
Raw Normal View History

import { useState, useMemo, useCallback } from 'react';
import {
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
import styles from './AuditLogPage.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' },
{ value: 'RBAC', label: 'RBAC' },
{ value: 'AGENT', label: 'AGENT' },
];
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,
});
}
type AuditRow = Omit<AuditEvent, 'id'> & { id: string };
const COLUMNS: Column<AuditRow>[] = [
{
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
},
{
key: 'username', header: 'User', sortable: true,
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
},
{
key: 'category', header: 'Category', width: '110px', sortable: true,
render: (_, row) => <Badge label={row.category} color="auto" />,
},
{ key: 'action', header: 'Action' },
{
key: 'target', header: 'Target',
render: (_, row) => <span className={styles.target}>{row.target}</span>,
},
{
key: 'result', header: 'Result', width: '90px', sortable: true,
render: (_, row) => (
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
),
},
];
export default function AuditLogPage() {
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 7 * 24 * 3600_000),
end: new Date(),
});
const [userFilter, setUserFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [page, setPage] = useState(0);
const [sortField, setSortField] = useState<string>('timestamp');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const handleSortChange = useCallback((key: string, dir: 'asc' | 'desc') => {
setSortField(key);
setSortDir(dir);
setPage(0);
}, []);
const { data } = useAuditLog({
username: userFilter || undefined,
category: categoryFilter || undefined,
search: searchFilter || undefined,
from: dateRange.start.toISOString(),
to: dateRange.end.toISOString(),
sort: sortField,
order: sortDir,
page,
size: 25,
});
const rows: AuditRow[] = useMemo(
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
[data],
);
const totalCount = data?.totalCount ?? 0;
return (
<div>
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={(range) => { setDateRange(range); setPage(0); }}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => { setUserFilter(e.target.value); setPage(0); }}
onClear={() => { setUserFilter(''); setPage(0); }}
className={styles.filterInput}
/>
<Select
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
onClear={() => { setSearchFilter(''); setPage(0); }}
className={styles.filterInput}
/>
</div>
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Audit Log</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{totalCount} events
</span>
<Badge label="AUTO" color="success" />
</div>
</div>
<DataTable
columns={COLUMNS}
data={rows}
sortable
flush
pageSize={25}
onSortChange={handleSortChange}
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
expandedContent={(row) => (
<div className={styles.expandedDetail}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{row.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{row.userAgent}</span>
</div>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
</div>
</div>
)}
/>
</div>
</div>
);
}