1301 lines
64 KiB
TypeScript
1301 lines
64 KiB
TypeScript
/**
|
|
* 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<AutomationConfig | null>(null);
|
|
const [currentRun, setCurrentRun] = useState<AutomationRun | null>(null);
|
|
const [pipelineOverview, setPipelineOverview] = useState<PipelineStage[]>([]);
|
|
const [metrics, setMetrics] = useState<any>(null);
|
|
const [showConfigModal, setShowConfigModal] = useState(false);
|
|
const [showProcessingCard, setShowProcessingCard] = useState<boolean>(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<boolean | null>(null);
|
|
const [eligibilityMessage, setEligibilityMessage] = useState<string | null>(null);
|
|
const [eligibilityChecked, setEligibilityChecked] = useState(false);
|
|
|
|
// New state for unified progress data
|
|
const [globalProgress, setGlobalProgress] = useState<GlobalProgress | null>(null);
|
|
const [stageProgress, setStageProgress] = useState<StageProgress[]>([]);
|
|
const [initialSnapshot, setInitialSnapshot] = useState<InitialSnapshot | null>(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<AutomationConfig>) => {
|
|
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 (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-lg text-gray-600 dark:text-gray-400">Please select a site to view automation</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex text-sm mt-2">
|
|
<div className="flex-1 text-center"></div>
|
|
<div className="flex-1 text-center"></div>
|
|
<div className="flex-1 text-center"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (visible.length === 2) {
|
|
return (
|
|
<div className="flex text-sm mt-2 justify-center gap-6">
|
|
{visible.map((it, idx) => (
|
|
<div key={idx} className="w-1/3 text-center">
|
|
<span className={`${it.colorCls ?? ''} block font-medium`}>{it.label}</span>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">{Number(it.value) || 0}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// default to 3 columns (equally spaced)
|
|
return (
|
|
<div className="flex text-sm mt-2">
|
|
{items.concat(Array(Math.max(0, 3 - items.length)).fill({ label: '', value: '' })).slice(0,3).map((it, idx) => (
|
|
<div key={idx} className="flex-1 text-center">
|
|
<span className={`${it.colorCls ?? ''} block font-medium`}>{it.label}</span>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">{it.value !== undefined && it.value !== null ? Number(it.value) : ''}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
return (
|
|
<>
|
|
<PageMeta title="Content Automation | IGNY8" description="Automatically create and publish content on your schedule" />
|
|
<PageHeader
|
|
title="Automation"
|
|
description=""
|
|
badge={{ icon: <BoltIcon />, color: 'teal' }}
|
|
parent="Automation"
|
|
/>
|
|
|
|
{/* Show eligibility notice when site has no data */}
|
|
{eligibilityChecked && !isEligible && (
|
|
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8">
|
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-2xl p-8 max-w-2xl text-center">
|
|
<div className="size-16 mx-auto mb-4 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
|
<BoltIcon className="size-8 text-amber-600 dark:text-amber-400" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
|
Site Not Eligible for Automation Yet
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
{eligibilityMessage || 'This site doesn\'t have any data yet. Start by adding keywords in the Planner module to enable automation.'}
|
|
</p>
|
|
<Button
|
|
variant="primary"
|
|
tone="brand"
|
|
onClick={() => window.location.href = '/planner/keywords'}
|
|
>
|
|
Go to Keyword Planner
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Show loading state */}
|
|
{loading && !eligibilityChecked && (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-gray-500 dark:text-gray-400">Loading automation data...</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main content - only show when eligible */}
|
|
{eligibilityChecked && isEligible && (
|
|
<div className="space-y-6">
|
|
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
|
|
|
|
|
|
{/* Compact Schedule & Controls Panel */}
|
|
{config && (
|
|
<ComponentCard className="mt-[10px] border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700 [&>div]:!py-3 [&>div]:!px-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
{config.is_enabled ? (
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-success-500">
|
|
<div className="size-2 bg-white rounded-full"></div>
|
|
<span className="text-sm font-semibold text-white">Enabled</span>
|
|
</div>
|
|
) : (
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20">
|
|
<div className="size-2 bg-white/60 rounded-full"></div>
|
|
<span className="text-sm font-semibold text-white/90">Disabled</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="h-4 w-px bg-white/25"></div>
|
|
<div className="text-sm text-white/90">
|
|
<span className="font-medium capitalize">{config.frequency}</span> at <span className="font-medium">{config.scheduled_time}</span>
|
|
</div>
|
|
<div className="h-4 w-px bg-white/25"></div>
|
|
<div className="text-sm text-white/80">
|
|
Last: <span className="font-medium">{config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}</span>
|
|
</div>
|
|
{config.is_enabled && (
|
|
<>
|
|
<div className="h-4 w-px bg-white/25"></div>
|
|
<div className="text-sm text-white/90">
|
|
Next: <span className="font-medium">{getNextRunTime(config)}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="h-4 w-px bg-white/25"></div>
|
|
<div className="text-sm text-white/90">
|
|
<span className="font-medium">Est:</span>{' '}
|
|
<span className="font-semibold text-white">{estimate?.estimated_credits || 0} content pieces</span>
|
|
{estimate && !estimate.sufficient && (
|
|
<span className="ml-1 text-white/90 font-semibold">(Limit reached)</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ready to Run Card - Inline horizontal */}
|
|
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all
|
|
${currentRun?.status === 'running' ? 'border-brand-300 bg-white' : currentRun?.status === 'paused' ? 'border-warning-300 bg-white' : totalPending > 0 ? 'border-success-300 bg-white' : 'border-white/30 bg-white/10'}`}>
|
|
<div className={`size-6 rounded-md flex items-center justify-center flex-shrink-0
|
|
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-brand-500 to-brand-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-warning-500 to-warning-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}>
|
|
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-3.5 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-3.5 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-3.5 text-white" /> : <BoltIcon className="size-3.5 text-white" />}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className={`text-sm font-semibold ${totalPending > 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'}
|
|
</span>
|
|
<span className={`text-xs ${totalPending > 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')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={() => setShowConfigModal(true)}
|
|
variant="outline"
|
|
tone="brand"
|
|
size="sm"
|
|
className="!border-white !text-white hover:!bg-white hover:!text-brand-700"
|
|
>
|
|
Configure
|
|
</Button>
|
|
{currentRun?.status === 'running' && (
|
|
<Button
|
|
onClick={handlePause}
|
|
variant="primary"
|
|
tone="warning"
|
|
size="sm"
|
|
className="!bg-white/10 !text-white hover:!bg-white/20"
|
|
>
|
|
Pause
|
|
</Button>
|
|
)}
|
|
{currentRun?.status === 'paused' && (
|
|
<Button
|
|
onClick={handleResume}
|
|
variant="primary"
|
|
tone="brand"
|
|
size="sm"
|
|
className="!bg-white/10 !text-white hover:!bg-white/20"
|
|
>
|
|
Resume
|
|
</Button>
|
|
)}
|
|
{!currentRun && (
|
|
<Button
|
|
onClick={handleRunNow}
|
|
variant="primary"
|
|
tone="success"
|
|
size="sm"
|
|
disabled={!config?.is_enabled}
|
|
className="!bg-white !text-brand-700 hover:!bg-success-600 hover:!text-white"
|
|
>
|
|
Run Now
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ComponentCard>
|
|
)}
|
|
|
|
{/* Metrics Summary Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
{/* Keywords */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
|
<ListIcon className="size-4 text-brand-600 dark:text-brand-400" />
|
|
</div>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">Keywords</div>
|
|
</div>
|
|
{(() => {
|
|
const res = getStageResult(1);
|
|
const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0;
|
|
return (
|
|
<div className="text-right">
|
|
<div className="text-3xl font-bold text-brand-600">{total}</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
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' },
|
|
])
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Clusters */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
|
<GroupIcon className="size-4 text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">Clusters</div>
|
|
</div>
|
|
{(() => {
|
|
const res = getStageResult(2);
|
|
const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0;
|
|
return (
|
|
<div className="text-right">
|
|
<div className="text-3xl font-bold text-purple-600">{total}</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
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' },
|
|
])
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Ideas */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
|
<BoltIcon className="size-4 text-warning-600 dark:text-warning-400" />
|
|
</div>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">Ideas</div>
|
|
</div>
|
|
{(() => {
|
|
const res = getStageResult(3);
|
|
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
|
|
return (
|
|
<div className="text-right">
|
|
<div className="text-3xl font-bold text-warning-600">{total}</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
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' },
|
|
])
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
|
<FileTextIcon className="size-4 text-success-600 dark:text-success-400" />
|
|
</div>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">Content</div>
|
|
</div>
|
|
{(() => {
|
|
const res = getStageResult(4);
|
|
const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0;
|
|
return (
|
|
<div className="text-right">
|
|
<div className="text-3xl font-bold text-success-600">{total}</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
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' },
|
|
])
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Images */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-info-100 dark:bg-info-900/30 flex items-center justify-center">
|
|
<FileIcon className="size-4 text-info-600 dark:text-info-400" />
|
|
</div>
|
|
<div className="text-base font-bold text-gray-900 dark:text-white">Images</div>
|
|
</div>
|
|
{(() => {
|
|
const res = getStageResult(6);
|
|
const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0;
|
|
return (
|
|
<div className="text-right">
|
|
<div className="text-3xl font-bold text-info-600">{total}</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
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' },
|
|
]);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Global Progress Bar - Shows full pipeline progress during automation run */}
|
|
{currentRun && (currentRun.status === 'running' || currentRun.status === 'paused') && (
|
|
<GlobalProgressBar
|
|
currentRun={currentRun}
|
|
globalProgress={globalProgress}
|
|
stages={stageProgress}
|
|
initialSnapshot={initialSnapshot}
|
|
/>
|
|
)}
|
|
|
|
{/* Current Processing Card - Shows real-time automation progress */}
|
|
{currentRun && showProcessingCard && activeSite && (
|
|
<CurrentProcessingCard
|
|
runId={currentRun.run_id}
|
|
siteId={activeSite.id}
|
|
currentRun={currentRun}
|
|
pipelineOverview={pipelineOverview}
|
|
onUpdate={async () => {
|
|
// 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 */}
|
|
<ComponentCard>
|
|
{/* Row 1: Stages 1-4 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
{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<number, string> = {
|
|
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 (
|
|
<div
|
|
key={stage.number}
|
|
className={`
|
|
relative rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900
|
|
border-l-[5px] ${stageBorderColor}
|
|
${ isActive
|
|
? 'shadow-lg ring-2 ring-brand-200 dark:ring-brand-800'
|
|
: isComplete
|
|
? ''
|
|
: stage.pending > 0
|
|
? `${stageConfig.hoverColor} hover:shadow-lg`
|
|
: ''
|
|
}
|
|
}
|
|
`}
|
|
>
|
|
{/* Header Row - Icon, Stage Number, Status on left; Function Name on right */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`size-8 rounded-lg bg-gradient-to-br ${stageConfig.color} flex items-center justify-center shadow-md flex-shrink-0`}>
|
|
<StageIcon className="size-4 text-white" />
|
|
</div>
|
|
<span className="text-base font-bold text-gray-900 dark:text-white">Stage {stage.number}</span>
|
|
{isActive && isStageEnabled && <span className="text-xs px-2 py-0.5 bg-brand-500 text-white rounded-full font-medium">● Active</span>}
|
|
{isActive && !isStageEnabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full font-medium">✓</span>}
|
|
{!isActive && !isComplete && stage.pending > 0 && !isStageEnabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{!isActive && !isComplete && stage.pending > 0 && isStageEnabled && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full font-medium">Ready</span>}
|
|
</div>
|
|
{/* Stage Function Name - Right side, larger font */}
|
|
<div className={`text-sm font-bold ${stageConfig.textColor}`}>{stageConfig.name}</div>
|
|
</div>
|
|
|
|
{/* Single Row: Pending & Processed - Larger Font */}
|
|
<div className="flex justify-between items-center mb-3">
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-0.5">Pending</div>
|
|
<div className={`text-xl font-bold ${pending > 0 ? stageConfig.textColor : 'text-gray-400 dark:text-gray-500'}`}>
|
|
{pending}
|
|
</div>
|
|
</div>
|
|
<div className="h-8 w-px bg-gray-200 dark:bg-gray-700"></div>
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-0.5">Processed</div>
|
|
<div className={`text-xl font-bold ${processed > 0 ? 'text-success-600 dark:text-success-400' : 'text-gray-400 dark:text-gray-500'}`}>
|
|
{processed}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credits and Duration - show during/after run */}
|
|
{result && (result.credits_used !== undefined || result.time_elapsed) && (
|
|
<div className="flex justify-between items-center py-2 border-t border-gray-200 dark:border-gray-700 text-xs">
|
|
{result.credits_used !== undefined && (
|
|
<span className="font-semibold text-warning-600 dark:text-warning-400">{result.credits_used} credits</span>
|
|
)}
|
|
{result.time_elapsed && (
|
|
<span className="text-gray-500 dark:text-gray-400">{result.time_elapsed}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress Bar with breathing circle indicator */}
|
|
{(isActive || isComplete || processed > 0) && (
|
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex justify-between items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span>Progress</span>
|
|
{isActive && (
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-brand-500"></span>
|
|
</span>
|
|
)}
|
|
{isComplete && (
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-success-500"></span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span>{isComplete ? '100' : progressPercent}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden shadow-inner">
|
|
<div
|
|
className={`bg-gradient-to-r ${isComplete ? 'from-success-400 to-success-600' : stageConfig.color} h-2.5 rounded-full transition-all duration-500`}
|
|
style={{ width: `${isComplete ? 100 : progressPercent}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Row 2: Stages 5-7 + Status Summary */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* 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<number, string> = {
|
|
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 (
|
|
<div
|
|
key={stage.number}
|
|
className={`
|
|
relative rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900
|
|
border-l-[5px] ${stageBorderColor}
|
|
${isActive
|
|
? 'shadow-lg ring-2 ring-brand-200 dark:ring-brand-800'
|
|
: isComplete
|
|
? ''
|
|
: stage.pending > 0
|
|
? `${stageConfig.hoverColor} hover:shadow-lg`
|
|
: ''
|
|
}
|
|
`}
|
|
>
|
|
{/* Header Row - Icon, Stage Number, Status on left; Function Name on right */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`size-8 rounded-lg bg-gradient-to-br ${stageConfig.color} flex items-center justify-center shadow-md flex-shrink-0`}>
|
|
<StageIcon className="size-4 text-white" />
|
|
</div>
|
|
<span className="text-base font-bold text-gray-900 dark:text-white">Stage {stage.number}</span>
|
|
{isActive && isStageEnabled && <span className="text-xs px-2 py-0.5 bg-brand-500 text-white rounded-full font-medium">● Active</span>}
|
|
{isActive && !isStageEnabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full font-medium">✓</span>}
|
|
{!isActive && !isComplete && stage.pending > 0 && !isStageEnabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{!isActive && !isComplete && stage.pending > 0 && isStageEnabled && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full font-medium">Ready</span>}
|
|
</div>
|
|
{/* Stage Function Name - Right side, larger font */}
|
|
<div className={`text-sm font-bold ${stageConfig.textColor}`}>{stageConfig.name}</div>
|
|
</div>
|
|
|
|
{/* Single Row: Pending & Processed - Larger Font */}
|
|
<div className="flex justify-between items-center mb-3">
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-0.5">Pending</div>
|
|
<div className={`text-xl font-bold ${pending > 0 ? stageConfig.textColor : 'text-gray-400 dark:text-gray-500'}`}>
|
|
{pending}
|
|
</div>
|
|
</div>
|
|
<div className="h-8 w-px bg-gray-200 dark:border-gray-700"></div>
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-0.5">Processed</div>
|
|
<div className={`text-xl font-bold ${processed > 0 ? 'text-success-600 dark:text-success-400' : 'text-gray-400 dark:text-gray-500'}`}>
|
|
{processed}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credits and Duration - show during/after run */}
|
|
{result && (result.credits_used !== undefined || result.time_elapsed) && (
|
|
<div className="flex justify-between items-center py-2 border-t border-gray-200 dark:border-gray-700 text-xs">
|
|
{result.credits_used !== undefined && (
|
|
<span className="font-semibold text-warning-600 dark:text-warning-400">{result.credits_used} credits</span>
|
|
)}
|
|
{result.time_elapsed && (
|
|
<span className="text-gray-500 dark:text-gray-400">{result.time_elapsed}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress Bar with breathing circle indicator */}
|
|
{(isActive || isComplete || processed > 0) && (
|
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex justify-between items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span>Progress</span>
|
|
{isActive && (
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-brand-500"></span>
|
|
</span>
|
|
)}
|
|
{isComplete && (
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-success-500"></span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span>{isComplete ? '100' : progressPercent}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden shadow-inner">
|
|
<div
|
|
className={`bg-gradient-to-r ${isComplete ? 'from-success-400 to-success-600' : stageConfig.color} h-2.5 rounded-full transition-all duration-500`}
|
|
style={{ width: `${isComplete ? 100 : progressPercent}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<div
|
|
className={`
|
|
relative rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900
|
|
border-l-[5px] border-l-success-500
|
|
${isActive
|
|
? 'shadow-lg ring-2 ring-success-200 dark:ring-success-800'
|
|
: isComplete
|
|
? ''
|
|
: pendingReview > 0
|
|
? 'hover:border-success-500 hover:shadow-lg'
|
|
: ''
|
|
}
|
|
`}
|
|
>
|
|
{/* Header Row - Icon, Stage Number, Status on left; Function Name on right */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`size-8 rounded-lg bg-gradient-to-br ${stageConfig.color} flex items-center justify-center shadow-md flex-shrink-0`}>
|
|
<PaperPlaneIcon className="size-4 text-white" />
|
|
</div>
|
|
<span className="text-base font-bold text-gray-900 dark:text-white">Stage 7</span>
|
|
{isActive && isStage7Enabled && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full font-medium">● Active</span>}
|
|
{isActive && !isStage7Enabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{isActive && !isStage7Enabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full font-medium">✓</span>}
|
|
{!isActive && !isComplete && pendingReview > 0 && !isStage7Enabled && <span className="text-xs px-2 py-0.5 bg-orange-500 text-white rounded-full font-medium">Skipped</span>}
|
|
{!isActive && !isComplete && pendingReview > 0 && isStage7Enabled && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full font-medium">Ready</span>}
|
|
</div>
|
|
{/* Stage Function Name - Right side, larger font */}
|
|
<div className={`text-sm font-bold ${stageConfig.textColor}`}>{stageConfig.name}</div>
|
|
</div>
|
|
|
|
{/* Single Row: Pending & Approved */}
|
|
<div className="flex justify-between items-center mb-3">
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-0.5">Pending</div>
|
|
<div className={`text-xl font-bold ${pendingReview > 0 ? stageConfig.textColor : 'text-gray-400 dark:text-gray-500'}`}>
|
|
{pendingReview}
|
|
</div>
|
|
</div>
|
|
<div className="h-8 w-px bg-gray-200 dark:bg-gray-700"></div>
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-0.5">Approved</div>
|
|
<div className={`text-xl font-bold ${approvedCount > 0 ? 'text-success-600 dark:text-success-400' : 'text-gray-400 dark:text-gray-500'}`}>
|
|
{approvedCount}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar with breathing circle indicator */}
|
|
{(isActive || isComplete || approvedCount > 0) && (
|
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex justify-between items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span>Progress</span>
|
|
{isActive && (
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-success-500"></span>
|
|
</span>
|
|
)}
|
|
{isComplete && (
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-success-500"></span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span>{isComplete ? '100' : progressPercent}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden shadow-inner">
|
|
<div
|
|
className={`bg-gradient-to-r ${stageConfig.color} h-2.5 rounded-full transition-all duration-500`}
|
|
style={{ width: `${isComplete ? 100 : progressPercent}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Approved summary card - Same layout as Stage 7 */}
|
|
<div className="rounded-2xl p-4 border-2 border-success-200 bg-gradient-to-br from-success-50 to-success-100 dark:from-success-900/10 dark:to-success-800/10 flex flex-col h-full">
|
|
{/* Header Row - Icon and Label on left, Big Count on right */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-gradient-to-br from-success-400 to-success-600 flex items-center justify-center shadow-md flex-shrink-0">
|
|
<CheckCircleIcon className="size-4 text-white" />
|
|
</div>
|
|
<span className="text-base font-bold text-success-900 dark:text-success-100">Approved</span>
|
|
</div>
|
|
{/* Big count on right */}
|
|
<div className="text-3xl font-bold text-success-600 dark:text-success-400">
|
|
{metrics?.content?.published ?? pipelineOverview[3]?.counts?.published ?? getStageResult(4)?.published ?? 0}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Label - Right aligned */}
|
|
<div className="text-sm font-bold text-success-600 dark:text-success-400 mb-4 text-right">Published Content</div>
|
|
</div>
|
|
|
|
</div>
|
|
</ComponentCard>
|
|
|
|
{/* Activity Log */}
|
|
{currentRun && <ActivityLog runId={currentRun.run_id} />}
|
|
|
|
{/* Run History */}
|
|
<RunHistory siteId={activeSite.id} />
|
|
|
|
{/* Config Modal */}
|
|
{showConfigModal && config && (
|
|
<ConfigModal config={config} onSave={handleSaveConfig} onCancel={() => setShowConfigModal(false)} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default AutomationPage; |