feat(orchestrator): add SSE events, queue controls, and mosaic rails sync
This commit is contained in:
50
apps/web/src/app/api/orchestrator/events/route.ts
Normal file
50
apps/web/src/app/api/orchestrator/events/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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<Response> {
|
||||
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 upstream = await fetch(`${getOrchestratorUrl()}/agents/events`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-Key": orchestratorApiKey,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
const text = await upstream.text();
|
||||
return new NextResponse(text || "Failed to connect to orchestrator events stream", {
|
||||
status: upstream.status || 502,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
43
apps/web/src/app/api/orchestrator/queue/pause/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/queue/pause/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 POST(): 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()}/queue/pause`, {
|
||||
method: "POST",
|
||||
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 });
|
||||
}
|
||||
}
|
||||
43
apps/web/src/app/api/orchestrator/queue/resume/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/queue/resume/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 POST(): 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()}/queue/resume`, {
|
||||
method: "POST",
|
||||
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 });
|
||||
}
|
||||
}
|
||||
43
apps/web/src/app/api/orchestrator/queue/stats/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/queue/stats/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()}/queue/stats`, {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Agent Status Widget - shows running agents
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
@@ -21,46 +21,57 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAgents = useCallback(async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/orchestrator/agents", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Agent[];
|
||||
setAgents(data);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("Failed to fetch agents:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setAgents([]); // Clear agents on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
// Fetch agents from orchestrator API
|
||||
useEffect(() => {
|
||||
const fetchAgents = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/orchestrator/agents", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Agent[];
|
||||
setAgents(data);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("Failed to fetch agents:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setAgents([]); // Clear agents on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgents();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
void fetchAgents();
|
||||
}, 30000);
|
||||
}, 20000);
|
||||
|
||||
const eventSource =
|
||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||
if (eventSource) {
|
||||
eventSource.onmessage = (): void => {
|
||||
void fetchAgents();
|
||||
};
|
||||
eventSource.onerror = (): void => {
|
||||
// polling remains fallback
|
||||
};
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, []);
|
||||
}, [fetchAgents]);
|
||||
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* including status, elapsed time, and work item details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Activity, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Activity, CheckCircle, XCircle, Clock, Loader2, Pause, Play } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface AgentTask {
|
||||
@@ -19,6 +19,14 @@ interface AgentTask {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface QueueStats {
|
||||
pending: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
function getElapsedTime(spawnedAt: string, completedAt?: string): string {
|
||||
const start = new Date(spawnedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
@@ -94,34 +102,84 @@ function getAgentTypeLabel(agentType: string): string {
|
||||
|
||||
export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [queueStats, setQueueStats] = useState<QueueStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isQueuePaused, setIsQueuePaused] = useState(false);
|
||||
const [isActionPending, setIsActionPending] = useState(false);
|
||||
|
||||
const fetchTasks = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/agents");
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
const data = (await res.json()) as AgentTask[];
|
||||
setTasks(data);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
} catch {
|
||||
setError("Unable to reach orchestrator");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchQueueStats = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/stats");
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
const data = (await res.json()) as QueueStats;
|
||||
setQueueStats(data);
|
||||
// Heuristic: active=0 with pending>0 for sustained windows usually means paused.
|
||||
setIsQueuePaused(data.active === 0 && data.pending > 0);
|
||||
} catch {
|
||||
// Keep widget functional even if queue controls are temporarily unavailable.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQueueState = useCallback(
|
||||
async (action: "pause" | "resume"): Promise<void> => {
|
||||
setIsActionPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/orchestrator/queue/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
setIsQueuePaused(action === "pause");
|
||||
await fetchQueueStats();
|
||||
} catch {
|
||||
setError("Unable to control queue state");
|
||||
} finally {
|
||||
setIsActionPending(false);
|
||||
}
|
||||
},
|
||||
[fetchQueueStats]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTasks = (): void => {
|
||||
fetch("/api/orchestrator/agents")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
return res.json() as Promise<AgentTask[]>;
|
||||
})
|
||||
.then((data) => {
|
||||
setTasks(data);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Unable to reach orchestrator");
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
void fetchTasks();
|
||||
void fetchQueueStats();
|
||||
|
||||
fetchTasks();
|
||||
const interval = setInterval(fetchTasks, 15000);
|
||||
const interval = setInterval(() => {
|
||||
void fetchTasks();
|
||||
void fetchQueueStats();
|
||||
}, 15000);
|
||||
|
||||
const eventSource =
|
||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||
if (eventSource) {
|
||||
eventSource.onmessage = (): void => {
|
||||
void fetchTasks();
|
||||
void fetchQueueStats();
|
||||
};
|
||||
eventSource.onerror = (): void => {
|
||||
// Polling remains the resilience path.
|
||||
};
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, []);
|
||||
}, [fetchTasks, fetchQueueStats]);
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
@@ -151,6 +209,23 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Queue: {isQueuePaused ? "Paused" : "Running"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
void setQueueState(isQueuePaused ? "resume" : "pause");
|
||||
}}
|
||||
disabled={isActionPending}
|
||||
className="inline-flex items-center gap-1 rounded border border-gray-300 dark:border-gray-700 px-2 py-1 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{isQueuePaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
|
||||
{isQueuePaused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center text-xs">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-2">
|
||||
@@ -173,6 +248,29 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queueStats && (
|
||||
<div className="grid grid-cols-3 gap-1 text-center text-xs">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{queueStats.pending}
|
||||
</div>
|
||||
<div className="text-gray-500">Queued</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{queueStats.active}
|
||||
</div>
|
||||
<div className="text-gray-500">Workers</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{queueStats.failed}
|
||||
</div>
|
||||
<div className="text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{tasks.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user