Add URL-based routing with sidebar navigation
Replace client-side state switching with proper Next.js routes: - /new - New scrape form - /jobs - Jobs list with table view - /jobs/[id] - Individual job details and logs - /analytics - Analytics overview (completed jobs) - /analytics/[id] - Analytics for specific job Add JobsContext for shared state across routes. Update Sidebar to use next/link with pathname matching. Root page redirects to /new. Also adds partial job status styling to JobsView. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,20 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -499,6 +513,7 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
<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' :
|
||||
@@ -507,6 +522,11 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
{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" />
|
||||
@@ -571,7 +591,55 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const time = row.original.scrape_time;
|
||||
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 (
|
||||
@@ -592,9 +660,65 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
accessorFn: (row) => calculateSpeed(row.reviews_count, row.scrape_time),
|
||||
accessorFn: (row) => {
|
||||
const isStuck = row.status === 'running' &&
|
||||
new Date().getTime() - new Date(row.created_at).getTime() > 10 * 60 * 1000;
|
||||
|
||||
// For actively running jobs (not stuck), calculate speed from elapsed time
|
||||
if (row.status === 'running' && !isStuck && row.started_at && row.reviews_count) {
|
||||
const elapsed = (Date.now() - new Date(row.started_at).getTime()) / 1000;
|
||||
return elapsed > 0 ? row.reviews_count / elapsed : null;
|
||||
}
|
||||
return calculateSpeed(row.reviews_count, row.scrape_time);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const speed = calculateSpeed(row.original.reviews_count, row.original.scrape_time);
|
||||
const job = row.original;
|
||||
const isRunning = job.status === 'running';
|
||||
const isPartial = job.status === 'partial';
|
||||
const isStuck = isRunning &&
|
||||
new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000;
|
||||
|
||||
// For actively running jobs (not stuck), show live speed
|
||||
if (isRunning && !isStuck && job.started_at && job.reviews_count) {
|
||||
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
|
||||
const speed = elapsed > 0 ? job.reviews_count / elapsed : 0;
|
||||
const isGood = speed >= 1;
|
||||
const isSlow = speed < 0.5;
|
||||
|
||||
return (
|
||||
<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
|
||||
if (isStuck && job.started_at && job.reviews_count) {
|
||||
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
|
||||
const speed = elapsed > 0 ? job.reviews_count / elapsed : 0;
|
||||
return (
|
||||
<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;
|
||||
@@ -644,21 +768,38 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const job = row.original;
|
||||
const canView = job.status === 'completed' && job.reviews_count;
|
||||
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 */}
|
||||
{/* View Reviews - available for any job with reviews */}
|
||||
{canView && (
|
||||
<button
|
||||
onClick={() => onSelectJob(job, previousJob)}
|
||||
className="px-2.5 py-1.5 bg-blue-600 text-white text-xs font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
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" />
|
||||
) : (
|
||||
'View'
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1,64 +1,73 @@
|
||||
'use client';
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: 'newScrape' | 'jobs' | 'reports';
|
||||
onViewChange: (view: 'newScrape' | 'jobs' | 'reports') => void;
|
||||
jobCount: number;
|
||||
}
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { jobs } = useJobs();
|
||||
|
||||
export default function Sidebar({ activeView, onViewChange, jobCount }: SidebarProps) {
|
||||
const navItems = [
|
||||
{
|
||||
id: 'newScrape' as const,
|
||||
href: '/new',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
label: 'New Scrape',
|
||||
label: 'New',
|
||||
matchPaths: ['/new'],
|
||||
},
|
||||
{
|
||||
id: 'jobs' as const,
|
||||
href: '/jobs',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" 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>
|
||||
),
|
||||
label: 'Jobs',
|
||||
badge: jobCount > 0 ? jobCount : undefined,
|
||||
matchPaths: ['/jobs'],
|
||||
badge: jobs.length > 0 ? jobs.length : undefined,
|
||||
},
|
||||
{
|
||||
id: 'reports' as const,
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Reports',
|
||||
label: 'Analytics',
|
||||
matchPaths: ['/analytics'],
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (item: typeof navItems[0]) => {
|
||||
// Check if current path starts with any of the match paths
|
||||
return item.matchPaths.some(path => pathname.startsWith(path));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-20 bg-gray-900 flex flex-col items-center py-6 gap-2">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`relative w-14 h-14 rounded-xl flex flex-col items-center justify-center gap-1 transition-all ${
|
||||
activeView === item.id
|
||||
isActive(item)
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
title={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-[10px] font-medium">{item.label.split(' ')[0]}</span>
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{item.badge > 99 ? '99+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user