From 84f37a46499c8a523d5ce50fb8c44926dda5e023 Mon Sep 17 00:00:00 2001 From: DJP Date: Fri, 10 Apr 2026 16:59:36 -0400 Subject: [PATCH] feat: wire audit trail page to real backend data - Fix API path: frontend now calls /audit/logs (was /audit) - Backend eagerly loads User relationship for audit entries - Backend response includes user_name field instead of just user_id - Frontend logs page fetches real data with pagination - Derive INFO/WARN/ERROR levels from action type - Format details JSON into readable descriptions - Add loading state and empty state handling Co-Authored-By: Claude Opus 4.6 --- backend/app/api/v1/audit.py | 1 + backend/app/services/audit_service.py | 4 +- frontend/src/app/admin/logs/page.tsx | 317 +++++++++++++++++--------- frontend/src/lib/api.ts | 18 +- 4 files changed, 224 insertions(+), 116 deletions(-) diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py index dd80372..9196ca2 100644 --- a/backend/app/api/v1/audit.py +++ b/backend/app/api/v1/audit.py @@ -44,6 +44,7 @@ async def list_audit_logs( { "id": str(log.id), "user_id": str(log.user_id) if log.user_id else None, + "user_name": log.user.name if log.user else "System", "action": log.action, "entity_type": log.entity_type, "entity_id": log.entity_id, diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index db44337..94c95b5 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -4,6 +4,7 @@ from uuid import UUID from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models.audit import AuditLog @@ -67,9 +68,10 @@ class AuditService: total_result = await db.execute(count_query) total = total_result.scalar() or 0 - # Paginate + # Paginate and eager-load user relationship for name display query = query.order_by(AuditLog.timestamp.desc()) query = query.offset((page - 1) * page_size).limit(page_size) + query = query.options(selectinload(AuditLog.user)) result = await db.execute(query) logs = list(result.scalars().all()) diff --git a/frontend/src/app/admin/logs/page.tsx b/frontend/src/app/admin/logs/page.tsx index 1b6de87..71d3374 100644 --- a/frontend/src/app/admin/logs/page.tsx +++ b/frontend/src/app/admin/logs/page.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { AppShell } from "@/components/layout/AppShell"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { @@ -15,32 +16,43 @@ import { TableRow, } from "@/components/ui/table"; import { formatDateTime } from "@/lib/utils"; -import { Search } from "lucide-react"; +import { Search, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; +import { getAuditLogs, type AuditLogEntry } from "@/lib/api"; -interface AuditEntry { - id: string; - timestamp: string; - user: string; - action: string; - resource: string; - details: string; - level: "INFO" | "WARN" | "ERROR"; +// Derive level from action name for visual badges +function getLevel(action: string): "INFO" | "WARN" | "ERROR" { + const lower = action.toLowerCase(); + if (lower.includes("error") || lower.includes("fail")) return "ERROR"; + if (lower.includes("cancel") || lower.includes("delete") || lower.includes("warn")) + return "WARN"; + return "INFO"; } -const mockAuditLogs: AuditEntry[] = [ - { id: "log-001", timestamp: "2026-04-10T09:15:00Z", user: "Sarah Chen", action: "JOB_LAUNCHED", resource: "OMG-2026-0044", details: "Job launched with 4 locales: de-DE, fr-FR, it-IT, es-ES", level: "INFO" }, - { id: "log-002", timestamp: "2026-04-10T09:14:50Z", user: "Sarah Chen", action: "FILE_UPLOADED", resource: "OMG-2026-0044", details: "Source file uploaded: DDA26_BFW_source.xlsx (42 lines)", level: "INFO" }, - { id: "log-003", timestamp: "2026-04-10T07:00:00Z", user: "James Miller", action: "JOB_CREATED", resource: "OMG-2026-0045", details: "Derived locale job created for de-AT, fr-BE, nl-BE", level: "INFO" }, - { id: "log-004", timestamp: "2026-04-09T09:45:00Z", user: "System", action: "LOCALE_ERROR", resource: "MND-2026-0012", details: "fr-FR locale failed: TM file not found for fr-FR locale", level: "ERROR" }, - { id: "log-005", timestamp: "2026-04-09T09:40:00Z", user: "System", action: "LOCALE_COMPLETE", resource: "MND-2026-0012", details: "de-DE locale completed successfully (142s, 5600/4800 tokens)", level: "INFO" }, - { id: "log-006", timestamp: "2026-04-09T08:20:00Z", user: "Sarah Chen", action: "JOB_LAUNCHED", resource: "MND-2026-0012", details: "UEFA Champions League Promo launched with 3 locales", level: "INFO" }, - { id: "log-007", timestamp: "2026-04-08T14:30:00Z", user: "Sarah Chen", action: "JOB_CREATED", resource: "OMG-2026-0044", details: "DDA 26 (BFW) job created", level: "INFO" }, - { id: "log-008", timestamp: "2026-04-06T16:42:00Z", user: "System", action: "JOB_COMPLETE", resource: "OMG-2026-0043", details: "Spring Prime Day 2026 completed all 6 locales successfully", level: "INFO" }, - { id: "log-009", timestamp: "2026-04-05T11:25:00Z", user: "Sarah Chen", action: "TM_UPLOADED", resource: "TM_sv-SE_Master.tmx", details: "7,120 entries loaded for sv-SE", level: "INFO" }, - { id: "log-010", timestamp: "2026-04-05T09:15:00Z", user: "System", action: "AUTH_FAILED", resource: "d.schmidt@amazon.com", details: "Login attempt with invalid credentials", level: "WARN" }, - { id: "log-011", timestamp: "2026-04-04T08:30:00Z", user: "System", action: "JOB_COMPLETE", resource: "PHD-2026-0008", details: "Summer Sale Landing Pages completed all 6 locales (56 lines)", level: "INFO" }, - { id: "log-012", timestamp: "2026-04-03T11:15:00Z", user: "Emily Brown", action: "JOB_CREATED", resource: "PHD-2026-0008", details: "Summer Sale Landing Pages created with 6 locales", level: "INFO" }, -]; +// Format details JSON into a readable string +function formatDetails( + action: string, + entityType: string, + entityId: string, + details: Record | null +): string { + const parts: string[] = []; + + if (details) { + if (details.campaign_name) parts.push(String(details.campaign_name)); + if (details.filename) + parts.push(`File: ${details.filename}`); + if (details.line_count) + parts.push(`${details.line_count} lines`); + if (details.locale_code) + parts.push(`Locale: ${details.locale_code}`); + } + + if (parts.length > 0) return parts.join(" | "); + + // Fallback: describe the action + const actionLabel = action.replace(/_/g, " "); + return `${actionLabel} on ${entityType} ${entityId.substring(0, 8)}...`; +} const levelBadge: Record = { INFO: "green", @@ -48,31 +60,108 @@ const levelBadge: Record = { ERROR: "red", }; +const ACTION_LABELS: Record = { + create: "JOB_CREATED", + upload_source: "FILE_UPLOADED", + launch: "JOB_LAUNCHED", + cancel: "JOB_CANCELLED", + delete: "JOB_DELETED", + rerun_locale: "LOCALE_RERUN", + login: "AUTH_LOGIN", + feedback: "FEEDBACK", +}; + export default function LogsPage() { const [search, setSearch] = useState(""); const [tab, setTab] = useState("audit"); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); - const filteredLogs = mockAuditLogs.filter((log) => { + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const data = await getAuditLogs({ + page, + page_size: 50, + }); + setLogs(data.items); + setTotal(data.total); + setTotalPages(data.pages); + } catch { + setLogs([]); + } finally { + setLoading(false); + } + }, [page]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + // Client-side search filter on loaded page + const filteredLogs = logs.filter((log) => { if (!search) return true; const s = search.toLowerCase(); + const actionLabel = ACTION_LABELS[log.action] || log.action.toUpperCase(); return ( - log.action.toLowerCase().includes(s) || - log.resource.toLowerCase().includes(s) || - log.details.toLowerCase().includes(s) || - log.user.toLowerCase().includes(s) + actionLabel.toLowerCase().includes(s) || + log.entity_id.toLowerCase().includes(s) || + log.entity_type.toLowerCase().includes(s) || + log.user_name.toLowerCase().includes(s) || + JSON.stringify(log.details || {}).toLowerCase().includes(s) ); }); - const errorLogs = filteredLogs.filter( - (log) => log.level === "ERROR" || log.level === "WARN" - ); + const errorLogs = filteredLogs.filter((log) => { + const level = getLevel(log.action); + return level === "ERROR" || level === "WARN"; + }); + + const renderRow = (log: AuditLogEntry, showUser = true) => { + const level = getLevel(log.action); + const actionLabel = ACTION_LABELS[log.action] || log.action.toUpperCase(); + const details = formatDetails( + log.action, + log.entity_type, + log.entity_id, + log.details + ); + + return ( + + + {formatDateTime(log.timestamp)} + + {showUser && ( + {log.user_name} + )} + + + {actionLabel} + + + + {log.entity_type}/{log.entity_id.substring(0, 8)} + + + {details} + + + {level} + + + ); + }; return (

- System audit trail and error logs + System audit trail and error logs ({total} total entries)

@@ -97,96 +186,98 @@ export default function LogsPage() { - - - - Timestamp - User - Action - Resource - Details - Level - - - - {filteredLogs.map((log) => ( - - - {formatDateTime(log.timestamp)} - - {log.user} - - - {log.action} - - - - {log.resource} - - - {log.details} - - - - {log.level} - - + {loading ? ( +
+ +
+ ) : filteredLogs.length === 0 ? ( +
+ {logs.length === 0 + ? "No audit logs recorded yet." + : "No logs match your search."} +
+ ) : ( +
+ + + Timestamp + User + Action + Resource + Details + Level - ))} - -
+ + + {filteredLogs.map((log) => renderRow(log, true))} + + + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )}
- - - - Timestamp - Action - Resource - Details - Level - - - - {errorLogs.length === 0 ? ( + {loading ? ( +
+ +
+ ) : ( +
+ - - No errors or warnings found. - + Timestamp + Action + Resource + Details + Level - ) : ( - errorLogs.map((log) => ( - - - {formatDateTime(log.timestamp)} - - - - {log.action} - - - - {log.resource} - - - {log.details} - - - - {log.level} - + + + {errorLogs.length === 0 ? ( + + + No errors or warnings found. - )) - )} - -
+ ) : ( + errorLogs.map((log) => renderRow(log, false)) + )} + + + )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 87792d2..ad1bd7b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -525,12 +525,26 @@ export async function getAnalytics(params?: { // ─── Audit ─────────────────────────────────────────────────────────── +export interface AuditLogEntry { + id: string; + user_id: string | null; + user_name: string; + action: string; + entity_type: string; + entity_id: string; + details: Record | null; + timestamp: string; + ip_address: string | null; +} + export async function getAuditLogs(params?: { page?: number; page_size?: number; action?: string; -}): Promise>> { - const response = await api.get("/audit", { params }); + entity_type?: string; + search?: string; +}): Promise> { + const response = await api.get("/audit/logs", { params }); return response.data; }