'use client'; import React, { useState, useMemo, useEffect, useCallback } from 'react'; import Link from 'next/link'; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, ColumnDef, flexRender, SortingState, ColumnFiltersState, } from '@tanstack/react-table'; import { JobStatus } from './ScraperTest'; interface LogEntry { timestamp: string; level: string; message: string; source: string; } interface JobLogs { job_id: string; status: string; error_message: string | null; logs: LogEntry[]; log_count: number; } interface JobsViewProps { jobs: JobStatus[]; onSelectJob: (job: JobStatus, previousJob?: JobStatus) => void; isLoadingJob: string | null; onRefresh?: () => void; } // Helper to format duration function formatDuration(seconds: number): string { if (seconds < 60) return `${seconds.toFixed(1)}s`; const mins = Math.floor(seconds / 60); const secs = Math.round(seconds % 60); return `${mins}m ${secs}s`; } // Helper to calculate speed (reviews/second) function calculateSpeed(reviewCount: number | null, scrapeTime: number | null): number | null { if (!reviewCount || !scrapeTime || scrapeTime === 0) return null; return reviewCount / scrapeTime; } // Helper to extract business name from URL function extractBusinessName(job: JobStatus): string { if (job.business_name) return job.business_name; try { const urlObj = new URL(job.url); const query = urlObj.searchParams.get('query'); return query ? decodeURIComponent(query) : 'Unknown Business'; } catch { return 'Unknown Business'; } } export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }: JobsViewProps) { const [sorting, setSorting] = useState([{ id: 'created_at', desc: true }]); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [selectedJobLogs, setSelectedJobLogs] = useState(null); const [loadingLogs, setLoadingLogs] = useState(null); const [expandedErrors, setExpandedErrors] = useState>(new Set()); // Live monitoring state const [monitoredJob, setMonitoredJob] = useState(null); const [monitoredJobLogs, setMonitoredJobLogs] = useState([]); const [isMonitoring, setIsMonitoring] = useState(false); // Delete state const [deleteConfirm, setDeleteConfirm] = useState(null); const [isDeleting, setIsDeleting] = useState(null); const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState<'all' | 'completed' | 'failed' | null>(null); const [isBulkDeleting, setIsBulkDeleting] = useState(false); // Real-time updates for running jobs in the table const [runningJobUpdates, setRunningJobUpdates] = useState>(new Map()); // SSE connection for real-time job updates useEffect(() => { const hasRunningJobs = jobs.some(j => j.status === 'running'); // Only connect to SSE if there are running jobs if (!hasRunningJobs) { setRunningJobUpdates(new Map()); return; } let eventSource: EventSource | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; const connect = () => { eventSource = new EventSource('/api/jobs/stream'); eventSource.onopen = () => { console.log('SSE connected for job updates'); }; eventSource.addEventListener('job_progress', (event) => { try { const data = JSON.parse(event.data); setRunningJobUpdates(prev => { const updated = new Map(prev); updated.set(data.job_id, { job_id: data.job_id, status: 'running', reviews_count: data.reviews_count, total_reviews: data.total_reviews, scrape_time: data.scrape_time, } as JobStatus); return updated; }); } catch (err) { console.error('Failed to parse job_progress event:', err); } }); eventSource.addEventListener('job_completed', (event) => { try { const data = JSON.parse(event.data); // Remove from running updates and trigger refresh setRunningJobUpdates(prev => { const updated = new Map(prev); updated.delete(data.job_id); return updated; }); // Refresh the jobs list to get final state onRefresh?.(); } catch (err) { console.error('Failed to parse job_completed event:', err); } }); eventSource.addEventListener('job_failed', (event) => { try { const data = JSON.parse(event.data); setRunningJobUpdates(prev => { const updated = new Map(prev); updated.delete(data.job_id); return updated; }); onRefresh?.(); } catch (err) { console.error('Failed to parse job_failed event:', err); } }); eventSource.addEventListener('job_partial', (event) => { try { const data = JSON.parse(event.data); setRunningJobUpdates(prev => { const updated = new Map(prev); updated.delete(data.job_id); return updated; }); onRefresh?.(); } catch (err) { console.error('Failed to parse job_partial event:', err); } }); eventSource.onerror = () => { console.log('SSE connection error, reconnecting...'); eventSource?.close(); // Reconnect after 3 seconds reconnectTimeout = setTimeout(connect, 3000); }; }; connect(); return () => { eventSource?.close(); if (reconnectTimeout) clearTimeout(reconnectTimeout); }; }, [jobs, onRefresh]); // Merge jobs with real-time updates const jobsWithUpdates = useMemo(() => { return jobs.map(job => { const update = runningJobUpdates.get(job.job_id); if (update && job.status === 'running') { return { ...job, ...update }; } return job; }); }, [jobs, runningJobUpdates]); const fetchJobLogs = async (jobId: string) => { setLoadingLogs(jobId); try { const response = await fetch(`/api/jobs/${jobId}/logs`); if (response.ok) { const data = await response.json(); setSelectedJobLogs(data); } } catch (err) { console.error('Failed to fetch logs:', err); } finally { setLoadingLogs(null); } }; // Generate crash report for a job const [copyingCrashReport, setCopyingCrashReport] = useState(null); const generateCrashReport = async (job: JobStatus): Promise => { // Fetch logs for the job let logs: LogEntry[] = []; let logCount = 0; try { const response = await fetch(`/api/jobs/${job.job_id}/logs`); if (response.ok) { const data = await response.json(); logs = data.logs || []; logCount = data.log_count || 0; } } catch (err) { console.error('Failed to fetch logs for crash report:', err); } const businessName = extractBusinessName(job); const now = new Date().toISOString(); // Format logs (last 50 entries) const recentLogs = [...logs] .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) .slice(0, 50) .reverse(); const logsFormatted = recentLogs.length > 0 ? recentLogs.map(log => `[${new Date(log.timestamp).toISOString()}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}` ).join('\n') : 'No logs available'; // Calculate duration const duration = job.scrape_time ? formatDuration(job.scrape_time) : job.started_at ? formatDuration((Date.now() - new Date(job.started_at).getTime()) / 1000) : 'Unknown'; // Build the crash report const report = `## Crash Report: ${job.job_id} **Generated**: ${now} **Status**: ${job.status.toUpperCase()} **Job Type**: google-reviews ### Business Info - **Name**: ${businessName} - **Address**: ${job.business_address || 'N/A'} - **Category**: ${job.business_category || 'N/A'} - **URL**: ${job.url} ### Job Timeline - **Created**: ${job.created_at} - **Started**: ${job.started_at || 'N/A'} - **Completed**: ${job.completed_at || 'N/A'} - **Last Update**: ${job.updated_at || 'N/A'} - **Duration**: ${duration} ### Progress at Failure - **Reviews Collected**: ${job.reviews_count ?? 0}${job.total_reviews ? ` / ${job.total_reviews}` : ''} - **Expected Total**: ${job.total_reviews_snapshot ?? 'Unknown'} - **Rating Snapshot**: ${job.rating_snapshot ?? 'N/A'} ### Error \`\`\` ${job.error_message || 'No error message captured'} \`\`\` ### Logs (${logCount} total, showing last ${recentLogs.length}) \`\`\` ${logsFormatted} \`\`\` ### Context for Debugging - This is a Google Reviews scraper job - The scraper uses Playwright to navigate Google Maps - Reviews are extracted by scrolling through the reviews panel - Common failure points: rate limiting, DOM structure changes, network timeouts ### Suggested Investigation 1. Check if error is related to rate limiting (look for 429 or "too many requests") 2. Check if DOM selectors have changed (look for "element not found" errors) 3. Check network/timeout issues (look for "timeout" or "navigation" errors) 4. Review the last few log entries before the error for context `; return report; }; const copyCrashReport = async (job: JobStatus) => { setCopyingCrashReport(job.job_id); try { const report = await generateCrashReport(job); await navigator.clipboard.writeText(report); // Brief visual feedback setTimeout(() => setCopyingCrashReport(null), 1500); } catch (err) { console.error('Failed to copy crash report:', err); setCopyingCrashReport(null); } }; // Live monitoring functions const startMonitoring = useCallback((job: JobStatus) => { setMonitoredJob(job); setMonitoredJobLogs([]); setIsMonitoring(true); }, []); const stopMonitoring = useCallback(() => { setIsMonitoring(false); setMonitoredJob(null); setMonitoredJobLogs([]); }, []); // Delete a single job const deleteJob = useCallback(async (jobId: string) => { setIsDeleting(jobId); try { const response = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' }); if (response.ok) { setDeleteConfirm(null); onRefresh?.(); } else { console.error('Failed to delete job'); } } catch (err) { console.error('Error deleting job:', err); } finally { setIsDeleting(null); } }, [onRefresh]); // Bulk delete jobs by status const bulkDeleteJobs = useCallback(async (status: 'all' | 'completed' | 'failed') => { setIsBulkDeleting(true); try { const jobsToDelete = status === 'all' ? jobs : jobs.filter(j => j.status === status); await Promise.all( jobsToDelete.map(job => fetch(`/api/jobs/${job.job_id}`, { method: 'DELETE' }) ) ); setBulkDeleteConfirm(null); onRefresh?.(); } catch (err) { console.error('Error bulk deleting jobs:', err); } finally { setIsBulkDeleting(false); } }, [jobs, onRefresh]); // SSE connection for live monitoring useEffect(() => { if (!isMonitoring || !monitoredJob) return; let eventSource: EventSource | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; const connect = () => { eventSource = new EventSource(`/api/jobs/${monitoredJob.job_id}/stream`); eventSource.onopen = () => { console.log('SSE connected for live monitoring'); }; eventSource.addEventListener('initial_state', (event) => { try { const data = JSON.parse(event.data); setMonitoredJob(prev => prev ? { ...prev, ...data } : prev); if (data.logs) { setMonitoredJobLogs(data.logs); } } catch (err) { console.error('Failed to parse initial_state event:', err); } }); eventSource.addEventListener('job_progress', (event) => { try { const data = JSON.parse(event.data); setMonitoredJob(prev => prev ? { ...prev, status: 'running', reviews_count: data.reviews_count, total_reviews: data.total_reviews, scrape_time: data.scrape_time, } : prev); if (data.logs) { setMonitoredJobLogs(data.logs); } } catch (err) { console.error('Failed to parse job_progress event:', err); } }); eventSource.addEventListener('job_completed', (event) => { try { const data = JSON.parse(event.data); setMonitoredJob(prev => prev ? { ...prev, status: 'completed', reviews_count: data.reviews_count, total_reviews: data.total_reviews, scrape_time: data.scrape_time, } : prev); if (data.logs) { setMonitoredJobLogs(data.logs); } // Stop monitoring but keep modal open setIsMonitoring(false); eventSource?.close(); } catch (err) { console.error('Failed to parse job_completed event:', err); } }); eventSource.addEventListener('job_failed', (event) => { try { const data = JSON.parse(event.data); setMonitoredJob(prev => prev ? { ...prev, status: 'failed', error_message: data.error, } : prev); if (data.logs) { setMonitoredJobLogs(data.logs); } setIsMonitoring(false); eventSource?.close(); } catch (err) { console.error('Failed to parse job_failed event:', err); } }); eventSource.addEventListener('job_update', (event) => { try { const data = JSON.parse(event.data); setMonitoredJob(prev => prev ? { ...prev, ...data } : prev); if (data.logs) { setMonitoredJobLogs(data.logs); } // Check if job is no longer running if (data.status && data.status !== 'running') { setIsMonitoring(false); eventSource?.close(); } } catch (err) { console.error('Failed to parse job_update event:', err); } }); eventSource.onerror = () => { console.log('SSE connection error for live monitor, reconnecting...'); eventSource?.close(); // Reconnect after 2 seconds reconnectTimeout = setTimeout(connect, 2000); }; }; connect(); return () => { eventSource?.close(); if (reconnectTimeout) clearTimeout(reconnectTimeout); }; }, [isMonitoring, monitoredJob?.job_id]); // Calculate summary stats const stats = useMemo(() => { const completed = jobsWithUpdates.filter(j => j.status === 'completed'); const failed = jobsWithUpdates.filter(j => j.status === 'failed'); const running = jobsWithUpdates.filter(j => j.status === 'running'); const totalReviews = completed.reduce((sum, j) => sum + (j.reviews_count || 0), 0); const totalTime = completed.reduce((sum, j) => sum + (j.scrape_time || 0), 0); const avgTime = completed.length > 0 ? totalTime / completed.length : 0; const successRate = jobsWithUpdates.length > 0 ? (completed.length / jobsWithUpdates.length) * 100 : 0; // Jobs today const today = new Date(); today.setHours(0, 0, 0, 0); const jobsToday = jobsWithUpdates.filter(j => new Date(j.created_at) >= today).length; // Average speed const speeds = completed .map(j => calculateSpeed(j.reviews_count, j.scrape_time)) .filter((s): s is number => s !== null); const avgSpeed = speeds.length > 0 ? speeds.reduce((a, b) => a + b, 0) / speeds.length : 0; return { total: jobsWithUpdates.length, completed: completed.length, failed: failed.length, running: running.length, totalReviews, avgTime, successRate, jobsToday, avgSpeed, }; }, [jobsWithUpdates]); // Filter jobs by status const filteredJobs = useMemo(() => { if (statusFilter === 'all') return jobsWithUpdates; return jobsWithUpdates.filter(j => j.status === statusFilter); }, [jobsWithUpdates, statusFilter]); // Find previous job for comparison const findPreviousJob = (job: JobStatus): JobStatus | undefined => { const sameBusinessJobs = jobsWithUpdates .filter(j => { const jobBusiness = extractBusinessName(j); const currentBusiness = extractBusinessName(job); return jobBusiness === currentBusiness && j.job_id !== job.job_id; }) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); const jobDate = new Date(job.created_at); return sameBusinessJobs.find(j => new Date(j.created_at) < jobDate && j.status === 'completed' && j.reviews_count ); }; // Table columns const columns = useMemo[]>( () => [ { accessorKey: 'business', header: ({ column }) => ( ), accessorFn: (row) => extractBusinessName(row), cell: ({ row }) => { const name = extractBusinessName(row.original); const address = row.original.business_address; return (
{name}
{address && (
{address}
)}
); }, }, { id: 'url', header: 'URL', cell: ({ row }) => { const url = row.original.url; return ( e.stopPropagation()} className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-medium rounded-lg transition-colors" title={url} > Open ); }, }, { accessorKey: 'status', header: ({ column }) => ( ), cell: ({ row }) => { const status = row.original.status; const isStuck = status === 'running' && new Date().getTime() - new Date(row.original.created_at).getTime() > 10 * 60 * 1000; return (
{status === 'running' && !isStuck && (
)} {status === 'partial' && ( )} {isStuck && ( )} {isStuck ? 'Stuck' : status.charAt(0).toUpperCase() + status.slice(1)}
); }, }, { accessorKey: 'reviews_count', header: ({ column }) => ( ), cell: ({ row }) => { const count = row.original.reviews_count; const total = row.original.total_reviews; if (count === null) return -; const coverage = total ? Math.round((count / total) * 100) : null; return (
{count.toLocaleString()} {total && total !== count && ( / {total.toLocaleString()} )}
{coverage !== null && coverage < 100 && (
)}
); }, }, { accessorKey: 'scrape_time', header: ({ column }) => ( ), cell: ({ row }) => { const job = row.original; const isRunning = job.status === 'running'; const isPartial = job.status === 'partial'; const isStuck = isRunning && new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000; // For actively running jobs (not stuck), show live elapsed time if (isRunning && !isStuck && job.started_at) { const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000; return (
{formatDuration(elapsed)} ); } // For stuck jobs, show frozen elapsed time in red (no pulse) if (isStuck && job.started_at) { const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000; return ( {formatDuration(elapsed)} ); } // For partial jobs, use scrape_time or calculate from timestamps if (isPartial) { const time = job.scrape_time; if (time !== null) { return ( {formatDuration(time)} ); } // Fallback: calculate from started_at to completed_at if (job.started_at && job.completed_at) { const elapsed = (new Date(job.completed_at).getTime() - new Date(job.started_at).getTime()) / 1000; return ( {formatDuration(elapsed)} ); } } const time = job.scrape_time; if (time === null) return -; return ( {formatDuration(time)} ); }, }, { id: 'speed', header: ({ column }) => ( ), accessorFn: (row) => { const isStuck = row.status === 'running' && new Date().getTime() - new Date(row.created_at).getTime() > 10 * 60 * 1000; // For actively running jobs (not stuck), calculate speed from last successful data retrieval if (row.status === 'running' && !isStuck && row.started_at && row.reviews_count) { // Use updated_at (last successful data loop) if available, otherwise fall back to Date.now() const endTime = row.updated_at ? new Date(row.updated_at).getTime() : Date.now(); const elapsed = (endTime - new Date(row.started_at).getTime()) / 1000; return elapsed > 0 ? row.reviews_count / elapsed : null; } return calculateSpeed(row.reviews_count, row.scrape_time); }, cell: ({ row }) => { const job = row.original; const isRunning = job.status === 'running'; const isPartial = job.status === 'partial'; const isStuck = isRunning && new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000; // For actively running jobs (not stuck), show live speed based on last successful data retrieval if (isRunning && !isStuck && job.started_at && job.reviews_count) { // Use updated_at (last successful data loop) if available, otherwise fall back to Date.now() const endTime = job.updated_at ? new Date(job.updated_at).getTime() : Date.now(); const elapsed = (endTime - new Date(job.started_at).getTime()) / 1000; const speed = elapsed > 0 ? job.reviews_count / elapsed : 0; const isGood = speed >= 1; const isSlow = speed < 0.5; return (
{speed.toFixed(1)}/s ); } // For stuck jobs, show frozen speed in red (use updated_at for accurate speed) if (isStuck && job.started_at && job.reviews_count) { const endTime = job.updated_at ? new Date(job.updated_at).getTime() : Date.now(); const elapsed = (endTime - new Date(job.started_at).getTime()) / 1000; const speed = elapsed > 0 ? job.reviews_count / elapsed : 0; return ( {speed.toFixed(1)}/s ); } // For partial jobs, show speed in orange if (isPartial) { const speed = calculateSpeed(job.reviews_count, job.scrape_time); if (speed !== null) { return ( {speed.toFixed(1)}/s ); } } const speed = calculateSpeed(job.reviews_count, job.scrape_time); if (speed === null) return -; const isGood = speed >= 1; const isSlow = speed < 0.5; return ( {speed.toFixed(1)}/s ); }, }, { accessorKey: 'created_at', header: ({ column }) => ( ), cell: ({ row }) => { const date = new Date(row.original.created_at); const isToday = new Date().toDateString() === date.toDateString(); return (
{isToday ? 'Today' : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
); }, sortingFn: (rowA, rowB) => { return new Date(rowA.original.created_at).getTime() - new Date(rowB.original.created_at).getTime(); }, }, { id: 'actions', header: 'Actions', cell: ({ row }) => { const job = row.original; const canView = job.reviews_count && job.reviews_count > 0; const isRunning = job.status === 'running'; const isPartial = job.status === 'partial'; const previousJob = findPreviousJob(job); return (
{/* View Reviews - available for any job with reviews */} {canView && ( )} {/* Live Monitor - for actively running jobs (not stuck) */} {(() => { const isStuck = job.status === 'running' && new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000; return job.status === 'running' && !isStuck && ( ); })()} {/* View DevTools */} e.stopPropagation()} > {/* View Logs */} {(() => { const isStuck = job.status === 'running' && new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000; const isError = job.status === 'failed' || isStuck; return ( ); })()} {/* Expand Error */} {job.status === 'failed' && job.error_message && ( )} {/* Copy Crash Report - for failed or partial jobs */} {(job.status === 'failed' || job.status === 'partial') && ( )} {/* Delete Job - allow for non-running or stuck jobs */} {(() => { const isStuck = job.status === 'running' && new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000; const canDelete = job.status !== 'running' || isStuck; return canDelete && ( ); })()}
); }, }, ], [isLoadingJob, loadingLogs, expandedErrors, jobsWithUpdates, startMonitoring, isDeleting, copyingCrashReport, copyCrashReport] ); const table = useReactTable({ data: filteredJobs, columns, state: { sorting, globalFilter, }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), globalFilterFn: (row, columnId, filterValue) => { const business = extractBusinessName(row.original).toLowerCase(); const search = filterValue.toLowerCase(); return business.includes(search); }, initialState: { pagination: { pageSize: 20 }, }, }); if (jobs.length === 0) { return (

No Jobs Yet

Start a new scrape to see your jobs here

); } return (
{/* Header */}

Jobs

Monitor and analyze your scraping jobs

{/* Summary Stats */}
} color="blue" /> } color={stats.successRate >= 90 ? 'green' : stats.successRate >= 70 ? 'yellow' : 'red'} /> } color="purple" /> } color="indigo" /> } color={stats.avgSpeed >= 1 ? 'green' : 'orange'} /> } color="teal" />
{/* Filters */}
{/* Search */}
setGlobalFilter(e.target.value)} className="w-full pl-9 pr-4 py-2 border-2 border-gray-200 rounded-lg focus:border-blue-500 focus:outline-none text-sm" />
{/* Status Filter */}
Status:
{['all', 'completed', 'running', 'failed'].map((status) => ( ))}
{/* Bulk Delete */}
{/* Table */}
{table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => ( ))} ))} {table.getRowModel().rows.map(row => ( {row.getVisibleCells().map(cell => ( ))} {/* Expanded Error Row */} {expandedErrors.has(row.original.job_id) && row.original.error_message && ( )} ))}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
                            {row.original.error_message}
                          
{/* Pagination */}
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{' '} {Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, filteredJobs.length)} of{' '} {filteredJobs.length} jobs
{/* Logs Modal */} {selectedJobLogs && (
setSelectedJobLogs(null)} >
e.stopPropagation()} > {/* Modal Header */}

Job Logs

{selectedJobLogs.log_count} log entries · Status: {' '} {selectedJobLogs.status}

{/* Error Message */} {selectedJobLogs.error_message && (

Error

{selectedJobLogs.error_message}

)} {/* Logs List */}
{selectedJobLogs.logs.length === 0 ? (

No logs available

This job was created before logging was enabled

) : (
{[...selectedJobLogs.logs] .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) .map((log, idx) => (
{new Date(log.timestamp).toLocaleTimeString()} {log.level} {log.source} {log.message}
))}
)}
{/* Modal Footer */}
)} {/* Live Monitor Modal */} {monitoredJob && (
stopMonitoring()} >
e.stopPropagation()} > {/* Modal Header */}
{isMonitoring && ( <> )} {!isMonitoring && ( )}

{isMonitoring ? 'Live Monitor' : 'Job Finished'}

{extractBusinessName(monitoredJob)}

{/* Progress Section */}
{monitoredJob.status === 'running' && (
)} {monitoredJob.status === 'completed' && ( )} {monitoredJob.status === 'failed' && ( )} {monitoredJob.status.charAt(0).toUpperCase() + monitoredJob.status.slice(1)}
{monitoredJob.reviews_count?.toLocaleString() || 0} {monitoredJob.total_reviews && ( / {monitoredJob.total_reviews.toLocaleString()} )}
reviews scraped
{/* Progress Bar */} {monitoredJob.total_reviews && monitoredJob.total_reviews > 0 && (
{Math.round(((monitoredJob.reviews_count || 0) / monitoredJob.total_reviews) * 100)}% complete {monitoredJob.status === 'running' && monitoredJob.reviews_count && monitoredJob.started_at && ( {(() => { const endTime = monitoredJob.updated_at ? new Date(monitoredJob.updated_at).getTime() : Date.now(); const elapsed = (endTime - new Date(monitoredJob.started_at).getTime()) / 1000; return elapsed > 0 ? (monitoredJob.reviews_count / elapsed).toFixed(1) : '0'; })()} reviews/sec )}
)} {/* Stats Row */}
Duration
{monitoredJob.scrape_time ? formatDuration(monitoredJob.scrape_time) : '-'}
Speed
{(() => { if (!monitoredJob.reviews_count || !monitoredJob.started_at) return '-'; // For running jobs, use updated_at; for completed, use scrape_time if (monitoredJob.status === 'running') { const endTime = monitoredJob.updated_at ? new Date(monitoredJob.updated_at).getTime() : Date.now(); const elapsed = (endTime - new Date(monitoredJob.started_at).getTime()) / 1000; return elapsed > 0 ? `${(monitoredJob.reviews_count / elapsed).toFixed(1)}/s` : '-'; } // For completed/partial jobs, use scrape_time if available if (monitoredJob.scrape_time) { return `${(monitoredJob.reviews_count / monitoredJob.scrape_time).toFixed(1)}/s`; } return '-'; })()}
Started
{monitoredJob.started_at ? new Date(monitoredJob.started_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '-' }
{/* Error Message */} {monitoredJob.status === 'failed' && monitoredJob.error_message && (

Error

{monitoredJob.error_message}

)} {/* Live Logs Section */}
Live Logs ({monitoredJobLogs.length} entries)
{isMonitoring && ( Auto-refreshing )}
{monitoredJobLogs.length === 0 ? (

Waiting for logs...

) : (
{[...monitoredJobLogs] .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) .map((log, idx) => (
{new Date(log.timestamp).toLocaleTimeString()} {' '} [{log.level}] {' '} [{log.source}] {' '} {log.message}
))}
)}
{/* Modal Footer */}
{monitoredJob.status === 'completed' && monitoredJob.reviews_count && ( )} stopMonitoring()} className="py-2.5 px-6 bg-purple-600 text-white rounded-lg font-semibold hover:bg-purple-700 transition-colors flex items-center gap-2" > DevTools
)} {/* Delete Confirmation Modal */} {deleteConfirm && (
setDeleteConfirm(null)} >
e.stopPropagation()} >

Delete Job

This action cannot be undone

Are you sure you want to delete this job?

{extractBusinessName(deleteConfirm)}

{deleteConfirm.reviews_count ? `${deleteConfirm.reviews_count} reviews` : 'No reviews'} ·{' '} {new Date(deleteConfirm.created_at).toLocaleDateString()}

)} {/* Bulk Delete Confirmation Modal */} {bulkDeleteConfirm && (
setBulkDeleteConfirm(null)} >
e.stopPropagation()} >

{bulkDeleteConfirm === 'all' ? 'Delete All Jobs' : bulkDeleteConfirm === 'completed' ? 'Delete Completed Jobs' : 'Delete Failed Jobs'}

This action cannot be undone

{bulkDeleteConfirm === 'all' ? ( <>You are about to delete {stats.total} jobs and all associated data. ) : bulkDeleteConfirm === 'completed' ? ( <>You are about to delete {stats.completed} completed jobs and their reviews data. ) : ( <>You are about to delete {stats.failed} failed jobs and their error logs. )}

Warning: All reviews data, logs, and job history will be permanently deleted.

)}
); } // Helper Components function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) { if (sorted === 'asc') { return ( ); } if (sorted === 'desc') { return ( ); } return ( ); } function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: React.ReactNode; color: string }) { const colors: Record = { blue: 'from-blue-100 to-blue-200 border-blue-300 text-blue-900', green: 'from-green-100 to-green-200 border-green-300 text-green-900', red: 'from-red-100 to-red-200 border-red-300 text-red-900', yellow: 'from-yellow-100 to-yellow-200 border-yellow-300 text-yellow-900', orange: 'from-orange-100 to-orange-200 border-orange-300 text-orange-900', purple: 'from-purple-100 to-purple-200 border-purple-300 text-purple-900', indigo: 'from-indigo-100 to-indigo-200 border-indigo-300 text-indigo-900', teal: 'from-teal-100 to-teal-200 border-teal-300 text-teal-900', }; return (
{icon} {label}
{value}
); } // Icons function ClipboardIcon() { return ( ); } function CheckIcon() { return ( ); } function StarIcon() { return ( ); } function ClockIcon() { return ( ); } function SpeedIcon() { return ( ); } function CalendarIcon() { return ( ); }