add metrics page
This commit is contained in:
220
dashboard/src/pages/MetricsPage.tsx
Normal file
220
dashboard/src/pages/MetricsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user