Files
stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
Jason Woltje 27bbbe79df
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
feat(#233): Connect agent dashboard to real orchestrator API
- Add GET /agents endpoint to orchestrator controller
- Update AgentStatusWidget to fetch from real API instead of mock data
- Add comprehensive tests for listAgents endpoint
- Auto-refresh agent list every 30 seconds
- Display agent status with proper icons and formatting
- Show error states when API is unavailable

Fixes #233

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 12:31:07 -06:00

204 lines
6.8 KiB
TypeScript

/**
* Agent Status Widget - shows running agents
*/
import { useState, useEffect } 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;
}
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);
// Fetch agents from orchestrator API
useEffect(() => {
const fetchAgents = async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
// Get orchestrator URL from environment or default to localhost
const orchestratorUrl = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? "http://localhost:8001";
const response = await fetch(`${orchestratorUrl}/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);
return (): void => {
clearInterval(interval);
};
}, []);
const getStatusIcon = (status: string): React.JSX.Element => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "running":
case "working":
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
case "spawning":
case "queued":
return <Clock className="w-4 h-4 text-yellow-500" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500" />;
case "failed":
case "error":
return <AlertCircle className="w-4 h-4 text-red-500" />;
case "terminated":
case "killed":
return <CheckCircle className="w-4 h-4 text-gray-500" />;
default:
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) => a.status.toLowerCase() === "running").length,
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
error: agents.filter((a) => a.status.toLowerCase() === "failed").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 ${
agent.status.toLowerCase() === "failed"
? "bg-red-50 border-red-200"
: agent.status.toLowerCase() === "running"
? "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>
);
}