feat(web): add orchestrator readiness badge and resilient events parsing
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
This commit is contained in:
43
apps/web/src/app/api/orchestrator/health/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/health/route.ts
Normal file
@@ -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<NextResponse> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export function OrchestratorEventsWidget({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streamConnected, setStreamConnected] = useState(false);
|
||||
const [backendReady, setBackendReady] = useState<boolean | null>(null);
|
||||
|
||||
const loadRecentEvents = useCallback(async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
{streamConnected ? (
|
||||
<Wifi className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3 text-gray-400" />
|
||||
)}
|
||||
<span>{streamConnected ? "Live stream connected" : "Polling mode"}</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 ${
|
||||
backendReady === true
|
||||
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300"
|
||||
: backendReady === false
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{backendReady === true ? "ready" : backendReady === false ? "degraded" : "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded bg-blue-50 dark:bg-blue-950 px-2 py-1 text-blue-700 dark:text-blue-300">
|
||||
<DatabaseZap className="w-3 h-3" />
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -24,7 +24,18 @@ describe("OrchestratorEventsWidget", () => {
|
||||
});
|
||||
|
||||
it("renders events and matrix signal count", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
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({
|
||||
@@ -43,6 +54,7 @@ describe("OrchestratorEventsWidget", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user