feat(web): add orchestrator events widget with matrix signal visibility
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:
@@ -55,6 +55,15 @@ const WIDGET_REGISTRY = {
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
},
|
||||
OrchestratorEventsWidget: {
|
||||
name: "orchestrator-events",
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent events and stream health for orchestration",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||
|
||||
@@ -12,6 +12,9 @@ vi.mock("@/components/widgets", () => ({
|
||||
AgentStatusWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||
<div>Agent Status Widget {id}</div>
|
||||
),
|
||||
OrchestratorEventsWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||
<div>Orchestrator Events Widget {id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function createWidgetPlacement(id: string): WidgetPlacement {
|
||||
@@ -34,4 +37,11 @@ describe("WidgetRenderer", () => {
|
||||
render(<WidgetRenderer widget={createWidgetPlacement("agent-status-123")} />);
|
||||
expect(screen.getByText("Agent Status Widget agent-status-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hyphenated orchestrator-events widget IDs correctly", () => {
|
||||
render(<WidgetRenderer widget={createWidgetPlacement("orchestrator-events-123")} />);
|
||||
expect(
|
||||
screen.getByText("Orchestrator Events Widget orchestrator-events-123")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CalendarWidget,
|
||||
QuickCaptureWidget,
|
||||
AgentStatusWidget,
|
||||
OrchestratorEventsWidget,
|
||||
} from "@/components/widgets";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
@@ -24,6 +25,7 @@ const WIDGET_COMPONENTS = {
|
||||
calendar: CalendarWidget,
|
||||
"quick-capture": QuickCaptureWidget,
|
||||
"agent-status": AgentStatusWidget,
|
||||
"orchestrator-events": OrchestratorEventsWidget,
|
||||
};
|
||||
|
||||
const WIDGET_CONFIG = {
|
||||
@@ -43,6 +45,10 @@ const WIDGET_CONFIG = {
|
||||
displayName: "Agent Status",
|
||||
description: "View running agent sessions",
|
||||
},
|
||||
"orchestrator-events": {
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent orchestration events and stream health",
|
||||
},
|
||||
};
|
||||
|
||||
export function WidgetRenderer({
|
||||
|
||||
159
apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Normal file
159
apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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 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 RecentEventsResponse;
|
||||
setEvents(payload.events);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to load events.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecentEvents();
|
||||
|
||||
const eventSource =
|
||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||
if (eventSource) {
|
||||
eventSource.onopen = (): void => {
|
||||
setStreamConnected(true);
|
||||
};
|
||||
eventSource.onmessage = (): void => {
|
||||
void loadRecentEvents();
|
||||
};
|
||||
eventSource.onerror = (): void => {
|
||||
setStreamConnected(false);
|
||||
};
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void loadRecentEvents();
|
||||
}, 15000);
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, [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-1 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||
import { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||
import { TaskProgressWidget } from "./TaskProgressWidget";
|
||||
import { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||
|
||||
export interface WidgetDefinition {
|
||||
name: string;
|
||||
@@ -95,6 +96,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
},
|
||||
OrchestratorEventsWidget: {
|
||||
name: "OrchestratorEventsWidget",
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent orchestration events with stream/Matrix visibility",
|
||||
component: OrchestratorEventsWidget,
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||
|
||||
describe("OrchestratorEventsWidget", () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading state initially", () => {
|
||||
mockFetch.mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
expect(screen.getByText("Loading orchestrator events...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("task.completed")).toBeInTheDocument();
|
||||
expect(screen.getByText("agent.running")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Matrix signals: 1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders error state when API fails", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unable to load events: HTTP 503/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { widgetRegistry } from "../WidgetRegistry";
|
||||
import { TasksWidget } from "../TasksWidget";
|
||||
import { CalendarWidget } from "../CalendarWidget";
|
||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
||||
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||
|
||||
describe("WidgetRegistry", (): void => {
|
||||
it("should have a registry of widgets", (): void => {
|
||||
@@ -32,6 +33,11 @@ describe("WidgetRegistry", (): void => {
|
||||
expect(widgetRegistry.QuickCaptureWidget!.component).toBe(QuickCaptureWidget);
|
||||
});
|
||||
|
||||
it("should include OrchestratorEventsWidget in registry", (): void => {
|
||||
expect(widgetRegistry.OrchestratorEventsWidget).toBeDefined();
|
||||
expect(widgetRegistry.OrchestratorEventsWidget!.component).toBe(OrchestratorEventsWidget);
|
||||
});
|
||||
|
||||
it("should have correct metadata for TasksWidget", (): void => {
|
||||
const tasksWidget = widgetRegistry.TasksWidget!;
|
||||
expect(tasksWidget.name).toBe("TasksWidget");
|
||||
|
||||
@@ -6,3 +6,4 @@ export { TasksWidget } from "./TasksWidget";
|
||||
export { CalendarWidget } from "./CalendarWidget";
|
||||
export { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||
export { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
export { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||
|
||||
Reference in New Issue
Block a user