add metrics page

This commit is contained in:
2025-05-04 17:12:42 +00:00
parent 43161635d0
commit b5a0f60110
2 changed files with 319 additions and 1 deletions

View File

@@ -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 (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
{subtext && <p className="text-xs text-gray-500 mt-1">{subtext}</p>}
</div>
);
}
export function MetricsPage() {
const { data: metrics, isLoading, error } = useMetrics();
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
);
}
if (error || !metrics) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
<p className="text-red-800">Failed to load metrics</p>
<p className="text-sm text-red-600 mt-1">{error?.message}</p>
</div>
);
}
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 (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Metrics</h1>
<p className="text-sm text-gray-600 mt-1">
Review statistics and trends
</p>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard label="Total Reviews" value={metrics.total_reviews} />
<StatCard
label="Completed"
value={metrics.completed_reviews}
subtext={`${Math.round((metrics.completed_reviews / metrics.total_reviews) * 100)}% completion rate`}
/>
<StatCard
label="Average Cost"
value={`$${metrics.average_cost_usd.toFixed(4)}`}
subtext="Per review"
/>
<StatCard
label="Total Cost"
value={`$${(metrics.average_cost_usd * metrics.completed_reviews).toFixed(2)}`}
subtext="All reviews"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Verdict Distribution */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Verdict Distribution
</h2>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={verdictData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
label={({ name, percent }) =>
`${name} (${((percent ?? 0) * 100).toFixed(0)}%)`
}
>
{verdictData.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={
Object.values(VERDICT_COLORS)[
index % Object.values(VERDICT_COLORS).length
]
}
/>
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
{/* Severity Distribution */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Findings by Severity
</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={severityData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Reviews Over Time */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Reviews Over Time
</h2>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={metrics.reviews_by_day}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="count"
stroke="#6366f1"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Cost by Agent */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Cost by Agent
</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={costByAgentData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => `$${v.toFixed(2)}`} />
<YAxis type="category" dataKey="name" width={80} />
<Tooltip formatter={(value) => `$${Number(value).toFixed(4)}`} />
<Bar dataKey="cost" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@@ -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,