From 32dae2a7d54c242618254bac4f3ac4b84dee6242 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Thu, 4 Dec 2025 10:45:43 +0000 Subject: [PATCH] ui --- .../igny8_core/business/automation/views.py | 83 ++- .../src/components/common/ComponentCard.tsx | 26 +- .../src/pages/Automation/AutomationPage.tsx | 559 ++++++++++++------ frontend/src/services/automationService.ts | 10 + 4 files changed, 480 insertions(+), 198 deletions(-) diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 56473dac..6a4aa6d8 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -325,16 +325,45 @@ class AutomationViewSet(viewsets.ViewSet): from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas from igny8_core.business.content.models import Tasks, Content, Images from django.db.models import Count - - # Stage 1: Keywords pending clustering + + def _counts_by_status(model, extra_filter=None, exclude_filter=None): + """Return a dict of counts keyed by status and the total for a given model and site.""" + qs = model.objects.filter(site=site) + if extra_filter: + qs = qs.filter(**extra_filter) + if exclude_filter: + qs = qs.exclude(**exclude_filter) + + # Group by status when available + try: + rows = qs.values('status').annotate(count=Count('id')) + counts = {r['status']: r['count'] for r in rows} + total = sum(counts.values()) + except Exception: + # Fallback: count all + total = qs.count() + counts = {'total': total} + + return counts, total + + # Stage 1: Keywords pending clustering (keep previous "pending" semantics but also return status breakdown) + stage_1_counts, stage_1_total = _counts_by_status( + Keywords, + extra_filter={'disabled': False} + ) + # pending definition used by the UI previously (new & not clustered) stage_1_pending = Keywords.objects.filter( site=site, status='new', cluster__isnull=True, disabled=False ).count() - + # Stage 2: Clusters needing ideas + stage_2_counts, stage_2_total = _counts_by_status( + Clusters, + extra_filter={'disabled': False} + ) stage_2_pending = Clusters.objects.filter( site=site, status='new', @@ -342,21 +371,24 @@ class AutomationViewSet(viewsets.ViewSet): ).exclude( ideas__isnull=False ).count() - + # Stage 3: Ideas ready to queue + stage_3_counts, stage_3_total = _counts_by_status(ContentIdeas) stage_3_pending = ContentIdeas.objects.filter( site=site, status='new' ).count() - + # Stage 4: Tasks ready for content generation - # Tasks don't have content FK - check if content exists via task title matching + stage_4_counts, stage_4_total = _counts_by_status(Tasks) stage_4_pending = Tasks.objects.filter( site=site, status='queued' ).count() - + # Stage 5: Content ready for image prompts + # We will provide counts per content status and also compute pending as previous (draft with 0 images) + stage_5_counts, stage_5_total = _counts_by_status(Content) stage_5_pending = Content.objects.filter( site=site, status='draft' @@ -365,62 +397,79 @@ class AutomationViewSet(viewsets.ViewSet): ).filter( images_count=0 ).count() - + # Stage 6: Image prompts ready for generation + stage_6_counts, stage_6_total = _counts_by_status(Images) stage_6_pending = Images.objects.filter( site=site, status='pending' ).count() - + # Stage 7: Content ready for review + # Provide counts per status for content and keep previous "review" pending count + stage_7_counts, stage_7_total = _counts_by_status(Content) stage_7_ready = Content.objects.filter( site=site, status='review' ).count() - + return Response({ 'stages': [ { 'number': 1, 'name': 'Keywords → Clusters', 'pending': stage_1_pending, - 'type': 'AI' + 'type': 'AI', + 'counts': stage_1_counts, + 'total': stage_1_total }, { 'number': 2, 'name': 'Clusters → Ideas', 'pending': stage_2_pending, - 'type': 'AI' + 'type': 'AI', + 'counts': stage_2_counts, + 'total': stage_2_total }, { 'number': 3, 'name': 'Ideas → Tasks', 'pending': stage_3_pending, - 'type': 'Local' + 'type': 'Local', + 'counts': stage_3_counts, + 'total': stage_3_total }, { 'number': 4, 'name': 'Tasks → Content', 'pending': stage_4_pending, - 'type': 'AI' + 'type': 'AI', + 'counts': stage_4_counts, + 'total': stage_4_total }, { 'number': 5, 'name': 'Content → Image Prompts', 'pending': stage_5_pending, - 'type': 'AI' + 'type': 'AI', + 'counts': stage_5_counts, + 'total': stage_5_total }, { 'number': 6, 'name': 'Image Prompts → Images', 'pending': stage_6_pending, - 'type': 'AI' + 'type': 'AI', + 'counts': stage_6_counts, + 'total': stage_6_total }, { 'number': 7, 'name': 'Manual Review Gate', 'pending': stage_7_ready, - 'type': 'Manual' + 'type': 'Manual', + 'counts': stage_7_counts, + 'total': stage_7_total } ] }) diff --git a/frontend/src/components/common/ComponentCard.tsx b/frontend/src/components/common/ComponentCard.tsx index 2bf76411..7ed069d6 100644 --- a/frontend/src/components/common/ComponentCard.tsx +++ b/frontend/src/components/common/ComponentCard.tsx @@ -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 = ({
- {/* Card Header */} -
-

- {title} -

- {desc && ( -

- {desc} -

- )} -
+ {/* Card Header (render only when title or desc provided) */} + {(title || desc) && ( +
+

+ {title} +

+ {desc && ( +

+ {desc} +

+ )} +
+ )} {/* Card Body */}
diff --git a/frontend/src/pages/Automation/AutomationPage.tsx b/frontend/src/pages/Automation/AutomationPage.tsx index 23451c07..db4e2678 100644 --- a/frontend/src/pages/Automation/AutomationPage.tsx +++ b/frontend/src/pages/Automation/AutomationPage.tsx @@ -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(null); const [currentRun, setCurrentRun] = useState(null); const [pipelineOverview, setPipelineOverview] = useState([]); + const [metrics, setMetrics] = useState(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 (
@@ -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 ( +
+
+
+
+
+ ); + } + + if (visible.length === 2) { + return ( +
+ {visible.map((it, idx) => ( +
+ {it.label} +
{Number(it.value) || 0}
+
+ ))} +
+ ); + } + + // default to 3 columns (equally spaced) + return ( +
+ {items.concat(Array(Math.max(0, 3 - items.length)).fill({ label: '', value: '' })).slice(0,3).map((it, idx) => ( +
+ {it.label} +
{it.value !== undefined && it.value !== null ? Number(it.value) : ''}
+
+ ))} +
+ ); + }; return ( <>
{/* Header */} -
+
@@ -193,62 +317,108 @@ const AutomationPage: React.FC = () => {
+ + {/* Compact Ready-to-Run card (header) - absolutely centered in header */} +
+
0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}> +
0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-500'}`}> + {!currentRun && totalPending > 0 ? : currentRun?.status === 'running' ? : currentRun?.status === 'paused' ? : } +
+
+
+ {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'} +
+
+ {currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')} +
+
+
+
+
{/* Compact Schedule & Controls Panel */} {config && ( - -
+ +
{config.is_enabled ? ( - <> -
- Enabled - +
+
+ Enabled +
) : ( - <> -
- Disabled - +
+
+ Disabled +
)}
-
-
- {config.frequency} at {config.scheduled_time} +
+
+ {config.frequency} at {config.scheduled_time}
-
-
- Last: {config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'} +
+
+ Last: {config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}
-
-
- Est:{' '} - - {estimate?.estimated_credits || 0} credits - +
+
+ Est:{' '} + {estimate?.estimated_credits || 0} credits {estimate && !estimate.sufficient && ( - (Low) + (Low) )}
- {currentRun?.status === 'running' && ( - )} {currentRun?.status === 'paused' && ( - )} {!currentRun && ( - )} @@ -259,155 +429,178 @@ const AutomationPage: React.FC = () => { {/* Metrics Summary Cards */}
+ {/* Keywords */}
-
-
- -
-
Keywords
-
-
-
- Total: - {pipelineOverview[0]?.pending || 0} -
-
-
- -
-
-
- -
-
Clusters
-
-
-
- Pending: - {pipelineOverview[1]?.pending || 0} -
-
-
- -
-
-
- -
-
Ideas
-
-
-
- Pending: - {pipelineOverview[2]?.pending || 0} -
-
-
- -
-
-
- -
-
Content
-
-
-
- Tasks: - {pipelineOverview[3]?.pending || 0} -
-
-
- -
-
-
- -
-
Images
-
-
-
- Pending: - {pipelineOverview[5]?.pending || 0} -
-
-
-
- - {/* Pipeline Status Card - Centered */} -
-
-
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' - } - `}> -
-
-
0 - ? 'bg-gradient-to-br from-success-500 to-success-600' - : 'bg-gradient-to-br from-slate-400 to-slate-500' - } - `}> - {currentRun?.status === 'running' &&
} - {currentRun?.status === 'paused' && } - {!currentRun && totalPending > 0 && } - {!currentRun && totalPending === 0 && } -
-
-
- {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'} -
-
- {currentRun && `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}`} - {!currentRun && totalPending > 0 && `${totalPending} items in pipeline`} - {!currentRun && totalPending === 0 && 'All stages clear'} -
-
+
+
+
+
- {currentRun && ( +
Keywords
+
+ {(() => { + const res = getStageResult(1); + const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0; + return (
-
Credits Used
-
{currentRun.total_credits_used}
+
{total}
- )} -
- - {/* Overall Progress Bar */} - {currentRun && currentRun.status === 'running' && ( -
-
- Overall Progress - {Math.round((currentRun.current_stage / 7) * 100)}% -
-
-
-
-
- )} + ); + })()}
+ {(() => { + 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' }, + ]) + ); + })()} +
+ + {/* Clusters */} +
+
+
+
+ +
+
Clusters
+
+ {(() => { + const res = getStageResult(2); + const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0; + return ( +
+
{total}
+
+ ); + })()} +
+ {(() => { + const res = getStageResult(2); + const newCount = res?.new ?? res?.new_items ?? pipelineOverview[1]?.counts?.new ?? metrics?.clusters?.new ?? 0; + const mapped = res?.mapped ?? pipelineOverview[1]?.counts?.mapped ?? metrics?.clusters?.mapped ?? 0; + return ( + renderMetricRow([ + { label: 'New:', value: newCount, colorCls: 'text-purple-700' }, + { label: 'Mapped:', value: mapped, colorCls: 'text-purple-700' }, + ]) + ); + })()} +
+ + {/* Ideas */} +
+
+
+
+ +
+
Ideas
+
+ {(() => { + const res = getStageResult(3); + const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0; + return ( +
+
{total}
+
+ ); + })()} +
+ {(() => { + const res = getStageResult(3); + const newCount = res?.new ?? res?.new_items ?? pipelineOverview[2]?.counts?.new ?? metrics?.ideas?.new ?? 0; + const queued = res?.queued ?? pipelineOverview[2]?.counts?.queued ?? metrics?.ideas?.queued ?? 0; + const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0; + return ( + renderMetricRow([ + { label: 'New:', value: newCount, colorCls: 'text-indigo-700' }, + { label: 'Queued:', value: queued, colorCls: 'text-indigo-700' }, + { label: 'Completed:', value: completed, colorCls: 'text-indigo-700' }, + ]) + ); + })()} +
+ + {/* Content */} +
+
+
+
+ +
+
Content
+
+ {(() => { + const res = getStageResult(4); + const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0; + return ( +
+
{total}
+
+ ); + })()} +
+ {(() => { + const res = getStageResult(4); + const draft = res?.draft ?? res?.drafts ?? pipelineOverview[3]?.counts?.draft ?? metrics?.content?.draft ?? 0; + const review = res?.review ?? res?.in_review ?? pipelineOverview[3]?.counts?.review ?? metrics?.content?.review ?? 0; + const publish = res?.published ?? res?.publish ?? pipelineOverview[3]?.counts?.published ?? metrics?.content?.published ?? 0; + return ( + renderMetricRow([ + { label: 'Draft:', value: draft, colorCls: 'text-green-700' }, + { label: 'Review:', value: review, colorCls: 'text-green-700' }, + { label: 'Publish:', value: publish, colorCls: 'text-green-700' }, + ]) + ); + })()} +
+ + {/* Images */} +
+
+
+
+ +
+
Images
+
+ {(() => { + const res = getStageResult(6); + const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0; + return ( +
+
{total}
+
+ ); + })()} +
+ {(() => { + const res = getStageResult(6); // stage 6 is Image Prompts -> Images + if (res && typeof res === 'object') { + const entries = Object.entries(res); + const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-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' }, + ]); + })()}
+ {/* Pipeline Stages */} {/* Row 1: Stages 1-4 */} @@ -644,11 +837,39 @@ const AutomationPage: React.FC = () => { > Go to Review → +
+ +
); })()} + {/* Published summary card (placed after Stage 7 in the same row) */} +
+
+
+
+ +
+
Published
+
+
 
+
+
+
{metrics?.content?.published ?? pipelineOverview[3]?.counts?.published ?? getStageResult(4)?.published ?? 0}
+
+
+ {/* Status Summary Card */} {currentRun && (
diff --git a/frontend/src/services/automationService.ts b/frontend/src/services/automationService.ts index af615ab2..344e55a9 100644 --- a/frontend/src/services/automationService.ts +++ b/frontend/src/services/automationService.ts @@ -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 => { + await fetchAPI(buildUrl('/publish_without_review/', { site_id: siteId }), { + method: 'POST', + }); + }, };