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.
| 8809b87 | 1 | /** |
| 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 | ||
| 19 | import { Hono } from "hono"; | |
| 20 | import { and, desc, eq, gte, lte } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { | |
| 23 | aiBudgets, | |
| 24 | aiCostEvents, | |
| 25 | agentSessions, | |
| 26 | repositories, | |
| 27 | users, | |
| 28 | } from "../db/schema"; | |
| 29 | import { Layout } from "../views/layout"; | |
| 30 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 31 | import type { AuthEnv } from "../middleware/auth"; | |
| 32 | import { | |
| 33 | aggregateEvents, | |
| 34 | dailyAverageCents, | |
| 35 | formatCents, | |
| 36 | formatTokens, | |
| 37 | projectMonthEndCents, | |
| 38 | startOfUtcMonth, | |
| 39 | summarizeCostsForRepo, | |
| 40 | summarizeCostsForUser, | |
| 41 | toUtcDayKey, | |
| 42 | } from "../lib/ai-cost-tracker"; | |
| 43 | import type { CostSummary } from "../lib/ai-cost-tracker"; | |
| 44 | ||
| 45 | const usage = new Hono<AuthEnv>(); | |
| 46 | usage.use("*", softAuth); | |
| 47 | ||
| 48 | // ─── Scoped CSS (all `.cost-*`) ─────────────────────────────────────────── | |
| 49 | const styles = ` | |
| eed4684 | 50 | .cost-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); } |
| 8809b87 | 51 | |
| 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; | |
| 6fd5915 | 64 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| 8809b87 | 65 | opacity: 0.75; pointer-events: none; |
| 66 | } | |
| 67 | .cost-orb { | |
| 68 | position: absolute; | |
| 69 | inset: -30% -10% auto auto; | |
| 70 | width: 460px; height: 460px; | |
| 6fd5915 | 71 | background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 8809b87 | 72 | 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; | |
| 6fd5915 | 85 | background: linear-gradient(135deg, #5b6ee8, #5f8fa0); |
| 86 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); | |
| 8809b87 | 87 | } |
| 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 { | |
| 6fd5915 | 99 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 8809b87 | 100 | -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; } | |
| 6fd5915 | 182 | .cost-trend-line { stroke: #5b6ee8; stroke-width: 2; fill: none; } |
| 8809b87 | 183 | .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; | |
| 6fd5915 | 195 | background: rgba(91,110,232,0.12); color: #c4b5fd; border: 1px solid rgba(91,110,232,0.30); |
| 8809b87 | 196 | 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; | |
| 6fd5915 | 211 | 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)); | |
| 8809b87 | 213 | color: var(--text-strong); font-weight: 600; cursor: pointer; font-size: 13px; |
| 214 | } | |
| 6fd5915 | 215 | .cost-budget-form button:hover { border-color: rgba(91,110,232,0.60); } |
| 8809b87 | 216 | .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); } | |
| 6fd5915 | 224 | .cost-sublinks a.is-current { color: var(--text-strong); border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.08); } |
| 8809b87 | 225 | `; |
| 226 | ||
| 227 | /** Build the inline-SVG sparkline for the last N days. Pure function. */ | |
| 228 | export 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. */ | |
| 259 | export 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 | ||
| 318 | const 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 ───────────────────────────────────────────────── | |
| 332 | usage.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> | |
| 891efbe | 374 | <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> | |
| 8809b87 | 378 | </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: | |
| 428 | <span style="font-family:var(--font-mono);color:var(--text-strong)">{formatCents(trend.max)}</span>. | |
| 429 | 30-day total: | |
| 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"> | |
| 6fd5915 | 444 | <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.6" /> |
| 445 | <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.05" /> | |
| 8809b87 | 446 | </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: | |
| 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 ───────────────────────────────────────── | |
| 616 | usage.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 ──────────────────────────────────────────────────────────── | |
| 640 | async 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 | ||
| 665 | async 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 ────────────────────────────────────────────────── | |
| 684 | export const __test = { | |
| 685 | buildTrendSparkline, | |
| 686 | buildDashboardForUser, | |
| 687 | CATEGORY_LABELS, | |
| 688 | }; | |
| 689 | ||
| 690 | export default usage; |