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; }