| ... | @@ -1,9 +1,17 @@ | ... | @@ -1,9 +1,17 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { UsageProviderKind } from "@t3tools/contracts"; | 2 | import { |
| 3 | import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; | 3 | USAGE_CONTRACT_VERSION, |
| | 4 | type EnvironmentId, |
| | 5 | type UsageProviderKind, |
| | 6 | } from "@t3tools/contracts"; |
| | 7 | import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, RefreshCwIcon } from "lucide-react"; |
| 4 | import { useMemo, useState } from "react"; | 8 | import { useMemo, useState } from "react"; |
| 5 | | 9 | |
| 6 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; | 10 | import { |
| | 11 | isCompatibleUsageContractVersion, |
| | 12 | type DailyTotals, |
| | 13 | type HourlyTotals, |
| | 14 | } from "@t3tools/shared/usageMerge"; |
| 7 | | 15 | |
| 8 | import { isElectron } from "../../env"; | 16 | import { isElectron } from "../../env"; |
| 9 | import { cn } from "../../lib/utils"; | 17 | import { cn } from "../../lib/utils"; |
| ... | @@ -24,6 +32,7 @@ import { | ... | @@ -24,6 +32,7 @@ import { |
| 24 | makeWindow, | 32 | makeWindow, |
| 25 | } from "@t3tools/shared/usageFormat"; | 33 | } from "@t3tools/shared/usageFormat"; |
| 26 | import { Button } from "../ui/button"; | 34 | import { Button } from "../ui/button"; |
| | 35 | import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; |
| 27 | import { ScrollArea } from "../ui/scroll-area"; | 36 | import { ScrollArea } from "../ui/scroll-area"; |
| 28 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 37 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 29 | import { SidebarInset } from "../ui/sidebar"; | 38 | import { SidebarInset } from "../ui/sidebar"; |
| ... | @@ -67,19 +76,19 @@ export function UsagePage() { | ... | @@ -67,19 +76,19 @@ export function UsagePage() { |
| 67 | const [metric, setMetric] = useState<UsageMetric>("cost"); | 76 | const [metric, setMetric] = useState<UsageMetric>("cost"); |
| 68 | const showingLimits = metric === "limits"; | 77 | const showingLimits = metric === "limits"; |
| 69 | const [breakdown, setBreakdown] = useState<"model" | "time">("model"); | 78 | const [breakdown, setBreakdown] = useState<"model" | "time">("model"); |
| | 79 | const [selectedEnvironmentIds, setSelectedEnvironmentIds] = |
| | 80 | useState<ReadonlySet<EnvironmentId> | null>(null); |
| 70 | const { days: windowDays, window } = windowSelection; | 81 | const { days: windowDays, window } = windowSelection; |
| 71 | const isPast24Hours = windowDays === 1; | 82 | const isPast24Hours = windowDays === 1; |
| 72 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); | 83 | const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( |
| | 84 | window, |
| | 85 | selectedEnvironmentIds, |
| | 86 | ); |
| 73 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | 87 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 74 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | 88 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 75 | reportFailure: false, | 89 | reportFailure: false, |
| 76 | }); | 90 | }); |
| 77 | | 91 | |
| 78 | // Hold the content until every environment is terminal. Rendering merged | | |
| 79 | // totals while devices are still answering makes every number on the page | | |
| 80 | // jump as each one lands. | | |
| 81 | const settling = isPending || isPartial; | | |
| 82 | | | |
| 83 | const days = useMemo( | 92 | const days = useMemo( |
| 84 | () => enumerateDays(window.sinceDay, window.untilDay), | 93 | () => enumerateDays(window.sinceDay, window.untilDay), |
| 85 | [window.sinceDay, window.untilDay], | 94 | [window.sinceDay, window.untilDay], |
| ... | @@ -118,6 +127,7 @@ export function UsagePage() { | ... | @@ -118,6 +127,7 @@ export function UsagePage() { |
| 118 | const refreshWindow = () => { | 127 | const refreshWindow = () => { |
| 119 | if (showingLimits) { | 128 | if (showingLimits) { |
| 120 | for (const [environmentId, presentation] of presentations) { | 129 | for (const [environmentId, presentation] of presentations) { |
| | 130 | if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue; |
| 121 | if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { | 131 | if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { |
| 122 | void refreshProviders({ environmentId, input: {} }); | 132 | void refreshProviders({ environmentId, input: {} }); |
| 123 | } | 133 | } |
| ... | @@ -141,21 +151,31 @@ export function UsagePage() { | ... | @@ -141,21 +151,31 @@ export function UsagePage() { |
| 141 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | 151 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` |
| 142 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; | 152 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; |
| 143 | const topbarContent = ( | 153 | const topbarContent = ( |
| 144 | <div className="flex w-full min-w-0 items-center gap-3"> | 154 | <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 py-2 xl:flex"> |
| 145 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0"> | 155 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="col-span-2 min-w-0"> |
| 146 | <WorkspaceBreadcrumbItem current> | 156 | <WorkspaceBreadcrumbItem> |
| 147 | <h1>Usage</h1> | 157 | <h1>Usage</h1> |
| 148 | </WorkspaceBreadcrumbItem> | 158 | </WorkspaceBreadcrumbItem> |
| 149 | {showingLimits ? null : ( | 159 | <WorkspaceBreadcrumbSeparator /> |
| 150 | <> | 160 | <WorkspaceBreadcrumbItem current className="min-w-10"> |
| 151 | <WorkspaceBreadcrumbSeparator className="hidden md:flex" /> | 161 | <UsageEnvironmentFilter |
| 152 | <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex"> | 162 | environments={environments} |
| 153 | <span className="truncate">{windowLabel}</span> | 163 | selectedEnvironments={selectedEnvironments} |
| 154 | </WorkspaceBreadcrumbItem> | 164 | selectedEnvironmentIds={selectedEnvironmentIds} |
| 155 | </> | 165 | onSelectionChange={setSelectedEnvironmentIds} |
| 156 | )} | 166 | showUsageStatus={!showingLimits} |
| | 167 | isPartial={isPartial} |
| | 168 | duplicateSources={merged.duplicateSources} |
| | 169 | staleEnvironments={merged.staleEnvironments} |
| | 170 | /> |
| | 171 | </WorkspaceBreadcrumbItem> |
| 157 | </WorkspaceBreadcrumb> | 172 | </WorkspaceBreadcrumb> |
| 158 | <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex"> | 173 | {!showingLimits ? ( |
| | 174 | <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block"> |
| | 175 | {windowLabel} |
| | 176 | </span> |
| | 177 | ) : null} |
| | 178 | <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex"> |
| 159 | <ToggleGroup | 179 | <ToggleGroup |
| 160 | aria-label="Usage metric" | 180 | aria-label="Usage metric" |
| 161 | variant="segmented" | 181 | variant="segmented" |
| ... | @@ -198,7 +218,7 @@ export function UsagePage() { | ... | @@ -198,7 +218,7 @@ export function UsagePage() { |
| 198 | <RefreshCwIcon className="size-3.5" /> | 218 | <RefreshCwIcon className="size-3.5" /> |
| 199 | </Button> | 219 | </Button> |
| 200 | </div> | 220 | </div> |
| 201 | <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden"> | 221 | <div className="col-span-2 ms-auto flex min-w-0 items-center justify-end gap-1 xl:hidden"> |
| 202 | <Select | 222 | <Select |
| 203 | value={metric} | 223 | value={metric} |
| 204 | onValueChange={(value) => { | 224 | onValueChange={(value) => { |
| ... | @@ -261,7 +281,9 @@ export function UsagePage() { | ... | @@ -261,7 +281,9 @@ export function UsagePage() { |
| 261 | return ( | 281 | return ( |
| 262 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | 282 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 263 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | 283 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 264 | <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader> | 284 | <WorkspacePageHeader electron={isElectron} className="h-auto"> |
| | 285 | {topbarContent} |
| | 286 | </WorkspacePageHeader> |
| 265 | | 287 | |
| 266 | <ScrollArea className="min-h-0 flex-1"> | 288 | <ScrollArea className="min-h-0 flex-1"> |
| 267 | <WorkspacePageContainer width="wide"> | 289 | <WorkspacePageContainer width="wide"> |
| ... | @@ -270,21 +292,18 @@ export function UsagePage() { | ... | @@ -270,21 +292,18 @@ export function UsagePage() { |
| 270 | <UsagePriceOverrides usage={environments} /> | 292 | <UsagePriceOverrides usage={environments} /> |
| 271 | </div> | 293 | </div> |
| 272 | ) : null} | 294 | ) : null} |
| 273 | {showingLimits ? ( | 295 | {selectedEnvironments.length === 0 ? ( |
| 274 | <UsageLimitsSection /> | 296 | <p className="text-sm text-muted-foreground"> |
| 275 | ) : settling ? ( | 297 | {environments.length === 0 |
| 276 | <> | 298 | ? `Connect an environment to see ${showingLimits ? "limits" : "usage"}.` |
| 277 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} | 299 | : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`} |
| 278 | <UsageSkeleton /> | 300 | </p> |
| 279 | </> | 301 | ) : showingLimits ? ( |
| | 302 | <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} /> |
| | 303 | ) : isPending ? ( |
| | 304 | <UsageSkeleton /> |
| 280 | ) : ( | 305 | ) : ( |
| 281 | <> | 306 | <> |
| 282 | <UsageCoverageNotice | | |
| 283 | environments={environments} | | |
| 284 | duplicateSources={merged.duplicateSources} | | |
| 285 | staleEnvironments={merged.staleEnvironments} | | |
| 286 | /> | | |
| 287 | | | |
| 288 | <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]"> | 307 | <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]"> |
| 289 | <div className="flex min-w-0 flex-col gap-5"> | 308 | <div className="flex min-w-0 flex-col gap-5"> |
| 290 | <div className="flex flex-col gap-1"> | 309 | <div className="flex flex-col gap-1"> |
| ... | @@ -550,10 +569,8 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri | ... | @@ -550,10 +569,8 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri |
| 550 | } | 569 | } |
| 551 | | 570 | |
| 552 | /** | 571 | /** |
| 553 | * Says plainly when the totals are incomplete: an environment that failed, or | 572 | * Explains failed or incompatible environments and deduplicated transcripts. |
| 554 | * one whose transcripts another environment already reported. Environments | 573 | * Shown inside the environment filter so arriving results do not move the page. |
| 555 | * that are still answering never reach this notice; the page shows the | | |
| 556 | * loading skeleton until every one is terminal. | | |
| 557 | */ | 574 | */ |
| 558 | function UsageCoverageNotice({ | 575 | function UsageCoverageNotice({ |
| 559 | environments, | 576 | environments, |
| ... | @@ -573,7 +590,7 @@ function UsageCoverageNotice({ | ... | @@ -573,7 +590,7 @@ function UsageCoverageNotice({ |
| 573 | } | 590 | } |
| 574 | | 591 | |
| 575 | return ( | 592 | return ( |
| 576 | <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground"> | 593 | <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground"> |
| 577 | {failed.map((environment) => ( | 594 | {failed.map((environment) => ( |
| 578 | <span key={environment.label}>{environment.label} could not report usage.</span> | 595 | <span key={environment.label}>{environment.label} could not report usage.</span> |
| 579 | ))} | 596 | ))} |
| ... | @@ -592,66 +609,149 @@ function UsageCoverageNotice({ | ... | @@ -592,66 +609,149 @@ function UsageCoverageNotice({ |
| 592 | ); | 609 | ); |
| 593 | } | 610 | } |
| 594 | | 611 | |
| 595 | /** | 612 | /** Environment selection and scan progress share a permanent header control. */ |
| 596 | * Per-device progress while the page waits for every environment to answer. | 613 | function UsageEnvironmentFilter({ |
| 597 | * Only rendered with two or more devices; a lone device has nothing to | | |
| 598 | * enumerate. | | |
| 599 | */ | | |
| 600 | function UsageDeviceStrip({ | | |
| 601 | environments, | 614 | environments, |
| | 615 | selectedEnvironments, |
| | 616 | selectedEnvironmentIds, |
| | 617 | onSelectionChange, |
| | 618 | showUsageStatus, |
| | 619 | isPartial, |
| | 620 | duplicateSources, |
| | 621 | staleEnvironments, |
| 602 | }: { | 622 | }: { |
| 603 | readonly environments: readonly EnvironmentUsageStatus[]; | 623 | readonly environments: readonly EnvironmentUsageStatus[]; |
| | 624 | readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; |
| | 625 | readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; |
| | 626 | readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void; |
| | 627 | readonly showUsageStatus: boolean; |
| | 628 | readonly isPartial: boolean; |
| | 629 | readonly duplicateSources: readonly string[]; |
| | 630 | readonly staleEnvironments: readonly string[]; |
| 604 | }) { | 631 | }) { |
| 605 | const scanning = environments.filter( | 632 | const allSelected = selectedEnvironmentIds === null; |
| 606 | (environment) => environment.summary === null && environment.error === null, | 633 | const label = allSelected |
| 607 | ); | 634 | ? "All environments" |
| | 635 | : selectedEnvironments.length === 1 |
| | 636 | ? selectedEnvironments[0]!.label |
| | 637 | : `${selectedEnvironments.length} environments`; |
| | 638 | const pendingCount = selectedEnvironments.filter( |
| | 639 | (environment) => |
| | 640 | environment.error === null && (environment.isPending || environment.summary === null), |
| | 641 | ).length; |
| | 642 | const hasIssue = |
| | 643 | selectedEnvironments.some((environment) => environment.error !== null) || |
| | 644 | staleEnvironments.length > 0; |
| | 645 | |
| 608 | return ( | 646 | return ( |
| 609 | <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs"> | 647 | <> |
| 610 | {environments.map((environment) => { | 648 | <Menu> |
| 611 | if (environment.summary !== null) { | 649 | <MenuTrigger className="group/usage-environment inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"> |
| 612 | return ( | 650 | <span className="min-w-0 truncate">{label}</span> |
| 613 | <span | 651 | <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground"> |
| 614 | key={environment.environmentId} | 652 | {showUsageStatus && pendingCount > 0 ? ( |
| 615 | className="flex items-center gap-1 text-foreground" | 653 | <> |
| 616 | > | 654 | <CircleDashedIcon className="size-3.5" aria-hidden /> |
| 617 | <CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden /> | 655 | <span className="sr-only"> |
| 618 | {environment.label} | 656 | {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still |
| 619 | </span> | 657 | scanning |
| 620 | ); | 658 | {isPartial ? "; totals are partial" : ""} |
| 621 | } | 659 | </span> |
| 622 | if (environment.error !== null) { | 660 | </> |
| 623 | return ( | 661 | ) : showUsageStatus && hasIssue ? ( |
| 624 | <span | 662 | <CircleAlertIcon |
| 625 | key={environment.environmentId} | 663 | className="size-3.5 text-amber-600 dark:text-amber-400" |
| 626 | className="flex items-center gap-1 text-destructive" | 664 | aria-label="Some environments could not report usage" |
| 627 | > | 665 | /> |
| 628 | <XIcon className="size-3" aria-hidden /> | 666 | ) : ( |
| 629 | {environment.label} | 667 | <ChevronDownIcon |
| 630 | </span> | 668 | className="size-3.5 opacity-0 transition-opacity group-hover/usage-environment:opacity-100 group-focus-visible/usage-environment:opacity-100 group-data-popup-open/usage-environment:opacity-100" |
| 631 | ); | 669 | aria-hidden |
| 632 | } | 670 | /> |
| 633 | return ( | 671 | )} |
| 634 | <span | | |
| 635 | key={environment.environmentId} | | |
| 636 | className="animate-status-pulse text-muted-foreground" | | |
| 637 | > | | |
| 638 | {environment.label}… | | |
| 639 | </span> | 672 | </span> |
| 640 | ); | 673 | </MenuTrigger> |
| 641 | })} | 674 | <MenuPopup align="start" className="w-80 max-w-[calc(100vw-2rem)]"> |
| 642 | <span className="ms-auto text-muted-foreground"> | 675 | <MenuCheckboxItem |
| 643 | {scanning.length === 1 | 676 | checked={allSelected} |
| 644 | ? "1 device still scanning" | 677 | closeOnClick={false} |
| 645 | : `${scanning.length} devices still scanning`} | 678 | onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())} |
| 646 | </span> | 679 | > |
| 647 | </div> | 680 | All environments |
| | 681 | </MenuCheckboxItem> |
| | 682 | <MenuSeparator /> |
| | 683 | {environments.map((environment) => { |
| | 684 | const checked = |
| | 685 | selectedEnvironmentIds === null || |
| | 686 | selectedEnvironmentIds.has(environment.environmentId); |
| | 687 | const status = |
| | 688 | environment.error !== null |
| | 689 | ? "Unavailable" |
| | 690 | : environment.summary !== null && |
| | 691 | !isCompatibleUsageContractVersion( |
| | 692 | environment.summary.contractVersion, |
| | 693 | USAGE_CONTRACT_VERSION, |
| | 694 | ) |
| | 695 | ? "Update required" |
| | 696 | : environment.summary === null |
| | 697 | ? "Scanning…" |
| | 698 | : environment.isPending |
| | 699 | ? "Refreshing…" |
| | 700 | : "Ready"; |
| | 701 | return ( |
| | 702 | <MenuCheckboxItem |
| | 703 | key={environment.environmentId} |
| | 704 | checked={checked} |
| | 705 | closeOnClick={false} |
| | 706 | className="grid-cols-[1rem_minmax(0,1fr)]" |
| | 707 | onCheckedChange={(nextChecked) => { |
| | 708 | const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); |
| | 709 | if (nextChecked) next.add(environment.environmentId); |
| | 710 | else next.delete(environment.environmentId); |
| | 711 | onSelectionChange(next.size === environments.length ? null : next); |
| | 712 | }} |
| | 713 | > |
| | 714 | <span className="flex min-w-0 items-center gap-3"> |
| | 715 | <span className="min-w-0 flex-1 truncate">{environment.label}</span> |
| | 716 | {showUsageStatus ? ( |
| | 717 | <span |
| | 718 | className={cn( |
| | 719 | "shrink-0 text-xs text-muted-foreground", |
| | 720 | environment.error !== null && "text-destructive", |
| | 721 | )} |
| | 722 | > |
| | 723 | {status} |
| | 724 | </span> |
| | 725 | ) : null} |
| | 726 | </span> |
| | 727 | </MenuCheckboxItem> |
| | 728 | ); |
| | 729 | })} |
| | 730 | {environments.length === 0 ? ( |
| | 731 | <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p> |
| | 732 | ) : null} |
| | 733 | {showUsageStatus && isPartial ? ( |
| | 734 | <p className="px-2 py-2 text-xs text-muted-foreground"> |
| | 735 | Totals are partial while selected environments scan. |
| | 736 | </p> |
| | 737 | ) : null} |
| | 738 | {showUsageStatus ? ( |
| | 739 | <UsageCoverageNotice |
| | 740 | environments={selectedEnvironments} |
| | 741 | duplicateSources={duplicateSources} |
| | 742 | staleEnvironments={staleEnvironments} |
| | 743 | /> |
| | 744 | ) : null} |
| | 745 | </MenuPopup> |
| | 746 | </Menu> |
| | 747 | </> |
| 648 | ); | 748 | ); |
| 649 | } | 749 | } |
| 650 | | 750 | |
| 651 | /** | 751 | /** |
| 652 | * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it | 752 | * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it |
| 653 | * breathes with the same `animate-skeleton` pulse as every other loading state. | 753 | * breathes with the same `animate-skeleton` pulse as every other loading state. |
| 654 | * Blocks fill in exactly once when the last device answers. | 754 | * Replaced by results as soon as the first environment answers. |
| 655 | */ | 755 | */ |
| 656 | function UsageSkeleton() { | 756 | function UsageSkeleton() { |
| 657 | return ( | 757 | return ( |