alot of othe mess fro autoamtion overview an ddetiaeld run apge sonly
This commit is contained in:
@@ -312,20 +312,43 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
def _calculate_historical_averages(self, site, completed_runs):
|
def _calculate_historical_averages(self, site, completed_runs):
|
||||||
"""Calculate historical averages from completed runs"""
|
"""Calculate historical averages from completed runs"""
|
||||||
if completed_runs.count() < 3:
|
run_count = completed_runs.count()
|
||||||
# Not enough data, return defaults
|
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 {
|
return {
|
||||||
'period_days': 30,
|
'period_days': 30,
|
||||||
'runs_analyzed': completed_runs.count(),
|
'runs_analyzed': run_count,
|
||||||
'avg_credits_stage_1': 0.2,
|
'avg_total_credits': 0,
|
||||||
'avg_credits_stage_2': 2.0,
|
'avg_duration_seconds': 0,
|
||||||
'avg_credits_stage_4': 5.0,
|
'avg_credits_per_item': 0,
|
||||||
'avg_credits_stage_5': 2.0,
|
'total_runs_analyzed': run_count,
|
||||||
'avg_credits_stage_6': 2.0,
|
'has_sufficient_data': False,
|
||||||
'avg_output_ratio_stage_1': 0.125,
|
'stages': [
|
||||||
'avg_output_ratio_stage_2': 8.7,
|
{
|
||||||
'avg_output_ratio_stage_5': 4.0,
|
'stage_number': stage_number,
|
||||||
'avg_output_ratio_stage_6': 1.0,
|
'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
|
# Calculate per-stage averages
|
||||||
@@ -340,6 +363,9 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
output_ratios_5 = []
|
output_ratios_5 = []
|
||||||
output_ratios_6 = []
|
output_ratios_6 = []
|
||||||
|
|
||||||
|
total_created_items = 0
|
||||||
|
total_credits_used = 0
|
||||||
|
|
||||||
for run in completed_runs[:10]: # Last 10 runs
|
for run in completed_runs[:10]: # Last 10 runs
|
||||||
if run.stage_1_result:
|
if run.stage_1_result:
|
||||||
processed = run.stage_1_result.get('keywords_processed', 0)
|
processed = run.stage_1_result.get('keywords_processed', 0)
|
||||||
@@ -349,6 +375,8 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
stage_1_credits.append(credits / processed)
|
stage_1_credits.append(credits / processed)
|
||||||
if created > 0 and processed > 0:
|
if created > 0 and processed > 0:
|
||||||
output_ratios_1.append(created / processed)
|
output_ratios_1.append(created / processed)
|
||||||
|
total_created_items += created
|
||||||
|
total_credits_used += credits
|
||||||
|
|
||||||
if run.stage_2_result:
|
if run.stage_2_result:
|
||||||
processed = run.stage_2_result.get('clusters_processed', 0)
|
processed = run.stage_2_result.get('clusters_processed', 0)
|
||||||
@@ -358,12 +386,16 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
stage_2_credits.append(credits / processed)
|
stage_2_credits.append(credits / processed)
|
||||||
if created > 0 and processed > 0:
|
if created > 0 and processed > 0:
|
||||||
output_ratios_2.append(created / processed)
|
output_ratios_2.append(created / processed)
|
||||||
|
total_created_items += created
|
||||||
|
total_credits_used += credits
|
||||||
|
|
||||||
if run.stage_4_result:
|
if run.stage_4_result:
|
||||||
processed = run.stage_4_result.get('tasks_processed', 0)
|
processed = run.stage_4_result.get('tasks_processed', 0)
|
||||||
credits = run.stage_4_result.get('credits_used', 0)
|
credits = run.stage_4_result.get('credits_used', 0)
|
||||||
if processed > 0:
|
if processed > 0:
|
||||||
stage_4_credits.append(credits / processed)
|
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:
|
if run.stage_5_result:
|
||||||
processed = run.stage_5_result.get('content_processed', 0)
|
processed = run.stage_5_result.get('content_processed', 0)
|
||||||
@@ -373,6 +405,8 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
stage_5_credits.append(credits / processed)
|
stage_5_credits.append(credits / processed)
|
||||||
if created > 0 and processed > 0:
|
if created > 0 and processed > 0:
|
||||||
output_ratios_5.append(created / processed)
|
output_ratios_5.append(created / processed)
|
||||||
|
total_created_items += created
|
||||||
|
total_credits_used += credits
|
||||||
|
|
||||||
if run.stage_6_result:
|
if run.stage_6_result:
|
||||||
processed = run.stage_6_result.get('images_processed', 0)
|
processed = run.stage_6_result.get('images_processed', 0)
|
||||||
@@ -382,22 +416,57 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
stage_6_credits.append(credits / processed)
|
stage_6_credits.append(credits / processed)
|
||||||
if created > 0 and processed > 0:
|
if created > 0 and processed > 0:
|
||||||
output_ratios_6.append(created / processed)
|
output_ratios_6.append(created / processed)
|
||||||
|
total_created_items += created
|
||||||
|
total_credits_used += credits
|
||||||
|
|
||||||
def avg(lst):
|
def avg(lst):
|
||||||
return sum(lst) / len(lst) if lst else 0
|
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 {
|
return {
|
||||||
'period_days': 30,
|
'period_days': 30,
|
||||||
'runs_analyzed': min(completed_runs.count(), 10),
|
'runs_analyzed': min(run_count, 10),
|
||||||
'avg_credits_stage_1': round(avg(stage_1_credits), 2),
|
'avg_total_credits': round(avg_total_credits, 1),
|
||||||
'avg_credits_stage_2': round(avg(stage_2_credits), 2),
|
'avg_duration_seconds': avg_duration_seconds,
|
||||||
'avg_credits_stage_4': round(avg(stage_4_credits), 2),
|
'avg_credits_per_item': round(avg_credits_per_item, 2),
|
||||||
'avg_credits_stage_5': round(avg(stage_5_credits), 2),
|
'total_runs_analyzed': min(run_count, 10),
|
||||||
'avg_credits_stage_6': round(avg(stage_6_credits), 2),
|
'has_sufficient_data': run_count >= 3,
|
||||||
'avg_output_ratio_stage_1': round(avg(output_ratios_1), 3),
|
'stages': [
|
||||||
'avg_output_ratio_stage_2': round(avg(output_ratios_2), 1),
|
{
|
||||||
'avg_output_ratio_stage_5': round(avg(output_ratios_5), 1),
|
'stage_number': stage_number,
|
||||||
'avg_output_ratio_stage_6': round(avg(output_ratios_6), 1),
|
'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):
|
def _calculate_predictive_analysis(self, site, historical_averages):
|
||||||
@@ -430,86 +499,62 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
return {
|
return {
|
||||||
'stages': [
|
'stages': [
|
||||||
{
|
{
|
||||||
'stage': 1,
|
'stage_number': 1,
|
||||||
'name': 'Keywords → Clusters',
|
'stage_name': 'Keywords → Clusters',
|
||||||
'pending_items': pending_keywords,
|
'pending_items': pending_keywords,
|
||||||
'avg_credits_per_item': historical_averages['avg_credits_stage_1'],
|
|
||||||
'estimated_credits': stage_1_credits,
|
'estimated_credits': stage_1_credits,
|
||||||
'avg_output_ratio': historical_averages['avg_output_ratio_stage_1'],
|
|
||||||
'estimated_output': expected_clusters,
|
'estimated_output': expected_clusters,
|
||||||
'output_type': 'clusters'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'stage': 2,
|
'stage_number': 2,
|
||||||
'name': 'Clusters → Ideas',
|
'stage_name': 'Clusters → Ideas',
|
||||||
'pending_items': pending_clusters,
|
'pending_items': pending_clusters,
|
||||||
'avg_credits_per_item': historical_averages['avg_credits_stage_2'],
|
|
||||||
'estimated_credits': stage_2_credits,
|
'estimated_credits': stage_2_credits,
|
||||||
'avg_output_ratio': historical_averages['avg_output_ratio_stage_2'],
|
|
||||||
'estimated_output': expected_ideas,
|
'estimated_output': expected_ideas,
|
||||||
'output_type': 'ideas'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'stage': 3,
|
'stage_number': 3,
|
||||||
'name': 'Ideas → Tasks',
|
'stage_name': 'Ideas → Tasks',
|
||||||
'pending_items': pending_ideas,
|
'pending_items': pending_ideas,
|
||||||
'avg_credits_per_item': 0,
|
|
||||||
'estimated_credits': 0,
|
'estimated_credits': 0,
|
||||||
'avg_output_ratio': 1.0,
|
|
||||||
'estimated_output': pending_ideas,
|
'estimated_output': pending_ideas,
|
||||||
'output_type': 'tasks'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'stage': 4,
|
'stage_number': 4,
|
||||||
'name': 'Tasks → Content',
|
'stage_name': 'Tasks → Content',
|
||||||
'pending_items': pending_tasks,
|
'pending_items': pending_tasks,
|
||||||
'avg_credits_per_item': historical_averages['avg_credits_stage_4'],
|
|
||||||
'estimated_credits': stage_4_credits,
|
'estimated_credits': stage_4_credits,
|
||||||
'avg_output_ratio': 1.0,
|
|
||||||
'estimated_output': pending_tasks,
|
'estimated_output': pending_tasks,
|
||||||
'output_type': 'content'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'stage': 5,
|
'stage_number': 5,
|
||||||
'name': 'Content → Image Prompts',
|
'stage_name': 'Content → Image Prompts',
|
||||||
'pending_items': pending_content,
|
'pending_items': pending_content,
|
||||||
'avg_credits_per_item': historical_averages['avg_credits_stage_5'],
|
|
||||||
'estimated_credits': stage_5_credits,
|
'estimated_credits': stage_5_credits,
|
||||||
'avg_output_ratio': historical_averages['avg_output_ratio_stage_5'],
|
|
||||||
'estimated_output': expected_prompts,
|
'estimated_output': expected_prompts,
|
||||||
'output_type': 'prompts'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'stage': 6,
|
'stage_number': 6,
|
||||||
'name': 'Image Prompts → Images',
|
'stage_name': 'Image Prompts → Images',
|
||||||
'pending_items': pending_images,
|
'pending_items': pending_images,
|
||||||
'avg_credits_per_item': historical_averages['avg_credits_stage_6'],
|
|
||||||
'estimated_credits': stage_6_credits,
|
'estimated_credits': stage_6_credits,
|
||||||
'avg_output_ratio': historical_averages['avg_output_ratio_stage_6'],
|
|
||||||
'estimated_output': expected_images,
|
'estimated_output': expected_images,
|
||||||
'output_type': 'images'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'stage': 7,
|
'stage_number': 7,
|
||||||
'name': 'Review → Approved',
|
'stage_name': 'Review → Approved',
|
||||||
'pending_items': pending_review,
|
'pending_items': pending_review,
|
||||||
'avg_credits_per_item': 0,
|
|
||||||
'estimated_credits': 0,
|
'estimated_credits': 0,
|
||||||
'avg_output_ratio': 1.0,
|
|
||||||
'estimated_output': pending_review,
|
'estimated_output': pending_review,
|
||||||
'output_type': 'approved'
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'total_estimated_credits': total_estimated,
|
'totals': {
|
||||||
'recommended_buffer': recommended_buffer,
|
'total_pending_items': pending_keywords + pending_clusters + pending_ideas + pending_tasks + pending_content + pending_images + pending_review,
|
||||||
'current_balance': site.account.credits,
|
'total_estimated_credits': total_estimated,
|
||||||
'is_sufficient': site.account.credits >= recommended_buffer,
|
'total_estimated_output': expected_clusters + expected_ideas + pending_tasks + expected_images + pending_review,
|
||||||
'expected_outputs': {
|
'recommended_buffer_credits': recommended_buffer,
|
||||||
'clusters': expected_clusters,
|
},
|
||||||
'ideas': expected_ideas,
|
'confidence': 'high' if historical_averages.get('has_sufficient_data') else 'low',
|
||||||
'content': pending_tasks,
|
|
||||||
'images': expected_images,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_attention_items(self, site):
|
def _get_attention_items(self, site):
|
||||||
@@ -551,7 +596,7 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
failed_runs = recent_runs.filter(status='failed')
|
failed_runs = recent_runs.filter(status='failed')
|
||||||
|
|
||||||
# Calculate averages from completed runs
|
# 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')
|
duration=F('completed_at') - F('started_at')
|
||||||
).aggregate(avg=Avg('duration'))['avg']
|
).aggregate(avg=Avg('duration'))['avg']
|
||||||
|
|
||||||
@@ -578,12 +623,11 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
'total_runs': all_runs.count(),
|
'total_runs': all_runs.count(),
|
||||||
'completed_runs': completed_runs.count(),
|
'completed_runs': completed_runs.count(),
|
||||||
'failed_runs': failed_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,
|
'running_runs': all_runs.filter(status__in=['running', 'paused']).count(),
|
||||||
'avg_duration_seconds': int(avg_duration.total_seconds()) if avg_duration else 0,
|
'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),
|
'avg_credits_per_run': round(avg_credits, 1),
|
||||||
'runs_this_week': this_week_runs.count(),
|
'avg_duration_last_7_days_seconds': int(avg_duration.total_seconds()) if avg_duration else 0,
|
||||||
'runs_last_week': last_week_runs.count(),
|
|
||||||
'credits_trend': credits_trend,
|
|
||||||
},
|
},
|
||||||
'predictive_analysis': predictive_analysis,
|
'predictive_analysis': predictive_analysis,
|
||||||
'attention_items': attention_items,
|
'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.'
|
'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'])
|
@extend_schema(tags=['Automation'])
|
||||||
@action(detail=False, methods=['get'], url_path='current_processing')
|
@action(detail=False, methods=['get'], url_path='current_processing')
|
||||||
def current_processing(self, request):
|
def current_processing(self, request):
|
||||||
|
|||||||
@@ -37,11 +37,19 @@ const CreditBreakdownChart: React.FC<CreditBreakdownChartProps> = ({ stages }) =
|
|||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
},
|
},
|
||||||
labels: chartLabels,
|
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: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: {
|
labels: {
|
||||||
colors: '#9ca3af',
|
colors: 'var(--color-gray-400)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plotOptions: {
|
plotOptions: {
|
||||||
@@ -53,20 +61,20 @@ const CreditBreakdownChart: React.FC<CreditBreakdownChartProps> = ({ stages }) =
|
|||||||
name: {
|
name: {
|
||||||
show: true,
|
show: true,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#9ca3af',
|
color: 'var(--color-gray-400)',
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
show: true,
|
show: true,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#111827',
|
color: 'var(--color-gray-900)',
|
||||||
formatter: (val: string) => `${parseFloat(val).toFixed(0)}`,
|
formatter: (val: string) => `${parseFloat(val).toFixed(0)}`,
|
||||||
},
|
},
|
||||||
total: {
|
total: {
|
||||||
show: true,
|
show: true,
|
||||||
label: 'Total Credits',
|
label: 'Total Credits',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#9ca3af',
|
color: 'var(--color-gray-400)',
|
||||||
formatter: () => `${chartData.reduce((a, b) => a + b, 0)}`,
|
formatter: () => `${chartData.reduce((a, b) => a + b, 0)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,74 @@ const EnhancedRunHistory: React.FC<EnhancedRunHistoryProps> = ({
|
|||||||
totalPages = 1,
|
totalPages = 1,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
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 getStatusBadge = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
@@ -31,6 +99,7 @@ const EnhancedRunHistory: React.FC<EnhancedRunHistoryProps> = ({
|
|||||||
paused: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400',
|
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',
|
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',
|
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';
|
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<EnhancedRunHistoryProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl overflow-hidden">
|
<div className="bg-white dark:bg-gray-900 rounded-xl overflow-hidden">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">Filter runs</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Showing {filteredRuns.length} of {runs.length} runs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusFilters.map(option => {
|
||||||
|
const isActive = statusFilter === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setStatusFilter(option.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors focus:outline-none focus:ring-2 focus:ring-brand-200 dark:focus:ring-brand-800
|
||||||
|
${isActive
|
||||||
|
? 'bg-brand-50 text-brand-700 border-brand-200 dark:bg-brand-900/30 dark:text-brand-200'
|
||||||
|
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-200 hover:text-brand-700 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-brand-700'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||||
@@ -123,7 +233,14 @@ const EnhancedRunHistory: React.FC<EnhancedRunHistoryProps> = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{runs.map((run) => (
|
{filteredRuns.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-6 text-center text-sm text-gray-600 dark:text-gray-400" colSpan={7}>
|
||||||
|
No runs match this filter.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filteredRuns.map((run) => (
|
||||||
<tr
|
<tr
|
||||||
key={run.run_id}
|
key={run.run_id}
|
||||||
onClick={() => navigate(`/automation/runs/${run.run_id}`)}
|
onClick={() => navigate(`/automation/runs/${run.run_id}`)}
|
||||||
@@ -131,20 +248,25 @@ const EnhancedRunHistory: React.FC<EnhancedRunHistoryProps> = ({
|
|||||||
>
|
>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<div className="text-sm font-medium text-brand-600 dark:text-brand-400 hover:underline">
|
<div className="text-sm font-medium text-brand-600 dark:text-brand-400 hover:underline">
|
||||||
{run.run_title}
|
{getDisplayTitle(run)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
{run.trigger_type}
|
{run.trigger_type}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<span
|
{(() => {
|
||||||
className={`inline-flex px-2 py-1 rounded-full text-xs font-semibold ${getStatusBadge(
|
const derivedStatus = getDerivedStatus(run);
|
||||||
run.status
|
return (
|
||||||
)}`}
|
<span
|
||||||
>
|
className={`inline-flex px-2 py-1 rounded-full text-xs font-semibold ${getStatusBadge(
|
||||||
{run.status}
|
derivedStatus
|
||||||
</span>
|
)}`}
|
||||||
|
>
|
||||||
|
{derivedStatus}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -168,11 +290,15 @@ const EnhancedRunHistory: React.FC<EnhancedRunHistoryProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<div className="text-sm text-gray-900 dark:text-white">
|
<div className={`text-sm ${
|
||||||
{run.summary?.items_processed || 0} → {run.summary?.items_created || 0}
|
(run.total_credits_used || 0) > 0 && (run.summary?.items_created || 0) === 0
|
||||||
|
? 'text-warning-700 dark:text-warning-300'
|
||||||
|
: 'text-gray-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{formatResultHeadline(run)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{run.summary?.content_created || 0} content, {run.summary?.images_generated || 0} images
|
{formatResultDetails(run)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
|
|||||||
@@ -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<number, { input: string; output: string }> = {
|
||||||
|
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<number, string> = {
|
||||||
|
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<MeaningfulRunHistoryProps> = ({
|
||||||
|
runs,
|
||||||
|
loading,
|
||||||
|
maxRuns = 5,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Run History
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
No runs with output yet. Start an automation run to generate content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Run History
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Recent runs with output
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{meaningfulRuns.map((run, index) => {
|
||||||
|
// Get only stages that produced output
|
||||||
|
const productiveStages = run.stages.filter(s => s.output > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={run.run_id}
|
||||||
|
onClick={() => 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 */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Run #{run.run_number}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium capitalize ${getStatusColor(run.status)}`}>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-brand-500 text-white">
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{formatDate(run.started_at)}</span>
|
||||||
|
<span>{formatDuration(run.duration_seconds)}</span>
|
||||||
|
<span className="font-medium text-brand-600 dark:text-brand-400">
|
||||||
|
{run.total_credits} cr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stage Outputs - Only show stages with output */}
|
||||||
|
{productiveStages.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{productiveStages.map(stage => (
|
||||||
|
<div
|
||||||
|
key={stage.stage}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${STAGE_COLORS[stage.stage]}`}></div>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{stage.input} {STAGE_LABELS[stage.stage]?.input}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">→</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||||
|
{stage.output} {STAGE_LABELS[stage.stage]?.output}
|
||||||
|
</span>
|
||||||
|
{stage.credits > 0 && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
({stage.credits}cr)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : run.total_credits > 0 ? (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||||
|
Processing run - no new items created
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Total from {meaningfulRuns.length} runs:
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4 font-medium">
|
||||||
|
<span className="text-brand-600 dark:text-brand-400">
|
||||||
|
{meaningfulRuns.reduce((sum, r) => sum + r.total_credits, 0)} credits
|
||||||
|
</span>
|
||||||
|
<span className="text-success-600 dark:text-success-400">
|
||||||
|
{meaningfulRuns.reduce((sum, r) =>
|
||||||
|
sum + r.stages.reduce((s, st) => s + st.output, 0), 0
|
||||||
|
)} items
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MeaningfulRunHistory;
|
||||||
@@ -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<PipelineOverviewCardProps> = ({ stages, loading }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stages || stages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Pipeline overview unavailable.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Pipeline Overview</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Pending items per stage with status distribution
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<div
|
||||||
|
key={stage.number}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Stage {stage.number}: {stage.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{stage.type} stage
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{stage.pending}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Pending</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stage.counts && (
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{Object.entries(stage.counts)
|
||||||
|
.filter(([status]) => status !== 'total')
|
||||||
|
.map(([status, count]) => (
|
||||||
|
<div key={status} className="flex items-center gap-1">
|
||||||
|
<span className="capitalize">{status.replace('_', ' ')}:</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{typeof stage.total === 'number' && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">{stage.total}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PipelineOverviewCard;
|
||||||
@@ -2,17 +2,18 @@
|
|||||||
* Predictive Cost Analysis Component
|
* Predictive Cost Analysis Component
|
||||||
* Shows estimated credits and outputs for next automation run
|
* Shows estimated credits and outputs for next automation run
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { PredictiveAnalysis } from '../../../types/automation';
|
import { HistoricalAverages, PredictiveAnalysis } from '../../../types/automation';
|
||||||
import ReactApexChart from 'react-apexcharts';
|
import ReactApexChart from 'react-apexcharts';
|
||||||
import { ApexOptions } from 'apexcharts';
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
|
||||||
interface PredictiveCostAnalysisProps {
|
interface PredictiveCostAnalysisProps {
|
||||||
analysis: PredictiveAnalysis;
|
analysis: PredictiveAnalysis;
|
||||||
|
historicalAverages?: HistoricalAverages;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ analysis, loading }) => {
|
const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ analysis, historicalAverages, loading }) => {
|
||||||
if (loading || !analysis) {
|
if (loading || !analysis) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
@@ -26,26 +27,77 @@ const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ 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 = {
|
const confidenceBadges = {
|
||||||
high: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400',
|
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',
|
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',
|
low: 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepare data for donut chart
|
const totals = analysis.totals || {
|
||||||
const chartData = (analysis.stages || [])
|
total_pending_items: 0,
|
||||||
.filter(s => (s.estimated_credits || 0) > 0)
|
total_estimated_credits: 0,
|
||||||
.map(s => s.estimated_credits || 0);
|
total_estimated_output: 0,
|
||||||
|
recommended_buffer_credits: 0,
|
||||||
const chartLabels = (analysis.stages || [])
|
};
|
||||||
.filter(s => (s.estimated_credits || 0) > 0)
|
|
||||||
.map(s => s.stage_name || 'Unknown');
|
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 = {
|
const chartOptions: ApexOptions = {
|
||||||
chart: {
|
chart: {
|
||||||
@@ -53,11 +105,19 @@ const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ analysi
|
|||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
},
|
},
|
||||||
labels: chartLabels,
|
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: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: {
|
labels: {
|
||||||
colors: '#9ca3af',
|
colors: 'var(--color-gray-400)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plotOptions: {
|
plotOptions: {
|
||||||
@@ -69,21 +129,21 @@ const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ analysi
|
|||||||
name: {
|
name: {
|
||||||
show: true,
|
show: true,
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#9ca3af',
|
color: 'var(--color-gray-400)',
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
show: true,
|
show: true,
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#111827',
|
color: 'var(--color-gray-900)',
|
||||||
formatter: (val: string) => `${parseFloat(val).toFixed(0)} cr`,
|
formatter: (val: string) => `${parseFloat(val).toFixed(0)} cr`,
|
||||||
},
|
},
|
||||||
total: {
|
total: {
|
||||||
show: true,
|
show: true,
|
||||||
label: 'Est. Total',
|
label: 'Est. Total',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#9ca3af',
|
color: 'var(--color-gray-400)',
|
||||||
formatter: () => `${analysis.totals?.total_estimated_credits || 0} cr`,
|
formatter: () => `${totalCredits || 0} cr`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -111,70 +171,107 @@ const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ analysi
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{!hasData && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div className="text-center py-10 text-gray-600 dark:text-gray-400">
|
||||||
<div className="text-center">
|
No predictive data available yet.
|
||||||
<div className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
|
||||||
{analysis.totals?.total_pending_items || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Pending Items</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
|
||||||
{analysis.totals?.total_estimated_output || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Est. Output</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-warning-600 dark:text-warning-400">
|
|
||||||
{analysis.totals?.total_estimated_credits || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Est. Credits</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{analysis.totals?.recommended_buffer_credits || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">+20% Buffer</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Donut Chart */}
|
|
||||||
{chartData.length > 0 && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<ReactApexChart
|
|
||||||
options={chartOptions}
|
|
||||||
series={chartData}
|
|
||||||
type="donut"
|
|
||||||
height={300}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stage Breakdown */}
|
{hasData && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Stage Breakdown</h4>
|
{totalPending === 0 && (
|
||||||
{(analysis.stages || []).map((stage) => (
|
<div className="mb-4 rounded-lg border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/40 px-4 py-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<div
|
No pending items detected. Automation would complete with minimal or no work.
|
||||||
key={stage.stage_number || 0}
|
|
||||||
className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{stage.stage_name || 'Unknown Stage'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{stage.pending_items || 0} items → ~{stage.estimated_output || 0} output
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
)}
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
~{stage.estimated_credits || 0} cr
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{totalPending}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Pending Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
|
{totalOutput}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Est. Output</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-warning-600 dark:text-warning-400">
|
||||||
|
{totalCredits}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Est. Credits</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{bufferCredits}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">+20% Buffer</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Est. credit per item: {perItemCredit.toFixed(2)} cr</span>
|
||||||
|
<span>Est. output ratio: {outputRatio.toFixed(2)}x</span>
|
||||||
|
<span>Based on {historicalAverages?.total_runs_analyzed || 0} completed runs</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(analysis.confidence === 'low' || analysis.confidence === 'medium') && (
|
||||||
|
<div className="mb-4 rounded-lg border border-warning-200 dark:border-warning-800 bg-warning-50 dark:bg-warning-900/20 px-4 py-2 text-xs text-warning-800 dark:text-warning-200">
|
||||||
|
Estimates may fluctuate with limited history. Complete more runs to improve prediction accuracy.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<ReactApexChart options={chartOptions} series={chartData} type="donut" height={300} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Stage Breakdown</h4>
|
||||||
|
{stageCalculations.filter(stage => stage.pending > 0 || stage.credits > 0 || stage.output > 0).length === 0 && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">No stage estimates available.</div>
|
||||||
|
)}
|
||||||
|
{stageCalculations
|
||||||
|
.filter(stage => stage.pending > 0 || stage.credits > 0 || stage.output > 0)
|
||||||
|
.map((stage) => (
|
||||||
|
<div
|
||||||
|
key={stage.key}
|
||||||
|
className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
<span>{stage.name}</span>
|
||||||
|
{highestCostStage?.stageNumber === stage.stageNumber && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-300">
|
||||||
|
Highest cost
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lowestYieldStage?.stageNumber === stage.stageNumber && stage.ratio > 0 && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-300">
|
||||||
|
Low yield
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{stage.pending} pending → ~{stage.output} output • {stage.perItem.toFixed(2)} cr/item
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
~{stage.credits} cr
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{stage.ratio > 0 ? `${stage.ratio.toFixed(2)}x output` : 'No output forecast'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<ProductionSummaryProps> = ({
|
||||||
|
totals,
|
||||||
|
actual_counts,
|
||||||
|
efficiency,
|
||||||
|
loading,
|
||||||
|
}) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<div key={i} className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Site Content Inventory
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Current totals across {totals.total_runs} automation runs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{totals.total_credits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
total credits used
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Inventory */}
|
||||||
|
<div className="grid grid-cols-5 gap-3 mb-5">
|
||||||
|
{inventoryStats.map((stat, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`rounded-lg p-3 ${stat.bgColor}`}
|
||||||
|
>
|
||||||
|
<div className={`text-2xl font-bold ${stat.color}`}>
|
||||||
|
{stat.value.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Automation Stats */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Runs with output: </span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{totals.runs_with_output} of {totals.total_runs}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Avg efficiency: </span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{creditsPerItem} cr/item
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Auto-approved: </span>
|
||||||
|
<span className="font-semibold text-success-600 dark:text-success-400">
|
||||||
|
{totals.approved_via_automation?.toLocaleString() || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionSummary;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RunStatistics } from '../../../types/automation';
|
import { RunStatistics } from '../../../types/automation';
|
||||||
import { BoltIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../../../icons';
|
import { BoltIcon, CheckCircleIcon, ClockIcon } from '../../../icons';
|
||||||
|
|
||||||
interface RunStatisticsSummaryProps {
|
interface RunStatisticsSummaryProps {
|
||||||
statistics: RunStatistics;
|
statistics: RunStatistics;
|
||||||
@@ -32,31 +32,42 @@ const RunStatisticsSummary: React.FC<RunStatisticsSummaryProps> = ({ statistics,
|
|||||||
return `${seconds}s`;
|
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 = [
|
const stats = [
|
||||||
{
|
{
|
||||||
label: 'Total Runs',
|
label: 'Total Runs',
|
||||||
value: statistics.total_runs || 0,
|
value: totalRuns,
|
||||||
|
helper: runningRuns > 0 ? `${runningRuns} running now` : 'No active runs',
|
||||||
icon: BoltIcon,
|
icon: BoltIcon,
|
||||||
color: 'brand' as const,
|
color: 'brand' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Completed',
|
label: 'Success Rate',
|
||||||
value: statistics.completed_runs || 0,
|
value: `${successRate.toFixed(1)}%`,
|
||||||
|
helper: `${completedRuns} completed`,
|
||||||
icon: CheckCircleIcon,
|
icon: CheckCircleIcon,
|
||||||
color: 'success' as const,
|
color: 'success' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Failed',
|
label: 'Avg Duration (7d)',
|
||||||
value: statistics.failed_runs || 0,
|
value: formatDuration(statistics.avg_duration_last_7_days_seconds || 0),
|
||||||
icon: XCircleIcon,
|
helper: failureRate > 0 ? `${failureRate.toFixed(1)}% failed` : 'No recent failures',
|
||||||
color: 'error' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Running',
|
|
||||||
value: statistics.running_runs || 0,
|
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
color: 'warning' as const,
|
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 (
|
return (
|
||||||
@@ -81,6 +92,7 @@ const RunStatisticsSummary: React.FC<RunStatisticsSummaryProps> = ({ statistics,
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</div>
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">{stat.label}</div>
|
<div className="text-sm text-gray-500 dark:text-gray-400">{stat.label}</div>
|
||||||
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">{stat.helper}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import { CheckCircleIcon, XCircleIcon, ClockIcon, BoltIcon } from '../../../icon
|
|||||||
|
|
||||||
interface RunSummaryCardProps {
|
interface RunSummaryCardProps {
|
||||||
run: RunDetailInfo;
|
run: RunDetailInfo;
|
||||||
|
summary?: {
|
||||||
|
itemsProcessed: number;
|
||||||
|
itemsCreated: number;
|
||||||
|
contentCreated: number;
|
||||||
|
imagesGenerated: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ run }) => {
|
const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ run, summary }) => {
|
||||||
if (!run) {
|
if (!run) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
@@ -20,14 +26,35 @@ const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ 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 = () => {
|
const getStatusIcon = () => {
|
||||||
switch (run.status) {
|
switch (derivedStatus) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return <CheckCircleIcon className="size-6 text-success-600 dark:text-success-400" />;
|
return <CheckCircleIcon className="size-6 text-success-600 dark:text-success-400" />;
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return <XCircleIcon className="size-6 text-error-600 dark:text-error-400" />;
|
return <XCircleIcon className="size-6 text-error-600 dark:text-error-400" />;
|
||||||
case 'running':
|
case 'running':
|
||||||
return <ClockIcon className="size-6 text-brand-600 dark:text-brand-400" />;
|
return <ClockIcon className="size-6 text-brand-600 dark:text-brand-400" />;
|
||||||
|
case 'partial':
|
||||||
|
return <BoltIcon className="size-6 text-warning-600 dark:text-warning-400" />;
|
||||||
default:
|
default:
|
||||||
return <BoltIcon className="size-6 text-gray-600 dark:text-gray-400" />;
|
return <BoltIcon className="size-6 text-gray-600 dark:text-gray-400" />;
|
||||||
}
|
}
|
||||||
@@ -40,11 +67,14 @@ const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ run }) => {
|
|||||||
paused: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400',
|
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',
|
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',
|
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 (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||||
@@ -53,9 +83,12 @@ const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ run }) => {
|
|||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{getDisplayTitle()}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadge()}`}>
|
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadge()}`}>
|
||||||
{(run.status || 'unknown').toUpperCase()}
|
{(derivedStatus || 'unknown').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
|
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||||
{run.trigger_type || 'manual'} trigger
|
{run.trigger_type || 'manual'} trigger
|
||||||
@@ -88,6 +121,40 @@ const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ run }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mt-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Outputs Created</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{totalOutputs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Content Created</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{summary?.contentCreated || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Images Generated</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{summary?.imagesGenerated || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Credits per Output</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{creditsPerOutput > 0 ? creditsPerOutput.toFixed(2) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Run ID: <span className="text-gray-700 dark:text-gray-300">{run.run_id}</span>
|
||||||
|
{run.completed_at && (
|
||||||
|
<span className="ml-3">Completed: {formatDateTime(run.completed_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ const StageAccordion: React.FC<StageAccordionProps> = ({ stages, initialSnapshot
|
|||||||
return 'text-success-600 dark:text-success-400';
|
return 'text-success-600 dark:text-success-400';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getInitialQueue = (stageNumber: number) => {
|
||||||
|
const mapping: Record<number, number | undefined> = {
|
||||||
|
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 (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl overflow-hidden">
|
<div className="bg-white dark:bg-gray-900 rounded-xl overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
@@ -129,6 +142,39 @@ const StageAccordion: React.FC<StageAccordionProps> = ({ stages, initialSnapshot
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Initial Queue</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{getInitialQueue(stage.stage_number)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Credits / Item</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{(stage.items_processed || 0) > 0
|
||||||
|
? ((stage.credits_used || 0) / (stage.items_processed || 1)).toFixed(2)
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Output Ratio</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{(stage.items_processed || 0) > 0
|
||||||
|
? ((stage.items_created || 0) / (stage.items_processed || 1)).toFixed(2)
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Items / Minute</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{(stage.duration_seconds || 0) > 0
|
||||||
|
? (((stage.items_processed || 0) / (stage.duration_seconds || 1)) * 60).toFixed(1)
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Historical Comparison */}
|
{/* Historical Comparison */}
|
||||||
{stage.comparison && (stage.comparison.historical_avg_credits || 0) > 0 && (
|
{stage.comparison && (stage.comparison.historical_avg_credits || 0) > 0 && (
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
|||||||
@@ -1,150 +1,139 @@
|
|||||||
/**
|
/**
|
||||||
* Automation Overview Page
|
* 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 React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import { automationService } from '../../services/automationService';
|
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 PageMeta from '../../components/common/PageMeta';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ComponentCard from '../../components/common/ComponentCard';
|
import MeaningfulRunHistory from '../../components/Automation/DetailView/MeaningfulRunHistory';
|
||||||
import RunStatisticsSummary from '../../components/Automation/DetailView/RunStatisticsSummary';
|
import ProductionSummary from '../../components/Automation/DetailView/ProductionSummary';
|
||||||
import PredictiveCostAnalysis from '../../components/Automation/DetailView/PredictiveCostAnalysis';
|
|
||||||
import AttentionItemsAlert from '../../components/Automation/DetailView/AttentionItemsAlert';
|
|
||||||
import EnhancedRunHistory from '../../components/Automation/DetailView/EnhancedRunHistory';
|
|
||||||
import {
|
import {
|
||||||
ListIcon,
|
BoltIcon,
|
||||||
GroupIcon,
|
ClockIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FileIcon,
|
PaperPlaneIcon,
|
||||||
BoltIcon,
|
|
||||||
} from '../../icons';
|
} 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 AutomationOverview: React.FC = () => {
|
||||||
const { activeSite } = useSiteStore();
|
const { activeSite } = useSiteStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [metrics, setMetrics] = useState<any>(null);
|
const [productionStats, setProductionStats] = useState<ProductionStats | null>(null);
|
||||||
const [overviewStats, setOverviewStats] = useState<OverviewStatsResponse | null>(null);
|
const [hasRunning, setHasRunning] = useState(false);
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [pendingCounts, setPendingCounts] = useState({
|
||||||
const [historyData, setHistoryData] = useState<any>(null);
|
keywords: 0,
|
||||||
|
content: 0,
|
||||||
|
images: 0,
|
||||||
|
review: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Load metrics for the 5 metric cards
|
const loadData = async () => {
|
||||||
const loadMetrics = async () => {
|
|
||||||
if (!activeSite) return;
|
if (!activeSite) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [
|
const [stats, currentRun, pipeline] = await Promise.all([
|
||||||
keywordsTotalRes, keywordsNewRes, keywordsMappedRes,
|
automationService.getProductionStats(activeSite.id),
|
||||||
clustersTotalRes, clustersNewRes, clustersMappedRes,
|
automationService.getCurrentRun(activeSite.id),
|
||||||
ideasTotalRes, ideasNewRes, ideasQueuedRes, ideasCompletedRes,
|
automationService.getPipelineOverview(activeSite.id),
|
||||||
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' }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setMetrics({
|
setProductionStats(stats);
|
||||||
keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 },
|
setHasRunning(!!currentRun.run && (currentRun.run.status === 'running' || currentRun.run.status === 'paused'));
|
||||||
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 },
|
// Extract pending counts from pipeline
|
||||||
tasks: { total: tasksTotalRes.count || 0 },
|
if (pipeline.stages) {
|
||||||
content: {
|
const stage1 = pipeline.stages.find((s: any) => s.number === 1);
|
||||||
total: contentTotalRes.count || 0,
|
const stage5 = pipeline.stages.find((s: any) => s.number === 5);
|
||||||
draft: contentDraftRes.count || 0,
|
const stage6 = pipeline.stages.find((s: any) => s.number === 6);
|
||||||
review: contentReviewRes.count || 0,
|
const stage7 = pipeline.stages.find((s: any) => s.number === 7);
|
||||||
published: contentPublishedRes.count || 0,
|
setPendingCounts({
|
||||||
not_published: contentNotPublishedRes.count || 0,
|
keywords: stage1?.pending || 0,
|
||||||
scheduled: contentScheduledRes.count || 0,
|
content: stage5?.pending || 0,
|
||||||
},
|
images: stage6?.pending || 0,
|
||||||
images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 },
|
review: stage7?.pending || 0,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to fetch metrics for automation overview', e);
|
console.error('Failed to load production stats', e);
|
||||||
}
|
} finally {
|
||||||
};
|
setLoading(false);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await Promise.all([loadMetrics(), loadOverviewStats(), loadEnhancedHistory(historyPage)]);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
}, [activeSite, historyPage]);
|
}, [activeSite]);
|
||||||
|
|
||||||
// Helper to render metric rows
|
const handleStartRun = async () => {
|
||||||
const renderMetricRow = (items: Array<{ label: string; value: number; colorCls: string }>) => {
|
if (!activeSite) return;
|
||||||
return (
|
|
||||||
<div className="flex justify-between text-xs mt-2">
|
try {
|
||||||
{items.map((item, idx) => (
|
await automationService.runNow(activeSite.id);
|
||||||
<div key={idx} className="flex items-baseline gap-1">
|
toast.success('Automation started');
|
||||||
<span className="text-gray-500 dark:text-gray-400">{item.label}</span>
|
navigate('/automation');
|
||||||
<span className={`font-semibold ${item.colorCls}`}>{item.value}</span>
|
} catch (e: any) {
|
||||||
</div>
|
toast.error(e?.message || 'Failed to start automation');
|
||||||
))}
|
}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!activeSite) {
|
if (!activeSite) {
|
||||||
@@ -158,166 +147,120 @@ const AutomationOverview: React.FC = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="text-gray-500 dark:text-gray-400">Loading automation overview...</div>
|
<div className="text-gray-500 dark:text-gray-400">Loading automation data...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="Automation Overview" description="Comprehensive automation dashboard" />
|
<PageMeta title="Automation Overview" description="Production dashboard" />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Automation Overview"
|
title="Automation Overview"
|
||||||
breadcrumb="Automation / Overview"
|
breadcrumb="Automation / Overview"
|
||||||
description="Comprehensive automation dashboard with metrics, cost estimation, and run history"
|
description="Your content production metrics and history"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Metrics Summary Cards */}
|
{/* Quick Actions Row - Compact */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="flex items-center gap-3">
|
||||||
{/* Keywords */}
|
<button
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
onClick={hasRunning ? () => navigate('/automation') : handleStartRun}
|
||||||
<div className="flex items-center justify-between mb-3">
|
className={`
|
||||||
<div className="flex items-center gap-2">
|
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all
|
||||||
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
${hasRunning
|
||||||
<ListIcon className="size-4 text-brand-600 dark:text-brand-400" />
|
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
|
||||||
</div>
|
: 'bg-brand-600 text-white hover:bg-brand-700'
|
||||||
<div className="text-base font-bold text-gray-900 dark:text-white">Keywords</div>
|
}
|
||||||
</div>
|
`}
|
||||||
<div className="text-right">
|
>
|
||||||
<div className="text-3xl font-bold text-brand-600">{metrics?.keywords?.total || 0}</div>
|
<BoltIcon className="w-4 h-4" />
|
||||||
</div>
|
{hasRunning ? 'View Running' : 'Start Run'}
|
||||||
</div>
|
</button>
|
||||||
{renderMetricRow([
|
|
||||||
{ label: 'New:', value: metrics?.keywords?.new || 0, colorCls: 'text-brand-600' },
|
<button
|
||||||
{ label: 'Mapped:', value: metrics?.keywords?.mapped || 0, colorCls: 'text-brand-600' },
|
onClick={() => navigate('/automation')}
|
||||||
])}
|
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
|
||||||
</div>
|
>
|
||||||
|
<ClockIcon className="w-4 h-4" />
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/writer/content')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
|
||||||
|
>
|
||||||
|
<FileTextIcon className="w-4 h-4" />
|
||||||
|
Content
|
||||||
|
{pendingCounts.content > 0 && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-warning-500 text-white">
|
||||||
|
{pendingCounts.content}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/writer/content?status=review')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
|
||||||
|
>
|
||||||
|
<PaperPlaneIcon className="w-4 h-4" />
|
||||||
|
Review
|
||||||
|
{pendingCounts.review > 0 && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-success-500 text-white">
|
||||||
|
{pendingCounts.review}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clusters */}
|
{/* Pipeline ready indicator */}
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
{(pendingCounts.keywords > 0 || pendingCounts.images > 0) && (
|
||||||
<div className="flex items-center justify-between mb-3">
|
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex items-center gap-2">
|
Pipeline: {pendingCounts.keywords > 0 && `${pendingCounts.keywords} keywords`}
|
||||||
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
{pendingCounts.keywords > 0 && pendingCounts.images > 0 && ', '}
|
||||||
<GroupIcon className="size-4 text-purple-600 dark:text-purple-400" />
|
{pendingCounts.images > 0 && `${pendingCounts.images} pending images`}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-base font-bold text-gray-900 dark:text-white">Clusters</div>
|
)}
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-3xl font-bold text-purple-600">{metrics?.clusters?.total || 0}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{renderMetricRow([
|
|
||||||
{ label: 'New:', value: metrics?.clusters?.new || 0, colorCls: 'text-purple-600' },
|
|
||||||
{ label: 'Mapped:', value: metrics?.clusters?.mapped || 0, colorCls: 'text-purple-600' },
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ideas */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
|
||||||
<BoltIcon className="size-4 text-warning-600 dark:text-warning-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-bold text-gray-900 dark:text-white">Ideas</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-3xl font-bold text-warning-600">{metrics?.ideas?.total || 0}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{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' },
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
|
||||||
<FileTextIcon className="size-4 text-success-600 dark:text-success-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-bold text-gray-900 dark:text-white">Content</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-3xl font-bold text-success-600">{metrics?.content?.total || 0}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{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' },
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Images */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="size-8 rounded-lg bg-info-100 dark:bg-info-900/30 flex items-center justify-center">
|
|
||||||
<FileIcon className="size-4 text-info-600 dark:text-info-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-bold text-gray-900 dark:text-white">Images</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-3xl font-bold text-info-600">{metrics?.images?.total || 0}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{renderMetricRow([
|
|
||||||
{ label: 'Pending:', value: metrics?.images?.pending || 0, colorCls: 'text-info-600' },
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost Estimation Card */}
|
{/* Production Summary - now uses actual_counts */}
|
||||||
{overviewStats ? (
|
<ProductionSummary
|
||||||
<>
|
totals={totals}
|
||||||
{/* Attention Items Alert */}
|
actual_counts={actual_counts}
|
||||||
{overviewStats.attention_items && (
|
efficiency={productionStats?.efficiency}
|
||||||
<AttentionItemsAlert items={overviewStats.attention_items} />
|
loading={loading}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* Statistics and Predictive Analysis */}
|
{/* Meaningful Run History - Full Width */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<MeaningfulRunHistory
|
||||||
{overviewStats.run_statistics && (
|
runs={productionStats?.meaningful_runs || []}
|
||||||
<RunStatisticsSummary statistics={overviewStats.run_statistics} loading={loading} />
|
loading={loading}
|
||||||
)}
|
maxRuns={10}
|
||||||
{overviewStats.predictive_analysis && (
|
/>
|
||||||
<PredictiveCostAnalysis analysis={overviewStats.predictive_analysis} loading={loading} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !loading && (
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Loading automation statistics...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Enhanced Run History */}
|
|
||||||
{historyData && historyData.runs && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Run History</h2>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Click on any run to view detailed analysis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<EnhancedRunHistory
|
|
||||||
runs={historyData.runs}
|
|
||||||
loading={loading}
|
|
||||||
currentPage={historyData.pagination?.page || 1}
|
|
||||||
totalPages={historyData.pagination?.total_pages || 1}
|
|
||||||
onPageChange={setHistoryPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fallback: Old Run History (if enhanced data not available) */}
|
|
||||||
{!historyData && activeSite && <RunHistory siteId={activeSite.id} />}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Automation Run Detail Page
|
* Automation Run Detail Page
|
||||||
* Comprehensive view of a single automation run
|
* 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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import { automationService } from '../../services/automationService';
|
import { automationService } from '../../services/automationService';
|
||||||
@@ -20,29 +20,74 @@ const AutomationRunDetail: React.FC = () => {
|
|||||||
const { runId } = useParams<{ runId: string }>();
|
const { runId } = useParams<{ runId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeSite } = useSiteStore();
|
const { activeSite } = useSiteStore();
|
||||||
const toast = useToast();
|
const { error: toastError } = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [runDetail, setRunDetail] = useState<RunDetailResponse | null>(null);
|
const [runDetail, setRunDetail] = useState<RunDetailResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const lastRequestKey = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const decodeTitle = (value: string | undefined | null) => {
|
||||||
loadRunDetail();
|
if (!value) return '';
|
||||||
}, [runId, activeSite]);
|
|
||||||
|
|
||||||
const loadRunDetail = async () => {
|
|
||||||
if (!activeSite || !runId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
return decodeURIComponent(value);
|
||||||
const data = await automationService.getRunDetail(activeSite.id, runId);
|
} catch {
|
||||||
setRunDetail(data);
|
return value;
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to load run detail', error);
|
|
||||||
toast.error(error.message || 'Failed to load run detail');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
if (!activeSite) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -59,6 +104,14 @@ const AutomationRunDetail: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!runDetail) {
|
if (!runDetail) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta
|
<PageMeta
|
||||||
title={`Run Detail - ${runDetail.run?.run_title || 'Automation Run'}`}
|
title={`Run Detail - ${displayTitle}`}
|
||||||
description="Detailed automation run analysis"
|
description="Detailed automation run analysis"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={runDetail.run?.run_title || 'Automation Run'}
|
title={displayTitle}
|
||||||
breadcrumb={`Automation / Runs / ${runDetail.run?.run_title || 'Detail'}`}
|
breadcrumb={`Automation / Runs / ${breadcrumbLabel || 'Detail'}`}
|
||||||
description="Comprehensive run analysis with stage breakdown and performance metrics"
|
description="Comprehensive run analysis with stage breakdown and performance metrics"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Run Summary */}
|
{/* Run Summary */}
|
||||||
{runDetail.run && <RunSummaryCard run={runDetail.run} />}
|
{normalizedRun && <RunSummaryCard run={normalizedRun} summary={stageSummary} />}
|
||||||
|
|
||||||
{/* Insights Panel */}
|
{/* Insights Panel */}
|
||||||
{runDetail.insights && runDetail.insights.length > 0 && (
|
{combinedInsights.length > 0 && (
|
||||||
<InsightsPanel insights={runDetail.insights} />
|
<InsightsPanel insights={combinedInsights} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two Column Layout */}
|
{/* Two Column Layout */}
|
||||||
|
|||||||
@@ -344,4 +344,70 @@ export const automationService = {
|
|||||||
}> => {
|
}> => {
|
||||||
return fetchAPI(buildUrl('/eligibility/', { site_id: siteId }));
|
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 }));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ export interface EnhancedRunHistoryItem {
|
|||||||
run_id: string;
|
run_id: string;
|
||||||
run_number: number;
|
run_number: number;
|
||||||
run_title: string;
|
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';
|
trigger_type: 'manual' | 'scheduled';
|
||||||
started_at: string;
|
started_at: string;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
@@ -149,7 +151,9 @@ export interface RunDetailInfo {
|
|||||||
run_id: string;
|
run_id: string;
|
||||||
run_number: number;
|
run_number: number;
|
||||||
run_title: string;
|
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';
|
trigger_type: 'manual' | 'scheduled';
|
||||||
started_at: string;
|
started_at: string;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user