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

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:
hsiegeln
2026-03-17 19:39:55 +01:00
parent 6d650cdf34
commit 033cfcf5fc
2 changed files with 387 additions and 274 deletions

View File

@@ -1,40 +1,24 @@
/* ─── Filter Toggle ─── */ /* ─── Filter Bar ─── */
.filterToggle { .filterBar {
width: 100%;
padding: 8px 20px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
text-align: left;
font-family: var(--font-body);
}
.filterToggle:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
/* ─── Filters ─── */
.filters {
display: flex; display: flex;
flex-direction: column; align-items: flex-end;
gap: 8px; gap: 10px;
padding: 10px 20px; padding: 10px 20px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} flex-shrink: 0;
flex-wrap: wrap;
.filtersCollapsed {
display: none;
} }
.filterGroup { .filterGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 3px;
min-width: 0;
}
.filterGroupGrow {
flex: 1;
min-width: 140px;
} }
.filterLabel { .filterLabel {
@@ -49,12 +33,12 @@
background: var(--bg-base); background: var(--bg-base);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 5px 8px; padding: 6px 10px;
color: var(--text-primary); color: var(--text-primary);
font-size: 11px; font-size: 12px;
outline: none;
transition: border-color 0.2s;
font-family: var(--font-body); font-family: var(--font-body);
outline: none;
transition: border-color 0.15s;
} }
.filterInput:focus { .filterInput:focus {
@@ -69,41 +53,98 @@
background: var(--bg-base); background: var(--bg-base);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 5px 8px; padding: 6px 10px;
color: var(--text-primary); color: var(--text-primary);
font-size: 11px; font-size: 12px;
font-family: var(--font-body);
outline: none; outline: none;
cursor: pointer; cursor: pointer;
font-family: var(--font-body);
} }
/* ─── Event Row Styles ─── */ /* ─── Table Area ─── */
.eventTimestamp { .tableArea {
font-family: var(--font-mono); flex: 1;
overflow-y: auto;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 10px 14px;
font-size: 10px; font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted); color: var(--text-muted);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
white-space: nowrap; white-space: nowrap;
} }
.eventAction { .thTimestamp {
color: var(--text-muted); width: 170px;
font-size: 11px;
} }
.eventCompact { .thResult {
display: flex; width: 90px;
align-items: center; }
gap: 6px;
font-size: 12px; .table td {
padding: 8px 14px;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 1px; border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
/* ─── Event Rows ─── */
.eventRow {
cursor: pointer;
transition: background 0.1s;
}
.eventRow:hover {
background: var(--bg-hover);
}
.eventRowExpanded {
background: var(--bg-hover);
}
.cellTimestamp {
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
color: var(--text-muted);
}
.cellUser {
font-weight: 500;
color: var(--text-primary);
}
.cellTarget {
font-family: var(--font-mono);
font-size: 11px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* ─── Badges ─── */ /* ─── Badges ─── */
.categoryBadge { .categoryBadge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;
border-radius: 99px; border-radius: 4px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -116,23 +157,64 @@
.resultBadge { .resultBadge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;
border-radius: 99px; border-radius: 4px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px;
} }
.resultSuccess { .resultSuccess {
background: rgba(34, 197, 94, 0.1); background: rgba(16, 185, 129, 0.12);
color: #22c55e; color: var(--green);
} }
.resultFailure { .resultFailure {
background: rgba(239, 68, 68, 0.1); background: rgba(244, 63, 94, 0.12);
color: #ef4444; color: var(--rose);
}
/* ─── Expanded Detail Row ─── */
.detailRow td {
padding: 0 14px 14px;
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.detailContent {
display: flex;
flex-direction: column;
gap: 10px;
}
.detailMeta {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.detailField {
display: flex;
align-items: baseline;
gap: 8px;
}
.detailLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
white-space: nowrap;
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
word-break: break-all;
} }
/* ─── Detail JSON ─── */
.detailJson { .detailJson {
margin: 0; margin: 0;
padding: 12px; padding: 12px;
@@ -147,9 +229,64 @@
word-break: break-word; word-break: break-word;
} }
/* ─── Mono ─── */ /* ─── Pagination ─── */
.mono { .pagination {
font-family: var(--font-mono); display: flex;
font-size: 11px; align-items: center;
white-space: nowrap; justify-content: center;
gap: 12px;
padding: 10px 20px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.pageBtn {
padding: 5px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-body);
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: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* ─── Empty State ─── */
.emptyState {
text-align: center;
padding: 48px 16px;
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.filterBar {
flex-direction: column;
align-items: stretch;
}
.filterGroupGrow {
min-width: unset;
}
.cellTarget {
max-width: 120px;
}
} }

View File

@@ -37,8 +37,7 @@ function AuditLogContent() {
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [selectedEventId, setSelectedEventId] = useState<number | null>(null); const [expandedRow, setExpandedRow] = useState<number | null>(null);
const [filtersVisible, setFiltersVisible] = useState(true);
const pageSize = 25; const pageSize = 25;
const params: AuditLogParams = { const params: AuditLogParams = {
@@ -57,233 +56,210 @@ function AuditLogContent() {
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0; const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0; const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
const selectedEvent = data?.items.find((e) => e.id === selectedEventId) ?? null;
return ( return (
<div className={layout.page}> <div className={layout.page}>
{/* Header */}
<div className={layout.panelHeader}> <div className={layout.panelHeader}>
<div> <div>
<div className={layout.panelTitle}>Audit Log</div> <div className={layout.panelTitle}>Audit Log</div>
{data && ( <div className={layout.panelSubtitle}>
<div className={layout.panelSubtitle}> {data
{data.totalCount.toLocaleString()} events ? `${data.totalCount.toLocaleString()} events`
</div> : 'Loading...'}
)} </div>
</div> </div>
</div> </div>
<div className={layout.split}> {/* Filter bar */}
{/* Left pane — event list */} <div className={styles.filterBar}>
<div className={layout.listPane}> <div className={styles.filterGroup}>
{/* Collapsible filter bar */} <label className={styles.filterLabel}>From</label>
<div style={{ flexShrink: 0 }}> <input
<button type="date"
type="button" className={styles.filterInput}
className={styles.filterToggle} value={from}
onClick={() => setFiltersVisible((v) => !v)} onChange={(e) => { setFrom(e.target.value); setPage(0); }}
> />
{filtersVisible ? 'Hide Filters' : 'Show Filters'} </div>
</button> <div className={styles.filterGroup}>
<div <label className={styles.filterLabel}>To</label>
className={`${styles.filters} ${!filtersVisible ? styles.filtersCollapsed : ''}`} <input
> type="date"
<div className={styles.filterGroup}> className={styles.filterInput}
<label className={styles.filterLabel}>From</label> value={to}
<input onChange={(e) => { setTo(e.target.value); setPage(0); }}
type="date" />
className={styles.filterInput} </div>
value={from} <div className={styles.filterGroup}>
onChange={(e) => { setFrom(e.target.value); setPage(0); }} <label className={styles.filterLabel}>User</label>
/> <input
</div> type="text"
<div className={styles.filterGroup}> className={styles.filterInput}
<label className={styles.filterLabel}>To</label> placeholder="Username..."
<input value={username}
type="date" onChange={(e) => { setUsername(e.target.value); setPage(0); }}
className={styles.filterInput} />
value={to} </div>
onChange={(e) => { setTo(e.target.value); setPage(0); }} <div className={styles.filterGroup}>
/> <label className={styles.filterLabel}>Category</label>
</div> <select
<div className={styles.filterGroup}> className={styles.filterSelect}
<label className={styles.filterLabel}>User</label> value={category}
<input onChange={(e) => { setCategory(e.target.value); setPage(0); }}
type="text" >
className={styles.filterInput} <option value="">All</option>
placeholder="Username..." <option value="INFRA">INFRA</option>
value={username} <option value="AUTH">AUTH</option>
onChange={(e) => { setUsername(e.target.value); setPage(0); }} <option value="USER_MGMT">USER_MGMT</option>
/> <option value="CONFIG">CONFIG</option>
</div> </select>
<div className={styles.filterGroup}> </div>
<label className={styles.filterLabel}>Category</label> <div className={`${styles.filterGroup} ${styles.filterGroupGrow}`}>
<select <label className={styles.filterLabel}>Search</label>
className={styles.filterSelect} <input
value={category} type="text"
onChange={(e) => { setCategory(e.target.value); setPage(0); }} className={styles.filterInput}
> placeholder="Search actions, targets..."
<option value="">All</option> value={search}
<option value="INFRA">INFRA</option> onChange={(e) => { setSearch(e.target.value); setPage(0); }}
<option value="AUTH">AUTH</option> />
<option value="USER_MGMT">USER_MGMT</option> </div>
<option value="CONFIG">CONFIG</option> </div>
</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>
{/* Event list */} {/* Table area */}
<div className={layout.entityList}> <div className={styles.tableArea}>
{audit.isLoading ? ( {audit.isLoading ? (
<div className={layout.loading}>Loading...</div> <div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<div className={layout.loading}>No events found.</div> <div className={styles.emptyState}>
) : ( No audit events found for the selected filters.
data.items.map((event) => ( </div>
<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} key={event.id}
className={`${layout.entityItem} ${ event={event}
selectedEventId === event.id ? layout.entityItemSelected : '' isExpanded={expandedRow === event.id}
}`} onToggle={() =>
onClick={() => setSelectedEventId(event.id)} setExpandedRow((prev) => (prev === event.id ? null : event.id))
> }
<div className={layout.entityInfo}> />
<div className={styles.eventTimestamp}> ))}
{formatTimestamp(event.timestamp)} </tbody>
</div> </table>
<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>
</div> </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> </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 { function formatTimestamp(iso: string): string {
try { try {
const d = new Date(iso); const d = new Date(iso);