FN-6970: fix live token usage refreshes
Keep Command Center token usage surfaces mounted and updated during live analytics polling. - Preserve existing analytics data while background polls are in flight so token surfaces revalidate without disappearing. - Add live refresh intervals for Overview and Tokens token-usage data. - Cover in-place token stat, chart, and model-row updates with Command Center and area tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6970-command-center-token-live.md | 7 ++ .../components/command-center/CommandCenter.tsx | 4 + .../__tests__/CommandCenter.test.tsx | 81 +++++++++++++-- .../components/command-center/areas/AreaShell.tsx | 3 + .../components/command-center/areas/TokensArea.tsx | 4 + .../command-center/areas/__tests__/areas.test.tsx | 111 +++++++++++++++++++-- .../command-center/areas/useAnalyticsArea.ts | 12 ++- 7 files changed, 208 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6970 Fusion-Task-Lineage: 670bb5ac-bc7d-47d8-abed-ef6748292cc6
This commit is contained in:
7
.changeset/fn-6970-command-center-token-live.md
Normal file
7
.changeset/fn-6970-command-center-token-live.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix Command Center token usage updating live without manual refresh.
|
||||||
|
category: fix
|
||||||
|
dev: Analytics polling now revalidates in the background when prior data exists so token cards, charts, and model rows stay mounted during live refresh.
|
||||||
@@ -94,6 +94,10 @@ The Open signals card must be real project data, not a swallowed missing-route b
|
|||||||
FNXC:CommandCenter 2026-06-18-23:45:
|
FNXC:CommandCenter 2026-06-18-23:45:
|
||||||
FN-6683 adds real Overview pie and line charts by reusing the already-fetched tokens and activity analytics. Keep the existing overview bars, sparkline, live strip, funnel, and loading/error/empty branches intact; no new endpoint is allowed for these additive affordances.
|
FN-6683 adds real Overview pie and line charts by reusing the already-fetched tokens and activity analytics. Keep the existing overview bars, sparkline, live strip, funnel, and loading/error/empty branches intact; no new endpoint is allowed for these additive affordances.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterTokenLive 2026-06-25-09:06:
|
||||||
|
Overview token usage is a live surface: the stat card, live strip, and token-by-model charts must consume the latest successful `/command-center/tokens` poll without remounting the page or requiring a date-range nudge.
|
||||||
|
*/
|
||||||
const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
|
const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
interface CommandCenterProps {
|
interface CommandCenterProps {
|
||||||
|
|||||||
@@ -534,11 +534,18 @@ describe("CommandCenter shell", () => {
|
|||||||
expect(liveMetricValue("command-center-live-tokens")).toBe("1,234,567,890");
|
expect(liveMetricValue("command-center-live-tokens")).toBe("1,234,567,890");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("live-polls token totals for the Overview card and live strip", async () => {
|
it("live-polls token totals for the Overview card, live strip, and model charts", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
let tokenTotal = 1_500;
|
let resolvePoll: ((value: ReturnType<typeof tokenFixture>) => void) | null = null;
|
||||||
apiMock.mockImplementation((path: string) => {
|
apiMock.mockImplementation((path: string) => {
|
||||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokenFixture(tokenTotal));
|
if (path.startsWith("/command-center/tokens")) {
|
||||||
|
if (apiMock.mock.calls.filter(([calledPath]) => typeof calledPath === "string" && calledPath.startsWith("/command-center/tokens")).length === 1) {
|
||||||
|
return Promise.resolve(tokenFixture(1_500));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePoll = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture());
|
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture());
|
||||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture());
|
if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture());
|
||||||
if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture());
|
if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture());
|
||||||
@@ -553,16 +560,78 @@ describe("CommandCenter shell", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(statValue("command-center-stat-tokens")).toBe("1,500");
|
expect(statValue("command-center-stat-tokens")).toBe("1,500");
|
||||||
|
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,500");
|
||||||
|
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("900")).toBeTruthy();
|
||||||
|
|
||||||
tokenTotal = 1_700;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(15_000);
|
vi.advanceTimersByTime(15_000);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(resolvePoll).not.toBeNull();
|
||||||
|
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
||||||
|
expect(statValue("command-center-stat-tokens")).toBe("1,500");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePoll?.({
|
||||||
|
...tokenFixture(1_900),
|
||||||
|
groups: [
|
||||||
|
{ ...tokenFixture().groups[0], totalTokens: 1_100, inputTokens: 700, outputTokens: 300 },
|
||||||
|
{ ...tokenFixture().groups[1], totalTokens: 800, inputTokens: 500, outputTokens: 200 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(statValue("command-center-stat-tokens")).toBe("1,700");
|
expect(statValue("command-center-stat-tokens")).toBe("1,900");
|
||||||
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,700");
|
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,900");
|
||||||
|
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("1,100")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates mounted Overview token surfaces from empty token data to populated poll data", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolvePoll: ((value: ReturnType<typeof tokenFixture>) => void) | null = null;
|
||||||
|
apiMock.mockImplementation((path: string) => {
|
||||||
|
if (path.startsWith("/command-center/tokens")) {
|
||||||
|
if (apiMock.mock.calls.filter(([calledPath]) => typeof calledPath === "string" && calledPath.startsWith("/command-center/tokens")).length === 1) {
|
||||||
|
return Promise.resolve(tokenFixture(0));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePoll = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture(0));
|
||||||
|
if (path.startsWith("/command-center/activity")) return Promise.resolve(emptyActivityFixture());
|
||||||
|
if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture());
|
||||||
|
if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(0));
|
||||||
|
if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 0 }]));
|
||||||
|
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CommandCenter />);
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("command-center-empty")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("command-center-stat-tokens")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(15_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(resolvePoll).not.toBeNull();
|
||||||
|
expect(screen.getByTestId("command-center-empty")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePoll?.(tokenFixture(1_500));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(statValue("command-center-stat-tokens")).toBe("1,500");
|
||||||
|
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,500");
|
||||||
|
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sources live tasks in progress from current column counts instead of funnel entered", async () => {
|
it("sources live tasks in progress from current column counts instead of funnel entered", async () => {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export interface AreaShellProps {
|
|||||||
* mirroring `ReliabilityView`'s state handling. Renders children only once
|
* mirroring `ReliabilityView`'s state handling. Renders children only once
|
||||||
* there is data to show; never crashes on an empty area (degrades to the
|
* there is data to show; never crashes on an empty area (degrades to the
|
||||||
* empty state instead).
|
* empty state instead).
|
||||||
|
*
|
||||||
|
* FNXC:CommandCenterTokenLive 2026-06-25-09:06:
|
||||||
|
* `isLoading` is a blocking initial-load signal. Background analytics polls with fallback data must keep children rendered so live token cards/charts do not disappear while revalidating.
|
||||||
*/
|
*/
|
||||||
export function AreaShell({ testId, isLoading, error, isEmpty, emptyMessage, children }: AreaShellProps) {
|
export function AreaShell({ testId, isLoading, error, isEmpty, emptyMessage, children }: AreaShellProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { formatCost, formatCount } from "./areaShared";
|
|||||||
|
|
||||||
type SortKey = "key" | "totalTokens" | "cost";
|
type SortKey = "key" | "totalTokens" | "cost";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterTokenLive 2026-06-25-09:06:
|
||||||
|
The Tokens detail area is the canonical token-usage live view. Polling must update totals, series bars, trends, and per-model rows in place while preserving controls such as granularity and sort state.
|
||||||
|
*/
|
||||||
const TOKENS_LIVE_REFRESH_MS = 15_000;
|
const TOKENS_LIVE_REFRESH_MS = 15_000;
|
||||||
const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"];
|
const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"];
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,78 @@ describe("useAnalyticsArea", () => {
|
|||||||
});
|
});
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
expect(apiMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats poll refreshes as background revalidation after data has loaded", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolvePoll: ((value: { ok: boolean; total: number }) => void) | null = null;
|
||||||
|
apiMock
|
||||||
|
.mockResolvedValueOnce({ ok: true, total: 1 })
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePoll = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAnalyticsArea<{ ok: boolean; total: number }>("/command-center/tokens", range7d, { pollMs: 1_000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current.data?.total).toBe(1);
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(resolvePoll).not.toBeNull();
|
||||||
|
expect(result.current.data?.total).toBe(1);
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePoll?.({ ok: true, total: 2 });
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current.data?.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up polling when a valid range becomes invalid and restarts after it becomes valid", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
apiMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ range }) => useAnalyticsArea<{ ok: boolean }>("/command-center/tokens", range, { pollMs: 1_000 }),
|
||||||
|
{ initialProps: { range: range7d } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({ range: customRange("2026-06-10", "2026-06-01") });
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({ range: customRange("2026-06-01", "2026-06-10") });
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ActivityArea", () => {
|
describe("ActivityArea", () => {
|
||||||
@@ -660,12 +732,15 @@ describe("TokensArea", () => {
|
|||||||
|
|
||||||
it("polls the live token value while preserving rendered content", async () => {
|
it("polls the live token value while preserving rendered content", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
let resolvePoll: ((value: ReturnType<typeof tokenFixture>) => void) | null = null;
|
||||||
apiMock
|
apiMock
|
||||||
.mockResolvedValueOnce(tokenFixture())
|
.mockResolvedValueOnce(tokenFixture())
|
||||||
.mockResolvedValueOnce({
|
.mockImplementationOnce(
|
||||||
...tokenFixture(),
|
() =>
|
||||||
totals: { ...tokenFixture().totals, totalTokens: 1700 },
|
new Promise((resolve) => {
|
||||||
});
|
resolvePoll = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
render(<TokensArea range={range7d} />);
|
render(<TokensArea range={range7d} />);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -673,14 +748,38 @@ describe("TokensArea", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
||||||
|
expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-row-gpt-4o").textContent).toContain("900");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(15_000);
|
vi.advanceTimersByTime(15_000);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(resolvePoll).not.toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-area-tokens-loading")).toBeNull();
|
||||||
|
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
||||||
|
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...tokenFixture(1_900),
|
||||||
|
series: [
|
||||||
|
tokenFixture().series[0],
|
||||||
|
{ ...tokenFixture().series[1], totalTokens: 1_300, inputTokens: 900, outputTokens: 400 },
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{ ...tokenFixture().groups[0], totalTokens: 1_100, inputTokens: 700, outputTokens: 300 },
|
||||||
|
{ ...tokenFixture().groups[1], totalTokens: 800, inputTokens: 500, outputTokens: 200 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
resolvePoll?.(updated);
|
||||||
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,700");
|
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,900");
|
||||||
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
|
expect(screen.getByLabelText("2026-06-09: 1,300")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-row-gpt-4o").textContent).toContain("1,100");
|
||||||
|
expect(screen.getByTestId("cc-tokens-line")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refetches when the date range changes", async () => {
|
it("refetches when the date range changes", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../../../api/legacy";
|
import { api } from "../../../api/legacy";
|
||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
import { isInvalidRange, rangeQuery } from "./areaShared";
|
import { isInvalidRange, rangeQuery } from "./areaShared";
|
||||||
@@ -34,6 +34,9 @@ function withRangeQuery(endpoint: string, query: string): string {
|
|||||||
* - Polling is opt-in via `options.pollMs`; invalid ranges never schedule an
|
* - Polling is opt-in via `options.pollMs`; invalid ranges never schedule an
|
||||||
* interval, and the interval is cleaned up on unmount/range/endpoint changes.
|
* interval, and the interval is cleaned up on unmount/range/endpoint changes.
|
||||||
*
|
*
|
||||||
|
* FNXC:CommandCenterTokenLive 2026-06-25-09:06:
|
||||||
|
* Live token refresh is background revalidation after the first successful payload, not a loading-state replacement. Command Center must keep Overview and Tokens surfaces mounted while a 15s poll is in flight so increased token totals render without manual refresh or range toggles.
|
||||||
|
*
|
||||||
* NOTE on the SWR-identity trap: this hook intentionally replaces `data`
|
* NOTE on the SWR-identity trap: this hook intentionally replaces `data`
|
||||||
* identity on every successful fetch. Consumers MUST key any selection / sort /
|
* identity on every successful fetch. Consumers MUST key any selection / sort /
|
||||||
* drill-down reset effect on a DERIVED value (e.g. `rows.map(r => r.id).join()`),
|
* drill-down reset effect on a DERIVED value (e.g. `rows.map(r => r.id).join()`),
|
||||||
@@ -45,6 +48,7 @@ export function useAnalyticsArea<T>(
|
|||||||
options: AnalyticsAreaOptions = {},
|
options: AnalyticsAreaOptions = {},
|
||||||
): AnalyticsAreaState<T> {
|
): AnalyticsAreaState<T> {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const dataRef = useRef<T | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -57,10 +61,14 @@ export function useAnalyticsArea<T>(
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
const hasFallbackData = dataRef.current !== null;
|
||||||
|
if (!hasFallbackData) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await api<T>(withRangeQuery(endpoint, query));
|
const result = await api<T>(withRangeQuery(endpoint, query));
|
||||||
|
dataRef.current = result;
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch (loadError: unknown) {
|
} catch (loadError: unknown) {
|
||||||
setError(loadError instanceof Error ? loadError.message : "Failed to load analytics");
|
setError(loadError instanceof Error ? loadError.message : "Failed to load analytics");
|
||||||
|
|||||||
Reference in New Issue
Block a user