All checks were successful
ci/woodpecker/push/web Pipeline was successful
- Fix prettier formatting for Tooltip formatter props (single-line) - Fix no-base-to-string by using typed props instead of Record<string, unknown> - Fix restrict-template-expressions by wrapping number in String() Refs #375 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
BarChart,
|
|
Bar,
|
|
PieChart,
|
|
Pie,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Legend,
|
|
} from "recharts";
|
|
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import {
|
|
fetchUsageSummary,
|
|
fetchTokenUsage,
|
|
fetchCostBreakdown,
|
|
fetchTaskOutcomes,
|
|
} from "@/lib/api/telemetry";
|
|
import type {
|
|
TimeRange,
|
|
UsageSummary,
|
|
TokenUsagePoint,
|
|
CostBreakdownItem,
|
|
TaskOutcomeItem,
|
|
} from "@/lib/api/telemetry";
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────
|
|
|
|
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
{ value: "7d", label: "7 Days" },
|
|
{ value: "30d", label: "30 Days" },
|
|
{ value: "90d", label: "90 Days" },
|
|
];
|
|
|
|
// Calm, PDA-friendly chart colors (no aggressive reds)
|
|
const CHART_COLORS = {
|
|
inputTokens: "#6366F1", // Indigo
|
|
outputTokens: "#38BDF8", // Sky blue
|
|
grid: "#E2E8F0", // Slate 200
|
|
barFill: "#818CF8", // Indigo 400
|
|
};
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
function formatNumber(value: number): string {
|
|
if (value >= 1_000_000) {
|
|
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
}
|
|
if (value >= 1_000) {
|
|
return `${(value / 1_000).toFixed(1)}K`;
|
|
}
|
|
return value.toFixed(0);
|
|
}
|
|
|
|
function formatCurrency(value: number): string {
|
|
return `$${value.toFixed(2)}`;
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
return `${(value * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
function formatDateLabel(dateStr: string): string {
|
|
const date = new Date(dateStr + "T00:00:00");
|
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
}
|
|
|
|
/**
|
|
* Map TaskOutcomeItem[] to recharts-compatible data with `fill` property.
|
|
* This replaces deprecated Cell component (removed in Recharts 4.0).
|
|
*/
|
|
function toFillData(
|
|
outcomes: TaskOutcomeItem[]
|
|
): { outcome: string; count: number; fill: string }[] {
|
|
return outcomes.map((item) => ({
|
|
outcome: item.outcome,
|
|
count: item.count,
|
|
fill: item.color,
|
|
}));
|
|
}
|
|
|
|
// ─── Sub-components ──────────────────────────────────────────────────
|
|
|
|
function SummaryCard({
|
|
title,
|
|
value,
|
|
subtitle,
|
|
}: {
|
|
title: string;
|
|
value: string;
|
|
subtitle?: string;
|
|
}): ReactElement {
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm font-medium text-gray-500">{title}</p>
|
|
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
|
|
{subtitle ? <p className="text-xs text-gray-400 mt-1">{subtitle}</p> : null}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function LoadingSkeleton(): ReactElement {
|
|
return (
|
|
<div className="space-y-6" data-testid="loading-skeleton">
|
|
{/* Summary cards skeleton */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardContent className="pt-6">
|
|
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse" />
|
|
<div className="h-8 bg-gray-200 rounded w-16 mt-2 animate-pulse" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
{/* Chart skeletons */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Card key={i} className={i === 0 ? "lg:col-span-2" : ""}>
|
|
<CardHeader>
|
|
<div className="h-6 bg-gray-200 rounded w-40 animate-pulse" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-64 bg-gray-100 rounded animate-pulse" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState(): ReactElement {
|
|
return (
|
|
<div
|
|
className="flex flex-col items-center justify-center py-16 text-center"
|
|
data-testid="empty-state"
|
|
>
|
|
<div className="text-4xl mb-4">📊</div>
|
|
<h2 className="text-xl font-semibold text-gray-700 mb-2">No usage data yet</h2>
|
|
<p className="text-gray-500 max-w-md">
|
|
Once you start using AI-powered features, your token usage and cost data will appear here.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page Component ─────────────────────────────────────────────
|
|
|
|
export default function UsagePage(): ReactElement {
|
|
const [timeRange, setTimeRange] = useState<TimeRange>("30d");
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isEmpty, setIsEmpty] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
|
const [tokenUsage, setTokenUsage] = useState<TokenUsagePoint[]>([]);
|
|
const [costBreakdown, setCostBreakdown] = useState<CostBreakdownItem[]>([]);
|
|
const [taskOutcomes, setTaskOutcomes] = useState<TaskOutcomeItem[]>([]);
|
|
|
|
const loadData = useCallback(async (range: TimeRange): Promise<void> => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const [summaryData, tokenData, costData, outcomeData] = await Promise.all([
|
|
fetchUsageSummary(range),
|
|
fetchTokenUsage(range),
|
|
fetchCostBreakdown(range),
|
|
fetchTaskOutcomes(range),
|
|
]);
|
|
|
|
setSummary(summaryData);
|
|
setTokenUsage(tokenData);
|
|
setCostBreakdown(costData);
|
|
setTaskOutcomes(outcomeData);
|
|
|
|
// Check if there's any meaningful data
|
|
setIsEmpty(summaryData.taskCount === 0);
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "We had trouble loading usage data. Please try again when you're ready."
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadData(timeRange);
|
|
}, [timeRange, loadData]);
|
|
|
|
function handleTimeRangeChange(range: TimeRange): void {
|
|
setTimeRange(range);
|
|
}
|
|
|
|
return (
|
|
<main className="container mx-auto px-4 py-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Usage</h1>
|
|
<p className="text-gray-600 mt-1">Token usage and cost overview</p>
|
|
</div>
|
|
|
|
{/* Time range selector */}
|
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1" role="group" aria-label="Time range">
|
|
{TIME_RANGES.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => {
|
|
handleTimeRangeChange(value);
|
|
}}
|
|
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
|
timeRange === value
|
|
? "bg-white text-gray-900 shadow-sm"
|
|
: "text-gray-600 hover:text-gray-900"
|
|
}`}
|
|
aria-pressed={timeRange === value}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error state */}
|
|
{error !== null ? (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
|
<p className="text-amber-800">{error}</p>
|
|
<button
|
|
onClick={() => void loadData(timeRange)}
|
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
) : isLoading ? (
|
|
<LoadingSkeleton />
|
|
) : isEmpty ? (
|
|
<EmptyState />
|
|
) : (
|
|
<div className="space-y-6">
|
|
{/* Summary Cards */}
|
|
<div
|
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
|
data-testid="summary-cards"
|
|
>
|
|
<SummaryCard
|
|
title="Total Tokens"
|
|
value={summary ? formatNumber(summary.totalTokens) : "0"}
|
|
subtitle="Input + Output"
|
|
/>
|
|
<SummaryCard
|
|
title="Estimated Cost"
|
|
value={summary ? formatCurrency(summary.totalCost) : "$0.00"}
|
|
subtitle="Based on provider pricing"
|
|
/>
|
|
<SummaryCard
|
|
title="Task Count"
|
|
value={summary ? formatNumber(summary.taskCount) : "0"}
|
|
subtitle="AI-assisted tasks"
|
|
/>
|
|
<SummaryCard
|
|
title="Quality Gate Pass Rate"
|
|
value={summary ? formatPercent(summary.avgQualityGatePassRate) : "0%"}
|
|
subtitle="Build, lint, test, typecheck"
|
|
/>
|
|
</div>
|
|
|
|
{/* Charts */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Token Usage Over Time — Full width */}
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Token Usage Over Time</CardTitle>
|
|
<CardDescription>Input and output tokens by day</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-72" data-testid="token-usage-chart">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={tokenUsage}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatDateLabel}
|
|
tick={{ fontSize: 12, fill: "#64748B" }}
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<YAxis
|
|
tickFormatter={formatNumber}
|
|
tick={{ fontSize: 12, fill: "#64748B" }}
|
|
width={60}
|
|
/>
|
|
<Tooltip
|
|
formatter={
|
|
((value: number, name: string) => [
|
|
formatNumber(value),
|
|
name === "inputTokens" ? "Input Tokens" : "Output Tokens",
|
|
]) as never
|
|
}
|
|
labelFormatter={((label: string) => formatDateLabel(label)) as never}
|
|
contentStyle={{
|
|
borderRadius: "8px",
|
|
border: "1px solid #E2E8F0",
|
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
|
}}
|
|
/>
|
|
<Legend
|
|
formatter={(value: string) =>
|
|
value === "inputTokens" ? "Input Tokens" : "Output Tokens"
|
|
}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="inputTokens"
|
|
stroke={CHART_COLORS.inputTokens}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
activeDot={{ r: 4 }}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="outputTokens"
|
|
stroke={CHART_COLORS.outputTokens}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
activeDot={{ r: 4 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Cost Breakdown by Model */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Cost by Model</CardTitle>
|
|
<CardDescription>Estimated cost breakdown</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-72" data-testid="cost-breakdown-chart">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={costBreakdown} layout="vertical">
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
|
<XAxis
|
|
type="number"
|
|
tickFormatter={(v: number) => formatCurrency(v)}
|
|
tick={{ fontSize: 12, fill: "#64748B" }}
|
|
/>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="model"
|
|
tick={{ fontSize: 11, fill: "#64748B" }}
|
|
width={140}
|
|
/>
|
|
<Tooltip
|
|
formatter={((value: number) => [formatCurrency(value), "Cost"]) as never}
|
|
contentStyle={{
|
|
borderRadius: "8px",
|
|
border: "1px solid #E2E8F0",
|
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
|
}}
|
|
/>
|
|
<Bar dataKey="cost" fill={CHART_COLORS.barFill} radius={[0, 4, 4, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Task Outcomes */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Task Outcomes</CardTitle>
|
|
<CardDescription>Distribution of task completion results</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div
|
|
className="h-72 flex items-center justify-center"
|
|
data-testid="task-outcomes-chart"
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={toFillData(taskOutcomes)}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={60}
|
|
outerRadius={100}
|
|
paddingAngle={2}
|
|
dataKey="count"
|
|
nameKey="outcome"
|
|
label={
|
|
((props: { outcome?: string; count?: number }) =>
|
|
`${props.outcome ?? ""}: ${String(props.count ?? 0)}`) as never
|
|
}
|
|
/>
|
|
<Tooltip
|
|
formatter={((value: number, name: string) => [value, name]) as never}
|
|
contentStyle={{
|
|
borderRadius: "8px",
|
|
border: "1px solid #E2E8F0",
|
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
|
}}
|
|
/>
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|