| author | |
| committer | |
| log | 17dbe8dda3ba87eb83804bc93fc0cb945186d204 |
| tree | cf00434695197e6550f1ad7c74f856ab0299ea16 |
| parent | b4be33f0747445f1c9df126e932c7b9792f322d5 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 74 insertions(+), 20 deletions(-)
apps/web/src/components/usage/UsagePage.test.tsx+10-7| ... | @@ -53,13 +53,16 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ | ... | @@ -53,13 +53,16 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ |
| 53 | vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); | 53 | vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); |
| 54 | vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); | 54 | vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); |
| 55 | vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); | 55 | vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); |
| 56 | vi.mock("./usageProviders", () => ({ | 56 | vi.mock("./usageProviders", async (importOriginal) => { |
| 57 | PROVIDER_ORDER: ["codex", "claude"], | 57 | const actual = await importOriginal<typeof import("./usageProviders")>(); |
| 58 | PROVIDER_PRESENTATION: { | 58 | return { |
| 59 | codex: { color: "white", label: "Codex", mark: "span" }, | 59 | ...actual, |
| 60 | claude: { color: "orange", label: "Claude Code", mark: "span" }, | 60 | PROVIDER_PRESENTATION: { |
| 61 | }, | 61 | codex: { color: "white", label: "Codex", mark: "span" }, |
| 62 | })); | 62 | claude: { color: "orange", label: "Claude Code", mark: "span" }, |
| 63 | }, | ||
| 64 | }; | ||
| 65 | }); | ||
| 63 | 66 | ||
| 64 | import { UsagePage } from "./UsagePage"; | 67 | import { UsagePage } from "./UsagePage"; |
| 65 | 68 |
apps/web/src/components/usage/UsagePage.tsx+27-7| ... | @@ -32,7 +32,7 @@ import { | ... | @@ -32,7 +32,7 @@ import { |
| 32 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | 32 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 33 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; | 33 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 34 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 34 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 35 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; | 35 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 36 | 36 | ||
| 37 | const WINDOW_OPTIONS = [ | 37 | const WINDOW_OPTIONS = [ |
| 38 | { days: 1, label: "Past 24h" }, | 38 | { days: 1, label: "Past 24h" }, |
| ... | @@ -74,6 +74,8 @@ export function UsagePage() { | ... | @@ -74,6 +74,8 @@ export function UsagePage() { |
| 74 | () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), | 74 | () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), |
| 75 | [isPast24Hours, merged.daily, merged.hourly], | 75 | [isPast24Hours, merged.daily, merged.hourly], |
| 76 | ); | 76 | ); |
| 77 | const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); | ||
| 78 | const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; | ||
| 77 | 79 | ||
| 78 | const selectWindow = (days: number) => { | 80 | const selectWindow = (days: number) => { |
| 79 | setWindowSelection({ | 81 | setWindowSelection({ |
| ... | @@ -225,7 +227,7 @@ export function UsagePage() { | ... | @@ -225,7 +227,7 @@ export function UsagePage() { |
| 225 | </span> | 227 | </span> |
| 226 | </div> | 228 | </div> |
| 227 | 229 | ||
| 228 | {PROVIDER_ORDER.map((provider) => { | 230 | {activeProviders.map((provider) => { |
| 229 | const totals = merged.providers.find((entry) => entry.provider === provider); | 231 | const totals = merged.providers.find((entry) => entry.provider === provider); |
| 230 | const share = | 232 | const share = |
| 231 | metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); | 233 | metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); |
| ... | @@ -276,6 +278,7 @@ export function UsagePage() { | ... | @@ -276,6 +278,7 @@ export function UsagePage() { |
| 276 | {metric === "tokens" ? "processed tokens" : "cost"} | 278 | {metric === "tokens" ? "processed tokens" : "cost"} |
| 277 | </h2> | 279 | </h2> |
| 278 | <UsageProviderChart | 280 | <UsageProviderChart |
| 281 | providers={activeProviders} | ||
| 279 | days={days} | 282 | days={days} |
| 280 | daily={merged.daily} | 283 | daily={merged.daily} |
| 281 | hours={hours} | 284 | hours={hours} |
| ... | @@ -331,7 +334,13 @@ export function UsagePage() { | ... | @@ -331,7 +334,13 @@ export function UsagePage() { |
| 331 | </div> | 334 | </div> |
| 332 | 335 | ||
| 333 | {breakdown === "model" ? ( | 336 | {breakdown === "model" ? ( |
| 334 | <table className="w-full text-sm"> | 337 | <table className="w-full table-fixed text-sm"> |
| 338 | <colgroup> | ||
| 339 | <col className="w-2/5" /> | ||
| 340 | <col className="w-1/5" /> | ||
| 341 | <col className="w-1/5" /> | ||
| 342 | <col className="w-1/5" /> | ||
| 343 | </colgroup> | ||
| 335 | <thead> | 344 | <thead> |
| 336 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> | 345 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 337 | <th className="py-2 font-normal">Model</th> | 346 | <th className="py-2 font-normal">Model</th> |
| ... | @@ -374,11 +383,19 @@ export function UsagePage() { | ... | @@ -374,11 +383,19 @@ export function UsagePage() { |
| 374 | </tbody> | 383 | </tbody> |
| 375 | </table> | 384 | </table> |
| 376 | ) : ( | 385 | ) : ( |
| 377 | <table className="w-full text-sm"> | 386 | <table className="w-full table-fixed text-sm"> |
| 387 | <colgroup> | ||
| 388 | <col className="w-2/5" /> | ||
| 389 | {activeProviders.map((provider) => ( | ||
| 390 | <col key={provider} style={{ width: timeValueColumnWidth }} /> | ||
| 391 | ))} | ||
| 392 | <col style={{ width: timeValueColumnWidth }} /> | ||
| 393 | <col style={{ width: timeValueColumnWidth }} /> | ||
| 394 | </colgroup> | ||
| 378 | <thead> | 395 | <thead> |
| 379 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> | 396 | <tr className="border-b border-border text-left text-xs text-muted-foreground"> |
| 380 | <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th> | 397 | <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th> |
| 381 | {PROVIDER_ORDER.map((provider) => ( | 398 | {activeProviders.map((provider) => ( |
| 382 | <th key={provider} className="py-2 text-right font-normal"> | 399 | <th key={provider} className="py-2 text-right font-normal"> |
| 383 | {PROVIDER_PRESENTATION[provider].label} | 400 | {PROVIDER_PRESENTATION[provider].label} |
| 384 | </th> | 401 | </th> |
| ... | @@ -390,7 +407,10 @@ export function UsagePage() { | ... | @@ -390,7 +407,10 @@ export function UsagePage() { |
| 390 | <tbody> | 407 | <tbody> |
| 391 | {breakdownPeriods.length === 0 ? ( | 408 | {breakdownPeriods.length === 0 ? ( |
| 392 | <tr> | 409 | <tr> |
| 393 | <td colSpan={5} className="py-6 text-center text-muted-foreground"> | 410 | <td |
| 411 | colSpan={activeProviders.length + 3} | ||
| 412 | className="py-6 text-center text-muted-foreground" | ||
| 413 | > | ||
| 394 | No activity in this window. | 414 | No activity in this window. |
| 395 | </td> | 415 | </td> |
| 396 | </tr> | 416 | </tr> |
| ... | @@ -405,7 +425,7 @@ export function UsagePage() { | ... | @@ -405,7 +425,7 @@ export function UsagePage() { |
| 405 | ? formatHourShort(period.hourStart, window.timeZone) | 425 | ? formatHourShort(period.hourStart, window.timeZone) |
| 406 | : formatDayShort(period.day)} | 426 | : formatDayShort(period.day)} |
| 407 | </td> | 427 | </td> |
| 408 | {PROVIDER_ORDER.map((provider) => ( | 428 | {activeProviders.map((provider) => ( |
| 409 | <td | 429 | <td |
| 410 | key={provider} | 430 | key={provider} |
| 411 | className="py-2 text-right text-muted-foreground tabular-nums" | 431 | className="py-2 text-right text-muted-foreground tabular-nums" |
apps/web/src/components/usage/UsageProviderChart.test.ts+12| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { buildDayColumns, niceScale } from "./UsageProviderChart"; | 3 | import { buildDayColumns, niceScale } from "./UsageProviderChart"; |
| 4 | import { providersWithUsage } from "./usageProviders"; | ||
| 4 | 5 | ||
| 5 | describe("niceScale", () => { | 6 | describe("niceScale", () => { |
| 6 | it("never puts the peak above the top of the scale", () => { | 7 | it("never puts the peak above the top of the scale", () => { |
| ... | @@ -96,6 +97,17 @@ describe("buildDayColumns", () => { | ... | @@ -96,6 +97,17 @@ describe("buildDayColumns", () => { |
| 96 | }); | 97 | }); |
| 97 | }); | 98 | }); |
| 98 | 99 | ||
| 100 | describe("providersWithUsage", () => { | ||
| 101 | it("omits providers with no cost or tokens", () => { | ||
| 102 | expect( | ||
| 103 | providersWithUsage([ | ||
| 104 | { provider: "codex", costUsd: 0, totalTokens: 0 }, | ||
| 105 | { provider: "claude", costUsd: 0, totalTokens: 200 }, | ||
| 106 | ]), | ||
| 107 | ).toEqual(["claude"]); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 99 | describe("hourly chart columns", () => { | 111 | describe("hourly chart columns", () => { |
| 100 | it("zero-fills inactive hours and preserves hourly provider values", () => { | 112 | it("zero-fills inactive hours and preserves hourly provider values", () => { |
| 101 | const byHour = new Map([ | 113 | const byHour = new Map([ |
apps/web/src/components/usage/UsageProviderChart.tsx+7-4| ... | @@ -19,6 +19,7 @@ const PLOT_TOP = 8; | ... | @@ -19,6 +19,7 @@ const PLOT_TOP = 8; |
| 19 | export type UsageChartMetric = "tokens" | "cost"; | 19 | export type UsageChartMetric = "tokens" | "cost"; |
| 20 | 20 | ||
| 21 | interface UsageProviderChartProps { | 21 | interface UsageProviderChartProps { |
| 22 | readonly providers: readonly UsageProviderKind[]; | ||
| 22 | readonly days: readonly string[]; | 23 | readonly days: readonly string[]; |
| 23 | readonly daily: readonly DailyTotals[]; | 24 | readonly daily: readonly DailyTotals[]; |
| 24 | readonly hours: readonly string[]; | 25 | readonly hours: readonly string[]; |
| ... | @@ -187,6 +188,7 @@ export function buildDayColumns( | ... | @@ -187,6 +188,7 @@ export function buildDayColumns( |
| 187 | } | 188 | } |
| 188 | 189 | ||
| 189 | export function UsageProviderChart({ | 190 | export function UsageProviderChart({ |
| 191 | providers, | ||
| 190 | days, | 192 | days, |
| 191 | daily, | 193 | daily, |
| 192 | hours, | 194 | hours, |
| ... | @@ -209,7 +211,7 @@ export function UsageProviderChart({ | ... | @@ -209,7 +211,7 @@ export function UsageProviderChart({ |
| 209 | const tooltipRef = useRef<HTMLDivElement | null>(null); | 211 | const tooltipRef = useRef<HTMLDivElement | null>(null); |
| 210 | const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); | 212 | const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); |
| 211 | 213 | ||
| 212 | const { paths, series, stepX, ticks, toY } = useMemo(() => { | 214 | const { paths, ticks, stepX, toY, series } = useMemo(() => { |
| 213 | if (periods.length === 0) { | 215 | if (periods.length === 0) { |
| 214 | return { | 216 | return { |
| 215 | paths: [], | 217 | paths: [], |
| ... | @@ -235,7 +237,8 @@ export function UsageProviderChart({ | ... | @@ -235,7 +237,8 @@ export function UsageProviderChart({ |
| 235 | const toY = (value: number) => | 237 | const toY = (value: number) => |
| 236 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); | 238 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); |
| 237 | 239 | ||
| 238 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { | 240 | const built = providers.map((provider) => { |
| 241 | const providerIndex = PROVIDER_ORDER.indexOf(provider); | ||
| 239 | const line = curvePath( | 242 | const line = curvePath( |
| 240 | smoothCurve( | 243 | smoothCurve( |
| 241 | columns.map((column, periodIndex) => ({ | 244 | columns.map((column, periodIndex) => ({ |
| ... | @@ -260,7 +263,7 @@ export function UsageProviderChart({ | ... | @@ -260,7 +263,7 @@ export function UsageProviderChart({ |
| 260 | ticks: tickValues, | 263 | ticks: tickValues, |
| 261 | toY, | 264 | toY, |
| 262 | }; | 265 | }; |
| 263 | }, [byPeriod, metric, periods]); | 266 | }, [byPeriod, metric, periods, providers]); |
| 264 | 267 | ||
| 265 | const format = metric === "tokens" ? formatTokens : formatUsd; | 268 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| 266 | 269 | ||
| ... | @@ -422,7 +425,7 @@ export function UsageProviderChart({ | ... | @@ -422,7 +425,7 @@ export function UsageProviderChart({ |
| 422 | }} | 425 | }} |
| 423 | > | 426 | > |
| 424 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> | 427 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> |
| 425 | {PROVIDER_ORDER.map((provider) => { | 428 | {providers.map((provider) => { |
| 426 | const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; | 429 | const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; |
| 427 | return ( | 430 | return ( |
| 428 | <div key={provider} className="flex items-center justify-between gap-3"> | 431 | <div key={provider} className="flex items-center justify-between gap-3"> |
apps/web/src/components/usage/usageProviders.ts+18-2| ... | @@ -10,8 +10,8 @@ type UsageProviderPresentation = { | ... | @@ -10,8 +10,8 @@ type UsageProviderPresentation = { |
| 10 | 10 | ||
| 11 | /** | 11 | /** |
| 12 | * Exhaustive presentation for providers supported by the usage contract. | 12 | * Exhaustive presentation for providers supported by the usage contract. |
| 13 | * Declaration order is reused by every chart, table, legend, and skeleton, so | 13 | * Declaration order is reused by every chart and table, so adding a provider |
| 14 | * adding a provider only requires its contract support and one entry here. | 14 | * only requires its contract support and one entry here. |
| 15 | */ | 15 | */ |
| 16 | export const PROVIDER_PRESENTATION = { | 16 | export const PROVIDER_PRESENTATION = { |
| 17 | codex: { | 17 | codex: { |
| ... | @@ -28,3 +28,19 @@ export const PROVIDER_PRESENTATION = { | ... | @@ -28,3 +28,19 @@ export const PROVIDER_PRESENTATION = { |
| 28 | 28 | ||
| 29 | /** Stable provider reading order across charts, summaries, tables, and hover rows. */ | 29 | /** Stable provider reading order across charts, summaries, tables, and hover rows. */ |
| 30 | export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; | 30 | export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; |
| 31 | |||
| 32 | /** Providers with real activity, independent of the metric currently displayed. */ | ||
| 33 | export function providersWithUsage( | ||
| 34 | totals: readonly { | ||
| 35 | readonly provider: UsageProviderKind; | ||
| 36 | readonly costUsd: number; | ||
| 37 | readonly totalTokens: number; | ||
| 38 | }[], | ||
| 39 | ): readonly UsageProviderKind[] { | ||
| 40 | const active = new Set( | ||
| 41 | totals | ||
| 42 | .filter((entry) => entry.totalTokens > 0 || entry.costUsd > 0) | ||
| 43 | .map((entry) => entry.provider), | ||
| 44 | ); | ||
| 45 | return PROVIDER_ORDER.filter((provider) => active.has(provider)); | ||
| 46 | } |