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
143 lines
3.6 KiB
TypeScript
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,
|
|
};
|
|
}
|