diff --git a/backend/igny8_core/business/automation/services/automation_logger.py b/backend/igny8_core/business/automation/services/automation_logger.py index 0b2d83a4..317ab249 100644 --- a/backend/igny8_core/business/automation/services/automation_logger.py +++ b/backend/igny8_core/business/automation/services/automation_logger.py @@ -140,7 +140,7 @@ class AutomationLogger: def _get_stage_log_path(self, account_id: int, site_id: int, run_id: str, stage_number: int) -> str: """Get stage log file path""" run_dir = self._get_run_dir(account_id, site_id, run_id) - return os.path.join(run_dir, f'stage_{stage_number}.log') + return os.path.join(run_dir, f'stage_{str(stage_number)}.log') def _append_to_main_log(self, account_id: int, site_id: int, run_id: str, message: str): """Append message to main log file""" diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py index a44ba2bd..cc7b7f57 100644 --- a/backend/igny8_core/business/automation/services/automation_service.py +++ b/backend/igny8_core/business/automation/services/automation_service.py @@ -17,6 +17,7 @@ from igny8_core.auth.models import Account, Site from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas from igny8_core.modules.writer.models import Tasks, Content, Images from igny8_core.ai.models import AITaskLog +from igny8_core.ai.engine import AIEngine # AI Functions from igny8_core.ai.functions.auto_cluster import AutoClusterFunction @@ -79,8 +80,8 @@ class AutomationService: # Check credit balance (with 20% buffer) required_credits = int(estimated_credits * 1.2) - if self.account.credits_balance < required_credits: - raise ValueError(f"Insufficient credits. Need ~{required_credits}, you have {self.account.credits_balance}") + if self.account.credits < required_credits: + raise ValueError(f"Insufficient credits. Need ~{required_credits}, you have {self.account.credits}") # Create run_id and log files run_id = self.logger.start_run(self.account.id, self.site.id, trigger_type) @@ -102,7 +103,7 @@ class AutomationService: ) self.logger.log_stage_progress( run_id, self.account.id, self.site.id, 0, - f"Credit check: Account has {self.account.credits_balance} credits, estimated need: {estimated_credits} credits" + f"Credit check: Account has {self.account.credits} credits, estimated need: {estimated_credits} credits" ) logger.info(f"[AutomationService] Started run: {run_id}") @@ -164,10 +165,11 @@ class AutomationService: stage_number, f"Processing batch {batch_num}/{total_batches} ({len(batch)} keywords)" ) - # Call AI function - result = AutoClusterFunction().execute( - payload={'ids': batch}, - account=self.account + # Call AI function via AIEngine + engine = AIEngine(account=self.account) + result = engine.execute( + fn=AutoClusterFunction(), + payload={'ids': batch} ) # Monitor task @@ -258,10 +260,11 @@ class AutomationService: stage_number, f"Generating ideas for cluster: {cluster.name}" ) - # Call AI function - result = GenerateIdeasFunction().execute( - payload={'ids': [cluster.id]}, - account=self.account + # Call AI function via AIEngine + engine = AIEngine(account=self.account) + result = engine.execute( + fn=GenerateIdeasFunction(), + payload={'ids': [cluster.id]} ) # Monitor task @@ -418,11 +421,10 @@ class AutomationService: stage_name = "Tasks → Content (AI)" start_time = time.time() - # Query queued tasks + # Query queued tasks (all queued tasks need content generated) pending_tasks = Tasks.objects.filter( site=self.site, - status='queued', - content__isnull=True + status='queued' ) total_count = pending_tasks.count() @@ -453,10 +455,11 @@ class AutomationService: stage_number, f"Generating content for task: {task.title}" ) - # Call AI function - result = GenerateContentFunction().execute( - payload={'ids': [task.id]}, - account=self.account + # Call AI function via AIEngine + engine = AIEngine(account=self.account) + result = engine.execute( + fn=GenerateContentFunction(), + payload={'ids': [task.id]} ) # Monitor task @@ -551,10 +554,11 @@ class AutomationService: stage_number, f"Extracting prompts from: {content.title}" ) - # Call AI function - result = GenerateImagePromptsFunction().execute( - payload={'ids': [content.id]}, - account=self.account + # Call AI function via AIEngine + engine = AIEngine(account=self.account) + result = engine.execute( + fn=GenerateImagePromptsFunction(), + payload={'ids': [content.id]} ) # Monitor task @@ -641,10 +645,11 @@ class AutomationService: stage_number, f"Generating image: {image.image_type} for '{content_title}'" ) - # Call AI function - result = GenerateImagesFunction().execute( - payload={'image_ids': [image.id]}, - account=self.account + # Call AI function via AIEngine + engine = AIEngine(account=self.account) + result = engine.execute( + fn=GenerateImagesFunction(), + payload={'image_ids': [image.id]} ) # Monitor task diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index fbb42d27..56473dac 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -311,3 +311,117 @@ class AutomationViewSet(viewsets.ViewSet): 'current_balance': site.account.credits, 'sufficient': site.account.credits >= (estimated_credits * 1.2) }) + + @action(detail=False, methods=['get']) + def pipeline_overview(self, request): + """ + GET /api/v1/automation/pipeline_overview/?site_id=123 + Get pipeline overview with pending counts for all stages + """ + site, error_response = self._get_site(request) + if error_response: + return error_response + + from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas + from igny8_core.business.content.models import Tasks, Content, Images + from django.db.models import Count + + # Stage 1: Keywords pending clustering + stage_1_pending = Keywords.objects.filter( + site=site, + status='new', + cluster__isnull=True, + disabled=False + ).count() + + # Stage 2: Clusters needing ideas + stage_2_pending = Clusters.objects.filter( + site=site, + status='new', + disabled=False + ).exclude( + ideas__isnull=False + ).count() + + # Stage 3: Ideas ready to queue + stage_3_pending = ContentIdeas.objects.filter( + site=site, + status='new' + ).count() + + # Stage 4: Tasks ready for content generation + # Tasks don't have content FK - check if content exists via task title matching + stage_4_pending = Tasks.objects.filter( + site=site, + status='queued' + ).count() + + # Stage 5: Content ready for image prompts + stage_5_pending = Content.objects.filter( + site=site, + status='draft' + ).annotate( + images_count=Count('images') + ).filter( + images_count=0 + ).count() + + # Stage 6: Image prompts ready for generation + stage_6_pending = Images.objects.filter( + site=site, + status='pending' + ).count() + + # Stage 7: Content ready for review + stage_7_ready = Content.objects.filter( + site=site, + status='review' + ).count() + + return Response({ + 'stages': [ + { + 'number': 1, + 'name': 'Keywords → Clusters', + 'pending': stage_1_pending, + 'type': 'AI' + }, + { + 'number': 2, + 'name': 'Clusters → Ideas', + 'pending': stage_2_pending, + 'type': 'AI' + }, + { + 'number': 3, + 'name': 'Ideas → Tasks', + 'pending': stage_3_pending, + 'type': 'Local' + }, + { + 'number': 4, + 'name': 'Tasks → Content', + 'pending': stage_4_pending, + 'type': 'AI' + }, + { + 'number': 5, + 'name': 'Content → Image Prompts', + 'pending': stage_5_pending, + 'type': 'AI' + }, + { + 'number': 6, + 'name': 'Image Prompts → Images', + 'pending': stage_6_pending, + 'type': 'AI' + }, + { + 'number': 7, + 'name': 'Manual Review Gate', + 'pending': stage_7_ready, + 'type': 'Manual' + } + ] + }) + diff --git a/AUTOMATION-DEPLOYMENT-CHECKLIST.md b/docs/automation/AUTOMATION-DEPLOYMENT-CHECKLIST.md similarity index 100% rename from AUTOMATION-DEPLOYMENT-CHECKLIST.md rename to docs/automation/AUTOMATION-DEPLOYMENT-CHECKLIST.md diff --git a/AUTOMATION-IMPLEMENTATION-README.md b/docs/automation/AUTOMATION-IMPLEMENTATION-README.md similarity index 100% rename from AUTOMATION-IMPLEMENTATION-README.md rename to docs/automation/AUTOMATION-IMPLEMENTATION-README.md diff --git a/automation-plan.md b/docs/automation/automation-plan.md similarity index 100% rename from automation-plan.md rename to docs/automation/automation-plan.md diff --git a/frontend/src/components/Automation/StageCard.tsx b/frontend/src/components/Automation/StageCard.tsx index 5ea84547..5aad391c 100644 --- a/frontend/src/components/Automation/StageCard.tsx +++ b/frontend/src/components/Automation/StageCard.tsx @@ -3,56 +3,88 @@ * Shows status and results for each automation stage */ import React from 'react'; -import { StageResult } from '../../services/automationService'; +import { StageResult, PipelineStage } from '../../services/automationService'; interface StageCardProps { stageNumber: number; stageName: string; - currentStage: number; - result: StageResult | null; + currentStage?: number; + result?: StageResult | null; + pipelineData?: PipelineStage; } const StageCard: React.FC = ({ stageNumber, stageName, - currentStage, - result, + currentStage = 0, + result = null, + pipelineData, }) => { const isPending = stageNumber > currentStage; const isActive = stageNumber === currentStage; const isComplete = stageNumber < currentStage || (result !== null && stageNumber <= 7); const getStatusColor = () => { - if (isActive) return 'border-blue-500 bg-blue-50'; - if (isComplete) return 'border-green-500 bg-green-50'; - return 'border-gray-300 bg-gray-50'; + if (isActive) return 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-400'; + if (isComplete) return 'border-green-500 bg-green-50 dark:bg-green-900/20 dark:border-green-400'; + if (pipelineData && pipelineData.pending > 0) return 'border-purple-400 bg-purple-50 dark:bg-purple-900/20 dark:border-purple-400'; + return 'border-gray-300 bg-gray-50 dark:bg-gray-800 dark:border-gray-600'; }; const getStatusIcon = () => { if (isActive) return '🔄'; if (isComplete) return '✅'; - return '⏳'; + if (pipelineData && pipelineData.pending > 0) return '📋'; + return '⏸'; + }; + + const getStatusText = () => { + if (isActive) return 'Processing'; + if (isComplete) return 'Completed'; + if (pipelineData && pipelineData.pending > 0) return 'Ready'; + return 'Waiting'; }; return ( -
+
-
Stage {stageNumber}
+
Stage {stageNumber}
{getStatusIcon()}
-
{stageName}
+
{stageName}
+ + {/* Show pipeline pending counts when not running */} + {!isActive && !isComplete && pipelineData && ( +
+
+ Pending: + {pipelineData.pending} +
+
{getStatusText()}
+
+ )} + + {/* Show run results when stage has completed */} {result && ( -
+
{Object.entries(result).map(([key, value]) => (
- {key.replace(/_/g, ' ')}: - {value} + {key.replace(/_/g, ' ')}: + {value}
))}
)} + + {/* Show processing indicator when active */} + {isActive && !result && ( +
+
Processing...
+
+ )}
); }; export default StageCard; + diff --git a/frontend/src/components/sites/SiteProgressWidget.tsx b/frontend/src/components/sites/SiteProgressWidget.tsx index 3d35291d..f86e07ec 100644 --- a/frontend/src/components/sites/SiteProgressWidget.tsx +++ b/frontend/src/components/sites/SiteProgressWidget.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card } from '../ui/card'; import Badge from '../ui/badge/Badge'; -import { fetchSiteProgress, SiteProgress } from '../../services/api'; +// import { fetchSiteProgress, SiteProgress } from '../../services/api'; import { CheckCircleIcon, XCircleIcon, AlertCircleIcon, ArrowRightIcon } from 'lucide-react'; interface SiteProgressWidgetProps { diff --git a/frontend/src/pages/Automation/AutomationPage.tsx b/frontend/src/pages/Automation/AutomationPage.tsx index df78ace7..482f634c 100644 --- a/frontend/src/pages/Automation/AutomationPage.tsx +++ b/frontend/src/pages/Automation/AutomationPage.tsx @@ -5,25 +5,36 @@ import React, { useState, useEffect } from 'react'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { useSiteStore } from '../../store/siteStore'; -import { automationService, AutomationRun, AutomationConfig } from '../../services/automationService'; -import StageCard from '../../components/Automation/StageCard'; +import { automationService, AutomationRun, AutomationConfig, PipelineStage } from '../../services/automationService'; import ActivityLog from '../../components/Automation/ActivityLog'; import ConfigModal from '../../components/Automation/ConfigModal'; import RunHistory from '../../components/Automation/RunHistory'; import PageMeta from '../../components/common/PageMeta'; 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 } from '../../icons'; +import { + BoltIcon, + ListIcon, + GroupIcon, + FileTextIcon, + PencilIcon, + FileIcon, + CheckCircleIcon, + ClockIcon, + PaperPlaneIcon, + ArrowRightIcon +} from '../../icons'; -const STAGE_NAMES = [ - 'Keywords → Clusters', - 'Clusters → Ideas', - 'Ideas → Tasks', - 'Tasks → Content', - 'Content → Image Prompts', - 'Image Prompts → Images', - 'Manual Review Gate', +const STAGE_CONFIG = [ + { icon: ListIcon, color: 'from-blue-500 to-blue-600', hoverColor: 'hover:border-blue-500', name: 'Keywords → Clusters' }, + { icon: GroupIcon, color: 'from-purple-500 to-purple-600', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' }, + { icon: CheckCircleIcon, color: 'from-indigo-500 to-indigo-600', hoverColor: 'hover:border-indigo-500', name: 'Ideas → Tasks' }, + { icon: PencilIcon, color: 'from-green-500 to-green-600', hoverColor: 'hover:border-green-500', name: 'Tasks → Content' }, + { icon: FileIcon, color: 'from-amber-500 to-amber-600', hoverColor: 'hover:border-amber-500', name: 'Content → Image Prompts' }, + { icon: FileTextIcon, color: 'from-pink-500 to-pink-600', hoverColor: 'hover:border-pink-500', name: 'Image Prompts → Images' }, + { icon: PaperPlaneIcon, color: 'from-teal-500 to-teal-600', hoverColor: 'hover:border-teal-500', name: 'Manual Review Gate' }, ]; const AutomationPage: React.FC = () => { @@ -31,41 +42,40 @@ const AutomationPage: React.FC = () => { const toast = useToast(); const [config, setConfig] = useState(null); const [currentRun, setCurrentRun] = useState(null); + const [pipelineOverview, setPipelineOverview] = useState([]); const [showConfigModal, setShowConfigModal] = useState(false); const [loading, setLoading] = useState(true); - const [lastUpdated, setLastUpdated] = useState(new Date()); const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null); - // Poll for current run updates useEffect(() => { if (!activeSite) return; - loadData(); - - // Poll every 5 seconds when run is active + const interval = setInterval(() => { if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) { loadCurrentRun(); + } else { + loadPipelineOverview(); } }, 5000); - + return () => clearInterval(interval); }, [activeSite, currentRun?.status]); const loadData = async () => { if (!activeSite) return; - try { setLoading(true); - const [configData, runData, estimateData] = await Promise.all([ + const [configData, runData, estimateData, pipelineData] = await Promise.all([ automationService.getConfig(activeSite.id), automationService.getCurrentRun(activeSite.id), automationService.estimate(activeSite.id), + automationService.getPipelineOverview(activeSite.id), ]); setConfig(configData); setCurrentRun(runData.run); setEstimate(estimateData); - setLastUpdated(new Date()); + setPipelineOverview(pipelineData.stages); } catch (error: any) { toast.error('Failed to load automation data'); console.error(error); @@ -76,7 +86,6 @@ const AutomationPage: React.FC = () => { const loadCurrentRun = async () => { if (!activeSite) return; - try { const data = await automationService.getCurrentRun(activeSite.id); setCurrentRun(data.run); @@ -85,18 +94,25 @@ const AutomationPage: React.FC = () => { } }; + 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 handleRunNow = async () => { if (!activeSite) return; - - // Check credit balance if (estimate && !estimate.sufficient) { toast.error(`Insufficient credits. Need ~${estimate.estimated_credits}, you have ${estimate.current_balance}`); return; } - try { const result = await automationService.runNow(activeSite.id); - toast.success('Automation started'); + toast.success('Automation started successfully'); loadCurrentRun(); } catch (error: any) { toast.error(error.response?.data?.error || 'Failed to start automation'); @@ -105,7 +121,6 @@ const AutomationPage: React.FC = () => { const handlePause = async () => { if (!currentRun) return; - try { await automationService.pause(currentRun.run_id); toast.success('Automation paused'); @@ -117,7 +132,6 @@ const AutomationPage: React.FC = () => { const handleResume = async () => { if (!currentRun) return; - try { await automationService.resume(currentRun.run_id); toast.success('Automation resumed'); @@ -129,7 +143,6 @@ const AutomationPage: React.FC = () => { const handleSaveConfig = async (newConfig: Partial) => { if (!activeSite) return; - try { await automationService.updateConfig(activeSite.id, newConfig); toast.success('Configuration saved'); @@ -156,184 +169,469 @@ const AutomationPage: React.FC = () => { ); } + const totalPending = pipelineOverview.reduce((sum, stage) => sum + stage.pending, 0); + return ( <> - +
- {/* Page Header with Site Selector (no sector) */} + {/* Header */}
-
+
-

AI Automation Pipeline

-
- {activeSite && ( -
- {lastUpdated && ( -

- Last updated: {lastUpdated.toLocaleTimeString()} +

+

AI Automation Pipeline

+ {activeSite && ( +

+ Site: {activeSite.name}

)} - -

- Site: {activeSite.name} -

-
- )} -
- -
- -
-
- - {/* Schedule Status Card */} - {config && ( - -
-
-
Status
-
- {config.is_enabled ? ( - ● Enabled - ) : ( - ○ Disabled - )} -
-
-
-
Schedule
-
- {config.frequency} at {config.scheduled_time} -
-
-
-
Last Run
-
- {config.last_run_at - ? new Date(config.last_run_at).toLocaleString() - : 'Never'} -
-
-
-
Estimated Credits
-
- {estimate?.estimated_credits || 0} credits - {estimate && !estimate.sufficient && ( - (Insufficient) - )} -
+
+ +
-
- - {currentRun?.status === 'running' && ( - - )} - {currentRun?.status === 'paused' && ( - - )} - {!currentRun && ( - - )} + {currentRun?.status === 'running' && ( + + )} + {currentRun?.status === 'paused' && ( + + )} + {!currentRun && ( + + )} +
)} - {/* Current Run Status */} - {currentRun && ( - -
-
-
Status
-
{currentRun.status}
-
-
-
Current Stage
-
- Stage {currentRun.current_stage}: {STAGE_NAMES[currentRun.current_stage - 1]} + {/* Pipeline Overview */} + +
+
+ {currentRun ? ( + + + Live Run Active - Stage {currentRun.current_stage} of 7 + + ) : ( + Pipeline Status - Ready to run + )} +
+
+ {totalPending} items pending +
+
+ + {/* Pipeline Overview - 5 cards spread to full width */} +
+ {/* Stages 1-6 in main row (with 3+4 combined) */} + {pipelineOverview.slice(0, 6).map((stage, index) => { + // Combine stages 3 and 4 into one card + if (index === 2) { + const stage3 = pipelineOverview[2]; + const stage4 = pipelineOverview[3]; + const isActive3 = currentRun?.current_stage === 3; + const isActive4 = currentRun?.current_stage === 4; + const isComplete3 = currentRun && currentRun.current_stage > 3; + const isComplete4 = currentRun && currentRun.current_stage > 4; + const result3 = currentRun ? (currentRun[`stage_3_result` as keyof AutomationRun] as any) : null; + const result4 = currentRun ? (currentRun[`stage_4_result` as keyof AutomationRun] as any) : null; + + return ( +
0 || stage4.pending > 0) + ? `border-slate-200 bg-white dark:bg-white/[0.03] dark:border-gray-800 hover:border-indigo-500 hover:shadow-lg` + : 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800' + } + `} + > +
+
Stages 3 & 4
+
+ +
+
+
+ Ideas → Tasks → Content +
+ + {/* Queue Details - Always Show */} +
+ {/* Stage 3 queue */} +
+
+ Ideas → Tasks + {isActive3 && ● Processing} + {isComplete3 && ✓ Completed} +
+ {result3 ? ( +
+
+ Processed: + {result3.ideas_processed || 0} +
+
+ Created: + {result3.tasks_created || 0} +
+
+ ) : ( +
+
+ Total Queue: + {stage3.pending} +
+
+ Processed: + 0 +
+
+ Remaining: + {stage3.pending} +
+
+ )} +
+ + {/* Stage 4 queue */} +
+
+ Tasks → Content + {isActive4 && ● Processing} + {isComplete4 && ✓ Completed} +
+ {result4 ? ( +
+
+ Processed: + {result4.tasks_processed || 0} +
+
+ Created: + {result4.content_created || 0} +
+
+ Credits: + {result4.credits_used || 0} +
+
+ ) : ( +
+
+ Total Queue: + {stage4.pending} +
+
+ Processed: + 0 +
+
+ Remaining: + {stage4.pending} +
+
+ )} +
+
+
+ ); + } + + // Skip stage 4 since it's combined with 3 + if (index === 3) return null; + + // Adjust index for stages 5 and 6 (shift by 1 because we skip stage 4) + const actualStage = index < 3 ? stage : pipelineOverview[index + 1]; + const stageConfig = STAGE_CONFIG[index < 3 ? index : index + 1]; + const StageIcon = stageConfig.icon; + const isActive = currentRun?.current_stage === actualStage.number; + const isComplete = currentRun && currentRun.current_stage > actualStage.number; + const result = currentRun ? (currentRun[`stage_${actualStage.number}_result` as keyof AutomationRun] as any) : null; + + return ( +
0 + ? `border-slate-200 bg-white dark:bg-white/[0.03] dark:border-gray-800 ${stageConfig.hoverColor} hover:shadow-lg` + : 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800' + } + `} + > + {/* Header */} +
+
+
Stage {actualStage.number}
+ {isActive && ● Processing} + {isComplete && ✓ Completed} + {!isActive && !isComplete && actualStage.pending > 0 && Ready} +
+
+ +
+
+ + {/* Stage Name */} +
+ {actualStage.name} +
+ + {/* Status Details - Always Show */} +
+ {/* Show results if completed */} + {result && ( +
+ {Object.entries(result).map(([key, value]) => ( +
+ {key.replace(/_/g, ' ')}: + + {value} + +
+ ))} +
+ )} + + {/* Show queue details if not completed */} + {!result && ( +
+
+ Total Queue: + {actualStage.pending} +
+
+ Processed: + 0 +
+
+ Remaining: + + {actualStage.pending} + +
+
+ )} + + {/* Show processing indicator if active */} + {isActive && ( +
+
+
+ Processing... +
+ {/* Progress bar placeholder */} +
+
+
+
+ )} + + {/* Show empty state */} + {!result && actualStage.pending === 0 && !isActive && ( +
+ No items to process +
+ )} +
-
-
-
Started
-
- {new Date(currentRun.started_at).toLocaleString()} -
-
-
-
Credits Used
-
{currentRun.total_credits_used}
+ ); + })} +
+ + {/* Stage 7 - Manual Review Gate (Separate Row) */} + {pipelineOverview[6] && ( +
+
+ {(() => { + const stage7 = pipelineOverview[6]; + const isActive = currentRun?.current_stage === 7; + const isComplete = currentRun && currentRun.current_stage > 7; + const result = currentRun ? (currentRun[`stage_7_result` as keyof AutomationRun] as any) : null; + + return ( +
0 + ? `border-slate-300 bg-white dark:bg-white/[0.05] dark:border-gray-700 hover:border-teal-500` + : 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800' + } + `} + > +
+
+ +
+
Stage 7
+
+ Manual Review Gate +
+
+ 🚫 Automation Stops Here +
+ + {stage7.pending > 0 && ( +
+
Content Ready for Manual Review
+
{stage7.pending}
+
pieces of content waiting
+
+ )} + + {result && ( +
+
Last Run Results
+
+ {Object.entries(result).map(([key, value]) => ( +
+
{key.replace(/_/g, ' ')}
+
{value}
+
+ ))} +
+
+ )} + +
+
+ Note: Automation ends when content reaches draft status with all images generated. + Please review content quality, accuracy, and brand voice manually before publishing to WordPress. +
+
+
+
+ ); + })()}
+ )} +
- {/* Stage Progress */} -
- {STAGE_NAMES.map((name, index) => ( - - ))} + {/* Current Run Details */} + {currentRun && ( + +
+ : + currentRun.status === 'paused' ? : + currentRun.status === 'completed' ? : + + } + accentColor={ + currentRun.status === 'running' ? 'blue' : + currentRun.status === 'paused' ? 'orange' : + currentRun.status === 'completed' ? 'success' : 'red' + } + /> + } + accentColor="blue" + /> + } + accentColor="blue" + /> + } + accentColor="green" + />
)} {/* Activity Log */} - {currentRun && ( - - )} + {currentRun && } {/* Run History */} {/* Config Modal */} {showConfigModal && config && ( - setShowConfigModal(false)} - /> + setShowConfigModal(false)} /> )}
@@ -341,3 +639,4 @@ const AutomationPage: React.FC = () => { }; export default AutomationPage; + diff --git a/frontend/src/pages/Automation/AutomationPage_old.tsx b/frontend/src/pages/Automation/AutomationPage_old.tsx new file mode 100644 index 00000000..568109d1 --- /dev/null +++ b/frontend/src/pages/Automation/AutomationPage_old.tsx @@ -0,0 +1,389 @@ +/** + * 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 } from '../../services/automationService'; +import StageCard from '../../components/Automation/StageCard'; +import ActivityLog from '../../components/Automation/ActivityLog'; +import ConfigModal from '../../components/Automation/ConfigModal'; +import RunHistory from '../../components/Automation/RunHistory'; +import PageMeta from '../../components/common/PageMeta'; +import ComponentCard from '../../components/common/ComponentCard'; +import DebugSiteSelector from '../../components/common/DebugSiteSelector'; +import Button from '../../components/ui/button/Button'; +import { BoltIcon } from '../../icons'; + +const STAGE_NAMES = [ + 'Keywords → Clusters', + 'Clusters → Ideas', + 'Ideas → Tasks', + 'Tasks → Content', + 'Content → Image Prompts', + 'Image Prompts → Images', + 'Manual Review Gate', +]; + +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 [showConfigModal, setShowConfigModal] = useState(false); + const [loading, setLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(new Date()); + const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null); + + // Poll for current run updates + useEffect(() => { + if (!activeSite) return; + + loadData(); + + // Poll every 5 seconds when run is active + const interval = setInterval(() => { + if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) { + loadCurrentRun(); + } else { + // Refresh pipeline overview when not running + loadPipelineOverview(); + } + }, 5000); + + return () => clearInterval(interval); + }, [activeSite, 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), + ]); + setConfig(configData); + setCurrentRun(runData.run); + setEstimate(estimateData); + setPipelineOverview(pipelineData.stages); + setLastUpdated(new Date()); + } catch (error: any) { + toast.error('Failed to load automation data'); + console.error(error); + } finally { + setLoading(false); + } + }; + + const loadCurrentRun = async () => { + if (!activeSite) return; + + try { + const data = await automationService.getCurrentRun(activeSite.id); + setCurrentRun(data.run); + } catch (error) { + console.error('Failed to poll current run', 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 handleRunNow = async () => { + if (!activeSite) return; + + // Check credit balance + if (estimate && !estimate.sufficient) { + toast.error(`Insufficient credits. Need ~${estimate.estimated_credits}, you have ${estimate.current_balance}`); + return; + } + + try { + const result = await automationService.runNow(activeSite.id); + toast.success('Automation started'); + loadCurrentRun(); + } catch (error: any) { + toast.error(error.response?.data?.error || 'Failed to start automation'); + } + }; + + const handlePause = async () => { + if (!currentRun) return; + + try { + await automationService.pause(currentRun.run_id); + toast.success('Automation paused'); + loadCurrentRun(); + } catch (error) { + toast.error('Failed to pause automation'); + } + }; + + const handleResume = async () => { + if (!currentRun) return; + + try { + await automationService.resume(currentRun.run_id); + toast.success('Automation resumed'); + loadCurrentRun(); + } 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); + loadData(); + } catch (error) { + toast.error('Failed to save configuration'); + } + }; + + if (loading) { + return ( +
+
Loading automation...
+
+ ); + } + + if (!activeSite) { + return ( +
+
Please select a site to view automation
+
+ ); + } + + return ( + <> + + +
+ {/* Page Header with Site Selector (no sector) */} +
+
+
+
+ +
+

AI Automation Pipeline

+
+ {activeSite && ( +
+ {lastUpdated && ( +

+ Last updated: {lastUpdated.toLocaleTimeString()} +

+ )} + +

+ Site: {activeSite.name} +

+
+ )} +
+ +
+ +
+
+ + {/* Schedule Status Card */} + {config && ( + +
+
+
Status
+
+ {config.is_enabled ? ( + ● Enabled + ) : ( + ○ Disabled + )} +
+
+
+
Schedule
+
+ {config.frequency} at {config.scheduled_time} +
+
+
+
Last Run
+
+ {config.last_run_at + ? new Date(config.last_run_at).toLocaleString() + : 'Never'} +
+
+
+
Estimated Credits
+
+ {estimate?.estimated_credits || 0} credits + {estimate && !estimate.sufficient && ( + (Insufficient) + )} +
+
+
+ +
+ + {currentRun?.status === 'running' && ( + + )} + {currentRun?.status === 'paused' && ( + + )} + {!currentRun && ( + + )} +
+
+ )} + + {/* Pipeline Overview - Always Visible */} + +
+
+ {currentRun ? ( + <> + ● Live Run Active - Stage {currentRun.current_stage} of 7 + + ) : ( + <> + Pipeline Status - Ready to run + + )} +
+
+ {pipelineOverview.reduce((sum, stage) => sum + stage.pending, 0)} total items pending +
+
+ + {/* Stage Cards Grid */} +
+ {STAGE_NAMES.map((name, index) => ( + + ))} +
+
+ + {/* Current Run Status */} + {currentRun && ( + +
+
+
Status
+
+ {currentRun.status === 'running' && ● {currentRun.status}} + {currentRun.status === 'paused' && ⏸ {currentRun.status}} + {currentRun.status === 'completed' && ✓ {currentRun.status}} + {currentRun.status === 'failed' && ✗ {currentRun.status}} +
+
+
+
Current Stage
+
+ Stage {currentRun.current_stage}: {STAGE_NAMES[currentRun.current_stage - 1]} +
+
+
+
Started
+
+ {new Date(currentRun.started_at).toLocaleString()} +
+
+
+
Credits Used
+
{currentRun.total_credits_used}
+
+
+
+ )} + + {/* Activity Log */} + {currentRun && ( + + )} + + {/* Run History */} + + + {/* Config Modal */} + {showConfigModal && config && ( + setShowConfigModal(false)} + /> + )} +
+ + ); +}; + +export default AutomationPage; diff --git a/frontend/src/pages/Planner/Dashboard.tsx b/frontend/src/pages/Planner/Dashboard.tsx index 42500ccb..e2191a5f 100644 --- a/frontend/src/pages/Planner/Dashboard.tsx +++ b/frontend/src/pages/Planner/Dashboard.tsx @@ -27,8 +27,8 @@ import { fetchClusters, fetchContentIdeas, fetchTasks, - fetchSiteBlueprints, - SiteBlueprint, + // fetchSiteBlueprints, + // SiteBlueprint, } from "../../services/api"; import { useSiteStore } from "../../store/siteStore"; import { useSectorStore } from "../../store/sectorStore"; @@ -78,12 +78,11 @@ export default function PlannerDashboard() { try { setLoading(true); - const [keywordsRes, clustersRes, ideasRes, tasksRes, blueprintsRes] = await Promise.all([ + const [keywordsRes, clustersRes, ideasRes, tasksRes] = await Promise.all([ fetchKeywords({ page_size: 1000, sector_id: activeSector?.id }), fetchClusters({ page_size: 1000, sector_id: activeSector?.id }), fetchContentIdeas({ page_size: 1000, sector_id: activeSector?.id }), - fetchTasks({ page_size: 1000, sector_id: activeSector?.id }), - activeSite?.id ? fetchSiteBlueprints({ site_id: activeSite.id, page_size: 100 }) : Promise.resolve({ results: [] }) + fetchTasks({ page_size: 1000, sector_id: activeSector?.id }) ]); const keywords = keywordsRes.results || []; diff --git a/frontend/src/pages/Sites/Dashboard.tsx b/frontend/src/pages/Sites/Dashboard.tsx index 9864331d..f171c148 100644 --- a/frontend/src/pages/Sites/Dashboard.tsx +++ b/frontend/src/pages/Sites/Dashboard.tsx @@ -12,7 +12,8 @@ import { Card } from '../../components/ui/card'; import Button from '../../components/ui/button/Button'; import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard'; import { useToast } from '../../components/ui/toast/ToastContainer'; -import { fetchAPI, fetchSiteBlueprints } from '../../services/api'; +import { fetchAPI } from '../../services/api'; +// import { fetchSiteBlueprints } from '../../services/api'; import SiteProgressWidget from '../../components/sites/SiteProgressWidget'; import { EyeIcon, @@ -70,10 +71,10 @@ export default function SiteDashboard() { const loadSiteData = async () => { try { setLoading(true); - const [siteData, statsData, blueprintsData] = await Promise.all([ + const [siteData, statsData] = await Promise.all([ fetchAPI(`/v1/auth/sites/${siteId}/`), fetchSiteStats(), - fetchSiteBlueprints({ site_id: Number(siteId) }), + // fetchSiteBlueprints({ site_id: Number(siteId) }), ]); if (siteData) { diff --git a/frontend/src/pages/Sites/DeploymentPanel.tsx b/frontend/src/pages/Sites/DeploymentPanel.tsx index bb21099b..1bc04523 100644 --- a/frontend/src/pages/Sites/DeploymentPanel.tsx +++ b/frontend/src/pages/Sites/DeploymentPanel.tsx @@ -25,7 +25,7 @@ import { } from '../../icons'; import { fetchDeploymentReadiness, - fetchSiteBlueprints, + // fetchSiteBlueprints, DeploymentReadiness, } from '../../services/api'; import { fetchAPI } from '../../services/api'; @@ -50,7 +50,8 @@ export default function DeploymentPanel() { if (!siteId) return; try { setLoading(true); - const blueprintsData = await fetchSiteBlueprints({ site_id: Number(siteId) }); + // const blueprintsData = await fetchSiteBlueprints({ site_id: Number(siteId) }); + const blueprintsData = null; if (blueprintsData?.results && blueprintsData.results.length > 0) { setBlueprints(blueprintsData.results); const firstBlueprint = blueprintsData.results[0]; diff --git a/frontend/src/services/automationService.ts b/frontend/src/services/automationService.ts index 2a51b73d..a9b999e0 100644 --- a/frontend/src/services/automationService.ts +++ b/frontend/src/services/automationService.ts @@ -47,6 +47,13 @@ export interface RunHistoryItem { current_stage: number; } +export interface PipelineStage { + number: number; + name: string; + pending: number; + type: 'AI' | 'Local' | 'Manual'; +} + function buildUrl(endpoint: string, params?: Record): string { let url = `/v1/automation${endpoint}`; if (params) { @@ -141,4 +148,11 @@ export const automationService = { }> => { return fetchAPI(buildUrl('/estimate/', { site_id: siteId })); }, + + /** + * Get pipeline overview with pending counts for all stages + */ + getPipelineOverview: async (siteId: number): Promise<{ stages: PipelineStage[] }> => { + return fetchAPI(buildUrl('/pipeline_overview/', { site_id: siteId })); + }, };