chore: upgrade Node.js runtime to v24 across codebase #419
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 { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||
import { TaskProgressWidget } from "./TaskProgressWidget";
|
||||
|
||||
export interface WidgetDefinition {
|
||||
name: string;
|
||||
@@ -83,6 +84,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
||||
minHeight: 2,
|
||||
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