Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

billing-usage.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

billing-usage.tsxBlame690 lines · 1 contributor
8809b87Claude1/**
2 * /billing/usage — Per-repo + per-agent AI cost dashboard.
3 *
4 * GET /billing/usage — the personal AI spend dashboard
5 * POST /billing/usage/budget — set monthly AI budget (cents)
6 *
7 * The dashboard is observational (we do NOT block calls on overspend yet —
8 * that's a future hard-gate). It surfaces:
9 *
10 * - Stat cards: This month, Last month, Daily average, Projected EOM
11 * - 30-day trend sparkline (inline SVG, no chart library)
12 * - Breakdown by category, by repo, by agent
13 * - Monthly budget form + an exceeded-warning banner
14 *
15 * All CSS is scoped under `.cost-*`. Reuses the 2026 hero/orb pattern from
16 * `/settings/billing` so the surface feels familiar.
17 */
18
19import { Hono } from "hono";
20import { and, desc, eq, gte, lte } from "drizzle-orm";
21import { db } from "../db";
22import {
23 aiBudgets,
24 aiCostEvents,
25 agentSessions,
26 repositories,
27 users,
28} from "../db/schema";
29import { Layout } from "../views/layout";
30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import {
33 aggregateEvents,
34 dailyAverageCents,
35 formatCents,
36 formatTokens,
37 projectMonthEndCents,
38 startOfUtcMonth,
39 summarizeCostsForRepo,
40 summarizeCostsForUser,
41 toUtcDayKey,
42} from "../lib/ai-cost-tracker";
43import type { CostSummary } from "../lib/ai-cost-tracker";
44
45const usage = new Hono<AuthEnv>();
46usage.use("*", softAuth);
47
48// ─── Scoped CSS (all `.cost-*`) ───────────────────────────────────────────
49const styles = `
eed4684Claude50 .cost-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
8809b87Claude51
52 .cost-hero {
53 position: relative;
54 margin-bottom: var(--space-5);
55 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
56 background: var(--bg-elevated);
57 border: 1px solid var(--border);
58 border-radius: 18px;
59 overflow: hidden;
60 }
61 .cost-hero::before {
62 content: '';
63 position: absolute; top: 0; left: 0; right: 0; height: 2px;
6fd5915Claude64 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8809b87Claude65 opacity: 0.75; pointer-events: none;
66 }
67 .cost-orb {
68 position: absolute;
69 inset: -30% -10% auto auto;
70 width: 460px; height: 460px;
6fd5915Claude71 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
8809b87Claude72 filter: blur(80px); opacity: 0.7;
73 pointer-events: none; z-index: 0;
74 }
75 .cost-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
76 .cost-hero-text { max-width: 680px; flex: 1; min-width: 240px; }
77 .cost-eyebrow {
78 display: inline-flex; align-items: center; gap: 8px;
79 font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.18em;
80 text-transform: uppercase; color: var(--text-muted); font-weight: 600;
81 margin-bottom: 16px;
82 }
83 .cost-eyebrow-dot {
84 width: 8px; height: 8px; border-radius: 9999px;
6fd5915Claude85 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
86 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8809b87Claude87 }
88 .cost-eyebrow strong { color: var(--accent); font-weight: 600; letter-spacing: 0.04em; }
89 .cost-title {
90 font-family: var(--font-display);
91 font-size: clamp(32px, 5vw, 48px);
92 font-weight: 800;
93 letter-spacing: -0.030em;
94 line-height: 1.05;
95 margin: 0 0 var(--space-3);
96 color: var(--text-strong);
97 }
98 .cost-title-grad {
6fd5915Claude99 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
8809b87Claude100 -webkit-background-clip: text; background-clip: text;
101 -webkit-text-fill-color: transparent; color: transparent;
102 }
103 .cost-total {
104 font-family: var(--font-mono);
105 font-variant-numeric: tabular-nums;
106 font-size: clamp(28px, 4vw, 40px);
107 font-weight: 700;
108 color: var(--text-strong);
109 letter-spacing: -0.02em;
110 margin-top: 4px;
111 }
112 .cost-total small {
113 font-size: 13px; color: var(--text-muted);
114 margin-left: 10px; font-weight: 500;
115 }
116 .cost-sub { font-size: 16px; color: var(--text-muted); margin: 0; line-height: 1.55; max-width: 580px; }
117
118 /* Banner */
119 .cost-banner {
120 margin-bottom: var(--space-4);
121 padding: 10px 14px;
122 border-radius: 10px;
123 font-size: 13.5px;
124 border: 1px solid var(--border);
125 background: rgba(255,255,255,0.025);
126 color: var(--text);
127 display: flex; align-items: center; gap: 10px;
128 }
129 .cost-banner.is-warn { border-color: rgba(245,158,11,0.40); background: rgba(245,158,11,0.08); color: #fde68a; }
130 .cost-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
131 .cost-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
132
133 /* Stat cards */
134 .cost-stats {
135 display: grid;
136 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
137 gap: var(--space-3);
138 margin-bottom: var(--space-5);
139 }
140 .cost-stat {
141 background: var(--bg-elevated);
142 border: 1px solid var(--border);
143 border-radius: 14px;
144 padding: var(--space-4);
145 display: flex; flex-direction: column; gap: 6px;
146 }
147 .cost-stat-head {
148 font-size: 10.5px; color: var(--text-muted);
149 text-transform: uppercase; letter-spacing: 0.14em;
150 font-family: var(--font-mono); font-weight: 600;
151 }
152 .cost-stat-val {
153 font-family: var(--font-mono); font-variant-numeric: tabular-nums;
154 font-size: 24px; font-weight: 700; color: var(--text-strong);
155 letter-spacing: -0.02em;
156 }
157 .cost-stat-sub { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
158
159 /* Section card (shared) */
160 .cost-section {
161 margin-bottom: var(--space-5);
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 14px;
165 overflow: hidden;
166 }
167 .cost-section-head {
168 padding: var(--space-4) var(--space-5);
169 border-bottom: 1px solid var(--border);
170 display: flex; align-items: flex-start; justify-content: space-between;
171 gap: var(--space-3); flex-wrap: wrap;
172 }
173 .cost-section-title {
174 margin: 0; font-family: var(--font-display); font-size: 16px;
175 font-weight: 700; color: var(--text-strong); letter-spacing: -0.014em;
176 }
177 .cost-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
178 .cost-section-body { padding: var(--space-4) var(--space-5); }
179
180 /* Trend chart */
181 .cost-trend { width: 100%; height: 140px; display: block; }
6fd5915Claude182 .cost-trend-line { stroke: #5b6ee8; stroke-width: 2; fill: none; }
8809b87Claude183 .cost-trend-fill { fill: url(#cost-grad); opacity: 0.45; }
184 .cost-trend-axis { stroke: var(--border); stroke-width: 1; opacity: 0.6; }
185 .cost-trend-label { fill: var(--text-muted); font-family: var(--font-mono); font-size: 10px; }
186
187 /* Breakdown table */
188 .cost-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
189 .cost-table th { text-align: left; font-weight: 600; color: var(--text-muted); padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; }
190 .cost-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); font-variant-numeric: tabular-nums; }
191 .cost-table td.is-num { text-align: right; font-family: var(--font-mono); }
192 .cost-table tr:last-child td { border-bottom: none; }
193 .cost-table .cost-cat-pill {
194 display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 11px;
6fd5915Claude195 background: rgba(91,110,232,0.12); color: #c4b5fd; border: 1px solid rgba(91,110,232,0.30);
8809b87Claude196 font-family: var(--font-mono);
197 }
198 .cost-empty { color: var(--text-muted); font-size: 13px; font-style: italic; padding: 6px 0; }
199
200 /* Budget form */
201 .cost-budget-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
202 .cost-budget-form label { font-size: 13px; color: var(--text-muted); }
203 .cost-budget-form input[type="number"] {
204 flex: 0 0 140px; padding: 8px 12px;
205 background: var(--bg-secondary, rgba(0,0,0,0.15));
206 border: 1px solid var(--border); border-radius: 8px; color: var(--text);
207 font-family: var(--font-mono); font-variant-numeric: tabular-nums;
208 }
209 .cost-budget-form button {
210 padding: 8px 16px; border-radius: 8px;
6fd5915Claude211 border: 1px solid rgba(91,110,232,0.40);
212 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.12));
8809b87Claude213 color: var(--text-strong); font-weight: 600; cursor: pointer; font-size: 13px;
214 }
6fd5915Claude215 .cost-budget-form button:hover { border-color: rgba(91,110,232,0.60); }
8809b87Claude216 .cost-budget-note { margin-top: 10px; font-size: 12px; color: var(--text-muted); }
217
218 .cost-foot { margin-top: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--border); font-size: 12.5px; color: var(--text-muted); text-align: center; }
219 .cost-foot a { color: var(--accent); }
220
221 .cost-sublinks { display: flex; gap: var(--space-3); flex-wrap: wrap; font-size: 13px; margin-bottom: var(--space-4); }
222 .cost-sublinks a { color: var(--text-muted); text-decoration: none; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: rgba(255,255,255,0.02); }
223 .cost-sublinks a:hover { color: var(--text-strong); border-color: var(--border-strong); }
6fd5915Claude224 .cost-sublinks a.is-current { color: var(--text-strong); border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.08); }
8809b87Claude225`;
226
227/** Build the inline-SVG sparkline for the last N days. Pure function. */
228export function buildTrendSparkline(
229 byDayCents: Array<{ day: string; cents: number }>,
230 days = 30
231): { points: string; areaPoints: string; max: number; total: number } {
232 const today = new Date();
233 // Build last N days inclusive, fill 0 where missing.
234 const map = new Map(byDayCents.map((d) => [d.day, d.cents]));
235 const series: Array<{ day: string; cents: number }> = [];
236 for (let i = days - 1; i >= 0; i--) {
237 const d = new Date(today);
238 d.setUTCDate(d.getUTCDate() - i);
239 const key = toUtcDayKey(d);
240 series.push({ day: key, cents: map.get(key) || 0 });
241 }
242 const max = Math.max(1, ...series.map((s) => s.cents));
243 const w = 100;
244 const h = 100;
245 const stepX = series.length > 1 ? w / (series.length - 1) : w;
246 const pts = series.map((s, i) => {
247 const x = +(i * stepX).toFixed(2);
248 const y = +(h - (s.cents / max) * h).toFixed(2);
249 return `${x},${y}`;
250 });
251 const points = pts.join(" ");
252 const areaPoints = `0,${h} ${points} ${w},${h}`;
253 const total = series.reduce((a, b) => a + b.cents, 0);
254 return { points, areaPoints, max, total };
255}
256
257/** Wrapper that builds the whole dashboard payload for a user. Exported so
258 * tests + the API endpoint share the same code path. */
259export async function buildDashboardForUser(userId: string): Promise<{
260 thisMonth: CostSummary;
261 lastMonth: CostSummary;
262 thirtyDay: CostSummary;
263 thisMonthCents: number;
264 lastMonthCents: number;
265 dailyAvgCents: number;
266 projectedEomCents: number;
267 budgetCents: number;
268 exceeds: boolean;
269}> {
270 const now = new Date();
271 const monthStart = startOfUtcMonth(now);
272 const prevMonthStart = new Date(
273 Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)
274 );
275 const prevMonthEnd = new Date(monthStart.getTime() - 1);
276 const thirtyAgo = new Date(now.getTime() - 30 * 24 * 3600 * 1000);
277
278 const [thisMonth, lastMonth, thirtyDay] = await Promise.all([
279 summarizeCostsForUser(userId, { fromDate: monthStart, toDate: now }),
280 summarizeCostsForUser(userId, {
281 fromDate: prevMonthStart,
282 toDate: prevMonthEnd,
283 }),
284 summarizeCostsForUser(userId, { fromDate: thirtyAgo, toDate: now }),
285 ]);
286
287 const thisMonthCents = thisMonth.totalCents;
288 const lastMonthCents = lastMonth.totalCents;
289 const dailyAvgCents = dailyAverageCents(thisMonthCents, now);
290 const projectedEomCents = projectMonthEndCents(thisMonthCents, now);
291
292 let budgetCents = 0;
293 try {
294 const [b] = await db
295 .select()
296 .from(aiBudgets)
297 .where(eq(aiBudgets.userId, userId))
298 .limit(1);
299 budgetCents = b?.monthlyCents ?? 0;
300 } catch {
301 /* tolerate — table may not yet exist */
302 }
303 const exceeds = budgetCents > 0 && projectedEomCents > budgetCents;
304
305 return {
306 thisMonth,
307 lastMonth,
308 thirtyDay,
309 thisMonthCents,
310 lastMonthCents,
311 dailyAvgCents,
312 projectedEomCents,
313 budgetCents,
314 exceeds,
315 };
316}
317
318const CATEGORY_LABELS: Record<string, string> = {
319 ai_review: "PR review",
320 ai_patch: "AI patches",
321 ci_healer: "CI healer",
322 spec_to_pr: "Spec-to-PR",
323 standup: "AI standup",
324 chat: "Repo chat",
325 voice: "Voice-to-PR",
326 test_gen: "Test gen",
327 refactor: "Multi-repo refactor",
328 other: "Other",
329};
330
331// ─── GET /billing/usage ─────────────────────────────────────────────────
332usage.get("/billing/usage", requireAuth, async (c) => {
333 const user = c.get("user")!;
334 const data = await buildDashboardForUser(user.id);
335 const trend = buildTrendSparkline(data.thirtyDay.byDay, 30);
336
337 // Hydrate repo names + agent names for the breakdown tables.
338 const repoLookup = await loadRepoLookup(data.thisMonth.byRepo.map((r) => r.repositoryId).filter((x): x is string => !!x));
339 const agentLookup = await loadAgentLookup(data.thisMonth.byAgent.map((r) => r.agentSessionId).filter((x): x is string => !!x));
340
341 const saved = c.req.query("saved") === "1";
342
343 return c.html(
344 <Layout title="AI usage — Gluecron" user={user}>
345 <style dangerouslySetInnerHTML={{ __html: styles }} />
346 <div class="cost-wrap">
347 {/* Hero */}
348 <section class="cost-hero">
349 <div class="cost-orb" aria-hidden="true" />
350 <div class="cost-hero-inner">
351 <div class="cost-hero-text">
352 <div class="cost-eyebrow">
353 <span class="cost-eyebrow-dot" aria-hidden="true" />
354 Billing · <strong>@{user.username}</strong> · usage
355 </div>
356 <h1 class="cost-title">
357 <span class="cost-title-grad">AI spend.</span>
358 </h1>
359 <div class="cost-total">
360 {formatCents(data.thisMonthCents)}
361 <small>this month</small>
362 </div>
363 <p class="cost-sub" style="margin-top:12px">
364 Per-repo + per-agent Anthropic spend, classified by feature. Cents are estimated from the published Claude rate card at call time.
365 </p>
366 </div>
367 </div>
368 </section>
369
370 <div class="cost-sublinks">
371 <a href="/settings/billing">Plans + payment</a>
372 <a href="/billing/usage" class="is-current">AI usage</a>
373 <a href="/settings/agents">Agents</a>
891efbeClaude374 <a href={`/share/${user.username}`} style="display:inline-flex;align-items:center;gap:6px;color:#00ff88;border-color:rgba(0,255,136,0.30);background:rgba(0,255,136,0.06)">
375 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
376 Share your AI stats
377 </a>
8809b87Claude378 </div>
379
380 {saved && (
381 <div class="cost-banner is-ok" role="status">
382 <span class="cost-banner-dot" aria-hidden="true" />
383 Budget saved.
384 </div>
385 )}
386 {data.exceeds && (
387 <div class="cost-banner is-warn" role="alert">
388 <span class="cost-banner-dot" aria-hidden="true" />
389 Projected month-end spend ({formatCents(data.projectedEomCents)}) exceeds your budget ({formatCents(data.budgetCents)}). Trim usage or raise the cap below.
390 </div>
391 )}
392
393 {/* Stat cards */}
394 <div class="cost-stats">
395 <div class="cost-stat">
396 <div class="cost-stat-head">This month</div>
397 <div class="cost-stat-val">{formatCents(data.thisMonthCents)}</div>
398 <div class="cost-stat-sub">
399 {formatTokens(data.thisMonth.totalInputTokens + data.thisMonth.totalOutputTokens)} tokens
400 </div>
401 </div>
402 <div class="cost-stat">
403 <div class="cost-stat-head">Last month</div>
404 <div class="cost-stat-val">{formatCents(data.lastMonthCents)}</div>
405 <div class="cost-stat-sub">
406 {formatTokens(data.lastMonth.totalInputTokens + data.lastMonth.totalOutputTokens)} tokens
407 </div>
408 </div>
409 <div class="cost-stat">
410 <div class="cost-stat-head">Daily average</div>
411 <div class="cost-stat-val">{formatCents(data.dailyAvgCents)}</div>
412 <div class="cost-stat-sub">elapsed month-to-date</div>
413 </div>
414 <div class="cost-stat">
415 <div class="cost-stat-head">Projected EOM</div>
416 <div class="cost-stat-val">{formatCents(data.projectedEomCents)}</div>
417 <div class="cost-stat-sub">linear extrapolation</div>
418 </div>
419 </div>
420
421 {/* Trend chart */}
422 <section class="cost-section">
423 <header class="cost-section-head">
424 <div>
425 <h3 class="cost-section-title">30-day trend</h3>
426 <p class="cost-section-sub">
427 Daily spend in cents. Peak day:&nbsp;
428 <span style="font-family:var(--font-mono);color:var(--text-strong)">{formatCents(trend.max)}</span>.
429 30-day total:&nbsp;
430 <span style="font-family:var(--font-mono);color:var(--text-strong)">{formatCents(trend.total)}</span>.
431 </p>
432 </div>
433 </header>
434 <div class="cost-section-body">
435 <svg
436 class="cost-trend"
437 viewBox="0 0 100 100"
438 preserveAspectRatio="none"
439 aria-label="30-day AI spend sparkline"
440 role="img"
441 >
442 <defs>
443 <linearGradient id="cost-grad" x1="0" y1="0" x2="0" y2="1">
6fd5915Claude444 <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.6" />
445 <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.05" />
8809b87Claude446 </linearGradient>
447 </defs>
448 <line class="cost-trend-axis" x1="0" y1="100" x2="100" y2="100" />
449 <polygon class="cost-trend-fill" points={trend.areaPoints} />
450 <polyline class="cost-trend-line" points={trend.points} />
451 </svg>
452 </div>
453 </section>
454
455 {/* Breakdown by category */}
456 <section class="cost-section">
457 <header class="cost-section-head">
458 <div>
459 <h3 class="cost-section-title">By feature this month</h3>
460 <p class="cost-section-sub">Where the spend is going — PR review, CI healing, refactors, etc.</p>
461 </div>
462 </header>
463 <div class="cost-section-body">
464 {data.thisMonth.byCategory.length === 0 ? (
465 <div class="cost-empty">No AI activity yet this month.</div>
466 ) : (
467 <table class="cost-table">
468 <thead>
469 <tr>
470 <th>Feature</th>
471 <th style="text-align:right">Spend</th>
472 <th style="text-align:right">Input tokens</th>
473 <th style="text-align:right">Output tokens</th>
474 </tr>
475 </thead>
476 <tbody>
477 {data.thisMonth.byCategory.map((c) => (
478 <tr>
479 <td>
480 <span class="cost-cat-pill">{CATEGORY_LABELS[c.category] || c.category}</span>
481 </td>
482 <td class="is-num">{formatCents(c.cents)}</td>
483 <td class="is-num">{formatTokens(c.inputTokens)}</td>
484 <td class="is-num">{formatTokens(c.outputTokens)}</td>
485 </tr>
486 ))}
487 </tbody>
488 </table>
489 )}
490 </div>
491 </section>
492
493 {/* By repo */}
494 <section class="cost-section">
495 <header class="cost-section-head">
496 <div>
497 <h3 class="cost-section-title">Top repos this month</h3>
498 <p class="cost-section-sub">Top 10 repositories by spend. NULL = global activity (not repo-scoped).</p>
499 </div>
500 </header>
501 <div class="cost-section-body">
502 {data.thisMonth.byRepo.length === 0 ? (
503 <div class="cost-empty">No repo-scoped AI activity yet this month.</div>
504 ) : (
505 <table class="cost-table">
506 <thead>
507 <tr>
508 <th>Repository</th>
509 <th style="text-align:right">Spend</th>
510 <th style="text-align:right">Tokens</th>
511 </tr>
512 </thead>
513 <tbody>
514 {data.thisMonth.byRepo.slice(0, 10).map((r) => {
515 const meta = r.repositoryId ? repoLookup.get(r.repositoryId) : null;
516 const label = meta ? `${meta.owner}/${meta.name}` : "(global)";
517 return (
518 <tr>
519 <td>{meta ? <a href={`/${meta.owner}/${meta.name}`}>{label}</a> : <span style="color:var(--text-muted)">{label}</span>}</td>
520 <td class="is-num">{formatCents(r.cents)}</td>
521 <td class="is-num">{formatTokens(r.inputTokens + r.outputTokens)}</td>
522 </tr>
523 );
524 })}
525 </tbody>
526 </table>
527 )}
528 </div>
529 </section>
530
531 {/* By agent */}
532 <section class="cost-section">
533 <header class="cost-section-head">
534 <div>
535 <h3 class="cost-section-title">Top agents this month</h3>
536 <p class="cost-section-sub">Top 10 agent sessions by spend. Manage caps at /settings/agents.</p>
537 </div>
538 </header>
539 <div class="cost-section-body">
540 {data.thisMonth.byAgent.filter((a) => a.agentSessionId).length === 0 ? (
541 <div class="cost-empty">No agent-attributed AI activity yet this month.</div>
542 ) : (
543 <table class="cost-table">
544 <thead>
545 <tr>
546 <th>Agent session</th>
547 <th style="text-align:right">Spend</th>
548 <th style="text-align:right">Tokens</th>
549 </tr>
550 </thead>
551 <tbody>
552 {data.thisMonth.byAgent
553 .filter((a) => a.agentSessionId)
554 .slice(0, 10)
555 .map((a) => {
556 const meta = a.agentSessionId ? agentLookup.get(a.agentSessionId) : null;
557 const label = meta ? meta.name : a.agentSessionId?.slice(0, 8) || "?";
558 return (
559 <tr>
560 <td>
561 <span style="font-family:var(--font-mono);font-size:12.5px">{label}</span>
562 </td>
563 <td class="is-num">{formatCents(a.cents)}</td>
564 <td class="is-num">{formatTokens(a.inputTokens + a.outputTokens)}</td>
565 </tr>
566 );
567 })}
568 </tbody>
569 </table>
570 )}
571 </div>
572 </section>
573
574 {/* Budget form */}
575 <section class="cost-section">
576 <header class="cost-section-head">
577 <div>
578 <h3 class="cost-section-title">Monthly budget</h3>
579 <p class="cost-section-sub">Advisory cap; we warn when projected EOM exceeds it. 0 disables the warning.</p>
580 </div>
581 </header>
582 <div class="cost-section-body">
583 <form
584 method="post"
585 action="/billing/usage/budget"
586 class="cost-budget-form"
587 >
588 <label for="budget-dollars">Cap (USD)</label>
589 <input
590 type="number"
591 id="budget-dollars"
592 name="dollars"
593 min="0"
594 step="1"
595 value={String(Math.round((data.budgetCents || 0) / 100))}
596 />
597 <button type="submit">Save</button>
598 </form>
599 <p class="cost-budget-note">
600 Current cap:&nbsp;
601 <span style="font-family:var(--font-mono);color:var(--text-strong)">{data.budgetCents > 0 ? formatCents(data.budgetCents) : "no cap"}</span>.
602 </p>
603 </div>
604 </section>
605
606 <div class="cost-foot">
607 Need raw events? <a href="/api/v2/usage/me">GET /api/v2/usage/me</a>{" "}
608 returns the same shape as this page in JSON.
609 </div>
610 </div>
611 </Layout>
612 );
613});
614
615// ─── POST /billing/usage/budget ─────────────────────────────────────────
616usage.post("/billing/usage/budget", requireAuth, async (c) => {
617 const user = c.get("user")!;
618 const body = await c.req.parseBody();
619 const rawDollars = body.dollars;
620 const dollars = Math.max(0, Math.floor(Number(rawDollars) || 0));
621 const cents = dollars * 100;
622 try {
623 await db
624 .insert(aiBudgets)
625 .values({ userId: user.id, monthlyCents: cents })
626 .onConflictDoUpdate({
627 target: aiBudgets.userId,
628 set: { monthlyCents: cents, updatedAt: new Date() },
629 });
630 } catch (err) {
631 console.warn(
632 "[billing-usage] budget save failed:",
633 err instanceof Error ? err.message : err
634 );
635 }
636 return c.redirect("/billing/usage?saved=1");
637});
638
639// ─── Helpers ────────────────────────────────────────────────────────────
640async function loadRepoLookup(
641 repoIds: string[]
642): Promise<Map<string, { name: string; owner: string }>> {
643 const lookup = new Map<string, { name: string; owner: string }>();
644 if (repoIds.length === 0) return lookup;
645 try {
646 const rows = await db
647 .select({
648 id: repositories.id,
649 name: repositories.name,
650 ownerName: users.username,
651 })
652 .from(repositories)
653 .innerJoin(users, eq(repositories.ownerId, users.id));
654 for (const r of rows) {
655 if (repoIds.includes(r.id)) {
656 lookup.set(r.id, { name: r.name, owner: r.ownerName });
657 }
658 }
659 } catch {
660 /* tolerate */
661 }
662 return lookup;
663}
664
665async function loadAgentLookup(
666 sessionIds: string[]
667): Promise<Map<string, { name: string }>> {
668 const lookup = new Map<string, { name: string }>();
669 if (sessionIds.length === 0) return lookup;
670 try {
671 const rows = await db
672 .select({ id: agentSessions.id, name: agentSessions.name })
673 .from(agentSessions);
674 for (const r of rows) {
675 if (sessionIds.includes(r.id)) lookup.set(r.id, { name: r.name });
676 }
677 } catch {
678 /* tolerate */
679 }
680 return lookup;
681}
682
683// ─── Test-only exports ──────────────────────────────────────────────────
684export const __test = {
685 buildTrendSparkline,
686 buildDashboardForUser,
687 CATEGORY_LABELS,
688};
689
690export default usage;