Initial commit — PlantGuideScraper project
This commit is contained in:
354
frontend/src/pages/Jobs.tsx
Normal file
354
frontend/src/pages/Jobs.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Leaf,
|
||||
Download,
|
||||
XOctagon,
|
||||
} from 'lucide-react'
|
||||
import { jobsApi, Job } from '../api/client'
|
||||
|
||||
export default function Jobs() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list({ limit: 100 }).then((res) => res.data),
|
||||
refetchInterval: 1000, // Faster refresh for live updates
|
||||
})
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: (id: number) => jobsApi.pause(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
})
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: (id: number) => jobsApi.resume(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
})
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: number) => jobsApi.cancel(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
})
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <RefreshCw className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />
|
||||
case 'paused':
|
||||
return <Pause className="w-4 h-4 text-gray-500" />
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
case 'paused':
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-700'
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-700'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
// Separate running jobs from others
|
||||
const runningJobs = data?.items.filter((j) => j.status === 'running') || []
|
||||
const otherJobs = data?.items.filter((j) => j.status !== 'running') || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Jobs</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : data?.items.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-400">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4" />
|
||||
<p>No jobs yet</p>
|
||||
<p className="text-sm mt-2">
|
||||
Select species and start a scrape job to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Running Jobs - More prominent display */}
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-blue-500" />
|
||||
Active Jobs ({runningJobs.length})
|
||||
</h2>
|
||||
{runningJobs.map((job) => (
|
||||
<RunningJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={() => pauseMutation.mutate(job.id)}
|
||||
onCancel={() => cancelMutation.mutate(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Jobs */}
|
||||
{otherJobs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{runningJobs.length > 0 && (
|
||||
<h2 className="text-lg font-semibold text-gray-600">Other Jobs</h2>
|
||||
)}
|
||||
{otherJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="bg-white rounded-lg shadow p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(job.status)}
|
||||
<h3 className="font-semibold">{job.name}</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${getStatusClass(
|
||||
job.status
|
||||
)}`}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<span className="mr-4">Source: {job.source}</span>
|
||||
<span className="mr-4">
|
||||
Downloaded: {job.images_downloaded}
|
||||
</span>
|
||||
<span>Rejected: {job.images_rejected}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for paused jobs */}
|
||||
{job.status === 'paused' && job.progress_total > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>
|
||||
{job.progress_current} / {job.progress_total} species
|
||||
</span>
|
||||
<span>
|
||||
{Math.round(
|
||||
(job.progress_current / job.progress_total) * 100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gray-400"
|
||||
style={{
|
||||
width: `${
|
||||
(job.progress_current / job.progress_total) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
Error: {job.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{job.started_at && (
|
||||
<span className="mr-4">
|
||||
Started: {new Date(job.started_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{job.completed_at && (
|
||||
<span>
|
||||
Completed: {new Date(job.completed_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4">
|
||||
{job.status === 'paused' && (
|
||||
<button
|
||||
onClick={() => resumeMutation.mutate(job.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Resume"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{(job.status === 'paused' || job.status === 'pending') && (
|
||||
<button
|
||||
onClick={() => cancelMutation.mutate(job.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunningJobCard({
|
||||
job,
|
||||
onPause,
|
||||
onCancel,
|
||||
}: {
|
||||
job: Job
|
||||
onPause: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
// Fetch real-time progress for this job
|
||||
const { data: progress } = useQuery({
|
||||
queryKey: ['job-progress', job.id],
|
||||
queryFn: () => jobsApi.progress(job.id).then((res) => res.data),
|
||||
refetchInterval: 500, // Very fast updates for live feel
|
||||
enabled: job.status === 'running',
|
||||
})
|
||||
|
||||
const currentSpecies = progress?.current_species || ''
|
||||
const progressCurrent = progress?.progress_current ?? job.progress_current
|
||||
const progressTotal = progress?.progress_total ?? job.progress_total
|
||||
const percentage = progressTotal > 0 ? Math.round((progressCurrent / progressTotal) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-white rounded-lg shadow-lg border-2 border-blue-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<h3 className="font-semibold text-lg">{job.name}</h3>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700 animate-pulse">
|
||||
running
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Live Stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Leaf className="w-4 h-4" />
|
||||
Species Progress
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-1">
|
||||
{progressCurrent} / {progressTotal}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Download className="w-4 h-4" />
|
||||
Downloaded
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600 mt-1">
|
||||
{job.images_downloaded}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<XOctagon className="w-4 h-4" />
|
||||
Rejected
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-600 mt-1">
|
||||
{job.images_rejected}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Species */}
|
||||
{currentSpecies && (
|
||||
<div className="mt-4 bg-white rounded-lg p-3 border">
|
||||
<div className="text-sm text-gray-500 mb-1">Currently scraping:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
|
||||
</span>
|
||||
<span className="font-medium text-blue-800 italic">{currentSpecies}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{progressTotal > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Progress</span>
|
||||
<span className="font-medium">{percentage}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
Source: {job.source} • Started: {job.started_at ? new Date(job.started_at).toLocaleString() : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user