feat(#101): Add Task Progress widget for orchestrator task monitoring
Create TaskProgressWidget showing live agent task execution progress: - Fetches from orchestrator /agents API with 15s auto-refresh - Shows stats (total/active/done/stopped), sorted task list - Agent type badges (worker/reviewer/tester) - Elapsed time tracking, error display - Dark mode support, PDA-friendly language - Registered in WidgetRegistry for dashboard use Includes 7 unit tests covering all states. Fixes #101 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
230
apps/web/src/components/widgets/TaskProgressWidget.tsx
Normal file
230
apps/web/src/components/widgets/TaskProgressWidget.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Task Progress Widget - shows orchestrator agent task progress
|
||||||
|
*
|
||||||
|
* Displays live progress of agent tasks being executed by the orchestrator,
|
||||||
|
* including status, elapsed time, and work item details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Activity, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface AgentTask {
|
||||||
|
agentId: string;
|
||||||
|
taskId: string;
|
||||||
|
status: string;
|
||||||
|
agentType: string;
|
||||||
|
spawnedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElapsedTime(spawnedAt: string, completedAt?: string): string {
|
||||||
|
const start = new Date(spawnedAt).getTime();
|
||||||
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||||
|
const diffMs = end - start;
|
||||||
|
|
||||||
|
if (diffMs < 60000) return `${String(Math.floor(diffMs / 1000))}s`;
|
||||||
|
if (diffMs < 3600000)
|
||||||
|
return `${String(Math.floor(diffMs / 60000))}m ${String(Math.floor((diffMs % 60000) / 1000))}s`;
|
||||||
|
return `${String(Math.floor(diffMs / 3600000))}h ${String(Math.floor((diffMs % 3600000) / 60000))}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(status: string): React.JSX.Element {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
|
case "spawning":
|
||||||
|
return <Loader2 className="w-4 h-4 text-yellow-500 animate-spin" />;
|
||||||
|
case "completed":
|
||||||
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||||
|
case "failed":
|
||||||
|
case "killed":
|
||||||
|
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "spawning":
|
||||||
|
return "Starting";
|
||||||
|
case "running":
|
||||||
|
return "In progress";
|
||||||
|
case "completed":
|
||||||
|
return "Done";
|
||||||
|
case "failed":
|
||||||
|
return "Stopped";
|
||||||
|
case "killed":
|
||||||
|
return "Terminated";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return "bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800";
|
||||||
|
case "spawning":
|
||||||
|
return "bg-yellow-50 border-yellow-200 dark:bg-yellow-950 dark:border-yellow-800";
|
||||||
|
case "completed":
|
||||||
|
return "bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800";
|
||||||
|
case "failed":
|
||||||
|
case "killed":
|
||||||
|
return "bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-50 border-gray-200 dark:bg-gray-900 dark:border-gray-700";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentTypeLabel(agentType: string): string {
|
||||||
|
switch (agentType) {
|
||||||
|
case "worker":
|
||||||
|
return "Worker";
|
||||||
|
case "reviewer":
|
||||||
|
return "Reviewer";
|
||||||
|
case "tester":
|
||||||
|
return "Tester";
|
||||||
|
default:
|
||||||
|
return agentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const orchestratorUrl = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? "http://localhost:3001";
|
||||||
|
|
||||||
|
const fetchTasks = (): void => {
|
||||||
|
fetch(`${orchestratorUrl}/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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTasks();
|
||||||
|
const interval = setInterval(fetchTasks, 15000);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: tasks.length,
|
||||||
|
active: tasks.filter((t) => t.status === "running" || t.status === "spawning").length,
|
||||||
|
completed: tasks.filter((t) => t.status === "completed").length,
|
||||||
|
failed: tasks.filter((t) => t.status === "failed" || t.status === "killed").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 task progress...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<XCircle className="w-6 h-6 text-gray-400 mb-2" />
|
||||||
|
<span className="text-gray-500 text-sm">{error}</span>
|
||||||
|
<span className="text-gray-400 text-xs mt-1">Retrying automatically</span>
|
||||||
|
</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 dark:bg-gray-800 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-gray-100">{stats.total}</div>
|
||||||
|
<div className="text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.active}</div>
|
||||||
|
<div className="text-blue-500">Active</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 dark:bg-green-950 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-green-600 dark:text-green-400">
|
||||||
|
{stats.completed}
|
||||||
|
</div>
|
||||||
|
<div className="text-green-500">Done</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 dark:bg-red-950 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-red-600 dark:text-red-400">{stats.failed}</div>
|
||||||
|
<div className="text-red-500">Stopped</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
<div className="flex-1 overflow-auto space-y-2">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 text-sm py-4">No agent tasks in progress</div>
|
||||||
|
) : (
|
||||||
|
tasks
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Active tasks first, then by spawn time
|
||||||
|
const statusOrder: Record<string, number> = {
|
||||||
|
running: 0,
|
||||||
|
spawning: 1,
|
||||||
|
failed: 2,
|
||||||
|
killed: 3,
|
||||||
|
completed: 4,
|
||||||
|
};
|
||||||
|
const orderA = statusOrder[a.status] ?? 5;
|
||||||
|
const orderB = statusOrder[b.status] ?? 5;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return new Date(b.spawnedAt).getTime() - new Date(a.spawnedAt).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.agentId}
|
||||||
|
className={`p-2 rounded-lg border ${getStatusColor(task.status)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{task.taskId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{getAgentTypeLabel(task.agentType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{getStatusLabel(task.status)}</span>
|
||||||
|
<span>{getElapsedTime(task.spawnedAt, task.completedAt)}</span>
|
||||||
|
</div>
|
||||||
|
{task.error && (
|
||||||
|
<div className="text-xs text-red-600 dark:text-red-400 mt-1 truncate">
|
||||||
|
{task.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { CalendarWidget } from "./CalendarWidget";
|
|||||||
import { QuickCaptureWidget } from "./QuickCaptureWidget";
|
import { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||||
import { AgentStatusWidget } from "./AgentStatusWidget";
|
import { AgentStatusWidget } from "./AgentStatusWidget";
|
||||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||||
|
import { TaskProgressWidget } from "./TaskProgressWidget";
|
||||||
|
|
||||||
export interface WidgetDefinition {
|
export interface WidgetDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -83,6 +84,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
|||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
maxWidth: 4,
|
maxWidth: 4,
|
||||||
},
|
},
|
||||||
|
TaskProgressWidget: {
|
||||||
|
name: "TaskProgressWidget",
|
||||||
|
displayName: "Task Progress",
|
||||||
|
description: "Live progress of orchestrator agent tasks",
|
||||||
|
component: TaskProgressWidget,
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 3,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* TaskProgressWidget Component Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { TaskProgressWidget } from "../TaskProgressWidget";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
describe("TaskProgressWidget", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render loading state initially", (): void => {
|
||||||
|
mockFetch.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise(() => {
|
||||||
|
// Never-resolving promise for loading state
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/loading task progress/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display tasks after successful fetch", async (): Promise<void> => {
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
agentId: "agent-1",
|
||||||
|
taskId: "TASK-001",
|
||||||
|
status: "running",
|
||||||
|
agentType: "worker",
|
||||||
|
spawnedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "agent-2",
|
||||||
|
taskId: "TASK-002",
|
||||||
|
status: "completed",
|
||||||
|
agentType: "reviewer",
|
||||||
|
spawnedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTasks),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("TASK-001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("TASK-002")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check stats
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument(); // Total
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display error state when fetch fails", async (): Promise<void> => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/unable to reach orchestrator/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display empty state when no tasks", async (): Promise<void> => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/no agent tasks in progress/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show agent type badges", async (): Promise<void> => {
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
agentId: "agent-1",
|
||||||
|
taskId: "TASK-001",
|
||||||
|
status: "running",
|
||||||
|
agentType: "worker",
|
||||||
|
spawnedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "agent-2",
|
||||||
|
taskId: "TASK-002",
|
||||||
|
status: "running",
|
||||||
|
agentType: "reviewer",
|
||||||
|
spawnedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "agent-3",
|
||||||
|
taskId: "TASK-003",
|
||||||
|
status: "running",
|
||||||
|
agentType: "tester",
|
||||||
|
spawnedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTasks),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Worker")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Reviewer")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tester")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display error message for failed tasks", async (): Promise<void> => {
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
agentId: "agent-1",
|
||||||
|
taskId: "TASK-FAIL",
|
||||||
|
status: "failed",
|
||||||
|
agentType: "worker",
|
||||||
|
spawnedAt: new Date().toISOString(),
|
||||||
|
error: "Build failed: type errors",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTasks),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Build failed: type errors")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sort active tasks before completed ones", async (): Promise<void> => {
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
agentId: "agent-completed",
|
||||||
|
taskId: "COMPLETED-TASK",
|
||||||
|
status: "completed",
|
||||||
|
agentType: "worker",
|
||||||
|
spawnedAt: new Date(Date.now() - 7200000).toISOString(),
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "agent-running",
|
||||||
|
taskId: "RUNNING-TASK",
|
||||||
|
status: "running",
|
||||||
|
agentType: "worker",
|
||||||
|
spawnedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTasks),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskElements = screen.getAllByText(/TASK/);
|
||||||
|
expect(taskElements).toHaveLength(2);
|
||||||
|
// Running task should appear before completed
|
||||||
|
expect(taskElements[0]?.textContent).toBe("RUNNING-TASK");
|
||||||
|
expect(taskElements[1]?.textContent).toBe("COMPLETED-TASK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user