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 <noreply@anthropic.com>
This commit is contained in:
parent
b11b2df0e2
commit
5ef7e588b6
9 changed files with 513 additions and 169 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -68,7 +68,12 @@ const ACTION_LABELS: Record<string, string> = {
|
|||
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() {
|
||||
|
|
|
|||
|
|
@ -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<UsageStats | null>(null);
|
||||
const [quality, setQuality] = useState<QualityMetrics | null>(null);
|
||||
const [localeStats, setLocaleStats] = useState<LocaleStat[]>([]);
|
||||
const [jobsOverTime, setJobsOverTime] = useState<JobsOverTimeEntry[]>([]);
|
||||
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<AppShell>
|
||||
<div className="space-y-6">
|
||||
|
|
@ -59,7 +129,7 @@ export default function ReportsPage() {
|
|||
<p className="text-sm text-gray-500">
|
||||
Platform usage and performance analytics
|
||||
</p>
|
||||
<Select defaultValue="30d">
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -72,130 +142,185 @@ export default function ReportsPage() {
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amazon-teal/10 flex items-center justify-center">
|
||||
<TrendingUp className="h-5 w-5 text-amazon-teal" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">119</p>
|
||||
<p className="text-xs text-gray-500">Total Jobs (30d)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amazon-success/10 flex items-center justify-center">
|
||||
<Target className="h-5 w-5 text-amazon-success" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">94%</p>
|
||||
<p className="text-xs text-gray-500">Success Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amazon-orange/10 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-amazon-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">4.2m</p>
|
||||
<p className="text-xs text-gray-500">Avg Duration/Locale</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">2.4M</p>
|
||||
<p className="text-xs text-gray-500">Total Tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amazon-teal/10 flex items-center justify-center">
|
||||
<TrendingUp className="h-5 w-5 text-amazon-teal" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{totalJobs}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Total Jobs{periodLabel ? ` (${periodLabel})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amazon-success/10 flex items-center justify-center">
|
||||
<Target className="h-5 w-5 text-amazon-success" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{successRate}%</p>
|
||||
<p className="text-xs text-gray-500">Success Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amazon-orange/10 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-amazon-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{avgDurationAll}m</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Avg Duration/Locale
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{totalTokens > 1_000_000
|
||||
? `${(totalTokens / 1_000_000).toFixed(1)}M`
|
||||
: totalTokens > 1_000
|
||||
? `${(totalTokens / 1_000).toFixed(1)}K`
|
||||
: totalTokens}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Total Tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jobs by Week</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportChart
|
||||
type="bar"
|
||||
data={jobsByWeek}
|
||||
dataKeys={[
|
||||
{ key: "completed", color: "#067D62", name: "Completed" },
|
||||
{ key: "errors", color: "#CC0C39", name: "Errors" },
|
||||
]}
|
||||
xAxisKey="name"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jobs by Week</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{jobsOverTime.length > 0 ? (
|
||||
<ReportChart
|
||||
type="bar"
|
||||
data={jobsOverTime}
|
||||
dataKeys={[
|
||||
{
|
||||
key: "completed",
|
||||
color: "#067D62",
|
||||
name: "Completed",
|
||||
},
|
||||
{ key: "errors", color: "#CC0C39", name: "Errors" },
|
||||
]}
|
||||
xAxisKey="name"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-12">
|
||||
No job data for this period.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Confidence Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportChart
|
||||
type="pie"
|
||||
data={confidenceDistribution}
|
||||
dataKeys={[{ key: "value", color: "#FF9900", name: "Count" }]}
|
||||
xAxisKey="name"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Confidence Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{confidenceData.length > 0 ? (
|
||||
<ReportChart
|
||||
type="pie"
|
||||
data={confidenceData}
|
||||
dataKeys={[
|
||||
{ key: "value", color: "#FF9900", name: "Count" },
|
||||
]}
|
||||
xAxisKey="name"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-12">
|
||||
No output data yet.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Token Usage by Locale</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportChart
|
||||
type="bar"
|
||||
data={tokensByLocale}
|
||||
dataKeys={[
|
||||
{ key: "input", color: "#232F3E", name: "Input" },
|
||||
{ key: "output", color: "#FF9900", name: "Output" },
|
||||
]}
|
||||
xAxisKey="locale"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Token Usage by Locale
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tokensByLocale.length > 0 ? (
|
||||
<ReportChart
|
||||
type="bar"
|
||||
data={tokensByLocale}
|
||||
dataKeys={[
|
||||
{ key: "tokens", color: "#232F3E", name: "Tokens" },
|
||||
]}
|
||||
xAxisKey="locale"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-12">
|
||||
No locale data for this period.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Avg Duration by Locale (minutes)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportChart
|
||||
type="bar"
|
||||
data={avgDuration}
|
||||
dataKeys={[
|
||||
{ key: "duration", color: "#007185", name: "Duration" },
|
||||
]}
|
||||
xAxisKey="locale"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Avg Duration by Locale (minutes)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{durationByLocale.length > 0 ? (
|
||||
<ReportChart
|
||||
type="bar"
|
||||
data={durationByLocale}
|
||||
dataKeys={[
|
||||
{
|
||||
key: "duration",
|
||||
color: "#007185",
|
||||
name: "Duration",
|
||||
},
|
||||
]}
|
||||
xAxisKey="locale"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-12">
|
||||
No locale data for this period.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ interface DataKey {
|
|||
|
||||
interface ReportChartProps {
|
||||
type: "bar" | "pie";
|
||||
data: Record<string, unknown>[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: Record<string, any>[];
|
||||
dataKeys: DataKey[];
|
||||
xAxisKey: string;
|
||||
height?: number;
|
||||
|
|
|
|||
|
|
@ -527,12 +527,64 @@ export async function deleteReferenceFile(fileId: string): Promise<void> {
|
|||
|
||||
// ─── 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<string, number>;
|
||||
}
|
||||
|
||||
export interface QualityMetrics {
|
||||
confidence_tiers: Record<string, number>;
|
||||
feedback_distribution: Record<string, number>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>> {
|
||||
const response = await api.get("/analytics", { params });
|
||||
}): Promise<UsageStats> {
|
||||
const response = await api.get("/reports/usage", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getQualityMetrics(params?: {
|
||||
client_id?: string;
|
||||
}): Promise<QualityMetrics> {
|
||||
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<LocaleStat[]> {
|
||||
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<JobsOverTimeEntry[]> {
|
||||
const response = await api.get("/reports/jobs-over-time", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue