Files
fusion/packages/dashboard/app/hooks/useExecutorStats.ts
gsxdsm e909d41fed FN-7116: suppress transient footer connecting flashes
Keep executor footer stats stable through a one-off suspension-like fetch blip.

- Debounce transient executor stats failures after a successful poll while retaining last-good stats.
- Preserve explicit error reporting for first-load, non-transient, and sustained failures.
- Cover desktop and mobile footer behavior plus hook retry and visibility-resume cases.
- Document the footer behavior and add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7116-footer-connecting-flash.md      |   7 ++
 docs/dashboard-guide.md                            |   2 +
 .../__tests__/ExecutorStatusBar.test.tsx           |  24 +++-
 .../app/hooks/__tests__/useExecutorStats.test.ts   | 132 ++++++++++++++++++++-
 packages/dashboard/app/hooks/useExecutorStats.ts   |  24 +++-
 5 files changed, 179 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7116

Fusion-Task-Lineage: 617b91a3-3d7c-42f1-8a44-ba3245eaf0dc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 10:38:23 -07:00

228 lines
7.0 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import type { Task } from "@fusion/core";
import { fetchExecutorStats } from "../api";
import type { ExecutorStats, ExecutorState } from "../api";
import { isTaskStuck } from "../utils/taskStuck";
import { isLikelyTabSuspensionError, isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
/*
* FNXC:ExecutorStatusBar 2026-06-27-00:00:
* Executor stats polling can hit one-off tab-suspension-like fetch errors while the tab remains visible. Keep the last-good footer stats through one transient poll so the bottom bar does not flash "Connecting…"; only sustained consecutive transient failures may surface the connection state.
*/
const TRANSIENT_FAILURE_THRESHOLD = 2;
export interface UseExecutorStatsResult {
/** Aggregated executor statistics */
stats: ExecutorStats;
/** Whether the stats are currently loading */
loading: boolean;
/** Error message if the last fetch failed */
error: string | null;
/** Manually refresh stats */
refresh: () => Promise<void>;
}
/**
* Derive the executor state from globalPause, enginePaused, and runningTaskCount.
*
* - "stopped": globalPause is true
* - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running
* - "paused": enginePaused is true AND runningTaskCount > 0
* - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0
*
* FNXC:EngineControls 2026-06-22-00:00:
* `globalPause` dominates the footer state matrix so an operator-stopped engine is distinct from idle even if in-progress tasks still exist.
*/
function deriveExecutorState(
globalPause: boolean,
enginePaused: boolean,
runningTaskCount: number
): ExecutorState {
if (globalPause) {
return "stopped";
}
if (enginePaused && runningTaskCount === 0) {
return "idle";
}
if (enginePaused && runningTaskCount > 0) {
return "paused";
}
// globalPause is false and enginePaused is false
if (runningTaskCount > 0) {
return "running";
}
return "idle";
}
/**
* Derive statistics from the task list.
*/
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): Pick<
ExecutorStats,
"runningTaskCount" | "blockedTaskCount" | "stuckTaskCount" | "queuedTaskCount" | "inReviewCount"
> {
let runningTaskCount = 0;
let blockedTaskCount = 0;
let stuckTaskCount = 0;
let queuedTaskCount = 0;
let inReviewCount = 0;
for (const task of tasks) {
switch (task.column) {
case "in-progress":
runningTaskCount++;
if (isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs)) {
stuckTaskCount++;
}
break;
case "todo":
queuedTaskCount++;
break;
case "in-review":
inReviewCount++;
break;
}
// Count tasks with blockedBy set
if (task.blockedBy && task.blockedBy.length > 0) {
blockedTaskCount++;
}
}
return {
runningTaskCount,
blockedTaskCount,
stuckTaskCount,
queuedTaskCount,
inReviewCount,
};
}
/**
* Hook for aggregating executor statistics for the status bar.
*
* - Receives the shared task list directly (same instance used by the board)
* so footer counts always match the board state
* - Polls `/api/executor/stats` every 5 seconds for executor state
* - Derives blockedTaskCount from tasks with blockedBy field set
* - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting;
* returns 0 when the setting is undefined/disabled
* - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped"
* - Returns ExecutorStats object with reactive updates
*/
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): UseExecutorStatsResult {
const [apiData, setApiData] = useState<{
globalPause: boolean;
enginePaused: boolean;
maxConcurrent: number;
lastActivityAt?: string;
}>({
globalPause: false,
enginePaused: false,
maxConcurrent: 2,
});
const [loading, setLoading] = useState(true);
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 consecutiveFailuresRef = useRef(0);
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
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
try {
setLoading(true);
const data = await fetchExecutorStats(projectId);
consecutiveFailuresRef.current = 0;
hasFetchedStatsRef.current = true;
setError(null);
setApiData(data);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Ignore abort errors
return;
}
const errorMessage = err instanceof Error ? err.message : "Failed to fetch executor stats";
if (shouldSuppressVisibilityResumeError(errorMessage)) {
return;
}
if (hasFetchedStatsRef.current && isLikelyTabSuspensionError(errorMessage)) {
consecutiveFailuresRef.current += 1;
if (consecutiveFailuresRef.current >= TRANSIENT_FAILURE_THRESHOLD) {
setError(errorMessage);
}
return;
}
consecutiveFailuresRef.current = 0;
setError(errorMessage);
} finally {
setLoading(false);
}
}, [projectId, shouldSuppressVisibilityResumeError]);
// Initial fetch
useEffect(() => {
refresh();
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, [refresh]);
// Polling - refresh every 5 seconds
useEffect(() => {
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Start new polling interval
intervalRef.current = setInterval(() => {
refresh();
}, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [refresh]);
// Derive stats from tasks and API data
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs, lastFetchTimeMs);
const executorState = deriveExecutorState(
apiData.globalPause,
apiData.enginePaused,
taskStats.runningTaskCount
);
const stats: ExecutorStats = {
...taskStats,
executorState,
maxConcurrent: apiData.maxConcurrent,
lastActivityAt: apiData.lastActivityAt,
};
return {
stats,
loading,
error,
refresh,
};
}