From 033cfcf5fca86a7631d04092e335f65d63e5954c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:39:55 +0100 Subject: [PATCH] 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) --- ui/src/pages/admin/AuditLogPage.module.css | 257 ++++++++++--- ui/src/pages/admin/AuditLogPage.tsx | 404 ++++++++++----------- 2 files changed, 387 insertions(+), 274 deletions(-) diff --git a/ui/src/pages/admin/AuditLogPage.module.css b/ui/src/pages/admin/AuditLogPage.module.css index cc2a5efd..1e1d8dd4 100644 --- a/ui/src/pages/admin/AuditLogPage.module.css +++ b/ui/src/pages/admin/AuditLogPage.module.css @@ -1,40 +1,24 @@ -/* ─── Filter Toggle ─── */ -.filterToggle { - 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 { +/* ─── Filter Bar ─── */ +.filterBar { display: flex; - flex-direction: column; - gap: 8px; + align-items: flex-end; + gap: 10px; padding: 10px 20px; border-bottom: 1px solid var(--border); -} - -.filtersCollapsed { - display: none; + flex-shrink: 0; + flex-wrap: wrap; } .filterGroup { display: flex; flex-direction: column; - gap: 2px; + gap: 3px; + min-width: 0; +} + +.filterGroupGrow { + flex: 1; + min-width: 140px; } .filterLabel { @@ -49,12 +33,12 @@ background: var(--bg-base); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 5px 8px; + padding: 6px 10px; color: var(--text-primary); - font-size: 11px; - outline: none; - transition: border-color 0.2s; + font-size: 12px; font-family: var(--font-body); + outline: none; + transition: border-color 0.15s; } .filterInput:focus { @@ -69,41 +53,98 @@ background: var(--bg-base); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 5px 8px; + padding: 6px 10px; color: var(--text-primary); - font-size: 11px; + font-size: 12px; + font-family: var(--font-body); outline: none; cursor: pointer; - font-family: var(--font-body); } -/* ─── Event Row Styles ─── */ -.eventTimestamp { - font-family: var(--font-mono); +/* ─── Table Area ─── */ +.tableArea { + 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-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; color: var(--text-muted); + background: var(--bg-surface); + border-bottom: 1px solid var(--border); white-space: nowrap; } -.eventAction { - color: var(--text-muted); - font-size: 11px; +.thTimestamp { + width: 170px; } -.eventCompact { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; +.thResult { + width: 90px; +} + +.table td { + padding: 8px 14px; 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 ─── */ .categoryBadge { display: inline-block; padding: 2px 8px; - border-radius: 99px; + border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; @@ -116,23 +157,64 @@ .resultBadge { display: inline-block; padding: 2px 8px; - border-radius: 99px; + border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.3px; } .resultSuccess { - background: rgba(34, 197, 94, 0.1); - color: #22c55e; + background: rgba(16, 185, 129, 0.12); + color: var(--green); } .resultFailure { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; + background: rgba(244, 63, 94, 0.12); + 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 { margin: 0; padding: 12px; @@ -147,9 +229,64 @@ word-break: break-word; } -/* ─── Mono ─── */ -.mono { - font-family: var(--font-mono); - font-size: 11px; - white-space: nowrap; +/* ─── Pagination ─── */ +.pagination { + display: flex; + align-items: center; + 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; + } } diff --git a/ui/src/pages/admin/AuditLogPage.tsx b/ui/src/pages/admin/AuditLogPage.tsx index d704f73a..6a27c999 100644 --- a/ui/src/pages/admin/AuditLogPage.tsx +++ b/ui/src/pages/admin/AuditLogPage.tsx @@ -37,8 +37,7 @@ function AuditLogContent() { const [category, setCategory] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); - const [selectedEventId, setSelectedEventId] = useState(null); - const [filtersVisible, setFiltersVisible] = useState(true); + const [expandedRow, setExpandedRow] = useState(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 (
+ {/* Header */}
Audit Log
- {data && ( -
- {data.totalCount.toLocaleString()} events -
- )} +
+ {data + ? `${data.totalCount.toLocaleString()} events` + : 'Loading...'} +
-
- {/* Left pane — event list */} -
- {/* Collapsible filter bar */} -
- -
-
- - { setFrom(e.target.value); setPage(0); }} - /> -
-
- - { setTo(e.target.value); setPage(0); }} - /> -
-
- - { setUsername(e.target.value); setPage(0); }} - /> -
-
- - -
-
- - { setSearch(e.target.value); setPage(0); }} - /> -
-
-
+ {/* Filter bar */} +
+
+ + { setFrom(e.target.value); setPage(0); }} + /> +
+
+ + { setTo(e.target.value); setPage(0); }} + /> +
+
+ + { setUsername(e.target.value); setPage(0); }} + /> +
+
+ + +
+
+ + { setSearch(e.target.value); setPage(0); }} + /> +
+
- {/* Event list */} -
- {audit.isLoading ? ( -
Loading...
- ) : !data || data.items.length === 0 ? ( -
No events found.
- ) : ( - data.items.map((event) => ( -
+ {audit.isLoading ? ( +
Loading...
+ ) : !data || data.items.length === 0 ? ( +
+ No audit events found for the selected filters. +
+ ) : ( + + + + + + + + + + + + + {data.items.map((event) => ( + setSelectedEventId(event.id)} - > -
-
- {formatTimestamp(event.timestamp)} -
-
- {event.username} - {event.action} -
-
- - {event.result} - -
-
- - )) - )} - - - {/* Pagination */} - {data && data.totalCount > 0 && ( -
- - - {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()} - - -
- )} - - - {/* Right pane — detail view */} -
- {!selectedEvent ? ( -
- Select an event to view details -
- ) : ( - <> -
-
Event Info
-
- Timestamp - - {formatTimestamp(selectedEvent.timestamp)} - -
-
- User - {selectedEvent.username} -
-
- Category - - {selectedEvent.category} - -
-
- Action - {selectedEvent.action} -
-
- Target - - {selectedEvent.target} - -
-
- Result - - - {selectedEvent.result} - - -
-
- IP Address - - {selectedEvent.ipAddress} - -
-
- User Agent - {selectedEvent.userAgent} -
-
- -
-
Detail Payload
-
-                  {JSON.stringify(selectedEvent.detail, null, 2)}
-                
-
- - )} -
+ event={event} + isExpanded={expandedRow === event.id} + onToggle={() => + setExpandedRow((prev) => (prev === event.id ? null : event.id)) + } + /> + ))} +
+
TimestampUserCategoryActionTargetResult
+ )}
+ + {/* Pagination */} + {data && data.totalCount > 0 && ( +
+ + + {showingFrom}--{showingTo} of {data.totalCount.toLocaleString()} + + +
+ )}
); } +function EventRow({ + event, + isExpanded, + onToggle, +}: { + event: { + id: number; + timestamp: string; + username: string; + category: string; + action: string; + target: string; + result: string; + detail: Record; + ipAddress: string; + userAgent: string; + }; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( + <> + + {formatTimestamp(event.timestamp)} + {event.username} + + {event.category} + + {event.action} + {event.target} + + + {event.result} + + + + {isExpanded && ( + + +
+
+
+ IP Address + {event.ipAddress} +
+
+ User Agent + {event.userAgent} +
+
+ {event.detail && Object.keys(event.detail).length > 0 && ( +
+                  {JSON.stringify(event.detail, null, 2)}
+                
+ )} +
+ + + )} + + ); +} + function formatTimestamp(iso: string): string { try { const d = new Date(iso);