feat(ui): Nested task slots in worker dashboard
Backend: - Add active_tasks array to GET /worker-registry/workers response - Include task details: role, dispensary, running_seconds, progress Frontend: - Show nested task list under each worker with duration - Display empty slots when worker has capacity - Update pod visualization to show 3 task slot nodes - Active slots pulse blue, empty slots gray - Hover for task details (dispensary, duration, progress) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -430,9 +430,59 @@ router.get('/workers', async (req: Request, res: Response) => {
|
||||
FROM worker_registry
|
||||
`);
|
||||
|
||||
// Get active tasks for all workers (for nested task view)
|
||||
const { rows: activeTasks } = await pool.query(`
|
||||
SELECT
|
||||
t.worker_id,
|
||||
t.id as task_id,
|
||||
t.role,
|
||||
t.status as task_status,
|
||||
t.started_at,
|
||||
t.progress,
|
||||
t.progress_message,
|
||||
EXTRACT(EPOCH FROM (NOW() - t.started_at))::int as running_seconds,
|
||||
d.id as dispensary_id,
|
||||
d.name as dispensary_name,
|
||||
d.city as dispensary_city,
|
||||
d.state as dispensary_state
|
||||
FROM worker_tasks t
|
||||
LEFT JOIN dispensaries d ON t.dispensary_id = d.id
|
||||
WHERE t.status IN ('running', 'claimed')
|
||||
ORDER BY t.worker_id, t.started_at
|
||||
`);
|
||||
|
||||
// Group tasks by worker_id
|
||||
const tasksByWorker: Record<string, any[]> = {};
|
||||
for (const task of activeTasks) {
|
||||
if (!tasksByWorker[task.worker_id]) {
|
||||
tasksByWorker[task.worker_id] = [];
|
||||
}
|
||||
tasksByWorker[task.worker_id].push({
|
||||
task_id: task.task_id,
|
||||
role: task.role,
|
||||
status: task.task_status,
|
||||
started_at: task.started_at,
|
||||
running_seconds: task.running_seconds,
|
||||
progress: task.progress,
|
||||
progress_message: task.progress_message,
|
||||
dispensary: task.dispensary_name ? {
|
||||
id: task.dispensary_id,
|
||||
name: task.dispensary_name,
|
||||
city: task.dispensary_city,
|
||||
state: task.dispensary_state,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach tasks to each worker
|
||||
const workersWithTasks = rows.map((worker: any) => ({
|
||||
...worker,
|
||||
active_tasks: tasksByWorker[worker.worker_id] || [],
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
workers: rows,
|
||||
workers: workersWithTasks,
|
||||
summary: summary[0]
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -116,6 +116,25 @@ interface Worker {
|
||||
elapsed_ms: number;
|
||||
}>;
|
||||
} | null;
|
||||
// Nested active tasks from API
|
||||
active_tasks?: ActiveTask[];
|
||||
}
|
||||
|
||||
// Active task running on a worker
|
||||
interface ActiveTask {
|
||||
task_id: number;
|
||||
role: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
running_seconds: number;
|
||||
progress?: number;
|
||||
progress_message?: string;
|
||||
dispensary?: {
|
||||
id: number;
|
||||
name: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Current task info
|
||||
@@ -486,41 +505,75 @@ function StepBadge({ worker }: { worker: Worker }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Task count badge showing active/max concurrent tasks with task details
|
||||
function TaskCountBadge({ worker, tasks }: { worker: Worker; tasks: Task[] }) {
|
||||
const activeCount = worker.active_task_count ?? (worker.current_task_id ? 1 : 0);
|
||||
const maxCount = worker.max_concurrent_tasks ?? 1;
|
||||
const taskIds = worker.current_task_ids ?? (worker.current_task_id ? [worker.current_task_id] : []);
|
||||
// Format seconds to human readable duration
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins < 60) return `${mins}m ${secs}s`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return `${hours}h ${remainMins}m`;
|
||||
}
|
||||
|
||||
if (activeCount === 0) {
|
||||
return <span className="text-gray-400 text-sm">Idle</span>;
|
||||
// Task count badge showing active/max concurrent tasks with nested task details
|
||||
function TaskCountBadge({ worker, tasks }: { worker: Worker; tasks: Task[] }) {
|
||||
const maxCount = worker.max_concurrent_tasks ?? 3;
|
||||
|
||||
// Use new active_tasks array if available, otherwise fall back to old method
|
||||
const activeTasks = worker.active_tasks ?? [];
|
||||
const activeCount = activeTasks.length || worker.active_task_count || (worker.current_task_id ? 1 : 0);
|
||||
|
||||
if (activeCount === 0 && activeTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-gray-400 text-sm">Idle</span>
|
||||
{/* Show empty slots */}
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: maxCount }).map((_, i) => (
|
||||
<div key={i} className="w-2 h-2 rounded-full bg-gray-200" title={`Slot ${i + 1}: empty`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get task details for display
|
||||
const activeTasks = taskIds.map(id => tasks.find(t => t.id === id)).filter(Boolean) as Task[];
|
||||
|
||||
// Build tooltip with full details
|
||||
const tooltipLines = activeTasks.map(task =>
|
||||
`#${task.id}: ${task.role}${task.dispensary_name ? ` - ${task.dispensary_name}` : ''}`
|
||||
);
|
||||
|
||||
// Show first task details inline
|
||||
const firstTask = activeTasks[0];
|
||||
const roleLabel = firstTask?.role?.replace(/_/g, ' ') || 'task';
|
||||
const storeName = firstTask?.dispensary_name;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5" title={tooltipLines.join('\n')}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{activeCount}/{maxCount} active
|
||||
</span>
|
||||
{firstTask && (
|
||||
<span className="text-xs text-gray-500 truncate max-w-[140px]">
|
||||
{roleLabel}{storeName ? `: ${storeName}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{activeTasks.length > 1 && (
|
||||
<span className="text-xs text-gray-400">+{activeTasks.length - 1} more</span>
|
||||
{/* Nested task list */}
|
||||
{activeTasks.length > 0 ? (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{activeTasks.map((task, i) => (
|
||||
<div
|
||||
key={task.task_id}
|
||||
className="flex items-center gap-1.5 text-xs bg-blue-50 rounded px-1.5 py-0.5"
|
||||
title={`Task #${task.task_id}: ${task.role}\n${task.dispensary?.name || 'Unknown'}\nRunning: ${formatDuration(task.running_seconds)}\n${task.progress_message || ''}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
<span className="text-gray-600 truncate max-w-[120px]">
|
||||
{task.dispensary?.name?.split(' ').slice(0, 2).join(' ') || task.role.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="text-gray-400 ml-auto whitespace-nowrap">
|
||||
{formatDuration(task.running_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Show empty slots */}
|
||||
{Array.from({ length: maxCount - activeTasks.length }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="flex items-center gap-1.5 text-xs text-gray-300 px-1.5 py-0.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-200" />
|
||||
<span>slot empty</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Fallback to old display if active_tasks not available
|
||||
<div className="text-xs text-gray-500">
|
||||
{worker.current_task_id && `Task #${worker.current_task_id}`}
|
||||
</div>
|
||||
)}
|
||||
{/* Show current step */}
|
||||
<StepBadge worker={worker} />
|
||||
@@ -528,7 +581,7 @@ function TaskCountBadge({ worker, tasks }: { worker: Worker; tasks: Task[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Pod visualization - shows pod as hub with worker nodes radiating out
|
||||
// Pod visualization - shows pod with task slots radiating out
|
||||
function PodVisualization({
|
||||
podName,
|
||||
workers,
|
||||
@@ -540,73 +593,91 @@ function PodVisualization({
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
const busyCount = workers.filter(w => w.current_task_id !== null).length;
|
||||
const allBusy = busyCount === workers.length;
|
||||
const allIdle = busyCount === 0;
|
||||
// Get the single worker for this pod (1 worker_registry entry per K8s pod)
|
||||
const worker = workers[0];
|
||||
const activeTasks = worker?.active_tasks ?? [];
|
||||
const maxSlots = worker?.max_concurrent_tasks ?? 3;
|
||||
const activeCount = activeTasks.length;
|
||||
const isBackingOff = worker?.metadata?.is_backing_off;
|
||||
const isDecommissioning = worker?.decommission_requested;
|
||||
|
||||
// Aggregate resource stats for the pod
|
||||
const totalMemoryMb = workers.reduce((sum, w) => sum + (w.metadata?.memory_mb || 0), 0);
|
||||
const totalCpuUserMs = workers.reduce((sum, w) => sum + (w.metadata?.cpu_user_ms || 0), 0);
|
||||
const totalCpuSystemMs = workers.reduce((sum, w) => sum + (w.metadata?.cpu_system_ms || 0), 0);
|
||||
const totalCompleted = workers.reduce((sum, w) => sum + w.tasks_completed, 0);
|
||||
const totalFailed = workers.reduce((sum, w) => sum + w.tasks_failed, 0);
|
||||
|
||||
// Pod color based on worker status
|
||||
const podColor = allBusy ? 'bg-blue-500' : allIdle ? 'bg-emerald-500' : 'bg-yellow-500';
|
||||
const podBorder = allBusy ? 'border-blue-400' : allIdle ? 'border-emerald-400' : 'border-yellow-400';
|
||||
const podGlow = allBusy ? 'shadow-blue-200' : allIdle ? 'shadow-emerald-200' : 'shadow-yellow-200';
|
||||
// Pod color based on task slot usage
|
||||
const allBusy = activeCount >= maxSlots;
|
||||
const allIdle = activeCount === 0;
|
||||
const podColor = isDecommissioning ? 'bg-orange-500' : isBackingOff ? 'bg-yellow-500' : allBusy ? 'bg-blue-500' : allIdle ? 'bg-emerald-500' : 'bg-blue-400';
|
||||
const podBorder = isDecommissioning ? 'border-orange-400' : isBackingOff ? 'border-yellow-400' : allBusy ? 'border-blue-400' : allIdle ? 'border-emerald-400' : 'border-blue-300';
|
||||
const podGlow = allBusy ? 'shadow-blue-200' : allIdle ? 'shadow-emerald-200' : 'shadow-blue-100';
|
||||
|
||||
// Selection ring
|
||||
const selectionRing = isSelected ? 'ring-4 ring-purple-400 ring-offset-2' : '';
|
||||
|
||||
// Build pod tooltip
|
||||
const podTooltip = [
|
||||
`Pod: ${podName}`,
|
||||
`Workers: ${busyCount}/${workers.length} busy`,
|
||||
`Memory: ${totalMemoryMb} MB (RSS)`,
|
||||
`CPU: ${formatCpuTime(totalCpuUserMs)} user, ${formatCpuTime(totalCpuSystemMs)} system`,
|
||||
`Tasks: ${totalCompleted} completed, ${totalFailed} failed`,
|
||||
`Pod: ${worker?.friendly_name || podName}`,
|
||||
`Tasks: ${activeCount}/${maxSlots} slots used`,
|
||||
worker?.metadata?.memory_mb ? `Memory: ${worker.metadata.memory_mb} MB (${worker.metadata.memory_percent || 0}%)` : '',
|
||||
`Completed: ${worker?.tasks_completed || 0} | Failed: ${worker?.tasks_failed || 0}`,
|
||||
isBackingOff ? `⚠️ Backing off: ${worker?.metadata?.backoff_reason}` : '',
|
||||
isDecommissioning ? '🛑 Stopping after current tasks' : '',
|
||||
'Click to select',
|
||||
].join('\n');
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
// Create task slots array (fill with active tasks, pad with empty)
|
||||
const taskSlots: (ActiveTask | null)[] = [...activeTasks];
|
||||
while (taskSlots.length < maxSlots) {
|
||||
taskSlots.push(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4">
|
||||
{/* Pod hub */}
|
||||
<div className="relative">
|
||||
{/* Task slot badge - shows count */}
|
||||
<div className="absolute -top-1 -right-1 z-20">
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 text-xs font-bold rounded-full ${
|
||||
activeCount === maxSlots ? 'bg-blue-600 text-white' :
|
||||
activeCount > 0 ? 'bg-blue-400 text-white' :
|
||||
'bg-gray-300 text-gray-600'
|
||||
}`}>
|
||||
{activeCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center pod circle */}
|
||||
<div
|
||||
className={`w-20 h-20 rounded-full ${podColor} border-4 ${podBorder} shadow-lg ${podGlow} ${selectionRing} flex items-center justify-center text-white font-bold text-xs text-center leading-tight z-10 relative cursor-pointer hover:scale-105 transition-all`}
|
||||
className={`w-20 h-20 rounded-full ${podColor} border-4 ${podBorder} shadow-lg ${podGlow} ${selectionRing} flex items-center justify-center text-white font-bold text-sm text-center leading-tight z-10 relative cursor-pointer hover:scale-105 transition-all`}
|
||||
title={podTooltip}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="px-1">{podName}</span>
|
||||
<span className="px-1">{worker?.friendly_name || podName}</span>
|
||||
</div>
|
||||
|
||||
{/* Worker nodes radiating out */}
|
||||
{workers.map((worker, index) => {
|
||||
const angle = (index * 360) / workers.length - 90; // Start from top
|
||||
{/* Task slot nodes radiating out */}
|
||||
{taskSlots.map((task, index) => {
|
||||
const angle = (index * 360) / maxSlots - 90; // Start from top
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const radius = 55; // Distance from center
|
||||
const radius = 50; // Distance from center
|
||||
const x = Math.cos(radians) * radius;
|
||||
const y = Math.sin(radians) * radius;
|
||||
|
||||
const isBusy = worker.current_task_id !== null;
|
||||
const isDecommissioning = worker.decommission_requested;
|
||||
const isBackingOff = worker.metadata?.is_backing_off;
|
||||
// Color priority: decommissioning > backing off > busy > idle
|
||||
const workerColor = isDecommissioning ? 'bg-orange-500' : isBackingOff ? 'bg-yellow-500' : isBusy ? 'bg-blue-500' : 'bg-emerald-500';
|
||||
const workerBorder = isDecommissioning ? 'border-orange-300' : isBackingOff ? 'border-yellow-300' : isBusy ? 'border-blue-300' : 'border-emerald-300';
|
||||
const isActive = task !== null;
|
||||
const slotColor = isActive ? 'bg-blue-500' : 'bg-gray-200';
|
||||
const slotBorder = isActive ? 'border-blue-300' : 'border-gray-300';
|
||||
|
||||
// Line from center to worker
|
||||
const lineLength = radius - 10;
|
||||
const lineX = Math.cos(radians) * (lineLength / 2 + 10);
|
||||
const lineY = Math.sin(radians) * (lineLength / 2 + 10);
|
||||
// Line from center to slot
|
||||
const lineLength = radius - 15;
|
||||
const lineX = Math.cos(radians) * (lineLength / 2 + 15);
|
||||
const lineY = Math.sin(radians) * (lineLength / 2 + 15);
|
||||
|
||||
const slotTooltip = task
|
||||
? `Slot ${index + 1}: ${task.role.replace(/_/g, ' ')}\n${task.dispensary?.name || 'Unknown store'}\nRunning: ${formatDuration(task.running_seconds)}\n${task.progress_message || ''}`
|
||||
: `Slot ${index + 1}: empty`;
|
||||
|
||||
return (
|
||||
<div key={worker.id}>
|
||||
<div key={index}>
|
||||
{/* Connection line */}
|
||||
<div
|
||||
className={`absolute w-0.5 ${isDecommissioning ? 'bg-orange-300' : isBackingOff ? 'bg-yellow-300' : isBusy ? 'bg-blue-300' : 'bg-emerald-300'}`}
|
||||
className={`absolute w-0.5 ${isActive ? 'bg-blue-300' : 'bg-gray-200'}`}
|
||||
style={{
|
||||
height: `${lineLength}px`,
|
||||
left: '50%',
|
||||
@@ -615,17 +686,17 @@ function PodVisualization({
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
/>
|
||||
{/* Worker node */}
|
||||
{/* Task slot node */}
|
||||
<div
|
||||
className={`absolute w-6 h-6 rounded-full ${workerColor} border-2 ${workerBorder} flex items-center justify-center text-white text-xs font-bold cursor-pointer hover:scale-110 transition-transform`}
|
||||
className={`absolute w-5 h-5 rounded-full ${slotColor} border-2 ${slotBorder} flex items-center justify-center cursor-pointer hover:scale-110 transition-transform ${isActive ? 'animate-pulse' : ''}`}
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
||||
}}
|
||||
title={`${worker.friendly_name}\nStatus: ${isDecommissioning ? 'Stopping after current task' : isBackingOff ? `Backing off: ${worker.metadata?.backoff_reason || 'resource pressure'}` : isBusy ? `Working on task #${worker.current_task_id}` : 'Ready - waiting for tasks'}\nMemory: ${worker.metadata?.memory_mb || 0} MB (${worker.metadata?.memory_percent || 0}%)\nCPU: ${formatCpuTime(worker.metadata?.cpu_user_ms || 0)} user, ${formatCpuTime(worker.metadata?.cpu_system_ms || 0)} sys\nCompleted: ${worker.tasks_completed} | Failed: ${worker.tasks_failed}\nLast heartbeat: ${new Date(worker.last_heartbeat_at).toLocaleTimeString()}`}
|
||||
title={slotTooltip}
|
||||
>
|
||||
{index + 1}
|
||||
{isActive && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -635,7 +706,7 @@ function PodVisualization({
|
||||
{/* Pod stats */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{busyCount}/{workers.length} busy
|
||||
{activeCount}/{maxSlots} busy
|
||||
</p>
|
||||
{isSelected && (
|
||||
<p className="text-xs text-purple-600 font-medium mt-1">Selected</p>
|
||||
|
||||
Reference in New Issue
Block a user