feat(ui): Task pool toggle, sortable columns, worker slot visualization

Tasks Dashboard:
- Add clickable Pool Open/Paused toggle button in header
- Add sortable columns (ID, Role, Store, Status, Worker, Duration, Created)
- Show menu_type and pool badges under Store column
- Add Pool column to Schedules table
- Filter stores by platform in Create Task modal

Workers Dashboard:
- Redesign pod visualization to show 3 worker slots per pod
- Each slot shows preflight checklist (Overload? Terminating? Pool Query?)
- Once qualified, shows City/State, Proxy IP, Antidetect status
- Hover shows full fingerprint data (browser, platform, bot detection)

Backend:
- Add menu_type to listTasks query
- Add pool_id/pool_name to schedules query with task_pools JOIN
- Migration 114: Add pool_id column to task_schedules table

🤖 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-14 03:00:19 -07:00
parent 15a5a4239e
commit e7b392141a
6 changed files with 392 additions and 148 deletions

View File

@@ -0,0 +1,10 @@
-- Migration 114: Add pool_id to task_schedules
-- Allows schedules to target specific geo pools
ALTER TABLE task_schedules
ADD COLUMN IF NOT EXISTS pool_id INTEGER REFERENCES task_pools(id);
-- Index for pool-based schedule queries
CREATE INDEX IF NOT EXISTS idx_task_schedules_pool ON task_schedules(pool_id) WHERE pool_id IS NOT NULL;
COMMENT ON COLUMN task_schedules.pool_id IS 'Optional geo pool filter. NULL = all pools/dispensaries matching state_code';

View File

@@ -214,27 +214,29 @@ router.get('/schedules', async (req: Request, res: Response) => {
const enabledOnly = req.query.enabled === 'true';
let query = `
SELECT id, name, role, description, enabled, interval_hours,
priority, state_code, platform, method,
COALESCE(is_immutable, false) as is_immutable,
last_run_at, next_run_at,
last_task_count, last_error, created_at, updated_at
FROM task_schedules
SELECT ts.id, ts.name, ts.role, ts.description, ts.enabled, ts.interval_hours,
ts.priority, ts.state_code, ts.pool_id, tp.display_name as pool_name,
ts.platform, ts.method,
COALESCE(ts.is_immutable, false) as is_immutable,
ts.last_run_at, ts.next_run_at,
ts.last_task_count, ts.last_error, ts.created_at, ts.updated_at
FROM task_schedules ts
LEFT JOIN task_pools tp ON tp.id = ts.pool_id
`;
if (enabledOnly) {
query += ` WHERE enabled = true`;
query += ` WHERE ts.enabled = true`;
}
query += ` ORDER BY
CASE role
CASE ts.role
WHEN 'store_discovery' THEN 1
WHEN 'product_discovery' THEN 2
WHEN 'analytics_refresh' THEN 3
ELSE 4
END,
state_code NULLS FIRST,
name`;
ts.state_code NULLS FIRST,
ts.name`;
const result = await pool.query(query);
res.json({ schedules: result.rows });

View File

@@ -453,6 +453,7 @@ class TaskService {
t.*,
d.name as dispensary_name,
d.slug as dispensary_slug,
d.menu_type as menu_type,
${poolColumns}
w.friendly_name as worker_name
FROM worker_tasks t

View File

@@ -3140,6 +3140,8 @@ export interface TaskSchedule {
interval_hours: number;
priority: number;
state_code: string | null;
pool_id: number | null;
pool_name: string | null;
platform: string | null;
method: 'curl' | 'http' | null;
is_immutable: boolean;

View File

@@ -27,6 +27,9 @@ import {
Timer,
Lock,
Globe,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from 'lucide-react';
interface Task {
@@ -34,6 +37,7 @@ interface Task {
role: string;
dispensary_id: number | null;
dispensary_name?: string;
menu_type?: string | null;
platform: string | null;
status: string;
priority: number;
@@ -92,6 +96,7 @@ interface Store {
name: string;
state_code: string;
crawl_enabled: boolean;
menu_type?: string;
}
interface CreateTaskModalProps {
@@ -138,10 +143,15 @@ function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProp
}
}, [isOpen]);
// Clear selected stores when platform changes
useEffect(() => {
setSelectedStores([]);
}, [taskPlatform]);
const fetchStores = async () => {
setStoresLoading(true);
try {
const res = await api.get('/api/stores?limit=500');
const res = await api.get('/api/stores?limit=2000');
setStores(res.data.stores || res.data || []);
} catch (err) {
console.error('Failed to fetch stores:', err);
@@ -150,10 +160,25 @@ function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProp
}
};
const filteredStores = stores.filter(s =>
s.name.toLowerCase().includes(storeSearch.toLowerCase()) ||
s.state_code?.toLowerCase().includes(storeSearch.toLowerCase())
);
// Filter stores by platform (menu_type) and search query
const filteredStores = stores.filter(s => {
// Platform filter: match menu_type to selected platform
let platformMatch = true;
if (taskPlatform === 'dutchie') {
platformMatch = s.menu_type === 'dutchie_plus' || s.menu_type === 'dutchie_iframe' || s.menu_type === 'dutchie';
} else if (taskPlatform === 'jane') {
platformMatch = s.menu_type === 'jane';
} else if (taskPlatform === 'treez') {
platformMatch = s.menu_type === 'treez';
}
// Search filter
const searchMatch = !storeSearch ||
s.name.toLowerCase().includes(storeSearch.toLowerCase()) ||
s.state_code?.toLowerCase().includes(storeSearch.toLowerCase());
return platformMatch && searchMatch;
});
const toggleStore = (store: Store) => {
if (selectedStores.find(s => s.id === store.id)) {
@@ -939,6 +964,10 @@ export default function TasksDashboard() {
const [searchQuery, setSearchQuery] = useState('');
const [showCapacity, setShowCapacity] = useState(true);
// Sorting
const [sortColumn, setSortColumn] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
// Pools for filter dropdown
const [pools, setPools] = useState<TaskPool[]>([]);
@@ -1130,9 +1159,66 @@ export default function TasksDashboard() {
return true;
});
// Sorting
const sortedTasks = [...filteredTasks].sort((a, b) => {
const dir = sortDirection === 'asc' ? 1 : -1;
switch (sortColumn) {
case 'id':
return (a.id - b.id) * dir;
case 'role':
return a.role.localeCompare(b.role) * dir;
case 'store':
return (a.dispensary_name || '').localeCompare(b.dispensary_name || '') * dir;
case 'status':
return a.status.localeCompare(b.status) * dir;
case 'worker':
return (getWorkerName(a)).localeCompare(getWorkerName(b)) * dir;
case 'duration':
const aDur = a.duration_sec ?? 0;
const bDur = b.duration_sec ?? 0;
return (aDur - bDur) * dir;
case 'created_at':
return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * dir;
default:
return 0;
}
});
// Handle column header click for sorting
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('desc');
}
setPage(0); // Reset to first page when sorting
};
// Sortable header component
const SortHeader = ({ column, children }: { column: string; children: React.ReactNode }) => (
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort(column)}
>
<div className="flex items-center gap-1">
{children}
{sortColumn === column ? (
sortDirection === 'asc' ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)
) : (
<ArrowUpDown className="w-3 h-3 text-gray-300" />
)}
</div>
</th>
);
// Pagination
const paginatedTasks = filteredTasks.slice(page * tasksPerPage, (page + 1) * tasksPerPage);
const totalPages = Math.ceil(filteredTasks.length / tasksPerPage);
const paginatedTasks = sortedTasks.slice(page * tasksPerPage, (page + 1) * tasksPerPage);
const totalPages = Math.ceil(sortedTasks.length / tasksPerPage);
const totalActive = (counts?.claimed || 0) + (counts?.running || 0);
const totalPending = counts?.pending || 0;
@@ -1164,6 +1250,27 @@ export default function TasksDashboard() {
</div>
<div className="flex items-center gap-4">
{/* Pool Toggle Button */}
<button
onClick={handleTogglePool}
disabled={poolToggling}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
poolOpen
? 'bg-emerald-50 border-emerald-400 text-emerald-700 hover:bg-emerald-100'
: 'bg-red-50 border-red-400 text-red-700 hover:bg-red-100'
}`}
title={poolOpen ? 'Click to pause pool' : 'Click to open pool'}
>
{poolToggling ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : poolOpen ? (
<PlayCircle className="w-4 h-4" />
) : (
<Square className="w-4 h-4" />
)}
Pool {poolOpen ? 'Open' : 'Paused'}
</button>
{/* Create Task Button */}
<button
onClick={() => setShowCreateModal(true)}
@@ -1448,6 +1555,9 @@ export default function TasksDashboard() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
State
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Pool
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Platform
</th>
@@ -1514,6 +1624,16 @@ export default function TasksDashboard() {
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{schedule.pool_name ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-50 text-purple-700 rounded font-medium">
<Globe className="w-3 h-3" />
{schedule.pool_name}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
schedule.platform === 'jane'
@@ -1679,30 +1799,13 @@ export default function TasksDashboard() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
ID
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Store
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Pool
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Worker
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Duration
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
<SortHeader column="id">ID</SortHeader>
<SortHeader column="role">Role</SortHeader>
<SortHeader column="store">Store</SortHeader>
<SortHeader column="status">Status</SortHeader>
<SortHeader column="worker">Worker</SortHeader>
<SortHeader column="duration">Duration</SortHeader>
<SortHeader column="created_at">Created</SortHeader>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Error
</th>
@@ -1713,7 +1816,7 @@ export default function TasksDashboard() {
<tbody className="divide-y divide-gray-200">
{paginatedTasks.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-gray-500">
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
No tasks found
</td>
</tr>
@@ -1724,18 +1827,29 @@ export default function TasksDashboard() {
<td className="px-4 py-3 text-sm text-gray-900">
{task.role.replace(/_/g, ' ')}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{task.dispensary_name || task.dispensary_id || '-'}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{task.pool_name ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
<Globe className="w-3 h-3" />
{task.pool_name}
</span>
) : (
<span className="text-gray-400">-</span>
)}
<td className="px-4 py-3">
<div className="text-sm text-gray-900">
{task.dispensary_name || task.dispensary_id || '-'}
</div>
<div className="flex items-center gap-2 mt-0.5">
{task.menu_type && (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
task.menu_type === 'dutchie_plus' ? 'bg-emerald-50 text-emerald-700' :
task.menu_type === 'dutchie_iframe' ? 'bg-purple-50 text-purple-700' :
task.menu_type === 'jane' ? 'bg-pink-50 text-pink-700' :
task.menu_type === 'treez' ? 'bg-amber-50 text-amber-700' :
'bg-gray-100 text-gray-600'
}`}>
{task.menu_type.replace(/_/g, ' ')}
</span>
)}
{task.pool_name && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
<Globe className="w-3 h-3" />
{task.pool_name}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<span
@@ -1780,7 +1894,7 @@ export default function TasksDashboard() {
{/* Pagination */}
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
<div className="text-sm text-gray-500">
Showing {page * tasksPerPage + 1} - {Math.min((page + 1) * tasksPerPage, filteredTasks.length)} of {filteredTasks.length} tasks
Showing {page * tasksPerPage + 1} - {Math.min((page + 1) * tasksPerPage, sortedTasks.length)} of {sortedTasks.length} tasks
</div>
<div className="flex items-center gap-2">
<button

View File

@@ -662,17 +662,195 @@ function TaskCountBadge({ worker, tasks }: { worker: Worker; tasks: Task[] }) {
);
}
// Pod visualization - shows pod with task slots radiating out
// Worker Slot component - shows preflight checklist or connection info
function WorkerSlot({
slotIndex,
task,
worker,
poolOpen
}: {
slotIndex: number;
task: ActiveTask | null;
worker: Worker | null;
poolOpen: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const httpIp = worker?.http_ip;
const fingerprint = worker?.fingerprint_data;
const geoState = worker?.current_state || fingerprint?.detectedLocation?.region;
const geoCity = worker?.current_city || fingerprint?.detectedLocation?.city;
const preflightPassed = worker?.is_qualified || worker?.preflight_http_status === 'passed';
const hasGeo = Boolean(geoState);
const isQualified = preflightPassed && hasGeo;
const isDecommissioning = worker?.decommission_requested;
const isBackingOff = worker?.metadata?.is_backing_off;
const maxTasks = worker?.max_concurrent_tasks || 3;
const isOverloaded = (worker?.active_task_count || 0) >= maxTasks;
// Build fingerprint tooltip
const fingerprintTooltip = [
'=== FINGERPRINT ===',
fingerprint?.browser ? `Browser: ${fingerprint.browser}` : 'Browser: Unknown',
fingerprint?.platform ? `Platform: ${fingerprint.platform}` : '',
fingerprint?.timezone ? `Timezone: ${fingerprint.timezone}` : '',
'',
'=== BOT DETECTION ===',
fingerprint?.botDetection ? `Webdriver: ${fingerprint.botDetection.webdriver ? '⚠️ DETECTED' : '✓ Hidden'}` : '',
fingerprint?.botDetection ? `Automation: ${fingerprint.botDetection.automationControlled ? '⚠️ DETECTED' : '✓ Hidden'}` : '',
'',
'=== PRODUCTS ===',
fingerprint?.productsReturned !== undefined ? `Products returned: ${fingerprint.productsReturned}` : '',
].filter(Boolean).join('\n');
// Slot is idle (no task)
if (!task) {
// Show preflight checklist if not yet qualified
if (!isQualified) {
return (
<div
className="relative bg-gray-50 border border-gray-200 rounded-lg p-2 min-w-[140px]"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="text-xs font-semibold text-gray-500 mb-2">Slot {slotIndex + 1}</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
{isOverloaded ? (
<XCircle className="w-3 h-3 text-red-500" />
) : (
<CheckCircle className="w-3 h-3 text-emerald-500" />
)}
<span className={isOverloaded ? 'text-red-600' : 'text-gray-600'}>Overload?</span>
</div>
<div className="flex items-center gap-2 text-xs">
{isDecommissioning ? (
<XCircle className="w-3 h-3 text-orange-500" />
) : (
<CheckCircle className="w-3 h-3 text-emerald-500" />
)}
<span className={isDecommissioning ? 'text-orange-600' : 'text-gray-600'}>Terminating?</span>
</div>
<div className="flex items-center gap-2 text-xs">
{!poolOpen ? (
<Clock className="w-3 h-3 text-yellow-500" />
) : hasGeo ? (
<CheckCircle className="w-3 h-3 text-emerald-500" />
) : (
<Clock className="w-3 h-3 text-yellow-500 animate-pulse" />
)}
<span className={!poolOpen || !hasGeo ? 'text-yellow-600' : 'text-gray-600'}>
Pool Query?
</span>
</div>
</div>
{/* Hover tooltip for fingerprint */}
{isHovered && fingerprint && (
<div className="absolute z-50 left-full ml-2 top-0 bg-gray-900 text-white text-xs rounded-lg p-3 whitespace-pre shadow-lg min-w-[200px]">
{fingerprintTooltip}
</div>
)}
</div>
);
}
// Qualified but idle - show connection info
return (
<div
className="relative bg-emerald-50 border border-emerald-200 rounded-lg p-2 min-w-[140px]"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="text-xs font-semibold text-emerald-600 mb-2 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Slot {slotIndex + 1} - Ready
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-1 text-gray-700">
<MapPin className="w-3 h-3 text-blue-500" />
<span className="font-medium">{geoCity ? `${geoCity}, ${geoState}` : geoState}</span>
</div>
<div className="flex items-center gap-1 text-gray-600">
<Globe className="w-3 h-3 text-purple-500" />
<span className="font-mono text-[10px]">{httpIp || 'No IP'}</span>
</div>
<div className="flex items-center gap-1 text-gray-600">
<VenetianMask className="w-3 h-3 text-emerald-500" />
<span>Antidetect </span>
</div>
</div>
{/* Hover tooltip for fingerprint */}
{isHovered && fingerprint && (
<div className="absolute z-50 left-full ml-2 top-0 bg-gray-900 text-white text-xs rounded-lg p-3 whitespace-pre shadow-lg min-w-[200px]">
{fingerprintTooltip}
</div>
)}
</div>
);
}
// Slot has active task - show task info with connection
return (
<div
className="relative bg-blue-50 border border-blue-200 rounded-lg p-2 min-w-[140px]"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="text-xs font-semibold text-blue-600 mb-2 flex items-center gap-1">
<Activity className="w-3 h-3 animate-pulse" />
Slot {slotIndex + 1} - Working
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-1 text-gray-700">
<MapPin className="w-3 h-3 text-blue-500" />
<span className="font-medium truncate max-w-[100px]">
{task.dispensary?.city && task.dispensary?.state
? `${task.dispensary.city}, ${task.dispensary.state}`
: geoCity && geoState
? `${geoCity}, ${geoState}`
: geoState || 'Unknown'}
</span>
</div>
<div className="flex items-center gap-1 text-gray-600">
<Globe className="w-3 h-3 text-purple-500" />
<span className="font-mono text-[10px]">{httpIp || 'No IP'}</span>
</div>
<div className="flex items-center gap-1 text-gray-600">
<VenetianMask className="w-3 h-3 text-emerald-500" />
<span>Antidetect </span>
</div>
<div className="mt-1 pt-1 border-t border-blue-200">
<div className="text-[10px] text-blue-700 truncate" title={task.dispensary?.name}>
{task.dispensary?.name?.split(' ').slice(0, 3).join(' ') || task.role}
</div>
<div className="text-[10px] text-blue-500">
{formatSecondsToTime(task.running_seconds)}
</div>
</div>
</div>
{/* Hover tooltip for fingerprint */}
{isHovered && fingerprint && (
<div className="absolute z-50 left-full ml-2 top-0 bg-gray-900 text-white text-xs rounded-lg p-3 whitespace-pre shadow-lg min-w-[200px]">
{fingerprintTooltip}
</div>
)}
</div>
);
}
// Pod visualization - shows pod with 3 worker slots
function PodVisualization({
podName,
workers,
isSelected = false,
onSelect
onSelect,
poolOpen = true
}: {
podName: string;
workers: Worker[];
isSelected?: boolean;
onSelect?: () => void;
poolOpen?: boolean;
}) {
// Get the single worker for this pod (1 worker_registry entry per K8s pod)
const worker = workers[0];
@@ -687,22 +865,10 @@ function PodVisualization({
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: ${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',
].filter(Boolean).join('\n');
// Create task slots array (fill with active tasks, pad with empty)
const taskSlots: (ActiveTask | null)[] = [...activeTasks];
while (taskSlots.length < maxSlots) {
@@ -711,88 +877,36 @@ function PodVisualization({
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>
{/* Pod header */}
<div
className={`${podColor} ${podBorder} border-2 rounded-lg px-4 py-2 cursor-pointer hover:scale-105 transition-all ${selectionRing}`}
onClick={onSelect}
title={`Pod: ${worker?.friendly_name || podName}\nClick to select`}
>
<div className="text-white font-bold text-sm text-center">
{worker?.friendly_name || podName}
</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-sm text-center leading-tight z-10 relative cursor-pointer hover:scale-105 transition-all`}
title={podTooltip}
onClick={onSelect}
>
<span className="px-1">{worker?.friendly_name || podName}</span>
</div>
{/* 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 = 50; // Distance from center
const x = Math.cos(radians) * radius;
const y = Math.sin(radians) * radius;
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 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: ${formatSecondsToTime(task.running_seconds)}`
: `Slot ${index + 1}: empty`;
return (
<div key={index}>
{/* Connection line */}
<div
className={`absolute w-0.5 ${isActive ? 'bg-blue-300' : 'bg-gray-200'}`}
style={{
height: `${lineLength}px`,
left: '50%',
top: '50%',
transform: `translate(-50%, -50%) translate(${lineX}px, ${lineY}px) rotate(${angle + 90}deg)`,
transformOrigin: 'center',
}}
/>
{/* Task slot node */}
<div
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={slotTooltip}
>
{isActive && <div className="w-2 h-2 rounded-full bg-white" />}
</div>
</div>
);
})}
</div>
{/* Pod stats */}
<div className="mt-12 text-center">
<p className="text-xs text-gray-500">
<div className="text-white/80 text-xs text-center">
{activeCount}/{maxSlots} busy
</p>
{isSelected && (
<p className="text-xs text-purple-600 font-medium mt-1">Selected</p>
)}
</div>
</div>
{/* Worker slots grid */}
<div className="flex gap-2 mt-3">
{taskSlots.map((task, index) => (
<WorkerSlot
key={index}
slotIndex={index}
task={task}
worker={worker}
poolOpen={poolOpen}
/>
))}
</div>
{isSelected && (
<p className="text-xs text-purple-600 font-medium mt-2">Selected</p>
)}
</div>
);
}
@@ -1323,6 +1437,7 @@ export function WorkersDashboard() {
workers={podWorkers}
isSelected={selectedPod === podName}
onSelect={() => setSelectedPod(selectedPod === podName ? null : podName)}
poolOpen={poolOpen}
/>
))}
</div>