From b5a0f60110b3c0c8f8cf349ee0877a1ab77f64fb Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Sun, 4 May 2025 17:12:42 +0000 Subject: [PATCH] add metrics page --- dashboard/src/pages/MetricsPage.tsx | 220 ++++++++++++++++++++++++++++ src/arbiter/api/routes/reviews.py | 100 ++++++++++++- 2 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/pages/MetricsPage.tsx 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,