Skip to content

Commit 2b8fbe8

Browse files
author
StackMemory Bot (CLI)
committed
feat(web): add GCP billing, spend calculator, and React dashboard components
1 parent bc3eb56 commit 2b8fbe8

17 files changed

Lines changed: 1129 additions & 0 deletions
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from "@/components/ui/card";
11+
12+
interface ModelSpend {
13+
modelKey: string;
14+
displayName: string;
15+
inputTokens: number;
16+
outputTokens: number;
17+
totalTokens: number;
18+
listCostUsd: number;
19+
effectiveCostUsd: number;
20+
}
21+
22+
interface SourceSpend {
23+
source: string;
24+
inputTokens: number;
25+
outputTokens: number;
26+
totalTokens: number;
27+
listCostUsd: number;
28+
effectiveCostUsd: number;
29+
}
30+
31+
interface DaySpend {
32+
date: string;
33+
inputTokens: number;
34+
outputTokens: number;
35+
totalTokens: number;
36+
listCostUsd: number;
37+
effectiveCostUsd: number;
38+
}
39+
40+
interface SpendSummary {
41+
totalInputTokens: number;
42+
totalOutputTokens: number;
43+
totalTokens: number;
44+
listCostUsd: number;
45+
effectiveCostUsd: number;
46+
discountMultiplier: number;
47+
formattedListCost: string;
48+
formattedEffectiveCost: string;
49+
bySource: Record<string, SourceSpend>;
50+
byModel: Record<string, ModelSpend>;
51+
byDay: DaySpend[];
52+
gcpSpendUsd?: number;
53+
gcpSpendFormatted?: string;
54+
gcpSource?: 'bigquery' | 'env';
55+
gcpTable?: string;
56+
gcpDaily?: { date: string; costUsd: number }[];
57+
}
58+
59+
function formatTokens(n: number): string {
60+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
61+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
62+
return n.toLocaleString();
63+
}
64+
65+
function formatUsd(n: number): string {
66+
return `$${n.toFixed(4)}`;
67+
}
68+
69+
export default function EvalsPage() {
70+
const [spend, setSpend] = useState<SpendSummary | null>(null);
71+
const [loading, setLoading] = useState(true);
72+
const [error, setError] = useState<string | null>(null);
73+
74+
useEffect(() => {
75+
fetch("/api/evals")
76+
.then((res) => {
77+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
78+
return res.json();
79+
})
80+
.then((data: SpendSummary) => {
81+
setSpend(data);
82+
setLoading(false);
83+
})
84+
.catch((err) => {
85+
setError(err instanceof Error ? err.message : String(err));
86+
setLoading(false);
87+
});
88+
}, []);
89+
90+
if (loading) {
91+
return (
92+
<div className="flex h-full items-center justify-center p-6">
93+
Loading AI spend estimate…
94+
</div>
95+
);
96+
}
97+
98+
if (error || !spend) {
99+
return (
100+
<div className="flex h-full items-center justify-center p-6 text-destructive">
101+
Failed to load spend estimate: {error || "unknown error"}
102+
</div>
103+
);
104+
}
105+
106+
const bySourceList = Object.values(spend.bySource);
107+
const byModelList = Object.values(spend.byModel);
108+
const hasGcp = spend.gcpSpendUsd !== undefined;
109+
110+
return (
111+
<div className="flex flex-col gap-6 p-6">
112+
<div>
113+
<h1 className="text-3xl font-bold">Evals</h1>
114+
<p className="text-muted-foreground">
115+
AI spend estimate across conductor traces and retrieval audits
116+
</p>
117+
</div>
118+
119+
{/* Summary Cards */}
120+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
121+
<Card>
122+
<CardHeader className="pb-2">
123+
<CardTitle className="text-sm font-medium">Effective Cost</CardTitle>
124+
</CardHeader>
125+
<CardContent>
126+
<div className="text-2xl font-bold text-green-600">
127+
{spend.formattedEffectiveCost}
128+
</div>
129+
<p className="text-xs text-muted-foreground">
130+
After {(spend.discountMultiplier * 100).toFixed(0)}% discount ramp
131+
</p>
132+
</CardContent>
133+
</Card>
134+
135+
<Card>
136+
<CardHeader className="pb-2">
137+
<CardTitle className="text-sm font-medium">List Cost</CardTitle>
138+
</CardHeader>
139+
<CardContent>
140+
<div className="text-2xl font-bold">{spend.formattedListCost}</div>
141+
<p className="text-xs text-muted-foreground">At posted model prices</p>
142+
</CardContent>
143+
</Card>
144+
145+
<Card>
146+
<CardHeader className="pb-2">
147+
<CardTitle className="text-sm font-medium">Total Tokens</CardTitle>
148+
</CardHeader>
149+
<CardContent>
150+
<div className="text-2xl font-bold text-blue-600">
151+
{formatTokens(spend.totalTokens)}
152+
</div>
153+
<p className="text-xs text-muted-foreground">
154+
{formatTokens(spend.totalInputTokens)} in /{" "}
155+
{formatTokens(spend.totalOutputTokens)} out
156+
</p>
157+
</CardContent>
158+
</Card>
159+
160+
<Card>
161+
<CardHeader className="pb-2">
162+
<CardTitle className="text-sm font-medium">GCP Spend</CardTitle>
163+
</CardHeader>
164+
<CardContent>
165+
<div className="text-2xl font-bold text-purple-600">
166+
{spend.gcpSpendFormatted ?? "—"}
167+
</div>
168+
<p className="text-xs text-muted-foreground">
169+
{spend.gcpSource === "bigquery"
170+
? `Live BigQuery ${spend.gcpTable ? ${spend.gcpTable}` : ""}`
171+
: hasGcp
172+
? "From GCP_AI_SPEND_USD"
173+
: "Set GCP billing env vars to include"}
174+
</p>
175+
</CardContent>
176+
</Card>
177+
</div>
178+
179+
{/* By Source */}
180+
<Card>
181+
<CardHeader>
182+
<CardTitle>By Source</CardTitle>
183+
<CardDescription>
184+
Token usage and cost grouped by subsystem
185+
</CardDescription>
186+
</CardHeader>
187+
<CardContent>
188+
{bySourceList.length === 0 ? (
189+
<p className="text-muted-foreground">No usage data found.</p>
190+
) : (
191+
<div className="overflow-x-auto">
192+
<table className="w-full text-sm">
193+
<thead>
194+
<tr className="border-b">
195+
<th className="text-left py-2 font-medium">Source</th>
196+
<th className="text-right py-2 font-medium">Tokens</th>
197+
<th className="text-right py-2 font-medium">List Cost</th>
198+
<th className="text-right py-2 font-medium">Effective Cost</th>
199+
</tr>
200+
</thead>
201+
<tbody>
202+
{bySourceList.map((s) => (
203+
<tr key={s.source} className="border-b last:border-0">
204+
<td className="py-2 capitalize">{s.source}</td>
205+
<td className="text-right py-2">
206+
{formatTokens(s.totalTokens)}
207+
</td>
208+
<td className="text-right py-2">{formatUsd(s.listCostUsd)}</td>
209+
<td className="text-right py-2">
210+
{formatUsd(s.effectiveCostUsd)}
211+
</td>
212+
</tr>
213+
))}
214+
</tbody>
215+
</table>
216+
</div>
217+
)}
218+
</CardContent>
219+
</Card>
220+
221+
{/* By Model */}
222+
<Card>
223+
<CardHeader>
224+
<CardTitle>By Model</CardTitle>
225+
<CardDescription>
226+
Cost breakdown per provider/model pair
227+
</CardDescription>
228+
</CardHeader>
229+
<CardContent>
230+
{byModelList.length === 0 ? (
231+
<p className="text-muted-foreground">No model usage data found.</p>
232+
) : (
233+
<div className="overflow-x-auto">
234+
<table className="w-full text-sm">
235+
<thead>
236+
<tr className="border-b">
237+
<th className="text-left py-2 font-medium">Model</th>
238+
<th className="text-right py-2 font-medium">Tokens</th>
239+
<th className="text-right py-2 font-medium">List Cost</th>
240+
<th className="text-right py-2 font-medium">Effective Cost</th>
241+
</tr>
242+
</thead>
243+
<tbody>
244+
{byModelList.map((m) => (
245+
<tr key={m.modelKey} className="border-b last:border-0">
246+
<td className="py-2">{m.displayName}</td>
247+
<td className="text-right py-2">
248+
{formatTokens(m.totalTokens)}
249+
</td>
250+
<td className="text-right py-2">{formatUsd(m.listCostUsd)}</td>
251+
<td className="text-right py-2">
252+
{formatUsd(m.effectiveCostUsd)}
253+
</td>
254+
</tr>
255+
))}
256+
</tbody>
257+
</table>
258+
</div>
259+
)}
260+
</CardContent>
261+
</Card>
262+
263+
{/* By Day */}
264+
<Card>
265+
<CardHeader>
266+
<CardTitle>Daily Trend</CardTitle>
267+
<CardDescription>Spend and tokens per day</CardDescription>
268+
</CardHeader>
269+
<CardContent>
270+
{spend.byDay.length === 0 ? (
271+
<p className="text-muted-foreground">No daily data found.</p>
272+
) : (
273+
<div className="overflow-x-auto">
274+
<table className="w-full text-sm">
275+
<thead>
276+
<tr className="border-b">
277+
<th className="text-left py-2 font-medium">Date</th>
278+
<th className="text-right py-2 font-medium">Tokens</th>
279+
<th className="text-right py-2 font-medium">List Cost</th>
280+
<th className="text-right py-2 font-medium">Effective Cost</th>
281+
</tr>
282+
</thead>
283+
<tbody>
284+
{spend.byDay.map((d) => (
285+
<tr key={d.date} className="border-b last:border-0">
286+
<td className="py-2">{d.date}</td>
287+
<td className="text-right py-2">
288+
{formatTokens(d.totalTokens)}
289+
</td>
290+
<td className="text-right py-2">{formatUsd(d.listCostUsd)}</td>
291+
<td className="text-right py-2">
292+
{formatUsd(d.effectiveCostUsd)}
293+
</td>
294+
</tr>
295+
))}
296+
</tbody>
297+
</table>
298+
</div>
299+
)}
300+
</CardContent>
301+
</Card>
302+
303+
{/* GCP Daily Spend */}
304+
{spend.gcpSource === "bigquery" && (
305+
<Card>
306+
<CardHeader>
307+
<CardTitle>GCP Daily Spend</CardTitle>
308+
<CardDescription>
309+
Live GCP cost from BigQuery{" "}
310+
{spend.gcpTable ? ${spend.gcpTable}` : ""}
311+
</CardDescription>
312+
</CardHeader>
313+
<CardContent>
314+
{!spend.gcpDaily || spend.gcpDaily.length === 0 ? (
315+
<p className="text-muted-foreground">No GCP daily data found.</p>
316+
) : (
317+
<div className="overflow-x-auto">
318+
<table className="w-full text-sm">
319+
<thead>
320+
<tr className="border-b">
321+
<th className="text-left py-2 font-medium">Date</th>
322+
<th className="text-right py-2 font-medium">GCP Cost</th>
323+
</tr>
324+
</thead>
325+
<tbody>
326+
{spend.gcpDaily.map((d) => (
327+
<tr key={d.date} className="border-b last:border-0">
328+
<td className="py-2">{d.date}</td>
329+
<td className="text-right py-2">{formatUsd(d.costUsd)}</td>
330+
</tr>
331+
))}
332+
</tbody>
333+
</table>
334+
</div>
335+
)}
336+
</CardContent>
337+
</Card>
338+
)}
339+
</div>
340+
);
341+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
@tailwind base;
2+
@tailwind components;
3+
@tailwind utilities;
4+
5+
:root {
6+
--background: 0 0% 100%;
7+
--foreground: 222.2 84% 4.9%;
8+
--card: 0 0% 100%;
9+
--card-foreground: 222.2 84% 4.9%;
10+
--popover: 0 0% 100%;
11+
--popover-foreground: 222.2 84% 4.9%;
12+
--primary: 222.2 47.4% 11.2%;
13+
--primary-foreground: 210 40% 98%;
14+
--secondary: 210 40% 96.1%;
15+
--secondary-foreground: 222.2 47.4% 11.2%;
16+
--muted: 210 40% 96.1%;
17+
--muted-foreground: 215.4 16.3% 46.9%;
18+
--accent: 210 40% 96.1%;
19+
--accent-foreground: 222.2 47.4% 11.2%;
20+
--destructive: 0 84.2% 60.2%;
21+
--destructive-foreground: 210 40% 98%;
22+
--border: 214.3 31.8% 91.4%;
23+
--input: 214.3 31.8% 91.4%;
24+
--ring: 222.2 84% 4.9%;
25+
--radius: 0.5rem;
26+
}
27+
28+
.dark {
29+
--background: 222.2 84% 4.9%;
30+
--foreground: 210 40% 98%;
31+
--card: 222.2 84% 4.9%;
32+
--card-foreground: 210 40% 98%;
33+
--popover: 222.2 84% 4.9%;
34+
--popover-foreground: 210 40% 98%;
35+
--primary: 210 40% 98%;
36+
--primary-foreground: 222.2 47.4% 11.2%;
37+
--secondary: 217.2 32.6% 17.5%;
38+
--secondary-foreground: 210 40% 98%;
39+
--muted: 217.2 32.6% 17.5%;
40+
--muted-foreground: 215 20.2% 65.1%;
41+
--accent: 217.2 32.6% 17.5%;
42+
--accent-foreground: 210 40% 98%;
43+
--destructive: 0 62.8% 30.6%;
44+
--destructive-foreground: 210 40% 98%;
45+
--border: 217.2 32.6% 17.5%;
46+
--input: 217.2 32.6% 17.5%;
47+
--ring: 212.7 26.8% 83.9%;
48+
}
49+
50+
* {
51+
border-color: hsl(var(--border));
52+
}
53+
54+
body {
55+
background-color: hsl(var(--background));
56+
color: hsl(var(--foreground));
57+
}

0 commit comments

Comments
 (0)