diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 5b3d549a..1165fddf 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -312,20 +312,43 @@ class AutomationViewSet(viewsets.ViewSet): def _calculate_historical_averages(self, site, completed_runs): """Calculate historical averages from completed runs""" - if completed_runs.count() < 3: - # Not enough data, return defaults + run_count = completed_runs.count() + default_stage_averages = { + 1: {'avg_credits': 0.2, 'avg_items_created': 0, 'avg_output_ratio': 0.125}, + 2: {'avg_credits': 2.0, 'avg_items_created': 0, 'avg_output_ratio': 8.7}, + 3: {'avg_credits': 0, 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + 4: {'avg_credits': 5.0, 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + 5: {'avg_credits': 2.0, 'avg_items_created': 0, 'avg_output_ratio': 4.0}, + 6: {'avg_credits': 2.0, 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + 7: {'avg_credits': 0, 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + } + + if run_count < 3: return { 'period_days': 30, - 'runs_analyzed': completed_runs.count(), - 'avg_credits_stage_1': 0.2, - 'avg_credits_stage_2': 2.0, - 'avg_credits_stage_4': 5.0, - 'avg_credits_stage_5': 2.0, - 'avg_credits_stage_6': 2.0, - 'avg_output_ratio_stage_1': 0.125, - 'avg_output_ratio_stage_2': 8.7, - 'avg_output_ratio_stage_5': 4.0, - 'avg_output_ratio_stage_6': 1.0, + 'runs_analyzed': run_count, + 'avg_total_credits': 0, + 'avg_duration_seconds': 0, + 'avg_credits_per_item': 0, + 'total_runs_analyzed': run_count, + 'has_sufficient_data': False, + 'stages': [ + { + 'stage_number': stage_number, + 'stage_name': f"Stage {stage_number}", + **averages, + } + for stage_number, averages in default_stage_averages.items() + ], + 'avg_credits_stage_1': default_stage_averages[1]['avg_credits'], + 'avg_credits_stage_2': default_stage_averages[2]['avg_credits'], + 'avg_credits_stage_4': default_stage_averages[4]['avg_credits'], + 'avg_credits_stage_5': default_stage_averages[5]['avg_credits'], + 'avg_credits_stage_6': default_stage_averages[6]['avg_credits'], + 'avg_output_ratio_stage_1': default_stage_averages[1]['avg_output_ratio'], + 'avg_output_ratio_stage_2': default_stage_averages[2]['avg_output_ratio'], + 'avg_output_ratio_stage_5': default_stage_averages[5]['avg_output_ratio'], + 'avg_output_ratio_stage_6': default_stage_averages[6]['avg_output_ratio'], } # Calculate per-stage averages @@ -340,6 +363,9 @@ class AutomationViewSet(viewsets.ViewSet): output_ratios_5 = [] output_ratios_6 = [] + total_created_items = 0 + total_credits_used = 0 + for run in completed_runs[:10]: # Last 10 runs if run.stage_1_result: processed = run.stage_1_result.get('keywords_processed', 0) @@ -349,6 +375,8 @@ class AutomationViewSet(viewsets.ViewSet): stage_1_credits.append(credits / processed) if created > 0 and processed > 0: output_ratios_1.append(created / processed) + total_created_items += created + total_credits_used += credits if run.stage_2_result: processed = run.stage_2_result.get('clusters_processed', 0) @@ -358,12 +386,16 @@ class AutomationViewSet(viewsets.ViewSet): stage_2_credits.append(credits / processed) if created > 0 and processed > 0: output_ratios_2.append(created / processed) + total_created_items += created + total_credits_used += credits if run.stage_4_result: processed = run.stage_4_result.get('tasks_processed', 0) credits = run.stage_4_result.get('credits_used', 0) if processed > 0: stage_4_credits.append(credits / processed) + total_created_items += run.stage_4_result.get('content_created', 0) + total_credits_used += credits if run.stage_5_result: processed = run.stage_5_result.get('content_processed', 0) @@ -373,6 +405,8 @@ class AutomationViewSet(viewsets.ViewSet): stage_5_credits.append(credits / processed) if created > 0 and processed > 0: output_ratios_5.append(created / processed) + total_created_items += created + total_credits_used += credits if run.stage_6_result: processed = run.stage_6_result.get('images_processed', 0) @@ -382,22 +416,57 @@ class AutomationViewSet(viewsets.ViewSet): stage_6_credits.append(credits / processed) if created > 0 and processed > 0: output_ratios_6.append(created / processed) + total_created_items += created + total_credits_used += credits def avg(lst): return sum(lst) / len(lst) if lst else 0 + avg_total_credits = completed_runs.aggregate(avg=Avg('total_credits_used'))['avg'] or 0 + avg_duration = completed_runs.annotate( + duration=F('completed_at') - F('started_at') + ).aggregate(avg=Avg('duration'))['avg'] + avg_duration_seconds = int(avg_duration.total_seconds()) if avg_duration else 0 + + derived_stage_averages = { + 1: {'avg_credits': round(avg(stage_1_credits), 2), 'avg_items_created': 0, 'avg_output_ratio': round(avg(output_ratios_1), 3)}, + 2: {'avg_credits': round(avg(stage_2_credits), 2), 'avg_items_created': 0, 'avg_output_ratio': round(avg(output_ratios_2), 1)}, + 3: {'avg_credits': 0, 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + 4: {'avg_credits': round(avg(stage_4_credits), 2), 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + 5: {'avg_credits': round(avg(stage_5_credits), 2), 'avg_items_created': 0, 'avg_output_ratio': round(avg(output_ratios_5), 1)}, + 6: {'avg_credits': round(avg(stage_6_credits), 2), 'avg_items_created': 0, 'avg_output_ratio': round(avg(output_ratios_6), 1)}, + 7: {'avg_credits': 0, 'avg_items_created': 0, 'avg_output_ratio': 1.0}, + } + + avg_credits_per_item = 0 + if total_created_items > 0: + avg_credits_per_item = total_credits_used / total_created_items + return { 'period_days': 30, - 'runs_analyzed': min(completed_runs.count(), 10), - 'avg_credits_stage_1': round(avg(stage_1_credits), 2), - 'avg_credits_stage_2': round(avg(stage_2_credits), 2), - 'avg_credits_stage_4': round(avg(stage_4_credits), 2), - 'avg_credits_stage_5': round(avg(stage_5_credits), 2), - 'avg_credits_stage_6': round(avg(stage_6_credits), 2), - 'avg_output_ratio_stage_1': round(avg(output_ratios_1), 3), - 'avg_output_ratio_stage_2': round(avg(output_ratios_2), 1), - 'avg_output_ratio_stage_5': round(avg(output_ratios_5), 1), - 'avg_output_ratio_stage_6': round(avg(output_ratios_6), 1), + 'runs_analyzed': min(run_count, 10), + 'avg_total_credits': round(avg_total_credits, 1), + 'avg_duration_seconds': avg_duration_seconds, + 'avg_credits_per_item': round(avg_credits_per_item, 2), + 'total_runs_analyzed': min(run_count, 10), + 'has_sufficient_data': run_count >= 3, + 'stages': [ + { + 'stage_number': stage_number, + 'stage_name': f"Stage {stage_number}", + **averages, + } + for stage_number, averages in derived_stage_averages.items() + ], + 'avg_credits_stage_1': derived_stage_averages[1]['avg_credits'], + 'avg_credits_stage_2': derived_stage_averages[2]['avg_credits'], + 'avg_credits_stage_4': derived_stage_averages[4]['avg_credits'], + 'avg_credits_stage_5': derived_stage_averages[5]['avg_credits'], + 'avg_credits_stage_6': derived_stage_averages[6]['avg_credits'], + 'avg_output_ratio_stage_1': derived_stage_averages[1]['avg_output_ratio'], + 'avg_output_ratio_stage_2': derived_stage_averages[2]['avg_output_ratio'], + 'avg_output_ratio_stage_5': derived_stage_averages[5]['avg_output_ratio'], + 'avg_output_ratio_stage_6': derived_stage_averages[6]['avg_output_ratio'], } def _calculate_predictive_analysis(self, site, historical_averages): @@ -430,86 +499,62 @@ class AutomationViewSet(viewsets.ViewSet): return { 'stages': [ { - 'stage': 1, - 'name': 'Keywords → Clusters', + 'stage_number': 1, + 'stage_name': 'Keywords → Clusters', 'pending_items': pending_keywords, - 'avg_credits_per_item': historical_averages['avg_credits_stage_1'], 'estimated_credits': stage_1_credits, - 'avg_output_ratio': historical_averages['avg_output_ratio_stage_1'], 'estimated_output': expected_clusters, - 'output_type': 'clusters' }, { - 'stage': 2, - 'name': 'Clusters → Ideas', + 'stage_number': 2, + 'stage_name': 'Clusters → Ideas', 'pending_items': pending_clusters, - 'avg_credits_per_item': historical_averages['avg_credits_stage_2'], 'estimated_credits': stage_2_credits, - 'avg_output_ratio': historical_averages['avg_output_ratio_stage_2'], 'estimated_output': expected_ideas, - 'output_type': 'ideas' }, { - 'stage': 3, - 'name': 'Ideas → Tasks', + 'stage_number': 3, + 'stage_name': 'Ideas → Tasks', 'pending_items': pending_ideas, - 'avg_credits_per_item': 0, 'estimated_credits': 0, - 'avg_output_ratio': 1.0, 'estimated_output': pending_ideas, - 'output_type': 'tasks' }, { - 'stage': 4, - 'name': 'Tasks → Content', + 'stage_number': 4, + 'stage_name': 'Tasks → Content', 'pending_items': pending_tasks, - 'avg_credits_per_item': historical_averages['avg_credits_stage_4'], 'estimated_credits': stage_4_credits, - 'avg_output_ratio': 1.0, 'estimated_output': pending_tasks, - 'output_type': 'content' }, { - 'stage': 5, - 'name': 'Content → Image Prompts', + 'stage_number': 5, + 'stage_name': 'Content → Image Prompts', 'pending_items': pending_content, - 'avg_credits_per_item': historical_averages['avg_credits_stage_5'], 'estimated_credits': stage_5_credits, - 'avg_output_ratio': historical_averages['avg_output_ratio_stage_5'], 'estimated_output': expected_prompts, - 'output_type': 'prompts' }, { - 'stage': 6, - 'name': 'Image Prompts → Images', + 'stage_number': 6, + 'stage_name': 'Image Prompts → Images', 'pending_items': pending_images, - 'avg_credits_per_item': historical_averages['avg_credits_stage_6'], 'estimated_credits': stage_6_credits, - 'avg_output_ratio': historical_averages['avg_output_ratio_stage_6'], 'estimated_output': expected_images, - 'output_type': 'images' }, { - 'stage': 7, - 'name': 'Review → Approved', + 'stage_number': 7, + 'stage_name': 'Review → Approved', 'pending_items': pending_review, - 'avg_credits_per_item': 0, 'estimated_credits': 0, - 'avg_output_ratio': 1.0, 'estimated_output': pending_review, - 'output_type': 'approved' }, ], - 'total_estimated_credits': total_estimated, - 'recommended_buffer': recommended_buffer, - 'current_balance': site.account.credits, - 'is_sufficient': site.account.credits >= recommended_buffer, - 'expected_outputs': { - 'clusters': expected_clusters, - 'ideas': expected_ideas, - 'content': pending_tasks, - 'images': expected_images, - } + 'totals': { + 'total_pending_items': pending_keywords + pending_clusters + pending_ideas + pending_tasks + pending_content + pending_images + pending_review, + 'total_estimated_credits': total_estimated, + 'total_estimated_output': expected_clusters + expected_ideas + pending_tasks + expected_images + pending_review, + 'recommended_buffer_credits': recommended_buffer, + }, + 'confidence': 'high' if historical_averages.get('has_sufficient_data') else 'low', } def _get_attention_items(self, site): @@ -551,7 +596,7 @@ class AutomationViewSet(viewsets.ViewSet): failed_runs = recent_runs.filter(status='failed') # Calculate averages from completed runs - avg_duration = completed_runs.annotate( + avg_duration = this_week_runs.filter(status='completed').annotate( duration=F('completed_at') - F('started_at') ).aggregate(avg=Avg('duration'))['avg'] @@ -578,12 +623,11 @@ class AutomationViewSet(viewsets.ViewSet): 'total_runs': all_runs.count(), 'completed_runs': completed_runs.count(), 'failed_runs': failed_runs.count(), - 'success_rate': round(completed_runs.count() / recent_runs.count() * 100, 1) if recent_runs.count() > 0 else 0, - 'avg_duration_seconds': int(avg_duration.total_seconds()) if avg_duration else 0, + 'running_runs': all_runs.filter(status__in=['running', 'paused']).count(), + 'total_credits_used': all_runs.aggregate(total=Sum('total_credits_used'))['total'] or 0, + 'total_credits_last_30_days': recent_runs.aggregate(total=Sum('total_credits_used'))['total'] or 0, 'avg_credits_per_run': round(avg_credits, 1), - 'runs_this_week': this_week_runs.count(), - 'runs_last_week': last_week_runs.count(), - 'credits_trend': credits_trend, + 'avg_duration_last_7_days_seconds': int(avg_duration.total_seconds()) if avg_duration else 0, }, 'predictive_analysis': predictive_analysis, 'attention_items': attention_items, @@ -1151,6 +1195,232 @@ class AutomationViewSet(viewsets.ViewSet): 'message': None if is_eligible else 'This site has no data yet. Add keywords in the Planner module to get started with automation.' }) + @extend_schema(tags=['Automation']) + @action(detail=False, methods=['get'], url_path='trend_data') + def trend_data(self, request): + """ + GET /api/v1/automation/trend_data/?site_id=123&limit=10 + Get trend data for credits usage visualization + Returns last N runs with credits and output metrics + """ + site, error_response = self._get_site(request) + if error_response: + return error_response + + limit = int(request.query_params.get('limit', 10)) + limit = min(limit, 50) # Cap at 50 runs + + runs = AutomationRun.objects.filter(site=site).order_by('-started_at')[:limit] + + trend_data = [] + for run in reversed(list(runs)): # Oldest first for chart + run_number = self._calculate_run_number(site, run) + + # Calculate items created from stage results + items_created = 0 + if run.stage_1_result: + items_created += run.stage_1_result.get('clusters_created', 0) + if run.stage_2_result: + items_created += run.stage_2_result.get('ideas_created', 0) + if run.stage_4_result: + items_created += run.stage_4_result.get('content_created', 0) + if run.stage_6_result: + items_created += run.stage_6_result.get('images_generated', 0) + + trend_data.append({ + 'run_id': run.run_id, + 'run_number': run_number, + 'credits_used': run.total_credits_used, + 'items_created': items_created, + 'date': run.started_at.isoformat() if run.started_at else None, + 'status': run.status, + }) + + # Calculate summary stats + total_credits = sum(d['credits_used'] for d in trend_data) + total_items = sum(d['items_created'] for d in trend_data) + avg_credits = total_credits / len(trend_data) if trend_data else 0 + + return Response({ + 'trend_data': trend_data, + 'summary': { + 'total_runs': len(trend_data), + 'total_credits': total_credits, + 'total_items': total_items, + 'avg_credits_per_run': round(avg_credits, 1), + 'avg_credits_per_item': round(total_credits / total_items, 2) if total_items > 0 else 0, + } + }) + + @extend_schema(tags=['Automation']) + @action(detail=False, methods=['get'], url_path='production_stats') + def production_stats(self, request): + """ + GET /api/v1/automation/production_stats/?site_id=123 + Get actual production statistics - what was really created across all runs + """ + site, error_response = self._get_site(request) + if error_response: + return error_response + + # Get actual entity counts from database (ground truth) + from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas + from igny8_core.business.content.models import Tasks, Content, Images + + actual_counts = { + 'keywords': Keywords.objects.filter(site=site).count(), + 'clusters': Clusters.objects.filter(site=site).count(), + 'ideas': ContentIdeas.objects.filter(site=site).count(), + 'tasks': Tasks.objects.filter(site=site).count(), + 'content': Content.objects.filter(site=site).count(), + 'images': Images.objects.filter(site=site).count(), + } + + # Get all runs for this site + all_runs = AutomationRun.objects.filter(site=site) + + # Aggregate actual production from stage results + totals = { + 'total_runs': all_runs.count(), + 'runs_with_output': 0, + 'total_credits': 0, + # Use actual database counts for current state + 'clusters_total': actual_counts['clusters'], + 'ideas_total': actual_counts['ideas'], + 'content_total': actual_counts['content'], + 'images_total': actual_counts['images'], + # Track what was created via automation (from run results) + 'clusters_created': 0, + 'ideas_created': 0, + 'content_created': 0, + 'images_created': 0, + 'approved_via_automation': 0, + } + + # Build meaningful runs list (credits > 0 or output > 0) + meaningful_runs = [] + + for run in all_runs.order_by('-started_at')[:15]: # Last 15 runs + run_data = { + 'run_id': run.run_id, + 'run_number': self._calculate_run_number(site, run), + 'status': run.status, + 'started_at': run.started_at.isoformat() if run.started_at else None, + 'duration_seconds': 0, + 'total_credits': run.total_credits_used, + 'stages': [], + } + + if run.completed_at and run.started_at: + run_data['duration_seconds'] = int((run.completed_at - run.started_at).total_seconds()) + + totals['total_credits'] += run.total_credits_used + has_output = False + + # Stage 1: Keywords → Clusters + if run.stage_1_result: + inp = run.stage_1_result.get('keywords_processed', 0) + out = run.stage_1_result.get('clusters_created', 0) + cr = run.stage_1_result.get('credits_used', 0) + totals['clusters_created'] += out + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 1, 'name': 'Keywords→Clusters', + 'input': inp, 'output': out, 'credits': cr + }) + + # Stage 2: Clusters → Ideas + if run.stage_2_result: + inp = run.stage_2_result.get('clusters_processed', 0) + out = run.stage_2_result.get('ideas_created', 0) + cr = run.stage_2_result.get('credits_used', 0) + totals['ideas_created'] += out + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 2, 'name': 'Clusters→Ideas', + 'input': inp, 'output': out, 'credits': cr + }) + + # Stage 3: Ideas → Tasks (1:1 always) + if run.stage_3_result: + out = run.stage_3_result.get('tasks_created', 0) + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 3, 'name': 'Ideas→Tasks', + 'input': out, 'output': out, 'credits': 0 + }) + + # Stage 4: Tasks → Content (1:1 always) + if run.stage_4_result: + out = run.stage_4_result.get('content_created', 0) + cr = run.stage_4_result.get('credits_used', 0) + totals['content_created'] += out + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 4, 'name': 'Tasks→Content', + 'input': out, 'output': out, 'credits': cr + }) + + # Stage 5: Content → Prompts (can be multiple prompts per content) + if run.stage_5_result: + inp = run.stage_5_result.get('content_processed', 0) + out = run.stage_5_result.get('prompts_created', 0) + cr = run.stage_5_result.get('credits_used', 0) + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 5, 'name': 'Content→Prompts', + 'input': inp, 'output': out, 'credits': cr + }) + + # Stage 6: Prompts → Images + if run.stage_6_result: + inp = run.stage_6_result.get('images_processed', 0) + out = run.stage_6_result.get('images_generated', 0) + cr = run.stage_6_result.get('credits_used', 0) + totals['images_created'] += out + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 6, 'name': 'Prompts→Images', + 'input': inp, 'output': out, 'credits': cr + }) + + # Stage 7: Review → Approved + if run.stage_7_result: + out = run.stage_7_result.get('approved_count', 0) + totals['approved_via_automation'] += out + if out > 0: + has_output = True + run_data['stages'].append({ + 'stage': 7, 'name': 'Review→Approved', + 'input': run.stage_7_result.get('ready_for_review', 0) or run.stage_7_result.get('review_total', 0), + 'output': out, 'credits': 0 + }) + + # Add to meaningful runs if has output or credits + if has_output or run.total_credits_used > 0: + totals['runs_with_output'] += 1 + meaningful_runs.append(run_data) + + # Calculate efficiency metrics using actual counts + total_created = totals['clusters_created'] + totals['ideas_created'] + totals['content_created'] + totals['images_created'] + credits_per_item = round(totals['total_credits'] / total_created, 2) if total_created > 0 else 0 + + return Response({ + 'totals': totals, + 'actual_counts': actual_counts, + 'efficiency': { + 'total_items_created': total_created, + 'credits_per_item': credits_per_item, + }, + 'meaningful_runs': meaningful_runs[:10], # Top 10 most recent meaningful runs + }) + @extend_schema(tags=['Automation']) @action(detail=False, methods=['get'], url_path='current_processing') def current_processing(self, request): diff --git a/frontend/src/components/Automation/DetailView/CreditBreakdownChart.tsx b/frontend/src/components/Automation/DetailView/CreditBreakdownChart.tsx index 278008b1..b0a450e3 100644 --- a/frontend/src/components/Automation/DetailView/CreditBreakdownChart.tsx +++ b/frontend/src/components/Automation/DetailView/CreditBreakdownChart.tsx @@ -37,11 +37,19 @@ const CreditBreakdownChart: React.FC = ({ stages }) = fontFamily: 'Inter, sans-serif', }, labels: chartLabels, - colors: ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#06b6d4', '#ec4899', '#6366f1'], + colors: [ + 'var(--color-brand-500)', + 'var(--color-purple-500)', + 'var(--color-warning-500)', + 'var(--color-success-500)', + 'var(--color-gray-500)', + 'var(--color-brand-700)', + 'var(--color-purple-700)', + ], legend: { position: 'bottom', labels: { - colors: '#9ca3af', + colors: 'var(--color-gray-400)', }, }, plotOptions: { @@ -53,20 +61,20 @@ const CreditBreakdownChart: React.FC = ({ stages }) = name: { show: true, fontSize: '12px', - color: '#9ca3af', + color: 'var(--color-gray-400)', }, value: { show: true, fontSize: '20px', fontWeight: 600, - color: '#111827', + color: 'var(--color-gray-900)', formatter: (val: string) => `${parseFloat(val).toFixed(0)}`, }, total: { show: true, label: 'Total Credits', fontSize: '12px', - color: '#9ca3af', + color: 'var(--color-gray-400)', formatter: () => `${chartData.reduce((a, b) => a + b, 0)}`, }, }, diff --git a/frontend/src/components/Automation/DetailView/EnhancedRunHistory.tsx b/frontend/src/components/Automation/DetailView/EnhancedRunHistory.tsx index e09e297e..a8d8da93 100644 --- a/frontend/src/components/Automation/DetailView/EnhancedRunHistory.tsx +++ b/frontend/src/components/Automation/DetailView/EnhancedRunHistory.tsx @@ -23,6 +23,74 @@ const EnhancedRunHistory: React.FC = ({ totalPages = 1, }) => { const navigate = useNavigate(); + const [statusFilter, setStatusFilter] = React.useState< + 'all' | 'completed' | 'running' | 'paused' | 'failed' | 'cancelled' | 'partial' + >('all'); + + const decodeTitle = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } + }; + + const getDisplayTitle = (run: EnhancedRunHistoryItem) => { + if (run.site_name) return run.site_name; + if (run.site_domain) return run.site_domain.replace('www.', ''); + + const decodedTitle = decodeTitle(run.run_title || ''); + try { + const url = new URL(decodedTitle.startsWith('http') ? decodedTitle : `https://${decodedTitle}`); + return url.hostname.replace('www.', '') || decodedTitle; + } catch { + if (decodedTitle) { + return decodedTitle; + } + if (run.run_number) { + return `Run #${run.run_number}`; + } + return 'Automation Run'; + } + }; + + const getDerivedStatus = (run: EnhancedRunHistoryItem): EnhancedRunHistoryItem['status'] => { + const completedStages = (run.stage_statuses || []).filter(s => s === 'completed').length; + const hasOutput = (run.summary?.items_created || 0) > 0 || (run.summary?.items_processed || 0) > 0; + if ((run.status === 'failed' || run.status === 'cancelled') && (completedStages > 0 || hasOutput)) { + return 'partial'; + } + return run.status; + }; + + const formatResultHeadline = (run: EnhancedRunHistoryItem) => { + const processed = run.summary?.items_processed || 0; + const created = run.summary?.items_created || 0; + if (processed > 0 || created > 0) { + return `${processed} → ${created}`; + } + if ((run.total_credits_used || 0) > 0) { + return 'Credits used, outputs pending'; + } + return 'No outputs yet'; + }; + + const formatResultDetails = (run: EnhancedRunHistoryItem) => { + const parts: string[] = []; + if ((run.summary?.content_created || 0) > 0) { + parts.push(`${run.summary?.content_created} content`); + } + if ((run.summary?.images_generated || 0) > 0) { + parts.push(`${run.summary?.images_generated} images`); + } + if ((run.summary?.items_created || 0) > 0 && parts.length === 0) { + parts.push(`${run.summary?.items_created} outputs`); + } + if (parts.length === 0 && (run.total_credits_used || 0) > 0) { + parts.push('Credits spent with no recorded outputs'); + } + return parts.join(', ') || 'No outputs recorded'; + }; const getStatusBadge = (status: string) => { const colors: Record = { @@ -31,6 +99,7 @@ const EnhancedRunHistory: React.FC = ({ paused: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400', failed: 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400', cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + partial: 'bg-warning-50 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300', }; return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; }; @@ -93,8 +162,49 @@ const EnhancedRunHistory: React.FC = ({ ); } + const filteredRuns = React.useMemo(() => { + if (statusFilter === 'all') return runs; + return runs.filter(run => getDerivedStatus(run) === statusFilter); + }, [runs, statusFilter]); + + const statusFilters: Array<{ label: string; value: typeof statusFilter; tone: string }> = [ + { label: 'All', value: 'all', tone: 'brand' }, + { label: 'Completed', value: 'completed', tone: 'success' }, + { label: 'Running', value: 'running', tone: 'brand' }, + { label: 'Partial', value: 'partial', tone: 'warning' }, + { label: 'Failed', value: 'failed', tone: 'error' }, + { label: 'Cancelled', value: 'cancelled', tone: 'gray' }, + { label: 'Paused', value: 'paused', tone: 'warning' }, + ]; + return (
+
+
+

Filter runs

+

+ Showing {filteredRuns.length} of {runs.length} runs +

+
+
+ {statusFilters.map(option => { + const isActive = statusFilter === option.value; + return ( + + ); + })} +
+
@@ -123,7 +233,14 @@ const EnhancedRunHistory: React.FC = ({ - {runs.map((run) => ( + {filteredRuns.length === 0 && ( + + + + )} + {filteredRuns.map((run) => ( navigate(`/automation/runs/${run.run_id}`)} @@ -131,20 +248,25 @@ const EnhancedRunHistory: React.FC = ({ >
+ No runs match this filter. +
- {run.run_title} + {getDisplayTitle(run)}
{run.trigger_type}
- - {run.status} - + {(() => { + const derivedStatus = getDerivedStatus(run); + return ( + + {derivedStatus} + + ); + })()}
@@ -168,11 +290,15 @@ const EnhancedRunHistory: React.FC = ({
-
- {run.summary?.items_processed || 0} → {run.summary?.items_created || 0} +
0 && (run.summary?.items_created || 0) === 0 + ? 'text-warning-700 dark:text-warning-300' + : 'text-gray-900 dark:text-white' + }`}> + {formatResultHeadline(run)}
- {run.summary?.content_created || 0} content, {run.summary?.images_generated || 0} images + {formatResultDetails(run)}
diff --git a/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx b/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx new file mode 100644 index 00000000..ac02c701 --- /dev/null +++ b/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx @@ -0,0 +1,235 @@ +/** + * Meaningful Run History Widget + * Shows only runs where actual work was done (credits > 0 or items created) + * Displays actual outputs per stage, not generic "items" + */ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface StageOutput { + stage: number; + name: string; + input: number; + output: number; + credits: number; +} + +interface MeaningfulRun { + run_id: string; + run_number: number; + status: string; + started_at: string; + duration_seconds: number; + total_credits: number; + stages: StageOutput[]; +} + +interface MeaningfulRunHistoryProps { + runs: MeaningfulRun[]; + loading?: boolean; + maxRuns?: number; +} + +const STAGE_LABELS: Record = { + 1: { input: 'Keywords', output: 'Clusters' }, + 2: { input: 'Clusters', output: 'Ideas' }, + 3: { input: 'Ideas', output: 'Tasks' }, + 4: { input: 'Tasks', output: 'Content' }, + 5: { input: 'Content', output: 'Prompts' }, + 6: { input: 'Prompts', output: 'Images' }, + 7: { input: 'In Review', output: 'Approved' }, +}; + +const STAGE_COLORS: Record = { + 1: 'bg-brand-500', + 2: 'bg-purple-500', + 3: 'bg-warning-500', + 4: 'bg-gray-600', + 5: 'bg-brand-400', + 6: 'bg-purple-400', + 7: 'bg-success-500', +}; + +const MeaningfulRunHistory: React.FC = ({ + runs, + loading, + maxRuns = 5, +}) => { + const navigate = useNavigate(); + + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + // Filter to only meaningful runs (credits > 0 OR any stage has output > 0) + const meaningfulRuns = runs + .filter(run => + run.total_credits > 0 || + run.stages.some(s => s.output > 0) + ) + .slice(0, maxRuns); + + if (meaningfulRuns.length === 0) { + return ( +
+

+ Run History +

+

+ No runs with output yet. Start an automation run to generate content. +

+
+ ); + } + + const formatDuration = (seconds: number): string => { + if (seconds < 60) return `${seconds}s`; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }; + + const formatDate = (dateStr: string) => { + try { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return dateStr; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': return 'text-success-600 dark:text-success-400'; + case 'failed': return 'text-error-600 dark:text-error-400'; + case 'cancelled': return 'text-warning-600 dark:text-warning-400'; + default: return 'text-gray-600 dark:text-gray-400'; + } + }; + + return ( +
+
+
+

+ Run History +

+

+ Recent runs with output +

+
+
+ +
+ {meaningfulRuns.map((run, index) => { + // Get only stages that produced output + const productiveStages = run.stages.filter(s => s.output > 0); + + return ( +
navigate(`/automation/runs/${run.run_id}`)} + className={` + p-4 rounded-lg border cursor-pointer transition-all + ${index === 0 + ? 'border-brand-200 bg-brand-50/30 dark:border-brand-800 dark:bg-brand-900/10' + : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' + } + `} + > + {/* Run Header */} +
+
+ + Run #{run.run_number} + + + {run.status} + + {index === 0 && ( + + Latest + + )} +
+
+ {formatDate(run.started_at)} + {formatDuration(run.duration_seconds)} + + {run.total_credits} cr + +
+
+ + {/* Stage Outputs - Only show stages with output */} + {productiveStages.length > 0 ? ( +
+ {productiveStages.map(stage => ( +
+
+ + {stage.input} {STAGE_LABELS[stage.stage]?.input} + + + + {stage.output} {STAGE_LABELS[stage.stage]?.output} + + {stage.credits > 0 && ( + + ({stage.credits}cr) + + )} +
+ ))} +
+ ) : run.total_credits > 0 ? ( +
+ Processing run - no new items created +
+ ) : null} +
+ ); + })} +
+ + {/* Summary */} +
+
+ + Total from {meaningfulRuns.length} runs: + +
+ + {meaningfulRuns.reduce((sum, r) => sum + r.total_credits, 0)} credits + + + {meaningfulRuns.reduce((sum, r) => + sum + r.stages.reduce((s, st) => s + st.output, 0), 0 + )} items + +
+
+
+
+ ); +}; + +export default MeaningfulRunHistory; diff --git a/frontend/src/components/Automation/DetailView/PipelineOverviewCard.tsx b/frontend/src/components/Automation/DetailView/PipelineOverviewCard.tsx new file mode 100644 index 00000000..c2678536 --- /dev/null +++ b/frontend/src/components/Automation/DetailView/PipelineOverviewCard.tsx @@ -0,0 +1,105 @@ +/** + * Pipeline Overview Card + * Summarizes pending items and status breakdown for each automation stage. + */ +import React from 'react'; + +interface PipelineStageCounts { + [key: string]: number; +} + +interface PipelineStageOverview { + number: number; + name: string; + pending: number; + type: 'AI' | 'Local' | 'Manual'; + counts?: PipelineStageCounts; + total?: number; +} + +interface PipelineOverviewCardProps { + stages: PipelineStageOverview[]; + loading?: boolean; +} + +const PipelineOverviewCard: React.FC = ({ stages, loading }) => { + if (loading) { + return ( +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } + + if (!stages || stages.length === 0) { + return ( +
+

Pipeline overview unavailable.

+
+ ); + } + + return ( +
+
+
+

Pipeline Overview

+

+ Pending items per stage with status distribution +

+
+
+ +
+ {stages.map((stage) => ( +
+
+
+
+ Stage {stage.number}: {stage.name} +
+
+ {stage.type} stage +
+
+
+
+ {stage.pending} +
+
Pending
+
+
+ + {stage.counts && ( +
+ {Object.entries(stage.counts) + .filter(([status]) => status !== 'total') + .map(([status, count]) => ( +
+ {status.replace('_', ' ')}: + {count} +
+ ))} + {typeof stage.total === 'number' && ( +
+ Total: + {stage.total} +
+ )} +
+ )} +
+ ))} +
+
+ ); +}; + +export default PipelineOverviewCard; diff --git a/frontend/src/components/Automation/DetailView/PredictiveCostAnalysis.tsx b/frontend/src/components/Automation/DetailView/PredictiveCostAnalysis.tsx index d5537a61..ef2f185c 100644 --- a/frontend/src/components/Automation/DetailView/PredictiveCostAnalysis.tsx +++ b/frontend/src/components/Automation/DetailView/PredictiveCostAnalysis.tsx @@ -2,17 +2,18 @@ * Predictive Cost Analysis Component * Shows estimated credits and outputs for next automation run */ -import React from 'react'; -import { PredictiveAnalysis } from '../../../types/automation'; +import React, { useMemo } from 'react'; +import { HistoricalAverages, PredictiveAnalysis } from '../../../types/automation'; import ReactApexChart from 'react-apexcharts'; import { ApexOptions } from 'apexcharts'; interface PredictiveCostAnalysisProps { analysis: PredictiveAnalysis; + historicalAverages?: HistoricalAverages; loading?: boolean; } -const PredictiveCostAnalysis: React.FC = ({ analysis, loading }) => { +const PredictiveCostAnalysis: React.FC = ({ analysis, historicalAverages, loading }) => { if (loading || !analysis) { return (
@@ -26,26 +27,77 @@ const PredictiveCostAnalysis: React.FC = ({ analysi ); } - const confidenceColors = { - high: 'text-success-600 dark:text-success-400', - medium: 'text-warning-600 dark:text-warning-400', - low: 'text-error-600 dark:text-error-400', - }; - const confidenceBadges = { high: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400', medium: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400', low: 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400', }; - // Prepare data for donut chart - const chartData = (analysis.stages || []) - .filter(s => (s.estimated_credits || 0) > 0) - .map(s => s.estimated_credits || 0); - - const chartLabels = (analysis.stages || []) - .filter(s => (s.estimated_credits || 0) > 0) - .map(s => s.stage_name || 'Unknown'); + const totals = analysis.totals || { + total_pending_items: 0, + total_estimated_credits: 0, + total_estimated_output: 0, + recommended_buffer_credits: 0, + }; + + const stageCalculations = (analysis.stages || []).map((stage, idx) => { + const pending = stage.pending_items || 0; + const credits = stage.estimated_credits || 0; + const output = stage.estimated_output || 0; + const perItem = pending > 0 ? credits / pending : 0; + const ratio = pending > 0 ? output / pending : 0; + return { + key: `${stage.stage_number ?? idx}-${stage.stage_name || 'stage'}`, + name: stage.stage_name || `Stage ${stage.stage_number || idx + 1}`, + pending, + credits, + output, + perItem, + ratio, + stageNumber: stage.stage_number || idx + 1, + }; + }); + + const totalPending = totals.total_pending_items || 0; + const totalCredits = totals.total_estimated_credits || 0; + const totalOutput = totals.total_estimated_output || 0; + const bufferCredits = totals.recommended_buffer_credits || Math.round(totalCredits * 1.2); + + const perItemCredit = useMemo(() => { + if (totalPending > 0 && totalCredits > 0) return totalCredits / totalPending; + return historicalAverages?.avg_credits_per_item || 0; + }, [totalPending, totalCredits, historicalAverages]); + + const outputRatio = useMemo(() => { + if (totalPending > 0 && totalOutput > 0) return totalOutput / totalPending; + const ratios = (historicalAverages?.stages || []) + .map(s => s.avg_output_ratio) + .filter(r => r > 0); + if (ratios.length > 0) return ratios.reduce((a, b) => a + b, 0) / ratios.length; + return 0; + }, [totalPending, totalOutput, historicalAverages]); + + const highestCostStage = stageCalculations.reduce((max, stage) => ( + stage.credits > (max?.credits || 0) ? stage : max + ), undefined as typeof stageCalculations[number] | undefined); + + const lowestYieldStage = stageCalculations + .filter(stage => stage.pending > 0) + .reduce((min, stage) => (min === undefined || stage.ratio < min.ratio ? stage : min), + undefined as typeof stageCalculations[number] | undefined + ); + + const chartData = stageCalculations + .filter(s => s.credits > 0) + .map(s => s.credits); + + const chartLabels = stageCalculations + .filter(s => s.credits > 0) + .map(s => s.name); + + const hasTotals = (totalPending + totalCredits + totalOutput) > 0; + const hasStageData = stageCalculations.some(stage => stage.pending > 0 || stage.credits > 0 || stage.output > 0); + const hasData = hasTotals || hasStageData || perItemCredit > 0; const chartOptions: ApexOptions = { chart: { @@ -53,11 +105,19 @@ const PredictiveCostAnalysis: React.FC = ({ analysi fontFamily: 'Inter, sans-serif', }, labels: chartLabels, - colors: ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#06b6d4', '#ec4899', '#6366f1'], + colors: [ + 'var(--color-brand-500)', + 'var(--color-purple-500)', + 'var(--color-warning-500)', + 'var(--color-success-500)', + 'var(--color-gray-500)', + 'var(--color-brand-700)', + 'var(--color-purple-700)', + ], legend: { position: 'bottom', labels: { - colors: '#9ca3af', + colors: 'var(--color-gray-400)', }, }, plotOptions: { @@ -69,21 +129,21 @@ const PredictiveCostAnalysis: React.FC = ({ analysi name: { show: true, fontSize: '14px', - color: '#9ca3af', + color: 'var(--color-gray-400)', }, value: { show: true, fontSize: '24px', fontWeight: 600, - color: '#111827', + color: 'var(--color-gray-900)', formatter: (val: string) => `${parseFloat(val).toFixed(0)} cr`, }, total: { show: true, label: 'Est. Total', fontSize: '14px', - color: '#9ca3af', - formatter: () => `${analysis.totals?.total_estimated_credits || 0} cr`, + color: 'var(--color-gray-400)', + formatter: () => `${totalCredits || 0} cr`, }, }, }, @@ -111,70 +171,107 @@ const PredictiveCostAnalysis: React.FC = ({ analysi
- {/* Summary Cards */} -
-
-
- {analysis.totals?.total_pending_items || 0} -
-
Pending Items
-
-
-
- {analysis.totals?.total_estimated_output || 0} -
-
Est. Output
-
-
-
- {analysis.totals?.total_estimated_credits || 0} -
-
Est. Credits
-
-
-
- {analysis.totals?.recommended_buffer_credits || 0} -
-
+20% Buffer
-
-
- - {/* Donut Chart */} - {chartData.length > 0 && ( -
- + {!hasData && ( +
+ No predictive data available yet.
)} - {/* Stage Breakdown */} -
-

Stage Breakdown

- {(analysis.stages || []).map((stage) => ( -
-
-
- {stage.stage_name || 'Unknown Stage'} -
-
- {stage.pending_items || 0} items → ~{stage.estimated_output || 0} output -
+ {hasData && ( + <> + {totalPending === 0 && ( +
+ No pending items detected. Automation would complete with minimal or no work.
-
-
- ~{stage.estimated_credits || 0} cr + )} +
+
+
+ {totalPending}
+
Pending Items
+
+
+
+ {totalOutput} +
+
Est. Output
+
+
+
+ {totalCredits} +
+
Est. Credits
+
+
+
+ {bufferCredits} +
+
+20% Buffer
- ))} -
+ +
+ Est. credit per item: {perItemCredit.toFixed(2)} cr + Est. output ratio: {outputRatio.toFixed(2)}x + Based on {historicalAverages?.total_runs_analyzed || 0} completed runs +
+ + {(analysis.confidence === 'low' || analysis.confidence === 'medium') && ( +
+ Estimates may fluctuate with limited history. Complete more runs to improve prediction accuracy. +
+ )} + + {chartData.length > 0 && ( +
+ +
+ )} + +
+

Stage Breakdown

+ {stageCalculations.filter(stage => stage.pending > 0 || stage.credits > 0 || stage.output > 0).length === 0 && ( +
No stage estimates available.
+ )} + {stageCalculations + .filter(stage => stage.pending > 0 || stage.credits > 0 || stage.output > 0) + .map((stage) => ( +
+
+
+ {stage.name} + {highestCostStage?.stageNumber === stage.stageNumber && ( + + Highest cost + + )} + {lowestYieldStage?.stageNumber === stage.stageNumber && stage.ratio > 0 && ( + + Low yield + + )} +
+
+ {stage.pending} pending → ~{stage.output} output • {stage.perItem.toFixed(2)} cr/item +
+
+
+
+ ~{stage.credits} cr +
+
+ {stage.ratio > 0 ? `${stage.ratio.toFixed(2)}x output` : 'No output forecast'} +
+
+
+ ))} +
+ + )}
); }; diff --git a/frontend/src/components/Automation/DetailView/ProductionSummary.tsx b/frontend/src/components/Automation/DetailView/ProductionSummary.tsx new file mode 100644 index 00000000..ff8ed0dc --- /dev/null +++ b/frontend/src/components/Automation/DetailView/ProductionSummary.tsx @@ -0,0 +1,180 @@ +/** + * Production Summary Widget + * Shows actual site inventory - real database counts + * Two-row layout: Current Inventory + Automation Created + */ +import React from 'react'; + +interface ActualCounts { + keywords: number; + clusters: number; + ideas: number; + tasks: number; + content: number; + images: number; +} + +interface AutomationTotals { + total_runs: number; + runs_with_output: number; + total_credits: number; + clusters_created: number; + ideas_created: number; + content_created: number; + images_created: number; + approved_via_automation: number; + // Actual totals in DB + clusters_total: number; + ideas_total: number; + content_total: number; + images_total: number; +} + +interface Efficiency { + total_items_created: number; + credits_per_item: number; +} + +interface ProductionSummaryProps { + totals: AutomationTotals; + actual_counts?: ActualCounts; + efficiency?: Efficiency; + loading?: boolean; +} + +const ProductionSummary: React.FC = ({ + totals, + actual_counts, + efficiency, + loading, +}) => { + if (loading) { + return ( +
+
+
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+
+
+ ); + } + + // Use actual database counts if available, fallback to totals + const counts = actual_counts || { + keywords: 0, + clusters: totals.clusters_total || totals.clusters_created, + ideas: totals.ideas_total || totals.ideas_created, + tasks: totals.content_total || totals.content_created, + content: totals.content_total || totals.content_created, + images: totals.images_total || totals.images_created, + }; + + const inventoryStats = [ + { + label: 'Keywords', + value: counts.keywords, + color: 'text-gray-700 dark:text-gray-300', + bgColor: 'bg-gray-100 dark:bg-gray-800', + }, + { + label: 'Clusters', + value: counts.clusters, + color: 'text-brand-600 dark:text-brand-400', + bgColor: 'bg-brand-50 dark:bg-brand-900/20', + }, + { + label: 'Ideas', + value: counts.ideas, + color: 'text-purple-600 dark:text-purple-400', + bgColor: 'bg-purple-50 dark:bg-purple-900/20', + }, + { + label: 'Content', + value: counts.content, + color: 'text-success-600 dark:text-success-400', + bgColor: 'bg-success-50 dark:bg-success-900/20', + }, + { + label: 'Images', + value: counts.images, + color: 'text-info-600 dark:text-info-400', + bgColor: 'bg-info-50 dark:bg-info-900/20', + }, + ]; + + const creditsPerItem = efficiency?.credits_per_item + ? efficiency.credits_per_item.toFixed(1) + : (totals.total_credits > 0 && efficiency?.total_items_created + ? (totals.total_credits / efficiency.total_items_created).toFixed(1) + : '0'); + + return ( +
+
+
+

+ Site Content Inventory +

+

+ Current totals across {totals.total_runs} automation runs +

+
+
+
+ {totals.total_credits.toLocaleString()} +
+
+ total credits used +
+
+
+ + {/* Current Inventory */} +
+ {inventoryStats.map((stat, index) => ( +
+
+ {stat.value.toLocaleString()} +
+
+ {stat.label} +
+
+ ))} +
+ + {/* Automation Stats */} +
+
+
+ Runs with output: + + {totals.runs_with_output} of {totals.total_runs} + +
+
+ Avg efficiency: + + {creditsPerItem} cr/item + +
+
+ Auto-approved: + + {totals.approved_via_automation?.toLocaleString() || 0} + +
+
+
+
+ ); +}; + +export default ProductionSummary; diff --git a/frontend/src/components/Automation/DetailView/RunStatisticsSummary.tsx b/frontend/src/components/Automation/DetailView/RunStatisticsSummary.tsx index 3ce2b9a5..62f0b29f 100644 --- a/frontend/src/components/Automation/DetailView/RunStatisticsSummary.tsx +++ b/frontend/src/components/Automation/DetailView/RunStatisticsSummary.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; import { RunStatistics } from '../../../types/automation'; -import { BoltIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../../../icons'; +import { BoltIcon, CheckCircleIcon, ClockIcon } from '../../../icons'; interface RunStatisticsSummaryProps { statistics: RunStatistics; @@ -32,31 +32,42 @@ const RunStatisticsSummary: React.FC = ({ statistics, return `${seconds}s`; }; + const totalRuns = statistics.total_runs || 0; + const completedRuns = statistics.completed_runs || 0; + const failedRuns = statistics.failed_runs || 0; + const runningRuns = statistics.running_runs || 0; + const successRate = totalRuns > 0 ? (completedRuns / totalRuns) * 100 : 0; + const failureRate = totalRuns > 0 ? (failedRuns / totalRuns) * 100 : 0; + const stats = [ { label: 'Total Runs', - value: statistics.total_runs || 0, + value: totalRuns, + helper: runningRuns > 0 ? `${runningRuns} running now` : 'No active runs', icon: BoltIcon, color: 'brand' as const, }, { - label: 'Completed', - value: statistics.completed_runs || 0, + label: 'Success Rate', + value: `${successRate.toFixed(1)}%`, + helper: `${completedRuns} completed`, icon: CheckCircleIcon, color: 'success' as const, }, { - label: 'Failed', - value: statistics.failed_runs || 0, - icon: XCircleIcon, - color: 'error' as const, - }, - { - label: 'Running', - value: statistics.running_runs || 0, + label: 'Avg Duration (7d)', + value: formatDuration(statistics.avg_duration_last_7_days_seconds || 0), + helper: failureRate > 0 ? `${failureRate.toFixed(1)}% failed` : 'No recent failures', icon: ClockIcon, color: 'warning' as const, }, + { + label: 'Avg Credits/Run', + value: Math.round(statistics.avg_credits_per_run || 0).toLocaleString(), + helper: `${(statistics.total_credits_last_30_days || 0).toLocaleString()} last 30d`, + icon: BoltIcon, + color: 'brand' as const, + }, ]; return ( @@ -81,6 +92,7 @@ const RunStatisticsSummary: React.FC = ({ statistics,
{stat.value}
{stat.label}
+
{stat.helper}
); })} diff --git a/frontend/src/components/Automation/DetailView/RunSummaryCard.tsx b/frontend/src/components/Automation/DetailView/RunSummaryCard.tsx index 6a53e90f..3e5b7664 100644 --- a/frontend/src/components/Automation/DetailView/RunSummaryCard.tsx +++ b/frontend/src/components/Automation/DetailView/RunSummaryCard.tsx @@ -9,9 +9,15 @@ import { CheckCircleIcon, XCircleIcon, ClockIcon, BoltIcon } from '../../../icon interface RunSummaryCardProps { run: RunDetailInfo; + summary?: { + itemsProcessed: number; + itemsCreated: number; + contentCreated: number; + imagesGenerated: number; + }; } -const RunSummaryCard: React.FC = ({ run }) => { +const RunSummaryCard: React.FC = ({ run, summary }) => { if (!run) { return (
@@ -20,14 +26,35 @@ const RunSummaryCard: React.FC = ({ run }) => { ); } + const getDisplayTitle = () => { + if (run.site_name) return run.site_name; + const title = run.run_title || ''; + try { + const url = new URL(title); + return url.hostname.replace('www.', '') || title; + } catch { + return title || `Run #${run.run_number || ''}`; + } + }; + + const derivedStatus: RunDetailInfo['status'] = (() => { + const hasCredits = (run.total_credits_used || 0) > 0; + if ((run.status === 'failed' || run.status === 'cancelled') && hasCredits) { + return 'partial'; + } + return run.status; + })(); + const getStatusIcon = () => { - switch (run.status) { + switch (derivedStatus) { case 'completed': return ; case 'failed': return ; case 'running': return ; + case 'partial': + return ; default: return ; } @@ -40,11 +67,14 @@ const RunSummaryCard: React.FC = ({ run }) => { paused: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400', failed: 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400', cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + partial: 'bg-warning-50 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300', }; - return colors[run.status] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; + return colors[derivedStatus] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; }; - const totalInitialItems = run.initial_snapshot?.total_initial_items || 0; + const totalInitialItems = run.initial_snapshot?.total_initial_items || summary?.itemsProcessed || 0; + const totalOutputs = summary?.itemsCreated || 0; + const creditsPerOutput = totalOutputs > 0 ? (run.total_credits_used || 0) / totalOutputs : 0; return (
@@ -53,9 +83,12 @@ const RunSummaryCard: React.FC = ({ run }) => { {getStatusIcon()}
+
+ {getDisplayTitle()} +
- {(run.status || 'unknown').toUpperCase()} + {(derivedStatus || 'unknown').toUpperCase()} {run.trigger_type || 'manual'} trigger @@ -88,6 +121,40 @@ const RunSummaryCard: React.FC = ({ run }) => {
+ +
+
+
Outputs Created
+
+ {totalOutputs} +
+
+
+
Content Created
+
+ {summary?.contentCreated || 0} +
+
+
+
Images Generated
+
+ {summary?.imagesGenerated || 0} +
+
+
+
Credits per Output
+
+ {creditsPerOutput > 0 ? creditsPerOutput.toFixed(2) : '—'} +
+
+
+ +
+ Run ID: {run.run_id} + {run.completed_at && ( + Completed: {formatDateTime(run.completed_at)} + )} +
diff --git a/frontend/src/components/Automation/DetailView/StageAccordion.tsx b/frontend/src/components/Automation/DetailView/StageAccordion.tsx index 6c67d56d..da8b150d 100644 --- a/frontend/src/components/Automation/DetailView/StageAccordion.tsx +++ b/frontend/src/components/Automation/DetailView/StageAccordion.tsx @@ -53,6 +53,19 @@ const StageAccordion: React.FC = ({ stages, initialSnapshot return 'text-success-600 dark:text-success-400'; }; + const getInitialQueue = (stageNumber: number) => { + const mapping: Record = { + 1: initialSnapshot?.stage_1_initial, + 2: initialSnapshot?.stage_2_initial, + 3: initialSnapshot?.stage_3_initial, + 4: initialSnapshot?.stage_4_initial, + 5: initialSnapshot?.stage_5_initial, + 6: initialSnapshot?.stage_6_initial, + 7: initialSnapshot?.stage_7_initial, + }; + return mapping[stageNumber] ?? 0; + }; + return (
@@ -129,6 +142,39 @@ const StageAccordion: React.FC = ({ stages, initialSnapshot
+
+
+
Initial Queue
+
+ {getInitialQueue(stage.stage_number)} +
+
+
+
Credits / Item
+
+ {(stage.items_processed || 0) > 0 + ? ((stage.credits_used || 0) / (stage.items_processed || 1)).toFixed(2) + : '—'} +
+
+
+
Output Ratio
+
+ {(stage.items_processed || 0) > 0 + ? ((stage.items_created || 0) / (stage.items_processed || 1)).toFixed(2) + : '—'} +
+
+
+
Items / Minute
+
+ {(stage.duration_seconds || 0) > 0 + ? (((stage.items_processed || 0) / (stage.duration_seconds || 1)) * 60).toFixed(1) + : '—'} +
+
+
+ {/* Historical Comparison */} {stage.comparison && (stage.comparison.historical_avg_credits || 0) > 0 && (
diff --git a/frontend/src/pages/Automation/AutomationOverview.tsx b/frontend/src/pages/Automation/AutomationOverview.tsx index eed1dbd7..8e56f298 100644 --- a/frontend/src/pages/Automation/AutomationOverview.tsx +++ b/frontend/src/pages/Automation/AutomationOverview.tsx @@ -1,150 +1,139 @@ /** * Automation Overview Page - * Comprehensive dashboard showing automation status, metrics, cost estimation, and run history + * Meaningful dashboard showing actual production data, not estimates */ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { useSiteStore } from '../../store/siteStore'; import { automationService } from '../../services/automationService'; -import { OverviewStatsResponse } from '../../types/automation'; -import { - fetchKeywords, - fetchClusters, - fetchContentIdeas, - fetchTasks, - fetchContent, - fetchImages, -} from '../../services/api'; -import RunHistory from '../../components/Automation/RunHistory'; import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; -import ComponentCard from '../../components/common/ComponentCard'; -import RunStatisticsSummary from '../../components/Automation/DetailView/RunStatisticsSummary'; -import PredictiveCostAnalysis from '../../components/Automation/DetailView/PredictiveCostAnalysis'; -import AttentionItemsAlert from '../../components/Automation/DetailView/AttentionItemsAlert'; -import EnhancedRunHistory from '../../components/Automation/DetailView/EnhancedRunHistory'; +import MeaningfulRunHistory from '../../components/Automation/DetailView/MeaningfulRunHistory'; +import ProductionSummary from '../../components/Automation/DetailView/ProductionSummary'; import { - ListIcon, - GroupIcon, + BoltIcon, + ClockIcon, FileTextIcon, - FileIcon, - BoltIcon, + PaperPlaneIcon, } from '../../icons'; +interface ActualCounts { + keywords: number; + clusters: number; + ideas: number; + tasks: number; + content: number; + images: number; +} + +interface AutomationTotals { + total_runs: number; + runs_with_output: number; + total_credits: number; + clusters_created: number; + ideas_created: number; + content_created: number; + images_created: number; + approved_via_automation: number; + clusters_total: number; + ideas_total: number; + content_total: number; + images_total: number; +} + +interface Efficiency { + total_items_created: number; + credits_per_item: number; +} + +interface MeaningfulRun { + run_id: string; + run_number: number; + status: string; + started_at: string; + duration_seconds: number; + total_credits: number; + stages: Array<{ + stage: number; + name: string; + input: number; + output: number; + credits: number; + }>; +} + +interface ProductionStats { + totals: AutomationTotals; + actual_counts: ActualCounts; + efficiency: Efficiency; + meaningful_runs: MeaningfulRun[]; +} + const AutomationOverview: React.FC = () => { const { activeSite } = useSiteStore(); + const navigate = useNavigate(); const toast = useToast(); const [loading, setLoading] = useState(true); - const [metrics, setMetrics] = useState(null); - const [overviewStats, setOverviewStats] = useState(null); - const [historyPage, setHistoryPage] = useState(1); - const [historyData, setHistoryData] = useState(null); + const [productionStats, setProductionStats] = useState(null); + const [hasRunning, setHasRunning] = useState(false); + const [pendingCounts, setPendingCounts] = useState({ + keywords: 0, + content: 0, + images: 0, + review: 0, + }); - // Load metrics for the 5 metric cards - const loadMetrics = async () => { + const loadData = async () => { if (!activeSite) return; + setLoading(true); try { - const [ - keywordsTotalRes, keywordsNewRes, keywordsMappedRes, - clustersTotalRes, clustersNewRes, clustersMappedRes, - ideasTotalRes, ideasNewRes, ideasQueuedRes, ideasCompletedRes, - tasksTotalRes, - contentTotalRes, contentDraftRes, contentReviewRes, contentPublishedRes, - contentNotPublishedRes, contentScheduledRes, - imagesTotalRes, imagesPendingRes, - ] = await Promise.all([ - fetchKeywords({ page_size: 1, site_id: activeSite.id }), - fetchKeywords({ page_size: 1, site_id: activeSite.id, status: 'new' }), - fetchKeywords({ page_size: 1, site_id: activeSite.id, status: 'mapped' }), - fetchClusters({ page_size: 1, site_id: activeSite.id }), - fetchClusters({ page_size: 1, site_id: activeSite.id, status: 'new' }), - fetchClusters({ page_size: 1, site_id: activeSite.id, status: 'mapped' }), - fetchContentIdeas({ page_size: 1, site_id: activeSite.id }), - fetchContentIdeas({ page_size: 1, site_id: activeSite.id, status: 'new' }), - fetchContentIdeas({ page_size: 1, site_id: activeSite.id, status: 'queued' }), - fetchContentIdeas({ page_size: 1, site_id: activeSite.id, status: 'completed' }), - fetchTasks({ page_size: 1, site_id: activeSite.id }), - fetchContent({ page_size: 1, site_id: activeSite.id }), - fetchContent({ page_size: 1, site_id: activeSite.id, status: 'draft' }), - fetchContent({ page_size: 1, site_id: activeSite.id, status: 'review' }), - fetchContent({ page_size: 1, site_id: activeSite.id, status__in: 'approved,published' }), - fetchContent({ page_size: 1, site_id: activeSite.id, status: 'approved' }), - fetchContent({ page_size: 1, site_id: activeSite.id, status: 'approved' }), - fetchImages({ page_size: 1 }), - fetchImages({ page_size: 1, status: 'pending' }), + const [stats, currentRun, pipeline] = await Promise.all([ + automationService.getProductionStats(activeSite.id), + automationService.getCurrentRun(activeSite.id), + automationService.getPipelineOverview(activeSite.id), ]); - 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, - not_published: contentNotPublishedRes.count || 0, - scheduled: contentScheduledRes.count || 0, - }, - images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 }, - }); + setProductionStats(stats); + setHasRunning(!!currentRun.run && (currentRun.run.status === 'running' || currentRun.run.status === 'paused')); + + // Extract pending counts from pipeline + if (pipeline.stages) { + const stage1 = pipeline.stages.find((s: any) => s.number === 1); + const stage5 = pipeline.stages.find((s: any) => s.number === 5); + const stage6 = pipeline.stages.find((s: any) => s.number === 6); + const stage7 = pipeline.stages.find((s: any) => s.number === 7); + setPendingCounts({ + keywords: stage1?.pending || 0, + content: stage5?.pending || 0, + images: stage6?.pending || 0, + review: stage7?.pending || 0, + }); + } } catch (e) { - console.warn('Failed to fetch metrics for automation overview', e); - } - }; - - // Load cost estimate - const loadOverviewStats = async () => { - if (!activeSite) return; - - try { - const stats = await automationService.getOverviewStats(activeSite.id); - setOverviewStats(stats); - } catch (e) { - console.warn('Failed to fetch overview stats', e); - } - }; - - // Load enhanced history - const loadEnhancedHistory = async (page: number = 1) => { - if (!activeSite) return; - - try { - const history = await automationService.getEnhancedHistory(activeSite.id, page, 10); - setHistoryData(history); - } catch (e) { - console.warn('Failed to fetch enhanced history', e); - // Set to null so fallback component shows - setHistoryData(null); + console.error('Failed to load production stats', e); + } finally { + setLoading(false); } }; useEffect(() => { - const loadData = async () => { - setLoading(true); - await Promise.all([loadMetrics(), loadOverviewStats(), loadEnhancedHistory(historyPage)]); - setLoading(false); - }; - if (activeSite) { loadData(); } - }, [activeSite, historyPage]); + }, [activeSite]); - // Helper to render metric rows - const renderMetricRow = (items: Array<{ label: string; value: number; colorCls: string }>) => { - return ( -
- {items.map((item, idx) => ( -
- {item.label} - {item.value} -
- ))} -
- ); + const handleStartRun = async () => { + if (!activeSite) return; + + try { + await automationService.runNow(activeSite.id); + toast.success('Automation started'); + navigate('/automation'); + } catch (e: any) { + toast.error(e?.message || 'Failed to start automation'); + } }; if (!activeSite) { @@ -158,166 +147,120 @@ const AutomationOverview: React.FC = () => { if (loading) { return (
-
Loading automation overview...
+
Loading automation data...
); } + const totals = productionStats?.totals || { + total_runs: 0, + runs_with_output: 0, + total_credits: 0, + clusters_created: 0, + ideas_created: 0, + content_created: 0, + images_created: 0, + approved_via_automation: 0, + clusters_total: 0, + ideas_total: 0, + content_total: 0, + images_total: 0, + }; + + const actual_counts = productionStats?.actual_counts || { + keywords: 0, + clusters: 0, + ideas: 0, + tasks: 0, + content: 0, + images: 0, + }; + return ( <> - +
- {/* Metrics Summary Cards */} -
- {/* Keywords */} -
-
-
-
- -
-
Keywords
-
-
-
{metrics?.keywords?.total || 0}
-
-
- {renderMetricRow([ - { label: 'New:', value: metrics?.keywords?.new || 0, colorCls: 'text-brand-600' }, - { label: 'Mapped:', value: metrics?.keywords?.mapped || 0, colorCls: 'text-brand-600' }, - ])} -
+ {/* Quick Actions Row - Compact */} +
+ + + + + + + - {/* Clusters */} -
-
-
-
- -
-
Clusters
-
-
-
{metrics?.clusters?.total || 0}
-
-
- {renderMetricRow([ - { label: 'New:', value: metrics?.clusters?.new || 0, colorCls: 'text-purple-600' }, - { label: 'Mapped:', value: metrics?.clusters?.mapped || 0, colorCls: 'text-purple-600' }, - ])} -
- - {/* Ideas */} -
-
-
-
- -
-
Ideas
-
-
-
{metrics?.ideas?.total || 0}
-
-
- {renderMetricRow([ - { label: 'New:', value: metrics?.ideas?.new || 0, colorCls: 'text-warning-600' }, - { label: 'Queued:', value: metrics?.ideas?.queued || 0, colorCls: 'text-warning-600' }, - { label: 'Done:', value: metrics?.ideas?.completed || 0, colorCls: 'text-warning-600' }, - ])} -
- - {/* Content */} -
-
-
-
- -
-
Content
-
-
-
{metrics?.content?.total || 0}
-
-
- {renderMetricRow([ - { label: 'Draft:', value: metrics?.content?.draft || 0, colorCls: 'text-success-600' }, - { label: 'Review:', value: metrics?.content?.review || 0, colorCls: 'text-success-600' }, - { label: 'Publish:', value: metrics?.content?.published || 0, colorCls: 'text-success-600' }, - ])} -
- - {/* Images */} -
-
-
-
- -
-
Images
-
-
-
{metrics?.images?.total || 0}
-
-
- {renderMetricRow([ - { label: 'Pending:', value: metrics?.images?.pending || 0, colorCls: 'text-info-600' }, - ])} -
+ {/* Pipeline ready indicator */} + {(pendingCounts.keywords > 0 || pendingCounts.images > 0) && ( + + Pipeline: {pendingCounts.keywords > 0 && `${pendingCounts.keywords} keywords`} + {pendingCounts.keywords > 0 && pendingCounts.images > 0 && ', '} + {pendingCounts.images > 0 && `${pendingCounts.images} pending images`} + + )}
- {/* Cost Estimation Card */} - {overviewStats ? ( - <> - {/* Attention Items Alert */} - {overviewStats.attention_items && ( - - )} + {/* Production Summary - now uses actual_counts */} + - {/* Statistics and Predictive Analysis */} -
- {overviewStats.run_statistics && ( - - )} - {overviewStats.predictive_analysis && ( - - )} -
- - ) : !loading && ( -
-

Loading automation statistics...

-
- )} - - {/* Enhanced Run History */} - {historyData && historyData.runs && ( -
-
-

Run History

-

- Click on any run to view detailed analysis -

-
- -
- )} - - {/* Fallback: Old Run History (if enhanced data not available) */} - {!historyData && activeSite && } + {/* Meaningful Run History - Full Width */} +
); diff --git a/frontend/src/pages/Automation/AutomationRunDetail.tsx b/frontend/src/pages/Automation/AutomationRunDetail.tsx index 1b5838f7..f9dca0ee 100644 --- a/frontend/src/pages/Automation/AutomationRunDetail.tsx +++ b/frontend/src/pages/Automation/AutomationRunDetail.tsx @@ -2,7 +2,7 @@ * Automation Run Detail Page * Comprehensive view of a single automation run */ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useSiteStore } from '../../store/siteStore'; import { automationService } from '../../services/automationService'; @@ -20,29 +20,74 @@ const AutomationRunDetail: React.FC = () => { const { runId } = useParams<{ runId: string }>(); const navigate = useNavigate(); const { activeSite } = useSiteStore(); - const toast = useToast(); + const { error: toastError } = useToast(); const [loading, setLoading] = useState(true); const [runDetail, setRunDetail] = useState(null); + const [error, setError] = useState(null); + const lastRequestKey = useRef(null); - useEffect(() => { - loadRunDetail(); - }, [runId, activeSite]); - - const loadRunDetail = async () => { - if (!activeSite || !runId) return; - + const decodeTitle = (value: string | undefined | null) => { + if (!value) return ''; try { - setLoading(true); - const data = await automationService.getRunDetail(activeSite.id, runId); - setRunDetail(data); - } catch (error: any) { - console.error('Failed to load run detail', error); - toast.error(error.message || 'Failed to load run detail'); - } finally { - setLoading(false); + return decodeURIComponent(value); + } catch { + return value; } }; + const getDisplayTitle = () => { + const run = runDetail?.run; + if (!run) return 'Automation Run'; + if (run.site_name) return run.site_name; + if (run.site_domain) return run.site_domain.replace('www.', ''); + + const decoded = decodeTitle(run.run_title); + if (decoded) return decoded; + if (run.run_number) return `Run #${run.run_number}`; + return 'Automation Run'; + }; + + useEffect(() => { + const loadRunDetail = async () => { + if (!runId) { + setError('Missing run id'); + setLoading(false); + return; + } + + if (!activeSite) { + setError('Please select a site to view automation run details.'); + setLoading(false); + return; + } + + const requestKey = `${activeSite.id}-${runId}`; + if (lastRequestKey.current === requestKey) { + return; + } + lastRequestKey.current = requestKey; + + try { + setLoading(true); + setError(null); + const data = await automationService.getRunDetail(activeSite.id, runId); + setRunDetail(data); + } catch (err: any) { + console.error('Failed to load run detail', err); + const message = err?.message === 'Internal server error' + ? 'Run detail is temporarily unavailable (server error). Please try again later.' + : err?.message || 'Failed to load run detail'; + setError(message); + toastError(message); + lastRequestKey.current = null; + } finally { + setLoading(false); + } + }; + + loadRunDetail(); + }, [runId, activeSite, toastError]); + if (!activeSite) { return (
@@ -59,6 +104,14 @@ const AutomationRunDetail: React.FC = () => { ); } + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!runDetail) { return (
@@ -67,26 +120,71 @@ const AutomationRunDetail: React.FC = () => { ); } + const displayTitle = getDisplayTitle(); + const breadcrumbLabel = runDetail.run?.run_number ? `Run #${runDetail.run.run_number}` : displayTitle; + const normalizedRun = runDetail.run ? { ...runDetail.run, run_title: displayTitle } : null; + const stageSummary = (runDetail.stages || []).reduce( + (acc, stage) => { + acc.itemsProcessed += stage.items_processed || 0; + acc.itemsCreated += stage.items_created || 0; + if (stage.stage_number === 4) acc.contentCreated += stage.items_created || 0; + if (stage.stage_number === 6) acc.imagesGenerated += stage.items_created || 0; + return acc; + }, + { itemsProcessed: 0, itemsCreated: 0, contentCreated: 0, imagesGenerated: 0 } + ); + + const derivedInsights = [] as RunDetailResponse['insights']; + if (normalizedRun) { + if ((normalizedRun.total_credits_used || 0) > 0 && stageSummary.itemsCreated === 0) { + derivedInsights.push({ + type: 'warning', + severity: 'warning', + message: 'Credits were spent but no outputs were recorded. Review stage errors and retry failed steps.', + }); + } + if (normalizedRun.status === 'running') { + derivedInsights.push({ + type: 'success', + severity: 'info', + message: `Run is currently active in stage ${normalizedRun.current_stage || 1}.`, + }); + } + } + + if ((runDetail.stages || []).some(stage => stage.status === 'failed')) { + const failedStage = runDetail.stages.find(stage => stage.status === 'failed'); + derivedInsights.push({ + type: 'error', + severity: 'error', + message: `Stage ${failedStage?.stage_number} failed. Review the stage details and error message for remediation.`, + }); + } + + const combinedInsights = runDetail.insights && runDetail.insights.length > 0 + ? [...runDetail.insights, ...derivedInsights] + : derivedInsights; + return ( <>
{/* Run Summary */} - {runDetail.run && } + {normalizedRun && } {/* Insights Panel */} - {runDetail.insights && runDetail.insights.length > 0 && ( - + {combinedInsights.length > 0 && ( + )} {/* Two Column Layout */} diff --git a/frontend/src/services/automationService.ts b/frontend/src/services/automationService.ts index 53f95c16..cfa0fb2e 100644 --- a/frontend/src/services/automationService.ts +++ b/frontend/src/services/automationService.ts @@ -344,4 +344,70 @@ export const automationService = { }> => { return fetchAPI(buildUrl('/eligibility/', { site_id: siteId })); }, + + /** + * Get trend data for credits usage visualization + */ + getTrendData: async (siteId: number, limit: number = 10): Promise<{ + trend_data: Array<{ + run_id: string; + run_number: number; + credits_used: number; + items_created: number; + date: string; + status: string; + }>; + summary: { + total_runs: number; + total_credits: number; + total_items: number; + avg_credits_per_run: number; + avg_credits_per_item: number; + }; + }> => { + return fetchAPI(buildUrl('/trend_data/', { site_id: siteId, limit })); + }, + + /** + * Get actual production statistics - what was really created + */ + getProductionStats: async (siteId: number): Promise<{ + totals: { + total_runs: number; + productive_runs: number; + total_credits: number; + clusters_created: number; + ideas_created: number; + tasks_created: number; + content_created: number; + prompts_created: number; + images_created: number; + approved_content: number; + }; + stage_efficiency: Array<{ + stage: number; + name: string; + total_input: number; + total_output: number; + total_credits: number; + runs_with_data: number; + }>; + meaningful_runs: Array<{ + run_id: string; + run_number: number; + status: string; + started_at: string; + duration_seconds: number; + total_credits: number; + stages: Array<{ + stage: number; + name: string; + input: number; + output: number; + credits: number; + }>; + }>; + }> => { + return fetchAPI(buildUrl('/production_stats/', { site_id: siteId })); + }, }; diff --git a/frontend/src/types/automation.ts b/frontend/src/types/automation.ts index 1bab7811..f94f601e 100644 --- a/frontend/src/types/automation.ts +++ b/frontend/src/types/automation.ts @@ -89,7 +89,9 @@ export interface EnhancedRunHistoryItem { run_id: string; run_number: number; run_title: string; - status: 'completed' | 'running' | 'paused' | 'failed' | 'cancelled'; + site_name?: string; + site_domain?: string; + status: 'completed' | 'running' | 'paused' | 'failed' | 'cancelled' | 'partial'; trigger_type: 'manual' | 'scheduled'; started_at: string; completed_at: string | null; @@ -149,7 +151,9 @@ export interface RunDetailInfo { run_id: string; run_number: number; run_title: string; - status: 'completed' | 'running' | 'paused' | 'failed' | 'cancelled'; + site_name?: string; + site_domain?: string; + status: 'completed' | 'running' | 'paused' | 'failed' | 'cancelled' | 'partial'; trigger_type: 'manual' | 'scheduled'; started_at: string; completed_at: string | null;