ui
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
interface ComponentCardProps {
|
||||
title: string | React.ReactNode;
|
||||
title?: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string; // Additional custom classes for styling
|
||||
desc?: string | React.ReactNode; // Description text
|
||||
@@ -15,17 +15,19 @@ const ComponentCard: React.FC<ComponentCardProps> = ({
|
||||
<div
|
||||
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] overflow-visible ${className}`}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div className="px-6 py-5 relative z-0">
|
||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
||||
{title}
|
||||
</h3>
|
||||
{desc && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{desc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Card Header (render only when title or desc provided) */}
|
||||
{(title || desc) && (
|
||||
<div className="px-6 py-5 relative z-0">
|
||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
||||
{title}
|
||||
</h3>
|
||||
{desc && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{desc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6 overflow-visible">
|
||||
|
||||
@@ -6,6 +6,14 @@ 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';
|
||||
@@ -43,6 +51,7 @@ const AutomationPage: React.FC = () => {
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null);
|
||||
@@ -72,6 +81,64 @@ const AutomationPage: React.FC = () => {
|
||||
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);
|
||||
@@ -153,6 +220,21 @@ const AutomationPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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]">
|
||||
@@ -171,13 +253,55 @@ const AutomationPage: React.FC = () => {
|
||||
|
||||
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="AI Automation Pipeline | IGNY8" description="Automated content creation from keywords to published articles" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<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">
|
||||
@@ -193,62 +317,108 @@ const AutomationPage: React.FC = () => {
|
||||
</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 Run'}
|
||||
{!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 in pipeline` : 'All stages clear')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebugSiteSelector />
|
||||
</div>
|
||||
|
||||
{/* Compact Schedule & Controls Panel */}
|
||||
{config && (
|
||||
<ComponentCard className="border-2 border-slate-200 dark:border-gray-800">
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-3 py-1">
|
||||
<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="size-2 bg-success-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-semibold text-success-600 dark:text-success-400">Enabled</span>
|
||||
</>
|
||||
<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="size-2 bg-gray-400 rounded-full"></div>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">Disabled</span>
|
||||
</>
|
||||
<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-slate-300 dark:bg-gray-700"></div>
|
||||
<div className="text-sm text-slate-700 dark:text-gray-300">
|
||||
<span className="font-medium capitalize">{config.frequency}</span> at {config.scheduled_time}
|
||||
<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-slate-300 dark:bg-gray-700"></div>
|
||||
<div className="text-sm text-slate-600 dark:text-gray-400">
|
||||
Last: {config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}
|
||||
<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>
|
||||
<div className="h-4 w-px bg-slate-300 dark:bg-gray-700"></div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Est:</span>{' '}
|
||||
<span className="font-semibold text-brand-600 dark:text-brand-400">
|
||||
{estimate?.estimated_credits || 0} credits
|
||||
</span>
|
||||
<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} credits</span>
|
||||
{estimate && !estimate.sufficient && (
|
||||
<span className="ml-1 text-error-600 dark:text-error-400 font-semibold">(Low)</span>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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}>
|
||||
<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>
|
||||
)}
|
||||
@@ -259,155 +429,178 @@ const AutomationPage: React.FC = () => {
|
||||
|
||||
{/* 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 gap-3 mb-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">Keywords</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-blue-700 dark:text-blue-300">Total:</span>
|
||||
<span className="font-bold text-blue-900 dark:text-blue-100">{pipelineOverview[0]?.pending || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 gap-3 mb-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">Clusters</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-purple-700 dark:text-purple-300">Pending:</span>
|
||||
<span className="font-bold text-purple-900 dark:text-purple-100">{pipelineOverview[1]?.pending || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 gap-3 mb-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">Ideas</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-indigo-700 dark:text-indigo-300">Pending:</span>
|
||||
<span className="font-bold text-indigo-900 dark:text-indigo-100">{pipelineOverview[2]?.pending || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 gap-3 mb-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">Content</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-green-700 dark:text-green-300">Tasks:</span>
|
||||
<span className="font-bold text-green-900 dark:text-green-100">{pipelineOverview[3]?.pending || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 gap-3 mb-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>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-pink-700 dark:text-pink-300">Pending:</span>
|
||||
<span className="font-bold text-pink-900 dark:text-pink-100">{pipelineOverview[5]?.pending || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Status Card - Centered */}
|
||||
<div className="flex justify-center">
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className={`
|
||||
rounded-2xl border-3 p-6 shadow-xl transition-all
|
||||
${currentRun?.status === 'running'
|
||||
? 'border-blue-500 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30'
|
||||
: currentRun?.status === 'paused'
|
||||
? 'border-amber-500 bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/30 dark:to-amber-800/30'
|
||||
: totalPending > 0
|
||||
? 'border-success-500 bg-gradient-to-br from-success-50 to-success-100 dark:from-success-900/30 dark:to-success-800/30'
|
||||
: 'border-slate-300 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-gray-800/30 dark:to-gray-700/30'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`
|
||||
size-16 rounded-2xl flex items-center justify-center shadow-lg
|
||||
${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?.status === 'running' && <div className="size-3 bg-white rounded-full animate-pulse"></div>}
|
||||
{currentRun?.status === 'paused' && <ClockIcon className="size-8 text-white" />}
|
||||
{!currentRun && totalPending > 0 && <CheckCircleIcon className="size-8 text-white" />}
|
||||
{!currentRun && totalPending === 0 && <BoltIcon className="size-8 text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900 dark:text-white mb-1">
|
||||
{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'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 dark:text-gray-300">
|
||||
{currentRun && `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}`}
|
||||
{!currentRun && totalPending > 0 && `${totalPending} items in pipeline`}
|
||||
{!currentRun && totalPending === 0 && 'All stages clear'}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
{currentRun && (
|
||||
<div className="text-sm font-bold text-blue-900 dark:text-blue-100">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-sm text-slate-600 dark:text-gray-400">Credits Used</div>
|
||||
<div className="text-3xl font-bold text-brand-600 dark:text-brand-400">{currentRun.total_credits_used}</div>
|
||||
<div className="text-3xl font-bold text-blue-900">{total}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overall Progress Bar */}
|
||||
{currentRun && currentRun.status === 'running' && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-xs text-slate-600 dark:text-gray-400 mb-2">
|
||||
<span>Overall Progress</span>
|
||||
<span>{Math.round((currentRun.current_stage / 7) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-3 rounded-full transition-all duration-500 animate-pulse"
|
||||
style={{ width: `${(currentRun.current_stage / 7) * 100}%` }}
|
||||
></div>
|
||||
</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">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-900">{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-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">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>
|
||||
);
|
||||
})()}
|
||||
</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">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-green-900">{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-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>
|
||||
);
|
||||
})()}
|
||||
</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>
|
||||
|
||||
|
||||
{/* Pipeline Stages */}
|
||||
<ComponentCard>
|
||||
{/* Row 1: Stages 1-4 */}
|
||||
@@ -644,11 +837,39 @@ const AutomationPage: React.FC = () => {
|
||||
>
|
||||
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"> </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>
|
||||
|
||||
{/* Status Summary Card */}
|
||||
{currentRun && (
|
||||
<div className="relative rounded-xl border-2 border-slate-300 dark:border-gray-700 p-5 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-gray-800/50 dark:to-gray-700/50">
|
||||
|
||||
@@ -157,4 +157,14 @@ export const automationService = {
|
||||
getPipelineOverview: async (siteId: number): Promise<{ stages: PipelineStage[] }> => {
|
||||
return fetchAPI(buildUrl('/pipeline_overview/', { site_id: siteId }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Publish all content without review (bulk action)
|
||||
* Note: backend must implement this endpoint for it to succeed.
|
||||
*/
|
||||
publishWithoutReview: async (siteId: number): Promise<void> => {
|
||||
await fetchAPI(buildUrl('/publish_without_review/', { site_id: siteId }), {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user