| ... | @@ -1,10 +1,10 @@ | ... | @@ -1,10 +1,10 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; | 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 | import { useMemo, useState } from "react"; | 4 | import { useMemo, useState } from "react"; |
| 5 | | 5 | |
| 6 | import { cn } from "../../lib/utils"; | 6 | import { cn } from "../../lib/utils"; |
| 7 | import { useUsage } from "../../state/usage"; | 7 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 8 | import { | 8 | import { |
| 9 | enumerateDays, | 9 | enumerateDays, |
| 10 | formatCount, | 10 | formatCount, |
| ... | @@ -37,6 +37,11 @@ export function UsagePage() { | ... | @@ -37,6 +37,11 @@ export function UsagePage() { |
| 37 | const window = useMemo(() => makeWindow(windowDays), [windowDays]); | 37 | const window = useMemo(() => makeWindow(windowDays), [windowDays]); |
| 38 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); | 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 | const days = useMemo( | 45 | const days = useMemo( |
| 41 | () => enumerateDays(window.sinceDay, window.untilDay), | 46 | () => enumerateDays(window.sinceDay, window.untilDay), |
| 42 | [window.sinceDay, window.untilDay], | 47 | [window.sinceDay, window.untilDay], |
| ... | @@ -112,19 +117,19 @@ export function UsagePage() { | ... | @@ -112,19 +117,19 @@ export function UsagePage() { |
| 112 | </div> | 117 | </div> |
| 113 | </header> | 118 | </header> |
| 114 | | 119 | |
| 115 | <UsageCoverageNotice | 120 | {settling ? ( |
| 116 | environments={environments} | 121 | <> |
| 117 | duplicateSources={merged.duplicateSources} | 122 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 118 | staleEnvironments={merged.staleEnvironments} | 123 | <UsageSkeleton /> |
| 119 | isPartial={isPartial} | 124 | </> |
| 120 | /> | | |
| 121 | | | |
| 122 | {isPending ? ( | | |
| 123 | <p className="py-16 text-center text-sm text-muted-foreground"> | | |
| 124 | Scanning provider transcripts… | | |
| 125 | </p> | | |
| 126 | ) : ( | 125 | ) : ( |
| 127 | <> | 126 | <> |
| | 127 | <UsageCoverageNotice |
| | 128 | environments={environments} |
| | 129 | duplicateSources={merged.duplicateSources} |
| | 130 | staleEnvironments={merged.staleEnvironments} |
| | 131 | /> |
| | 132 | |
| 128 | {/* Cost first: the financial answer, then the provider split. */} | 133 | {/* Cost first: the financial answer, then the provider split. */} |
| 129 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | 134 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 130 | {/* The summary follows the chart toggle, so the headline and the | 135 | {/* The summary follows the chart toggle, so the headline and the |
| ... | @@ -391,37 +396,30 @@ function Metric({ | ... | @@ -391,37 +396,30 @@ function Metric({ |
| 391 | } | 396 | } |
| 392 | | 397 | |
| 393 | /** | 398 | /** |
| 394 | * Says plainly when the totals are incomplete: an environment still answering, | 399 | * Says plainly when the totals are incomplete: an environment that failed, or |
| 395 | * one that failed, or one whose transcripts another environment already | 400 | * one whose transcripts another environment already reported. Environments |
| 396 | * reported. | 401 | * that are still answering never reach this notice; the page shows the |
| | 402 | * loading skeleton until every one is terminal. |
| 397 | */ | 403 | */ |
| 398 | function UsageCoverageNotice({ | 404 | function UsageCoverageNotice({ |
| 399 | environments, | 405 | environments, |
| 400 | duplicateSources, | 406 | duplicateSources, |
| 401 | staleEnvironments, | 407 | staleEnvironments, |
| 402 | isPartial, | | |
| 403 | }: { | 408 | }: { |
| 404 | readonly environments: readonly { | 409 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 405 | environmentId: string; | | |
| 406 | label: string; | | |
| 407 | error: string | null; | | |
| 408 | isPending: boolean; | | |
| 409 | }[]; | | |
| 410 | readonly duplicateSources: readonly string[]; | 410 | readonly duplicateSources: readonly string[]; |
| 411 | readonly staleEnvironments: readonly string[]; | 411 | readonly staleEnvironments: readonly string[]; |
| 412 | readonly isPartial: boolean; | | |
| 413 | }) { | 412 | }) { |
| 414 | const failed = environments.filter((environment) => environment.error !== null); | 413 | const failed = environments.filter((environment) => environment.error !== null); |
| 415 | const stale = environments.filter((environment) => | 414 | const stale = environments.filter((environment) => |
| 416 | staleEnvironments.includes(environment.environmentId), | 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 | return null; | 418 | return null; |
| 420 | } | 419 | } |
| 421 | | 420 | |
| 422 | return ( | 421 | return ( |
| 423 | <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground"> | 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 | {failed.map((environment) => ( | 423 | {failed.map((environment) => ( |
| 426 | <span key={environment.label}>{environment.label} could not report usage.</span> | 424 | <span key={environment.label}>{environment.label} could not report usage.</span> |
| 427 | ))} | 425 | ))} |
| ... | @@ -439,3 +437,126 @@ function UsageCoverageNotice({ | ... | @@ -439,3 +437,126 @@ function UsageCoverageNotice({ |
| 439 | </div> | 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 | } |