| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 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 { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; |
| 3 | import { ArrowLeftIcon, CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; | | |
| 4 | import { useMemo, useState } from "react"; | 3 | import { useMemo, useState } from "react"; |
| 5 | | 4 | |
| | 5 | import { isElectron } from "../../env"; |
| 6 | import { cn } from "../../lib/utils"; | 6 | import { cn } from "../../lib/utils"; |
| 7 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | 7 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 8 | import { | 8 | import { |
| ... | @@ -15,6 +15,8 @@ import { | ... | @@ -15,6 +15,8 @@ import { |
| 15 | makeWindow, | 15 | makeWindow, |
| 16 | } from "@t3tools/shared/usageFormat"; | 16 | } from "@t3tools/shared/usageFormat"; |
| 17 | import { ScrollArea } from "../ui/scroll-area"; | 17 | import { ScrollArea } from "../ui/scroll-area"; |
| | 18 | import { SidebarInset } from "../ui/sidebar"; |
| | 19 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; |
| 18 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 20 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 19 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 21 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
| 20 | | 22 | |
| ... | @@ -28,9 +30,6 @@ export function UsagePage() { | ... | @@ -28,9 +30,6 @@ export function UsagePage() { |
| 28 | const [windowDays, setWindowDays] = useState<number>(30); | 30 | const [windowDays, setWindowDays] = useState<number>(30); |
| 29 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); | 31 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 30 | const [breakdown, setBreakdown] = useState<"model" | "day">("model"); | 32 | const [breakdown, setBreakdown] = useState<"model" | "day">("model"); |
| 31 | const canGoBack = useCanGoBack(); | | |
| 32 | const navigate = useNavigate(); | | |
| 33 | const router = useRouter(); | | |
| 34 | | 33 | |
| 35 | // Recomputed only when the window length changes, so a re-render does not | 34 | // Recomputed only when the window length changes, so a re-render does not |
| 36 | // shift the range and refetch every environment. | 35 | // shift the range and refetch every environment. |
| ... | @@ -63,143 +62,207 @@ export function UsagePage() { | ... | @@ -63,143 +62,207 @@ export function UsagePage() { |
| 63 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; | 62 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; |
| 64 | | 63 | |
| 65 | return ( | 64 | return ( |
| 66 | <ScrollArea className="h-full"> | 65 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 67 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> | 66 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 68 | <header className="flex flex-wrap items-start justify-between gap-4"> | 67 | {!isElectron && ( |
| 69 | <div className="flex items-start gap-3"> | 68 | <header |
| 70 | <button | 69 | className={cn( |
| 71 | type="button" | 70 | "workspace-topbar px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", |
| 72 | aria-label="Back" | 71 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 73 | onClick={() => { | 72 | )} |
| 74 | if (canGoBack) { | 73 | > |
| 75 | router.history.back(); | 74 | <h1 className="text-sm font-medium text-foreground">Usage</h1> |
| 76 | return; | 75 | </header> |
| 77 | } | 76 | )} |
| 78 | void navigate({ to: "/" }); | 77 | |
| 79 | }} | 78 | {isElectron && ( |
| 80 | className="mt-1 cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" | 79 | <div |
| 81 | > | 80 | className={cn( |
| 82 | <ArrowLeftIcon className="size-3.5" /> | 81 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", |
| 83 | </button> | 82 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 84 | <div className="flex flex-col gap-1"> | 83 | )} |
| 85 | <h1 className="text-2xl font-semibold text-foreground">Usage</h1> | 84 | > |
| | 85 | <h1 className="text-xs font-medium tracking-wide text-muted-foreground/70">Usage</h1> |
| | 86 | </div> |
| | 87 | )} |
| | 88 | |
| | 89 | <ScrollArea className="min-h-0 flex-1"> |
| | 90 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> |
| | 91 | <div className="flex flex-wrap items-center justify-between gap-4"> |
| 86 | <p className="text-sm text-muted-foreground"> | 92 | <p className="text-sm text-muted-foreground"> |
| 87 | {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} | 93 | {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} |
| 88 | </p> | 94 | </p> |
| 89 | </div> | 95 | <div className="flex items-center gap-2"> |
| 90 | </div> | 96 | <div className="flex overflow-hidden rounded-md border border-border"> |
| 91 | <div className="flex items-center gap-2"> | 97 | {WINDOW_OPTIONS.map((option) => ( |
| 92 | <div className="flex overflow-hidden rounded-md border border-border"> | 98 | <button |
| 93 | {WINDOW_OPTIONS.map((option) => ( | 99 | key={option.days} |
| | 100 | type="button" |
| | 101 | onClick={() => setWindowDays(option.days)} |
| | 102 | className={cn( |
| | 103 | "cursor-pointer px-3 py-1.5 text-xs", |
| | 104 | option.days === windowDays |
| | 105 | ? "bg-muted text-foreground" |
| | 106 | : "text-muted-foreground hover:text-foreground", |
| | 107 | )} |
| | 108 | > |
| | 109 | {option.label} |
| | 110 | </button> |
| | 111 | ))} |
| | 112 | </div> |
| 94 | <button | 113 | <button |
| 95 | key={option.days} | | |
| 96 | type="button" | 114 | type="button" |
| 97 | onClick={() => setWindowDays(option.days)} | 115 | onClick={refresh} |
| 98 | className={cn( | 116 | aria-label="Refresh usage" |
| 99 | "cursor-pointer px-3 py-1.5 text-xs", | 117 | className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" |
| 100 | option.days === windowDays | | |
| 101 | ? "bg-muted text-foreground" | | |
| 102 | : "text-muted-foreground hover:text-foreground", | | |
| 103 | )} | | |
| 104 | > | 118 | > |
| 105 | {option.label} | 119 | <RefreshCwIcon className="size-3.5" /> |
| 106 | </button> | 120 | </button> |
| 107 | ))} | 121 | </div> |
| 108 | </div> | 122 | </div> |
| 109 | <button | | |
| 110 | type="button" | | |
| 111 | onClick={refresh} | | |
| 112 | aria-label="Refresh usage" | | |
| 113 | className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" | | |
| 114 | > | | |
| 115 | <RefreshCwIcon className="size-3.5" /> | | |
| 116 | </button> | | |
| 117 | </div> | | |
| 118 | </header> | | |
| 119 | | 123 | |
| 120 | {settling ? ( | 124 | {settling ? ( |
| 121 | <> | 125 | <> |
| 122 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} | 126 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 123 | <UsageSkeleton /> | 127 | <UsageSkeleton /> |
| 124 | </> | 128 | </> |
| 125 | ) : ( | 129 | ) : ( |
| 126 | <> | 130 | <> |
| 127 | <UsageCoverageNotice | 131 | <UsageCoverageNotice |
| 128 | environments={environments} | 132 | environments={environments} |
| 129 | duplicateSources={merged.duplicateSources} | 133 | duplicateSources={merged.duplicateSources} |
| 130 | staleEnvironments={merged.staleEnvironments} | 134 | staleEnvironments={merged.staleEnvironments} |
| 131 | /> | 135 | /> |
| 132 | | 136 | |
| 133 | {/* Cost first: the financial answer, then the provider split. */} | 137 | {/* Cost first: the financial answer, then the provider split. */} |
| 134 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | 138 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 135 | {/* The summary follows the chart toggle, so the headline and the | 139 | {/* The summary follows the chart toggle, so the headline and the |
| 136 | series are always reading the same units. */} | 140 | series are always reading the same units. */} |
| 137 | <div className="flex flex-col gap-5"> | 141 | <div className="flex flex-col gap-5"> |
| 138 | <div className="flex flex-col gap-1"> | 142 | <div className="flex flex-col gap-1"> |
| 139 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> | 143 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> |
| 140 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | 144 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} |
| 141 | </span> | 145 | </span> |
| 142 | <span className="text-4xl font-semibold text-foreground tabular-nums"> | 146 | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 143 | {metric === "cost" | 147 | {metric === "cost" |
| 144 | ? `${formatUsd(merged.costUsd)}*` | 148 | ? `${formatUsd(merged.costUsd)}*` |
| 145 | : formatTokens(merged.totalTokens)} | 149 | : formatTokens(merged.totalTokens)} |
| 146 | </span> | 150 | </span> |
| 147 | <span className="text-xs text-muted-foreground"> | | |
| 148 | {metric === "cost" | | |
| 149 | ? "* if billed at full API rate" | | |
| 150 | : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} | | |
| 151 | </span> | | |
| 152 | </div> | | |
| 153 | | | |
| 154 | {orderedProviders.map((provider) => { | | |
| 155 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; | | |
| 156 | return ( | | |
| 157 | <div key={provider.provider} className="flex flex-col gap-1.5"> | | |
| 158 | <div className="flex items-baseline justify-between"> | | |
| 159 | <span className="flex items-center gap-2 text-sm text-foreground"> | | |
| 160 | <ProviderMark provider={provider.provider} className="size-4" /> | | |
| 161 | {PROVIDER_LABEL[provider.provider]} | | |
| 162 | </span> | | |
| 163 | <span className="text-sm text-foreground tabular-nums"> | | |
| 164 | {metric === "cost" | | |
| 165 | ? formatUsd(provider.costUsd) | | |
| 166 | : formatTokens(provider.totalTokens)} | | |
| 167 | </span> | | |
| 168 | </div> | | |
| 169 | <div className="h-1 w-full overflow-hidden rounded-full bg-muted"> | | |
| 170 | <div | | |
| 171 | className="h-full" | | |
| 172 | style={{ | | |
| 173 | width: `${(share * 100).toFixed(1)}%`, | | |
| 174 | backgroundColor: PROVIDER_COLOR[provider.provider], | | |
| 175 | }} | | |
| 176 | /> | | |
| 177 | </div> | | |
| 178 | <span className="text-xs text-muted-foreground"> | 151 | <span className="text-xs text-muted-foreground"> |
| 179 | {metric === "cost" | 152 | {metric === "cost" |
| 180 | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` | 153 | ? "* if billed at full API rate" |
| 181 | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} | 154 | : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} |
| 182 | </span> | 155 | </span> |
| 183 | </div> | 156 | </div> |
| 184 | ); | | |
| 185 | })} | | |
| 186 | </div> | | |
| 187 | | 157 | |
| 188 | <div className="flex flex-col gap-3"> | 158 | {orderedProviders.map((provider) => { |
| 189 | <div className="flex flex-wrap items-center justify-between gap-3"> | 159 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; |
| 190 | <h2 className="text-sm font-medium text-foreground"> | 160 | return ( |
| 191 | Daily {metric === "tokens" ? "processed tokens" : "cost"} | 161 | <div key={provider.provider} className="flex flex-col gap-1.5"> |
| 192 | </h2> | 162 | <div className="flex items-baseline justify-between"> |
| 193 | <div className="flex items-center gap-4"> | 163 | <span className="flex items-center gap-2 text-sm text-foreground"> |
| | 164 | <ProviderMark provider={provider.provider} className="size-4" /> |
| | 165 | {PROVIDER_LABEL[provider.provider]} |
| | 166 | </span> |
| | 167 | <span className="text-sm text-foreground tabular-nums"> |
| | 168 | {metric === "cost" |
| | 169 | ? formatUsd(provider.costUsd) |
| | 170 | : formatTokens(provider.totalTokens)} |
| | 171 | </span> |
| | 172 | </div> |
| | 173 | <div className="h-1 w-full overflow-hidden rounded-full bg-muted"> |
| | 174 | <div |
| | 175 | className="h-full" |
| | 176 | style={{ |
| | 177 | width: `${(share * 100).toFixed(1)}%`, |
| | 178 | backgroundColor: PROVIDER_COLOR[provider.provider], |
| | 179 | }} |
| | 180 | /> |
| | 181 | </div> |
| | 182 | <span className="text-xs text-muted-foreground"> |
| | 183 | {metric === "cost" |
| | 184 | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` |
| | 185 | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} |
| | 186 | </span> |
| | 187 | </div> |
| | 188 | ); |
| | 189 | })} |
| | 190 | </div> |
| | 191 | |
| | 192 | <div className="flex flex-col gap-3"> |
| | 193 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| | 194 | <h2 className="text-sm font-medium text-foreground"> |
| | 195 | Daily {metric === "tokens" ? "processed tokens" : "cost"} |
| | 196 | </h2> |
| | 197 | <div className="flex items-center gap-4"> |
| | 198 | <div className="flex overflow-hidden rounded-md border border-border"> |
| | 199 | {(["cost", "tokens"] as const).map((option) => ( |
| | 200 | <button |
| | 201 | key={option} |
| | 202 | type="button" |
| | 203 | onClick={() => setMetric(option)} |
| | 204 | className={cn( |
| | 205 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", |
| | 206 | option === metric |
| | 207 | ? "bg-muted text-foreground" |
| | 208 | : "text-muted-foreground hover:text-foreground", |
| | 209 | )} |
| | 210 | > |
| | 211 | {option} |
| | 212 | </button> |
| | 213 | ))} |
| | 214 | </div> |
| | 215 | <UsageChartLegend /> |
| | 216 | </div> |
| | 217 | </div> |
| | 218 | <UsageProviderChart days={days} daily={merged.daily} metric={metric} /> |
| | 219 | </div> |
| | 220 | </section> |
| | 221 | |
| | 222 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> |
| | 223 | <Metric |
| | 224 | label="Processed tokens" |
| | 225 | value={formatTokens(merged.totalTokens)} |
| | 226 | detail={`${formatTokens(dailyAverage)} per active day`} |
| | 227 | /> |
| | 228 | <Metric |
| | 229 | label="Cached input" |
| | 230 | value={formatTokens(merged.cachedInputTokens)} |
| | 231 | detail={`${formatPercent(cachedShare)} of observed input`} |
| | 232 | /> |
| | 233 | <Metric |
| | 234 | label="Uncached input" |
| | 235 | value={formatTokens(merged.uncachedInputTokens)} |
| | 236 | detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} |
| | 237 | /> |
| | 238 | <Metric |
| | 239 | label="Output" |
| | 240 | value={formatTokens(merged.outputTokens)} |
| | 241 | detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`} |
| | 242 | /> |
| | 243 | <Metric |
| | 244 | label="Cache savings" |
| | 245 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} |
| | 246 | detail={ |
| | 247 | merged.costUsd > 0 |
| | 248 | ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` |
| | 249 | : "vs full input rates" |
| | 250 | } |
| | 251 | /> |
| | 252 | </section> |
| | 253 | |
| | 254 | <section className="flex flex-col gap-3"> |
| | 255 | <div className="flex items-center justify-between gap-3"> |
| | 256 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 194 | <div className="flex overflow-hidden rounded-md border border-border"> | 257 | <div className="flex overflow-hidden rounded-md border border-border"> |
| 195 | {(["cost", "tokens"] as const).map((option) => ( | 258 | {(["model", "day"] as const).map((option) => ( |
| 196 | <button | 259 | <button |
| 197 | key={option} | 260 | key={option} |
| 198 | type="button" | 261 | type="button" |
| 199 | onClick={() => setMetric(option)} | 262 | onClick={() => setBreakdown(option)} |
| 200 | className={cn( | 263 | className={cn( |
| 201 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | 264 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", |
| 202 | option === metric | 265 | option === breakdown |
| 203 | ? "bg-muted text-foreground" | 266 | ? "bg-muted text-foreground" |
| 204 | : "text-muted-foreground hover:text-foreground", | 267 | : "text-muted-foreground hover:text-foreground", |
| 205 | )} | 268 | )} |
| ... | @@ -208,160 +271,103 @@ export function UsagePage() { | ... | @@ -208,160 +271,103 @@ export function UsagePage() { |
| 208 | </button> | 271 | </button> |
| 209 | ))} | 272 | ))} |
| 210 | </div> | 273 | </div> |
| 211 | <UsageChartLegend /> | | |
| 212 | </div> | 274 | </div> |
| 213 | </div> | | |
| 214 | <UsageProviderChart days={days} daily={merged.daily} metric={metric} /> | | |
| 215 | </div> | | |
| 216 | </section> | | |
| 217 | | | |
| 218 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | | |
| 219 | <Metric | | |
| 220 | label="Processed tokens" | | |
| 221 | value={formatTokens(merged.totalTokens)} | | |
| 222 | detail={`${formatTokens(dailyAverage)} per active day`} | | |
| 223 | /> | | |
| 224 | <Metric | | |
| 225 | label="Cached input" | | |
| 226 | value={formatTokens(merged.cachedInputTokens)} | | |
| 227 | detail={`${formatPercent(cachedShare)} of observed input`} | | |
| 228 | /> | | |
| 229 | <Metric | | |
| 230 | label="Uncached input" | | |
| 231 | value={formatTokens(merged.uncachedInputTokens)} | | |
| 232 | detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} | | |
| 233 | /> | | |
| 234 | <Metric | | |
| 235 | label="Output" | | |
| 236 | value={formatTokens(merged.outputTokens)} | | |
| 237 | detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`} | | |
| 238 | /> | | |
| 239 | <Metric | | |
| 240 | label="Cache savings" | | |
| 241 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} | | |
| 242 | detail={ | | |
| 243 | merged.costUsd > 0 | | |
| 244 | ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` | | |
| 245 | : "vs full input rates" | | |
| 246 | } | | |
| 247 | /> | | |
| 248 | </section> | | |
| 249 | | | |
| 250 | <section className="flex flex-col gap-3"> | | |
| 251 | <div className="flex items-center justify-between gap-3"> | | |
| 252 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> | | |
| 253 | <div className="flex overflow-hidden rounded-md border border-border"> | | |
| 254 | {(["model", "day"] as const).map((option) => ( | | |
| 255 | <button | | |
| 256 | key={option} | | |
| 257 | type="button" | | |
| 258 | onClick={() => setBreakdown(option)} | | |
| 259 | className={cn( | | |
| 260 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | | |
| 261 | option === breakdown | | |
| 262 | ? "bg-muted text-foreground" | | |
| 263 | : "text-muted-foreground hover:text-foreground", | | |
| 264 | )} | | |
| 265 | > | | |
| 266 | {option} | | |
| 267 | </button> | | |
| 268 | ))} | | |
| 269 | </div> | | |
| 270 | </div> | | |
| 271 | | 275 | |
| 272 | {breakdown === "model" ? ( | 276 | {breakdown === "model" ? ( |
| 273 | <table className="w-full text-sm"> | 277 | <table className="w-full text-sm"> |
| 274 | <thead> | 278 | <thead> |
| 275 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> | 279 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 276 | <th className="py-2 font-normal">Model</th> | 280 | <th className="py-2 font-normal">Model</th> |
| 277 | <th className="py-2 text-right font-normal">Cost</th> | 281 | <th className="py-2 text-right font-normal">Cost</th> |
| 278 | <th className="py-2 text-right font-normal">Share</th> | 282 | <th className="py-2 text-right font-normal">Share</th> |
| 279 | <th className="py-2 text-right font-normal">Tokens</th> | 283 | <th className="py-2 text-right font-normal">Tokens</th> |
| 280 | </tr> | | |
| 281 | </thead> | | |
| 282 | <tbody> | | |
| 283 | {merged.models.length === 0 ? ( | | |
| 284 | <tr> | | |
| 285 | <td colSpan={4} className="py-6 text-center text-muted-foreground"> | | |
| 286 | No activity in this window. | | |
| 287 | </td> | | |
| 288 | </tr> | | |
| 289 | ) : ( | | |
| 290 | merged.models.map((model) => ( | | |
| 291 | <tr | | |
| 292 | key={`${model.provider}:${model.model}`} | | |
| 293 | className="border-b border-border/50" | | |
| 294 | > | | |
| 295 | <td className="py-2 text-foreground"> | | |
| 296 | <span className="flex items-center gap-2"> | | |
| 297 | <ProviderMark provider={model.provider} className="size-3.5" /> | | |
| 298 | {model.model} | | |
| 299 | </span> | | |
| 300 | </td> | | |
| 301 | <td className="py-2 text-right text-foreground tabular-nums"> | | |
| 302 | {formatUsd(model.costUsd)} | | |
| 303 | </td> | | |
| 304 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | | |
| 305 | {formatPercent(model.costShare)} | | |
| 306 | </td> | | |
| 307 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | | |
| 308 | {formatTokens(model.totalTokens)} | | |
| 309 | </td> | | |
| 310 | </tr> | 284 | </tr> |
| 311 | )) | 285 | </thead> |
| 312 | )} | 286 | <tbody> |
| 313 | </tbody> | 287 | {merged.models.length === 0 ? ( |
| 314 | </table> | 288 | <tr> |
| 315 | ) : ( | 289 | <td colSpan={4} className="py-6 text-center text-muted-foreground"> |
| 316 | <table className="w-full text-sm"> | 290 | No activity in this window. |
| 317 | <thead> | | |
| 318 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> | | |
| 319 | <th className="py-2 font-normal">Day</th> | | |
| 320 | {PROVIDER_ORDER.map((provider) => ( | | |
| 321 | <th key={provider} className="py-2 text-right font-normal"> | | |
| 322 | {PROVIDER_LABEL[provider]} | | |
| 323 | </th> | | |
| 324 | ))} | | |
| 325 | <th className="py-2 text-right font-normal">Total</th> | | |
| 326 | <th className="py-2 text-right font-normal">Tokens</th> | | |
| 327 | </tr> | | |
| 328 | </thead> | | |
| 329 | <tbody> | | |
| 330 | {recentDays.length === 0 ? ( | | |
| 331 | <tr> | | |
| 332 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> | | |
| 333 | No activity in this window. | | |
| 334 | </td> | | |
| 335 | </tr> | | |
| 336 | ) : ( | | |
| 337 | recentDays.map((day) => ( | | |
| 338 | <tr key={day.day} className="border-b border-border/50"> | | |
| 339 | <td className="py-2 text-foreground">{formatDayShort(day.day)}</td> | | |
| 340 | {PROVIDER_ORDER.map((provider) => ( | | |
| 341 | <td | | |
| 342 | key={provider} | | |
| 343 | className="py-2 text-right text-muted-foreground tabular-nums" | | |
| 344 | > | | |
| 345 | {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} | | |
| 346 | </td> | 291 | </td> |
| | 292 | </tr> |
| | 293 | ) : ( |
| | 294 | merged.models.map((model) => ( |
| | 295 | <tr |
| | 296 | key={`${model.provider}:${model.model}`} |
| | 297 | className="border-b border-border/50" |
| | 298 | > |
| | 299 | <td className="py-2 text-foreground"> |
| | 300 | <span className="flex items-center gap-2"> |
| | 301 | <ProviderMark provider={model.provider} className="size-3.5" /> |
| | 302 | {model.model} |
| | 303 | </span> |
| | 304 | </td> |
| | 305 | <td className="py-2 text-right text-foreground tabular-nums"> |
| | 306 | {formatUsd(model.costUsd)} |
| | 307 | </td> |
| | 308 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| | 309 | {formatPercent(model.costShare)} |
| | 310 | </td> |
| | 311 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| | 312 | {formatTokens(model.totalTokens)} |
| | 313 | </td> |
| | 314 | </tr> |
| | 315 | )) |
| | 316 | )} |
| | 317 | </tbody> |
| | 318 | </table> |
| | 319 | ) : ( |
| | 320 | <table className="w-full text-sm"> |
| | 321 | <thead> |
| | 322 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| | 323 | <th className="py-2 font-normal">Day</th> |
| | 324 | {PROVIDER_ORDER.map((provider) => ( |
| | 325 | <th key={provider} className="py-2 text-right font-normal"> |
| | 326 | {PROVIDER_LABEL[provider]} |
| | 327 | </th> |
| 347 | ))} | 328 | ))} |
| 348 | <td className="py-2 text-right text-foreground tabular-nums"> | 329 | <th className="py-2 text-right font-normal">Total</th> |
| 349 | {formatUsd(day.costUsd)} | 330 | <th className="py-2 text-right font-normal">Tokens</th> |
| 350 | </td> | | |
| 351 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | | |
| 352 | {formatTokens(day.totalTokens)} | | |
| 353 | </td> | | |
| 354 | </tr> | 331 | </tr> |
| 355 | )) | 332 | </thead> |
| 356 | )} | 333 | <tbody> |
| 357 | </tbody> | 334 | {recentDays.length === 0 ? ( |
| 358 | </table> | 335 | <tr> |
| 359 | )} | 336 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> |
| 360 | </section> | 337 | No activity in this window. |
| 361 | </> | 338 | </td> |
| 362 | )} | 339 | </tr> |
| | 340 | ) : ( |
| | 341 | recentDays.map((day) => ( |
| | 342 | <tr key={day.day} className="border-b border-border/50"> |
| | 343 | <td className="py-2 text-foreground">{formatDayShort(day.day)}</td> |
| | 344 | {PROVIDER_ORDER.map((provider) => ( |
| | 345 | <td |
| | 346 | key={provider} |
| | 347 | className="py-2 text-right text-muted-foreground tabular-nums" |
| | 348 | > |
| | 349 | {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} |
| | 350 | </td> |
| | 351 | ))} |
| | 352 | <td className="py-2 text-right text-foreground tabular-nums"> |
| | 353 | {formatUsd(day.costUsd)} |
| | 354 | </td> |
| | 355 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| | 356 | {formatTokens(day.totalTokens)} |
| | 357 | </td> |
| | 358 | </tr> |
| | 359 | )) |
| | 360 | )} |
| | 361 | </tbody> |
| | 362 | </table> |
| | 363 | )} |
| | 364 | </section> |
| | 365 | </> |
| | 366 | )} |
| | 367 | </div> |
| | 368 | </ScrollArea> |
| 363 | </div> | 369 | </div> |
| 364 | </ScrollArea> | 370 | </SidebarInset> |
| 365 | ); | 371 | ); |
| 366 | } | 372 | } |
| 367 | | 373 | |