Files
stack/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Jason Woltje 9d9a01f5f7
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): add orchestrator readiness badge and resilient events parsing
2026-02-17 16:20:03 -06:00

191 lines
6.4 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { Activity, DatabaseZap, Loader2, Wifi, WifiOff } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface OrchestratorEvent {
type: string;
timestamp: string;
agentId?: string;
taskId?: string;
data?: Record<string, unknown>;
}
interface RecentEventsResponse {
events: OrchestratorEvent[];
}
function isMatrixSignal(event: OrchestratorEvent): boolean {
const text = JSON.stringify(event).toLowerCase();
return (
text.includes("matrix") ||
text.includes("room") ||
text.includes("channel") ||
text.includes("thread")
);
}
export function OrchestratorEventsWidget({
id: _id,
config: _config,
}: WidgetProps): React.JSX.Element {
const [events, setEvents] = useState<OrchestratorEvent[]>([]);
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 {
const response = await fetch("/api/orchestrator/events/recent?limit=25");
if (!response.ok) {
throw new Error(`Unable to load events: HTTP ${String(response.status)}`);
}
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.");
} finally {
setIsLoading(false);
}
}, []);
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;
if (eventSource) {
eventSource.onopen = (): void => {
setStreamConnected(true);
};
eventSource.onmessage = (): void => {
void loadRecentEvents();
void loadHealth();
};
eventSource.onerror = (): void => {
setStreamConnected(false);
};
}
const interval = setInterval(() => {
void loadRecentEvents();
void loadHealth();
}, 15000);
return (): void => {
clearInterval(interval);
eventSource?.close();
};
}, [loadHealth, loadRecentEvents]);
const matrixSignals = useMemo(
() => events.filter((event) => isMatrixSignal(event)).length,
[events]
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-5 h-5 text-gray-400 animate-spin" />
<span className="ml-2 text-gray-500 text-sm">Loading orchestrator events...</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-center">
<WifiOff className="w-5 h-5 text-amber-500 mb-2" />
<span className="text-sm text-amber-600">{error}</span>
</div>
);
}
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-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" />
<span>Matrix signals: {matrixSignals}</span>
</div>
</div>
<div className="flex-1 overflow-auto space-y-2">
{events.length === 0 ? (
<div className="text-center text-sm text-gray-500 py-4">
No recent orchestration events.
</div>
) : (
events
.slice()
.reverse()
.map((event, index) => (
<div
key={`${event.timestamp}-${event.type}-${String(index)}`}
className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-2 py-2"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Activity className="w-3 h-3 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
{event.type}
</span>
{isMatrixSignal(event) && (
<span className="text-[10px] rounded bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5">
matrix
</span>
)}
</div>
<span className="text-[10px] text-gray-500">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
<div className="mt-1 text-[11px] text-gray-600 dark:text-gray-300">
{event.taskId ? `Task ${event.taskId}` : "Task n/a"}
{event.agentId ? ` · Agent ${event.agentId.slice(0, 8)}` : ""}
</div>
</div>
))
)}
</div>
</div>
);
}