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:
Kelly
2025-12-13 20:40:15 -07:00
parent b51ba17d32
commit 6439de5cd4
2 changed files with 194 additions and 73 deletions

View File

@@ -430,9 +430,59 @@ router.get('/workers', async (req: Request, res: Response) => {
FROM worker_registry 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({ res.json({
success: true, success: true,
workers: rows, workers: workersWithTasks,
summary: summary[0] summary: summary[0]
}); });
} catch (error: any) { } catch (error: any) {

View File

@@ -116,6 +116,25 @@ interface Worker {
elapsed_ms: number; elapsed_ms: number;
}>; }>;
} | null; } | 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 // Current task info
@@ -486,41 +505,75 @@ function StepBadge({ worker }: { worker: Worker }) {
); );
} }
// Task count badge showing active/max concurrent tasks with task details // Format seconds to human readable duration
function TaskCountBadge({ worker, tasks }: { worker: Worker; tasks: Task[] }) { function formatDuration(seconds: number): string {
const activeCount = worker.active_task_count ?? (worker.current_task_id ? 1 : 0); if (seconds < 60) return `${seconds}s`;
const maxCount = worker.max_concurrent_tasks ?? 1; const mins = Math.floor(seconds / 60);
const taskIds = worker.current_task_ids ?? (worker.current_task_id ? [worker.current_task_id] : []); 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) { // Task count badge showing active/max concurrent tasks with nested task details
return <span className="text-gray-400 text-sm">Idle</span>; 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 ( 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"> <span className="text-sm font-medium text-blue-600">
{activeCount}/{maxCount} active {activeCount}/{maxCount} active
</span> </span>
{firstTask && ( {/* Nested task list */}
<span className="text-xs text-gray-500 truncate max-w-[140px]"> {activeTasks.length > 0 ? (
{roleLabel}{storeName ? `: ${storeName}` : ''} <div className="flex flex-col gap-1 mt-1">
</span> {activeTasks.map((task, i) => (
)} <div
{activeTasks.length > 1 && ( key={task.task_id}
<span className="text-xs text-gray-400">+{activeTasks.length - 1} more</span> 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 */} {/* Show current step */}
<StepBadge worker={worker} /> <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({ function PodVisualization({
podName, podName,
workers, workers,
@@ -540,73 +593,91 @@ function PodVisualization({
isSelected?: boolean; isSelected?: boolean;
onSelect?: () => void; onSelect?: () => void;
}) { }) {
const busyCount = workers.filter(w => w.current_task_id !== null).length; // Get the single worker for this pod (1 worker_registry entry per K8s pod)
const allBusy = busyCount === workers.length; const worker = workers[0];
const allIdle = busyCount === 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 // Pod color based on task slot usage
const totalMemoryMb = workers.reduce((sum, w) => sum + (w.metadata?.memory_mb || 0), 0); const allBusy = activeCount >= maxSlots;
const totalCpuUserMs = workers.reduce((sum, w) => sum + (w.metadata?.cpu_user_ms || 0), 0); const allIdle = activeCount === 0;
const totalCpuSystemMs = workers.reduce((sum, w) => sum + (w.metadata?.cpu_system_ms || 0), 0); const podColor = isDecommissioning ? 'bg-orange-500' : isBackingOff ? 'bg-yellow-500' : allBusy ? 'bg-blue-500' : allIdle ? 'bg-emerald-500' : 'bg-blue-400';
const totalCompleted = workers.reduce((sum, w) => sum + w.tasks_completed, 0); const podBorder = isDecommissioning ? 'border-orange-400' : isBackingOff ? 'border-yellow-400' : allBusy ? 'border-blue-400' : allIdle ? 'border-emerald-400' : 'border-blue-300';
const totalFailed = workers.reduce((sum, w) => sum + w.tasks_failed, 0); const podGlow = allBusy ? 'shadow-blue-200' : allIdle ? 'shadow-emerald-200' : 'shadow-blue-100';
// 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';
// Selection ring // Selection ring
const selectionRing = isSelected ? 'ring-4 ring-purple-400 ring-offset-2' : ''; const selectionRing = isSelected ? 'ring-4 ring-purple-400 ring-offset-2' : '';
// Build pod tooltip // Build pod tooltip
const podTooltip = [ const podTooltip = [
`Pod: ${podName}`, `Pod: ${worker?.friendly_name || podName}`,
`Workers: ${busyCount}/${workers.length} busy`, `Tasks: ${activeCount}/${maxSlots} slots used`,
`Memory: ${totalMemoryMb} MB (RSS)`, worker?.metadata?.memory_mb ? `Memory: ${worker.metadata.memory_mb} MB (${worker.metadata.memory_percent || 0}%)` : '',
`CPU: ${formatCpuTime(totalCpuUserMs)} user, ${formatCpuTime(totalCpuSystemMs)} system`, `Completed: ${worker?.tasks_completed || 0} | Failed: ${worker?.tasks_failed || 0}`,
`Tasks: ${totalCompleted} completed, ${totalFailed} failed`, isBackingOff ? `⚠️ Backing off: ${worker?.metadata?.backoff_reason}` : '',
isDecommissioning ? '🛑 Stopping after current tasks' : '',
'Click to select', '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 ( return (
<div className="flex flex-col items-center p-4"> <div className="flex flex-col items-center p-4">
{/* Pod hub */} {/* Pod hub */}
<div className="relative"> <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 */} {/* Center pod circle */}
<div <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} title={podTooltip}
onClick={onSelect} onClick={onSelect}
> >
<span className="px-1">{podName}</span> <span className="px-1">{worker?.friendly_name || podName}</span>
</div> </div>
{/* Worker nodes radiating out */} {/* Task slot nodes radiating out */}
{workers.map((worker, index) => { {taskSlots.map((task, index) => {
const angle = (index * 360) / workers.length - 90; // Start from top const angle = (index * 360) / maxSlots - 90; // Start from top
const radians = (angle * Math.PI) / 180; 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 x = Math.cos(radians) * radius;
const y = Math.sin(radians) * radius; const y = Math.sin(radians) * radius;
const isBusy = worker.current_task_id !== null; const isActive = task !== null;
const isDecommissioning = worker.decommission_requested; const slotColor = isActive ? 'bg-blue-500' : 'bg-gray-200';
const isBackingOff = worker.metadata?.is_backing_off; const slotBorder = isActive ? 'border-blue-300' : 'border-gray-300';
// 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';
// Line from center to worker // Line from center to slot
const lineLength = radius - 10; const lineLength = radius - 15;
const lineX = Math.cos(radians) * (lineLength / 2 + 10); const lineX = Math.cos(radians) * (lineLength / 2 + 15);
const lineY = Math.sin(radians) * (lineLength / 2 + 10); 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 ( return (
<div key={worker.id}> <div key={index}>
{/* Connection line */} {/* Connection line */}
<div <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={{ style={{
height: `${lineLength}px`, height: `${lineLength}px`,
left: '50%', left: '50%',
@@ -615,17 +686,17 @@ function PodVisualization({
transformOrigin: 'center', transformOrigin: 'center',
}} }}
/> />
{/* Worker node */} {/* Task slot node */}
<div <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={{ style={{
left: '50%', left: '50%',
top: '50%', top: '50%',
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`, 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>
</div> </div>
); );
@@ -635,7 +706,7 @@ function PodVisualization({
{/* Pod stats */} {/* Pod stats */}
<div className="mt-12 text-center"> <div className="mt-12 text-center">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{busyCount}/{workers.length} busy {activeCount}/{maxSlots} busy
</p> </p>
{isSelected && ( {isSelected && (
<p className="text-xs text-purple-600 font-medium mt-1">Selected</p> <p className="text-xs text-purple-600 font-medium mt-1">Selected</p>