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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [streamConnected, setStreamConnected] = useState(false);
|
const [streamConnected, setStreamConnected] = useState(false);
|
||||||
|
const [backendReady, setBackendReady] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const loadRecentEvents = useCallback(async (): Promise<void> => {
|
const loadRecentEvents = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -39,8 +40,15 @@ export function OrchestratorEventsWidget({
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Unable to load events: HTTP ${String(response.status)}`);
|
throw new Error(`Unable to load events: HTTP ${String(response.status)}`);
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as RecentEventsResponse;
|
const payload = (await response.json()) as unknown;
|
||||||
setEvents(payload.events);
|
const events =
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"events" in payload &&
|
||||||
|
Array.isArray(payload.events)
|
||||||
|
? (payload.events as RecentEventsResponse["events"])
|
||||||
|
: [];
|
||||||
|
setEvents(events);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unable to load events.");
|
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(() => {
|
useEffect(() => {
|
||||||
void loadRecentEvents();
|
void loadRecentEvents();
|
||||||
|
void loadHealth();
|
||||||
|
|
||||||
const eventSource =
|
const eventSource =
|
||||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||||
@@ -60,6 +78,7 @@ export function OrchestratorEventsWidget({
|
|||||||
};
|
};
|
||||||
eventSource.onmessage = (): void => {
|
eventSource.onmessage = (): void => {
|
||||||
void loadRecentEvents();
|
void loadRecentEvents();
|
||||||
|
void loadHealth();
|
||||||
};
|
};
|
||||||
eventSource.onerror = (): void => {
|
eventSource.onerror = (): void => {
|
||||||
setStreamConnected(false);
|
setStreamConnected(false);
|
||||||
@@ -68,13 +87,14 @@ export function OrchestratorEventsWidget({
|
|||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void loadRecentEvents();
|
void loadRecentEvents();
|
||||||
|
void loadHealth();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
};
|
};
|
||||||
}, [loadRecentEvents]);
|
}, [loadHealth, loadRecentEvents]);
|
||||||
|
|
||||||
const matrixSignals = useMemo(
|
const matrixSignals = useMemo(
|
||||||
() => events.filter((event) => isMatrixSignal(event)).length,
|
() => events.filter((event) => isMatrixSignal(event)).length,
|
||||||
@@ -102,13 +122,24 @@ export function OrchestratorEventsWidget({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full space-y-3">
|
<div className="flex flex-col h-full space-y-3">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<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 ? (
|
{streamConnected ? (
|
||||||
<Wifi className="w-3 h-3 text-green-500" />
|
<Wifi className="w-3 h-3 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<WifiOff className="w-3 h-3 text-gray-400" />
|
<WifiOff className="w-3 h-3 text-gray-400" />
|
||||||
)}
|
)}
|
||||||
<span>{streamConnected ? "Live stream connected" : "Polling mode"}</span>
|
<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>
|
||||||
<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">
|
<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" />
|
<DatabaseZap className="w-3 h-3" />
|
||||||
|
|||||||
@@ -147,8 +147,15 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/orchestrator/events/recent?limit=5");
|
const res = await fetch("/api/orchestrator/events/recent?limit=5");
|
||||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||||
const data = (await res.json()) as { events: RecentOrchestratorEvent[] };
|
const payload = (await res.json()) as unknown;
|
||||||
setRecentEvents(data.events);
|
const events =
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"events" in payload &&
|
||||||
|
Array.isArray(payload.events)
|
||||||
|
? (payload.events as RecentOrchestratorEvent[])
|
||||||
|
: [];
|
||||||
|
setRecentEvents(events);
|
||||||
} catch {
|
} catch {
|
||||||
// Optional enhancement path; do not fail widget if recent-events endpoint is unavailable.
|
// Optional enhancement path; do not fail widget if recent-events endpoint is unavailable.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,25 +24,37 @@ describe("OrchestratorEventsWidget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders events and matrix signal count", async () => {
|
it("renders events and matrix signal count", async () => {
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockImplementation((input: RequestInfo | URL) => {
|
||||||
ok: true,
|
const url =
|
||||||
json: () =>
|
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
Promise.resolve({
|
|
||||||
events: [
|
if (url.includes("/api/orchestrator/health")) {
|
||||||
{
|
return Promise.resolve({
|
||||||
type: "task.completed",
|
ok: true,
|
||||||
timestamp: "2026-02-17T16:40:00.000Z",
|
json: () => Promise.resolve({ status: "ok" }),
|
||||||
taskId: "TASK-1",
|
} as unknown as Response);
|
||||||
data: { channelId: "room-123" },
|
}
|
||||||
},
|
|
||||||
{
|
return Promise.resolve({
|
||||||
type: "agent.running",
|
ok: true,
|
||||||
timestamp: "2026-02-17T16:41:00.000Z",
|
json: () =>
|
||||||
taskId: "TASK-2",
|
Promise.resolve({
|
||||||
agentId: "agent-abc12345",
|
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(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||||
@@ -51,6 +63,7 @@ describe("OrchestratorEventsWidget", () => {
|
|||||||
expect(screen.getByText("task.completed")).toBeInTheDocument();
|
expect(screen.getByText("task.completed")).toBeInTheDocument();
|
||||||
expect(screen.getByText("agent.running")).toBeInTheDocument();
|
expect(screen.getByText("agent.running")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Matrix signals: 1/)).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-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-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-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