| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 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 | import { useMemo, useState } from "react"; | 4 | import { useMemo, useState } from "react"; |
| 4 | | 5 | |
| 5 | import { cn } from "../../lib/utils"; | 6 | import { cn } from "../../lib/utils"; |
| ... | @@ -27,6 +28,9 @@ export function UsagePage() { | ... | @@ -27,6 +28,9 @@ export function UsagePage() { |
| 27 | const [windowDays, setWindowDays] = useState<number>(30); | 28 | const [windowDays, setWindowDays] = useState<number>(30); |
| 28 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); | 29 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 29 | const [breakdown, setBreakdown] = useState<"model" | "day">("model"); | 30 | const [breakdown, setBreakdown] = useState<"model" | "day">("model"); |
| | 31 | const canGoBack = useCanGoBack(); |
| | 32 | const navigate = useNavigate(); |
| | 33 | const router = useRouter(); |
| 30 | | 34 | |
| 31 | // Recomputed only when the window length changes, so a re-render does not | 35 | // Recomputed only when the window length changes, so a re-render does not |
| 32 | // shift the range and refetch every environment. | 36 | // shift the range and refetch every environment. |
| ... | @@ -57,11 +61,27 @@ export function UsagePage() { | ... | @@ -57,11 +61,27 @@ export function UsagePage() { |
| 57 | <ScrollArea className="h-full"> | 61 | <ScrollArea className="h-full"> |
| 58 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> | 62 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> |
| 59 | <header className="flex flex-wrap items-start justify-between gap-4"> | 63 | <header className="flex flex-wrap items-start justify-between gap-4"> |
| 60 | <div className="flex flex-col gap-1"> | 64 | <div className="flex items-start gap-3"> |
| 61 | <h1 className="text-2xl font-semibold text-foreground">Usage</h1> | 65 | <button |
| 62 | <p className="text-sm text-muted-foreground"> | 66 | type="button" |
| 63 | {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} | 67 | aria-label="Back" |
| 64 | </p> | 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 | </div> | 85 | </div> |
| 66 | <div className="flex items-center gap-2"> | 86 | <div className="flex items-center gap-2"> |
| 67 | <div className="flex overflow-hidden rounded-md border border-border"> | 87 | <div className="flex overflow-hidden rounded-md border border-border"> |
| ... | @@ -222,140 +242,116 @@ export function UsagePage() { | ... | @@ -222,140 +242,116 @@ export function UsagePage() { |
| 222 | /> | 242 | /> |
| 223 | </section> | 243 | </section> |
| 224 | | 244 | |
| 225 | <section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,18rem)]"> | 245 | <section className="flex flex-col gap-3"> |
| 226 | <div className="flex flex-col gap-3"> | 246 | <div className="flex items-center justify-between gap-3"> |
| 227 | <div className="flex items-center justify-between gap-3"> | 247 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 228 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> | 248 | <div className="flex overflow-hidden rounded-md border border-border"> |
| 229 | <div className="flex overflow-hidden rounded-md border border-border"> | 249 | {(["model", "day"] as const).map((option) => ( |
| 230 | {(["model", "day"] as const).map((option) => ( | 250 | <button |
| 231 | <button | 251 | key={option} |
| 232 | key={option} | 252 | type="button" |
| 233 | type="button" | 253 | onClick={() => setBreakdown(option)} |
| 234 | onClick={() => setBreakdown(option)} | 254 | className={cn( |
| 235 | className={cn( | 255 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", |
| 236 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | 256 | option === breakdown |
| 237 | option === breakdown | 257 | ? "bg-muted text-foreground" |
| 238 | ? "bg-muted text-foreground" | 258 | : "text-muted-foreground hover:text-foreground", |
| 239 | : "text-muted-foreground hover:text-foreground", | 259 | )} |
| 240 | )} | 260 | > |
| 241 | > | 261 | {option} |
| 242 | {option} | 262 | </button> |
| 243 | </button> | 263 | ))} |
| 244 | ))} | | |
| 245 | </div> | | |
| 246 | </div> | 264 | </div> |
| | 265 | </div> |
| 247 | | 266 | |
| 248 | {breakdown === "model" ? ( | 267 | {breakdown === "model" ? ( |
| 249 | <table className="w-full text-sm"> | 268 | <table className="w-full text-sm"> |
| 250 | <thead> | 269 | <thead> |
| 251 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> | 270 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 252 | <th className="py-2 font-normal">Model</th> | 271 | <th className="py-2 font-normal">Model</th> |
| 253 | <th className="py-2 text-right font-normal">Cost</th> | 272 | <th className="py-2 text-right font-normal">Cost</th> |
| 254 | <th className="py-2 text-right font-normal">Share</th> | 273 | <th className="py-2 text-right font-normal">Share</th> |
| 255 | <th className="py-2 text-right font-normal">Tokens</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 | </tr> | 283 | </tr> |
| 257 | </thead> | 284 | ) : ( |
| 258 | <tbody> | 285 | merged.models.map((model) => ( |
| 259 | {merged.models.length === 0 ? ( | 286 | <tr |
| 260 | <tr> | 287 | key={`${model.provider}:${model.model}`} |
| 261 | <td colSpan={4} className="py-6 text-center text-muted-foreground"> | 288 | className="border-b border-border/50" |
| 262 | No activity in this window. | 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 | </td> | 304 | </td> |
| 264 | </tr> | 305 | </tr> |
| 265 | ) : ( | 306 | )) |
| 266 | merged.models.map((model) => ( | 307 | )} |
| 267 | <tr | 308 | </tbody> |
| 268 | key={`${model.provider}:${model.model}`} | 309 | </table> |
| 269 | className="border-b border-border/50" | 310 | ) : ( |
| 270 | > | 311 | <table className="w-full text-sm"> |
| 271 | <td className="py-2 text-foreground"> | 312 | <thead> |
| 272 | <span className="flex items-center gap-2"> | 313 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 273 | <ProviderMark provider={model.provider} className="size-3.5" /> | 314 | <th className="py-2 font-normal">Day</th> |
| 274 | {model.model} | 315 | {PROVIDER_ORDER.map((provider) => ( |
| 275 | </span> | 316 | <th key={provider} className="py-2 text-right font-normal"> |
| 276 | </td> | 317 | {PROVIDER_LABEL[provider]} |
| 277 | <td className="py-2 text-right text-foreground tabular-nums"> | 318 | </th> |
| 278 | {formatUsd(model.costUsd)} | 319 | ))} |
| 279 | </td> | 320 | <th className="py-2 text-right font-normal">Total</th> |
| 280 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | 321 | <th className="py-2 text-right font-normal">Tokens</th> |
| 281 | {formatPercent(model.costShare)} | 322 | </tr> |
| 282 | </td> | 323 | </thead> |
| 283 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | 324 | <tbody> |
| 284 | {formatTokens(model.totalTokens)} | 325 | {recentDays.length === 0 ? ( |
| 285 | </td> | 326 | <tr> |
| 286 | </tr> | 327 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> |
| 287 | )) | 328 | No activity in this window. |
| 288 | )} | 329 | </td> |
| 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> | | |
| 303 | </tr> | 330 | </tr> |
| 304 | </thead> | 331 | ) : ( |
| 305 | <tbody> | 332 | recentDays.map((day) => ( |
| 306 | {recentDays.length === 0 ? ( | 333 | <tr key={day.day} className="border-b border-border/50"> |
| 307 | <tr> | 334 | <td className="py-2 text-foreground">{formatDayShort(day.day)}</td> |
| 308 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> | 335 | {PROVIDER_ORDER.map((provider) => ( |
| 309 | No activity in this window. | 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 | </td> | 348 | </td> |
| 311 | </tr> | 349 | </tr> |
| 312 | ) : ( | 350 | )) |
| 313 | recentDays.map((day) => ( | 351 | )} |
| 314 | <tr key={day.day} className="border-b border-border/50"> | 352 | </tbody> |
| 315 | <td className="py-2 text-foreground">{formatDayShort(day.day)}</td> | 353 | </table> |
| 316 | {PROVIDER_ORDER.map((provider) => ( | 354 | )} |
| 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> | | |
| 359 | </section> | 355 | </section> |
| 360 | </> | 356 | </> |
| 361 | )} | 357 | )} |
| ... | @@ -394,15 +390,6 @@ function Metric({ | ... | @@ -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 | * Says plainly when the totals are incomplete: an environment still answering, | 394 | * Says plainly when the totals are incomplete: an environment still answering, |
| 408 | * one that failed, or one whose transcripts another environment already | 395 | * one that failed, or one whose transcripts another environment already |