refactor: rework audit log to full-width table with filter bar
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
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
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>
This commit is contained in:
@@ -37,8 +37,7 @@ function AuditLogContent() {
|
||||
const [category, setCategory] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
|
||||
const [filtersVisible, setFiltersVisible] = useState(true);
|
||||
const [expandedRow, setExpandedRow] = useState<number | null>(null);
|
||||
const pageSize = 25;
|
||||
|
||||
const params: AuditLogParams = {
|
||||
@@ -57,233 +56,210 @@ function AuditLogContent() {
|
||||
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
|
||||
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
|
||||
|
||||
const selectedEvent = data?.items.find((e) => e.id === selectedEventId) ?? null;
|
||||
|
||||
return (
|
||||
<div className={layout.page}>
|
||||
{/* Header */}
|
||||
<div className={layout.panelHeader}>
|
||||
<div>
|
||||
<div className={layout.panelTitle}>Audit Log</div>
|
||||
{data && (
|
||||
<div className={layout.panelSubtitle}>
|
||||
{data.totalCount.toLocaleString()} events
|
||||
</div>
|
||||
)}
|
||||
<div className={layout.panelSubtitle}>
|
||||
{data
|
||||
? `${data.totalCount.toLocaleString()} events`
|
||||
: 'Loading...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={layout.split}>
|
||||
{/* Left pane — event list */}
|
||||
<div className={layout.listPane}>
|
||||
{/* Collapsible filter bar */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
onClick={() => setFiltersVisible((v) => !v)}
|
||||
>
|
||||
{filtersVisible ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
<div
|
||||
className={`${styles.filters} ${!filtersVisible ? styles.filtersCollapsed : ''}`}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Event list */}
|
||||
<div className={layout.entityList}>
|
||||
{audit.isLoading ? (
|
||||
<div className={layout.loading}>Loading...</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<div className={layout.loading}>No events found.</div>
|
||||
) : (
|
||||
data.items.map((event) => (
|
||||
<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}
|
||||
className={`${layout.entityItem} ${
|
||||
selectedEventId === event.id ? layout.entityItemSelected : ''
|
||||
}`}
|
||||
onClick={() => setSelectedEventId(event.id)}
|
||||
>
|
||||
<div className={layout.entityInfo}>
|
||||
<div className={styles.eventTimestamp}>
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</div>
|
||||
<div className={styles.eventCompact}>
|
||||
<span>{event.username}</span>
|
||||
<span className={styles.eventAction}>{event.action}</span>
|
||||
</div>
|
||||
<div className={styles.eventCompact}>
|
||||
<span
|
||||
className={`${styles.resultBadge} ${
|
||||
event.result === 'SUCCESS'
|
||||
? styles.resultSuccess
|
||||
: styles.resultFailure
|
||||
}`}
|
||||
>
|
||||
{event.result}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalCount > 0 && (
|
||||
<div className={layout.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={layout.pageBtn}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className={layout.pageInfo}>
|
||||
{showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={layout.pageBtn}
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right pane — detail view */}
|
||||
<div className={layout.detailPane}>
|
||||
{!selectedEvent ? (
|
||||
<div className={layout.detailEmpty}>
|
||||
Select an event to view details
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={layout.detailSection}>
|
||||
<div className={layout.detailSectionTitle}>Event Info</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>Timestamp</span>
|
||||
<span className={`${layout.fieldVal} ${styles.mono}`}>
|
||||
{formatTimestamp(selectedEvent.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>User</span>
|
||||
<span className={layout.fieldVal}>{selectedEvent.username}</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>Category</span>
|
||||
<span className={layout.fieldVal}>
|
||||
<span className={styles.categoryBadge}>{selectedEvent.category}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>Action</span>
|
||||
<span className={layout.fieldVal}>{selectedEvent.action}</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>Target</span>
|
||||
<span className={`${layout.fieldVal} ${styles.mono}`}>
|
||||
{selectedEvent.target}
|
||||
</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>Result</span>
|
||||
<span className={layout.fieldVal}>
|
||||
<span
|
||||
className={`${styles.resultBadge} ${
|
||||
selectedEvent.result === 'SUCCESS'
|
||||
? styles.resultSuccess
|
||||
: styles.resultFailure
|
||||
}`}
|
||||
>
|
||||
{selectedEvent.result}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>IP Address</span>
|
||||
<span className={`${layout.fieldVal} ${styles.mono}`}>
|
||||
{selectedEvent.ipAddress}
|
||||
</span>
|
||||
</div>
|
||||
<div className={layout.fieldRow}>
|
||||
<span className={layout.fieldLabel}>User Agent</span>
|
||||
<span className={layout.fieldVal}>{selectedEvent.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={layout.detailSection}>
|
||||
<div className={layout.detailSectionTitle}>Detail Payload</div>
|
||||
<pre className={styles.detailJson}>
|
||||
{JSON.stringify(selectedEvent.detail, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user