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
|
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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user