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:
10
backend/migrations/114_schedule_pool_id.sql
Normal file
10
backend/migrations/114_schedule_pool_id.sql
Normal 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';
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user