From 5ef7e588b69e57bd1bdf274bfc5e1466f022591f Mon Sep 17 00:00:00 2001 From: DJP Date: Fri, 10 Apr 2026 17:17:14 -0400 Subject: [PATCH] feat: wire analytics to real data and add audit logging across all endpoints Replace mock chart data on reports page with real backend queries (jobs over time, locale stats, usage stats, quality metrics). Add audit logging to auth (login/login_failed), file management (upload/delete TM and reference files), and feedback submission so the system logs page shows complete activity. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/v1/files.py | 24 + backend/app/api/v1/output.py | 8 + backend/app/api/v1/reports.py | 28 ++ backend/app/auth/router.py | 21 +- backend/app/services/report_service.py | 84 +++- frontend/src/app/admin/logs/page.tsx | 5 + frontend/src/app/admin/reports/page.tsx | 447 +++++++++++------- frontend/src/components/admin/ReportChart.tsx | 3 +- frontend/src/lib/api.ts | 62 ++- 9 files changed, 513 insertions(+), 169 deletions(-) diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index bd80cab..2115917 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -11,10 +11,12 @@ from app.schemas.files import ( ReferenceFileResponse, TMFileResponse, ) +from app.services.audit_service import AuditService from app.services.file_service import FileService router = APIRouter(prefix="/files", tags=["files"]) file_service = FileService() +audit_service = AuditService() # ---- TM Files ---- @@ -39,6 +41,12 @@ async def upload_tm_file( db, client_id, locale_code, channel, file.file, file.filename, uploaded_by=current_user["user_id"], ) + await audit_service.log( + db, action="upload_tm", entity_type="tm_file", entity_id=str(tm.id), + user_id=current_user["user_id"], + details={"filename": tm.filename, "locale": locale_code, "channel": channel, "segments": tm.segment_count}, + ) + await db.commit() return FileUploadResponse( id=tm.id, filename=tm.filename, @@ -94,6 +102,11 @@ async def delete_tm_file( deleted = await file_service.delete_tm_file(db, file_id) if not deleted: raise HTTPException(status_code=404, detail="TM file not found") + await audit_service.log( + db, action="delete_tm", entity_type="tm_file", entity_id=str(file_id), + user_id=current_user["user_id"], + ) + await db.commit() # ---- Reference Files ---- @@ -120,6 +133,12 @@ async def upload_reference_file( db, client_id, file_type, locale_scope, file.file, file.filename, uploaded_by=current_user["user_id"], ) + await audit_service.log( + db, action="upload_reference", entity_type="reference_file", entity_id=str(ref.id), + user_id=current_user["user_id"], + details={"filename": ref.filename, "file_type": file_type.value, "locale_scope": locale_scope}, + ) + await db.commit() return FileUploadResponse( id=ref.id, filename=ref.filename, @@ -177,3 +196,8 @@ async def delete_reference_file( deleted = await file_service.delete_reference_file(db, file_id) if not deleted: raise HTTPException(status_code=404, detail="Reference file not found") + await audit_service.log( + db, action="delete_reference", entity_type="reference_file", entity_id=str(file_id), + user_id=current_user["user_id"], + ) + await db.commit() diff --git a/backend/app/api/v1/output.py b/backend/app/api/v1/output.py index d206175..68235c4 100644 --- a/backend/app/api/v1/output.py +++ b/backend/app/api/v1/output.py @@ -7,12 +7,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.schemas.feedback import FeedbackCreate, FeedbackResponse from app.schemas.output import OutputPreviewResponse +from app.services.audit_service import AuditService from app.services.feedback_service import FeedbackService from app.services.output_service import OutputService router = APIRouter(prefix="/output", tags=["output"]) output_service = OutputService() feedback_service = FeedbackService() +audit_service = AuditService() @router.get( @@ -49,6 +51,12 @@ async def create_feedback( feedback = await feedback_service.create_feedback( db, body, current_user["user_id"] ) + await audit_service.log( + db, action="feedback", entity_type="output_row", entity_id=str(body.output_row_id), + user_id=current_user["user_id"], + details={"flag_type": body.flag_type.value, "comment": body.comment}, + ) + await db.commit() return FeedbackResponse.model_validate(feedback) diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py index fdee7bf..c2ef107 100644 --- a/backend/app/api/v1/reports.py +++ b/backend/app/api/v1/reports.py @@ -48,3 +48,31 @@ async def get_quality_metrics( ) -> dict[str, Any]: """Get quality metrics (confidence tier distribution, feedback stats).""" return await report_service.get_quality_metrics(db, client_id=client_id) + + +@router.get("/locale-stats") +async def get_locale_stats( + client_id: UUID | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +) -> list[dict[str, Any]]: + """Get per-locale stats (tokens, cost, avg duration).""" + return await report_service.get_locale_stats( + db, client_id=client_id, date_from=date_from, date_to=date_to + ) + + +@router.get("/jobs-over-time") +async def get_jobs_over_time( + client_id: UUID | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +) -> list[dict[str, Any]]: + """Get job counts grouped by week for chart display.""" + return await report_service.get_jobs_over_time( + db, client_id=client_id, date_from=date_from, date_to=date_to + ) diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index c42a85d..0b2c750 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -1,26 +1,45 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from app.auth.schemas import LoginRequest, RefreshRequest, TokenResponse, UserClaims from app.auth.service import AuthService from app.dependencies import get_current_user, get_db +from app.services.audit_service import AuditService router = APIRouter(prefix="/auth", tags=["auth"]) auth_service = AuthService() +audit_service = AuditService() @router.post("/login", response_model=TokenResponse) async def login( body: LoginRequest, + request: Request, db: AsyncSession = Depends(get_db), ) -> TokenResponse: """Authenticate user and return access + refresh tokens.""" result = await auth_service.login(body.email, body.password, db) if result is None: + await audit_service.log( + db, action="login_failed", entity_type="user", entity_id=body.email, + details={"reason": "Invalid credentials"}, + ip_address=request.client.host if request.client else None, + ) + await db.commit() raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", ) + # Extract user_id from the access token claims + claims = auth_service.validate_token(result["access_token"]) + user_id = claims["sub"] if claims else body.email + await audit_service.log( + db, action="login", entity_type="user", entity_id=str(user_id), + user_id=user_id if claims else None, + details={"email": body.email}, + ip_address=request.client.host if request.client else None, + ) + await db.commit() return TokenResponse(**result) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 1209417..2df8110 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Any from uuid import UUID -from sqlalchemy import func, select +from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.audit import TokenUsageLog @@ -131,3 +131,85 @@ class ReportService: "confidence_tiers": tier_breakdown, "feedback_distribution": feedback_breakdown, } + + async def get_locale_stats( + self, + db: AsyncSession, + client_id: UUID | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + ) -> list[dict[str, Any]]: + """Get per-locale aggregate stats (tokens, cost, avg duration, job count).""" + query = select( + LocaleInstance.locale_code, + func.count(LocaleInstance.id).label("count"), + func.sum(LocaleInstance.token_usage).label("total_tokens"), + func.sum(LocaleInstance.estimated_cost).label("total_cost"), + func.avg( + func.extract("epoch", LocaleInstance.completed_at) + - func.extract("epoch", LocaleInstance.started_at) + ).label("avg_duration_seconds"), + ).where( + LocaleInstance.status == LocaleStatus.complete, + LocaleInstance.started_at.isnot(None), + LocaleInstance.completed_at.isnot(None), + ).group_by(LocaleInstance.locale_code) + + if client_id: + query = query.join(Job).where(Job.client_id == client_id) + if date_from: + query = query.join(Job, isouter=True).where(Job.created_at >= date_from) + if date_to: + if "job" not in str(query): + query = query.join(Job, isouter=True) + query = query.where(Job.created_at <= date_to) + + result = await db.execute(query) + return [ + { + "locale": row.locale_code, + "count": row.count, + "total_tokens": row.total_tokens or 0, + "total_cost": float(row.total_cost or 0.0), + "avg_duration_minutes": round((row.avg_duration_seconds or 0) / 60, 1), + } + for row in result.all() + ] + + async def get_jobs_over_time( + self, + db: AsyncSession, + client_id: UUID | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + ) -> list[dict[str, Any]]: + """Get job counts grouped by week.""" + query = select( + func.date_trunc("week", Job.created_at).label("week"), + func.count(Job.id).label("total"), + func.sum(case((Job.status == JobStatus.complete, 1), else_=0)).label("completed_raw"), + func.sum(case((Job.status == JobStatus.error, 1), else_=0)).label("errors_raw"), + ).group_by( + func.date_trunc("week", Job.created_at) + ).order_by( + func.date_trunc("week", Job.created_at) + ) + + if client_id: + query = query.where(Job.client_id == client_id) + if date_from: + query = query.where(Job.created_at >= date_from) + if date_to: + query = query.where(Job.created_at <= date_to) + + result = await db.execute(query) + rows = [] + for row in result.all(): + week_str = row.week.strftime("%b %d") if row.week else "?" + rows.append({ + "name": week_str, + "jobs": row.total or 0, + "completed": row.completed_raw or 0, + "errors": row.errors_raw or 0, + }) + return rows diff --git a/frontend/src/app/admin/logs/page.tsx b/frontend/src/app/admin/logs/page.tsx index 71d3374..33e204c 100644 --- a/frontend/src/app/admin/logs/page.tsx +++ b/frontend/src/app/admin/logs/page.tsx @@ -68,7 +68,12 @@ const ACTION_LABELS: Record = { delete: "JOB_DELETED", rerun_locale: "LOCALE_RERUN", login: "AUTH_LOGIN", + login_failed: "LOGIN_FAILED", feedback: "FEEDBACK", + upload_tm: "TM_UPLOADED", + delete_tm: "TM_DELETED", + upload_reference: "REF_UPLOADED", + delete_reference: "REF_DELETED", }; export default function LogsPage() { diff --git a/frontend/src/app/admin/reports/page.tsx b/frontend/src/app/admin/reports/page.tsx index 827c46c..55bd4ba 100644 --- a/frontend/src/app/admin/reports/page.tsx +++ b/frontend/src/app/admin/reports/page.tsx @@ -1,9 +1,8 @@ "use client"; -import React from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { AppShell } from "@/components/layout/AppShell"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { ReportChart } from "@/components/admin/ReportChart"; import { Select, @@ -12,46 +11,117 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { TrendingUp, Clock, Zap, Target } from "lucide-react"; - -const jobsByWeek = [ - { name: "W10", jobs: 12, completed: 10, errors: 2 }, - { name: "W11", jobs: 18, completed: 16, errors: 1 }, - { name: "W12", jobs: 15, completed: 14, errors: 1 }, - { name: "W13", jobs: 22, completed: 20, errors: 2 }, - { name: "W14", jobs: 28, completed: 25, errors: 3 }, - { name: "W15", jobs: 24, completed: 22, errors: 1 }, -]; - -const confidenceDistribution = [ - { name: "HIGH", value: 68, fill: "#067D62" }, - { name: "MODERATE", value: 24, fill: "#F0AD4E" }, - { name: "LOW", value: 8, fill: "#CC0C39" }, -]; - -const tokensByLocale = [ - { locale: "de-DE", input: 245000, output: 198000 }, - { locale: "fr-FR", input: 238000, output: 195000 }, - { locale: "it-IT", input: 221000, output: 182000 }, - { locale: "es-ES", input: 235000, output: 190000 }, - { locale: "nl-NL", input: 156000, output: 128000 }, - { locale: "sv-SE", input: 142000, output: 118000 }, - { locale: "pl-PL", input: 134000, output: 112000 }, - { locale: "pt-PT", input: 128000, output: 105000 }, -]; - -const avgDuration = [ - { locale: "de-DE", duration: 4.2 }, - { locale: "fr-FR", duration: 4.5 }, - { locale: "it-IT", duration: 4.8 }, - { locale: "es-ES", duration: 4.1 }, - { locale: "nl-NL", duration: 3.8 }, - { locale: "sv-SE", duration: 3.6 }, - { locale: "pl-PL", duration: 4.9 }, - { locale: "pt-PT", duration: 3.7 }, -]; +import { TrendingUp, Clock, Zap, Target, Loader2 } from "lucide-react"; +import { + getUsageStats, + getQualityMetrics, + getLocaleStats, + getJobsOverTime, + type UsageStats, + type QualityMetrics, + type LocaleStat, + type JobsOverTimeEntry, +} from "@/lib/api"; export default function ReportsPage() { + const [period, setPeriod] = useState("all"); + const [loading, setLoading] = useState(true); + const [usage, setUsage] = useState(null); + const [quality, setQuality] = useState(null); + const [localeStats, setLocaleStats] = useState([]); + const [jobsOverTime, setJobsOverTime] = useState([]); + + const fetchAll = useCallback(async () => { + setLoading(true); + + // Compute date_from based on period + let date_from: string | undefined; + if (period !== "all") { + const now = new Date(); + const days = period === "7d" ? 7 : period === "30d" ? 30 : 90; + const from = new Date(now.getTime() - days * 86400000); + date_from = from.toISOString(); + } + + const params = date_from ? { date_from } : undefined; + + try { + const [u, q, l, j] = await Promise.all([ + getUsageStats(params), + getQualityMetrics(), + getLocaleStats(params), + getJobsOverTime(params), + ]); + setUsage(u); + setQuality(q); + setLocaleStats(l); + setJobsOverTime(j); + } catch { + // Partial failure is ok, show what we have + } finally { + setLoading(false); + } + }, [period]); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + // Derived stats + const totalJobs = usage?.total_jobs || 0; + const statusBreakdown = usage?.status_breakdown || {}; + const completedJobs = + (statusBreakdown["complete"] || 0) + (statusBreakdown["exported"] || 0); + const errorJobs = statusBreakdown["error"] || 0; + const successRate = + totalJobs > 0 + ? Math.round(((totalJobs - errorJobs) / totalJobs) * 100) + : 0; + const totalTokens = usage?.total_tokens || 0; + const avgDurationAll = + localeStats.length > 0 + ? ( + localeStats.reduce((s, l) => s + l.avg_duration_minutes, 0) / + localeStats.length + ).toFixed(1) + : "0"; + + // Confidence chart data + const TIER_COLORS: Record = { + high: "#067D62", + moderate: "#F0AD4E", + low: "#CC0C39", + }; + const confidenceData = Object.entries(quality?.confidence_tiers || {}).map( + ([tier, count]) => ({ + name: tier.toUpperCase(), + value: count, + fill: TIER_COLORS[tier] || "#999", + }) + ); + + // Locale token chart + const tokensByLocale = localeStats.map((l) => ({ + locale: l.locale, + tokens: l.total_tokens, + cost: l.total_cost, + })); + + // Locale duration chart + const durationByLocale = localeStats.map((l) => ({ + locale: l.locale, + duration: l.avg_duration_minutes, + })); + + const periodLabel = + period === "7d" + ? "7d" + : period === "30d" + ? "30d" + : period === "90d" + ? "90d" + : ""; + return (
@@ -59,7 +129,7 @@ export default function ReportsPage() {

Platform usage and performance analytics

- @@ -72,130 +142,185 @@ export default function ReportsPage() {
- {/* Summary stats */} -
- - -
-
- -
-
-

119

-

Total Jobs (30d)

-
-
-
-
- - -
-
- -
-
-

94%

-

Success Rate

-
-
-
-
- - -
-
- -
-
-

4.2m

-

Avg Duration/Locale

-
-
-
-
- - -
-
- -
-
-

2.4M

-

Total Tokens

-
-
-
-
-
+ {loading ? ( +
+ +
+ ) : ( + <> + {/* Summary stats */} +
+ + +
+
+ +
+
+

{totalJobs}

+

+ Total Jobs{periodLabel ? ` (${periodLabel})` : ""} +

+
+
+
+
+ + +
+
+ +
+
+

{successRate}%

+

Success Rate

+
+
+
+
+ + +
+
+ +
+
+

{avgDurationAll}m

+

+ Avg Duration/Locale +

+
+
+
+
+ + +
+
+ +
+
+

+ {totalTokens > 1_000_000 + ? `${(totalTokens / 1_000_000).toFixed(1)}M` + : totalTokens > 1_000 + ? `${(totalTokens / 1_000).toFixed(1)}K` + : totalTokens} +

+

Total Tokens

+
+
+
+
+
- {/* Charts */} -
- - - Jobs by Week - - - - - + {/* Charts */} +
+ + + Jobs by Week + + + {jobsOverTime.length > 0 ? ( + + ) : ( +

+ No job data for this period. +

+ )} +
+
- - - Confidence Distribution - - - - - + + + + Confidence Distribution + + + + {confidenceData.length > 0 ? ( + + ) : ( +

+ No output data yet. +

+ )} +
+
- - - Token Usage by Locale - - - - - + + + + Token Usage by Locale + + + + {tokensByLocale.length > 0 ? ( + + ) : ( +

+ No locale data for this period. +

+ )} +
+
- - - - Avg Duration by Locale (minutes) - - - - - - -
+ + + + Avg Duration by Locale (minutes) + + + + {durationByLocale.length > 0 ? ( + + ) : ( +

+ No locale data for this period. +

+ )} +
+
+
+ + )}
); diff --git a/frontend/src/components/admin/ReportChart.tsx b/frontend/src/components/admin/ReportChart.tsx index c8d7c7d..996b248 100644 --- a/frontend/src/components/admin/ReportChart.tsx +++ b/frontend/src/components/admin/ReportChart.tsx @@ -23,7 +23,8 @@ interface DataKey { interface ReportChartProps { type: "bar" | "pie"; - data: Record[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Record[]; dataKeys: DataKey[]; xAxisKey: string; height?: number; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 93d812d..c9871a5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -527,12 +527,64 @@ export async function deleteReferenceFile(fileId: string): Promise { // ─── Analytics ─────────────────────────────────────────────────────── -export async function getAnalytics(params?: { - from?: string; - to?: string; +export interface UsageStats { + total_jobs: number; + total_tokens: number; + total_cost: number; + status_breakdown: Record; +} + +export interface QualityMetrics { + confidence_tiers: Record; + feedback_distribution: Record; +} + +export interface LocaleStat { + locale: string; + count: number; + total_tokens: number; + total_cost: number; + avg_duration_minutes: number; +} + +export interface JobsOverTimeEntry { + name: string; + jobs: number; + completed: number; + errors: number; +} + +export async function getUsageStats(params?: { + date_from?: string; + date_to?: string; client_id?: string; -}): Promise> { - const response = await api.get("/analytics", { params }); +}): Promise { + const response = await api.get("/reports/usage", { params }); + return response.data; +} + +export async function getQualityMetrics(params?: { + client_id?: string; +}): Promise { + const response = await api.get("/reports/quality", { params }); + return response.data; +} + +export async function getLocaleStats(params?: { + date_from?: string; + date_to?: string; + client_id?: string; +}): Promise { + const response = await api.get("/reports/locale-stats", { params }); + return response.data; +} + +export async function getJobsOverTime(params?: { + date_from?: string; + date_to?: string; + client_id?: string; +}): Promise { + const response = await api.get("/reports/jobs-over-time", { params }); return response.data; }