191 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|