290 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|