Files
stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
Jason Woltje 7f94ecdc7a
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/manual/ci Pipeline was successful
fix: add missing orchestrator endpoints + fix AgentStatusWidget (#657)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 16:43:51 +00:00

222 lines
7.1 KiB
TypeScript

/**
* Agent Status Widget - shows running agents
*/
import { useState, useEffect, useCallback } from "react";
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface Agent {
agentId: string;
taskId: string;
status: string;
agentType: string;
spawnedAt: string;
completedAt?: string;
error?: string;
}
function isWorking(status: string): boolean {
const s = status.toLowerCase();
return s === "running" || s === "working";
}
function isIdle(status: string): boolean {
const s = status.toLowerCase();
return s === "idle" || s === "spawning" || s === "waiting" || s === "queued";
}
function isErrored(status: string): boolean {
const s = status.toLowerCase();
return s === "failed" || s === "error";
}
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
const [agents, setAgents] = useState<Agent[]>([]);
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(() => {
void fetchAgents();
// Refresh every 30 seconds
const interval = setInterval(() => {
void fetchAgents();
}, 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 => {
if (isWorking(status)) {
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
}
if (isIdle(status)) {
return <Clock className="w-4 h-4 text-yellow-500" />;
}
if (isErrored(status)) {
return <AlertCircle className="w-4 h-4 text-red-500" />;
}
const s = status.toLowerCase();
if (s === "completed" || s === "terminated" || s === "killed") {
return <CheckCircle className="w-4 h-4 text-gray-500" />;
}
return <Clock className="w-4 h-4 text-gray-400" />;
};
const getStatusText = (status: string): string => {
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
const getAgentName = (agent: Agent): string => {
const typeMap: Record<string, string> = {
worker: "Worker Agent",
reviewer: "Code Review Agent",
tester: "Test Runner Agent",
};
return typeMap[agent.agentType] ?? `${getStatusText(agent.agentType)} Agent`;
};
const getTimeSinceSpawn = (timestamp: string): string => {
const now = new Date();
const spawned = new Date(timestamp);
const diffMs = now.getTime() - spawned.getTime();
if (diffMs < 60000) return "Just now";
if (diffMs < 3600000) return `${String(Math.floor(diffMs / 60000))}m ago`;
if (diffMs < 86400000) return `${String(Math.floor(diffMs / 3600000))}h ago`;
return `${String(Math.floor(diffMs / 86400000))}d ago`;
};
const stats = {
total: agents.length,
working: agents.filter((a) => isWorking(a.status)).length,
idle: agents.filter((a) => isIdle(a.status)).length,
error: agents.filter((a) => isErrored(a.status)).length,
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500 text-sm">Loading agents...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-red-500 text-sm">
<AlertCircle className="w-4 h-4 inline mr-1" />
{error}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full space-y-3">
{/* Summary stats */}
<div className="grid grid-cols-4 gap-1 text-center text-xs">
<div className="bg-gray-50 rounded p-2">
<div className="text-lg font-bold text-gray-900">{stats.total}</div>
<div className="text-gray-500">Total</div>
</div>
<div className="bg-blue-50 rounded p-2">
<div className="text-lg font-bold text-blue-600">{stats.working}</div>
<div className="text-blue-500">Working</div>
</div>
<div className="bg-gray-100 rounded p-2">
<div className="text-lg font-bold text-gray-600">{stats.idle}</div>
<div className="text-gray-500">Idle</div>
</div>
<div className="bg-red-50 rounded p-2">
<div className="text-lg font-bold text-red-600">{stats.error}</div>
<div className="text-red-500">Error</div>
</div>
</div>
{/* Agent list */}
<div className="flex-1 overflow-auto space-y-2">
{agents.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-4">No agents running</div>
) : (
agents.map((agent) => (
<div
key={agent.agentId}
className={`p-3 rounded-lg border ${
isErrored(agent.status)
? "bg-red-50 border-red-200"
: isWorking(agent.status)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-gray-900">{getAgentName(agent)}</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
{getStatusIcon(agent.status)}
<span>{getStatusText(agent.status)}</span>
</div>
</div>
<div className="text-xs text-gray-600 mb-1">Task: {agent.taskId}</div>
{agent.error && <div className="text-xs text-red-600 mb-1">{agent.error}</div>}
<div className="flex items-center justify-between text-xs text-gray-400">
<span>Agent ID: {agent.agentId.slice(0, 8)}...</span>
<span>{getTimeSinceSpawn(agent.spawnedAt)}</span>
</div>
</div>
))
)}
</div>
</div>
);
}