| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | 2 | import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; |
| 3 | | import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react"; |
| 3 | import { ArrowLeftIcon, CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; |
| 4 | 4 | import { useMemo, useState } from "react"; |
| 5 | 5 | |
| 6 | 6 | import { cn } from "../../lib/utils"; |
| 7 | | import { useUsage } from "../../state/usage"; |
| 7 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 8 | 8 | import { |
| 9 | 9 | enumerateDays, |
| 10 | 10 | formatCount, |
| ... | ... | @@ -37,6 +37,11 @@ export function UsagePage() { |
| 37 | 37 | const window = useMemo(() => makeWindow(windowDays), [windowDays]); |
| 38 | 38 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); |
| 39 | 39 | |
| 40 | // Hold the content until every environment is terminal. Rendering merged |
| 41 | // totals while devices are still answering makes every number on the page |
| 42 | // jump as each one lands. |
| 43 | const settling = isPending || isPartial; |
| 44 | |
| 40 | 45 | const days = useMemo( |
| 41 | 46 | () => enumerateDays(window.sinceDay, window.untilDay), |
| 42 | 47 | [window.sinceDay, window.untilDay], |
| ... | ... | @@ -112,19 +117,19 @@ export function UsagePage() { |
| 112 | 117 | </div> |
| 113 | 118 | </header> |
| 114 | 119 | |
| 115 | | <UsageCoverageNotice |
| 116 | | environments={environments} |
| 117 | | duplicateSources={merged.duplicateSources} |
| 118 | | staleEnvironments={merged.staleEnvironments} |
| 119 | | isPartial={isPartial} |
| 120 | | /> |
| 121 | | |
| 122 | | {isPending ? ( |
| 123 | | <p className="py-16 text-center text-sm text-muted-foreground"> |
| 124 | | Scanning provider transcripts… |
| 125 | | </p> |
| 120 | {settling ? ( |
| 121 | <> |
| 122 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 123 | <UsageSkeleton /> |
| 124 | </> |
| 126 | 125 | ) : ( |
| 127 | 126 | <> |
| 127 | <UsageCoverageNotice |
| 128 | environments={environments} |
| 129 | duplicateSources={merged.duplicateSources} |
| 130 | staleEnvironments={merged.staleEnvironments} |
| 131 | /> |
| 132 | |
| 128 | 133 | {/* Cost first: the financial answer, then the provider split. */} |
| 129 | 134 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 130 | 135 | {/* The summary follows the chart toggle, so the headline and the |
| ... | ... | @@ -391,37 +396,30 @@ function Metric({ |
| 391 | 396 | } |
| 392 | 397 | |
| 393 | 398 | /** |
| 394 | | * Says plainly when the totals are incomplete: an environment still answering, |
| 395 | | * one that failed, or one whose transcripts another environment already |
| 396 | | * reported. |
| 399 | * Says plainly when the totals are incomplete: an environment that failed, or |
| 400 | * one whose transcripts another environment already reported. Environments |
| 401 | * that are still answering never reach this notice; the page shows the |
| 402 | * loading skeleton until every one is terminal. |
| 397 | 403 | */ |
| 398 | 404 | function UsageCoverageNotice({ |
| 399 | 405 | environments, |
| 400 | 406 | duplicateSources, |
| 401 | 407 | staleEnvironments, |
| 402 | | isPartial, |
| 403 | 408 | }: { |
| 404 | | readonly environments: readonly { |
| 405 | | environmentId: string; |
| 406 | | label: string; |
| 407 | | error: string | null; |
| 408 | | isPending: boolean; |
| 409 | | }[]; |
| 409 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 410 | 410 | readonly duplicateSources: readonly string[]; |
| 411 | 411 | readonly staleEnvironments: readonly string[]; |
| 412 | | readonly isPartial: boolean; |
| 413 | 412 | }) { |
| 414 | 413 | const failed = environments.filter((environment) => environment.error !== null); |
| 415 | 414 | const stale = environments.filter((environment) => |
| 416 | 415 | staleEnvironments.includes(environment.environmentId), |
| 417 | 416 | ); |
| 418 | | if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) { |
| 417 | if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { |
| 419 | 418 | return null; |
| 420 | 419 | } |
| 421 | 420 | |
| 422 | 421 | return ( |
| 423 | 422 | <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground"> |
| 424 | | {isPartial ? <span>Some environments are still reporting. Totals are partial.</span> : null} |
| 425 | 423 | {failed.map((environment) => ( |
| 426 | 424 | <span key={environment.label}>{environment.label} could not report usage.</span> |
| 427 | 425 | ))} |
| ... | ... | @@ -439,3 +437,126 @@ function UsageCoverageNotice({ |
| 439 | 437 | </div> |
| 440 | 438 | ); |
| 441 | 439 | } |
| 440 | |
| 441 | /** |
| 442 | * Per-device progress while the page waits for every environment to answer. |
| 443 | * Only rendered with two or more devices; a lone device has nothing to |
| 444 | * enumerate. |
| 445 | */ |
| 446 | function UsageDeviceStrip({ |
| 447 | environments, |
| 448 | }: { |
| 449 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 450 | }) { |
| 451 | const scanning = environments.filter( |
| 452 | (environment) => environment.summary === null && environment.error === null, |
| 453 | ); |
| 454 | return ( |
| 455 | <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs"> |
| 456 | {environments.map((environment) => { |
| 457 | if (environment.summary !== null) { |
| 458 | return ( |
| 459 | <span |
| 460 | key={environment.environmentId} |
| 461 | className="flex items-center gap-1 text-foreground" |
| 462 | > |
| 463 | <CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden /> |
| 464 | {environment.label} |
| 465 | </span> |
| 466 | ); |
| 467 | } |
| 468 | if (environment.error !== null) { |
| 469 | return ( |
| 470 | <span |
| 471 | key={environment.environmentId} |
| 472 | className="flex items-center gap-1 text-destructive" |
| 473 | > |
| 474 | <XIcon className="size-3" aria-hidden /> |
| 475 | {environment.label} |
| 476 | </span> |
| 477 | ); |
| 478 | } |
| 479 | return ( |
| 480 | <span |
| 481 | key={environment.environmentId} |
| 482 | className="animate-status-pulse text-muted-foreground" |
| 483 | > |
| 484 | {environment.label}… |
| 485 | </span> |
| 486 | ); |
| 487 | })} |
| 488 | <span className="ms-auto text-muted-foreground"> |
| 489 | {scanning.length === 1 |
| 490 | ? "1 device still scanning" |
| 491 | : `${scanning.length} devices still scanning`} |
| 492 | </span> |
| 493 | </div> |
| 494 | ); |
| 495 | } |
| 496 | |
| 497 | /** Deterministic bar heights (each unique: they double as keys). */ |
| 498 | const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; |
| 499 | |
| 500 | /** |
| 501 | * Static stand-in with the loaded page's shape: headline, provider split, |
| 502 | * chart and metrics strip. No shimmer; blocks fill in exactly once when the |
| 503 | * last device answers. |
| 504 | */ |
| 505 | function UsageSkeleton() { |
| 506 | return ( |
| 507 | <> |
| 508 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 509 | <div className="flex flex-col gap-5"> |
| 510 | <div className="flex flex-col gap-1"> |
| 511 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> |
| 512 | Raw token cost |
| 513 | </span> |
| 514 | <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" /> |
| 515 | <div className="h-3 w-28 rounded-sm bg-muted" /> |
| 516 | </div> |
| 517 | |
| 518 | {PROVIDER_ORDER.map((provider) => ( |
| 519 | <div key={provider} className="flex flex-col gap-1.5"> |
| 520 | <div className="flex items-center justify-between"> |
| 521 | <span className="flex items-center gap-2 text-sm text-foreground"> |
| 522 | <ProviderMark provider={provider} className="size-4" /> |
| 523 | {PROVIDER_LABEL[provider]} |
| 524 | </span> |
| 525 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> |
| 526 | </div> |
| 527 | <div className="h-1 w-full rounded-full bg-muted" /> |
| 528 | <div className="h-3 w-36 rounded-sm bg-muted" /> |
| 529 | </div> |
| 530 | ))} |
| 531 | </div> |
| 532 | |
| 533 | <div className="flex flex-col gap-3"> |
| 534 | <h2 className="py-1 text-sm font-medium text-foreground">Daily cost</h2> |
| 535 | {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a |
| 536 | relayout when the real chart swaps in. */} |
| 537 | <div className="flex h-56 items-end gap-1 pl-16"> |
| 538 | {SKELETON_BAR_HEIGHTS.map((height) => ( |
| 539 | <div |
| 540 | key={height} |
| 541 | className="flex-1 rounded-sm bg-muted" |
| 542 | style={{ height: `${height}%` }} |
| 543 | /> |
| 544 | ))} |
| 545 | </div> |
| 546 | </div> |
| 547 | </section> |
| 548 | |
| 549 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> |
| 550 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( |
| 551 | (label) => ( |
| 552 | <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3"> |
| 553 | <span className="text-xs text-muted-foreground">{label}</span> |
| 554 | <div className="my-1 h-5 w-16 rounded-sm bg-muted" /> |
| 555 | <div className="h-3 w-24 rounded-sm bg-muted" /> |
| 556 | </div> |
| 557 | ), |
| 558 | )} |
| 559 | </section> |
| 560 | </> |
| 561 | ); |
| 562 | } |