Files
fusion/packages/dashboard/app/hooks/useEngineStatus.ts
gsxdsm e46ea00ff5 FN-6827: add engine status banner
Add project-scoped engine connectivity remediation to the dashboard.

- Add engine status/start API helpers and server routes that can resume paused projects or start missing engines.
- Render a project banner with dashboard-only guidance, polling, disabled starting state, localized copy, and focused tests.
- Document the engine status banner behavior and add a changeset for the published CLI package.

Files changed:
 .changeset/fn-6827-engine-disconnected-banner.md   |   7 +
 docs/dashboard-guide.md                            |   8 +
 packages/dashboard/app/api/legacy.ts               |  20 +++
 .../app/components/EngineStatusBanner.css          |  87 +++++++++++
 .../app/components/EngineStatusBanner.tsx          |  62 ++++++++
 .../__tests__/EngineStatusBanner.test.tsx          | 145 +++++++++++++++++
 .../app/components/dashboard/DashboardBanners.tsx  |   3 +
 .../app/hooks/__tests__/useEngineStatus.test.ts    | 171 +++++++++++++++++++++
 packages/dashboard/app/hooks/useEngineStatus.ts    | 111 +++++++++++++
 packages/dashboard/src/__tests__/server.test.ts    | 129 ++++++++++++++++
 packages/dashboard/src/server.ts                   |  89 +++++++++++
 packages/i18n/locales/en/app.json                  |  10 ++
 packages/i18n/locales/es/app.json                  |  10 ++
 packages/i18n/locales/fr/app.json                  |  10 ++
 packages/i18n/locales/ko/app.json                  |  10 ++
 packages/i18n/locales/zh-CN/app.json               |  10 ++
 packages/i18n/locales/zh-TW/app.json               |  10 ++
 17 files changed, 892 insertions(+)

Fusion-Task-Id: FN-6827

Fusion-Task-Lineage: 56821ca3-04c8-4674-9400-3639f10e463d
2026-06-25 08:35:44 -07:00

112 lines
3.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchEngineStatus, startEngine, type EngineStatusResponse } from "../api";
const POLL_INTERVAL_MS = 10000;
const DISCONNECTED_UNREACHABLE_STATUS: EngineStatusResponse = {
connected: false,
starting: false,
canStart: false,
reason: "unreachable",
};
export interface UseEngineStatusResult {
status: EngineStatusResponse | null;
loading: boolean;
error: string | null;
canStart: boolean;
starting: boolean;
refetch: () => Promise<void>;
start: () => Promise<void>;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/*
* FNXC:EngineStatusBanner 2026-06-22-00:00:
* The banner must never leave the board silently inert. Poll while the current project is disconnected, stop once connected to avoid steady-state traffic, and fold local Start engine clicks into `starting` so the button cannot be double-triggered before the server reports its transient starting state.
*/
export function useEngineStatus(projectId?: string): UseEngineStatusResult {
const [status, setStatus] = useState<EngineStatusResponse | null>(null);
const [loading, setLoading] = useState(Boolean(projectId));
const [error, setError] = useState<string | null>(null);
const [startInFlight, setStartInFlight] = useState(false);
const requestIdRef = useRef(0);
const refetch = useCallback(async () => {
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
if (!projectId) {
setStatus(null);
setLoading(false);
setStartInFlight(false);
return;
}
setLoading(true);
try {
const nextStatus = await fetchEngineStatus(projectId);
if (requestIdRef.current !== requestId) return;
setStatus(nextStatus);
if (nextStatus.connected) {
setStartInFlight(false);
}
} catch {
if (requestIdRef.current !== requestId) return;
setStatus({ ...DISCONNECTED_UNREACHABLE_STATUS, projectId });
} finally {
if (requestIdRef.current === requestId) {
setLoading(false);
}
}
}, [projectId]);
const start = useCallback(async () => {
if (!projectId) return;
setStartInFlight(true);
setError(null);
try {
const startedStatus = await startEngine(projectId);
setStatus(startedStatus);
await refetch();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setStartInFlight(false);
}
}, [projectId, refetch]);
useEffect(() => {
setStatus(null);
setError(null);
setStartInFlight(false);
void refetch();
}, [refetch]);
useEffect(() => {
if (!projectId || status?.connected) return;
const interval = window.setInterval(() => {
void refetch();
}, POLL_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [projectId, refetch, status?.connected]);
return useMemo(() => ({
status,
loading,
error,
canStart: Boolean(status?.canStart),
starting: Boolean(status?.starting || startInFlight),
refetch,
start,
}), [error, loading, refetch, start, startInFlight, status]);
}