| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | | import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; |
| 3 | | import { ArrowLeftIcon, CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; |
| 2 | import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; |
| 4 | 3 | import { useMemo, useState } from "react"; |
| 5 | 4 | |
| 5 | import { isElectron } from "../../env"; |
| 6 | 6 | import { cn } from "../../lib/utils"; |
| 7 | 7 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 8 | 8 | import { |
| ... | ... | @@ -15,6 +15,8 @@ import { |
| 15 | 15 | makeWindow, |
| 16 | 16 | } from "@t3tools/shared/usageFormat"; |
| 17 | 17 | import { ScrollArea } from "../ui/scroll-area"; |
| 18 | import { SidebarInset } from "../ui/sidebar"; |
| 19 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; |
| 18 | 20 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 19 | 21 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
| 20 | 22 | |
| ... | ... | @@ -28,9 +30,6 @@ export function UsagePage() { |
| 28 | 30 | const [windowDays, setWindowDays] = useState<number>(30); |
| 29 | 31 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 30 | 32 | const [breakdown, setBreakdown] = useState<"model" | "day">("model"); |
| 31 | | const canGoBack = useCanGoBack(); |
| 32 | | const navigate = useNavigate(); |
| 33 | | const router = useRouter(); |
| 34 | 33 | |
| 35 | 34 | // Recomputed only when the window length changes, so a re-render does not |
| 36 | 35 | // shift the range and refetch every environment. |
| ... | ... | @@ -63,143 +62,207 @@ export function UsagePage() { |
| 63 | 62 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; |
| 64 | 63 | |
| 65 | 64 | return ( |
| 66 | | <ScrollArea className="h-full"> |
| 67 | | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> |
| 68 | | <header className="flex flex-wrap items-start justify-between gap-4"> |
| 69 | | <div className="flex items-start gap-3"> |
| 70 | | <button |
| 71 | | type="button" |
| 72 | | aria-label="Back" |
| 73 | | onClick={() => { |
| 74 | | if (canGoBack) { |
| 75 | | router.history.back(); |
| 76 | | return; |
| 77 | | } |
| 78 | | void navigate({ to: "/" }); |
| 79 | | }} |
| 80 | | className="mt-1 cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" |
| 81 | | > |
| 82 | | <ArrowLeftIcon className="size-3.5" /> |
| 83 | | </button> |
| 84 | | <div className="flex flex-col gap-1"> |
| 85 | | <h1 className="text-2xl font-semibold text-foreground">Usage</h1> |
| 65 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 66 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 67 | {!isElectron && ( |
| 68 | <header |
| 69 | className={cn( |
| 70 | "workspace-topbar px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", |
| 71 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 72 | )} |
| 73 | > |
| 74 | <h1 className="text-sm font-medium text-foreground">Usage</h1> |
| 75 | </header> |
| 76 | )} |
| 77 | |
| 78 | {isElectron && ( |
| 79 | <div |
| 80 | className={cn( |
| 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)]", |
| 82 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 83 | )} |
| 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 | 92 | <p className="text-sm text-muted-foreground"> |
| 87 | 93 | {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} |
| 88 | 94 | </p> |
| 89 | | </div> |
| 90 | | </div> |
| 91 | | <div className="flex items-center gap-2"> |
| 92 | | <div className="flex overflow-hidden rounded-md border border-border"> |
| 93 | | {WINDOW_OPTIONS.map((option) => ( |
| 95 | <div className="flex items-center gap-2"> |
| 96 | <div className="flex overflow-hidden rounded-md border border-border"> |
| 97 | {WINDOW_OPTIONS.map((option) => ( |
| 98 | <button |
| 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 | 113 | <button |
| 95 | | key={option.days} |
| 96 | 114 | type="button" |
| 97 | | onClick={() => setWindowDays(option.days)} |
| 98 | | className={cn( |
| 99 | | "cursor-pointer px-3 py-1.5 text-xs", |
| 100 | | option.days === windowDays |
| 101 | | ? "bg-muted text-foreground" |
| 102 | | : "text-muted-foreground hover:text-foreground", |
| 103 | | )} |
| 115 | onClick={refresh} |
| 116 | aria-label="Refresh usage" |
| 117 | className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" |
| 104 | 118 | > |
| 105 | | {option.label} |
| 119 | <RefreshCwIcon className="size-3.5" /> |
| 106 | 120 | </button> |
| 107 | | ))} |
| 121 | </div> |
| 108 | 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 ? ( |
| 121 | | <> |
| 122 | | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 123 | | <UsageSkeleton /> |
| 124 | | </> |
| 125 | | ) : ( |
| 126 | | <> |
| 127 | | <UsageCoverageNotice |
| 128 | | environments={environments} |
| 129 | | duplicateSources={merged.duplicateSources} |
| 130 | | staleEnvironments={merged.staleEnvironments} |
| 131 | | /> |
| 124 | {settling ? ( |
| 125 | <> |
| 126 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 127 | <UsageSkeleton /> |
| 128 | </> |
| 129 | ) : ( |
| 130 | <> |
| 131 | <UsageCoverageNotice |
| 132 | environments={environments} |
| 133 | duplicateSources={merged.duplicateSources} |
| 134 | staleEnvironments={merged.staleEnvironments} |
| 135 | /> |
| 132 | 136 | |
| 133 | | {/* Cost first: the financial answer, then the provider split. */} |
| 134 | | <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 |
| 137 | {/* Cost first: the financial answer, then the provider split. */} |
| 138 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 139 | {/* The summary follows the chart toggle, so the headline and the |
| 136 | 140 | series are always reading the same units. */} |
| 137 | | <div className="flex flex-col gap-5"> |
| 138 | | <div className="flex flex-col gap-1"> |
| 139 | | <span className="text-xs tracking-wide text-muted-foreground uppercase"> |
| 140 | | {metric === "cost" ? "Raw token cost" : "Processed tokens"} |
| 141 | | </span> |
| 142 | | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 143 | | {metric === "cost" |
| 144 | | ? `${formatUsd(merged.costUsd)}*` |
| 145 | | : formatTokens(merged.totalTokens)} |
| 146 | | </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> |
| 141 | <div className="flex flex-col gap-5"> |
| 142 | <div className="flex flex-col gap-1"> |
| 143 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> |
| 144 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} |
| 145 | </span> |
| 146 | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 147 | {metric === "cost" |
| 148 | ? `${formatUsd(merged.costUsd)}*` |
| 149 | : formatTokens(merged.totalTokens)} |
| 150 | </span> |
| 178 | 151 | <span className="text-xs text-muted-foreground"> |
| 179 | 152 | {metric === "cost" |
| 180 | | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` |
| 181 | | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} |
| 153 | ? "* if billed at full API rate" |
| 154 | : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} |
| 182 | 155 | </span> |
| 183 | 156 | </div> |
| 184 | | ); |
| 185 | | })} |
| 186 | | </div> |
| 187 | 157 | |
| 188 | | <div className="flex flex-col gap-3"> |
| 189 | | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 190 | | <h2 className="text-sm font-medium text-foreground"> |
| 191 | | Daily {metric === "tokens" ? "processed tokens" : "cost"} |
| 192 | | </h2> |
| 193 | | <div className="flex items-center gap-4"> |
| 158 | {orderedProviders.map((provider) => { |
| 159 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; |
| 160 | return ( |
| 161 | <div key={provider.provider} className="flex flex-col gap-1.5"> |
| 162 | <div className="flex items-baseline justify-between"> |
| 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 | 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 | 259 | <button |
| 197 | 260 | key={option} |
| 198 | 261 | type="button" |
| 199 | | onClick={() => setMetric(option)} |
| 262 | onClick={() => setBreakdown(option)} |
| 200 | 263 | className={cn( |
| 201 | 264 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", |
| 202 | | option === metric |
| 265 | option === breakdown |
| 203 | 266 | ? "bg-muted text-foreground" |
| 204 | 267 | : "text-muted-foreground hover:text-foreground", |
| 205 | 268 | )} |
| ... | ... | @@ -208,160 +271,103 @@ export function UsagePage() { |
| 208 | 271 | </button> |
| 209 | 272 | ))} |
| 210 | 273 | </div> |
| 211 | | <UsageChartLegend /> |
| 212 | 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" ? ( |
| 273 | | <table className="w-full text-sm"> |
| 274 | | <thead> |
| 275 | | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 276 | | <th className="py-2 font-normal">Model</th> |
| 277 | | <th className="py-2 text-right font-normal">Cost</th> |
| 278 | | <th className="py-2 text-right font-normal">Share</th> |
| 279 | | <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> |
| 276 | {breakdown === "model" ? ( |
| 277 | <table className="w-full text-sm"> |
| 278 | <thead> |
| 279 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 280 | <th className="py-2 font-normal">Model</th> |
| 281 | <th className="py-2 text-right font-normal">Cost</th> |
| 282 | <th className="py-2 text-right font-normal">Share</th> |
| 283 | <th className="py-2 text-right font-normal">Tokens</th> |
| 310 | 284 | </tr> |
| 311 | | )) |
| 312 | | )} |
| 313 | | </tbody> |
| 314 | | </table> |
| 315 | | ) : ( |
| 316 | | <table className="w-full text-sm"> |
| 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)} |
| 285 | </thead> |
| 286 | <tbody> |
| 287 | {merged.models.length === 0 ? ( |
| 288 | <tr> |
| 289 | <td colSpan={4} className="py-6 text-center text-muted-foreground"> |
| 290 | No activity in this window. |
| 346 | 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"> |
| 349 | | {formatUsd(day.costUsd)} |
| 350 | | </td> |
| 351 | | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 352 | | {formatTokens(day.totalTokens)} |
| 353 | | </td> |
| 329 | <th className="py-2 text-right font-normal">Total</th> |
| 330 | <th className="py-2 text-right font-normal">Tokens</th> |
| 354 | 331 | </tr> |
| 355 | | )) |
| 356 | | )} |
| 357 | | </tbody> |
| 358 | | </table> |
| 359 | | )} |
| 360 | | </section> |
| 361 | | </> |
| 362 | | )} |
| 332 | </thead> |
| 333 | <tbody> |
| 334 | {recentDays.length === 0 ? ( |
| 335 | <tr> |
| 336 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> |
| 337 | No activity in this window. |
| 338 | </td> |
| 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 | 369 | </div> |
| 364 | | </ScrollArea> |
| 370 | </SidebarInset> |
| 365 | 371 | ); |
| 366 | 372 | } |
| 367 | 373 | |