| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | | import { RefreshCwIcon } from "lucide-react"; |
| 2 | import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; |
| 3 | import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react"; |
| 3 | 4 | import { useMemo, useState } from "react"; |
| 4 | 5 | |
| 5 | 6 | import { cn } from "../../lib/utils"; |
| ... | ... | @@ -27,6 +28,9 @@ export function UsagePage() { |
| 27 | 28 | const [windowDays, setWindowDays] = useState<number>(30); |
| 28 | 29 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 29 | 30 | const [breakdown, setBreakdown] = useState<"model" | "day">("model"); |
| 31 | const canGoBack = useCanGoBack(); |
| 32 | const navigate = useNavigate(); |
| 33 | const router = useRouter(); |
| 30 | 34 | |
| 31 | 35 | // Recomputed only when the window length changes, so a re-render does not |
| 32 | 36 | // shift the range and refetch every environment. |
| ... | ... | @@ -57,11 +61,27 @@ export function UsagePage() { |
| 57 | 61 | <ScrollArea className="h-full"> |
| 58 | 62 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> |
| 59 | 63 | <header className="flex flex-wrap items-start justify-between gap-4"> |
| 60 | | <div className="flex flex-col gap-1"> |
| 61 | | <h1 className="text-2xl font-semibold text-foreground">Usage</h1> |
| 62 | | <p className="text-sm text-muted-foreground"> |
| 63 | | {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} |
| 64 | | </p> |
| 64 | <div className="flex items-start gap-3"> |
| 65 | <button |
| 66 | type="button" |
| 67 | aria-label="Back" |
| 68 | onClick={() => { |
| 69 | if (canGoBack) { |
| 70 | router.history.back(); |
| 71 | return; |
| 72 | } |
| 73 | void navigate({ to: "/" }); |
| 74 | }} |
| 75 | className="mt-1 cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" |
| 76 | > |
| 77 | <ArrowLeftIcon className="size-3.5" /> |
| 78 | </button> |
| 79 | <div className="flex flex-col gap-1"> |
| 80 | <h1 className="text-2xl font-semibold text-foreground">Usage</h1> |
| 81 | <p className="text-sm text-muted-foreground"> |
| 82 | {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} |
| 83 | </p> |
| 84 | </div> |
| 65 | 85 | </div> |
| 66 | 86 | <div className="flex items-center gap-2"> |
| 67 | 87 | <div className="flex overflow-hidden rounded-md border border-border"> |
| ... | ... | @@ -222,140 +242,116 @@ export function UsagePage() { |
| 222 | 242 | /> |
| 223 | 243 | </section> |
| 224 | 244 | |
| 225 | | <section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,18rem)]"> |
| 226 | | <div className="flex flex-col gap-3"> |
| 227 | | <div className="flex items-center justify-between gap-3"> |
| 228 | | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 229 | | <div className="flex overflow-hidden rounded-md border border-border"> |
| 230 | | {(["model", "day"] as const).map((option) => ( |
| 231 | | <button |
| 232 | | key={option} |
| 233 | | type="button" |
| 234 | | onClick={() => setBreakdown(option)} |
| 235 | | className={cn( |
| 236 | | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", |
| 237 | | option === breakdown |
| 238 | | ? "bg-muted text-foreground" |
| 239 | | : "text-muted-foreground hover:text-foreground", |
| 240 | | )} |
| 241 | | > |
| 242 | | {option} |
| 243 | | </button> |
| 244 | | ))} |
| 245 | | </div> |
| 245 | <section className="flex flex-col gap-3"> |
| 246 | <div className="flex items-center justify-between gap-3"> |
| 247 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 248 | <div className="flex overflow-hidden rounded-md border border-border"> |
| 249 | {(["model", "day"] as const).map((option) => ( |
| 250 | <button |
| 251 | key={option} |
| 252 | type="button" |
| 253 | onClick={() => setBreakdown(option)} |
| 254 | className={cn( |
| 255 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", |
| 256 | option === breakdown |
| 257 | ? "bg-muted text-foreground" |
| 258 | : "text-muted-foreground hover:text-foreground", |
| 259 | )} |
| 260 | > |
| 261 | {option} |
| 262 | </button> |
| 263 | ))} |
| 246 | 264 | </div> |
| 265 | </div> |
| 247 | 266 | |
| 248 | | {breakdown === "model" ? ( |
| 249 | | <table className="w-full text-sm"> |
| 250 | | <thead> |
| 251 | | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 252 | | <th className="py-2 font-normal">Model</th> |
| 253 | | <th className="py-2 text-right font-normal">Cost</th> |
| 254 | | <th className="py-2 text-right font-normal">Share</th> |
| 255 | | <th className="py-2 text-right font-normal">Tokens</th> |
| 267 | {breakdown === "model" ? ( |
| 268 | <table className="w-full text-sm"> |
| 269 | <thead> |
| 270 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 271 | <th className="py-2 font-normal">Model</th> |
| 272 | <th className="py-2 text-right font-normal">Cost</th> |
| 273 | <th className="py-2 text-right font-normal">Share</th> |
| 274 | <th className="py-2 text-right font-normal">Tokens</th> |
| 275 | </tr> |
| 276 | </thead> |
| 277 | <tbody> |
| 278 | {merged.models.length === 0 ? ( |
| 279 | <tr> |
| 280 | <td colSpan={4} className="py-6 text-center text-muted-foreground"> |
| 281 | No activity in this window. |
| 282 | </td> |
| 256 | 283 | </tr> |
| 257 | | </thead> |
| 258 | | <tbody> |
| 259 | | {merged.models.length === 0 ? ( |
| 260 | | <tr> |
| 261 | | <td colSpan={4} className="py-6 text-center text-muted-foreground"> |
| 262 | | No activity in this window. |
| 284 | ) : ( |
| 285 | merged.models.map((model) => ( |
| 286 | <tr |
| 287 | key={`${model.provider}:${model.model}`} |
| 288 | className="border-b border-border/50" |
| 289 | > |
| 290 | <td className="py-2 text-foreground"> |
| 291 | <span className="flex items-center gap-2"> |
| 292 | <ProviderMark provider={model.provider} className="size-3.5" /> |
| 293 | {model.model} |
| 294 | </span> |
| 295 | </td> |
| 296 | <td className="py-2 text-right text-foreground tabular-nums"> |
| 297 | {formatUsd(model.costUsd)} |
| 298 | </td> |
| 299 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 300 | {formatPercent(model.costShare)} |
| 301 | </td> |
| 302 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 303 | {formatTokens(model.totalTokens)} |
| 263 | 304 | </td> |
| 264 | 305 | </tr> |
| 265 | | ) : ( |
| 266 | | merged.models.map((model) => ( |
| 267 | | <tr |
| 268 | | key={`${model.provider}:${model.model}`} |
| 269 | | className="border-b border-border/50" |
| 270 | | > |
| 271 | | <td className="py-2 text-foreground"> |
| 272 | | <span className="flex items-center gap-2"> |
| 273 | | <ProviderMark provider={model.provider} className="size-3.5" /> |
| 274 | | {model.model} |
| 275 | | </span> |
| 276 | | </td> |
| 277 | | <td className="py-2 text-right text-foreground tabular-nums"> |
| 278 | | {formatUsd(model.costUsd)} |
| 279 | | </td> |
| 280 | | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 281 | | {formatPercent(model.costShare)} |
| 282 | | </td> |
| 283 | | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 284 | | {formatTokens(model.totalTokens)} |
| 285 | | </td> |
| 286 | | </tr> |
| 287 | | )) |
| 288 | | )} |
| 289 | | </tbody> |
| 290 | | </table> |
| 291 | | ) : ( |
| 292 | | <table className="w-full text-sm"> |
| 293 | | <thead> |
| 294 | | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 295 | | <th className="py-2 font-normal">Day</th> |
| 296 | | {PROVIDER_ORDER.map((provider) => ( |
| 297 | | <th key={provider} className="py-2 text-right font-normal"> |
| 298 | | {PROVIDER_LABEL[provider]} |
| 299 | | </th> |
| 300 | | ))} |
| 301 | | <th className="py-2 text-right font-normal">Total</th> |
| 302 | | <th className="py-2 text-right font-normal">Tokens</th> |
| 306 | )) |
| 307 | )} |
| 308 | </tbody> |
| 309 | </table> |
| 310 | ) : ( |
| 311 | <table className="w-full text-sm"> |
| 312 | <thead> |
| 313 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 314 | <th className="py-2 font-normal">Day</th> |
| 315 | {PROVIDER_ORDER.map((provider) => ( |
| 316 | <th key={provider} className="py-2 text-right font-normal"> |
| 317 | {PROVIDER_LABEL[provider]} |
| 318 | </th> |
| 319 | ))} |
| 320 | <th className="py-2 text-right font-normal">Total</th> |
| 321 | <th className="py-2 text-right font-normal">Tokens</th> |
| 322 | </tr> |
| 323 | </thead> |
| 324 | <tbody> |
| 325 | {recentDays.length === 0 ? ( |
| 326 | <tr> |
| 327 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> |
| 328 | No activity in this window. |
| 329 | </td> |
| 303 | 330 | </tr> |
| 304 | | </thead> |
| 305 | | <tbody> |
| 306 | | {recentDays.length === 0 ? ( |
| 307 | | <tr> |
| 308 | | <td colSpan={5} className="py-6 text-center text-muted-foreground"> |
| 309 | | No activity in this window. |
| 331 | ) : ( |
| 332 | recentDays.map((day) => ( |
| 333 | <tr key={day.day} className="border-b border-border/50"> |
| 334 | <td className="py-2 text-foreground">{formatDayShort(day.day)}</td> |
| 335 | {PROVIDER_ORDER.map((provider) => ( |
| 336 | <td |
| 337 | key={provider} |
| 338 | className="py-2 text-right text-muted-foreground tabular-nums" |
| 339 | > |
| 340 | {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} |
| 341 | </td> |
| 342 | ))} |
| 343 | <td className="py-2 text-right text-foreground tabular-nums"> |
| 344 | {formatUsd(day.costUsd)} |
| 345 | </td> |
| 346 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 347 | {formatTokens(day.totalTokens)} |
| 310 | 348 | </td> |
| 311 | 349 | </tr> |
| 312 | | ) : ( |
| 313 | | recentDays.map((day) => ( |
| 314 | | <tr key={day.day} className="border-b border-border/50"> |
| 315 | | <td className="py-2 text-foreground">{formatDayShort(day.day)}</td> |
| 316 | | {PROVIDER_ORDER.map((provider) => ( |
| 317 | | <td |
| 318 | | key={provider} |
| 319 | | className="py-2 text-right text-muted-foreground tabular-nums" |
| 320 | | > |
| 321 | | {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} |
| 322 | | </td> |
| 323 | | ))} |
| 324 | | <td className="py-2 text-right text-foreground tabular-nums"> |
| 325 | | {formatUsd(day.costUsd)} |
| 326 | | </td> |
| 327 | | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 328 | | {formatTokens(day.totalTokens)} |
| 329 | | </td> |
| 330 | | </tr> |
| 331 | | )) |
| 332 | | )} |
| 333 | | </tbody> |
| 334 | | </table> |
| 335 | | )} |
| 336 | | </div> |
| 337 | | |
| 338 | | <div className="flex flex-col gap-3"> |
| 339 | | <h2 className="text-sm font-medium text-foreground">Cost quality</h2> |
| 340 | | <dl className="flex flex-col"> |
| 341 | | <QualityRow |
| 342 | | label="Provider reported" |
| 343 | | value={formatPercent(merged.costQuality.providerReportedShare)} |
| 344 | | /> |
| 345 | | <QualityRow |
| 346 | | label="Model priced" |
| 347 | | value={formatPercent(merged.costQuality.modelPricedShare)} |
| 348 | | /> |
| 349 | | <QualityRow |
| 350 | | label="Unpriced" |
| 351 | | value={formatPercent(merged.costQuality.unpricedShare)} |
| 352 | | /> |
| 353 | | <QualityRow |
| 354 | | label="Cache savings" |
| 355 | | value={formatUsd(merged.costQuality.cacheSavingsUsd)} |
| 356 | | /> |
| 357 | | </dl> |
| 358 | | </div> |
| 350 | )) |
| 351 | )} |
| 352 | </tbody> |
| 353 | </table> |
| 354 | )} |
| 359 | 355 | </section> |
| 360 | 356 | </> |
| 361 | 357 | )} |
| ... | ... | @@ -394,15 +390,6 @@ function Metric({ |
| 394 | 390 | ); |
| 395 | 391 | } |
| 396 | 392 | |
| 397 | | function QualityRow({ label, value }: { readonly label: string; readonly value: string }) { |
| 398 | | return ( |
| 399 | | <div className="flex items-center justify-between border-b border-border/50 py-2 text-sm"> |
| 400 | | <dt className="text-muted-foreground">{label}</dt> |
| 401 | | <dd className="text-foreground tabular-nums">{value}</dd> |
| 402 | | </div> |
| 403 | | ); |
| 404 | | } |
| 405 | | |
| 406 | 393 | /** |
| 407 | 394 | * Says plainly when the totals are incomplete: an environment still answering, |
| 408 | 395 | * one that failed, or one whose transcripts another environment already |