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
This commit is contained in:
5
.changeset/tame-tab-resume-fetch.md
Normal file
5
.changeset/tame-tab-resume-fetch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Suppress transient dashboard fetch errors after tab resume so cached data remains visible and executor status shows a reconnecting state instead of raw network errors.
|
||||
@@ -77,6 +77,7 @@ import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { isLikelyTabSuspensionError } from "./hooks/visibilitySuspension";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import { FileBrowserProvider } from "./context/FileBrowserContext";
|
||||
import { ShellProvider } from "./context/ShellContext";
|
||||
@@ -257,8 +258,15 @@ function AppInner() {
|
||||
|
||||
// Project management hooks - MUST be called before any conditional logic
|
||||
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects();
|
||||
const hasEverLoadedProjectsRef = useRef(projects.length > 0);
|
||||
const { nodes } = useNodes();
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length > 0) {
|
||||
hasEverLoadedProjectsRef.current = true;
|
||||
}
|
||||
}, [projects.length]);
|
||||
|
||||
// Node context for local/remote node switching - must be called before useCurrentProject
|
||||
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
|
||||
|
||||
@@ -1396,12 +1404,18 @@ function AppInner() {
|
||||
}
|
||||
}, [shellState]);
|
||||
|
||||
const isSuppressedProjectResumeError =
|
||||
Boolean(projectsError) &&
|
||||
isLikelyTabSuspensionError(projectsError ?? "") &&
|
||||
hasEverLoadedProjectsRef.current;
|
||||
|
||||
const showBackendConnectionErrorPage =
|
||||
!projectsLoading &&
|
||||
!currentProjectLoading &&
|
||||
projects.length === 0 &&
|
||||
!currentProject &&
|
||||
Boolean(projectsError);
|
||||
Boolean(projectsError) &&
|
||||
!isSuppressedProjectResumeError;
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
background: var(--executor-status-error-bg);
|
||||
}
|
||||
|
||||
.executor-status-bar--connecting,
|
||||
.executor-status-bar--loading {
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -102,6 +103,14 @@
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.executor-status-bar__indicator--connecting {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.executor-status-bar__indicator--connecting.executor-status-bar__indicator--active {
|
||||
animation: executor-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Numeric count display */
|
||||
.executor-status-bar__count {
|
||||
font-family: var(--font-mono);
|
||||
@@ -251,13 +260,21 @@
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.executor-status-bar__error {
|
||||
.executor-status-bar__error,
|
||||
.executor-status-bar__connecting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.executor-status-bar__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.executor-status-bar__connecting {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Loading message */
|
||||
.executor-status-bar__loading-text {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react";
|
||||
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||
import { useExecutorStats } from "../hooks/useExecutorStats";
|
||||
import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension";
|
||||
import type { ExecutorState, AiSessionSummary } from "../api";
|
||||
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
|
||||
|
||||
@@ -124,6 +125,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
if (hideWhenKeyboardOpen) return null;
|
||||
|
||||
if (error) {
|
||||
if (isLikelyTabSuspensionError(error)) {
|
||||
return (
|
||||
<div className="executor-status-bar executor-status-bar--connecting" role="status" aria-label={t("executor.status", "Executor status")}>
|
||||
<span className="executor-status-bar__connecting">
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--connecting executor-status-bar__indicator--active" aria-hidden="true" />
|
||||
{t("executor.connecting", "Connecting…")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label={t("executor.status", "Executor status")}>
|
||||
<span className="executor-status-bar__error">
|
||||
|
||||
@@ -285,17 +285,33 @@ describe("ExecutorStatusBar", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: defaultStats,
|
||||
loading: false,
|
||||
error: "Failed to fetch stats",
|
||||
error: "Stats unavailable",
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Failed to fetch stats");
|
||||
expect(statusBar).toHaveTextContent("Stats unavailable");
|
||||
expect(statusBar).toHaveClass("executor-status-bar--error");
|
||||
});
|
||||
|
||||
it("shows connecting state instead of suspension error text", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: defaultStats,
|
||||
loading: false,
|
||||
error: "Failed to fetch",
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Connecting…");
|
||||
expect(statusBar).not.toHaveTextContent("Failed to fetch");
|
||||
expect(statusBar).toHaveClass("executor-status-bar--connecting");
|
||||
});
|
||||
|
||||
it("does not show stat segments when error is present", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: defaultStats,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { renderHook, act, fireEvent } from "@testing-library/react";
|
||||
import { useNodes } from "../useNodes";
|
||||
import { useManagedDockerNodes } from "../useManagedDockerNodes";
|
||||
import { useMeshState } from "../useMeshState";
|
||||
import * as api from "../../api";
|
||||
import * as nodeApi from "../../api-node";
|
||||
import type { NodeInfo, NodeOnboardingInput } from "../../api";
|
||||
import type { ManagedDockerNodeInfo, NodeInfo, NodeOnboardingInput } from "../../api";
|
||||
import type { NodeMeshState } from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchNodes: vi.fn(),
|
||||
@@ -11,6 +14,11 @@ vi.mock("../../api", () => ({
|
||||
updateNode: vi.fn(),
|
||||
unregisterNode: vi.fn(),
|
||||
checkNodeHealth: vi.fn(),
|
||||
fetchMeshState: vi.fn(),
|
||||
fetchManagedDockerNodes: vi.fn(),
|
||||
fetchManagedDockerNodeContainerStatus: vi.fn(),
|
||||
fetchDockerNodeLogs: vi.fn(),
|
||||
createManagedDockerNode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api-node", () => ({
|
||||
@@ -22,6 +30,8 @@ const mockRegisterNode = vi.mocked(api.registerNode);
|
||||
const mockUpdateNode = vi.mocked(api.updateNode);
|
||||
const mockUnregisterNode = vi.mocked(api.unregisterNode);
|
||||
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
|
||||
const mockFetchMeshState = vi.mocked(api.fetchMeshState);
|
||||
const mockFetchManagedDockerNodes = vi.mocked(api.fetchManagedDockerNodes);
|
||||
const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings);
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
@@ -38,19 +48,70 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMeshNode(overrides: Partial<NodeMeshState> = {}): NodeMeshState {
|
||||
return {
|
||||
id: "node_local",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
capabilities: ["executor"],
|
||||
maxConcurrent: 2,
|
||||
activeTasks: 0,
|
||||
queuedTasks: 0,
|
||||
lastHeartbeat: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as NodeMeshState;
|
||||
}
|
||||
|
||||
function makeDockerNode(overrides: Partial<ManagedDockerNodeInfo> = {}): ManagedDockerNodeInfo {
|
||||
return {
|
||||
id: "docker-1",
|
||||
name: "Docker Node",
|
||||
nodeId: "node-docker-1",
|
||||
status: "running",
|
||||
hostConfig: { type: "local" },
|
||||
volumeMounts: [],
|
||||
...overrides,
|
||||
} as ManagedDockerNodeInfo;
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function setVisibilityState(state: DocumentVisibilityState): void {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
async function simulateHiddenToVisibleResume(): Promise<void> {
|
||||
setVisibilityState("hidden");
|
||||
act(() => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
setVisibilityState("visible");
|
||||
await act(async () => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useNodes", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
setVisibilityState("visible");
|
||||
mockFetchNodes.mockReset();
|
||||
mockRegisterNode.mockReset();
|
||||
mockUpdateNode.mockReset();
|
||||
mockUnregisterNode.mockReset();
|
||||
mockCheckNodeHealth.mockReset();
|
||||
mockFetchMeshState.mockReset();
|
||||
mockFetchManagedDockerNodes.mockReset();
|
||||
mockPersistNodeProjectPathMappings.mockReset();
|
||||
});
|
||||
|
||||
@@ -277,6 +338,26 @@ describe("useNodes", () => {
|
||||
expect(result.current.nodes[0].name).toBe("After Refresh");
|
||||
});
|
||||
|
||||
it("suppresses visibility-resume suspension errors when nodes already exist", async () => {
|
||||
mockFetchNodes
|
||||
.mockResolvedValueOnce([makeNode({ name: "Initial" })])
|
||||
.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("Initial");
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await simulateHiddenToVisibleResume();
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("Initial");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("refetches when visibility changes back to visible", async () => {
|
||||
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
mockFetchNodes
|
||||
@@ -319,3 +400,67 @@ describe("useNodes", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMeshState", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
setVisibilityState("visible");
|
||||
mockFetchMeshState.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("suppresses visibility-resume suspension errors when mesh state already exists", async () => {
|
||||
mockFetchMeshState
|
||||
.mockResolvedValueOnce({ nodes: [makeMeshNode({ name: "Initial Mesh" })] })
|
||||
.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useMeshState());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.meshState[0].name).toBe("Initial Mesh");
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await simulateHiddenToVisibleResume();
|
||||
|
||||
expect(result.current.meshState[0].name).toBe("Initial Mesh");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useManagedDockerNodes", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
setVisibilityState("visible");
|
||||
mockFetchManagedDockerNodes.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("suppresses visibility-resume suspension errors when docker nodes already exist", async () => {
|
||||
mockFetchManagedDockerNodes
|
||||
.mockResolvedValueOnce([makeDockerNode({ name: "Initial Docker" })])
|
||||
.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useManagedDockerNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.dockerNodes[0].name).toBe("Initial Docker");
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await simulateHiddenToVisibleResume();
|
||||
|
||||
expect(result.current.dockerNodes[0].name).toBe("Initial Docker");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { renderHook, act, fireEvent } from "@testing-library/react";
|
||||
import { useProjects } from "../useProjects";
|
||||
import * as api from "../../api";
|
||||
import * as swrCache from "../../utils/swrCache";
|
||||
@@ -40,9 +40,17 @@ async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function setVisibilityState(state: DocumentVisibilityState): void {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
describe("useProjects", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
setVisibilityState("visible");
|
||||
mockFetchProjectsAcrossNodes.mockReset();
|
||||
mockRegisterProject.mockReset();
|
||||
mockUpdateProject.mockReset();
|
||||
@@ -171,6 +179,68 @@ describe("useProjects", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses visibility-resume suspension errors when projects already exist", async () => {
|
||||
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||
mockFetchProjectsAcrossNodes
|
||||
.mockResolvedValueOnce([makeProject({ id: "proj-1" })])
|
||||
.mockResolvedValueOnce([makeProject({ id: "proj-1" })])
|
||||
.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0]?.id).toBe("proj-1");
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
setVisibilityState("hidden");
|
||||
act(() => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
setVisibilityState("visible");
|
||||
await act(async () => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0]?.id).toBe("proj-1");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps connection errors visible when no projects exist", async () => {
|
||||
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||
mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toEqual([]);
|
||||
expect(result.current.error).toBe("Failed to fetch");
|
||||
});
|
||||
|
||||
it("suppresses initial revalidation suspension errors when cache has projects", async () => {
|
||||
mockReadCache.mockReturnValueOnce([makeProject({ id: "cached-project" })]);
|
||||
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||
mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
setVisibilityState("hidden");
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0]?.id).toBe("cached-project");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes projects using the same normalization", async () => {
|
||||
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||
mockFetchProjectsAcrossNodes
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isLikelyTabSuspensionError,
|
||||
isVisibilityResumeError,
|
||||
lastVisibilityTransition,
|
||||
useTabVisibilitySuspension,
|
||||
} from "../visibilitySuspension";
|
||||
@@ -23,6 +24,18 @@ describe("visibilitySuspension", () => {
|
||||
expect(isLikelyTabSuspensionError("Validation error: missing key")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches visibility-resume errors when recently hidden and suspension-like", () => {
|
||||
expect(isVisibilityResumeError("Failed to fetch", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects visibility-resume errors when not recently hidden", () => {
|
||||
expect(isVisibilityResumeError("Failed to fetch", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects visibility-resume errors for unrelated failures", () => {
|
||||
expect(isVisibilityResumeError("Request failed: 500", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks recently hidden window", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Task } from "@fusion/core";
|
||||
import { fetchExecutorStats } from "../api";
|
||||
import type { ExecutorStats, ExecutorState } from "../api";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
|
||||
|
||||
@@ -117,6 +118,12 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const hasFetchedStatsRef = useRef(false);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
return hasFetchedStatsRef.current && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
// Cancel any in-flight requests
|
||||
@@ -129,17 +136,21 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchExecutorStats(projectId);
|
||||
hasFetchedStatsRef.current = true;
|
||||
setApiData(data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Ignore abort errors
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch executor stats");
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch executor stats";
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, shouldSuppressVisibilityResumeError]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchManagedDockerNodes,
|
||||
} from "../api";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
|
||||
export interface UseManagedDockerNodesResult {
|
||||
dockerNodes: ManagedDockerNodeInfo[];
|
||||
@@ -28,6 +29,16 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const dockerNodesRef = useRef(dockerNodes);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
|
||||
useEffect(() => {
|
||||
dockerNodesRef.current = dockerNodes;
|
||||
}, [dockerNodes]);
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
return dockerNodesRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -35,9 +46,12 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||
const data = await fetchManagedDockerNodes();
|
||||
setDockerNodes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch managed Docker nodes";
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [shouldSuppressVisibilityResumeError]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -51,8 +65,9 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch managed Docker nodes";
|
||||
if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -98,7 +113,7 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [refresh, shouldSuppressVisibilityResumeError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { NodeMeshState } from "@fusion/core";
|
||||
import { fetchMeshState } from "../api";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
@@ -21,6 +22,16 @@ export function useMeshState(): UseMeshStateResult {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const meshStateRef = useRef(meshState);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
|
||||
useEffect(() => {
|
||||
meshStateRef.current = meshState;
|
||||
}, [meshState]);
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
return meshStateRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -28,9 +39,12 @@ export function useMeshState(): UseMeshStateResult {
|
||||
const data = await fetchMeshState();
|
||||
setMeshState(data.nodes);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state"));
|
||||
const errorMessage = err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state");
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
}, [shouldSuppressVisibilityResumeError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -44,8 +58,9 @@ export function useMeshState(): UseMeshStateResult {
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state"));
|
||||
const errorMessage = err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state");
|
||||
if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -87,7 +102,7 @@ export function useMeshState(): UseMeshStateResult {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [refresh, shouldSuppressVisibilityResumeError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../api";
|
||||
import { persistNodeProjectPathMappings } from "../api-node";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
|
||||
export interface UseNodesResult {
|
||||
nodes: NodeInfo[];
|
||||
@@ -45,6 +46,16 @@ export function useNodes(): UseNodesResult {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const nodesRef = useRef(nodes);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
|
||||
useEffect(() => {
|
||||
nodesRef.current = nodes;
|
||||
}, [nodes]);
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
return nodesRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -52,10 +63,13 @@ export function useNodes(): UseNodesResult {
|
||||
const data = await fetchNodes();
|
||||
setNodes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes"));
|
||||
const errorMessage = err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes");
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
// Keep stale data visible if polling refresh fails
|
||||
}
|
||||
}, [t]);
|
||||
}, [shouldSuppressVisibilityResumeError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -69,8 +83,9 @@ export function useNodes(): UseNodesResult {
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes"));
|
||||
const errorMessage = err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes");
|
||||
if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -116,7 +131,7 @@ export function useNodes(): UseNodesResult {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [refresh, shouldSuppressVisibilityResumeError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ProjectHealth } from "../api";
|
||||
import { fetchProjectHealth } from "../api";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
|
||||
export interface UseMultiProjectHealthResult {
|
||||
/** Map of project ID to health data */
|
||||
@@ -36,9 +37,19 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const healthMapRef = useRef(healthMap);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
// Track if we've completed the initial load
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
healthMapRef.current = healthMap;
|
||||
}, [healthMap]);
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
return Object.keys(healthMapRef.current).length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
/**
|
||||
* Refresh health data for all projects.
|
||||
* This is called both for initial load and for background polling.
|
||||
@@ -105,13 +116,16 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
|
||||
// Ignore abort errors
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch health data");
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch health data";
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
// Mark initial load complete even on error so we don't stay in loading state
|
||||
initialLoadCompleteRef.current = true;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectIds]);
|
||||
}, [projectIds, shouldSuppressVisibilityResumeError]);
|
||||
|
||||
const refreshProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../api";
|
||||
import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
|
||||
export interface UseProjectsResult {
|
||||
/** List of all registered projects (local + remote) */
|
||||
@@ -91,6 +92,16 @@ export function useProjects(): UseProjectsResult {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const projectsRef = useRef(projects);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
|
||||
useEffect(() => {
|
||||
projectsRef.current = projects;
|
||||
}, [projects]);
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
return projectsRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -100,10 +111,13 @@ export function useProjects(): UseProjectsResult {
|
||||
setProjects(normalizedData);
|
||||
writeCache(SWR_CACHE_KEYS.PROJECTS, normalizedData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch projects";
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
// Don't clear existing projects on error - keep showing stale data
|
||||
}
|
||||
}, []);
|
||||
}, [shouldSuppressVisibilityResumeError]);
|
||||
|
||||
// Initial fetch and visibility change handler
|
||||
useEffect(() => {
|
||||
@@ -127,9 +141,10 @@ export function useProjects(): UseProjectsResult {
|
||||
}
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - t0);
|
||||
console.warn(`[useProjects] initial fetch failed after ${elapsed}ms: ${err instanceof Error ? err.message : String(err)}`);
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch projects";
|
||||
console.warn(`[useProjects] initial fetch failed after ${elapsed}ms: ${errorMessage}`);
|
||||
if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -175,7 +190,7 @@ export function useProjects(): UseProjectsResult {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [projects.length, refresh]);
|
||||
}, [projects.length, refresh, shouldSuppressVisibilityResumeError]);
|
||||
|
||||
// Polling for updates
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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[];
|
||||
@@ -40,6 +41,16 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
|
||||
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
|
||||
@@ -65,14 +76,23 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
// 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: getErrorMessage(err) || "Failed to fetch usage data",
|
||||
error: errorMessage,
|
||||
hasFetched: true,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
}, [shouldSuppressVisibilityResumeError]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,10 @@ export function isLikelyTabSuspensionError(message: string): boolean {
|
||||
return SUSPENSION_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||
}
|
||||
|
||||
export function isVisibilityResumeError(errorMessage: string, wasRecentlyHiddenResult: boolean): boolean {
|
||||
return wasRecentlyHiddenResult && isLikelyTabSuspensionError(errorMessage);
|
||||
}
|
||||
|
||||
export function lastVisibilityTransition(): { hiddenAt: number | null; visibleAt: number | null } {
|
||||
return {
|
||||
hiddenAt: lastHiddenAt,
|
||||
|
||||
Reference in New Issue
Block a user