Files
igny8/frontend/src/components/common/ImageQueueModal.tsx
IGNY8 VPS (Salman) 3283a83b42 feat(migrations): Rename indexes and update global integration settings fields for improved clarity and functionality
feat(admin): Add API monitoring, debug console, and system health templates for enhanced admin interface

docs: Add AI system cleanup summary and audit report detailing architecture, token management, and recommendations

docs: Introduce credits and tokens system guide outlining configuration, data flow, and monitoring strategies
2025-12-20 12:55:05 +00:00

608 lines
23 KiB
TypeScript

/**
* ImageQueueModal - Displays image generation queue with individual progress bars
* Similar to WP plugin's image-queue-processor.js modal
* Stage 1: Shows all progress bars immediately when Generate button is clicked
*/
import React, { useEffect, useState, useRef } from 'react';
import { Modal } from '../ui/modal';
import { FileIcon, TimeIcon, CheckCircleIcon, ErrorIcon } from '../../icons';
import { fetchAPI } from '../../services/api';
export interface ImageQueueItem {
imageId: number | null;
index: number;
label: string;
type: 'featured' | 'in_article';
position?: number;
contentTitle: string;
prompt?: string; // Image prompt text
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
imageUrl: string | null;
error: string | null;
}
interface ImageQueueModalProps {
isOpen: boolean;
onClose: () => void;
queue: ImageQueueItem[];
totalImages: number;
taskId?: string | null;
model?: string;
provider?: string;
onUpdateQueue?: (queue: ImageQueueItem[]) => void;
}
export default function ImageQueueModal({
isOpen,
onClose,
queue,
totalImages,
taskId,
model,
provider,
onUpdateQueue,
}: ImageQueueModalProps) {
const [localQueue, setLocalQueue] = useState<ImageQueueItem[]>(queue);
// Track smooth progress animation for each item
const [smoothProgress, setSmoothProgress] = useState<Record<number, number>>({});
const progressIntervalsRef = useRef<Record<number, ReturnType<typeof setInterval>>>({});
useEffect(() => {
setLocalQueue(queue);
}, [queue]);
useEffect(() => {
if (onUpdateQueue) {
onUpdateQueue(localQueue);
}
}, [localQueue, onUpdateQueue]);
// Time-based progress: 1% → 50% in 5s, then 2% every 200ms until 80%, then event-based to 100%
useEffect(() => {
// Helper function for Phase 2: 2% every 200ms until 80%
const startPhase2Animation = (index: number, startProgress: number) => {
if (progressIntervalsRef.current[index]) {
clearInterval(progressIntervalsRef.current[index]);
delete progressIntervalsRef.current[index];
}
let currentPercent = Math.max(startProgress, 50);
const interval = setInterval(() => {
setSmoothProgress(prev => {
const current = prev[index] ?? currentPercent;
// Only increment if still below 80% and item is actively processing
const item = localQueue.find(item => item.index === index);
if (current < 80 && item?.status === 'processing') {
const newPercentage = Math.min(current + 2, 80); // 2% every 200ms
currentPercent = newPercentage;
// Stop if we've reached 80%
if (newPercentage >= 80) {
clearInterval(interval);
delete progressIntervalsRef.current[index];
}
return {
...prev,
[index]: newPercentage
};
} else {
// Stop if status changed or reached 80%
clearInterval(interval);
delete progressIntervalsRef.current[index];
return prev;
}
});
}, 200); // 2% every 200ms
progressIntervalsRef.current[index] = interval;
};
const indicesToClear: number[] = [];
localQueue.forEach((item) => {
if (item.status === 'processing') {
const currentProgress = smoothProgress[item.index] ?? (item.progress > 0 ? item.progress : 1);
if (currentProgress < 50 && !progressIntervalsRef.current[item.index]) {
let currentPercent = Math.max(currentProgress, 1);
const interval = setInterval(() => {
setSmoothProgress(prev => {
const current = prev[item.index] ?? currentPercent;
if (current < 50 && item.status === 'processing') {
const newPercentage = Math.min(current + 1, 50);
currentPercent = newPercentage;
if (newPercentage >= 50) {
clearInterval(interval);
delete progressIntervalsRef.current[item.index];
if (newPercentage < 80) {
startPhase2Animation(item.index, newPercentage);
}
}
return {
...prev,
[item.index]: newPercentage
};
} else {
clearInterval(interval);
delete progressIntervalsRef.current[item.index];
return prev;
}
});
}, 200);
progressIntervalsRef.current[item.index] = interval;
} else if (currentProgress >= 50 && currentProgress < 80 && !progressIntervalsRef.current[item.index]) {
startPhase2Animation(item.index, currentProgress);
} else if (item.imageUrl && currentProgress < 100 && !progressIntervalsRef.current[item.index]) {
if (progressIntervalsRef.current[item.index]) {
clearInterval(progressIntervalsRef.current[item.index]);
delete progressIntervalsRef.current[item.index];
}
let animatingProgress = Math.max(currentProgress, 80);
const startProgress = animatingProgress;
const endProgress = 100;
const duration = 800;
const startTime = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(1, elapsed / duration);
const eased = 1 - Math.pow(1 - progress, 2);
animatingProgress = startProgress + ((endProgress - startProgress) * eased);
if (animatingProgress >= 100 || elapsed >= duration) {
animatingProgress = 100;
clearInterval(interval);
delete progressIntervalsRef.current[item.index];
}
setSmoothProgress(prev => ({
...prev,
[item.index]: Math.round(animatingProgress)
}));
}, 16);
progressIntervalsRef.current[item.index] = interval;
}
} else {
if (progressIntervalsRef.current[item.index]) {
clearInterval(progressIntervalsRef.current[item.index]);
delete progressIntervalsRef.current[item.index];
}
if (smoothProgress[item.index] !== undefined) {
indicesToClear.push(item.index);
}
}
});
if (indicesToClear.length > 0) {
setSmoothProgress(prev => {
const next = { ...prev };
indicesToClear.forEach(idx => {
delete next[idx];
});
return next;
});
}
// Cleanup on unmount
return () => {
Object.values(progressIntervalsRef.current).forEach(interval => clearInterval(interval));
progressIntervalsRef.current = {};
};
}, [localQueue, smoothProgress]);
// Polling for task status updates
useEffect(() => {
if (!isOpen || !taskId) return;
let pollAttempts = 0;
const maxPollAttempts = 300; // 5 minutes max (300 * 1 second)
const pollInterval = setInterval(async () => {
pollAttempts++;
// Stop polling after max attempts
if (pollAttempts > maxPollAttempts) {
console.warn('Polling timeout reached, stopping');
clearInterval(pollInterval);
return;
}
try {
console.log(`[ImageQueueModal] Polling task status (attempt ${pollAttempts}):`, taskId);
const data = await fetchAPI(`/v1/system/settings/task_progress/${taskId}/`);
console.log(`[ImageQueueModal] Task status response:`, data);
// Check if data is valid (not HTML error page)
if (!data || typeof data !== 'object') {
console.warn('Invalid task status response:', data);
return;
}
// Check state (task_progress returns 'state', not 'status')
const taskState = data.state || data.status;
console.log(`[ImageQueueModal] Task state:`, taskState);
if (taskState === 'SUCCESS' || taskState === 'FAILURE') {
console.log(`[ImageQueueModal] Task completed with state:`, taskState);
clearInterval(pollInterval);
// Update final state
if (taskState === 'SUCCESS' && data.result) {
console.log(`[ImageQueueModal] Updating queue from result:`, data.result);
updateQueueFromTaskResult(data.result);
} else if (taskState === 'SUCCESS' && data.meta && data.meta.result) {
// Some responses have result in meta
console.log(`[ImageQueueModal] Updating queue from meta result:`, data.meta.result);
updateQueueFromTaskResult(data.meta.result);
}
return;
}
// Update progress from task meta
if (data.meta) {
console.log(`[ImageQueueModal] Updating queue from meta:`, data.meta);
updateQueueFromTaskMeta(data.meta);
} else {
console.log(`[ImageQueueModal] No meta data in response`);
}
} catch (error: any) {
// Check if it's a JSON parse error (HTML response) or API error
if (error.message && (error.message.includes('JSON') || error.message.includes('API Error'))) {
console.error('Task status endpoint error:', {
message: error.message,
status: error.status,
taskId: taskId,
endpoint: `/v1/system/settings/task_progress/${taskId}/`,
error: error
});
// If it's a 404, the endpoint might not exist - stop polling after a few attempts
if (error.status === 404) {
console.error('Task progress endpoint not found (404). Stopping polling.');
clearInterval(pollInterval);
return;
}
// Don't stop polling for other errors, but log them
} else {
console.error('Error polling task status:', error);
}
}
}, 1000); // Poll every second
return () => clearInterval(pollInterval);
}, [isOpen, taskId]);
// Helper function to convert image_path to frontend-accessible URL
const getImageUrlFromPath = (imagePath: string | null | undefined): string | null => {
if (!imagePath) return null;
// If path contains 'ai-images', extract filename and convert to web URL
if (imagePath.includes('ai-images')) {
const filename = imagePath.split('ai-images/')[1] || imagePath.split('ai-images\\')[1];
if (filename) {
return `/images/ai-images/${filename}`;
}
}
// If path is already a web path, return as-is
if (imagePath.startsWith('/images/')) {
return imagePath;
}
// Otherwise, try to extract filename and use ai-images path
const filename = imagePath.split('/').pop() || imagePath.split('\\').pop();
return filename ? `/images/ai-images/${filename}` : null;
};
const updateQueueFromTaskMeta = (meta: any) => {
const { current_image, total_images, completed, failed, results, current_image_progress, current_image_id } = meta;
setLocalQueue(prevQueue => {
return prevQueue.map((item, index) => {
const result = results?.find((r: any) => r.image_id === item.imageId);
if (result) {
// Stop smooth animation for completed/failed items
if (result.status === 'completed' || result.status === 'failed') {
if (progressIntervalsRef.current[item.index]) {
clearInterval(progressIntervalsRef.current[item.index]);
delete progressIntervalsRef.current[item.index];
}
}
// Only set imageUrl if image is completed AND image_path exists (image is saved)
const imageUrl = (result.status === 'completed' && result.image_path)
? getImageUrlFromPath(result.image_path)
: null;
// Use backend progress as target (0%, 50%, 75%, 90%, 100%)
// This works for both featured and in-article images
const backendProgress = (current_image_id === item.imageId && current_image_progress !== undefined)
? current_image_progress
: (result.status === 'completed' ? 100 :
result.status === 'failed' ? 0 :
item.progress);
return {
...item,
status: result.status === 'completed' ? 'completed' :
result.status === 'failed' ? 'failed' : 'processing',
progress: backendProgress, // Use backend progress as target for smooth animation
imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing
error: result.error || null
};
}
// Update based on current_image index and progress
if (index + 1 < current_image) {
// Already completed - stop animation
if (progressIntervalsRef.current[item.index]) {
clearInterval(progressIntervalsRef.current[item.index]);
delete progressIntervalsRef.current[item.index];
}
return { ...item, status: 'completed', progress: 100 };
}
// SAFE: Only change to 'processing' when backend confirms with actual image ID
// This ensures progress bar only moves when actual processing starts
// Works consistently for both featured and in-article images
else if (current_image_id === item.imageId) {
// Currently processing - use backend progress if available
const backendProgress = (current_image_progress !== undefined)
? current_image_progress
: (smoothProgress[item.index] ?? 0);
return { ...item, status: 'processing', progress: backendProgress };
}
// Keep as 'pending' until backend confirms processing with image ID
return item;
});
});
};
const updateQueueFromTaskResult = (result: any) => {
const { results } = result;
setLocalQueue(prevQueue => {
return prevQueue.map((item) => {
const taskResult = results?.find((r: any) => r.image_id === item.imageId);
if (taskResult) {
// Stop smooth animation
if (progressIntervalsRef.current[item.index]) {
clearInterval(progressIntervalsRef.current[item.index]);
delete progressIntervalsRef.current[item.index];
}
// Only set imageUrl if image is completed AND image_path exists (image is saved)
const imageUrl = (taskResult.status === 'completed' && taskResult.image_path)
? getImageUrlFromPath(taskResult.image_path)
: null;
return {
...item,
status: taskResult.status === 'completed' ? 'completed' : 'failed',
progress: taskResult.status === 'completed' ? 100 : 0,
imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing
error: taskResult.error || null
};
}
return item;
});
});
};
if (!isOpen) return null;
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <TimeIcon className="w-4 h-4" />;
case 'processing':
return (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
);
case 'completed':
return <CheckCircleIcon className="w-4 h-4" />;
case 'failed':
return <ErrorIcon className="w-4 h-4" />;
default:
return <TimeIcon className="w-4 h-4" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return 'Pending';
case 'processing':
return 'Generating...';
case 'completed':
return 'Complete';
case 'failed':
return 'Failed';
default:
return 'Pending';
}
};
const getProgressColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-500';
case 'failed':
return 'bg-red-500';
case 'processing':
return 'bg-blue-500';
default:
return 'bg-gray-300';
}
};
const isProcessing = localQueue.some(item => item.status === 'processing');
const completedCount = localQueue.filter(item => item.status === 'completed').length;
const failedCount = localQueue.filter(item => item.status === 'failed').length;
const allDone = localQueue.every(item => item.status === 'completed' || item.status === 'failed');
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
showCloseButton={!isProcessing}
>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileIcon className="w-6 h-6 text-blue-500" />
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Generating Images
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Total: {totalImages} image{totalImages !== 1 ? 's' : ''} in queue
</p>
{model && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Model: {provider === 'openai' ? 'OpenAI' : provider === 'runware' ? 'Runware' : provider || 'Unknown'} {model === 'dall-e-2' ? 'DALL·E 2' : model === 'dall-e-3' ? 'DALL·E 3' : model}
</p>
)}
</div>
</div>
</div>
{/* Queue List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-3">
{localQueue.map((item) => (
<div
key={item.index}
className={`p-4 rounded-lg border-2 transition-colors ${
item.status === 'processing'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
: item.status === 'completed'
? 'bg-green-50 dark:bg-green-900/20 border-green-500'
: item.status === 'failed'
? 'bg-red-50 dark:bg-red-900/20 border-red-500'
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-300 dark:border-gray-600'
}`}
>
<div className="flex items-center gap-4">
{/* Left: Queue Info */}
<div className="flex-1">
{/* Header Row */}
<div className="flex items-center gap-3 mb-2">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold">
{item.index}
</span>
<span className="font-semibold text-sm text-gray-900 dark:text-white">
{item.label}
</span>
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate">
{item.prompt
? (() => {
const words = item.prompt.split(' ');
const firstTenWords = words.slice(0, 10).join(' ');
return words.length > 10 ? `${firstTenWords}...` : item.prompt;
})()
: item.contentTitle}
</span>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap flex items-center gap-1">
{getStatusIcon(item.status)}
<span>{getStatusText(item.status)}</span>
</span>
</div>
{/* Progress Bar - Use smooth progress for visual display */}
<div className="relative h-5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(item.status)} transition-all duration-150 ease-out`}
style={{ width: `${smoothProgress[item.index] ?? item.progress ?? 0}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span
className={`text-xs font-bold ${
(smoothProgress[item.index] ?? item.progress ?? 0) >= 50
? 'text-white'
: 'text-gray-700 dark:text-gray-200'
}`}
>
{smoothProgress[item.index] ?? item.progress ?? 0}%
</span>
</div>
</div>
{/* Error Message */}
{item.error && (
<div className="mt-2 p-2 bg-red-100 dark:bg-red-900/30 border-l-4 border-red-500 rounded text-xs text-red-700 dark:text-red-300">
{item.error}
</div>
)}
</div>
{/* Right: Thumbnail - Only show if image is completed and saved */}
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.status === 'completed' && item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.label}
className="w-full h-full object-cover"
onError={(e) => {
// Hide image if it fails to load (not yet available)
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML = '<span class="text-xs text-gray-400 dark:text-gray-500">No image</span>';
}
}}
/>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500">
No image
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{completedCount} completed{failedCount > 0 ? `, ${failedCount} failed` : ''} of {totalImages} total
</div>
{allDone && (
<button
onClick={onClose}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Close
</button>
)}
</div>
</div>
</Modal>
);
}