diff --git a/cannaiq/src/App.tsx b/cannaiq/src/App.tsx index 323fff5f..b7b1c426 100755 --- a/cannaiq/src/App.tsx +++ b/cannaiq/src/App.tsx @@ -47,7 +47,6 @@ import CrossStateCompare from './pages/CrossStateCompare'; import StateDetail from './pages/StateDetail'; import { Discovery } from './pages/Discovery'; import { WorkersDashboard } from './pages/WorkersDashboard'; -import { JobQueue } from './pages/JobQueue'; import TasksDashboard from './pages/TasksDashboard'; import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard'; import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator'; @@ -125,8 +124,6 @@ export default function App() { } /> {/* Workers Dashboard */} } /> - {/* Job Queue Management */} - } /> {/* Task Queue Dashboard */} } /> {/* Scraper Overview Dashboard (new primary) */} diff --git a/cannaiq/src/components/Layout.tsx b/cannaiq/src/components/Layout.tsx index b3e3f63a..468410cf 100755 --- a/cannaiq/src/components/Layout.tsx +++ b/cannaiq/src/components/Layout.tsx @@ -184,8 +184,7 @@ export function Layout({ children }: LayoutProps) { } label="Orchestrator" isActive={isActive('/admin/orchestrator')} /> } label="Users" isActive={isActive('/users')} /> } label="Workers" isActive={isActive('/workers')} /> - } label="Job Queue" isActive={isActive('/job-queue')} /> - } label="Task Queue" isActive={isActive('/tasks')} /> + } label="Tasks" isActive={isActive('/tasks')} /> } label="SEO Pages" isActive={isActive('/admin/seo')} /> } label="Proxies" isActive={isActive('/proxies')} /> } label="API Keys" isActive={isActive('/api-permissions')} /> diff --git a/cannaiq/src/pages/JobQueue.tsx b/cannaiq/src/pages/JobQueue.tsx deleted file mode 100644 index f436ef0c..00000000 --- a/cannaiq/src/pages/JobQueue.tsx +++ /dev/null @@ -1,910 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Layout } from '../components/Layout'; -import { api } from '../lib/api'; -import { - RefreshCw, - XCircle, - Clock, - CheckCircle, - Activity, - ChevronLeft, - ChevronRight, - Users, - Inbox, - Timer, - Plus, - X, - Search, - Calendar, - Trash2, -} from 'lucide-react'; - -// Worker from registry -interface WorkerResources { - memory_mb?: number; - memory_total_mb?: number; - memory_rss_mb?: number; - cpu_user_ms?: number; - cpu_system_ms?: number; -} - -interface Worker { - id: number; - worker_id: string; - friendly_name: string; - role: string; - status: string; - pod_name: string | null; - hostname: string | null; - started_at: string; - last_heartbeat_at: string; - last_task_at: string | null; - tasks_completed: number; - tasks_failed: number; - current_task_id: number | null; - health_status: string; - seconds_since_heartbeat: number; - metadata?: WorkerResources; -} - -// Task from worker_tasks -interface Task { - id: number; - role: string; - dispensary_id: number | null; - dispensary_name?: string; - dispensary_slug?: string; - status: string; - priority: number; - claimed_by: string | null; - claimed_at: string | null; - started_at: string | null; - completed_at: string | null; - error: string | null; - error_message: string | null; - retry_count: number; - max_retries: number; - result: any; - created_at: string; - updated_at: string; -} - -interface TaskCounts { - pending: number; - running: number; - completed: number; - failed: number; - total: number; -} - -interface Store { - id: number; - name: string; - state_code: string; - crawl_enabled: boolean; -} - -interface CreateTaskModalProps { - isOpen: boolean; - onClose: () => void; - onTaskCreated: () => void; -} - -const ROLES = [ - { id: 'product_refresh', name: 'Product Resync', description: 'Re-crawl products for price/stock changes' }, - { id: 'product_discovery', name: 'Product Discovery', description: 'Initial crawl for new dispensaries' }, - { id: 'store_discovery', name: 'Store Discovery', description: 'Discover new dispensary locations' }, - { id: 'entry_point_discovery', name: 'Entry Point Discovery', description: 'Resolve platform IDs from menu URLs' }, - { id: 'analytics_refresh', name: 'Analytics Refresh', description: 'Refresh materialized views' }, -]; - -function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProps) { - const [role, setRole] = useState('product_refresh'); - const [priority, setPriority] = useState(10); - const [scheduleType, setScheduleType] = useState<'now' | 'scheduled'>('now'); - const [scheduledFor, setScheduledFor] = useState(''); - const [stores, setStores] = useState([]); - const [storeSearch, setStoreSearch] = useState(''); - const [selectedStores, setSelectedStores] = useState([]); - const [loading, setLoading] = useState(false); - const [storesLoading, setStoresLoading] = useState(false); - const [error, setError] = useState(null); - - // Fetch stores when modal opens - useEffect(() => { - if (isOpen) { - fetchStores(); - } - }, [isOpen]); - - const fetchStores = async () => { - setStoresLoading(true); - try { - const res = await api.get('/api/stores?limit=500'); - setStores(res.data.stores || res.data || []); - } catch (err) { - console.error('Failed to fetch stores:', err); - } finally { - setStoresLoading(false); - } - }; - - const filteredStores = stores.filter(s => - s.name.toLowerCase().includes(storeSearch.toLowerCase()) || - s.state_code?.toLowerCase().includes(storeSearch.toLowerCase()) - ); - - const toggleStore = (store: Store) => { - if (selectedStores.find(s => s.id === store.id)) { - setSelectedStores(selectedStores.filter(s => s.id !== store.id)); - } else { - setSelectedStores([...selectedStores, store]); - } - }; - - const selectAll = () => { - setSelectedStores(filteredStores); - }; - - const clearAll = () => { - setSelectedStores([]); - }; - - const handleSubmit = async () => { - setLoading(true); - setError(null); - - try { - const scheduledDate = scheduleType === 'scheduled' && scheduledFor - ? new Date(scheduledFor).toISOString() - : undefined; - - // For store_discovery and analytics_refresh, no store is needed - if (role === 'store_discovery' || role === 'analytics_refresh') { - await api.post('/api/tasks', { - role, - priority, - scheduled_for: scheduledDate, - platform: 'dutchie', - }); - } else if (selectedStores.length === 0) { - setError('Please select at least one store'); - setLoading(false); - return; - } else { - // Create tasks for each selected store - for (const store of selectedStores) { - await api.post('/api/tasks', { - role, - dispensary_id: store.id, - priority, - scheduled_for: scheduledDate, - platform: 'dutchie', - }); - } - } - - onTaskCreated(); - onClose(); - // Reset form - setSelectedStores([]); - setPriority(10); - setScheduleType('now'); - setScheduledFor(''); - } catch (err: any) { - setError(err.response?.data?.error || err.message || 'Failed to create task'); - } finally { - setLoading(false); - } - }; - - if (!isOpen) return null; - - const needsStore = role !== 'store_discovery' && role !== 'analytics_refresh'; - - return ( -
-
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-

Create New Task

- -
- - {/* Body */} -
- {error && ( -
- {error} -
- )} - - {/* Role Selection */} -
- -
- {ROLES.map(r => ( - - ))} -
-
- - {/* Store Selection (for roles that need it) */} - {needsStore && ( -
- -
- {/* Search */} -
-
- - setStoreSearch(e.target.value)} - placeholder="Search stores..." - className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded" - /> -
-
- - | - -
-
- - {/* Store List */} -
- {storesLoading ? ( -
- - Loading stores... -
- ) : filteredStores.length === 0 ? ( -
No stores found
- ) : ( - filteredStores.map(store => ( - - )) - )} -
-
-
- )} - - {/* Priority */} -
- - setPriority(parseInt(e.target.value))} - className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" - /> -
- 0 (Low - Batch) - 10 (Normal) - 50 (High) - 100 (Urgent) -
-
- - {/* Schedule */} -
- -
- - -
- - {scheduleType === 'scheduled' && ( -
-
- - setScheduledFor(e.target.value)} - className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded" - /> -
-
- )} -
-
- - {/* Footer */} -
-
- {needsStore ? ( - selectedStores.length > 0 ? ( - `Will create ${selectedStores.length} task${selectedStores.length > 1 ? 's' : ''}` - ) : ( - 'Select stores to create tasks' - ) - ) : ( - 'Will create 1 task' - )} -
-
- - -
-
-
-
-
- ); -} - -function formatRelativeTime(dateStr: string | null): string { - if (!dateStr) return '-'; - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSecs = Math.round(diffMs / 1000); - const diffMins = Math.round(diffMs / 60000); - - if (diffSecs < 60) return `${diffSecs}s ago`; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffMins < 1440) return `${Math.round(diffMins / 60)}h ago`; - return `${Math.round(diffMins / 1440)}d ago`; -} - -function formatDuration(startStr: string | null, endStr: string | null): string { - if (!startStr) return '-'; - const start = new Date(startStr); - const end = endStr ? new Date(endStr) : new Date(); - const diffMs = end.getTime() - start.getTime(); - - if (diffMs < 1000) return `${diffMs}ms`; - if (diffMs < 60000) return `${(diffMs / 1000).toFixed(1)}s`; - const mins = Math.floor(diffMs / 60000); - const secs = Math.floor((diffMs % 60000) / 1000); - if (mins < 60) return `${mins}m ${secs}s`; - const hrs = Math.floor(mins / 60); - return `${hrs}h ${mins % 60}m`; -} - -// Live timer component for running tasks -function LiveTimer({ startedAt, isRunning }: { startedAt: string | null; isRunning: boolean }) { - const [, setTick] = useState(0); - - useEffect(() => { - if (!isRunning || !startedAt) return; - const interval = setInterval(() => setTick(t => t + 1), 1000); - return () => clearInterval(interval); - }, [isRunning, startedAt]); - - if (!startedAt) return -; - - const duration = formatDuration(startedAt, null); - - if (isRunning) { - return ( - - - {duration} - - ); - } - - return {duration}; -} - -function WorkerStatusBadge({ status, healthStatus }: { status: string; healthStatus: string }) { - const getColors = () => { - if (healthStatus === 'offline' || status === 'offline') return 'bg-gray-100 text-gray-600'; - if (healthStatus === 'stale') return 'bg-yellow-100 text-yellow-700'; - if (healthStatus === 'busy' || status === 'active') return 'bg-blue-100 text-blue-700'; - if (healthStatus === 'ready' || status === 'idle') return 'bg-green-100 text-green-700'; - return 'bg-gray-100 text-gray-600'; - }; - - return ( - - {healthStatus || status} - - ); -} - -function TaskStatusBadge({ status, error, retryCount }: { status: string; error?: string | null; retryCount?: number }) { - const config: Record = { - pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: Clock }, - running: { bg: 'bg-blue-100', text: 'text-blue-700', icon: Activity }, - completed: { bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircle }, - failed: { bg: 'bg-red-100', text: 'text-red-700', icon: XCircle }, - }; - - const cfg = config[status] || { bg: 'bg-gray-100', text: 'text-gray-700', icon: Clock }; - const Icon = cfg.icon; - - // Build tooltip text - let tooltip = ''; - if (error) { - tooltip = error; - } - if (retryCount && retryCount > 0) { - tooltip = `Attempt ${retryCount + 1}${error ? `: ${error}` : ''}`; - } - - return ( - - - {status} - {retryCount && retryCount > 0 && status !== 'failed' && ( - ({retryCount}) - )} - - ); -} - -function RoleBadge({ role }: { role: string }) { - const colors: Record = { - product_refresh: 'bg-emerald-100 text-emerald-700', - product_discovery: 'bg-blue-100 text-blue-700', - store_discovery: 'bg-purple-100 text-purple-700', - entry_point_discovery: 'bg-orange-100 text-orange-700', - analytics_refresh: 'bg-pink-100 text-pink-700', - }; - - return ( - - {role.replace(/_/g, ' ')} - - ); -} - -function PriorityBadge({ priority }: { priority: number }) { - let bg = 'bg-gray-100 text-gray-700'; - if (priority >= 80) bg = 'bg-red-100 text-red-700'; - else if (priority >= 50) bg = 'bg-orange-100 text-orange-700'; - else if (priority >= 20) bg = 'bg-yellow-100 text-yellow-700'; - - return ( - - P{priority} - - ); -} - -export function JobQueue() { - const [workers, setWorkers] = useState([]); - const [tasks, setTasks] = useState([]); - const [counts, setCounts] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - - // Pagination - const [taskPage, setTaskPage] = useState(0); - const tasksPerPage = 25; - - // Cleanup stale workers (called once on page load) - const cleanupStaleWorkers = useCallback(async () => { - try { - await api.post('/api/worker-registry/cleanup', { stale_threshold_minutes: 2 }); - } catch (err: any) { - console.error('Failed to cleanup stale workers:', err); - } - }, []); - - // Fetch workers - const fetchWorkers = useCallback(async () => { - try { - const workersRes = await api.get('/api/worker-registry/workers'); - setWorkers(workersRes.data.workers || []); - } catch (err: any) { - console.error('Failed to fetch workers:', err); - } - }, []); - - // Fetch tasks and counts (auto-refresh every 15s) - const fetchTasks = useCallback(async () => { - try { - const taskUrl = `/api/tasks?limit=${tasksPerPage}&offset=${taskPage * tasksPerPage}`; - - const [tasksRes, countsRes] = await Promise.all([ - api.get(taskUrl), - api.get('/api/tasks/counts'), - ]); - - setTasks(tasksRes.data.tasks || []); - setCounts(countsRes.data); - setError(null); - } catch (err: any) { - console.error('Fetch error:', err); - setError(err.message || 'Failed to fetch data'); - } finally { - setLoading(false); - } - }, [taskPage]); - - // Initial load - cleanup stale workers first, then fetch - useEffect(() => { - cleanupStaleWorkers().then(() => { - fetchWorkers(); - fetchTasks(); - }); - }, [cleanupStaleWorkers, fetchWorkers, fetchTasks]); - - // Auto-refresh tasks every 15 seconds - useEffect(() => { - const interval = setInterval(fetchTasks, 15000); - return () => clearInterval(interval); - }, [fetchTasks]); - - // Refresh workers every 60 seconds - useEffect(() => { - const interval = setInterval(fetchWorkers, 60000); - return () => clearInterval(interval); - }, [fetchWorkers]); - - // Delete a task - const handleDeleteTask = async (taskId: number) => { - if (!confirm('Delete this task?')) return; - try { - await api.delete(`/api/tasks/${taskId}`); - fetchTasks(); - } catch (err: any) { - console.error('Delete error:', err); - alert(err.response?.data?.error || 'Failed to delete task'); - } - }; - - // Get active workers (for display) - const activeWorkers = workers.filter(w => w.status !== 'offline' && w.status !== 'terminated'); - - if (loading) { - return ( - -
- -
-
- ); - } - - return ( - -
- {/* Header */} -
-
-

Task Queue

-

- Workers pull tasks from the pool by priority (auto-refresh every 15s) -

-
- -
- - {/* Create Task Modal */} - setShowCreateModal(false)} - onTaskCreated={fetchTasks} - /> - - {error && ( -
-

{error}

-
- )} - - {/* Stats Cards */} - {counts && ( -
-
-
-
- -
-
-

Active Workers

-

{activeWorkers.length}

-
-
-
-
-
-
- -
-
-

Pending Tasks

-

{counts.pending}

-
-
-
-
-
-
- -
-
-

Running

-

{counts.running}

-
-
-
-
-
-
- -
-
-

Completed

-

{counts.completed}

-
-
-
-
-
-
- -
-
-

Failed

-

{counts.failed}

-
-
-
-
- )} - - {/* Task Pool Section */} -
-
-
-
-

- - Task Pool -

-

- Tasks waiting to be picked up by workers -

-
- -
-
- - - - - - - - - - - - - - - - {tasks.length === 0 ? ( - - - - ) : ( - tasks.map((task) => { - // Find worker assigned to this task - const assignedWorker = task.claimed_by - ? workers.find(w => w.worker_id === task.claimed_by) - : null; - - return ( - - - - - - - - - - - ); - }) - )} - -
PriorityRoleDispensaryStatusAssigned ToCreatedDuration
- -

No tasks found

-
- - - - - {task.dispensary_slug ? ( - - {task.dispensary_slug.length > 25 - ? task.dispensary_slug.slice(0, 25) + '…' - : task.dispensary_slug} - - ) : task.dispensary_name ? ( - - {task.dispensary_name.length > 25 - ? task.dispensary_name.slice(0, 25) + '…' - : task.dispensary_name} - - ) : task.dispensary_id ? ( - ID: {task.dispensary_id} - ) : ( - - - )} - - - - {assignedWorker ? ( - - - {assignedWorker.friendly_name} - - ) : task.claimed_by ? ( - {task.claimed_by.slice(0, 12)}... - ) : ( - Unassigned - )} - - {formatRelativeTime(task.created_at)} - - {task.status === 'running' ? ( - - ) : task.started_at ? ( - formatDuration(task.started_at, task.completed_at) - ) : ( - '-' - )} - - {(task.status === 'failed' || task.status === 'completed' || task.status === 'pending') && ( - - )} -
- - {/* Pagination */} -
-
- Showing {taskPage * tasksPerPage + 1} - {Math.min((taskPage + 1) * tasksPerPage, taskPage * tasksPerPage + tasks.length)} tasks -
-
- - Page {taskPage + 1} - -
-
-
-
-
- ); -} - -export default JobQueue; diff --git a/cannaiq/src/pages/TasksDashboard.tsx b/cannaiq/src/pages/TasksDashboard.tsx index 2c8dbbbb..c472f571 100644 --- a/cannaiq/src/pages/TasksDashboard.tsx +++ b/cannaiq/src/pages/TasksDashboard.tsx @@ -12,10 +12,16 @@ import { Search, ChevronDown, ChevronUp, + ChevronLeft, + ChevronRight, Gauge, Users, Play, Square, + Plus, + X, + Calendar, + Trash2, } from 'lucide-react'; interface Task { @@ -65,6 +71,313 @@ interface TaskCounts { stale: number; } +interface Store { + id: number; + name: string; + state_code: string; + crawl_enabled: boolean; +} + +interface CreateTaskModalProps { + isOpen: boolean; + onClose: () => void; + onTaskCreated: () => void; +} + +const TASK_ROLES = [ + { id: 'product_refresh', name: 'Product Resync', description: 'Re-crawl products for price/stock changes' }, + { id: 'product_discovery', name: 'Product Discovery', description: 'Initial crawl for new dispensaries' }, + { id: 'store_discovery', name: 'Store Discovery', description: 'Discover new dispensary locations' }, + { id: 'entry_point_discovery', name: 'Entry Point Discovery', description: 'Resolve platform IDs from menu URLs' }, + { id: 'analytics_refresh', name: 'Analytics Refresh', description: 'Refresh materialized views' }, +]; + +function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProps) { + const [role, setRole] = useState('product_refresh'); + const [priority, setPriority] = useState(10); + const [scheduleType, setScheduleType] = useState<'now' | 'scheduled'>('now'); + const [scheduledFor, setScheduledFor] = useState(''); + const [stores, setStores] = useState([]); + const [storeSearch, setStoreSearch] = useState(''); + const [selectedStores, setSelectedStores] = useState([]); + const [loading, setLoading] = useState(false); + const [storesLoading, setStoresLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + fetchStores(); + } + }, [isOpen]); + + const fetchStores = async () => { + setStoresLoading(true); + try { + const res = await api.get('/api/stores?limit=500'); + setStores(res.data.stores || res.data || []); + } catch (err) { + console.error('Failed to fetch stores:', err); + } finally { + setStoresLoading(false); + } + }; + + const filteredStores = stores.filter(s => + s.name.toLowerCase().includes(storeSearch.toLowerCase()) || + s.state_code?.toLowerCase().includes(storeSearch.toLowerCase()) + ); + + const toggleStore = (store: Store) => { + if (selectedStores.find(s => s.id === store.id)) { + setSelectedStores(selectedStores.filter(s => s.id !== store.id)); + } else { + setSelectedStores([...selectedStores, store]); + } + }; + + const selectAll = () => setSelectedStores(filteredStores); + const clearAll = () => setSelectedStores([]); + + const handleSubmit = async () => { + setLoading(true); + setError(null); + + try { + const scheduledDate = scheduleType === 'scheduled' && scheduledFor + ? new Date(scheduledFor).toISOString() + : undefined; + + if (role === 'store_discovery' || role === 'analytics_refresh') { + await api.post('/api/tasks', { + role, + priority, + scheduled_for: scheduledDate, + platform: 'dutchie', + }); + } else if (selectedStores.length === 0) { + setError('Please select at least one store'); + setLoading(false); + return; + } else { + for (const store of selectedStores) { + await api.post('/api/tasks', { + role, + dispensary_id: store.id, + priority, + scheduled_for: scheduledDate, + platform: 'dutchie', + }); + } + } + + onTaskCreated(); + onClose(); + setSelectedStores([]); + setPriority(10); + setScheduleType('now'); + setScheduledFor(''); + } catch (err: any) { + setError(err.response?.data?.error || err.message || 'Failed to create task'); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + const needsStore = role !== 'store_discovery' && role !== 'analytics_refresh'; + + return ( +
+
+
+
+
+

Create New Task

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+ {TASK_ROLES.map(r => ( + + ))} +
+
+ + {needsStore && ( +
+ +
+
+
+ + setStoreSearch(e.target.value)} + placeholder="Search stores..." + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded" + /> +
+
+ + | + +
+
+
+ {storesLoading ? ( +
+ + Loading stores... +
+ ) : filteredStores.length === 0 ? ( +
No stores found
+ ) : ( + filteredStores.map(store => ( + + )) + )} +
+
+
+ )} + +
+ + setPriority(parseInt(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + /> +
+ 0 (Low) + 10 (Normal) + 50 (High) + 100 (Urgent) +
+
+ +
+ +
+ + +
+ {scheduleType === 'scheduled' && ( +
+ + setScheduledFor(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded" + /> +
+ )} +
+
+ +
+
+ {needsStore ? ( + selectedStores.length > 0 ? `Will create ${selectedStores.length} task${selectedStores.length > 1 ? 's' : ''}` : 'Select stores to create tasks' + ) : 'Will create 1 task'} +
+
+ + +
+
+
+
+
+ ); +} + const ROLES = [ 'store_discovery', 'entry_point_discovery', @@ -139,6 +452,11 @@ export default function TasksDashboard() { const [error, setError] = useState(null); const [poolPaused, setPoolPaused] = useState(false); const [poolLoading, setPoolLoading] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + + // Pagination + const [page, setPage] = useState(0); + const tasksPerPage = 25; // Filters const [roleFilter, setRoleFilter] = useState(''); @@ -189,6 +507,17 @@ export default function TasksDashboard() { } }; + const handleDeleteTask = async (taskId: number) => { + if (!confirm('Delete this task?')) return; + try { + await api.delete(`/api/tasks/${taskId}`); + fetchData(); + } catch (err: any) { + console.error('Delete error:', err); + alert(err.response?.data?.error || 'Failed to delete task'); + } + }; + useEffect(() => { fetchData(); const interval = setInterval(fetchData, 15000); // Auto-refresh every 15 seconds @@ -208,6 +537,10 @@ export default function TasksDashboard() { return true; }); + // Pagination + const paginatedTasks = filteredTasks.slice(page * tasksPerPage, (page + 1) * tasksPerPage); + const totalPages = Math.ceil(filteredTasks.length / tasksPerPage); + const totalActive = (counts?.claimed || 0) + (counts?.running || 0); const totalPending = counts?.pending || 0; @@ -238,29 +571,37 @@ export default function TasksDashboard() {
+ {/* Create Task Button */} + {/* Pool Toggle */} - Auto-refreshes every 15s + onClick={togglePool} + disabled={poolLoading} + className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${ + poolPaused + ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' + : 'bg-red-100 text-red-700 hover:bg-red-200' + }`} + > + {poolPaused ? ( + <> + + Resume Pool + + ) : ( + <> + + Pause Pool + + )} + + Auto-refreshes every 15s
@@ -269,6 +610,13 @@ export default function TasksDashboard() {
{error}
)} + {/* Create Task Modal */} + setShowCreateModal(false)} + onTaskCreated={fetchData} + /> + {/* Status Summary Cards */}
{Object.entries(counts || {}).map(([status, count]) => ( @@ -471,17 +819,19 @@ export default function TasksDashboard() { Error + + - {filteredTasks.length === 0 ? ( + {paginatedTasks.length === 0 ? ( - + No tasks found ) : ( - filteredTasks.map((task) => ( + paginatedTasks.map((task) => ( #{task.id} @@ -512,12 +862,47 @@ export default function TasksDashboard() { {task.error_message || '-'} + + {(task.status === 'failed' || task.status === 'completed' || task.status === 'pending') && ( + + )} + )) )}
+ + {/* Pagination */} +
+
+ Showing {page * tasksPerPage + 1} - {Math.min((page + 1) * tasksPerPage, filteredTasks.length)} of {filteredTasks.length} tasks +
+
+ + Page {page + 1} of {totalPages || 1} + +
+