feat: add Audit Log admin page with filtering, pagination, and detail expansion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
260
ui/src/pages/admin/AuditLogPage.module.css
Normal file
260
ui/src/pages/admin/AuditLogPage.module.css
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessDenied {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Filters ─── */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup:nth-child(3),
|
||||||
|
.filterGroup:nth-child(5) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput:focus {
|
||||||
|
border-color: var(--amber-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Table ─── */
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventRow {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventRow:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventRowExpanded {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultSuccess {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultFailure {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Detail Row ─── */
|
||||||
|
.detailRow td {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailJson {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Pagination ─── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:hover:not(:disabled) {
|
||||||
|
border-color: var(--amber-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
ui/src/pages/admin/AuditLogPage.tsx
Normal file
225
ui/src/pages/admin/AuditLogPage.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
|
||||||
|
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={styles.page}>
|
||||||
|
<div className={styles.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<string | 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 ? Math.ceil(data.total / pageSize) : 0;
|
||||||
|
const showingFrom = data && data.total > 0 ? page * pageSize + 1 : 0;
|
||||||
|
const showingTo = data ? Math.min((page + 1) * pageSize, data.total) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.pageTitle}>Audit Log</h1>
|
||||||
|
{data && (
|
||||||
|
<span className={styles.totalCount}>{data.total.toLocaleString()} events</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<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}>
|
||||||
|
<label className={styles.filterLabel}>Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.filterInput}
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audit.isLoading ? (
|
||||||
|
<div className={styles.loading}>Loading...</div>
|
||||||
|
) : !data || data.events.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>No audit events found for the selected filters.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.events.map((event) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={event.id}
|
||||||
|
className={`${styles.eventRow} ${expandedRow === event.id ? styles.eventRowExpanded : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRow((prev) => (prev === event.id ? null : event.id))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className={styles.mono}>
|
||||||
|
{formatTimestamp(event.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td>{event.username}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.categoryBadge}>{event.category}</span>
|
||||||
|
</td>
|
||||||
|
<td>{event.action}</td>
|
||||||
|
<td className={styles.mono}>{event.target}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`${styles.resultBadge} ${
|
||||||
|
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.result}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedRow === event.id && (
|
||||||
|
<tr key={`${event.id}-detail`} className={styles.detailRow}>
|
||||||
|
<td colSpan={6}>
|
||||||
|
<pre className={styles.detailJson}>
|
||||||
|
{JSON.stringify(event.detail, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.pageBtn}
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className={styles.pageInfo}>
|
||||||
|
Showing {showingFrom}-{showingTo} of {data.total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.pageBtn}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user