feat: replace UI with design system example pages wired to real API
Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +1,148 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
|
||||
import {
|
||||
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuditLog } from '../../api/queries/admin/audit';
|
||||
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' },
|
||||
];
|
||||
|
||||
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 [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
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 { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
|
||||
const { data } = useAuditLog({
|
||||
username: userFilter || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
search: searchFilter || undefined,
|
||||
from: dateRange.start.toISOString(),
|
||||
to: dateRange.end.toISOString(),
|
||||
page,
|
||||
size: 25,
|
||||
});
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
|
||||
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'action', header: 'Action' },
|
||||
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
|
||||
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
|
||||
];
|
||||
|
||||
const rows = useMemo(() =>
|
||||
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
|
||||
const rows: AuditRow[] = useMemo(
|
||||
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
|
||||
[data],
|
||||
);
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<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={[
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'AUTH', label: 'Auth' },
|
||||
{ value: 'CONFIG', label: 'Config' },
|
||||
{ value: 'RBAC', label: 'RBAC' },
|
||||
{ value: 'INFRA', label: 'Infra' },
|
||||
]}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
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>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={25}
|
||||
expandedContent={(row) => (
|
||||
<div style={{ padding: '0.75rem' }}>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
||||
<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="LIVE" color="success" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={rows}
|
||||
sortable
|
||||
flush
|
||||
pageSize={25}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user