Files
fusion/packages/dashboard/app/hooks/useUsageData.ts
gsxdsm b6243d68fe FN-6197: suppress tab-resume fetch errors in dashboard
Keep cached dashboard data visible while reconnecting after transient tab-resume fetch failures.

- detect likely tab suspension and visibility-resume fetch errors across dashboard data hooks
- suppress transient "Failed to fetch" errors when cached project and node data already exist
- show a "Connecting…" executor status state instead of surfacing raw resume-time fetch errors
- add dashboard tests covering visibility suspension handling and executor reconnect rendering
- add a patch changeset for the published CLI package

Files changed:
 .changeset/tame-tab-resume-fetch.md                |   5 +
 packages/dashboard/app/App.tsx                     |  16 ++-
 .../dashboard/app/components/ExecutorStatusBar.css |  19 ++-
 .../dashboard/app/components/ExecutorStatusBar.tsx |  12 ++
 .../__tests__/ExecutorStatusBar.test.tsx           |  20 ++-
 .../dashboard/app/hooks/__tests__/useNodes.test.ts | 149 ++++++++++++++++++++-
 .../app/hooks/__tests__/useProjects.test.ts        |  72 +++++++++-
 .../hooks/__tests__/visibilitySuspension.test.ts   |  13 ++
 packages/dashboard/app/hooks/useExecutorStats.ts   |  15 ++-
 .../dashboard/app/hooks/useManagedDockerNodes.ts   |  25 +++-
 packages/dashboard/app/hooks/useMeshState.ts       |  25 +++-
 packages/dashboard/app/hooks/useNodes.ts           |  25 +++-
 packages/dashboard/app/hooks/useProjectHealth.ts   |  18 ++-
 packages/dashboard/app/hooks/useProjects.ts        |  27 +++-
 packages/dashboard/app/hooks/useUsageData.ts       |  24 +++-
 .../dashboard/app/hooks/visibilitySuspension.ts    |   4 +
 16 files changed, 435 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-6197

Fusion-Task-Lineage: 834f56d8-8391-414f-8cbd-7e626c5724d0
2026-06-10 09:21:48 -07:00

143 lines
3.6 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { getErrorMessage } from "@fusion/core";
import { fetchUsageData, type ProviderUsage } from "../api";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
interface UsageDataState {
providers: ProviderUsage[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
hasFetched: boolean;
}
interface UseUsageDataOptions {
/** Auto-refresh interval in ms (default: 30 seconds) */
pollInterval?: number;
/** Whether to auto-refresh (default: true) */
autoRefresh?: boolean;
}
/**
* Hook for fetching and polling provider usage data.
*
* Features:
* - Initial fetch on mount
* - Auto-refresh every 30 seconds when enabled
* - Manual refresh capability
* - Loading and error states
* - Cleanup on unmount
*/
export function useUsageData(options: UseUsageDataOptions = {}) {
const { pollInterval = 30_000, autoRefresh = true } = options;
const [state, setState] = useState<UsageDataState>({
providers: [],
loading: true,
error: null,
lastUpdated: null,
hasFetched: false,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const stateRef = useRef(state);
const visibilitySuspension = useTabVisibilitySuspension();
useEffect(() => {
stateRef.current = state;
}, [state]);
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
return stateRef.current.hasFetched && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
}, [visibilitySuspension]);
const fetchData = useCallback(async (isManual = false) => {
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
if (isManual) {
setState((prev) => ({ ...prev, loading: true, error: null }));
}
try {
const { providers } = await fetchUsageData();
setState({
providers,
loading: false,
error: null,
lastUpdated: new Date(),
hasFetched: true,
});
} catch (err) {
// Don't update state if the request was aborted
if (err instanceof Error && err.name === "AbortError") return;
const errorMessage = getErrorMessage(err) || "Failed to fetch usage data";
if (shouldSuppressVisibilityResumeError(errorMessage)) {
setState((prev) => ({
...prev,
loading: false,
}));
return;
}
setState((prev) => ({
...prev,
loading: false,
error: errorMessage,
hasFetched: true,
}));
}
}, [shouldSuppressVisibilityResumeError]);
// Initial fetch
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh
useEffect(() => {
if (!autoRefresh) return;
pollRef.current = setInterval(() => {
fetchData(false);
}, pollInterval);
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [autoRefresh, pollInterval, fetchData]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);
const refresh = useCallback(() => {
return fetchData(true);
}, [fetchData]);
return {
providers: state.providers,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
hasFetched: state.hasFetched,
refresh,
};
}