Files
cameleer-server/ui/src/pages/admin/AuditLogPage.tsx
hsiegeln 033cfcf5fc
All checks were successful
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 54s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 36s
CI / build (pull_request) Successful in 1m10s
CI / cleanup-branch (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
refactor: rework audit log to full-width table with filter bar
Replace split-pane layout with a table-based design: horizontal filter
bar, full-width data table with sticky headers, expandable detail rows
showing IP/user-agent/JSON payload, and bottom pagination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:39:55 +01:00

278 lines
8.2 KiB
TypeScript

import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
import layout from '../../styles/AdminLayout.module.css';
import styles from './AuditLogPage.module.css';
function defaultFrom(): string {
const d = new Date();
d.setDate(d.getDate() - 7);
return d.toISOString().slice(0, 10);
}
function defaultTo(): string {
return new Date().toISOString().slice(0, 10);
}
export function AuditLogPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
}
return <AuditLogContent />;
}
function AuditLogContent() {
const [from, setFrom] = useState(defaultFrom);
const [to, setTo] = useState(defaultTo);
const [username, setUsername] = useState('');
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const pageSize = 25;
const params: AuditLogParams = {
from: from || undefined,
to: to || undefined,
username: username || undefined,
category: category || undefined,
search: search || undefined,
page,
size: pageSize,
};
const audit = useAuditLog(params);
const data = audit.data;
const totalPages = data?.totalPages ?? 0;
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
return (
<div className={layout.page}>
{/* Header */}
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Audit Log</div>
<div className={layout.panelSubtitle}>
{data
? `${data.totalCount.toLocaleString()} events`
: 'Loading...'}
</div>
</div>
</div>
{/* Filter bar */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={`${styles.filterGroup} ${styles.filterGroupGrow}`}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search actions, targets..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
</div>
{/* Table area */}
<div className={styles.tableArea}>
{audit.isLoading ? (
<div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>
No audit events found for the selected filters.
</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thTimestamp}>Timestamp</th>
<th>User</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
<th className={styles.thResult}>Result</th>
</tr>
</thead>
<tbody>
{data.items.map((event) => (
<EventRow
key={event.id}
event={event}
isExpanded={expandedRow === event.id}
onToggle={() =>
setExpandedRow((prev) => (prev === event.id ? null : event.id))
}
/>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{data && data.totalCount > 0 && (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
{showingFrom}--{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
);
}
function EventRow({
event,
isExpanded,
onToggle,
}: {
event: {
id: number;
timestamp: string;
username: string;
category: string;
action: string;
target: string;
result: string;
detail: Record<string, unknown>;
ipAddress: string;
userAgent: string;
};
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<>
<tr
className={`${styles.eventRow} ${isExpanded ? styles.eventRowExpanded : ''}`}
onClick={onToggle}
>
<td className={styles.cellTimestamp}>{formatTimestamp(event.timestamp)}</td>
<td className={styles.cellUser}>{event.username}</td>
<td>
<span className={styles.categoryBadge}>{event.category}</span>
</td>
<td>{event.action}</td>
<td className={styles.cellTarget}>{event.target}</td>
<td>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
}`}
>
{event.result}
</span>
</td>
</tr>
{isExpanded && (
<tr className={styles.detailRow}>
<td colSpan={6}>
<div className={styles.detailContent}>
<div className={styles.detailMeta}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<span className={styles.detailValue}>{event.ipAddress}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{event.userAgent}</span>
</div>
</div>
{event.detail && Object.keys(event.detail).length > 0 && (
<pre className={styles.detailJson}>
{JSON.stringify(event.detail, null, 2)}
</pre>
)}
</div>
</td>
</tr>
)}
</>
);
}
function formatTimestamp(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return iso;
}
}