/** * Automation Dashboard Page * Main page for managing AI automation pipeline */ import React, { useState, useEffect } from 'react'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { useSiteStore } from '../../store/siteStore'; import { automationService, AutomationRun, AutomationConfig, PipelineStage, RunProgressResponse, GlobalProgress, StageProgress, InitialSnapshot } from '../../services/automationService'; import { fetchKeywords, fetchClusters, fetchContentIdeas, fetchTasks, fetchContent, fetchImages, } from '../../services/api'; import ActivityLog from '../../components/Automation/ActivityLog'; import ConfigModal from '../../components/Automation/ConfigModal'; import RunHistory from '../../components/Automation/RunHistory'; import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCardV2'; import GlobalProgressBar, { getProcessedFromResult } from '../../components/Automation/GlobalProgressBar'; import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; import ComponentCard from '../../components/common/ComponentCard'; import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard'; import DebugSiteSelector from '../../components/common/DebugSiteSelector'; import Button from '../../components/ui/button/Button'; import { BoltIcon, ListIcon, GroupIcon, FileTextIcon, PencilIcon, FileIcon, CheckCircleIcon, ClockIcon, PaperPlaneIcon, ArrowRightIcon } from '../../icons'; /** * Stage config with colors for visual distinction matching module color scheme: * PLANNER PIPELINE (Blue → Pink → Amber): * - Keywords→Clusters: brand/blue * - Clusters→Ideas: purple/pink * - Ideas→Tasks: warning/amber * WRITER PIPELINE (Navy → Blue → Pink → Green): * - Tasks→Content: gray-dark (navy) - entry point to Writer * - Content→Prompts: brand/blue * - Prompts→Images: purple/pink * - Images→Publish: success/green */ const STAGE_CONFIG = [ { icon: ListIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/30', hoverColor: 'hover:border-brand-500', name: 'Keywords → Clusters' }, { icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' }, { icon: BoltIcon, color: 'from-warning-500 to-warning-600', textColor: 'text-warning-600 dark:text-warning-400', bgColor: 'bg-warning-100 dark:bg-warning-900/30', hoverColor: 'hover:border-warning-500', name: 'Ideas → Tasks' }, { icon: CheckCircleIcon, color: 'from-gray-700 to-gray-800', textColor: 'text-gray-700 dark:text-gray-300', bgColor: 'bg-gray-100 dark:bg-gray-800/30', hoverColor: 'hover:border-gray-500', name: 'Tasks → Content' }, { icon: PencilIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/30', hoverColor: 'hover:border-brand-500', name: 'Content → Image Prompts' }, { icon: FileIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30', hoverColor: 'hover:border-purple-500', name: 'Image Prompts → Images' }, { icon: PaperPlaneIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600 dark:text-success-400', bgColor: 'bg-success-100 dark:bg-success-900/30', hoverColor: 'hover:border-success-500', name: 'Review → Published' }, ]; const AutomationPage: React.FC = () => { const { activeSite } = useSiteStore(); const toast = useToast(); const [config, setConfig] = useState(null); const [currentRun, setCurrentRun] = useState(null); const [pipelineOverview, setPipelineOverview] = useState([]); const [metrics, setMetrics] = useState(null); const [showConfigModal, setShowConfigModal] = useState(false); const [showProcessingCard, setShowProcessingCard] = useState(true); const [loading, setLoading] = useState(true); const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null); // Eligibility check - site must have data to use automation const [isEligible, setIsEligible] = useState(null); const [eligibilityMessage, setEligibilityMessage] = useState(null); const [eligibilityChecked, setEligibilityChecked] = useState(false); // New state for unified progress data const [globalProgress, setGlobalProgress] = useState(null); const [stageProgress, setStageProgress] = useState([]); const [initialSnapshot, setInitialSnapshot] = useState(null); // Track site ID to avoid duplicate calls when activeSite object reference changes const siteId = activeSite?.id; /** * Calculate time remaining until next scheduled run * Returns formatted string like "in 5h 23m" or "in 2d 3h" */ const getNextRunTime = (config: AutomationConfig): string => { if (!config.is_enabled || !config.scheduled_time) return ''; const now = new Date(); const [schedHours, schedMinutes] = config.scheduled_time.split(':').map(Number); // Create next run date const nextRun = new Date(); nextRun.setUTCHours(schedHours, schedMinutes, 0, 0); // If scheduled time has passed today, set to tomorrow if (nextRun <= now) { if (config.frequency === 'daily') { nextRun.setUTCDate(nextRun.getUTCDate() + 1); } else if (config.frequency === 'weekly') { nextRun.setUTCDate(nextRun.getUTCDate() + 7); } } // Calculate difference in milliseconds const diff = nextRun.getTime() - now.getTime(); const totalMinutes = Math.floor(diff / (1000 * 60)); const totalHours = Math.floor(totalMinutes / 60); const days = Math.floor(totalHours / 24); const remainingHours = totalHours % 24; const remainingMinutes = totalMinutes % 60; // Format output if (days > 0) { return `in ${days}d ${remainingHours}h`; } else if (remainingHours > 0) { return `in ${remainingHours}h ${remainingMinutes}m`; } else { return `in ${remainingMinutes}m`; } }; useEffect(() => { if (!siteId) return; // Reset state when site changes setConfig(null); setCurrentRun(null); setEstimate(null); setPipelineOverview([]); setMetrics(null); setIsEligible(null); setEligibilityMessage(null); setEligibilityChecked(false); // First check eligibility, then load data only if eligible checkEligibilityAndLoad(); }, [siteId]); const checkEligibilityAndLoad = async () => { if (!activeSite) return; try { setLoading(true); const eligibility = await automationService.checkEligibility(activeSite.id); setIsEligible(eligibility.is_eligible); setEligibilityMessage(eligibility.message); setEligibilityChecked(true); // Only load full data if site is eligible if (eligibility.is_eligible) { await loadData(); } else { setLoading(false); } } catch (error) { console.error('Failed to check eligibility:', error); // On error, fall back to loading data anyway setIsEligible(true); setEligibilityChecked(true); await loadData(); } }; // Separate polling effect - only run if eligible useEffect(() => { if (!siteId || !isEligible) return; if (!currentRun || (currentRun.status !== 'running' && currentRun.status !== 'paused')) { // Only poll pipeline overview when not running const interval = setInterval(() => { loadPipelineOverview(); }, 5000); return () => clearInterval(interval); } // When automation is running, refresh both run and metrics const interval = setInterval(() => { loadCurrentRun(); loadPipelineOverview(); loadMetrics(); }, 5000); return () => clearInterval(interval); }, [siteId, isEligible, currentRun?.status]); const loadData = async () => { if (!activeSite) return; try { setLoading(true); const [configData, runData, estimateData, pipelineData] = await Promise.all([ automationService.getConfig(activeSite.id), automationService.getCurrentRun(activeSite.id), automationService.estimate(activeSite.id), automationService.getPipelineOverview(activeSite.id), ]); // Also fetch the same low-level metrics used by Home page so we can show authoritative totals try { const siteId = activeSite.id; const [ keywordsTotalRes, keywordsNewRes, keywordsMappedRes, clustersTotalRes, clustersNewRes, clustersMappedRes, ideasTotalRes, ideasNewRes, ideasQueuedRes, ideasCompletedRes, tasksTotalRes, contentTotalRes, contentDraftRes, contentReviewRes, contentPublishedRes, imagesTotalRes, imagesPendingRes, ] = await Promise.all([ fetchKeywords({ page_size: 1, site_id: siteId }), fetchKeywords({ page_size: 1, site_id: siteId, status: 'new' }), fetchKeywords({ page_size: 1, site_id: siteId, status: 'mapped' }), fetchClusters({ page_size: 1, site_id: siteId }), fetchClusters({ page_size: 1, site_id: siteId, status: 'new' }), fetchClusters({ page_size: 1, site_id: siteId, status: 'mapped' }), fetchContentIdeas({ page_size: 1, site_id: siteId }), fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'new' }), fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'queued' }), fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'completed' }), fetchTasks({ page_size: 1, site_id: siteId }), fetchContent({ page_size: 1, site_id: siteId }), fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }), fetchContent({ page_size: 1, site_id: siteId, status: 'review' }), fetchContent({ page_size: 1, site_id: siteId, status__in: 'approved,published' }), fetchImages({ page_size: 1 }), fetchImages({ page_size: 1, status: 'pending' }), ]); setMetrics({ keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 }, clusters: { total: clustersTotalRes.count || 0, new: clustersNewRes.count || 0, mapped: clustersMappedRes.count || 0 }, ideas: { total: ideasTotalRes.count || 0, new: ideasNewRes.count || 0, queued: ideasQueuedRes.count || 0, completed: ideasCompletedRes.count || 0 }, tasks: { total: tasksTotalRes.count || 0 }, content: { total: contentTotalRes.count || 0, draft: contentDraftRes.count || 0, review: contentReviewRes.count || 0, published: contentPublishedRes.count || 0, }, images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 }, }); } catch (e) { // Non-fatal: keep metrics null if any of the low-level calls fail console.warn('Failed to fetch low-level metrics for automation page', e); } setConfig(configData); setCurrentRun(runData.run); setEstimate(estimateData); setPipelineOverview(pipelineData.stages); // show processing card when there's a current run if (runData.run) { setShowProcessingCard(true); } } catch (error: any) { console.error('Failed to load automation data:', error); toast.error('Failed to load automation data'); } finally { setLoading(false); } }; const loadCurrentRun = async () => { if (!activeSite) return; try { const data = await automationService.getCurrentRun(activeSite.id); setCurrentRun(data.run); // ensure processing card is visible when a run exists if (data.run) { setShowProcessingCard(true); // Also load unified progress data for GlobalProgressBar await loadRunProgress(data.run.run_id); } } catch (error) { console.error('Failed to poll current run', error); } }; const loadRunProgress = async (runId?: string) => { if (!activeSite) return; try { const progressData = await automationService.getRunProgress(activeSite.id, runId); if (progressData.global_progress) { setGlobalProgress(progressData.global_progress); } if (progressData.stages) { setStageProgress(progressData.stages); } if (progressData.initial_snapshot) { setInitialSnapshot(progressData.initial_snapshot); } } catch (error) { console.error('Failed to load run progress', error); } }; const loadPipelineOverview = async () => { if (!activeSite) return; try { const data = await automationService.getPipelineOverview(activeSite.id); setPipelineOverview(data.stages); } catch (error) { console.error('Failed to poll pipeline overview', error); } }; const loadMetrics = async () => { if (!activeSite) return; try { const siteId = activeSite.id; const [ keywordsTotalRes, keywordsNewRes, keywordsMappedRes, clustersTotalRes, clustersNewRes, clustersMappedRes, ideasTotalRes, ideasNewRes, ideasQueuedRes, ideasCompletedRes, tasksTotalRes, contentTotalRes, contentDraftRes, contentReviewRes, contentPublishedRes, imagesTotalRes, imagesPendingRes, ] = await Promise.all([ fetchKeywords({ page_size: 1, site_id: siteId }), fetchKeywords({ page_size: 1, site_id: siteId, status: 'new' }), fetchKeywords({ page_size: 1, site_id: siteId, status: 'mapped' }), fetchClusters({ page_size: 1, site_id: siteId }), fetchClusters({ page_size: 1, site_id: siteId, status: 'new' }), fetchClusters({ page_size: 1, site_id: siteId, status: 'mapped' }), fetchContentIdeas({ page_size: 1, site_id: siteId }), fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'new' }), fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'queued' }), fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'completed' }), fetchTasks({ page_size: 1, site_id: siteId }), fetchContent({ page_size: 1, site_id: siteId }), fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }), fetchContent({ page_size: 1, site_id: siteId, status: 'review' }), fetchContent({ page_size: 1, site_id: siteId, status__in: 'approved,published' }), fetchImages({ page_size: 1 }), fetchImages({ page_size: 1, status: 'pending' }), ]); setMetrics({ keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 }, clusters: { total: clustersTotalRes.count || 0, new: clustersNewRes.count || 0, mapped: clustersMappedRes.count || 0 }, ideas: { total: ideasTotalRes.count || 0, new: ideasNewRes.count || 0, queued: ideasQueuedRes.count || 0, completed: ideasCompletedRes.count || 0 }, tasks: { total: tasksTotalRes.count || 0 }, content: { total: contentTotalRes.count || 0, draft: contentDraftRes.count || 0, review: contentReviewRes.count || 0, published: contentPublishedRes.count || 0, }, images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 }, }); } catch (e) { console.warn('Failed to fetch metrics', e); } }; const handleRunNow = async () => { if (!activeSite) return; if (estimate && !estimate.sufficient) { toast.error(`Content limit reached. This run needs ~${estimate.estimated_credits} pieces, you have ${estimate.current_balance} remaining.`); return; } try { const result = await automationService.runNow(activeSite.id); toast.success('Automation started successfully'); loadCurrentRun(); } catch (error: any) { const errorMsg = error.response?.data?.error || 'Failed to start automation'; // Use informational messages for "no work to do" cases, errors for system/account issues const isInfoMessage = errorMsg.toLowerCase().includes('no keywords') || errorMsg.toLowerCase().includes('no pending') || errorMsg.toLowerCase().includes('nothing to process') || errorMsg.toLowerCase().includes('no items') || errorMsg.toLowerCase().includes('all stages') || errorMsg.toLowerCase().includes('minimum') || errorMsg.toLowerCase().includes('at least'); if (isInfoMessage) { toast.info(errorMsg); } else { toast.error(errorMsg); } } }; const handlePause = async () => { if (!currentRun || !activeSite) return; try { await automationService.pause(activeSite.id, currentRun.run_id); toast.success('Automation paused'); // refresh run and pipeline/metrics await loadCurrentRun(); await loadPipelineOverview(); await loadMetrics(); } catch (error) { toast.error('Failed to pause automation'); } }; const handleResume = async () => { if (!currentRun || !activeSite) return; try { await automationService.resume(activeSite.id, currentRun.run_id); toast.success('Automation resumed'); await loadCurrentRun(); await loadPipelineOverview(); await loadMetrics(); } catch (error) { toast.error('Failed to resume automation'); } }; const handleSaveConfig = async (newConfig: Partial) => { if (!activeSite) return; try { await automationService.updateConfig(activeSite.id, newConfig); toast.success('Configuration saved'); setShowConfigModal(false); // Optimistically update config locally and refresh data setConfig((prev) => ({ ...(prev as AutomationConfig), ...newConfig } as AutomationConfig)); await loadPipelineOverview(); await loadMetrics(); await loadCurrentRun(); } catch (error) { console.error('Failed to save config:', error); toast.error('Failed to save configuration'); } }; const handlePublishAllWithoutReview = async () => { if (!activeSite) return; if (!confirm('Publish all content without review? This cannot be undone.')) return; try { await automationService.publishWithoutReview(activeSite.id); toast.success('Publish job started (without review)'); // refresh metrics and pipeline loadData(); loadPipelineOverview(); } catch (error: any) { console.error('Failed to publish without review', error); toast.error(error?.response?.data?.error || 'Failed to publish without review'); } }; if (!activeSite) { return (
Please select a site to view automation
); } const totalPending = pipelineOverview.reduce((sum, stage) => sum + stage.pending, 0); const getStageResult = (stageNumber: number) => { if (!currentRun) return null; return (currentRun as any)[`stage_${stageNumber}_result`]; }; const renderMetricRow = (items: Array<{ label: string; value: any; colorCls?: string }>) => { const visible = items.filter(i => i && (i.value !== undefined && i.value !== null)); if (visible.length === 0) { return (
); } if (visible.length === 2) { return (
{visible.map((it, idx) => (
{it.label}
{Number(it.value) || 0}
))}
); } // default to 3 columns (equally spaced) return (
{items.concat(Array(Math.max(0, 3 - items.length)).fill({ label: '', value: '' })).slice(0,3).map((it, idx) => (
{it.label}
{it.value !== undefined && it.value !== null ? Number(it.value) : ''}
))}
); }; return ( <> , color: 'teal' }} parent="Automation" /> {/* Show eligibility notice when site has no data */} {eligibilityChecked && !isEligible && (

Site Not Eligible for Automation Yet

{eligibilityMessage || 'This site doesn\'t have any data yet. Start by adding keywords in the Planner module to enable automation.'}

)} {/* Show loading state */} {loading && !eligibilityChecked && (
Loading automation data...
)} {/* Main content - only show when eligible */} {eligibilityChecked && isEligible && (
{/* Compact Ready-to-Run card (header) - absolutely centered in header */} {/* Compact Schedule & Controls Panel */} {config && (
{config.is_enabled ? (
Enabled
) : (
Disabled
)}
{config.frequency} at {config.scheduled_time}
Last: {config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}
{config.is_enabled && ( <>
Next: {getNextRunTime(config)}
)}
Est:{' '} {estimate?.estimated_credits || 0} content pieces {estimate && !estimate.sufficient && ( (Limit reached) )}
{/* Ready to Run Card - Inline horizontal */}
0 ? 'border-success-300 bg-white' : 'border-white/30 bg-white/10'}`}>
0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}> {!currentRun && totalPending > 0 ? : currentRun?.status === 'running' ? : currentRun?.status === 'paused' ? : }
0 || currentRun ? 'text-gray-900' : 'text-white/90'}`}> {currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`} {currentRun?.status === 'paused' && 'Paused'} {!currentRun && totalPending > 0 && 'Ready to Run'} {!currentRun && totalPending === 0 && 'No Items Pending'} 0 || currentRun ? 'text-gray-600' : 'text-white/70'}`}> {currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
{currentRun?.status === 'running' && ( )} {currentRun?.status === 'paused' && ( )} {!currentRun && ( )}
)} {/* Metrics Summary Cards */}
{/* Keywords */}
Keywords
{(() => { const res = getStageResult(1); const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0; return (
{total}
); })()}
{(() => { const res = getStageResult(1); const newCount = res?.new ?? res?.new_items ?? pipelineOverview[0]?.counts?.new ?? metrics?.keywords?.new ?? 0; const mapped = res?.mapped ?? pipelineOverview[0]?.counts?.mapped ?? metrics?.keywords?.mapped ?? 0; return ( renderMetricRow([ { label: 'New:', value: newCount, colorCls: 'text-brand-600' }, { label: 'Mapped:', value: mapped, colorCls: 'text-brand-600' }, ]) ); })()}
{/* Clusters */}
Clusters
{(() => { const res = getStageResult(2); const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0; return (
{total}
); })()}
{(() => { const res = getStageResult(2); const newCount = res?.new ?? res?.new_items ?? pipelineOverview[1]?.counts?.new ?? metrics?.clusters?.new ?? 0; const mapped = res?.mapped ?? pipelineOverview[1]?.counts?.mapped ?? metrics?.clusters?.mapped ?? 0; return ( renderMetricRow([ { label: 'New:', value: newCount, colorCls: 'text-purple-600' }, { label: 'Mapped:', value: mapped, colorCls: 'text-purple-600' }, ]) ); })()}
{/* Ideas */}
Ideas
{(() => { const res = getStageResult(3); const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0; return (
{total}
); })()}
{(() => { const res = getStageResult(3); const newCount = res?.new ?? res?.new_items ?? pipelineOverview[2]?.counts?.new ?? metrics?.ideas?.new ?? 0; const queued = res?.queued ?? pipelineOverview[2]?.counts?.queued ?? metrics?.ideas?.queued ?? 0; const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0; return ( renderMetricRow([ { label: 'New:', value: newCount, colorCls: 'text-warning-600' }, { label: 'Queued:', value: queued, colorCls: 'text-warning-600' }, { label: 'Completed:', value: completed, colorCls: 'text-warning-600' }, ]) ); })()}
{/* Content */}
Content
{(() => { const res = getStageResult(4); const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0; return (
{total}
); })()}
{(() => { const res = getStageResult(4); const draft = res?.draft ?? res?.drafts ?? pipelineOverview[3]?.counts?.draft ?? metrics?.content?.draft ?? 0; const review = res?.review ?? res?.in_review ?? pipelineOverview[3]?.counts?.review ?? metrics?.content?.review ?? 0; const publish = res?.published ?? res?.publish ?? pipelineOverview[3]?.counts?.published ?? metrics?.content?.published ?? 0; return ( renderMetricRow([ { label: 'Draft:', value: draft, colorCls: 'text-success-600' }, { label: 'Review:', value: review, colorCls: 'text-success-600' }, { label: 'Publish:', value: publish, colorCls: 'text-success-600' }, ]) ); })()}
{/* Images */}
Images
{(() => { const res = getStageResult(6); const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0; return (
{total}
); })()}
{(() => { const res = getStageResult(6); // stage 6 is Image Prompts -> Images if (res && typeof res === 'object') { const entries = Object.entries(res); const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-info-600' })); return renderMetricRow(items); } const counts = pipelineOverview[5]?.counts ?? metrics?.images ?? null; if (counts && typeof counts === 'object') { const entries = Object.entries(counts); const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-info-600' })); return renderMetricRow(items); } return renderMetricRow([ { label: 'Pending:', value: pipelineOverview[5]?.pending ?? metrics?.images?.pending ?? 0, colorCls: 'text-info-600' }, ]); })()}
{/* Global Progress Bar - Shows full pipeline progress during automation run */} {currentRun && (currentRun.status === 'running' || currentRun.status === 'paused') && ( )} {/* Current Processing Card - Shows real-time automation progress */} {currentRun && showProcessingCard && activeSite && ( { // Refresh current run status, pipeline overview and metrics (no full page reload) await loadCurrentRun(); await loadPipelineOverview(); await loadMetrics(); }} onClose={() => { // hide the processing card until next run setShowProcessingCard(false); }} /> )} {/* Pipeline Stages */} {/* Row 1: Stages 1-4 */}
{pipelineOverview.slice(0, 4).map((stage, index) => { const stageConfig = STAGE_CONFIG[index]; const StageIcon = stageConfig.icon; const isActive = currentRun?.current_stage === stage.number; const isComplete = currentRun && currentRun.current_stage > stage.number; const result = currentRun ? (currentRun[`stage_${stage.number}_result` as keyof AutomationRun] as any) : null; // FIXED: Get processed count from stage result using correct key const processed = getProcessedFromResult(result, stage.number); // FIXED: For total, prioritize: // 1. *_total from result (set during active processing, most accurate) // 2. pending from real-time pipeline_overview (current DB state) // 3. Fallback to processed (for completed stages) const totalKeyMap: Record = { 1: 'keywords_total', 2: 'clusters_total', 3: 'ideas_total', 4: 'tasks_total', 5: 'content_total', 6: 'images_total', 7: 'review_total' }; const resultTotal = result?.[totalKeyMap[stage.number]] ?? 0; // For total: prioritize result total (set at stage start), then fallback to DB pending + processed const dbPending = stage.pending ?? 0; const total = resultTotal > 0 ? resultTotal : (isActive || isComplete ? dbPending + processed : dbPending); // FIXED: For active stages, "Pending" = items remaining = total - processed // For inactive stages, "Pending" = items ready in queue (from DB) const pending = isActive || isComplete ? Math.max(0, total - processed) : dbPending; const progressPercent = total > 0 ? Math.min(Math.round((processed / total) * 100), 100) : 0; // Determine the left border color based on stage const stageBorderColors = ['border-l-brand-500', 'border-l-purple-500', 'border-l-warning-500', 'border-l-gray-600']; const stageBorderColor = stageBorderColors[index] || 'border-l-brand-500'; // Check if this stage is enabled in config const stageEnabledKey = `stage_${stage.number}_enabled` as keyof AutomationConfig; const isStageEnabled = config?.[stageEnabledKey] ?? true; return (
0 ? `${stageConfig.hoverColor} hover:shadow-lg` : '' } } `} > {/* Header Row - Icon, Stage Number, Status on left; Function Name on right */}
Stage {stage.number} {isActive && isStageEnabled && ● Active} {isActive && !isStageEnabled && Skipped} {isComplete && } {!isActive && !isComplete && stage.pending > 0 && !isStageEnabled && Skipped} {!isActive && !isComplete && stage.pending > 0 && isStageEnabled && Ready}
{/* Stage Function Name - Right side, larger font */}
{stageConfig.name}
{/* Single Row: Pending & Processed - Larger Font */}
Pending
0 ? stageConfig.textColor : 'text-gray-400 dark:text-gray-500'}`}> {pending}
Processed
0 ? 'text-success-600 dark:text-success-400' : 'text-gray-400 dark:text-gray-500'}`}> {processed}
{/* Credits and Duration - show during/after run */} {result && (result.credits_used !== undefined || result.time_elapsed) && (
{result.credits_used !== undefined && ( {result.credits_used} credits )} {result.time_elapsed && ( {result.time_elapsed} )}
)} {/* Progress Bar with breathing circle indicator */} {(isActive || isComplete || processed > 0) && (
Progress {isActive && ( )} {isComplete && ( )}
{isComplete ? '100' : progressPercent}%
)}
); })}
{/* Row 2: Stages 5-7 + Status Summary */}
{/* Stages 5-6 */} {pipelineOverview.slice(4, 6).map((stage, index) => { const actualIndex = index + 4; const stageConfig = STAGE_CONFIG[actualIndex]; const StageIcon = stageConfig.icon; const isActive = currentRun?.current_stage === stage.number; const isComplete = currentRun && currentRun.current_stage > stage.number; const result = currentRun ? (currentRun[`stage_${stage.number}_result` as keyof AutomationRun] as any) : null; // FIXED: Get processed count from stage result using correct key const processed = getProcessedFromResult(result, stage.number); // FIXED: Same logic as stages 1-4 const totalKeyMap: Record = { 5: 'content_total', 6: 'images_total', }; const resultTotal = result?.[totalKeyMap[stage.number]] ?? 0; // For total: prioritize result total (set at stage start), then fallback to DB pending + processed const dbPending = stage.pending ?? 0; const total = resultTotal > 0 ? resultTotal : (isActive || isComplete ? dbPending + processed : dbPending); // FIXED: For active stages, "Pending" = items remaining = total - processed const pending = isActive || isComplete ? Math.max(0, total - processed) : dbPending; const progressPercent = total > 0 ? Math.min(Math.round((processed / total) * 100), 100) : 0; // Determine the left border color based on stage (5=brand, 6=purple) const stageBorderColors56 = ['border-l-brand-500', 'border-l-purple-500']; const stageBorderColor = stageBorderColors56[index] || 'border-l-brand-500'; // Check if this stage is enabled in config const stageEnabledKey = `stage_${stage.number}_enabled` as keyof AutomationConfig; const isStageEnabled = config?.[stageEnabledKey] ?? true; return (
0 ? `${stageConfig.hoverColor} hover:shadow-lg` : '' } `} > {/* Header Row - Icon, Stage Number, Status on left; Function Name on right */}
Stage {stage.number} {isActive && isStageEnabled && ● Active} {isActive && !isStageEnabled && Skipped} {isComplete && } {!isActive && !isComplete && stage.pending > 0 && !isStageEnabled && Skipped} {!isActive && !isComplete && stage.pending > 0 && isStageEnabled && Ready}
{/* Stage Function Name - Right side, larger font */}
{stageConfig.name}
{/* Single Row: Pending & Processed - Larger Font */}
Pending
0 ? stageConfig.textColor : 'text-gray-400 dark:text-gray-500'}`}> {pending}
Processed
0 ? 'text-success-600 dark:text-success-400' : 'text-gray-400 dark:text-gray-500'}`}> {processed}
{/* Credits and Duration - show during/after run */} {result && (result.credits_used !== undefined || result.time_elapsed) && (
{result.credits_used !== undefined && ( {result.credits_used} credits )} {result.time_elapsed && ( {result.time_elapsed} )}
)} {/* Progress Bar with breathing circle indicator */} {(isActive || isComplete || processed > 0) && (
Progress {isActive && ( )} {isComplete && ( )}
{isComplete ? '100' : progressPercent}%
)}
); })} {/* Stage 7 - Review → Published (Auto-approve) */} {pipelineOverview[6] && (() => { const stage7 = pipelineOverview[6]; const stageConfig = STAGE_CONFIG[6]; const isActive = currentRun?.current_stage === 7; const isComplete = currentRun && currentRun.status === 'completed'; const result = currentRun ? (currentRun[`stage_7_result` as keyof AutomationRun] as any) : null; // For stage 7: pending = items in review, processed = items approved const approvedCount = result?.approved_count ?? 0; const totalReview = result?.review_total ?? result?.ready_for_review ?? stage7.pending ?? 0; const pendingReview = isActive || isComplete ? Math.max(0, totalReview - approvedCount) : stage7.pending ?? 0; const progressPercent = totalReview > 0 ? Math.min(Math.round((approvedCount / totalReview) * 100), 100) : 0; // Check if stage 7 is enabled in config const isStage7Enabled = config?.stage_7_enabled ?? true; return (
0 ? 'hover:border-success-500 hover:shadow-lg' : '' } `} > {/* Header Row - Icon, Stage Number, Status on left; Function Name on right */}
Stage 7 {isActive && isStage7Enabled && ● Active} {isActive && !isStage7Enabled && Skipped} {isActive && !isStage7Enabled && Skipped} {isComplete && } {!isActive && !isComplete && pendingReview > 0 && !isStage7Enabled && Skipped} {!isActive && !isComplete && pendingReview > 0 && isStage7Enabled && Ready}
{/* Stage Function Name - Right side, larger font */}
{stageConfig.name}
{/* Single Row: Pending & Approved */}
Pending
0 ? stageConfig.textColor : 'text-gray-400 dark:text-gray-500'}`}> {pendingReview}
Approved
0 ? 'text-success-600 dark:text-success-400' : 'text-gray-400 dark:text-gray-500'}`}> {approvedCount}
{/* Progress Bar with breathing circle indicator */} {(isActive || isComplete || approvedCount > 0) && (
Progress {isActive && ( )} {isComplete && ( )}
{isComplete ? '100' : progressPercent}%
)}
); })()} {/* Approved summary card - Same layout as Stage 7 */}
{/* Header Row - Icon and Label on left, Big Count on right */}
Approved
{/* Big count on right */}
{metrics?.content?.published ?? pipelineOverview[3]?.counts?.published ?? getStageResult(4)?.published ?? 0}
{/* Status Label - Right aligned */}
Published Content
{/* Activity Log */} {currentRun && } {/* Run History */} {/* Config Modal */} {showConfigModal && config && ( setShowConfigModal(false)} /> )}
)} ); }; export default AutomationPage;