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."""
|
"""Review REST API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
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.api.deps import DbSession, RedisClient
|
||||||
from arbiter.db.models import (
|
from arbiter.db.models import (
|
||||||
DeliberationStepModel,
|
DeliberationStepModel,
|
||||||
|
FindingModel,
|
||||||
ReviewModel,
|
ReviewModel,
|
||||||
)
|
)
|
||||||
from arbiter.models.enums import Severity, Verdict
|
from arbiter.models.enums import Severity, Verdict
|
||||||
@@ -149,6 +151,25 @@ class ManualReviewResponse(BaseModel):
|
|||||||
message: str
|
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)
|
@router.get("", response_model=ReviewListResponse)
|
||||||
async def list_reviews(
|
async def list_reviews(
|
||||||
db: DbSession,
|
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)
|
@router.get("/{review_id}", response_model=ReviewDetail)
|
||||||
async def get_review(
|
async def get_review(
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
|||||||
Reference in New Issue
Block a user