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:
parent
8b07a59da0
commit
84f37a4649
4 changed files with 224 additions and 116 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue