From 9d9a01f5f7091e67cd70459d55ba2c8901898c96 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 17 Feb 2026 16:20:03 -0600 Subject: [PATCH] feat(web): add orchestrator readiness badge and resilient events parsing --- .../src/app/api/orchestrator/health/route.ts | 43 ++++++++++++++++ .../widgets/OrchestratorEventsWidget.tsx | 39 ++++++++++++-- .../components/widgets/TaskProgressWidget.tsx | 11 +++- .../OrchestratorEventsWidget.test.tsx | 51 ++++++++++++------- docs/tasks.md | 1 + 5 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/app/api/orchestrator/health/route.ts diff --git a/apps/web/src/app/api/orchestrator/health/route.ts b/apps/web/src/app/api/orchestrator/health/route.ts new file mode 100644 index 0000000..233cce2 --- /dev/null +++ b/apps/web/src/app/api/orchestrator/health/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; + +const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001"; + +function getOrchestratorUrl(): string { + return ( + process.env.ORCHESTRATOR_URL ?? + process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + DEFAULT_ORCHESTRATOR_URL + ); +} + +export async function GET(): Promise { + const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY; + if (!orchestratorApiKey) { + return NextResponse.json( + { error: "ORCHESTRATOR_API_KEY is not configured on the web server." }, + { status: 503 } + ); + } + + try { + const response = await fetch(`${getOrchestratorUrl()}/health/ready`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-API-Key": orchestratorApiKey, + }, + cache: "no-store", + }); + + const text = await response.text(); + return new NextResponse(text, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }); + } catch { + return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 }); + } +} diff --git a/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx b/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx index 5384aec..4719a8d 100644 --- a/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx +++ b/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx @@ -32,6 +32,7 @@ export function OrchestratorEventsWidget({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [streamConnected, setStreamConnected] = useState(false); + const [backendReady, setBackendReady] = useState(null); const loadRecentEvents = useCallback(async (): Promise => { try { @@ -39,8 +40,15 @@ export function OrchestratorEventsWidget({ if (!response.ok) { throw new Error(`Unable to load events: HTTP ${String(response.status)}`); } - const payload = (await response.json()) as RecentEventsResponse; - setEvents(payload.events); + const payload = (await response.json()) as unknown; + const events = + payload && + typeof payload === "object" && + "events" in payload && + Array.isArray(payload.events) + ? (payload.events as RecentEventsResponse["events"]) + : []; + setEvents(events); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Unable to load events."); @@ -49,8 +57,18 @@ export function OrchestratorEventsWidget({ } }, []); + const loadHealth = useCallback(async (): Promise => { + try { + const response = await fetch("/api/orchestrator/health"); + setBackendReady(response.ok); + } catch { + setBackendReady(false); + } + }, []); + useEffect(() => { void loadRecentEvents(); + void loadHealth(); const eventSource = typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null; @@ -60,6 +78,7 @@ export function OrchestratorEventsWidget({ }; eventSource.onmessage = (): void => { void loadRecentEvents(); + void loadHealth(); }; eventSource.onerror = (): void => { setStreamConnected(false); @@ -68,13 +87,14 @@ export function OrchestratorEventsWidget({ const interval = setInterval(() => { void loadRecentEvents(); + void loadHealth(); }, 15000); return (): void => { clearInterval(interval); eventSource?.close(); }; - }, [loadRecentEvents]); + }, [loadHealth, loadRecentEvents]); const matrixSignals = useMemo( () => events.filter((event) => isMatrixSignal(event)).length, @@ -102,13 +122,24 @@ export function OrchestratorEventsWidget({ return (
-
+
{streamConnected ? ( ) : ( )} {streamConnected ? "Live stream connected" : "Polling mode"} + + {backendReady === true ? "ready" : backendReady === false ? "degraded" : "unknown"} +
diff --git a/apps/web/src/components/widgets/TaskProgressWidget.tsx b/apps/web/src/components/widgets/TaskProgressWidget.tsx index f03a9b7..850c262 100644 --- a/apps/web/src/components/widgets/TaskProgressWidget.tsx +++ b/apps/web/src/components/widgets/TaskProgressWidget.tsx @@ -147,8 +147,15 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R try { const res = await fetch("/api/orchestrator/events/recent?limit=5"); if (!res.ok) throw new Error(`HTTP ${String(res.status)}`); - const data = (await res.json()) as { events: RecentOrchestratorEvent[] }; - setRecentEvents(data.events); + const payload = (await res.json()) as unknown; + const events = + payload && + typeof payload === "object" && + "events" in payload && + Array.isArray(payload.events) + ? (payload.events as RecentOrchestratorEvent[]) + : []; + setRecentEvents(events); } catch { // Optional enhancement path; do not fail widget if recent-events endpoint is unavailable. } diff --git a/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx b/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx index 7349836..35f1577 100644 --- a/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx +++ b/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx @@ -24,25 +24,37 @@ describe("OrchestratorEventsWidget", () => { }); it("renders events and matrix signal count", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - events: [ - { - type: "task.completed", - timestamp: "2026-02-17T16:40:00.000Z", - taskId: "TASK-1", - data: { channelId: "room-123" }, - }, - { - type: "agent.running", - timestamp: "2026-02-17T16:41:00.000Z", - taskId: "TASK-2", - agentId: "agent-abc12345", - }, - ], - }), + mockFetch.mockImplementation((input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("/api/orchestrator/health")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ status: "ok" }), + } as unknown as Response); + } + + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + events: [ + { + type: "task.completed", + timestamp: "2026-02-17T16:40:00.000Z", + taskId: "TASK-1", + data: { channelId: "room-123" }, + }, + { + type: "agent.running", + timestamp: "2026-02-17T16:41:00.000Z", + taskId: "TASK-2", + agentId: "agent-abc12345", + }, + ], + }), + } as unknown as Response); }); render(); @@ -51,6 +63,7 @@ describe("OrchestratorEventsWidget", () => { expect(screen.getByText("task.completed")).toBeInTheDocument(); expect(screen.getByText("agent.running")).toBeInTheDocument(); expect(screen.getByText(/Matrix signals: 1/)).toBeInTheDocument(); + expect(screen.getByText("ready")).toBeInTheDocument(); }); }); diff --git a/docs/tasks.md b/docs/tasks.md index c24131d..ed114ba 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -387,3 +387,4 @@ | ORCH-OBS-008 | done | Integrate new widget into HUD/WidgetRegistry and extend widget regression coverage | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-007 | | orch | 2026-02-17T17:03Z | 2026-02-17T17:08Z | 10K | 7K | | ORCH-OBS-009 | done | Seed default/reset local HUD layout with orchestration widgets so visibility works out-of-box | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-008 | | orch | 2026-02-17T17:10Z | 2026-02-17T17:14Z | 8K | 6K | | ORCH-OBS-010 | done | Enrich `TaskProgressWidget` with latest recent-event context from `/api/orchestrator/events/recent` | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-009 | | orch | 2026-02-17T17:15Z | 2026-02-17T17:20Z | 8K | 6K | +| ORCH-OBS-011 | done | Add orchestrator health proxy and readiness badge (`ready/degraded`) in events widget | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-010 | | orch | 2026-02-17T17:22Z | 2026-02-17T17:27Z | 8K | 6K |