From e22724d93996f608349436ae15d854f7a092d688 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 09:54:42 +0000 Subject: [PATCH 1/2] Improve audit logs UX: pagination, filters, timestamps, and visual polish Browse: fix server-side pagination (was re-paginating server data client-side), add quick date range presets, Enter key to apply filters, status code filter, relative timestamps with tooltips, truncated path tooltips, better empty state. Detail: visual diff highlighting (red/green for old/new values), copy correlation ID button, consistent badge coloring for source/action/status, relative timestamps, module column in correlated entries, current entry highlighting. Dashboard: quick date range presets, clickable KPI cards that navigate to filtered browse view (total events, avg response, error rate). --- .../SimpleModule.AuditLogs/Views/Browse.tsx | 601 ++++++++++++------ .../Views/Dashboard.tsx | 50 +- .../SimpleModule.AuditLogs/Views/Detail.tsx | 503 +++++++++------ 3 files changed, 783 insertions(+), 371 deletions(-) diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx index 1e92394a..152f3d59 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx @@ -4,8 +4,8 @@ import { Button, Card, CardContent, - DataGridPage, Input, + PageShell, Select, SelectContent, SelectItem, @@ -17,8 +17,12 @@ import { TableHead, TableHeader, TableRow, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from '@simplemodule/ui'; -import { useState } from 'react'; +import { type FormEvent, useState } from 'react'; import type { AuditEntry, AuditQueryRequest } from '../types'; interface PagedResult { @@ -53,6 +57,13 @@ const ACTION_LABELS: Record = { 10: 'Other', }; +const DATE_PRESETS = [ + { label: 'Last hour', hours: 1 }, + { label: 'Last 24h', hours: 24 }, + { label: 'Last 7 days', hours: 168 }, + { label: 'Last 30 days', hours: 720 }, +]; + function sourceBadgeVariant(source: number) { if (source === 1) return 'success' as const; if (source === 2) return 'warning' as const; @@ -87,7 +98,21 @@ function formatTimestamp(iso: string): string { }); } -function buildFilterParams(f: Partial): Record { +function relativeTime(iso: string): string { + const now = Date.now(); + const then = new Date(iso).getTime(); + const diffSec = Math.floor((now - then) / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return formatTimestamp(iso); +} + +function buildFilterParams(f: Partial, page?: number): Record { const params: Record = {}; if (f.from) params.from = String(f.from); if (f.to) params.to = String(f.to); @@ -95,9 +120,41 @@ function buildFilterParams(f: Partial): Record 1) params.page = String(page); return params; } +function ChevronLeft() { + return ( + + ); +} + +function ChevronRight() { + return ( + + ); +} + export default function Browse({ result, filters }: Props) { const [from, setFrom] = useState(filters.from ? String(filters.from) : ''); const [to, setTo] = useState(filters.to ? String(filters.to) : ''); @@ -105,17 +162,28 @@ export default function Browse({ result, filters }: Props) { const [action, setAction] = useState(filters.action != null ? String(filters.action) : '__all__'); const [module, setModule] = useState(filters.module ?? ''); const [searchText, setSearchText] = useState(filters.searchText ?? ''); + const [statusCode, setStatusCode] = useState( + filters.statusCode != null ? String(filters.statusCode) : '__all__', + ); + + const totalPages = Math.max(1, Math.ceil(result.totalCount / result.pageSize)); + const currentPage = result.page; - function applyFilters() { - const params = buildFilterParams({ + function currentFilters() { + return { from: from || undefined, to: to || undefined, source: source !== '__all__' ? Number(source) : undefined, action: action !== '__all__' ? Number(action) : undefined, module: module || undefined, searchText: searchText || undefined, - }); - router.get('/audit-logs/browse', params); + statusCode: statusCode !== '__all__' ? Number(statusCode) : undefined, + }; + } + + function applyFilters(e?: FormEvent) { + e?.preventDefault(); + router.get('/audit-logs/browse', buildFilterParams(currentFilters())); } function clearFilters() { @@ -125,192 +193,359 @@ export default function Browse({ result, filters }: Props) { setAction('__all__'); setModule(''); setSearchText(''); + setStatusCode('__all__'); router.get('/audit-logs/browse'); } - function exportLogs(format: string) { - const params = buildFilterParams({ - from: from || undefined, - to: to || undefined, - source: source !== '__all__' ? Number(source) : undefined, - action: action !== '__all__' ? Number(action) : undefined, - module: module || undefined, - searchText: searchText || undefined, + function applyDatePreset(hours: number) { + const now = new Date(); + const past = new Date(now.getTime() - hours * 60 * 60 * 1000); + const toLocal = now.toISOString().slice(0, 16); + const fromLocal = past.toISOString().slice(0, 16); + setFrom(fromLocal); + setTo(toLocal); + router.get( + '/audit-logs/browse', + buildFilterParams({ + ...currentFilters(), + from: fromLocal, + to: toLocal, + }), + ); + } + + function goToPage(page: number) { + router.get('/audit-logs/browse', buildFilterParams(currentFilters(), page), { + preserveState: true, }); - const query = new URLSearchParams({ ...params, format }).toString(); + } + + function exportLogs(format: string) { + const query = new URLSearchParams({ + ...buildFilterParams(currentFilters()), + format, + }).toString(); window.location.href = `/api/audit-logs/export?${query}`; } - const filterPanel = ( - - -
-
- - setFrom(e.target.value)} - /> -
-
- - setTo(e.target.value)} - /> -
-
- Source - -
-
- Action - -
-
- - setModule(e.target.value)} - /> -
-
- - setSearchText(e.target.value)} - /> -
-
-
- - -
-
-
- ); + const hasActiveFilters = + from || + to || + source !== '__all__' || + action !== '__all__' || + module || + searchText || + statusCode !== '__all__'; + + const startItem = (currentPage - 1) * result.pageSize + 1; + const endItem = Math.min(currentPage * result.pageSize, result.totalCount); return ( - - - - - } - data={result.items} - filterBar={filterPanel} - emptyTitle="No audit logs found" - emptyDescription="Try adjusting your filters or check back later." - > - {(pageData) => ( - - - - Timestamp - Source - User - Action - Module - Path - Status - Duration - - - - {pageData.map((entry) => ( - router.get(`/audit-logs/${entry.id}`)} - > - - {formatTimestamp(entry.timestamp)} - - - - {SOURCE_LABELS[entry.source] ?? 'Unknown'} - - - - {entry.userName || entry.userId || '\u2014'} - - - {entry.action != null ? ( - - {ACTION_LABELS[entry.action] ?? 'Unknown'} - - ) : ( - '\u2014' + + + + + + } + > + {/* Filter Panel */} + + + {/* Quick date presets */} +
+ Quick range: + {DATE_PRESETS.map((preset) => ( + + ))} +
+ +
+
+
+ + setFrom(e.target.value)} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+ Source + +
+
+ Action + +
+
+ Status + +
+
+ + setModule(e.target.value)} + /> +
+
+ + setSearchText(e.target.value)} + /> +
+
+ + {hasActiveFilters && ( + )} - - {entry.module || '\u2014'} - - {entry.path || '\u2014'} - - - {entry.statusCode != null ? ( - {entry.statusCode} +
+
+ +
+
+ + {/* Results Table */} + {result.items.length === 0 ? ( + + +

No audit logs found

+

+ {hasActiveFilters + ? 'Try adjusting your filters or selecting a different date range.' + : 'Audit entries will appear here as activity occurs.'} +

+ {hasActiveFilters && ( + + )} +
+
+ ) : ( + + +
+ + + Time + Source + User + Action + Module + Path + Status + Duration + + + + {result.items.map((entry) => ( + router.get(`/audit-logs/${entry.id}`)} + > + + + + {relativeTime(entry.timestamp)} + + {formatTimestamp(entry.timestamp)} + + + + + {SOURCE_LABELS[entry.source] ?? 'Unknown'} + + + + {entry.userName || entry.userId || '\u2014'} + + + {entry.action != null ? ( + + {ACTION_LABELS[entry.action] ?? 'Unknown'} + + ) : ( + '\u2014' + )} + + {entry.module || '\u2014'} + + {entry.path ? ( + + + {entry.path} + + + + {entry.httpMethod && `${entry.httpMethod} `} + {entry.path} + + + + ) : ( + '\u2014' + )} + + + {entry.statusCode != null ? ( + + {entry.statusCode} + + ) : ( + '\u2014' + )} + + + {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} + + + ))} + +
+ + + )} + + {/* Server-side Pagination */} + {result.totalCount > 0 && ( +
+ + Showing {startItem}\u2013{endItem} of {result.totalCount.toLocaleString()} entries + + {totalPages > 1 && ( +
+ + {paginationRange(currentPage, totalPages).map((p, i) => + p === 'ellipsis' ? ( + + ... + ) : ( - '\u2014' - )} - - - {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} - - - ))} - - - )} - + + ), + )} + +
+ )} +
+ )} + + ); } + +/** Build a compact pagination range: 1 ... 4 5 [6] 7 8 ... 20 */ +function paginationRange(current: number, total: number): (number | 'ellipsis')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + const pages: (number | 'ellipsis')[] = []; + pages.push(1); + if (current > 3) pages.push('ellipsis'); + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (current < total - 2) pages.push('ellipsis'); + pages.push(total); + return pages; +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Dashboard.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Dashboard.tsx index 17958e9c..6f98eb63 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Dashboard.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Dashboard.tsx @@ -67,6 +67,13 @@ const STATUS_COLORS: Record = { Other: 'var(--color-muted)', }; +const DATE_PRESETS = [ + { label: 'Last 24h', hours: 24 }, + { label: 'Last 7 days', hours: 168 }, + { label: 'Last 30 days', hours: 720 }, + { label: 'Last 90 days', hours: 2160 }, +]; + // ---- Helpers ---- function dictToChartData(dict: Record): { name: string; value: number }[] { @@ -86,15 +93,17 @@ function KpiCard({ value, subtitle, accent, + onClick, }: { title: string; value: string; subtitle?: string; accent?: 'default' | 'danger'; + onClick?: () => void; }) { return ( - - + +

{title}

(new Date(to)); const [selectedUser, setSelectedUser] = useState(userId || '__all__'); + function navigate(params: Record) { + router.get('/audit-logs/dashboard', params); + } + function applyFilters() { const params: Record = {}; if (dateFrom) params.from = dateFrom.toISOString(); if (dateTo) params.to = dateTo.toISOString(); if (selectedUser && selectedUser !== '__all__') params.userId = selectedUser; - router.get('/audit-logs/dashboard', params); + navigate(params); + } + + function applyDatePreset(hours: number) { + const now = new Date(); + const past = new Date(now.getTime() - hours * 60 * 60 * 1000); + setDateFrom(past); + setDateTo(now); + const params: Record = { + from: past.toISOString(), + to: now.toISOString(), + }; + if (selectedUser && selectedUser !== '__all__') params.userId = selectedUser; + navigate(params); + } + + function browseWithFilter(params: Record) { + router.get('/audit-logs/browse', { from, to, ...params }); } // Prepare chart data @@ -290,6 +320,17 @@ export default function Dashboard({ stats, from, to, userId, users }: Props) { description="System activity overview and metrics" actions={

+ {/* Quick date presets */} + {DATE_PRESETS.map((preset) => ( + + ))}
From @@ -324,18 +365,21 @@ export default function Dashboard({ stats, from, to, userId, users }: Props) { title="Total Events" value={stats.totalEntries.toLocaleString()} subtitle={`${formatDate(from)} \u2014 ${formatDate(to)}`} + onClick={() => browseWithFilter({})} /> 0 ? `${stats.averageDurationMs}ms` : '\u2014'} subtitle="HTTP requests" + onClick={() => browseWithFilter({ source: '0' })} /> 0 ? `${stats.errorRate}%` : '0%'} accent={stats.errorRate > 5 ? 'danger' : 'default'} subtitle="4xx + 5xx responses" + onClick={() => browseWithFilter({ statusCode: '400' })} />
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx index 6c559dc5..a14982e6 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx @@ -13,7 +13,12 @@ import { TableHead, TableHeader, TableRow, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from '@simplemodule/ui'; +import { useState } from 'react'; import type { AuditEntry } from '../types'; interface Props { @@ -36,6 +41,28 @@ const ACTION_LABELS: Record = { 10: 'Other', }; +function sourceBadgeVariant(source: number) { + if (source === 1) return 'success' as const; + if (source === 2) return 'warning' as const; + return 'default' as const; +} + +function actionBadgeVariant(action: number | null | undefined) { + if (action == null) return 'default' as const; + if (action === 0) return 'success' as const; + if (action === 2) return 'danger' as const; + if (action === 5 || action === 7) return 'warning' as const; + return 'info' as const; +} + +function statusBadgeVariant(statusCode: number | null | undefined) { + if (statusCode == null) return 'default' as const; + if (statusCode >= 200 && statusCode < 300) return 'success' as const; + if (statusCode >= 400 && statusCode < 500) return 'warning' as const; + if (statusCode >= 500) return 'danger' as const; + return 'default' as const; +} + function LabeledField({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -46,7 +73,28 @@ function LabeledField({ label, children }: { label: string; children: React.Reac } function formatTimestamp(ts: string): string { - return new Date(ts).toLocaleString(); + return new Date(ts).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function relativeTime(iso: string): string { + const now = Date.now(); + const then = new Date(iso).getTime(); + const diffSec = Math.floor((now - then) / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return formatTimestamp(iso); } function formatJson(raw: string): string { @@ -69,7 +117,6 @@ function parseChanges(raw: string): ChangeEntry[] { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return (parsed as Array>).map((item) => { - // Normalize both { old, new } and { oldValue, newValue } formats if ('old' in item || 'new' in item) { return { field: String(item.field ?? ''), old: item.old, new: item.new }; } @@ -102,6 +149,52 @@ function hasUpdateStyle(changes: ChangeEntry[]): boolean { return changes.some((c) => 'old' in c || 'new' in c); } +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + + + + {copied ? 'Copied!' : 'Copy to clipboard'} + + ); +} + export default function Detail({ entry, correlated }: Props) { const showHttp = !!entry.httpMethod; const showDomain = !!(entry.module || entry.entityType || entry.action != null); @@ -112,213 +205,253 @@ export default function Detail({ entry, correlated }: Props) { const isUpdate = hasUpdateStyle(changes); return ( - router.get('/audit-logs/browse')}> - Back to Browse - - } - breadcrumbs={[ - { label: 'Audit Logs', href: '/audit-logs/browse' }, - { label: `Entry #${entry.id}` }, - ]} - > - {/* Overview Card */} - - - Overview - - -
- {formatTimestamp(entry.timestamp)} - - {SOURCE_LABELS[entry.source] ?? `Unknown (${entry.source})`} - - - {entry.correlationId} - - {entry.userName || entry.userId} - {entry.ipAddress} - {entry.userAgent} -
-
-
- - {/* HTTP Details Card */} - {showHttp && ( + + router.get('/audit-logs/browse')}> + Back to Browse + + } + breadcrumbs={[ + { label: 'Audit Logs', href: '/audit-logs/browse' }, + { label: `Entry #${entry.id}` }, + ]} + > + {/* Overview Card */} - HTTP Details + Overview
- - - {entry.httpMethod} - - {entry.path} + + + + {relativeTime(entry.timestamp)} + + {formatTimestamp(entry.timestamp)} + - - {entry.queryString ? ( - {entry.queryString} - ) : ( - '\u2014' - )} - - - = 200 && entry.statusCode < 300 ? 'default' : 'destructive' - } - > - {entry.statusCode} + + + {SOURCE_LABELS[entry.source] ?? `Unknown (${entry.source})`} - - {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} + + + {entry.correlationId} + + + {entry.userName || entry.userId} + {entry.ipAddress} + {entry.userAgent}
- {entry.requestBody && ( -
-

Request Body

-
-                  {formatJson(entry.requestBody)}
-                
-
- )}
- )} - {/* Domain Details Card */} - {showDomain && ( - - - Domain Details - - -
- {entry.module} - {entry.entityType} - {entry.entityId} - - {entry.action != null ? ( - {ACTION_LABELS[entry.action] ?? `Unknown (${entry.action})`} - ) : ( - '\u2014' - )} - -
-
-
- )} + {/* HTTP Details Card */} + {showHttp && ( + + + HTTP Details + + +
+ + + {entry.httpMethod} + + {entry.path} + + + {entry.queryString ? ( + {entry.queryString} + ) : ( + '\u2014' + )} + + + {entry.statusCode} + + + {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} + +
+ {entry.requestBody && ( +
+

Request Body

+
+                    {formatJson(entry.requestBody)}
+                  
+
+ )} +
+
+ )} - {/* Changes Card */} - {showChanges && changes.length > 0 && ( - - - Changes - - - - - - Field - {isUpdate ? ( - <> - Old - New - + {/* Domain Details Card */} + {showDomain && ( + + + Domain Details + + +
+ {entry.module} + {entry.entityType} + {entry.entityId} + + {entry.action != null ? ( + + {ACTION_LABELS[entry.action] ?? `Unknown (${entry.action})`} + ) : ( - Value + '\u2014' )} - - - - {changes.map((change) => ( - - {change.field} + +
+
+
+ )} + + {/* Changes Card */} + {showChanges && changes.length > 0 && ( + + + Changes + + +
+ + + Field {isUpdate ? ( <> - - {change.old != null ? String(change.old) : '\u2014'} - - - {change.new != null ? String(change.new) : '\u2014'} - + Old Value + New Value ) : ( - - {change.value != null ? String(change.value) : '\u2014'} - + Value )} - ))} - -
-
-
- )} - - {/* Metadata Card */} - {showMetadata && ( - - - Metadata - - -
-              {formatJson(entry.metadata)}
-            
-
-
- )} - - {/* Correlated Entries Card */} - {correlated.length > 0 && ( - - - Correlated Entries - - - - - - ID - Timestamp - Source - Action - Path - - - - {correlated.map((e) => ( - router.get(`/audit-logs/${e.id}`)} - > - #{e.id} - {formatTimestamp(e.timestamp)} - {SOURCE_LABELS[e.source] ?? 'Unknown'} - - {e.action != null ? ( - - {ACTION_LABELS[e.action] ?? `Unknown (${e.action})`} - + + + {changes.map((change) => ( + + {change.field} + {isUpdate ? ( + <> + + {change.old != null ? ( + + {String(change.old)} + + ) : ( + \u2014 + )} + + + {change.new != null ? ( + + {String(change.new)} + + ) : ( + \u2014 + )} + + ) : ( - '\u2014' + + {change.value != null ? String(change.value) : '\u2014'} + )} - - {e.path || '\u2014'} + + ))} + +
+
+
+ )} + + {/* Metadata Card */} + {showMetadata && ( + + + Metadata + + +
+                {formatJson(entry.metadata)}
+              
+
+
+ )} + + {/* Correlated Entries Card */} + {correlated.length > 0 && ( + + + + Correlated Entries + + ({correlated.length} related) + + + + + + + + ID + Time + Source + Action + Module + Path - ))} - -
-
-
- )} -
+ + + {correlated.map((e) => ( + router.get(`/audit-logs/${e.id}`)} + > + #{e.id} + + + + {relativeTime(e.timestamp)} + + {formatTimestamp(e.timestamp)} + + + + + {SOURCE_LABELS[e.source] ?? 'Unknown'} + + + + {e.action != null ? ( + + {ACTION_LABELS[e.action] ?? `Unknown (${e.action})`} + + ) : ( + '\u2014' + )} + + {e.module || '\u2014'} + {e.path || '\u2014'} + + ))} + + + + + )} +
+ ); } From cf62acd7faeb2169d2339c05538c65abc937ff25 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 10:38:11 +0000 Subject: [PATCH 2/2] Simplify: extract shared utils, fix bugs found in code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract SOURCE_LABELS, ACTION_LABELS, badge variant functions, formatTimestamp, and relativeTime into utils/audit-utils.ts to eliminate duplication between Browse.tsx and Detail.tsx - Fix CopyButton setTimeout leak: use useEffect with cleanup to prevent state updates on unmounted components - Fix browseWithFilter passing full ISO strings to datetime-local inputs (slice to YYYY-MM-DDTHH:mm format) - Remove broken status code range filter (backend does exact-match on StatusCode, so "4xx" label was misleading — only matched 400) - Remove unnecessary navigate() wrapper in Dashboard - Remove redundant setState calls before navigation in applyDatePreset - Fix pagination ellipsis keys to avoid array-index-as-key lint error - Add aria-hidden to CopyButton SVGs for accessibility --- .../SimpleModule.AuditLogs/Views/Browse.tsx | 132 +++--------------- .../Views/Dashboard.tsx | 15 +- .../SimpleModule.AuditLogs/Views/Detail.tsx | 84 +++-------- .../utils/audit-utils.ts | 67 +++++++++ 4 files changed, 112 insertions(+), 186 deletions(-) create mode 100644 modules/AuditLogs/src/SimpleModule.AuditLogs/utils/audit-utils.ts diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx index 152f3d59..231c76bc 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Browse.tsx @@ -24,6 +24,15 @@ import { } from '@simplemodule/ui'; import { type FormEvent, useState } from 'react'; import type { AuditEntry, AuditQueryRequest } from '../types'; +import { + ACTION_LABELS, + actionBadgeVariant, + formatTimestamp, + relativeTime, + SOURCE_LABELS, + sourceBadgeVariant, + statusBadgeVariant, +} from '../utils/audit-utils'; interface PagedResult { items: T[]; @@ -37,26 +46,6 @@ interface Props { filters: AuditQueryRequest; } -const SOURCE_LABELS: Record = { - 0: 'HTTP', - 1: 'Domain', - 2: 'Changes', -}; - -const ACTION_LABELS: Record = { - 0: 'Created', - 1: 'Updated', - 2: 'Deleted', - 3: 'Viewed', - 4: 'Login OK', - 5: 'Login Fail', - 6: 'Perm Granted', - 7: 'Perm Revoked', - 8: 'Setting Changed', - 9: 'Exported', - 10: 'Other', -}; - const DATE_PRESETS = [ { label: 'Last hour', hours: 1 }, { label: 'Last 24h', hours: 24 }, @@ -64,54 +53,6 @@ const DATE_PRESETS = [ { label: 'Last 30 days', hours: 720 }, ]; -function sourceBadgeVariant(source: number) { - if (source === 1) return 'success' as const; - if (source === 2) return 'warning' as const; - return 'default' as const; -} - -function statusBadgeVariant(statusCode: number | null | undefined) { - if (statusCode == null) return 'default' as const; - if (statusCode >= 200 && statusCode < 300) return 'success' as const; - if (statusCode >= 400 && statusCode < 500) return 'warning' as const; - if (statusCode >= 500) return 'danger' as const; - return 'default' as const; -} - -function actionBadgeVariant(action: number | null | undefined) { - if (action == null) return 'default' as const; - if (action === 0) return 'success' as const; - if (action === 2) return 'danger' as const; - if (action === 5 || action === 7) return 'warning' as const; - return 'info' as const; -} - -function formatTimestamp(iso: string): string { - const d = new Date(iso); - return d.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function relativeTime(iso: string): string { - const now = Date.now(); - const then = new Date(iso).getTime(); - const diffSec = Math.floor((now - then) / 1000); - if (diffSec < 60) return 'just now'; - const diffMin = Math.floor(diffSec / 60); - if (diffMin < 60) return `${diffMin}m ago`; - const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; - const diffDay = Math.floor(diffHr / 24); - if (diffDay < 30) return `${diffDay}d ago`; - return formatTimestamp(iso); -} - function buildFilterParams(f: Partial, page?: number): Record { const params: Record = {}; if (f.from) params.from = String(f.from); @@ -120,7 +61,7 @@ function buildFilterParams(f: Partial, page?: number): Record if (f.action != null) params.action = String(f.action); if (f.module) params.module = f.module; if (f.searchText) params.searchText = f.searchText; - if (f.statusCode != null) params.statusCode = String(f.statusCode); + if (page && page > 1) params.page = String(page); return params; } @@ -162,9 +103,6 @@ export default function Browse({ result, filters }: Props) { const [action, setAction] = useState(filters.action != null ? String(filters.action) : '__all__'); const [module, setModule] = useState(filters.module ?? ''); const [searchText, setSearchText] = useState(filters.searchText ?? ''); - const [statusCode, setStatusCode] = useState( - filters.statusCode != null ? String(filters.statusCode) : '__all__', - ); const totalPages = Math.max(1, Math.ceil(result.totalCount / result.pageSize)); const currentPage = result.page; @@ -177,7 +115,6 @@ export default function Browse({ result, filters }: Props) { action: action !== '__all__' ? Number(action) : undefined, module: module || undefined, searchText: searchText || undefined, - statusCode: statusCode !== '__all__' ? Number(statusCode) : undefined, }; } @@ -187,13 +124,6 @@ export default function Browse({ result, filters }: Props) { } function clearFilters() { - setFrom(''); - setTo(''); - setSource('__all__'); - setAction('__all__'); - setModule(''); - setSearchText(''); - setStatusCode('__all__'); router.get('/audit-logs/browse'); } @@ -202,8 +132,6 @@ export default function Browse({ result, filters }: Props) { const past = new Date(now.getTime() - hours * 60 * 60 * 1000); const toLocal = now.toISOString().slice(0, 16); const fromLocal = past.toISOString().slice(0, 16); - setFrom(fromLocal); - setTo(toLocal); router.get( '/audit-logs/browse', buildFilterParams({ @@ -229,13 +157,7 @@ export default function Browse({ result, filters }: Props) { } const hasActiveFilters = - from || - to || - source !== '__all__' || - action !== '__all__' || - module || - searchText || - statusCode !== '__all__'; + from || to || source !== '__all__' || action !== '__all__' || module || searchText; const startItem = (currentPage - 1) * result.pageSize + 1; const endItem = Math.min(currentPage * result.pageSize, result.totalCount); @@ -331,21 +253,6 @@ export default function Browse({ result, filters }: Props) {
-
- Status - -
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx index a14982e6..5ebe8b66 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/Detail.tsx @@ -18,51 +18,23 @@ import { TooltipProvider, TooltipTrigger, } from '@simplemodule/ui'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { AuditEntry } from '../types'; +import { + ACTION_LABELS, + actionBadgeVariant, + formatTimestamp, + relativeTime, + SOURCE_LABELS, + sourceBadgeVariant, + statusBadgeVariant, +} from '../utils/audit-utils'; interface Props { entry: AuditEntry; correlated: AuditEntry[]; } -const SOURCE_LABELS: Record = { 0: 'HTTP', 1: 'Domain', 2: 'Changes' }; -const ACTION_LABELS: Record = { - 0: 'Created', - 1: 'Updated', - 2: 'Deleted', - 3: 'Viewed', - 4: 'Login OK', - 5: 'Login Fail', - 6: 'Perm Granted', - 7: 'Perm Revoked', - 8: 'Setting Changed', - 9: 'Exported', - 10: 'Other', -}; - -function sourceBadgeVariant(source: number) { - if (source === 1) return 'success' as const; - if (source === 2) return 'warning' as const; - return 'default' as const; -} - -function actionBadgeVariant(action: number | null | undefined) { - if (action == null) return 'default' as const; - if (action === 0) return 'success' as const; - if (action === 2) return 'danger' as const; - if (action === 5 || action === 7) return 'warning' as const; - return 'info' as const; -} - -function statusBadgeVariant(statusCode: number | null | undefined) { - if (statusCode == null) return 'default' as const; - if (statusCode >= 200 && statusCode < 300) return 'success' as const; - if (statusCode >= 400 && statusCode < 500) return 'warning' as const; - if (statusCode >= 500) return 'danger' as const; - return 'default' as const; -} - function LabeledField({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -72,31 +44,6 @@ function LabeledField({ label, children }: { label: string; children: React.Reac ); } -function formatTimestamp(ts: string): string { - return new Date(ts).toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function relativeTime(iso: string): string { - const now = Date.now(); - const then = new Date(iso).getTime(); - const diffSec = Math.floor((now - then) / 1000); - if (diffSec < 60) return 'just now'; - const diffMin = Math.floor(diffSec / 60); - if (diffMin < 60) return `${diffMin}m ago`; - const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; - const diffDay = Math.floor(diffHr / 24); - if (diffDay < 30) return `${diffDay}d ago`; - return formatTimestamp(iso); -} - function formatJson(raw: string): string { try { return JSON.stringify(JSON.parse(raw), null, 2); @@ -152,10 +99,15 @@ function hasUpdateStyle(changes: ChangeEntry[]): boolean { function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const id = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(id); + }, [copied]); + function handleCopy() { - navigator.clipboard.writeText(text); + navigator.clipboard.writeText(text).catch(() => {}); setCopied(true); - setTimeout(() => setCopied(false), 2000); } return ( @@ -173,6 +125,7 @@ function CopyButton({ text }: { text: string }) { stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" + aria-hidden="true" > @@ -183,6 +136,7 @@ function CopyButton({ text }: { text: string }) { stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" + aria-hidden="true" > diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/utils/audit-utils.ts b/modules/AuditLogs/src/SimpleModule.AuditLogs/utils/audit-utils.ts new file mode 100644 index 00000000..cf4482d5 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/utils/audit-utils.ts @@ -0,0 +1,67 @@ +export const SOURCE_LABELS: Record = { + 0: 'HTTP', + 1: 'Domain', + 2: 'Changes', +}; + +export const ACTION_LABELS: Record = { + 0: 'Created', + 1: 'Updated', + 2: 'Deleted', + 3: 'Viewed', + 4: 'Login OK', + 5: 'Login Fail', + 6: 'Perm Granted', + 7: 'Perm Revoked', + 8: 'Setting Changed', + 9: 'Exported', + 10: 'Other', +}; + +export function sourceBadgeVariant(source: number) { + if (source === 1) return 'success' as const; + if (source === 2) return 'warning' as const; + return 'default' as const; +} + +export function actionBadgeVariant(action: number | null | undefined) { + if (action == null) return 'default' as const; + if (action === 0) return 'success' as const; + if (action === 2) return 'danger' as const; + if (action === 5 || action === 7) return 'warning' as const; + return 'info' as const; +} + +export function statusBadgeVariant(statusCode: number | null | undefined) { + if (statusCode == null) return 'default' as const; + if (statusCode >= 200 && statusCode < 300) return 'success' as const; + if (statusCode >= 400 && statusCode < 500) return 'warning' as const; + if (statusCode >= 500) return 'danger' as const; + return 'default' as const; +} + +export function formatTimestamp(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +export function relativeTime(iso: string): string { + const now = Date.now(); + const then = new Date(iso).getTime(); + const diffSec = Math.floor((now - then) / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return formatTimestamp(iso); +}