Files
igny8/frontend/src/components/Automation/CurrentProcessingCard.tsx
2025-12-28 01:46:27 +00:00

619 lines
26 KiB
TypeScript

/**
* Current Processing Card Component
* Shows real-time automation progress with pause/resume/cancel controls
*/
import React, { useEffect, useState } from 'react';
import { automationService, ProcessingState, AutomationRun, PipelineStage } from '../../services/automationService';
import {
fetchKeywords,
fetchClusters,
fetchContentIdeas,
fetchTasks,
fetchContent,
fetchContentImages,
} from '../../services/api';
import { useToast } from '../ui/toast/ToastContainer';
import Button from '../ui/button/Button';
import {
PlayIcon,
PauseIcon,
XMarkIcon,
ClockIcon,
BoltIcon
} from '../../icons';
interface CurrentProcessingCardProps {
runId: string;
siteId: number;
currentRun: AutomationRun;
onUpdate: () => void;
onClose: () => void;
pipelineOverview?: PipelineStage[];
}
const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
runId,
siteId,
currentRun,
onUpdate,
onClose,
pipelineOverview,
}) => {
const [processingState, setProcessingState] = useState<ProcessingState | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPausing, setIsPausing] = useState(false);
const [isResuming, setIsResuming] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [fetchedCurrently, setFetchedCurrently] = useState<ProcessingState['currently_processing']>([]);
const [fetchedUpNext, setFetchedUpNext] = useState<ProcessingState['up_next']>([]);
const [isLocallyPaused, setIsLocallyPaused] = useState(false);
const [showDebugTable, setShowDebugTable] = useState(false);
const toast = useToast();
useEffect(() => {
let isMounted = true;
const fetchState = async () => {
try {
const state = await automationService.getCurrentProcessing(siteId, runId);
console.debug('getCurrentProcessing response for run', runId, state);
if (!isMounted) return;
setProcessingState(state);
setError(null);
// If stage completed (all items processed), trigger page refresh
if (state && state.processed_items >= state.total_items && state.total_items > 0) {
onUpdate();
}
} catch (err) {
if (!isMounted) return;
console.error('Error fetching processing state:', err);
setError('Failed to load processing state');
}
};
// Only fetch if status is running or paused and not locally paused
if (!isLocallyPaused && (currentRun.status === 'running' || currentRun.status === 'paused')) {
// Initial fetch
fetchState();
// Poll every 3 seconds
const interval = setInterval(fetchState, 3000);
return () => {
isMounted = false;
clearInterval(interval);
};
}
return () => {
isMounted = false;
};
}, [siteId, runId, currentRun.status, currentRun.current_stage, onUpdate, isLocallyPaused]);
// Attempt to fetch example items for the current stage when the API does not provide up_next/currently_processing
useEffect(() => {
let isMounted = true;
const stageNumber = currentRun.current_stage;
const loadStageQueue = async () => {
try {
switch (stageNumber) {
case 1: {
const res = await fetchKeywords({ page_size: 5, site_id: siteId, status: 'new' });
if (!isMounted) return;
const items = (res.results || []).map((r: any) => ({ id: r.id, title: r.title || r.name || String(r.id), type: 'keyword' }));
setFetchedUpNext(items);
setFetchedCurrently(items.slice(0, 1));
break;
}
case 2: {
const res = await fetchClusters({ page_size: 5, site_id: siteId, status: 'new' });
if (!isMounted) return;
const items = (res.results || []).map((r: any) => ({ id: r.id, title: r.name || String(r.id), type: 'cluster' }));
setFetchedUpNext(items);
setFetchedCurrently(items.slice(0, 1));
break;
}
case 3: {
const res = await fetchContentIdeas({ page_size: 5, site_id: siteId, status: 'queued' });
if (!isMounted) return;
const items = (res.results || []).map((r: any) => ({ id: r.id, title: r.title || String(r.id), type: 'idea' }));
setFetchedUpNext(items);
setFetchedCurrently(items.slice(0, 1));
break;
}
case 4: {
// Tasks -> Content (show queued tasks)
try {
const res = await fetchTasks({ page_size: 5, site_id: siteId, status: 'queued' });
if (!isMounted) return;
const items = (res.results || []).map((r: any) => ({ id: r.id, title: r.title || r.name || String(r.id), type: 'task' }));
setFetchedUpNext(items);
setFetchedCurrently(items.slice(0, 1));
} catch (e) {
// ignore
}
break;
}
case 5: {
// Content -> Image Prompts (show content items awaiting prompts)
try {
const res = await fetchContent({ page_size: 5, site_id: siteId, status: 'queued' });
if (!isMounted) return;
const items = (res.results || []).map((r: any) => ({ id: r.id, title: r.title || r.name || String(r.id), type: 'content' }));
setFetchedUpNext(items);
setFetchedCurrently(items.slice(0, 1));
} catch (e) {
// ignore
}
break;
}
case 6: {
const res = await fetchContentImages({ page_size: 5, site_id: siteId, status: 'pending' });
if (!isMounted) return;
const items = (res.results || []).map((r: any) => ({ id: r.id, title: r.filename || String(r.id), type: 'image' }));
setFetchedUpNext(items);
setFetchedCurrently(items.slice(0, 1));
break;
}
default:
// For stages without a clear read API, clear fetched lists
setFetchedUpNext([]);
setFetchedCurrently([]);
}
} catch (err) {
console.warn('Failed to fetch stage queue samples:', err);
}
};
// Only attempt when there's no live up_next data
if ((!processingState || (processingState && (processingState.up_next || []).length === 0)) && currentRun.status === 'running') {
loadStageQueue();
}
return () => { isMounted = false; };
}, [siteId, currentRun.current_stage, currentRun.status]);
const handlePause = async () => {
setIsPausing(true);
try {
await automationService.pause(siteId, runId);
toast?.success('Automation pausing... will complete current item');
// Optimistically mark paused locally so UI stays paused until backend confirms
setIsLocallyPaused(true);
// Trigger update to refresh run status
setTimeout(onUpdate, 1000);
} catch (error: any) {
toast?.error(error?.message || 'Failed to pause automation');
} finally {
setIsPausing(false);
}
};
const handleResume = async () => {
setIsResuming(true);
try {
await automationService.resume(siteId, runId);
toast?.success('Automation resumed');
// Clear local paused flag
setIsLocallyPaused(false);
// Trigger update to refresh run status
setTimeout(onUpdate, 1000);
} catch (error: any) {
toast?.error(error?.message || 'Failed to resume automation');
} finally {
setIsResuming(false);
}
};
const handleCancel = async () => {
if (!confirm('Are you sure you want to cancel this automation run? This cannot be undone.')) {
return;
}
setIsCancelling(true);
try {
await automationService.cancel(siteId, runId);
toast?.success('Automation cancelling... will complete current item');
// Trigger update to refresh run status
setTimeout(onUpdate, 1500);
} catch (error: any) {
toast?.error(error?.message || 'Failed to cancel automation');
} finally {
setIsCancelling(false);
}
};
const formatDuration = (startTime: string) => {
const start = new Date(startTime).getTime();
const now = Date.now();
const diffMs = now - start;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
if (diffHours > 0) {
return `${diffHours}h ${diffMins % 60}m`;
}
return `${diffMins}m`;
};
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<p className="text-red-700 dark:text-red-300 text-sm">{error}</p>
<button
onClick={onClose}
className="text-red-500 hover:text-red-700 dark:hover:text-red-300"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</div>
);
}
// Build a fallback processing state from currentRun and pipelineOverview when API doesn't return live state
const currentStageIndex = (currentRun.current_stage || 1) - 1;
const stageOverview = pipelineOverview && pipelineOverview[currentStageIndex] ? pipelineOverview[currentStageIndex] : null;
const stageResult = (currentRun as any)[`stage_${currentRun.current_stage}_result`];
// FIXED: Helper to get processed count using correct key
const getProcessedFromResult = (result: any, stageNumber: number): number => {
if (!result) return 0;
const keyMap: Record<number, string> = {
1: 'keywords_processed',
2: 'clusters_processed',
3: 'ideas_processed',
4: 'tasks_processed',
5: 'content_processed',
6: 'images_processed',
7: 'ready_for_review'
};
return result[keyMap[stageNumber]] ?? 0;
};
const fallbackState: ProcessingState | null = ((): ProcessingState | null => {
if (!processingState && (stageOverview || stageResult)) {
// FIXED: Use stage-specific key instead of summing all numeric values
const processed = getProcessedFromResult(stageResult, currentRun.current_stage);
const total = (stageOverview?.pending || 0) + processed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
return {
stage_number: currentRun.current_stage,
stage_name: stageOverview?.name || `Stage ${currentRun.current_stage}`,
stage_type: stageOverview?.type || 'AI',
total_items: total,
processed_items: processed,
percentage,
currently_processing: [],
up_next: [],
remaining_count: Math.max(0, total - processed),
};
}
return null;
})();
const displayState = processingState || fallbackState;
// If we don't have a live displayState, keep rendering the card using computed values
// Computed processed/total (use processingState when available, otherwise derive from stageResult + overview)
const computedProcessed = ((): number => {
if (displayState && typeof displayState.processed_items === 'number') return displayState.processed_items;
if (stageResult) {
// FIXED: Use stage-specific key for processed count
return getProcessedFromResult(stageResult, currentRun.current_stage);
}
return 0;
})();
const computedTotal = ((): number => {
if (displayState && typeof displayState.total_items === 'number' && displayState.total_items > 0) return displayState.total_items;
const pending = stageOverview?.pending ?? 0;
return Math.max(pending + computedProcessed, 0);
})();
const percentage = computedTotal > 0 ? Math.round((computedProcessed / computedTotal) * 100) : 0;
const isPaused = currentRun.status === 'paused';
// Choose stage accent color (simple map matching AutomationPage STAGE_CONFIG)
const stageColors = [
'from-blue-500 to-blue-600',
'from-purple-500 to-purple-600',
'from-indigo-500 to-indigo-600',
'from-green-500 to-green-600',
'from-amber-500 to-amber-600',
'from-pink-500 to-pink-600',
'from-teal-500 to-teal-600',
];
const stageColorClass = stageColors[(currentRun.current_stage || 1) - 1] || 'from-blue-500 to-blue-600';
return (
<div className={`border-2 rounded-lg p-6 mb-6 ${
isPaused
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
}`}>
{/* Header Row with Main Info and Close */}
<div className="flex items-start justify-between mb-4">
{/* Left Side - Main Info (75%) */}
<div className="flex-1 pr-6">
<div className="flex items-center gap-3 mb-4">
<div className={isPaused ? '' : 'animate-pulse'}>
{isPaused ? (
<PauseIcon className="w-8 h-8 text-yellow-600 dark:text-yellow-400" />
) : (
<BoltIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
)}
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{isPaused ? 'Automation Paused' : 'Automation In Progress'}
</h2>
{/* Centered stage row + dynamic action text */}
{displayState && (
<div className="mt-2">
<div className="text-center text-sm font-medium text-gray-700 dark:text-gray-300">
Stage {currentRun.current_stage}: {displayState.stage_name}
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
isPaused
? 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300'
: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
}`}>
{displayState.stage_type}
</span>
</div>
<div className="text-center text-sm text-gray-600 dark:text-gray-400 mt-1">
{(() => {
// Build dynamic action text based on stage type and counts
const verb = displayState.stage_type === 'AI' ? 'Generating' : 'Processing';
// target label for the current stage (what is being produced)
const targetLabelMap: Record<number, string> = {
1: 'Clusters',
2: 'Ideas',
3: 'Tasks',
4: 'Content',
5: 'Image Prompts',
6: 'Images',
7: 'Review',
};
const label = targetLabelMap[displayState.stage_number] || 'Items';
return `${verb} ${computedProcessed}/${computedTotal} ${label}`;
})()}
</div>
</div>
)}
</div>
</div>
{/* Progress Info */}
{displayState && (
<>
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{percentage}%
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{computedProcessed}/{computedTotal} completed
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-500 ${
isPaused
? 'bg-yellow-600 dark:bg-yellow-500'
: 'bg-blue-600 dark:bg-blue-500'
}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
{/* Currently Processing and Up Next */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Currently Processing */}
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Currently Processing:
</h3>
<div className="space-y-1">
{((displayState.currently_processing && displayState.currently_processing.length > 0) ? displayState.currently_processing : fetchedCurrently).length > 0 ? (
((displayState.currently_processing && displayState.currently_processing.length > 0) ? displayState.currently_processing : fetchedCurrently).map((item, idx) => (
<div key={idx} className="flex items-start gap-2 text-sm">
<span className={isPaused ? 'text-yellow-600 dark:text-yellow-400 mt-1' : 'text-blue-600 dark:text-blue-400 mt-1'}></span>
<span className="text-gray-800 dark:text-gray-200 font-medium line-clamp-2">
{item.title}
</span>
</div>
))
) : (
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
{isPaused ? 'Paused' : 'No items currently processing'}
</div>
)}
</div>
</div>
{/* Up Next */}
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Up Next:
</h3>
<div className="space-y-1">
{((displayState.up_next && displayState.up_next.length > 0) ? displayState.up_next : fetchedUpNext).length > 0 ? (
<>
{((displayState.up_next && displayState.up_next.length > 0) ? displayState.up_next : fetchedUpNext).map((item, idx) => (
<div key={idx} className="flex items-start gap-2 text-sm">
<span className="text-gray-400 dark:text-gray-500 mt-1"></span>
<span className="text-gray-600 dark:text-gray-400 line-clamp-2">
{item.title}
</span>
</div>
))}
{displayState.remaining_count > ((displayState.up_next?.length || 0) + (displayState.currently_processing?.length || 0)) && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
+ {displayState.remaining_count - ((displayState.up_next?.length || 0) + (displayState.currently_processing?.length || 0))} more in queue
</div>
)}
</>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
Queue empty
</div>
)}
</div>
</div>
</div>
{/* Control Buttons */}
<div className="flex items-center gap-3">
{currentRun.status === 'running' ? (
<Button
onClick={handlePause}
disabled={isPausing}
variant="secondary"
size="sm"
startIcon={<PauseIcon className="w-4 h-4" />}
>
{isPausing ? 'Pausing...' : 'Pause'}
</Button>
) : currentRun.status === 'paused' ? (
<Button
onClick={handleResume}
disabled={isResuming}
variant="primary"
size="sm"
startIcon={<PlayIcon className="w-4 h-4" />}
>
{isResuming ? 'Resuming...' : 'Resume'}
</Button>
) : null}
<Button
onClick={handleCancel}
disabled={isCancelling}
variant="danger"
size="sm"
startIcon={<XMarkIcon className="w-4 h-4" />}
>
{isCancelling ? 'Cancelling...' : 'Cancel'}
</Button>
</div>
</>
)}
</div>
{/* Right Side - Metrics and Close (25%) */}
<div className="w-64 flex-shrink-0">
{/* Close Button */}
<div className="flex justify-end mb-4">
<Button variant="ghost" size="sm" onClick={onClose} startIcon={<XMarkIcon className="w-4 h-4" />}>
Close
</Button>
</div>
{/* Metrics Cards */}
<div className="space-y-3">
{/* Duration */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-0">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Duration</div>
</div>
<div className="text-sm font-bold text-gray-900 dark:text-white">{formatDuration(currentRun.started_at)}</div>
</div>
</div>
{/* Credits Used */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-0">
<div className="flex items-center gap-2">
<BoltIcon className="w-4 h-4 text-amber-500" />
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Credits Used</div>
</div>
<div className="text-sm font-bold text-amber-600 dark:text-amber-400">{currentRun.total_credits_used}</div>
</div>
</div>
{/* Current Stage */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-0">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Stage</div>
<div className="text-sm font-bold text-gray-900 dark:text-white">{currentRun.current_stage} of 7</div>
</div>
</div>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-0">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Status</div>
<div className={`text-sm font-semibold ${isPaused ? 'text-yellow-600 dark:text-yellow-400' : 'text-blue-600 dark:text-blue-400'}`}>
{isPaused ? 'Paused' : 'Running'}
</div>
</div>
</div>
</div>
</div>
{/* Debug table toggle + table for stage data */}
<div className="mt-4">
<button
type="button"
onClick={() => setShowDebugTable(!showDebugTable)}
className="text-xs text-slate-600 hover:underline"
>
{showDebugTable ? 'Hide' : 'Show'} debug table
</button>
{showDebugTable && (
<div className="mt-3 bg-white dark:bg-gray-800 p-3 rounded border">
<div className="text-sm font-semibold mb-2">Stage Data</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left">
<th className="pr-4">Stage</th>
<th className="pr-4">Pending</th>
<th className="pr-4">Processed</th>
<th className="pr-4">Total</th>
<th className="pr-4">Currently (sample)</th>
<th className="pr-4">Up Next (sample)</th>
</tr>
</thead>
<tbody>
{(pipelineOverview || []).map((stage) => {
const result = (currentRun as any)[`stage_${stage.number}_result`];
const processed = result ? Object.values(result).reduce((s: number, v: any) => typeof v === 'number' ? s + v : s, 0) : 0;
const total = Math.max((stage.pending || 0) + processed, 0);
const currently = currentRun.current_stage === stage.number ? (processingState?.currently_processing?.slice(0,3) || fetchedCurrently) : [];
const upnext = currentRun.current_stage === stage.number ? (processingState?.up_next?.slice(0,5) || fetchedUpNext) : [];
return (
<tr key={stage.number} className="border-t">
<td className="py-2">{stage.number} {stage.name}</td>
<td className="py-2">{stage.pending}</td>
<td className="py-2">{processed}</td>
<td className="py-2">{total}</td>
<td className="py-2">{currently.map(c => c.title).join(', ') || '-'}</td>
<td className="py-2">{upnext.map(u => u.title).join(', ') || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default CurrentProcessingCard;