diff --git a/dashboard/src/pages/MetricsPage.tsx b/dashboard/src/pages/MetricsPage.tsx
new file mode 100644
index 0000000..9da7b7d
--- /dev/null
+++ b/dashboard/src/pages/MetricsPage.tsx
@@ -0,0 +1,220 @@
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ PieChart,
+ Pie,
+ Cell,
+ LineChart,
+ Line,
+} from 'recharts';
+import { useMetrics } from '../api/reviews';
+import { LoadingSpinner } from '../components/common/LoadingSpinner';
+
+const VERDICT_COLORS = {
+ approve: '#22c55e',
+ request_changes: '#ef4444',
+ comment: '#3b82f6',
+};
+
+const SEVERITY_COLORS = {
+ critical: '#dc2626',
+ high: '#f97316',
+ medium: '#eab308',
+ low: '#3b82f6',
+ info: '#9ca3af',
+};
+
+const AGENT_COLORS = {
+ security: '#ef4444',
+ style: '#3b82f6',
+ complexity: '#a855f7',
+};
+
+interface StatCardProps {
+ label: string;
+ value: string | number;
+ subtext?: string;
+}
+
+function StatCard({ label, value, subtext }: StatCardProps) {
+ return (
+
+
{label}
+
{value}
+ {subtext &&
{subtext}
}
+
+ );
+}
+
+export function MetricsPage() {
+ const { data: metrics, isLoading, error } = useMetrics();
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !metrics) {
+ return (
+
+
Failed to load metrics
+
{error?.message}
+
+ );
+ }
+
+ const verdictData = Object.entries(metrics.verdict_counts).map(
+ ([name, value]) => ({
+ name: name === 'request_changes' ? 'Changes' : name,
+ value,
+ }),
+ );
+
+ const severityData = Object.entries(metrics.severity_counts).map(
+ ([name, value]) => ({
+ name: name.charAt(0).toUpperCase() + name.slice(1),
+ value,
+ fill: SEVERITY_COLORS[name as keyof typeof SEVERITY_COLORS],
+ }),
+ );
+
+ const costByAgentData = Object.entries(metrics.cost_by_agent).map(
+ ([name, value]) => ({
+ name: name.charAt(0).toUpperCase() + name.slice(1),
+ cost: value,
+ fill: AGENT_COLORS[name as keyof typeof AGENT_COLORS],
+ }),
+ );
+
+ return (
+
+
+
Metrics
+
+ Review statistics and trends
+
+
+
+ {/* Stat Cards */}
+
+
+
+
+
+
+
+ {/* Charts */}
+
+ {/* Verdict Distribution */}
+
+
+ Verdict Distribution
+
+
+
+
+ `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`
+ }
+ >
+ {verdictData.map((_, index) => (
+ |
+ ))}
+
+
+
+
+
+
+ {/* Severity Distribution */}
+
+
+ Findings by Severity
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Reviews Over Time */}
+
+
+ Reviews Over Time
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Cost by Agent */}
+
+
+ Cost by Agent
+
+
+
+
+ `$${v.toFixed(2)}`} />
+
+ `$${Number(value).toFixed(4)}`} />
+
+
+
+
+
+
+ );
+}
diff --git a/src/arbiter/api/routes/reviews.py b/src/arbiter/api/routes/reviews.py
index 530a66b..b7ef27f 100644
--- a/src/arbiter/api/routes/reviews.py
+++ b/src/arbiter/api/routes/reviews.py
@@ -1,7 +1,8 @@
"""Review REST API endpoints."""
import logging
-from datetime import datetime
+from collections import defaultdict
+from datetime import datetime, timedelta
from typing import Annotated, Any
from fastapi import APIRouter, HTTPException, Query, status
@@ -12,6 +13,7 @@ from sqlalchemy.orm import selectinload
from arbiter.api.deps import DbSession, RedisClient
from arbiter.db.models import (
DeliberationStepModel,
+ FindingModel,
ReviewModel,
)
from arbiter.models.enums import Severity, Verdict
@@ -149,6 +151,25 @@ class ManualReviewResponse(BaseModel):
message: str
+class ReviewsByDay(BaseModel):
+ """Reviews per day."""
+
+ date: str
+ count: int
+
+
+class MetricsResponse(BaseModel):
+ """Metrics response schema."""
+
+ total_reviews: int
+ completed_reviews: int
+ average_cost_usd: float
+ verdict_counts: dict[str, int]
+ severity_counts: dict[str, int]
+ reviews_by_day: list[ReviewsByDay]
+ cost_by_agent: dict[str, float]
+
+
@router.get("", response_model=ReviewListResponse)
async def list_reviews(
db: DbSession,
@@ -222,6 +243,83 @@ async def list_reviews(
)
+@router.get("/metrics", response_model=MetricsResponse)
+async def get_metrics(
+ db: DbSession,
+) -> MetricsResponse:
+ """Get aggregate metrics for reviews."""
+ # Total and completed counts
+ total_query = select(func.count()).select_from(ReviewModel)
+ total_reviews = await db.scalar(total_query) or 0
+
+ completed_query = (
+ select(func.count()).select_from(ReviewModel).where(ReviewModel.status == "completed")
+ )
+ completed_reviews = await db.scalar(completed_query) or 0
+
+ # Average cost
+ avg_cost_query = select(func.avg(ReviewModel.total_cost_usd)).where(
+ ReviewModel.status == "completed"
+ )
+ avg_cost = await db.scalar(avg_cost_query) or 0.0
+
+ # Verdict counts
+ verdict_query = (
+ select(ReviewModel.verdict, func.count())
+ .where(ReviewModel.verdict.is_not(None))
+ .group_by(ReviewModel.verdict)
+ )
+ verdict_result = await db.execute(verdict_query)
+ verdict_counts: dict[str, int] = {}
+ for row in verdict_result:
+ verdict_value = row[0].value if hasattr(row[0], "value") else str(row[0])
+ verdict_counts[verdict_value] = row[1]
+
+ # Severity counts from findings
+ severity_query = select(FindingModel.severity, func.count()).group_by(FindingModel.severity)
+ severity_result = await db.execute(severity_query)
+ severity_counts: dict[str, int] = {}
+ for sev_row in severity_result:
+ severity_value = sev_row[0].value if hasattr(sev_row[0], "value") else str(sev_row[0])
+ severity_counts[severity_value] = int(sev_row[1])
+
+ # Reviews by day (last 30 days)
+ thirty_days_ago = datetime.now() - timedelta(days=30)
+ reviews_by_day_query = (
+ select(
+ func.date(ReviewModel.created_at).label("date"),
+ func.count().label("count"),
+ )
+ .where(ReviewModel.created_at >= thirty_days_ago)
+ .group_by(func.date(ReviewModel.created_at))
+ .order_by(func.date(ReviewModel.created_at))
+ )
+ reviews_by_day_result = await db.execute(reviews_by_day_query)
+ reviews_by_day = [
+ ReviewsByDay(date=str(day_row[0]), count=int(day_row[1]))
+ for day_row in reviews_by_day_result
+ ]
+
+ # Cost by agent - aggregate from all reviews
+ cost_by_agent: dict[str, float] = defaultdict(float)
+ cost_query = select(ReviewModel.cost_by_agent).where(ReviewModel.cost_by_agent.is_not(None))
+ cost_result = await db.execute(cost_query)
+ for cost_row in cost_result:
+ if cost_row[0]:
+ for agent, cost in cost_row[0].items():
+ cost_by_agent[agent] += cost
+
+ return MetricsResponse(
+ total_reviews=total_reviews,
+ completed_reviews=completed_reviews,
+ average_cost_usd=float(avg_cost),
+ verdict_counts=verdict_counts,
+ severity_counts=severity_counts,
+ reviews_by_day=reviews_by_day,
+ cost_by_agent=dict(cost_by_agent),
+ )
+
+
@router.get("/{review_id}", response_model=ReviewDetail)
async def get_review(
db: DbSession,