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 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-10 16:59:36 -04:00
parent 8b07a59da0
commit 84f37a4649
4 changed files with 224 additions and 116 deletions

View file

@ -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,

View file

@ -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())

View file

@ -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<string, unknown> | 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<string, "green" | "amber" | "red"> = {
INFO: "green",
@ -48,31 +60,108 @@ const levelBadge: Record<string, "green" | "amber" | "red"> = {
ERROR: "red",
};
const ACTION_LABELS: Record<string, string> = {
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<AuditLogEntry[]>([]);
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 (
<TableRow key={log.id}>
<TableCell className="text-xs font-mono text-gray-500">
{formatDateTime(log.timestamp)}
</TableCell>
{showUser && (
<TableCell className="text-sm">{log.user_name}</TableCell>
)}
<TableCell>
<Badge variant="gray" className="font-mono text-xs">
{actionLabel}
</Badge>
</TableCell>
<TableCell className="text-sm font-mono">
{log.entity_type}/{log.entity_id.substring(0, 8)}
</TableCell>
<TableCell className="text-sm text-gray-600 max-w-[300px] truncate">
{details}
</TableCell>
<TableCell>
<Badge variant={levelBadge[level]}>{level}</Badge>
</TableCell>
</TableRow>
);
};
return (
<AppShell>
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-gray-500">
System audit trail and error logs
System audit trail and error logs ({total} total entries)
</p>
<div className="relative w-[300px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
@ -97,96 +186,98 @@ export default function LogsPage() {
<TabsContent value="audit">
<Card className="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Details</TableHead>
<TableHead className="w-[80px]">Level</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-xs font-mono text-gray-500">
{formatDateTime(log.timestamp)}
</TableCell>
<TableCell className="text-sm">{log.user}</TableCell>
<TableCell>
<Badge variant="gray" className="font-mono text-xs">
{log.action}
</Badge>
</TableCell>
<TableCell className="text-sm font-mono">
{log.resource}
</TableCell>
<TableCell className="text-sm text-gray-600 max-w-[300px] truncate">
{log.details}
</TableCell>
<TableCell>
<Badge variant={levelBadge[log.level]}>
{log.level}
</Badge>
</TableCell>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : filteredLogs.length === 0 ? (
<div className="py-16 text-center text-gray-400 text-sm">
{logs.length === 0
? "No audit logs recorded yet."
: "No logs match your search."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Details</TableHead>
<TableHead className="w-[80px]">Level</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => renderRow(log, true))}
</TableBody>
</Table>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100">
<p className="text-xs text-gray-400">
Page {page} of {totalPages}
</p>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</Card>
</TabsContent>
<TabsContent value="errors">
<Card className="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Details</TableHead>
<TableHead className="w-[80px]">Level</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errorLogs.length === 0 ? (
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableCell
colSpan={5}
className="text-center py-8 text-gray-400"
>
No errors or warnings found.
</TableCell>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Details</TableHead>
<TableHead className="w-[80px]">Level</TableHead>
</TableRow>
) : (
errorLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-xs font-mono text-gray-500">
{formatDateTime(log.timestamp)}
</TableCell>
<TableCell>
<Badge variant="gray" className="font-mono text-xs">
{log.action}
</Badge>
</TableCell>
<TableCell className="text-sm font-mono">
{log.resource}
</TableCell>
<TableCell className="text-sm text-gray-600">
{log.details}
</TableCell>
<TableCell>
<Badge variant={levelBadge[log.level]}>
{log.level}
</Badge>
</TableHeader>
<TableBody>
{errorLogs.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-8 text-gray-400"
>
No errors or warnings found.
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
errorLogs.map((log) => renderRow(log, false))
)}
</TableBody>
</Table>
)}
</Card>
</TabsContent>
</Tabs>

View file

@ -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<string, unknown> | null;
timestamp: string;
ip_address: string | null;
}
export async function getAuditLogs(params?: {
page?: number;
page_size?: number;
action?: string;
}): Promise<PaginatedResponse<Record<string, unknown>>> {
const response = await api.get("/audit", { params });
entity_type?: string;
search?: string;
}): Promise<PaginatedResponse<AuditLogEntry>> {
const response = await api.get("/audit/logs", { params });
return response.data;
}