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,
|
minWidth: 1,
|
||||||
minHeight: 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;
|
} as const;
|
||||||
|
|
||||||
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ vi.mock("@/components/widgets", () => ({
|
|||||||
AgentStatusWidget: ({ id }: { id: string }): React.JSX.Element => (
|
AgentStatusWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||||
<div>Agent Status Widget {id}</div>
|
<div>Agent Status Widget {id}</div>
|
||||||
),
|
),
|
||||||
|
OrchestratorEventsWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||||
|
<div>Orchestrator Events Widget {id}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function createWidgetPlacement(id: string): WidgetPlacement {
|
function createWidgetPlacement(id: string): WidgetPlacement {
|
||||||
@@ -34,4 +37,11 @@ describe("WidgetRenderer", () => {
|
|||||||
render(<WidgetRenderer widget={createWidgetPlacement("agent-status-123")} />);
|
render(<WidgetRenderer widget={createWidgetPlacement("agent-status-123")} />);
|
||||||
expect(screen.getByText("Agent Status Widget agent-status-123")).toBeInTheDocument();
|
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,
|
CalendarWidget,
|
||||||
QuickCaptureWidget,
|
QuickCaptureWidget,
|
||||||
AgentStatusWidget,
|
AgentStatusWidget,
|
||||||
|
OrchestratorEventsWidget,
|
||||||
} from "@/components/widgets";
|
} from "@/components/widgets";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ const WIDGET_COMPONENTS = {
|
|||||||
calendar: CalendarWidget,
|
calendar: CalendarWidget,
|
||||||
"quick-capture": QuickCaptureWidget,
|
"quick-capture": QuickCaptureWidget,
|
||||||
"agent-status": AgentStatusWidget,
|
"agent-status": AgentStatusWidget,
|
||||||
|
"orchestrator-events": OrchestratorEventsWidget,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WIDGET_CONFIG = {
|
const WIDGET_CONFIG = {
|
||||||
@@ -43,6 +45,10 @@ const WIDGET_CONFIG = {
|
|||||||
displayName: "Agent Status",
|
displayName: "Agent Status",
|
||||||
description: "View running agent sessions",
|
description: "View running agent sessions",
|
||||||
},
|
},
|
||||||
|
"orchestrator-events": {
|
||||||
|
displayName: "Orchestrator Events",
|
||||||
|
description: "Recent orchestration events and stream health",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WidgetRenderer({
|
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 { AgentStatusWidget } from "./AgentStatusWidget";
|
||||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||||
import { TaskProgressWidget } from "./TaskProgressWidget";
|
import { TaskProgressWidget } from "./TaskProgressWidget";
|
||||||
|
import { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||||
|
|
||||||
export interface WidgetDefinition {
|
export interface WidgetDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -95,6 +96,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
|||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
maxWidth: 3,
|
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 { TasksWidget } from "../TasksWidget";
|
||||||
import { CalendarWidget } from "../CalendarWidget";
|
import { CalendarWidget } from "../CalendarWidget";
|
||||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
||||||
|
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||||
|
|
||||||
describe("WidgetRegistry", (): void => {
|
describe("WidgetRegistry", (): void => {
|
||||||
it("should have a registry of widgets", (): void => {
|
it("should have a registry of widgets", (): void => {
|
||||||
@@ -32,6 +33,11 @@ describe("WidgetRegistry", (): void => {
|
|||||||
expect(widgetRegistry.QuickCaptureWidget!.component).toBe(QuickCaptureWidget);
|
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 => {
|
it("should have correct metadata for TasksWidget", (): void => {
|
||||||
const tasksWidget = widgetRegistry.TasksWidget!;
|
const tasksWidget = widgetRegistry.TasksWidget!;
|
||||||
expect(tasksWidget.name).toBe("TasksWidget");
|
expect(tasksWidget.name).toBe("TasksWidget");
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { TasksWidget } from "./TasksWidget";
|
|||||||
export { CalendarWidget } from "./CalendarWidget";
|
export { CalendarWidget } from "./CalendarWidget";
|
||||||
export { QuickCaptureWidget } from "./QuickCaptureWidget";
|
export { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||||
export { AgentStatusWidget } from "./AgentStatusWidget";
|
export { AgentStatusWidget } from "./AgentStatusWidget";
|
||||||
|
export { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||||
|
|||||||
@@ -383,3 +383,5 @@
|
|||||||
| ORCH-OBS-004 | done | Add tests/docs updates for recent events and operator command usage | #411 | orchestrator,docs | feature/mosaic-stack-finalization | ORCH-OBS-001 | | orch | 2026-02-17T16:36Z | 2026-02-17T16:40Z | 8K | 6K |
|
| ORCH-OBS-004 | done | Add tests/docs updates for recent events and operator command usage | #411 | orchestrator,docs | feature/mosaic-stack-finalization | ORCH-OBS-001 | | orch | 2026-02-17T16:36Z | 2026-02-17T16:40Z | 8K | 6K |
|
||||||
| ORCH-OBS-005 | done | Fix HUD widget ID generation/parsing for hyphenated widget types (`quick-capture`, `agent-status`) | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-004 | | orch | 2026-02-17T16:42Z | 2026-02-17T16:48Z | 8K | 6K |
|
| ORCH-OBS-005 | done | Fix HUD widget ID generation/parsing for hyphenated widget types (`quick-capture`, `agent-status`) | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-004 | | orch | 2026-02-17T16:42Z | 2026-02-17T16:48Z | 8K | 6K |
|
||||||
| ORCH-OBS-006 | done | Add `WidgetRenderer` regression tests for hyphenated widget IDs | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-005 | | orch | 2026-02-17T16:48Z | 2026-02-17T16:50Z | 5K | 3K |
|
| ORCH-OBS-006 | done | Add `WidgetRenderer` regression tests for hyphenated widget IDs | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-005 | | orch | 2026-02-17T16:48Z | 2026-02-17T16:50Z | 5K | 3K |
|
||||||
|
| ORCH-OBS-007 | done | Add `OrchestratorEventsWidget` for live/recent orchestration visibility with Matrix signal hints | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-002 | | orch | 2026-02-17T16:55Z | 2026-02-17T17:03Z | 12K | 9K |
|
||||||
|
| 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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user