|
@@ -0,0 +1,1330 @@
|
|
|
|
|
+"use client";
|
|
|
|
|
+
|
|
|
|
|
+import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
|
|
|
|
+import {
|
|
|
|
|
+ Card,
|
|
|
|
|
+ CardContent,
|
|
|
|
|
+ CardDescription,
|
|
|
|
|
+ CardHeader,
|
|
|
|
|
+ CardTitle,
|
|
|
|
|
+} from "@/components/ui/card";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { Badge } from "@/components/ui/badge";
|
|
|
|
|
+import { Progress } from "@/components/ui/progress";
|
|
|
|
|
+import {
|
|
|
|
|
+ type QueueStatus,
|
|
|
|
|
+ type JobInfo,
|
|
|
|
|
+ apiClient,
|
|
|
|
|
+} from "@/lib/api";
|
|
|
|
|
+import {
|
|
|
|
|
+ RefreshCw,
|
|
|
|
|
+ Trash2,
|
|
|
|
|
+ CheckCircle2,
|
|
|
|
|
+ XCircle,
|
|
|
|
|
+ Loader2,
|
|
|
|
|
+ Clock,
|
|
|
|
|
+ Image,
|
|
|
|
|
+ Activity,
|
|
|
|
|
+ AlertCircle,
|
|
|
|
|
+ Copy,
|
|
|
|
|
+ Eye,
|
|
|
|
|
+ Calendar,
|
|
|
|
|
+ Settings,
|
|
|
|
|
+ FileText,
|
|
|
|
|
+ Zap,
|
|
|
|
|
+
|
|
|
|
|
+ Download,
|
|
|
|
|
+ X,
|
|
|
|
|
+
|
|
|
|
|
+ Video,
|
|
|
|
|
+
|
|
|
|
|
+} from "lucide-react";
|
|
|
|
|
+import { cn } from "@/lib/utils";
|
|
|
|
|
+
|
|
|
|
|
+interface EnhancedQueueListProps {
|
|
|
|
|
+ queueStatus: QueueStatus | null;
|
|
|
|
|
+ loading: boolean;
|
|
|
|
|
+ onRefresh: () => void;
|
|
|
|
|
+ onCancelJob: (jobId: string) => void;
|
|
|
|
|
+ onClearQueue: () => void;
|
|
|
|
|
+ actionLoading: boolean;
|
|
|
|
|
+ onCopyParameters?: (job: JobInfo) => void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ImageModalProps {
|
|
|
|
|
+ isOpen: boolean;
|
|
|
|
|
+ onClose: () => void;
|
|
|
|
|
+ imageUrl: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ isVideo?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function ImageModal({
|
|
|
|
|
+ isOpen,
|
|
|
|
|
+ onClose,
|
|
|
|
|
+ imageUrl,
|
|
|
|
|
+ title,
|
|
|
|
|
+ isVideo = false,
|
|
|
|
|
+}: ImageModalProps) {
|
|
|
|
|
+ if (!isOpen) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4">
|
|
|
|
|
+ <div className="relative max-w-7xl max-h-full w-full">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="icon"
|
|
|
|
|
+ onClick={onClose}
|
|
|
|
|
+ className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="h-6 w-6" />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="bg-background rounded-lg shadow-2xl overflow-hidden">
|
|
|
|
|
+ <div className="p-4 border-b">
|
|
|
|
|
+ <h3 className="text-lg font-semibold truncate">{title}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="p-4">
|
|
|
|
|
+ <div className="relative flex items-center justify-center min-h-[200px] max-h-[80vh]">
|
|
|
|
|
+ {isVideo ? (
|
|
|
|
|
+ <video
|
|
|
|
|
+ src={imageUrl}
|
|
|
|
|
+ controls
|
|
|
|
|
+ className="max-w-full max-h-[80vh] rounded-lg"
|
|
|
|
|
+ preload="metadata"
|
|
|
|
|
+ >
|
|
|
|
|
+ Your browser does not support the video tag.
|
|
|
|
|
+ </video>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={imageUrl}
|
|
|
|
|
+ alt={title}
|
|
|
|
|
+ className="max-w-full max-h-[80vh] object-contain rounded-lg"
|
|
|
|
|
+ onLoad={(e) => {
|
|
|
|
|
+ const target = e.target as HTMLImageElement;
|
|
|
|
|
+ target.style.opacity = "1";
|
|
|
|
|
+ }}
|
|
|
|
|
+ onError={(e) => {
|
|
|
|
|
+ const target = e.target as HTMLImageElement;
|
|
|
|
|
+ target.style.display = "none";
|
|
|
|
|
+ const parent = target.parentElement;
|
|
|
|
|
+ if (parent) {
|
|
|
|
|
+ parent.innerHTML = `
|
|
|
|
|
+ <div class="flex flex-col items-center justify-center text-muted-foreground p-8">
|
|
|
|
|
+ <Image class="h-16 w-16 mb-4" />
|
|
|
|
|
+ <p class="text-center">Failed to load image</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ opacity: "0",
|
|
|
|
|
+ transition: "opacity 0.3s ease-in-out",
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="p-4 border-t flex justify-end">
|
|
|
|
|
+ <Button variant="outline" onClick={onClose}>
|
|
|
|
|
+ Close
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ViewMode = "compact" | "detailed";
|
|
|
|
|
+
|
|
|
|
|
+// Debounce utility for frequent updates
|
|
|
|
|
+function useDebounce<T>(value: T, delay: number): T {
|
|
|
|
|
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const handler = setTimeout(() => {
|
|
|
|
|
+ setDebouncedValue(value);
|
|
|
|
|
+ }, delay);
|
|
|
|
|
+
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ clearTimeout(handler);
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [value, delay]);
|
|
|
|
|
+
|
|
|
|
|
+ return debouncedValue;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Throttle utility for performance
|
|
|
|
|
+function useThrottle<T>(value: T, delay: number): T {
|
|
|
|
|
+ const [throttledValue, setThrottledValue] = useState<T>(value);
|
|
|
|
|
+ const lastExecuted = useRef<number>(0);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const timer = setTimeout(() => {
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ if (now - lastExecuted.current >= delay) {
|
|
|
|
|
+ setThrottledValue(value);
|
|
|
|
|
+ lastExecuted.current = now;
|
|
|
|
|
+ }
|
|
|
|
|
+ }, delay - (Date.now() - lastExecuted.current));
|
|
|
|
|
+
|
|
|
|
|
+ return () => clearTimeout(timer);
|
|
|
|
|
+ }, [value, delay]);
|
|
|
|
|
+
|
|
|
|
|
+ return throttledValue;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function EnhancedQueueList({
|
|
|
|
|
+ queueStatus,
|
|
|
|
|
+ loading,
|
|
|
|
|
+ onRefresh,
|
|
|
|
|
+ onCancelJob,
|
|
|
|
|
+ onClearQueue,
|
|
|
|
|
+ actionLoading,
|
|
|
|
|
+ onCopyParameters,
|
|
|
|
|
+}: EnhancedQueueListProps) {
|
|
|
|
|
+ const [viewMode, setViewMode] = useState<ViewMode>("detailed");
|
|
|
|
|
+ const [selectedJob, setSelectedJob] = useState<string | null>(null);
|
|
|
|
|
+ const [lastUpdateTime, setLastUpdateTime] = useState(() => Date.now());
|
|
|
|
|
+ const [filterStatus, setFilterStatus] = useState<string>("all");
|
|
|
|
|
+ const [modalState, setModalState] = useState<{
|
|
|
|
|
+ isOpen: boolean;
|
|
|
|
|
+ imageUrl: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ isVideo: boolean;
|
|
|
|
|
+ }>({
|
|
|
|
|
+ isOpen: false,
|
|
|
|
|
+ imageUrl: "",
|
|
|
|
|
+ title: "",
|
|
|
|
|
+ isVideo: false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Debounce the queue status to prevent excessive updates
|
|
|
|
|
+ const debouncedQueueStatus = useDebounce(queueStatus, 100);
|
|
|
|
|
+
|
|
|
|
|
+ // Throttle progress updates to reduce rendering frequency
|
|
|
|
|
+ const throttledJobs = useThrottle(debouncedQueueStatus?.jobs || [], 200);
|
|
|
|
|
+
|
|
|
|
|
+ // Update the last update time when we get new data
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (queueStatus?.jobs) {
|
|
|
|
|
+ const timer = setTimeout(() => setLastUpdateTime(Date.now()), 0);
|
|
|
|
|
+ return () => clearTimeout(timer);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [queueStatus?.jobs]);
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate queue statistics with memoization and throttling
|
|
|
|
|
+ const queueStats = useMemo(() => {
|
|
|
|
|
+ if (!throttledJobs.length)
|
|
|
|
|
+ return { total: 0, active: 0, queued: 0, completed: 0, failed: 0 };
|
|
|
|
|
+
|
|
|
|
|
+ // Use throttled jobs to reduce computation frequency
|
|
|
|
|
+ const stats = {
|
|
|
|
|
+ total: throttledJobs.length,
|
|
|
|
|
+ active: 0,
|
|
|
|
|
+ queued: 0,
|
|
|
|
|
+ completed: 0,
|
|
|
|
|
+ failed: 0,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Optimized counting with single pass
|
|
|
|
|
+ throttledJobs.forEach((job) => {
|
|
|
|
|
+ switch (job.status) {
|
|
|
|
|
+ case "processing":
|
|
|
|
|
+ stats.active++;
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "queued":
|
|
|
|
|
+ stats.queued++;
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "completed":
|
|
|
|
|
+ stats.completed++;
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "failed":
|
|
|
|
|
+ stats.failed++;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return stats;
|
|
|
|
|
+ }, [throttledJobs]);
|
|
|
|
|
+
|
|
|
|
|
+ // Memoized job sorting with better performance and filtering
|
|
|
|
|
+ const sortedJobs = useMemo(() => {
|
|
|
|
|
+ if (!throttledJobs.length) return [];
|
|
|
|
|
+
|
|
|
|
|
+ // Apply status filter
|
|
|
|
|
+ const filteredJobs =
|
|
|
|
|
+ filterStatus === "all"
|
|
|
|
|
+ ? throttledJobs
|
|
|
|
|
+ : throttledJobs.filter((job) => job.status === filterStatus);
|
|
|
|
|
+
|
|
|
|
|
+ const statusPriority = {
|
|
|
|
|
+ processing: 0,
|
|
|
|
|
+ queued: 1,
|
|
|
|
|
+ pending: 2,
|
|
|
|
|
+ completed: 3,
|
|
|
|
|
+ failed: 4,
|
|
|
|
|
+ cancelled: 5,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Use a more efficient sorting approach
|
|
|
|
|
+ const jobsByStatus: Record<string, JobInfo[]> = {
|
|
|
|
|
+ processing: [],
|
|
|
|
|
+ queued: [],
|
|
|
|
|
+ pending: [],
|
|
|
|
|
+ completed: [],
|
|
|
|
|
+ failed: [],
|
|
|
|
|
+ cancelled: [],
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Group jobs by status first
|
|
|
|
|
+ filteredJobs.forEach((job) => {
|
|
|
|
|
+ const status = job.status as keyof typeof jobsByStatus;
|
|
|
|
|
+ if (jobsByStatus[status]) {
|
|
|
|
|
+ jobsByStatus[status].push(job);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Sort within each status group and concatenate
|
|
|
|
|
+ const result: JobInfo[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ Object.entries(statusPriority)
|
|
|
|
|
+ .sort(([, a], [, b]) => a - b)
|
|
|
|
|
+ .forEach(([status]) => {
|
|
|
|
|
+ const statusJobs = jobsByStatus[status] || [];
|
|
|
|
|
+ statusJobs.sort((a, b) => {
|
|
|
|
|
+ const timeA = new Date(a.created_at || 0).getTime();
|
|
|
|
|
+ const timeB = new Date(b.created_at || 0).getTime();
|
|
|
|
|
+ return timeB - timeA; // Newest first
|
|
|
|
|
+ });
|
|
|
|
|
+ result.push(...statusJobs);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, [throttledJobs, filterStatus]);
|
|
|
|
|
+
|
|
|
|
|
+ // Memoized status icons and colors to prevent recreation
|
|
|
|
|
+ const statusConfig = useMemo(
|
|
|
|
|
+ () => ({
|
|
|
|
|
+ icons: {
|
|
|
|
|
+ completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
|
|
|
|
|
+ failed: <XCircle className="h-4 w-4 text-red-500" />,
|
|
|
|
|
+ processing: <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />,
|
|
|
|
|
+ queued: <Clock className="h-4 w-4 text-yellow-500" />,
|
|
|
|
|
+ cancelled: <XCircle className="h-4 w-4 text-gray-500" />,
|
|
|
|
|
+ pending: <AlertCircle className="h-4 w-4 text-gray-500" />,
|
|
|
|
|
+ },
|
|
|
|
|
+ colors: {
|
|
|
|
|
+ completed: "text-green-600 dark:text-green-400",
|
|
|
|
|
+ failed: "text-red-600 dark:text-red-400",
|
|
|
|
|
+ processing: "text-blue-600 dark:text-blue-400",
|
|
|
|
|
+ queued: "text-yellow-600 dark:text-yellow-400",
|
|
|
|
|
+ cancelled: "text-gray-600 dark:text-gray-400",
|
|
|
|
|
+ pending: "text-gray-600 dark:text-gray-400",
|
|
|
|
|
+ },
|
|
|
|
|
+ badges: {
|
|
|
|
|
+ completed: "default" as const,
|
|
|
|
|
+ failed: "destructive" as const,
|
|
|
|
|
+ processing: "secondary" as const,
|
|
|
|
|
+ queued: "outline" as const,
|
|
|
|
|
+ cancelled: "outline" as const,
|
|
|
|
|
+ pending: "outline" as const,
|
|
|
|
|
+ },
|
|
|
|
|
+ }),
|
|
|
|
|
+ [],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const getStatusIcon = useCallback(
|
|
|
|
|
+ (status: string) => {
|
|
|
|
|
+ return (
|
|
|
|
|
+ statusConfig.icons[status as keyof typeof statusConfig.icons] ||
|
|
|
|
|
+ statusConfig.icons.pending
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ [statusConfig],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const getStatusColor = useCallback(
|
|
|
|
|
+ (status: string) => {
|
|
|
|
|
+ return (
|
|
|
|
|
+ statusConfig.colors[status as keyof typeof statusConfig.colors] ||
|
|
|
|
|
+ statusConfig.colors.pending
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ [statusConfig],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const getStatusBadgeVariant = useCallback(
|
|
|
|
|
+ (status: string) => {
|
|
|
|
|
+ return (
|
|
|
|
|
+ statusConfig.badges[status as keyof typeof statusConfig.badges] ||
|
|
|
|
|
+ "outline"
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ [statusConfig],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Optimized duration formatting with better readability
|
|
|
|
|
+ const formatDuration = useCallback((startTime: string, endTime?: string) => {
|
|
|
|
|
+ if (!startTime) return "Unknown";
|
|
|
|
|
+
|
|
|
|
|
+ const start = new Date(startTime).getTime();
|
|
|
|
|
+ const end = endTime ? new Date(endTime).getTime() : Date.now();
|
|
|
|
|
+ const duration = Math.max(0, Math.floor((end - start) / 1000));
|
|
|
|
|
+
|
|
|
|
|
+ if (duration < 60) return `${duration}s`;
|
|
|
|
|
+ if (duration < 3600) {
|
|
|
|
|
+ const minutes = Math.floor(duration / 60);
|
|
|
|
|
+ const seconds = duration % 60;
|
|
|
|
|
+ return `${minutes}m ${seconds}s`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (duration < 86400) {
|
|
|
|
|
+ const hours = Math.floor(duration / 3600);
|
|
|
|
|
+ const minutes = Math.floor((duration % 3600) / 60);
|
|
|
|
|
+ return `${hours}h ${minutes}m`;
|
|
|
|
|
+ }
|
|
|
|
|
+ const days = Math.floor(duration / 86400);
|
|
|
|
|
+ const hours = Math.floor((duration % 86400) / 3600);
|
|
|
|
|
+ return `${days}d ${hours}h`;
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // Memoized job type detection
|
|
|
|
|
+ const getJobType = useCallback((job: JobInfo) => {
|
|
|
|
|
+ const message = (job.message || "").toLowerCase();
|
|
|
|
|
+ if (message.includes("text2img") || message.includes("text to image"))
|
|
|
|
|
+ return "Text to Image";
|
|
|
|
|
+ if (message.includes("img2img") || message.includes("image to image"))
|
|
|
|
|
+ return "Image to Image";
|
|
|
|
|
+ if (message.includes("upscale") || message.includes("upscaler"))
|
|
|
|
|
+ return "Upscale";
|
|
|
|
|
+ if (message.includes("convert") || message.includes("conversion"))
|
|
|
|
|
+ return "Model Conversion";
|
|
|
|
|
+ return "Unknown";
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const getJobTypeIcon = useCallback(
|
|
|
|
|
+ (job: JobInfo) => {
|
|
|
|
|
+ const type = getJobType(job);
|
|
|
|
|
+ switch (type) {
|
|
|
|
|
+ case "Text to Image":
|
|
|
|
|
+ return <Image className="h-4 w-4" />;
|
|
|
|
|
+ case "Image to Image":
|
|
|
|
|
+ return <Image className="h-4 w-4" />;
|
|
|
|
|
+ case "Upscale":
|
|
|
|
|
+ return <Zap className="h-4 w-4" />;
|
|
|
|
|
+ case "Model Conversion":
|
|
|
|
|
+ return <Settings className="h-4 w-4" />;
|
|
|
|
|
+ default:
|
|
|
|
|
+ return <FileText className="h-4 w-4" />;
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ [getJobType],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Generate image URL from file path using the API client
|
|
|
|
|
+ const getImageUrl = useCallback(
|
|
|
|
|
+ (
|
|
|
|
|
+ jobId: string,
|
|
|
|
|
+ output: { url: string; path: string; filename?: string },
|
|
|
|
|
+ thumbnail: boolean = false,
|
|
|
|
|
+ ) => {
|
|
|
|
|
+ const filename = output.filename || output.path.split("/").pop();
|
|
|
|
|
+
|
|
|
|
|
+ if (!filename) {
|
|
|
|
|
+ console.error("No filename found for output:", output);
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Use the API client's getImageUrl method for consistency and proper authentication
|
|
|
|
|
+ const baseUrl = apiClient.getImageUrl(jobId, filename);
|
|
|
|
|
+
|
|
|
|
|
+ if (thumbnail) {
|
|
|
|
|
+ // Add thumbnail parameters
|
|
|
|
|
+ return `${baseUrl}&thumb=1&size=200`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return baseUrl;
|
|
|
|
|
+ },
|
|
|
|
|
+ [],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ // Check if file is a video based on extension
|
|
|
|
|
+ const isVideoFile = useCallback((filename: string) => {
|
|
|
|
|
+ const videoExtensions = [".mp4", ".avi", ".mov", ".mkv", ".webm", ".gif"];
|
|
|
|
|
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
|
|
|
|
+ return videoExtensions.includes(ext);
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // Open image modal
|
|
|
|
|
+ const openImageModal = useCallback(
|
|
|
|
|
+ (imageUrl: string, title: string, isVideo: boolean = false) => {
|
|
|
|
|
+ setModalState({
|
|
|
|
|
+ isOpen: true,
|
|
|
|
|
+ imageUrl,
|
|
|
|
|
+ title,
|
|
|
|
|
+ isVideo,
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ [],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Close image modal
|
|
|
|
|
+ const closeImageModal = useCallback(() => {
|
|
|
|
|
+ setModalState({
|
|
|
|
|
+ isOpen: false,
|
|
|
|
|
+ imageUrl: "",
|
|
|
|
|
+ title: "",
|
|
|
|
|
+ isVideo: false,
|
|
|
|
|
+ });
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // Enhanced parameter extraction
|
|
|
|
|
+ const extractParameters = useCallback(
|
|
|
|
|
+ (job: JobInfo) => {
|
|
|
|
|
+ const params: Record<string, string | number | boolean | undefined> = {};
|
|
|
|
|
+
|
|
|
|
|
+ // Basic job information
|
|
|
|
|
+ if (job.id || job.request_id) {
|
|
|
|
|
+ params["Job ID"] = job.id || job.request_id;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.status) {
|
|
|
|
|
+ params["Status"] = job.status;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.message) {
|
|
|
|
|
+ params["Message"] = job.message;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.queue_position !== undefined) {
|
|
|
|
|
+ params["Queue Position"] = job.queue_position;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.progress !== undefined) {
|
|
|
|
|
+ params["Progress"] = `${Math.round(job.progress * 100)}%`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Timing information
|
|
|
|
|
+ if (job.created_at) {
|
|
|
|
|
+ params["Created"] = new Date(job.created_at).toLocaleString();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.updated_at && job.updated_at !== job.created_at) {
|
|
|
|
|
+ params["Updated"] = new Date(job.updated_at).toLocaleString();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.created_at) {
|
|
|
|
|
+ params["Duration"] = formatDuration(job.created_at, job.updated_at);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Output information
|
|
|
|
|
+ if (job.outputs?.length) {
|
|
|
|
|
+ params["Output Files"] = job.outputs.length;
|
|
|
|
|
+ params["Filenames"] = job.outputs
|
|
|
|
|
+ .map((o) => o.filename || o.path.split("/").pop())
|
|
|
|
|
+ .join(", ");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (job.result?.images?.length) {
|
|
|
|
|
+ params["Legacy Images"] = job.result.images.length;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Error information
|
|
|
|
|
+ if (job.error) {
|
|
|
|
|
+ params["Error"] = job.error;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return params;
|
|
|
|
|
+ },
|
|
|
|
|
+ [formatDuration],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Debounced copy function to prevent spam
|
|
|
|
|
+ const copyParameters = useCallback(
|
|
|
|
|
+ (job: JobInfo) => {
|
|
|
|
|
+ const params = extractParameters(job);
|
|
|
|
|
+ if (Object.keys(params).length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ const paramsText = Object.entries(params)
|
|
|
|
|
+ .map(([key, value]) => `${key}: ${value}`)
|
|
|
|
|
+ .join("\n");
|
|
|
|
|
+
|
|
|
|
|
+ // Use clipboard API with fallback
|
|
|
|
|
+ if (navigator.clipboard?.writeText) {
|
|
|
|
|
+ navigator.clipboard.writeText(paramsText).catch(() => {
|
|
|
|
|
+ // Fallback for older browsers
|
|
|
|
|
+ const textArea = document.createElement("textarea");
|
|
|
|
|
+ textArea.value = paramsText;
|
|
|
|
|
+ document.body.appendChild(textArea);
|
|
|
|
|
+ textArea.select();
|
|
|
|
|
+ document.execCommand("copy");
|
|
|
|
|
+ document.body.removeChild(textArea);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onCopyParameters?.(job);
|
|
|
|
|
+ },
|
|
|
|
|
+ [extractParameters, onCopyParameters],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Memoized event handlers
|
|
|
|
|
+ const handleSelectedJobToggle = useCallback((jobId: string | null) => {
|
|
|
|
|
+ setSelectedJob((current) => (current === jobId ? null : jobId));
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const handleCancelJob = useCallback(
|
|
|
|
|
+ (jobId: string) => {
|
|
|
|
|
+ onCancelJob(jobId);
|
|
|
|
|
+ },
|
|
|
|
|
+ [onCancelJob],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Progress update optimization - only update progress if it changed significantly
|
|
|
|
|
+ const ProgressBar = useCallback(
|
|
|
|
|
+ ({ job }: { job: JobInfo }) => {
|
|
|
|
|
+ if (job.progress === undefined) return null;
|
|
|
|
|
+
|
|
|
|
|
+ // Ensure progress is properly clamped between 0-100
|
|
|
|
|
+ const progressValue = Math.max(0, Math.min(100, job.progress * 100));
|
|
|
|
|
+
|
|
|
|
|
+ // Get appropriate status message based on actual job status
|
|
|
|
|
+ const getStatusMessage = () => {
|
|
|
|
|
+ switch (job.status) {
|
|
|
|
|
+ case "completed":
|
|
|
|
|
+ return "Completed";
|
|
|
|
|
+ case "failed":
|
|
|
|
|
+ return job.error || "Failed";
|
|
|
|
|
+ case "cancelled":
|
|
|
|
|
+ return "Cancelled";
|
|
|
|
|
+ case "queued":
|
|
|
|
|
+ return `Queued${job.queue_position ? ` (Position: ${job.queue_position})` : ""}`;
|
|
|
|
|
+ case "processing":
|
|
|
|
|
+ return job.message || "Processing...";
|
|
|
|
|
+ default:
|
|
|
|
|
+ return job.message || "Pending...";
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <div className="flex items-center justify-between text-sm">
|
|
|
|
|
+ <span className={cn("font-medium", getStatusColor(job.status))}>
|
|
|
|
|
+ {getStatusMessage()}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {job.status === "processing" && (
|
|
|
|
|
+ <span className="text-muted-foreground">
|
|
|
|
|
+ {Math.round(progressValue)}%
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {job.status === "processing" && (
|
|
|
|
|
+ <Progress value={progressValue} className="h-2" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ [getStatusColor],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Skip rendering if no meaningful changes
|
|
|
|
|
+ if (!queueStatus && !loading) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-6">
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardContent className="text-center py-12">
|
|
|
|
|
+ <Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
|
|
|
+ <p className="text-muted-foreground">Loading queue status...</p>
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-6">
|
|
|
|
|
+ {/* Queue Status */}
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardHeader>
|
|
|
|
|
+ <div className="flex flex-col gap-4">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <CardTitle>Queue Status</CardTitle>
|
|
|
|
|
+ <CardDescription>
|
|
|
|
|
+ Current queue status and statistics
|
|
|
|
|
+ {lastUpdateTime && (
|
|
|
|
|
+ <span className="text-xs text-muted-foreground block">
|
|
|
|
|
+ Last updated:{" "}
|
|
|
|
|
+ {new Date(lastUpdateTime).toLocaleTimeString()}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </CardDescription>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={viewMode === "detailed" ? "default" : "outline"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setViewMode("detailed")}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Eye className="h-4 w-4 mr-2" />
|
|
|
|
|
+ Detailed
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={viewMode === "compact" ? "default" : "outline"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setViewMode("compact")}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Activity className="h-4 w-4 mr-2" />
|
|
|
|
|
+ Compact
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button onClick={onRefresh} disabled={loading}>
|
|
|
|
|
+ <RefreshCw
|
|
|
|
|
+ className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
|
|
|
|
+ />
|
|
|
|
|
+ Refresh
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ onClick={onClearQueue}
|
|
|
|
|
+ disabled={actionLoading || !throttledJobs.length}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 className="h-4 w-4 mr-2" />
|
|
|
|
|
+ Clear Queue
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Filter Controls */}
|
|
|
|
|
+ <div className="flex items-center gap-4 flex-wrap">
|
|
|
|
|
+ <span className="text-sm font-medium">Filter:</span>
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={filterStatus === "all" ? "default" : "outline"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setFilterStatus("all")}
|
|
|
|
|
+ >
|
|
|
|
|
+ All ({queueStats.total})
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={
|
|
|
|
|
+ filterStatus === "processing" ? "default" : "outline"
|
|
|
|
|
+ }
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setFilterStatus("processing")}
|
|
|
|
|
+ >
|
|
|
|
|
+ Active ({queueStats.active})
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={filterStatus === "queued" ? "default" : "outline"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setFilterStatus("queued")}
|
|
|
|
|
+ >
|
|
|
|
|
+ Queued ({queueStats.queued})
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={filterStatus === "completed" ? "default" : "outline"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setFilterStatus("completed")}
|
|
|
|
|
+ >
|
|
|
|
|
+ Completed ({queueStats.completed})
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={filterStatus === "failed" ? "default" : "outline"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setFilterStatus("failed")}
|
|
|
|
|
+ >
|
|
|
|
|
+ Failed ({queueStats.failed})
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </CardHeader>
|
|
|
|
|
+ <CardContent>
|
|
|
|
|
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <div className="text-2xl font-bold">{queueStats.total}</div>
|
|
|
|
|
+ <div className="text-sm text-muted-foreground">Total Jobs</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <div className="text-2xl font-bold text-blue-600">
|
|
|
|
|
+ {queueStats.active}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-sm text-muted-foreground">Active</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <div className="text-2xl font-bold text-yellow-600">
|
|
|
|
|
+ {queueStats.queued}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-sm text-muted-foreground">Queued</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <div className="text-2xl font-bold text-green-600">
|
|
|
|
|
+ {queueStats.completed}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-sm text-muted-foreground">Completed</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <div className="text-2xl font-bold text-red-600">
|
|
|
|
|
+ {queueStats.failed}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-sm text-muted-foreground">Failed</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Jobs List */}
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {sortedJobs.map((job) => {
|
|
|
|
|
+ const jobId = job.id || job.request_id;
|
|
|
|
|
+ if (!jobId) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Card
|
|
|
|
|
+ key={jobId}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "transition-all hover:shadow-md",
|
|
|
|
|
+ job.status === "processing" &&
|
|
|
|
|
+ "border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20",
|
|
|
|
|
+ job.status === "completed" &&
|
|
|
|
|
+ "border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20",
|
|
|
|
|
+ job.status === "failed" &&
|
|
|
|
|
+ "border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20",
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ <CardContent className="p-6">
|
|
|
|
|
+ {viewMode === "detailed" ? (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {/* Header */}
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ {getStatusIcon(job.status)}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3 className="font-semibold">Job {jobId}</h3>
|
|
|
|
|
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
+ {getJobTypeIcon(job)}
|
|
|
|
|
+ <span>{getJobType(job)}</span>
|
|
|
|
|
+ {job.queue_position !== undefined && (
|
|
|
|
|
+ <span>• Position: {job.queue_position}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Badge variant={getStatusBadgeVariant(job.status)}>
|
|
|
|
|
+ {job.status}
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ {(job.status === "queued" ||
|
|
|
|
|
+ job.status === "processing") && (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => handleCancelJob(jobId)}
|
|
|
|
|
+ disabled={actionLoading}
|
|
|
|
|
+ >
|
|
|
|
|
+ <XCircle className="h-4 w-4" />
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Progress */}
|
|
|
|
|
+ <ProgressBar job={job} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Details Grid */}
|
|
|
|
|
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span className="text-muted-foreground flex items-center gap-1">
|
|
|
|
|
+ <Calendar className="h-3 w-3" />
|
|
|
|
|
+ Created
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <p className="font-medium">
|
|
|
|
|
+ {job.created_at
|
|
|
|
|
+ ? new Date(job.created_at).toLocaleString()
|
|
|
|
|
+ : "Unknown"}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {job.updated_at && job.updated_at !== job.created_at && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span className="text-muted-foreground flex items-center gap-1">
|
|
|
|
|
+ <Clock className="h-3 w-3" />
|
|
|
|
|
+ Updated
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <p className="font-medium">
|
|
|
|
|
+ {new Date(job.updated_at).toLocaleString()}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {job.created_at && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span className="text-muted-foreground flex items-center gap-1">
|
|
|
|
|
+ <Clock className="h-3 w-3" />
|
|
|
|
|
+ Duration
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <p className="font-medium">
|
|
|
|
|
+ {formatDuration(job.created_at, job.updated_at)}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span className="text-muted-foreground flex items-center gap-1">
|
|
|
|
|
+ <FileText className="h-3 w-3" />
|
|
|
|
|
+ Parameters
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div className="flex gap-1">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-6 px-2 text-xs"
|
|
|
|
|
+ onClick={() => copyParameters(job)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Copy className="h-3 w-3 mr-1" />
|
|
|
|
|
+ Copy
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-6 px-2 text-xs"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ handleSelectedJobToggle(
|
|
|
|
|
+ selectedJob === jobId ? null : jobId,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ {selectedJob === jobId ? "Hide" : "Show"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Expanded Parameters */}
|
|
|
|
|
+ {selectedJob === jobId && (
|
|
|
|
|
+ <div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h4 className="font-medium text-sm">
|
|
|
|
|
+ Job Details & Parameters
|
|
|
|
|
+ </h4>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-6 px-2 text-xs"
|
|
|
|
|
+ onClick={() => copyParameters(job)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Copy className="h-3 w-3 mr-1" />
|
|
|
|
|
+ Copy All
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="grid gap-3 text-sm">
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const params = extractParameters(job);
|
|
|
|
|
+ const entries = Object.entries(params);
|
|
|
|
|
+ return entries.length > 0 ? (
|
|
|
|
|
+ entries.map(([key, value]) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={key}
|
|
|
|
|
+ className="flex justify-between items-start gap-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="text-muted-foreground capitalize min-w-[100px] text-xs">
|
|
|
|
|
+ {key}:
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="font-mono text-xs break-all flex-1 text-right">
|
|
|
|
|
+ {String(value)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <p className="text-muted-foreground text-sm">
|
|
|
|
|
+ No parameters available
|
|
|
|
|
+ </p>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Results - Enhanced with better thumbnails and click-to-view */}
|
|
|
|
|
+ {job.status === "completed" &&
|
|
|
|
|
+ job.outputs &&
|
|
|
|
|
+ job.outputs.length > 0 && (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h4 className="font-medium text-sm flex items-center gap-2">
|
|
|
|
|
+ <Image className="h-4 w-4" />
|
|
|
|
|
+ Generated Images ({job.outputs.length})
|
|
|
|
|
+ </h4>
|
|
|
|
|
+ <div className="text-xs text-muted-foreground">
|
|
|
|
|
+ Click image to view full size • Hover to download
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
|
|
|
+ {job.outputs.map((output, index) => {
|
|
|
|
|
+ const isVideo = isVideoFile(
|
|
|
|
|
+ output.filename || "",
|
|
|
|
|
+ );
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={index} className="relative group">
|
|
|
|
|
+ <div className="aspect-square bg-muted rounded-lg overflow-hidden border border-border/50 hover:border-border transition-all duration-200 cursor-pointer">
|
|
|
|
|
+ {isVideo ? (
|
|
|
|
|
+ <video
|
|
|
|
|
+ src={getImageUrl(jobId, output)}
|
|
|
|
|
+ className="w-full h-full object-cover"
|
|
|
|
|
+ muted
|
|
|
|
|
+ loop
|
|
|
|
|
+ playsInline
|
|
|
|
|
+ onMouseEnter={(e) => {
|
|
|
|
|
+ const video =
|
|
|
|
|
+ e.target as HTMLVideoElement;
|
|
|
|
|
+ video.play().catch(() => { });
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseLeave={(e) => {
|
|
|
|
|
+ const video =
|
|
|
|
|
+ e.target as HTMLVideoElement;
|
|
|
|
|
+ video.pause();
|
|
|
|
|
+ video.currentTime = 0;
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ getImageUrl(jobId, output, false),
|
|
|
|
|
+ `Generated ${output.filename || `video ${index + 1}`}`,
|
|
|
|
|
+ true,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={getImageUrl(jobId, output, true)}
|
|
|
|
|
+ alt={`Generated image ${index + 1}`}
|
|
|
|
|
+ className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ getImageUrl(jobId, output, false),
|
|
|
|
|
+ `Generated ${output.filename || `image ${index + 1}`}`,
|
|
|
|
|
+ false,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ onError={(e) => {
|
|
|
|
|
+ // Enhanced fallback for failed images
|
|
|
|
|
+ const target =
|
|
|
|
|
+ e.target as HTMLImageElement;
|
|
|
|
|
+ target.style.display = "none";
|
|
|
|
|
+ const parent = target.parentElement;
|
|
|
|
|
+ if (parent) {
|
|
|
|
|
+ parent.innerHTML = `
|
|
|
|
|
+ <div class="w-full h-full flex items-center justify-center text-muted-foreground bg-muted/50">
|
|
|
|
|
+ <div class="text-center">
|
|
|
|
|
+ <svg class="h-8 w-8 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <p class="text-xs">Load failed</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Hover overlay with actions */}
|
|
|
|
|
+ <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-200 rounded-lg flex items-center justify-center gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-8 px-2 text-xs"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ getImageUrl(jobId, output, false),
|
|
|
|
|
+ `Generated ${output.filename || `image ${index + 1}`}`,
|
|
|
|
|
+ isVideo,
|
|
|
|
|
+ );
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Eye className="h-3 w-3 mr-1" />
|
|
|
|
|
+ View
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-8 px-2 text-xs"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ const url = getImageUrl(jobId, output);
|
|
|
|
|
+ const link =
|
|
|
|
|
+ document.createElement("a");
|
|
|
|
|
+ link.href = url;
|
|
|
|
|
+ link.download =
|
|
|
|
|
+ output.filename ||
|
|
|
|
|
+ `generated-${index + 1}.${isVideo ? "mp4" : "png"}`;
|
|
|
|
|
+ document.body.appendChild(link);
|
|
|
|
|
+ link.click();
|
|
|
|
|
+ document.body.removeChild(link);
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Download className="h-3 w-3 mr-1" />
|
|
|
|
|
+ Save
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* File type indicator */}
|
|
|
|
|
+ <div className="absolute top-2 right-2">
|
|
|
|
|
+ {isVideo && (
|
|
|
|
|
+ <div className="bg-black/70 text-white text-xs px-1.5 py-0.5 rounded flex items-center gap-1">
|
|
|
|
|
+ <Video className="h-3 w-3" />
|
|
|
|
|
+ Video
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Results - Enhanced backwards compatibility for result.images */}
|
|
|
|
|
+ {job.status === "completed" &&
|
|
|
|
|
+ job.result?.images &&
|
|
|
|
|
+ job.result.images.length > 0 && (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h4 className="font-medium text-sm flex items-center gap-2">
|
|
|
|
|
+ <Image className="h-4 w-4" />
|
|
|
|
|
+ Generated Images ({job.result.images.length})
|
|
|
|
|
+ </h4>
|
|
|
|
|
+ <div className="text-xs text-muted-foreground">
|
|
|
|
|
+ Legacy format • Click to view
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
|
|
|
+ {job.result.images.map((imageData, index) => (
|
|
|
|
|
+ <div key={index} className="relative group">
|
|
|
|
|
+ <div className="aspect-square bg-muted rounded-lg overflow-hidden border border-border/50 hover:border-border transition-all duration-200 cursor-pointer">
|
|
|
|
|
+ {imageData.startsWith("data:image") ? (
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={imageData}
|
|
|
|
|
+ alt={`Generated image ${index + 1}`}
|
|
|
|
|
+ className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ imageData,
|
|
|
|
|
+ `Generated legacy image ${index + 1}`,
|
|
|
|
|
+ false,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="w-full h-full flex items-center justify-center text-muted-foreground bg-muted/50 cursor-pointer"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ imageData,
|
|
|
|
|
+ `Generated image ${index + 1}`,
|
|
|
|
|
+ false,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <Image className="h-8 w-8 mx-auto mb-2" />
|
|
|
|
|
+ <p className="text-xs">No preview</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Hover overlay */}
|
|
|
|
|
+ <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-200 rounded-lg flex items-center justify-center gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-8 px-2 text-xs"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ imageData,
|
|
|
|
|
+ `Generated image ${index + 1}`,
|
|
|
|
|
+ false,
|
|
|
|
|
+ );
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Eye className="h-3 w-3 mr-1" />
|
|
|
|
|
+ View
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {imageData.startsWith("data:image") && (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-8 px-2 text-xs"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ const link =
|
|
|
|
|
+ document.createElement("a");
|
|
|
|
|
+ link.href = imageData;
|
|
|
|
|
+ link.download = `generated-image-${index + 1}.png`;
|
|
|
|
|
+ document.body.appendChild(link);
|
|
|
|
|
+ link.click();
|
|
|
|
|
+ document.body.removeChild(link);
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Download className="h-3 w-3 mr-1" />
|
|
|
|
|
+ Save
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Error */}
|
|
|
|
|
+ {job.error && (
|
|
|
|
|
+ <div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
|
|
|
|
+ <p className="text-sm text-red-700 dark:text-red-300">
|
|
|
|
|
+ <strong>Error:</strong> {job.error}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ /* Compact View with thumbnails */
|
|
|
|
|
+ <div className="flex items-center justify-between gap-4">
|
|
|
|
|
+ <div className="flex items-center gap-4 flex-1 min-w-0">
|
|
|
|
|
+ {getStatusIcon(job.status)}
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="flex items-center gap-2 mb-1">
|
|
|
|
|
+ <h3 className="font-semibold truncate">
|
|
|
|
|
+ Job {jobId}
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <Badge
|
|
|
|
|
+ variant={getStatusBadgeVariant(job.status)}
|
|
|
|
|
+ className="text-xs"
|
|
|
|
|
+ >
|
|
|
|
|
+ {job.status}
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
|
|
|
|
+ {getJobTypeIcon(job)}
|
|
|
|
|
+ <span className="truncate">{getJobType(job)}</span>
|
|
|
|
|
+ {job.queue_position !== undefined && (
|
|
|
|
|
+ <span>• Pos: {job.queue_position}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {job.created_at && (
|
|
|
|
|
+ <span>
|
|
|
|
|
+ • {new Date(job.created_at).toLocaleTimeString()}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {job.message && job.status !== "completed" && (
|
|
|
|
|
+ <p className="text-sm text-muted-foreground truncate">
|
|
|
|
|
+ {job.message}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Thumbnail preview for completed jobs in compact view */}
|
|
|
|
|
+ {job.status === "completed" &&
|
|
|
|
|
+ (job.outputs?.length ||
|
|
|
|
|
+ job.result?.images?.length) && (
|
|
|
|
|
+ <div className="flex gap-1 mt-2">
|
|
|
|
|
+ {(job.outputs || [])
|
|
|
|
|
+ .slice(0, 3)
|
|
|
|
|
+ .map((output, index) => (
|
|
|
|
|
+ <div key={index} className="relative group">
|
|
|
|
|
+ <div className="w-12 h-12 bg-muted rounded overflow-hidden border border-border/50">
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={getImageUrl(jobId, output, true)}
|
|
|
|
|
+ alt={`Generated ${index + 1}`}
|
|
|
|
|
+ className="w-full h-full object-cover"
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ openImageModal(
|
|
|
|
|
+ getImageUrl(jobId, output, false),
|
|
|
|
|
+ `Generated ${output.filename || `image ${index + 1}`}`,
|
|
|
|
|
+ isVideoFile(output.filename || ""),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {(job.outputs || job.result?.images || [])
|
|
|
|
|
+ .length > 3 && (
|
|
|
|
|
+ <div className="w-12 h-12 bg-muted rounded border border-border/50 flex items-center justify-center text-xs text-muted-foreground">
|
|
|
|
|
+ +
|
|
|
|
|
+ {(job.outputs || job.result?.images || [])
|
|
|
|
|
+ .length - 3}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-4 flex-shrink-0">
|
|
|
|
|
+ {job.progress !== undefined &&
|
|
|
|
|
+ job.status === "processing" && (
|
|
|
|
|
+ <div className="text-right">
|
|
|
|
|
+ <div className="text-sm font-medium">
|
|
|
|
|
+ {Math.max(0, Math.min(100, Math.round(job.progress * 100)))}%
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="w-24 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="h-full bg-blue-500 transition-all duration-300"
|
|
|
|
|
+ style={{ width: `${Math.max(0, Math.min(100, job.progress * 100))}%` }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {(job.status === "queued" ||
|
|
|
|
|
+ job.status === "processing") && (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => handleCancelJob(jobId)}
|
|
|
|
|
+ disabled={actionLoading}
|
|
|
|
|
+ >
|
|
|
|
|
+ <XCircle className="h-4 w-4" />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {(!throttledJobs || throttledJobs.length === 0) && (
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardContent className="text-center py-12">
|
|
|
|
|
+ <Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
|
|
|
+ <h3 className="text-lg font-semibold mb-2">
|
|
|
|
|
+ {filterStatus === "all"
|
|
|
|
|
+ ? "No jobs in queue"
|
|
|
|
|
+ : `No ${filterStatus} jobs`}
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <p className="text-muted-foreground mb-4">
|
|
|
|
|
+ {filterStatus === "all"
|
|
|
|
|
+ ? "Start a generation to see jobs appear here."
|
|
|
|
|
+ : `Try changing the filter to see more jobs.`}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ {filterStatus !== "all" && (
|
|
|
|
|
+ <Button variant="outline" onClick={() => setFilterStatus("all")}>
|
|
|
|
|
+ Show All Jobs
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Image Modal */}
|
|
|
|
|
+ <ImageModal
|
|
|
|
|
+ isOpen={modalState.isOpen}
|
|
|
|
|
+ onClose={closeImageModal}
|
|
|
|
|
+ imageUrl={modalState.imageUrl}
|
|
|
|
|
+ title={modalState.title}
|
|
|
|
|
+ isVideo={modalState.isVideo}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|