Files
igny8/frontend/src/pages/Automation/AutomationPage.tsx
IGNY8 VPS (Salman) 293182da31 UX Text Improvements: Automation Page - User-Friendly Language
IMPROVEMENTS - Automation Page:
- Page title: 'AI Automation Pipeline' → 'Automate Everything'
- Page description updated to be more conversational
- Status badge: 'Ready to Run' → 'Ready to Go!' with expanded explanation
- Schedule display: More conversational format (e.g., 'Runs every day at 2:00 AM | Last run: Never | Uses about 5 credits per run')
- Pipeline stage names completely rewritten with descriptions:
  - 'Keywords → Clusters' → 'ORGANIZE KEYWORDS' (Group related search terms into topic clusters)
  - 'Clusters → Ideas' → 'CREATE ARTICLE IDEAS' (Generate article titles and outlines for each cluster)
  - 'Ideas → Tasks' → 'PREPARE WRITING JOBS' (Convert ideas into tasks for the AI writer)
  - 'Tasks → Content' → 'WRITE ARTICLES' (AI generates full, complete articles)
  - 'Content → Image Prompts' → 'CREATE IMAGE DESCRIPTIONS' (Generate descriptions for AI to create images)
  - 'Image Prompts → Images' → 'GENERATE IMAGES' (AI creates custom images for your articles)
  - 'Manual Review Gate' → 'REVIEW & PUBLISH ⚠️' (Review articles before they go live)
- Button updates:
  - 'Configure' → '⚙️ Adjust Settings' (with tooltip)
  - 'Run Now' now has tooltip explaining it starts immediately
- Pipeline statistics section:
  - Added header: 'Here's what's in your automation pipeline:'
  - Metric labels updated with context:
    - 'Keywords' → 'Search Terms (waiting to organize)'
    - 'Clusters' → 'Topic Groups (ready for ideas)'
    - 'Ideas' → 'Article Ideas (waiting to write)'
    - 'Content' → 'Articles (in various stages)'
    - 'Images' → 'Images (created and waiting)'

NO CODE CHANGES: Only visible user-facing text updates
2025-12-25 06:09:03 +00:00

1006 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
fetchKeywords,
fetchClusters,
fetchContentIdeas,
fetchTasks,
fetchContent,
fetchContentImages,
} 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/CurrentProcessingCard';
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,
ListIcon,
GroupIcon,
FileTextIcon,
PencilIcon,
FileIcon,
CheckCircleIcon,
ClockIcon,
PaperPlaneIcon,
ArrowRightIcon
} from '../../icons';
const STAGE_CONFIG = [
{ icon: ListIcon, color: 'from-blue-500 to-blue-600', textColor: 'text-blue-600', hoverColor: 'hover:border-blue-500', name: 'ORGANIZE KEYWORDS', desc: 'Group related search terms into topic clusters' },
{ icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'CREATE ARTICLE IDEAS', desc: 'Generate article titles and outlines for each cluster' },
{ icon: CheckCircleIcon, color: 'from-indigo-500 to-indigo-600', textColor: 'text-indigo-600', hoverColor: 'hover:border-indigo-500', name: 'PREPARE WRITING JOBS', desc: 'Convert ideas into tasks for the AI writer' },
{ icon: PencilIcon, color: 'from-green-500 to-green-600', textColor: 'text-green-600', hoverColor: 'hover:border-green-500', name: 'WRITE ARTICLES', desc: 'AI generates full, complete articles' },
{ icon: FileIcon, color: 'from-amber-500 to-amber-600', textColor: 'text-amber-600', hoverColor: 'hover:border-amber-500', name: 'CREATE IMAGE DESCRIPTIONS', desc: 'Generate descriptions for AI to create images' },
{ icon: FileTextIcon, color: 'from-pink-500 to-pink-600', textColor: 'text-pink-600', hoverColor: 'hover:border-pink-500', name: 'GENERATE IMAGES', desc: 'AI creates custom images for your articles' },
{ icon: PaperPlaneIcon, color: 'from-teal-500 to-teal-600', textColor: 'text-teal-600', hoverColor: 'hover:border-teal-500', name: 'REVIEW & PUBLISH ⚠️', desc: 'Review articles before they go live (manual approval needed)' },
];
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);
useEffect(() => {
if (!activeSite) return;
loadData();
const interval = setInterval(() => {
if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) {
// When automation is running, refresh both run and metrics
loadCurrentRun();
loadPipelineOverview();
loadMetrics(); // Add metrics refresh during run
} else {
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),
]);
// 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: 'published' }),
fetchContentImages({ page_size: 1, site_id: siteId }),
fetchContentImages({ page_size: 1, site_id: siteId, 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) {
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);
// ensure processing card is visible when a run exists
if (data.run) setShowProcessingCard(true);
} 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 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: 'published' }),
fetchContentImages({ page_size: 1, site_id: siteId }),
fetchContentImages({ page_size: 1, site_id: siteId, 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(`Insufficient credits. Need ~${estimate.estimated_credits}, you have ${estimate.current_balance}`);
return;
}
try {
const result = await automationService.runNow(activeSite.id);
toast.success('Automation started successfully');
loadCurrentRun();
} catch (error: any) {
toast.error(error.response?.data?.error || 'Failed to start automation');
}
};
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 (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-lg text-gray-600 dark:text-gray-400">Loading automation...</div>
</div>
);
}
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-xs mt-1">
<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-xs mt-1 justify-center gap-6">
{visible.map((it, idx) => (
<div key={idx} className="w-1/3 text-center">
<span className={`${it.colorCls ?? ''} block`}>{it.label}</span>
<div className="font-bold text-slate-900 dark:text-white">{Number(it.value) || 0}</div>
</div>
))}
</div>
);
}
// default to 3 columns (equally spaced)
return (
<div className="flex text-xs mt-1">
{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`}>{it.label}</span>
<div className="font-bold text-slate-900 dark:text-white">{it.value !== undefined && it.value !== null ? Number(it.value) : ''}</div>
</div>
))}
</div>
);
};
return (
<>
<PageMeta title="Automate Everything | IGNY8" description="Set your content on automatic - Let our AI create and publish content on a schedule" />
<div className="space-y-6">
{/* Header */}
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600">
<BoltIcon className="text-white size-5" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automate Everything</h2>
{activeSite && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Site: <span className="font-medium text-brand-600 dark:text-brand-400">{activeSite.name}</span>
</p>
)}
</div>
</div>
</div>
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
<div className="hidden sm:flex absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-blue-500 to-blue-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-amber-500 to-amber-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-500'}`}>
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-4 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-4 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-4 text-white" /> : <BoltIcon className="size-4 text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate">
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
{currentRun?.status === 'paused' && 'Paused'}
{!currentRun && totalPending > 0 && 'Ready to Go!'}
{!currentRun && totalPending === 0 && 'No Items Pending'}
</div>
<div className="text-xs text-slate-600 dark:text-gray-400 truncate">
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items waiting - Everything is queued up and ready for the next run` : 'All stages clear')}
</div>
</div>
</div>
</div>
<DebugSiteSelector />
</div>
{/* Compact Schedule & Controls Panel */}
{config && (
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700">
<div className="flex flex-col lg:flex-row items-center lg:items-center justify-between gap-3 py-3 px-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">
Runs <span className="font-medium capitalize">{config.frequency === 'daily' ? 'every day' : 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 run: <span className="font-medium">{config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}</span>
</div>
<div className="h-4 w-px bg-white/25"></div>
<div className="text-sm text-white/90">
Uses about <span className="font-semibold text-white">{estimate?.estimated_credits || 0} credits</span> per run
{estimate && !estimate.sufficient && (
<span className="ml-1 text-white/90 font-semibold">(Low)</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setShowConfigModal(true)}
variant="outline"
tone="brand"
size="sm"
title="Change when this automation runs and how many credits it uses"
className="!border-white !text-white hover:!bg-white hover:!text-brand-700"
>
Adjust Settings
</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}
title="Start the automation immediately instead of waiting for the scheduled time"
className="!bg-white !text-brand-700 hover:!bg-success-600 hover:!text-white"
>
Run Now
</Button>
)}
</div>
</div>
</ComponentCard>
)}
{/* Pipeline Statistics Header */}
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Here's what's in your automation pipeline:</h3>
</div>
{/* Metrics Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{/* Keywords */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
<ListIcon className="size-5 text-white" />
</div>
<div className="text-sm font-bold text-blue-900 dark:text-blue-100">Search Terms</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-blue-900">{total}</div>
<div className="text-xs text-blue-700 dark:text-blue-300">waiting to organize</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-blue-700' },
{ label: 'Mapped:', value: mapped, colorCls: 'text-blue-700' },
])
);
})()}
</div>
{/* Clusters */}
<div className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-4 border-2 border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center">
<GroupIcon className="size-5 text-white" />
</div>
<div className="text-sm font-bold text-purple-900 dark:text-purple-100">Topic Groups</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-900">{total}</div>
<div className="text-xs text-purple-700 dark:text-purple-300">ready for ideas</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-700' },
{ label: 'Mapped:', value: mapped, colorCls: 'text-purple-700' },
])
);
})()}
</div>
{/* Ideas */}
<div className="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-4 border-2 border-indigo-200 dark:border-indigo-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center">
<CheckCircleIcon className="size-5 text-white" />
</div>
<div className="text-sm font-bold text-indigo-900 dark:text-indigo-100">Article 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-indigo-900">{total}</div>
<div className="text-xs text-indigo-700 dark:text-indigo-300">waiting to write</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-indigo-700' },
{ label: 'Queued:', value: queued, colorCls: 'text-indigo-700' },
{ label: 'Completed:', value: completed, colorCls: 'text-indigo-700' },
])
);
})()}
</div>
{/* Content */}
<div className="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-xl p-4 border-2 border-green-200 dark:border-green-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
<FileTextIcon className="size-5 text-white" />
</div>
<div className="text-sm font-bold text-green-900 dark:text-green-100">Articles</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-green-900">{total}</div>
<div className="text-xs text-green-700 dark:text-green-300">in various stages</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-green-700' },
{ label: 'Review:', value: review, colorCls: 'text-green-700' },
{ label: 'Publish:', value: publish, colorCls: 'text-green-700' },
])
);
})()}
</div>
{/* Images */}
<div className="bg-gradient-to-br from-pink-50 to-pink-100 dark:from-pink-900/20 dark:to-pink-800/20 rounded-xl p-4 border-2 border-pink-200 dark:border-pink-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center">
<FileIcon className="size-5 text-white" />
</div>
<div className="text-sm font-bold text-pink-900 dark:text-pink-100">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-pink-900">{total}</div>
<div className="text-xs text-pink-700 dark:text-pink-300">created and waiting</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-pink-700' }));
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-pink-700' }));
return renderMetricRow(items);
}
return renderMetricRow([
{ label: 'Pending:', value: pipelineOverview[5]?.pending ?? metrics?.images?.pending ?? 0, colorCls: 'text-pink-700' },
]);
})()}
</div>
</div>
{/* 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;
const processed = result ? Object.values(result).reduce((sum: number, val) => typeof val === 'number' ? sum + val : sum, 0) : 0;
const total = (stage.pending ?? 0) + processed;
const progressPercent = total > 0 ? Math.round((processed / total) * 100) : 0;
return (
<div
key={stage.number}
className={`
relative rounded-xl border-2 p-5 transition-all
${isActive
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 shadow-lg'
: isComplete
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
: stage.pending > 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'
}
`}
>
{/* Compact Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="text-sm font-bold text-gray-900 dark:text-white">Stage {stage.number}</div>
{isActive && <span className="text-xs px-2 py-0.5 bg-blue-500 text-white rounded-full"> Active</span>}
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full"></span>}
{!isActive && !isComplete && stage.pending > 0 && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full">Ready</span>}
</div>
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{stageConfig.name}</div>
</div>
<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>
</div>
{/* Queue Metrics */}
<div className="space-y-1.5 text-xs mb-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Total Queue:</span>
<span className="font-bold text-slate-900 dark:text-white">{stage.pending}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Processed:</span>
<span className="font-bold text-slate-900 dark:text-white">{processed}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Remaining:</span>
<span className={`font-bold ${stageConfig.textColor} dark:${stageConfig.textColor}`}>
{stage.pending}
</span>
</div>
{/* Credits and Time - Section 6 Enhancement */}
{result && result.credits_used !== undefined && (
<div className="flex justify-between pt-1.5 border-t border-slate-200 dark:border-gray-700">
<span className="text-gray-600 dark:text-gray-400">Credits Used:</span>
<span className="font-bold text-amber-600 dark:text-amber-400">{result.credits_used}</span>
</div>
)}
{result && result.time_elapsed && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">{result.time_elapsed}</span>
</div>
)}
</div>
{/* Progress Bar */}
{(isActive || isComplete || processed > 0) && (
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-gray-700">
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1.5">
<span>Progress</span>
<span>{isComplete ? '100' : progressPercent}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className={`bg-gradient-to-r ${stageConfig.color} h-2 rounded-full transition-all duration-500 ${isActive ? 'animate-pulse' : ''}`}
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;
const processed = result ? Object.values(result).reduce((sum: number, val) => typeof val === 'number' ? sum + val : sum, 0) : 0;
const total = (stage.pending ?? 0) + processed;
const progressPercent = total > 0 ? Math.round((processed / total) * 100) : 0;
return (
<div
key={stage.number}
className={`
relative rounded-xl border-2 p-5 transition-all
${isActive
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 shadow-lg'
: isComplete
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
: stage.pending > 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'
}
`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="text-sm font-bold text-gray-900 dark:text-white">Stage {stage.number}</div>
{isActive && <span className="text-xs px-2 py-0.5 bg-blue-500 text-white rounded-full"> Active</span>}
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full"></span>}
{!isActive && !isComplete && stage.pending > 0 && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full">Ready</span>}
</div>
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{stageConfig.name}</div>
</div>
<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>
</div>
<div className="space-y-1.5 text-xs mb-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Total Queue:</span>
<span className="font-bold text-slate-900 dark:text-white">{stage.pending}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Processed:</span>
<span className="font-bold text-slate-900 dark:text-white">{processed}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Remaining:</span>
<span className={`font-bold ${stageConfig.textColor}`}>
{stage.pending}
</span>
</div>
{/* Credits and Time - Section 6 Enhancement */}
{result && result.credits_used !== undefined && (
<div className="flex justify-between pt-1.5 border-t border-slate-200 dark:border-gray-700">
<span className="text-gray-600 dark:text-gray-400">Credits Used:</span>
<span className="font-bold text-amber-600 dark:text-amber-400">{result.credits_used}</span>
</div>
)}
{result && result.time_elapsed && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">{result.time_elapsed}</span>
</div>
)}
</div>
{(isActive || isComplete || processed > 0) && (
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-gray-700">
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1.5">
<span>Progress</span>
<span>{isComplete ? '100' : progressPercent}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className={`bg-gradient-to-r ${stageConfig.color} h-2 rounded-full transition-all duration-500 ${isActive ? 'animate-pulse' : ''}`}
style={{ width: `${isComplete ? 100 : progressPercent}%` }}
></div>
</div>
</div>
)}
</div>
);
})}
{/* Stage 7 - Manual Review Gate */}
{pipelineOverview[6] && (() => {
const stage7 = pipelineOverview[6];
const isActive = currentRun?.current_stage === 7;
const isComplete = currentRun && currentRun.current_stage > 7;
return (
<div
className={`
relative rounded-xl border-3 p-5 transition-all
${isActive
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 shadow-lg'
: isComplete
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
: stage7.pending > 0
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
: 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800'
}
`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="text-sm font-bold text-gray-900 dark:text-white">Stage 7</div>
<span className="text-xs px-2 py-0.5 bg-amber-500 text-white rounded-full">🚫 Stop</span>
</div>
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">Manual Review Gate</div>
</div>
<div className="size-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-md">
<PaperPlaneIcon className="size-4 text-white" />
</div>
</div>
{stage7.pending > 0 && (
<div className="text-center py-4">
<div className="text-3xl font-bold text-amber-600 dark:text-amber-400">{stage7.pending}</div>
<div className="text-xs text-amber-700 dark:text-amber-300 mt-1">ready for review</div>
</div>
)}
<div className="mt-3 pt-3 border-t border-amber-200 dark:border-amber-700">
<Button
variant="primary"
tone="brand"
size="sm"
className="w-full text-xs"
disabled={stage7.pending === 0}
>
Go to Review
</Button>
<div className="mt-2">
<Button
variant="outline"
tone="success"
size="sm"
className="w-full text-xs"
disabled={stage7.pending === 0}
onClick={handlePublishAllWithoutReview}
>
Publish all Without Review
</Button>
</div>
</div>
</div>
);
})()}
{/* Published summary card (placed after Stage 7 in the same row) */}
<div className="rounded-xl p-5 border-2 border-green-200 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/10 dark:to-green-800/10 flex flex-col h-full">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
<FileTextIcon className="size-5 text-white" />
</div>
<div className="text-sm font-bold text-green-900 dark:text-green-100">Published</div>
</div>
<div className="text-right">&nbsp;</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="text-4xl md:text-5xl font-extrabold text-green-800 dark:text-green-300">{metrics?.content?.published ?? pipelineOverview[3]?.counts?.published ?? getStageResult(4)?.published ?? 0}</div>
</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;