1898 lines
80 KiB
TypeScript
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 ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown'}
|
|
</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>
|
|
);
|
|
}
|