'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); } }; // 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 elapsed time if (row.status === 'running' && !isStuck && row.started_at && row.reviews_count) { const elapsed = (Date.now() - 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 if (isRunning && !isStuck && job.started_at && job.reviews_count) { const elapsed = (Date.now() - 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 if (isStuck && job.started_at && job.reviews_count) { const elapsed = (Date.now() - 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 && ( )} {/* 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] ); 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.scrape_time && ( {(monitoredJob.reviews_count / monitoredJob.scrape_time).toFixed(1)} reviews/sec )}
)} {/* Stats Row */}
Duration
{monitoredJob.scrape_time ? formatDuration(monitoredJob.scrape_time) : '-'}
Speed
{monitoredJob.reviews_count && monitoredJob.scrape_time ? `${(monitoredJob.reviews_count / monitoredJob.scrape_time).toFixed(1)}/s` : '-' }
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 ( ); }