forge/frontend/components/JobTracker.tsx

290 lines
10 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStore } from '@/lib/store';
import api from '@/lib/api';
import { toast } from 'react-hot-toast';
import {
Loader2,
CheckCircle2,
XCircle,
Clock,
ChevronDown,
ChevronUp,
Image as ImageIcon,
Video,
Mic,
FileText,
X,
} from 'lucide-react';
import { clsx } from 'clsx';
const MODULE_ICONS: Record<string, any> = {
image_generator: ImageIcon,
image_upscaler: ImageIcon,
background_remover: ImageIcon,
video_generator: Video,
video_upscaler: Video,
subtitle_processor: Video,
text_to_speech: Mic,
voice_to_text: Mic,
speech_to_speech: Mic,
alt_text_generator: FileText,
prompt_studio: FileText,
};
const MODULE_LABELS: Record<string, string> = {
image_generator: 'Image Generation',
image_upscaler: 'Image Upscale',
background_remover: 'BG Removal',
video_generator: 'Video Generation',
video_upscaler: 'Video Upscale',
subtitle_processor: 'Subtitles',
text_to_speech: 'Text to Speech',
voice_to_text: 'Voice to Text',
speech_to_speech: 'Voice Clone',
alt_text_generator: 'Alt Text',
prompt_studio: 'Prompt Studio',
};
interface JobTrackerProps {
className?: string;
}
export default function JobTracker({ className }: JobTrackerProps) {
const { activeJobs, updateJob, removeJob } = useStore();
const [expanded, setExpanded] = useState(false);
const [polling, setPolling] = useState(false);
// Poll for job updates
const pollJobs = useCallback(async () => {
const pendingJobs = activeJobs.filter(
(job) => job.status === 'queued' || job.status === 'processing'
);
if (pendingJobs.length === 0) {
setPolling(false);
return;
}
setPolling(true);
for (const job of pendingJobs) {
try {
const response = await api.get(`/jobs/${job.id}`);
const updatedJob = response.data;
if (updatedJob.status !== job.status || updatedJob.progress !== job.progress) {
updateJob(job.id, {
status: updatedJob.status,
progress: updatedJob.progress,
completed_at: updatedJob.completed_at,
output_asset_ids: updatedJob.output_asset_ids,
error_message: updatedJob.error_message,
});
// Show toast on completion
if (updatedJob.status === 'completed' && job.status !== 'completed') {
toast.success(`${MODULE_LABELS[job.module] || job.module} completed!`, {
duration: 5000,
});
} else if (updatedJob.status === 'failed' && job.status !== 'failed') {
toast.error(`${MODULE_LABELS[job.module] || job.module} failed`, {
duration: 5000,
});
}
}
} catch (error) {
console.error(`Failed to poll job ${job.id}:`, error);
}
}
}, [activeJobs, updateJob]);
// Set up polling interval
useEffect(() => {
const hasPendingJobs = activeJobs.some(
(job) => job.status === 'queued' || job.status === 'processing'
);
if (!hasPendingJobs) return;
// Poll immediately
pollJobs();
// Then poll every 2 seconds
const interval = setInterval(pollJobs, 2000);
return () => clearInterval(interval);
}, [activeJobs.length, pollJobs]);
const pendingCount = activeJobs.filter(
(job) => job.status === 'queued' || job.status === 'processing'
).length;
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
case 'failed':
return <XCircle className="w-4 h-4 text-red-400" />;
case 'processing':
return <Loader2 className="w-4 h-4 text-forge-yellow animate-spin" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
return `${Math.floor(diffMin / 60)}h ago`;
};
if (activeJobs.length === 0) return null;
return (
<div className={clsx('relative', className)}>
{/* Trigger Button */}
<button
onClick={() => setExpanded(!expanded)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-colors',
pendingCount > 0
? 'bg-forge-yellow/10 text-forge-yellow'
: 'bg-forge-gray text-gray-400 hover:text-white'
)}
>
{pendingCount > 0 ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4" />
)}
<span className="text-sm font-medium">
{pendingCount > 0 ? `${pendingCount} Active` : `${activeJobs.length} Jobs`}
</span>
{expanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{/* Dropdown Panel */}
{expanded && (
<div className="absolute right-0 top-full mt-2 w-96 bg-forge-dark border border-gray-800 rounded-xl shadow-2xl z-50 overflow-hidden">
<div className="p-3 border-b border-gray-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Active Jobs</h3>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
const finished = activeJobs.filter(j => j.status === 'completed' || j.status === 'failed');
finished.forEach(j => removeJob(j.id));
}}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
Clear Finished
</button>
<span className="text-xs text-gray-500">
{polling && 'Updating...'}
</span>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{activeJobs.slice(0, 10).map((job) => {
const Icon = MODULE_ICONS[job.module] || FileText;
return (
<div
key={job.id}
className="p-3 border-b border-gray-800 last:border-0 hover:bg-forge-gray/50"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-forge-gray rounded-lg flex items-center justify-center flex-shrink-0">
<Icon className="w-4 h-4 text-forge-yellow" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-white truncate">
{MODULE_LABELS[job.module] || job.module}
</span>
<div className="flex items-center gap-2">
{getStatusIcon(job.status)}
<button
onClick={async () => {
// If job is still running, cancel it in the backend
if (job.status === 'queued' || job.status === 'processing') {
try {
await api.delete(`/jobs/${job.id}`);
toast.success('Job cancelled');
} catch (err) {
console.error('Failed to cancel job:', err);
toast.error('Failed to cancel job');
}
}
// Remove from UI
removeJob(job.id);
}}
className="p-1 text-gray-500 hover:text-red-400 transition-colors"
title={job.status === 'queued' || job.status === 'processing' ? 'Cancel job' : 'Remove from list'}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
{/* Progress bar for processing jobs */}
{(job.status === 'processing' || job.status === 'queued') && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>{job.status === 'queued' ? 'Queued' : 'Processing'}</span>
<span>{job.progress}%</span>
</div>
<div className="h-1.5 bg-forge-gray rounded-full overflow-hidden">
<div
className="h-full bg-forge-yellow transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
{/* Completed/Failed status */}
{job.status === 'completed' && (
<p className="text-xs text-green-400 mt-1">
Completed {job.completed_at && formatTime(job.completed_at)}
</p>
)}
{job.status === 'failed' && (
<p className="text-xs text-red-400 mt-1 truncate">
{job.error_message || 'Failed'}
</p>
)}
<p className="text-xs text-gray-600 mt-1">
Started {formatTime(job.created_at)}
</p>
</div>
</div>
</div>
);
})}
</div>
{activeJobs.length > 10 && (
<div className="p-2 border-t border-gray-800 text-center">
<a href="/history" className="text-xs text-forge-yellow hover:underline">
View all {activeJobs.length} jobs
</a>
</div>
)}
</div>
)}
</div>
);
}