All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m56s
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m58s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m12s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 37s
- AuditLogPage: e.details -> e.detail (correct property name) - AgentInstance: BarChart x: number -> x: String(i) (BarSeries requires string) - AppsTab: add missing CatalogRoute import - Dashboard: wrap MonoText in span for title attribute (MonoText lacks title prop) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import {
|
|
Badge, Button, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
|
|
} from '@cameleer/design-system';
|
|
import type { Column } from '@cameleer/design-system';
|
|
import { Download } from 'lucide-react';
|
|
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
|
|
import styles from './AuditLogPage.module.css';
|
|
import tableStyles from '../../styles/table-section.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 exportCsv(events: AuditEvent[]) {
|
|
const headers = ['Timestamp', 'User', 'Category', 'Action', 'Target', 'Result', 'Details'];
|
|
const rows = events.map(e => [
|
|
e.timestamp, e.username, e.category, e.action, e.target, e.result, e.detail ?? '',
|
|
]);
|
|
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `cameleer-audit-${new Date().toISOString().slice(0, 16).replace(':', '-')}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
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={tableStyles.tableSection}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<span className={tableStyles.tableTitle}>Audit Log</span>
|
|
<div className={tableStyles.tableRight}>
|
|
<span className={tableStyles.tableMeta}>
|
|
{totalCount} events
|
|
</span>
|
|
<Badge label="AUTO" color="success" />
|
|
<Button variant="ghost" size="sm" onClick={() => exportCsv(data?.items ?? [])}>
|
|
<Download size={14} /> Export CSV
|
|
</Button>
|
|
</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>
|
|
);
|
|
}
|