authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-08-24 04:03:02+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-23 23:03:02-04:00
log17dbe8dda3ba87eb83804bc93fc0cb945186d204
treecf00434695197e6550f1ad7c74f856ab0299ea16
parentb4be33f0747445f1c9df126e932c7b9792f322d5
signature Signed by PGP key B5690EEEBB952194

fix(web): show only providers with usage in usage views (#7563)


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", () => ({
53vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));53vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
54vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));54vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
55vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));55vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
56vi.mock("./usageProviders", () => ({56vi.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});
6366
64import { UsagePage } from "./UsagePage";67import { UsagePage } from "./UsagePage";
6568
apps/web/src/components/usage/UsagePage.tsx+27-7
...@@ -32,7 +32,7 @@ import {...@@ -32,7 +32,7 @@ import {
32import { WorkspacePageContainer } from "../WorkspacePageContainer";32import { WorkspacePageContainer } from "../WorkspacePageContainer";
33import { WorkspacePageHeader } from "../WorkspacePageHeader";33import { WorkspacePageHeader } from "../WorkspacePageHeader";
34import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";34import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
35import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";35import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
3636
37const WINDOW_OPTIONS = [37const 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)}%`;
7779
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>
227229
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 <UsageProviderChart280 <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>
332335
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 <td429 <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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { buildDayColumns, niceScale } from "./UsageProviderChart";3import { buildDayColumns, niceScale } from "./UsageProviderChart";
4import { providersWithUsage } from "./usageProviders";
45
5describe("niceScale", () => {6describe("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});
9899
100describe("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
99describe("hourly chart columns", () => {111describe("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;
19export type UsageChartMetric = "tokens" | "cost";19export type UsageChartMetric = "tokens" | "cost";
2020
21interface UsageProviderChartProps {21interface 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}
188189
189export function UsageProviderChart({190export 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);
211213
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);
237239
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]);
264267
265 const format = metric === "tokens" ? formatTokens : formatUsd;268 const format = metric === "tokens" ? formatTokens : formatUsd;
266269
...@@ -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 = {
1010
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, so13 * 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 */
16export const PROVIDER_PRESENTATION = {16export const PROVIDER_PRESENTATION = {
17 codex: {17 codex: {
...@@ -28,3 +28,19 @@ export const PROVIDER_PRESENTATION = {...@@ -28,3 +28,19 @@ export const PROVIDER_PRESENTATION = {
2828
29/** Stable provider reading order across charts, summaries, tables, and hover rows. */29/** Stable provider reading order across charts, summaries, tables, and hover rows. */
30export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];30export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];
31
32/** Providers with real activity, independent of the metric currently displayed. */
33export 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}