Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,29 +449,29 @@ function UsageDeviceStrip({
readonly environments: readonly EnvironmentUsageStatus[];
}) {
const scanning = environments.filter(
(environment) => environment.summary === null && environment.error === null,
(environment) => environment.isPending && environment.error === null,
);
return (
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs">
{environments.map((environment) => {
if (environment.summary !== null) {
if (environment.error !== null) {
return (
<span
key={environment.environmentId}
className="flex items-center gap-1 text-foreground"
className="flex items-center gap-1 text-destructive"
>
<CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden />
<XIcon className="size-3" aria-hidden />
{environment.label}
</span>
);
}
if (environment.error !== null) {
if (!environment.isPending && environment.summary !== null) {
return (
<span
key={environment.environmentId}
className="flex items-center gap-1 text-destructive"
className="flex items-center gap-1 text-foreground"
>
<XIcon className="size-3" aria-hidden />
<CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden />
{environment.label}
</span>
);
Expand Down
47 changes: 19 additions & 28 deletions apps/web/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,21 @@
* @module state/usage
*/
import { useAtomValue } from "@effect/atom-react";
import {
USAGE_CONTRACT_VERSION,
type EnvironmentId,
type UsageSummary,
type UsageSummaryInput,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { USAGE_CONTRACT_VERSION, type UsageSummaryInput } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
import { useCallback, useMemo } from "react";

import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentPresentations } from "./presentation";
import { serverEnvironment } from "./server";
import {
deriveEnvironmentUsageStatus,
deriveUsageSettlingState,
type EnvironmentUsageStatus,
} from "./usageStatus";

export interface EnvironmentUsageStatus {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly isPending: boolean;
readonly error: string | null;
readonly summary: UsageSummary | null;
}
export type { EnvironmentUsageStatus } from "./usageStatus";

/**
* Reads every environment's summary for one window.
Expand All @@ -45,13 +38,14 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
const statuses: EnvironmentUsageStatus[] = [];
for (const [environmentId, presentation] of presentations) {
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
statuses.push({
environmentId,
label: presentation.entry.target.label,
isPending: result.waiting,
error: result._tag === "Failure" ? "This environment could not report usage." : null,
summary: Option.getOrNull(AsyncResult.value(result)),
});
statuses.push(
deriveEnvironmentUsageStatus({
connectionPhase: presentation.connection.phase,
result,
environmentId,
label: presentation.entry.target.label,
}),
);
}
return statuses;
}).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)),
Expand Down Expand Up @@ -111,16 +105,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {
return mergeUsage(answered, USAGE_CONTRACT_VERSION);
}, [environments]);

const answeredCount = environments.filter((environment) => environment.summary !== null).length;
const stillReporting = environments.filter(
(environment) => environment.summary === null && environment.error === null,
).length;
const settling = deriveUsageSettlingState(environments);

return {
merged,
environments,
isPending: answeredCount === 0 && stillReporting > 0,
isPartial: answeredCount > 0 && stillReporting > 0,
isPending: settling.isPending,
isPartial: settling.isPartial,
refresh,
};
}
157 changes: 157 additions & 0 deletions apps/web/src/state/usageStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import {
USAGE_CONTRACT_VERSION,
type EnvironmentId,
type UsageDay,
type UsageSummary,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { describe, expect, it } from "vite-plus/test";

import { deriveEnvironmentUsageStatus, deriveUsageSettlingState } from "./usageStatus";

const summary: UsageSummary = {
contractVersion: USAGE_CONTRACT_VERSION,
readAt: "2026-08-09T00:00:00.000Z",
timeZone: "UTC",
sinceDay: "2026-08-01" as UsageDay,
untilDay: "2026-08-09" as UsageDay,
buckets: [],
sources: [],
pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 0 },
scanDurationMs: 1,
};

const freshSummary: UsageSummary = {
...summary,
readAt: "2026-08-09T00:01:00.000Z",
scanDurationMs: 2,
};

function status(
environmentId: string,
connectionPhase: EnvironmentConnectionPhase,
result: AsyncResult.AsyncResult<UsageSummary, string>,
) {
return deriveEnvironmentUsageStatus({
environmentId: environmentId as EnvironmentId,
label: environmentId,
connectionPhase,
result,
});
}

describe("usage status", () => {
it("settles a refresh only after every environment answers the new request", () => {
const macRefreshing = status(
"mac",
"connected",
AsyncResult.success<UsageSummary, string>(summary, { waiting: true }),
);
const linuxRefreshing = status(
"linux",
"connected",
AsyncResult.success<UsageSummary, string>(summary, { waiting: true }),
);

expect(macRefreshing.summary).toBe(summary);
expect(deriveUsageSettlingState([macRefreshing, linuxRefreshing])).toEqual({
isPending: true,
isPartial: false,
});

const macFinished = status("mac", "connected", AsyncResult.success(summary));
expect(deriveUsageSettlingState([macFinished, linuxRefreshing])).toEqual({
isPending: false,
isPartial: true,
});

const linuxFinished = status("linux", "connected", AsyncResult.success(summary));
expect(deriveUsageSettlingState([macFinished, linuxFinished])).toEqual({
isPending: false,
isPartial: false,
});
});

it("waits while an environment makes its initial connection", () => {
expect(
status("connecting", "connecting", AsyncResult.initial<UsageSummary, string>(true)),
).toEqual(expect.objectContaining({ isPending: true, error: null, summary: null }));
});

it("waits for a connected environment's first usage result", () => {
expect(status("connected", "connected", AsyncResult.initial<UsageSummary, string>())).toEqual(
expect.objectContaining({ isPending: true, error: null, summary: null }),
);
});

it("waits through reconnect and a fresh scan before completing", () => {
const reconnecting = status("laptop", "reconnecting", AsyncResult.success(summary));
const refreshing = status(
"laptop",
"connected",
AsyncResult.success<UsageSummary, string>(summary, { waiting: true }),
);
const refreshed = status("laptop", "connected", AsyncResult.success(freshSummary));

expect(reconnecting).toEqual(
expect.objectContaining({ isPending: true, error: null, summary }),
);
expect(refreshing).toEqual(expect.objectContaining({ isPending: true, error: null, summary }));
expect(refreshed).toEqual(
expect.objectContaining({ isPending: false, error: null, summary: freshSummary }),
);
});

it("settles terminal connection phases without a completed summary as failures", () => {
for (const connectionPhase of ["available", "offline", "error"] as const) {
expect(
status("offline", connectionPhase, AsyncResult.initial<UsageSummary, string>(true)),
).toEqual(
expect.objectContaining({
isPending: false,
error: "This environment could not report usage.",
summary: null,
}),
);
}
});

it("keeps a completed summary when its environment later disconnects", () => {
const result = AsyncResult.success<UsageSummary, string>(summary);
const connected = status("laptop", "connected", result);
const disconnected = status("laptop", "offline", result);

expect(connected).toEqual(expect.objectContaining({ isPending: false, error: null, summary }));
expect(disconnected).toEqual(connected);
});

it("drops a retained previous summary when its refresh fails", () => {
const previous = AsyncResult.success<UsageSummary, string>(summary);
const retrying = AsyncResult.failWithPrevious("scan failed", {
previous: Option.some(previous),
waiting: true,
});
const failed = AsyncResult.failWithPrevious("scan failed", {
previous: Option.some(previous),
});

expect(status("desktop", "connected", retrying)).toEqual(
expect.objectContaining({ isPending: true, error: null, summary }),
);

const failedStatus = status("desktop", "connected", failed);
expect(failedStatus).toEqual(
expect.objectContaining({
isPending: false,
error: "This environment could not report usage.",
summary: null,
}),
);
expect(deriveUsageSettlingState([failedStatus])).toEqual({
isPending: false,
isPartial: false,
});
});
});
81 changes: 81 additions & 0 deletions apps/web/src/state/usageStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentId, UsageSummary } from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";

const USAGE_REPORT_ERROR = "This environment could not report usage.";

export interface EnvironmentUsageStatus {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly isPending: boolean;
readonly error: string | null;
readonly summary: UsageSummary | null;
}

type UsageConnectionState = "connected" | "transitioning" | "terminal";

/**
* Connection phases decide whether a usage request can still make progress.
* Keeping this exhaustive makes a newly added phase an explicit product
* decision instead of silently treating it as a failure.
*/
function classifyUsageConnection(phase: EnvironmentConnectionPhase): UsageConnectionState {
switch (phase) {
case "connected":
return "connected";
case "connecting":
case "reconnecting":
return "transitioning";
case "available":
case "offline":
case "error":
return "terminal";
}
}

/** Projects transport and SWR state into the status rendered for one environment. */
export function deriveEnvironmentUsageStatus<E>(input: {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly connectionPhase: EnvironmentConnectionPhase;
readonly result: AsyncResult.AsyncResult<UsageSummary, E>;
}): EnvironmentUsageStatus {
const connection = classifyUsageConnection(input.connectionPhase);
const isPending =
connection === "transitioning" ||
(connection === "connected" && (input.result.waiting || input.result._tag === "Initial"));
const summary = Option.getOrNull(AsyncResult.value(input.result));
const hasTerminalQueryFailure = input.result._tag === "Failure" && !isPending;
return {
environmentId: input.environmentId,
label: input.label,
isPending,
error:
hasTerminalQueryFailure || (connection === "terminal" && summary === null)
? USAGE_REPORT_ERROR
: null,
summary: hasTerminalQueryFailure ? null : summary,
};
}

/** Derives the page gate for the current request generation. */
export function deriveUsageSettlingState(environments: readonly EnvironmentUsageStatus[]): {
readonly isPending: boolean;
readonly isPartial: boolean;
} {
// SWR preserves the previous summary while a refresh is in flight. A
// retained value belongs to the previous request, so it does not count as
// this request having answered until `waiting` clears.
const answeredCount = environments.filter(
(environment) => environment.summary !== null && !environment.isPending,
).length;
const stillReporting = environments.filter(
(environment) => environment.isPending && environment.error === null,
).length;

return {
isPending: answeredCount === 0 && stillReporting > 0,
isPartial: answeredCount > 0 && stillReporting > 0,
};
}
Loading