355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|