Files
whyrating-engine-legacy/web/components/JobsView.tsx
Alejandro Gutiérrez 65eb979c12 feat: Add "Copy Crash Report" button for failed/partial jobs
- Generate structured markdown crash report optimized for Claude
- Includes: job metadata, timeline, progress, error, logs (last 50)
- Adds context and suggested investigation steps
- Orange clipboard button appears for failed/partial jobs
- Shows green checkmark briefly after successful copy
- Fetches logs async when generating report

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:09:48 +00:00

1898 lines
80 KiB
TypeScript

'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<SortingState>([{ id: 'created_at', desc: true }]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedJobLogs, setSelectedJobLogs] = useState<JobLogs | null>(null);
const [loadingLogs, setLoadingLogs] = useState<string | null>(null);
const [expandedErrors, setExpandedErrors] = useState<Set<string>>(new Set());
// Live monitoring state
const [monitoredJob, setMonitoredJob] = useState<JobStatus | null>(null);
const [monitoredJobLogs, setMonitoredJobLogs] = useState<LogEntry[]>([]);
const [isMonitoring, setIsMonitoring] = useState(false);
// Delete state
const [deleteConfirm, setDeleteConfirm] = useState<JobStatus | null>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(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<Map<string, JobStatus>>(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<string | null>(null);
const generateCrashReport = async (job: JobStatus): Promise<string> => {
// 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<ColumnDef<JobStatus>[]>(
() => [
{
accessorKey: 'business',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Business
<SortIcon sorted={column.getIsSorted()} />
</button>
),
accessorFn: (row) => extractBusinessName(row),
cell: ({ row }) => {
const name = extractBusinessName(row.original);
const address = row.original.business_address;
return (
<div className="max-w-xs">
<div className="font-semibold text-gray-900 truncate" title={name}>
{name}
</div>
{address && (
<div className="text-xs text-gray-500 truncate" title={address}>
{address}
</div>
)}
</div>
);
},
},
{
id: 'url',
header: 'URL',
cell: ({ row }) => {
const url = row.original.url;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => 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}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open
</a>
);
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Status
<SortIcon sorted={column.getIsSorted()} />
</button>
),
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 (
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
status === 'completed' ? 'bg-green-100 text-green-800' :
status === 'partial' ? 'bg-orange-100 text-orange-800' :
isStuck ? 'bg-red-100 text-red-800' :
status === 'running' ? 'bg-blue-100 text-blue-800' :
status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{status === 'running' && !isStuck && (
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
)}
{status === 'partial' && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
{isStuck && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
{isStuck ? 'Stuck' : status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</div>
);
},
},
{
accessorKey: 'reviews_count',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Reviews
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => {
const count = row.original.reviews_count;
const total = row.original.total_reviews;
if (count === null) return <span className="text-gray-400">-</span>;
const coverage = total ? Math.round((count / total) * 100) : null;
return (
<div>
<div className="font-semibold text-gray-900">
{count.toLocaleString()}
{total && total !== count && (
<span className="text-gray-500 font-normal"> / {total.toLocaleString()}</span>
)}
</div>
{coverage !== null && coverage < 100 && (
<div className="mt-1">
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${coverage}%` }}
/>
</div>
</div>
)}
</div>
);
},
},
{
accessorKey: 'scrape_time',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Duration
<SortIcon sorted={column.getIsSorted()} />
</button>
),
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 (
<span className="font-medium text-blue-600 flex items-center gap-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
{formatDuration(elapsed)}
</span>
);
}
// 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 (
<span className="font-medium text-red-600">
{formatDuration(elapsed)}
</span>
);
}
// For partial jobs, use scrape_time or calculate from timestamps
if (isPartial) {
const time = job.scrape_time;
if (time !== null) {
return (
<span className="font-medium text-orange-600">
{formatDuration(time)}
</span>
);
}
// 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 (
<span className="font-medium text-orange-600">
{formatDuration(elapsed)}
</span>
);
}
}
const time = job.scrape_time;
if (time === null) return <span className="text-gray-400">-</span>;
return (
<span className="font-medium text-gray-700">
{formatDuration(time)}
</span>
);
},
},
{
id: 'speed',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Speed
<SortIcon sorted={column.getIsSorted()} />
</button>
),
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 (
<span className={`font-medium flex items-center gap-1 ${
isGood ? 'text-green-600' : isSlow ? 'text-orange-500' : 'text-blue-600'
}`}>
<div className="w-2 h-2 bg-current rounded-full animate-pulse" />
{speed.toFixed(1)}/s
</span>
);
}
// 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 (
<span className="font-medium text-red-600">
{speed.toFixed(1)}/s
</span>
);
}
// For partial jobs, show speed in orange
if (isPartial) {
const speed = calculateSpeed(job.reviews_count, job.scrape_time);
if (speed !== null) {
return (
<span className="font-medium text-orange-600">
{speed.toFixed(1)}/s
</span>
);
}
}
const speed = calculateSpeed(job.reviews_count, job.scrape_time);
if (speed === null) return <span className="text-gray-400">-</span>;
const isGood = speed >= 1;
const isSlow = speed < 0.5;
return (
<span className={`font-medium ${
isGood ? 'text-green-700' : isSlow ? 'text-orange-600' : 'text-gray-700'
}`}>
{speed.toFixed(1)}/s
</span>
);
},
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Date
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => {
const date = new Date(row.original.created_at);
const isToday = new Date().toDateString() === date.toDateString();
return (
<div>
<div className="font-medium text-gray-900">
{isToday ? 'Today' : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
<div className="text-xs text-gray-500">
{date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
);
},
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 (
<div className="flex items-center gap-2">
{/* View Reviews - available for any job with reviews */}
{canView && (
<button
onClick={() => onSelectJob(job, previousJob)}
className={`px-2.5 py-1.5 text-xs font-semibold rounded-lg transition-colors flex items-center gap-1.5 ${
isRunning
? 'bg-purple-600 text-white hover:bg-purple-700'
: isPartial
? 'bg-orange-600 text-white hover:bg-orange-700'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
title={isRunning ? 'Preview analytics (job still running)' : isPartial ? 'View partial results' : 'View analytics'}
>
{isLoadingJob === job.job_id ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
{isRunning && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
{isRunning ? 'Preview' : isPartial ? 'Partial' : 'View'}
</>
)}
</button>
)}
{/* 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 && (
<button
onClick={() => startMonitoring(job)}
className="px-2.5 py-1.5 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 transition-colors flex items-center gap-1.5"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
</span>
Live
</button>
);
})()}
{/* View DevTools */}
<Link
href={`/jobs/${job.job_id}`}
className="p-1.5 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
title="View DevTools"
onClick={(e) => e.stopPropagation()}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</Link>
{/* 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 (
<button
onClick={() => fetchJobLogs(job.job_id)}
className={`p-1.5 rounded-lg transition-colors ${
isError
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
title="View logs"
>
{loadingLogs === job.job_id ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
</button>
);
})()}
{/* Expand Error */}
{job.status === 'failed' && job.error_message && (
<button
onClick={() => {
const newExpanded = new Set(expandedErrors);
if (newExpanded.has(job.job_id)) {
newExpanded.delete(job.job_id);
} else {
newExpanded.add(job.job_id);
}
setExpandedErrors(newExpanded);
}}
className="p-1.5 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
title="Show error"
>
<svg className={`w-4 h-4 transition-transform ${expandedErrors.has(job.job_id) ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
{/* Copy Crash Report - for failed or partial jobs */}
{(job.status === 'failed' || job.status === 'partial') && (
<button
onClick={() => copyCrashReport(job)}
disabled={copyingCrashReport === job.job_id}
className={`p-1.5 rounded-lg transition-colors ${
copyingCrashReport === job.job_id
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700 hover:bg-orange-200'
}`}
title={copyingCrashReport === job.job_id ? 'Copied!' : 'Copy crash report for Claude'}
>
{copyingCrashReport === job.job_id ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
)}
</button>
)}
{/* 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 && (
<button
onClick={() => setDeleteConfirm(job)}
className="p-1.5 bg-gray-100 text-gray-500 rounded-lg hover:bg-red-100 hover:text-red-600 transition-colors"
title={isStuck ? "Delete stuck job" : "Delete job"}
>
{isDeleting === job.job_id ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</button>
);
})()}
</div>
);
},
},
],
[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 (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<svg className="w-20 h-20 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="text-xl font-semibold text-gray-700 mb-2">No Jobs Yet</h3>
<p className="text-sm text-gray-500">Start a new scrape to see your jobs here</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6">
{/* Header */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900">Jobs</h2>
<p className="text-sm text-gray-600 mt-1">
Monitor and analyze your scraping jobs
</p>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<StatCard
label="Total Jobs"
value={stats.total}
icon={<ClipboardIcon />}
color="blue"
/>
<StatCard
label="Success Rate"
value={`${stats.successRate.toFixed(0)}%`}
icon={<CheckIcon />}
color={stats.successRate >= 90 ? 'green' : stats.successRate >= 70 ? 'yellow' : 'red'}
/>
<StatCard
label="Reviews Scraped"
value={stats.totalReviews.toLocaleString()}
icon={<StarIcon />}
color="purple"
/>
<StatCard
label="Avg Duration"
value={formatDuration(stats.avgTime)}
icon={<ClockIcon />}
color="indigo"
/>
<StatCard
label="Avg Speed"
value={`${stats.avgSpeed.toFixed(1)}/s`}
icon={<SpeedIcon />}
color={stats.avgSpeed >= 1 ? 'green' : 'orange'}
/>
<StatCard
label="Today"
value={stats.jobsToday}
icon={<CalendarIcon />}
color="teal"
/>
</div>
{/* Filters */}
<div className="bg-white border-2 border-gray-200 rounded-xl p-4 mb-6 flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search by business name..."
value={globalFilter}
onChange={(e) => 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"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Status:</span>
<div className="flex gap-1">
{['all', 'completed', 'running', 'failed'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition-colors ${
statusFilter === status
? status === 'completed' ? 'bg-green-600 text-white' :
status === 'running' ? 'bg-blue-600 text-white' :
status === 'failed' ? 'bg-red-600 text-white' :
'bg-gray-900 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === 'all' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
{status !== 'all' && (
<span className="ml-1 opacity-75">
({status === 'completed' ? stats.completed : status === 'running' ? stats.running : stats.failed})
</span>
)}
</button>
))}
</div>
</div>
{/* Bulk Delete */}
<div className="flex items-center gap-2 ml-auto">
<div className="relative group">
<button
className="px-3 py-1.5 text-xs font-semibold rounded-lg bg-gray-100 text-gray-600 hover:bg-red-100 hover:text-red-600 transition-colors flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Bulk Delete
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="absolute right-0 mt-1 w-48 bg-white border-2 border-gray-200 rounded-lg shadow-lg hidden group-hover:block z-10">
<button
onClick={() => setBulkDeleteConfirm('completed')}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
disabled={stats.completed === 0}
>
Delete Completed
<span className="text-xs text-gray-400">({stats.completed})</span>
</button>
<button
onClick={() => setBulkDeleteConfirm('failed')}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
disabled={stats.failed === 0}
>
Delete Failed
<span className="text-xs text-gray-400">({stats.failed})</span>
</button>
<div className="border-t border-gray-200" />
<button
onClick={() => setBulkDeleteConfirm('all')}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center justify-between font-semibold"
disabled={stats.total === 0}
>
Delete All Jobs
<span className="text-xs text-red-400">({stats.total})</span>
</button>
</div>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white border-2 border-gray-200 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b-2 border-gray-200">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} className="px-4 py-3 text-left text-sm text-gray-700">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-100">
{table.getRowModel().rows.map(row => (
<React.Fragment key={row.id}>
<tr className="hover:bg-gray-50 transition-colors">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-4 py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
{/* Expanded Error Row */}
{expandedErrors.has(row.original.job_id) && row.original.error_message && (
<tr className="bg-red-50">
<td colSpan={columns.length} className="px-4 py-3">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<pre className="text-sm text-red-800 font-mono whitespace-pre-wrap break-all">
{row.original.error_message}
</pre>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-4 py-3 border-t-2 border-gray-200 flex items-center justify-between bg-gray-50">
<div className="text-sm text-gray-600">
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
</div>
<div className="flex gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-1.5 text-sm font-medium border-2 border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors"
>
Previous
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-1.5 text-sm font-medium border-2 border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors"
>
Next
</button>
</div>
</div>
</div>
{/* Logs Modal */}
{selectedJobLogs && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={() => setSelectedJobLogs(null)}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="bg-gray-100 border-b-2 border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-gray-900">Job Logs</h3>
<p className="text-sm text-gray-500">
{selectedJobLogs.log_count} log entries · Status: {' '}
<span className={`font-semibold ${
selectedJobLogs.status === 'completed' ? 'text-green-600' :
selectedJobLogs.status === 'failed' ? 'text-red-600' :
selectedJobLogs.status === 'running' ? 'text-blue-600' :
'text-gray-600'
}`}>
{selectedJobLogs.status}
</span>
</p>
</div>
<button
onClick={() => setSelectedJobLogs(null)}
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Error Message */}
{selectedJobLogs.error_message && (
<div className="bg-red-50 border-b border-red-200 px-6 py-3">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<p className="font-semibold text-red-800">Error</p>
<p className="text-sm text-red-700 font-mono whitespace-pre-wrap">{selectedJobLogs.error_message}</p>
</div>
</div>
</div>
)}
{/* Logs List */}
<div className="flex-1 overflow-y-auto p-4">
{selectedJobLogs.logs.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<svg className="w-12 h-12 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="font-medium">No logs available</p>
<p className="text-sm">This job was created before logging was enabled</p>
</div>
) : (
<div className="space-y-1 font-mono text-sm">
{[...selectedJobLogs.logs]
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
.map((log, idx) => (
<div
key={idx}
className={`px-3 py-2 rounded-lg ${
log.level === 'ERROR' ? 'bg-red-50 border border-red-200' :
log.level === 'WARNING' ? 'bg-yellow-50 border border-yellow-200' :
log.level === 'INFO' ? 'bg-blue-50 border border-blue-200' :
'bg-gray-50 border border-gray-200'
}`}
>
<div className="flex items-start gap-3">
<span className="text-gray-400 text-xs whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${
log.level === 'ERROR' ? 'bg-red-200 text-red-800' :
log.level === 'WARNING' ? 'bg-yellow-200 text-yellow-800' :
log.level === 'INFO' ? 'bg-blue-200 text-blue-800' :
'bg-gray-200 text-gray-700'
}`}>
{log.level}
</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${
log.source === 'browser' ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'
}`}>
{log.source}
</span>
<span className="flex-1 text-gray-800 break-all">
{log.message}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Modal Footer */}
<div className="bg-gray-100 border-t-2 border-gray-200 px-6 py-4">
<button
onClick={() => setSelectedJobLogs(null)}
className="w-full py-2.5 bg-gray-900 text-white rounded-lg font-semibold hover:bg-gray-800 transition-colors"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Live Monitor Modal */}
{monitoredJob && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={() => stopMonitoring()}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative">
{isMonitoring && (
<>
<span className="absolute inline-flex h-full w-full rounded-full bg-white opacity-75 animate-ping"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-white"></span>
</>
)}
{!isMonitoring && (
<span className={`inline-flex rounded-full h-3 w-3 ${
monitoredJob.status === 'completed' ? 'bg-green-200' :
monitoredJob.status === 'failed' ? 'bg-red-300' : 'bg-gray-300'
}`}></span>
)}
</div>
<div>
<h3 className="text-xl font-bold text-white">
{isMonitoring ? 'Live Monitor' : 'Job Finished'}
</h3>
<p className="text-sm text-green-100">
{extractBusinessName(monitoredJob)}
</p>
</div>
</div>
<button
onClick={() => stopMonitoring()}
className="p-2 hover:bg-white/20 rounded-full transition-colors"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress Section */}
<div className="px-6 py-4 bg-gray-50 border-b-2 border-gray-200">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-semibold ${
monitoredJob.status === 'completed' ? 'bg-green-100 text-green-800' :
monitoredJob.status === 'running' ? 'bg-blue-100 text-blue-800' :
monitoredJob.status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{monitoredJob.status === 'running' && (
<div className="w-2.5 h-2.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
{monitoredJob.status === 'completed' && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
{monitoredJob.status === 'failed' && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
{monitoredJob.status.charAt(0).toUpperCase() + monitoredJob.status.slice(1)}
</span>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">
{monitoredJob.reviews_count?.toLocaleString() || 0}
{monitoredJob.total_reviews && (
<span className="text-gray-400 font-normal text-lg"> / {monitoredJob.total_reviews.toLocaleString()}</span>
)}
</div>
<div className="text-xs text-gray-500">reviews scraped</div>
</div>
</div>
{/* Progress Bar */}
{monitoredJob.total_reviews && monitoredJob.total_reviews > 0 && (
<div className="mt-3">
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
monitoredJob.status === 'completed' ? 'bg-green-500' :
monitoredJob.status === 'failed' ? 'bg-red-500' :
'bg-blue-500'
}`}
style={{ width: `${Math.min(100, ((monitoredJob.reviews_count || 0) / monitoredJob.total_reviews) * 100)}%` }}
/>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-500">
<span>{Math.round(((monitoredJob.reviews_count || 0) / monitoredJob.total_reviews) * 100)}% complete</span>
{monitoredJob.status === 'running' && monitoredJob.reviews_count && monitoredJob.started_at && (
<span>
{(() => {
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
</span>
)}
</div>
</div>
)}
{/* Stats Row */}
<div className="grid grid-cols-3 gap-4 mt-4">
<div className="bg-white rounded-lg p-3 border border-gray-200">
<div className="text-xs text-gray-500 mb-1">Duration</div>
<div className="text-lg font-semibold text-gray-900">
{monitoredJob.scrape_time ? formatDuration(monitoredJob.scrape_time) : '-'}
</div>
</div>
<div className="bg-white rounded-lg p-3 border border-gray-200">
<div className="text-xs text-gray-500 mb-1">Speed</div>
<div className="text-lg font-semibold text-gray-900">
{(() => {
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 '-';
})()}
</div>
</div>
<div className="bg-white rounded-lg p-3 border border-gray-200">
<div className="text-xs text-gray-500 mb-1">Started</div>
<div className="text-lg font-semibold text-gray-900">
{monitoredJob.started_at
? new Date(monitoredJob.started_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
: '-'
}
</div>
</div>
</div>
</div>
{/* Error Message */}
{monitoredJob.status === 'failed' && monitoredJob.error_message && (
<div className="bg-red-50 border-b border-red-200 px-6 py-3">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<p className="font-semibold text-red-800">Error</p>
<p className="text-sm text-red-700 font-mono whitespace-pre-wrap">{monitoredJob.error_message}</p>
</div>
</div>
</div>
)}
{/* Live Logs Section */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="px-6 py-2 bg-gray-100 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm font-semibold text-gray-700">Live Logs</span>
<span className="text-xs text-gray-500">({monitoredJobLogs.length} entries)</span>
</div>
{isMonitoring && (
<span className="text-xs text-green-600 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></span>
Auto-refreshing
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 bg-gray-900">
{monitoredJobLogs.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<svg className="w-12 h-12 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="font-medium text-gray-400">Waiting for logs...</p>
</div>
) : (
<div className="space-y-1 font-mono text-xs">
{[...monitoredJobLogs]
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
.map((log, idx) => (
<div
key={idx}
className={`px-2 py-1 rounded ${
log.level === 'ERROR' ? 'bg-red-900/30 text-red-300' :
log.level === 'WARNING' ? 'bg-yellow-900/30 text-yellow-300' :
log.level === 'INFO' ? 'text-green-300' :
'text-gray-400'
}`}
>
<span className="text-gray-500">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
{' '}
<span className={`font-bold ${
log.level === 'ERROR' ? 'text-red-400' :
log.level === 'WARNING' ? 'text-yellow-400' :
log.level === 'INFO' ? 'text-blue-400' :
'text-gray-500'
}`}>
[{log.level}]
</span>
{' '}
<span className={`${
log.source === 'browser' ? 'text-purple-400' : 'text-green-400'
}`}>
[{log.source}]
</span>
{' '}
<span>{log.message}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Modal Footer */}
<div className="bg-gray-100 border-t-2 border-gray-200 px-6 py-4 flex gap-3">
{monitoredJob.status === 'completed' && monitoredJob.reviews_count && (
<button
onClick={() => {
const previousJob = findPreviousJob(monitoredJob);
onSelectJob(monitoredJob, previousJob);
stopMonitoring();
}}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
View Reviews
</button>
)}
<Link
href={`/jobs/${monitoredJob.job_id}`}
onClick={() => 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"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
DevTools
</Link>
<button
onClick={() => stopMonitoring()}
className={`${monitoredJob.status === 'completed' && monitoredJob.reviews_count ? '' : 'flex-1'} py-2.5 px-6 bg-gray-900 text-white rounded-lg font-semibold hover:bg-gray-800 transition-colors`}
>
Close
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={() => setDeleteConfirm(null)}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Delete Job</h3>
<p className="text-sm text-gray-500">This action cannot be undone</p>
</div>
</div>
<p className="text-gray-700 mb-2">
Are you sure you want to delete this job?
</p>
<div className="bg-gray-100 rounded-lg p-3 mb-4">
<p className="font-semibold text-gray-900">{extractBusinessName(deleteConfirm)}</p>
<p className="text-sm text-gray-500">
{deleteConfirm.reviews_count ? `${deleteConfirm.reviews_count} reviews` : 'No reviews'} ·{' '}
{new Date(deleteConfirm.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="bg-gray-100 px-6 py-4 flex gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="flex-1 py-2.5 bg-white border-2 border-gray-200 text-gray-700 rounded-lg font-semibold hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => deleteJob(deleteConfirm.job_id)}
disabled={isDeleting === deleteConfirm.job_id}
className="flex-1 py-2.5 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{isDeleting === deleteConfirm.job_id ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Deleting...
</>
) : (
'Delete Job'
)}
</button>
</div>
</div>
</div>
)}
{/* Bulk Delete Confirmation Modal */}
{bulkDeleteConfirm && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={() => setBulkDeleteConfirm(null)}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">
{bulkDeleteConfirm === 'all' ? 'Delete All Jobs' :
bulkDeleteConfirm === 'completed' ? 'Delete Completed Jobs' :
'Delete Failed Jobs'}
</h3>
<p className="text-sm text-gray-500">This action cannot be undone</p>
</div>
</div>
<p className="text-gray-700 mb-4">
{bulkDeleteConfirm === 'all' ? (
<>You are about to delete <span className="font-bold text-red-600">{stats.total} jobs</span> and all associated data.</>
) : bulkDeleteConfirm === 'completed' ? (
<>You are about to delete <span className="font-bold text-red-600">{stats.completed} completed jobs</span> and their reviews data.</>
) : (
<>You are about to delete <span className="font-bold text-red-600">{stats.failed} failed jobs</span> and their error logs.</>
)}
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-800">
<strong>Warning:</strong> All reviews data, logs, and job history will be permanently deleted.
</p>
</div>
</div>
<div className="bg-gray-100 px-6 py-4 flex gap-3">
<button
onClick={() => setBulkDeleteConfirm(null)}
className="flex-1 py-2.5 bg-white border-2 border-gray-200 text-gray-700 rounded-lg font-semibold hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => bulkDeleteJobs(bulkDeleteConfirm)}
disabled={isBulkDeleting}
className="flex-1 py-2.5 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{isBulkDeleting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Deleting...
</>
) : (
`Delete ${bulkDeleteConfirm === 'all' ? stats.total : bulkDeleteConfirm === 'completed' ? stats.completed : stats.failed} Jobs`
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Helper Components
function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) {
if (sorted === 'asc') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
}
if (sorted === 'desc') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
return (
<svg className="w-4 h-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: React.ReactNode; color: string }) {
const colors: Record<string, string> = {
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 (
<div className={`bg-gradient-to-br ${colors[color]} border-2 rounded-xl p-4`}>
<div className="flex items-center gap-2 mb-1">
<span className="opacity-70">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-wide opacity-80">{label}</span>
</div>
<div className="text-2xl font-bold">{value}</div>
</div>
);
}
// Icons
function ClipboardIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
);
}
function CheckIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
function StarIcon() {
return (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
);
}
function ClockIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
function SpeedIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
function CalendarIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
}