automation overview page implemeantion initital complete
This commit is contained in:
@@ -49,6 +49,7 @@ const Approved = lazy(() => import("./pages/Writer/Approved"));
|
||||
// Automation Module - Lazy loaded
|
||||
const AutomationPage = lazy(() => import("./pages/Automation/AutomationPage"));
|
||||
const AutomationOverview = lazy(() => import("./pages/Automation/AutomationOverview"));
|
||||
const AutomationRunDetail = lazy(() => import("./pages/Automation/AutomationRunDetail"));
|
||||
const PipelineSettings = lazy(() => import("./pages/Automation/PipelineSettings"));
|
||||
|
||||
// Linker Module - Lazy loaded
|
||||
@@ -198,6 +199,7 @@ export default function App() {
|
||||
{/* Automation Module */}
|
||||
<Route path="/automation" element={<Navigate to="/automation/overview" replace />} />
|
||||
<Route path="/automation/overview" element={<AutomationOverview />} />
|
||||
<Route path="/automation/runs/:runId" element={<AutomationRunDetail />} />
|
||||
<Route path="/automation/settings" element={<PipelineSettings />} />
|
||||
<Route path="/automation/run" element={<AutomationPage />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Attention Items Alert Component
|
||||
* Shows items that need attention (failures, skipped items)
|
||||
*/
|
||||
import React from 'react';
|
||||
import { AttentionItems } from '../../../types/automation';
|
||||
import { ExclamationTriangleIcon } from '../../../icons';
|
||||
|
||||
interface AttentionItemsAlertProps {
|
||||
items: AttentionItems;
|
||||
}
|
||||
|
||||
const AttentionItemsAlert: React.FC<AttentionItemsAlertProps> = ({ items }) => {
|
||||
if (!items) return null;
|
||||
|
||||
const totalIssues = (items.skipped_ideas || 0) + (items.failed_content || 0) + (items.failed_images || 0);
|
||||
|
||||
if (totalIssues === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-warning-50 dark:bg-warning-900/20 border border-warning-200 dark:border-warning-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationTriangleIcon className="size-6 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-warning-900 dark:text-warning-200 mb-2">
|
||||
Items Requiring Attention
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-warning-800 dark:text-warning-300">
|
||||
{items.skipped_ideas > 0 && (
|
||||
<div>• {items.skipped_ideas} content idea{items.skipped_ideas > 1 ? 's' : ''} skipped</div>
|
||||
)}
|
||||
{items.failed_content > 0 && (
|
||||
<div>• {items.failed_content} content piece{items.failed_content > 1 ? 's' : ''} failed generation</div>
|
||||
)}
|
||||
{items.failed_images > 0 && (
|
||||
<div>• {items.failed_images} image{items.failed_images > 1 ? 's' : ''} failed generation</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttentionItemsAlert;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Credit Breakdown Chart Component
|
||||
* Donut chart showing credit distribution across stages
|
||||
*/
|
||||
import React from 'react';
|
||||
import { DetailedStage } from '../../../types/automation';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
|
||||
interface CreditBreakdownChartProps {
|
||||
stages: DetailedStage[];
|
||||
}
|
||||
|
||||
const CreditBreakdownChart: React.FC<CreditBreakdownChartProps> = ({ stages }) => {
|
||||
// Filter stages with credits used
|
||||
const stagesWithCredits = (stages || []).filter(s => (s.credits_used || 0) > 0);
|
||||
|
||||
if (stagesWithCredits.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-4">
|
||||
Credit Distribution
|
||||
</h3>
|
||||
<div className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||
No credits used
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = stagesWithCredits.map(s => s.credits_used);
|
||||
const chartLabels = stagesWithCredits.map(s => `Stage ${s.stage_number}`);
|
||||
|
||||
const chartOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
labels: chartLabels,
|
||||
colors: ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#06b6d4', '#ec4899', '#6366f1'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
colors: '#9ca3af',
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '70%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: {
|
||||
show: true,
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
},
|
||||
value: {
|
||||
show: true,
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
formatter: (val: string) => `${parseFloat(val).toFixed(0)}`,
|
||||
},
|
||||
total: {
|
||||
show: true,
|
||||
label: 'Total Credits',
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
formatter: () => `${chartData.reduce((a, b) => a + b, 0)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
theme: 'dark',
|
||||
y: {
|
||||
formatter: (val: number) => `${val} credits`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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-4">
|
||||
Credit Distribution
|
||||
</h3>
|
||||
<ReactApexChart
|
||||
options={chartOptions}
|
||||
series={chartData}
|
||||
type="donut"
|
||||
height={280}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditBreakdownChart;
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Efficiency Metrics Component
|
||||
* Displays efficiency statistics and historical comparison
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EfficiencyMetrics as EfficiencyMetricsType, HistoricalComparison } from '../../../types/automation';
|
||||
|
||||
interface EfficiencyMetricsProps {
|
||||
efficiency: EfficiencyMetricsType;
|
||||
historicalComparison: HistoricalComparison;
|
||||
}
|
||||
|
||||
const EfficiencyMetrics: React.FC<EfficiencyMetricsProps> = ({ efficiency, historicalComparison }) => {
|
||||
// Add null safety
|
||||
if (!efficiency || !historicalComparison) {
|
||||
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-4">
|
||||
Efficiency Metrics
|
||||
</h3>
|
||||
<div className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||
Loading metrics...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getVarianceColor = (current: number, historical: number) => {
|
||||
if (historical === 0) return 'text-gray-600 dark:text-gray-400';
|
||||
const variance = ((current - historical) / historical) * 100;
|
||||
if (Math.abs(variance) < 10) return 'text-gray-600 dark:text-gray-400';
|
||||
if (variance > 0) return 'text-error-600 dark:text-error-400';
|
||||
return 'text-success-600 dark:text-success-400';
|
||||
};
|
||||
|
||||
const getVarianceText = (current: number, historical: number) => {
|
||||
if (historical === 0) return '';
|
||||
const variance = ((current - historical) / historical) * 100;
|
||||
return `${variance > 0 ? '+' : ''}${variance.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
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-4">
|
||||
Efficiency Metrics
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Credits per Item</span>
|
||||
<span className={`text-xs font-medium ${getVarianceColor(efficiency.credits_per_item || 0, historicalComparison.avg_credits_per_item || 0)}`}>
|
||||
{getVarianceText(efficiency.credits_per_item || 0, historicalComparison.avg_credits_per_item || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{(efficiency.credits_per_item || 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Avg: {(historicalComparison.avg_credits_per_item || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Items per Minute</div>
|
||||
<div className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{(efficiency.items_per_minute || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Credits per Minute</div>
|
||||
<div className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{(efficiency.credits_per_minute || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EfficiencyMetrics;
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Enhanced Run History Component
|
||||
* Displays automation run history with enhanced data and clickable rows
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EnhancedRunHistoryItem, StageStatus } from '../../../types/automation';
|
||||
import { formatDistanceToNow } from '../../../utils/dateUtils';
|
||||
|
||||
interface EnhancedRunHistoryProps {
|
||||
runs: EnhancedRunHistoryItem[];
|
||||
loading?: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
const EnhancedRunHistory: React.FC<EnhancedRunHistoryProps> = ({
|
||||
runs,
|
||||
loading,
|
||||
onPageChange,
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400',
|
||||
running: 'bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-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',
|
||||
cancelled: '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';
|
||||
};
|
||||
|
||||
const getStageStatusIcon = (status: StageStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '✓';
|
||||
case 'failed':
|
||||
return '✗';
|
||||
case 'skipped':
|
||||
return '○';
|
||||
case 'pending':
|
||||
return '·';
|
||||
default:
|
||||
return '·';
|
||||
}
|
||||
};
|
||||
|
||||
const getStageStatusColor = (status: StageStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-success-600 dark:text-success-400';
|
||||
case 'failed':
|
||||
return 'text-error-600 dark:text-error-400';
|
||||
case 'skipped':
|
||||
return 'text-gray-400 dark:text-gray-600';
|
||||
case 'pending':
|
||||
return 'text-gray-300 dark:text-gray-700';
|
||||
default:
|
||||
return 'text-gray-300 dark:text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-8 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">No automation runs yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Run
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Stages
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Results
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Credits
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{runs.map((run) => (
|
||||
<tr
|
||||
key={run.run_id}
|
||||
onClick={() => navigate(`/automation/runs/${run.run_id}`)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm font-medium text-brand-600 dark:text-brand-400 hover:underline">
|
||||
{run.run_title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{run.trigger_type}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 rounded-full text-xs font-semibold ${getStatusBadge(
|
||||
run.status
|
||||
)}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{(run.stage_statuses || []).map((status, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`text-lg font-bold ${getStageStatusColor(status)}`}
|
||||
title={`Stage ${idx + 1}: ${status}`}
|
||||
>
|
||||
{getStageStatusIcon(status)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{run.stages_completed || 0}/7 completed
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{formatDuration(run.duration_seconds || 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{run.summary?.items_processed || 0} → {run.summary?.items_created || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{run.summary?.content_created || 0} content, {run.summary?.images_generated || 0} images
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{(run.total_credits_used || 0).toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{formatDistanceToNow(run.started_at)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && onPageChange && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedRunHistory;
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Insights Panel Component
|
||||
* Displays auto-generated insights and alerts
|
||||
*/
|
||||
import React from 'react';
|
||||
import { RunInsight } from '../../../types/automation';
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon, InfoIcon, XCircleIcon } from '../../../icons';
|
||||
|
||||
interface InsightsPanelProps {
|
||||
insights: RunInsight[];
|
||||
}
|
||||
|
||||
const InsightsPanel: React.FC<InsightsPanelProps> = ({ insights }) => {
|
||||
if (!insights || insights.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getInsightStyle = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800';
|
||||
case 'warning':
|
||||
return 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800';
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getInsightIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="size-5 text-success-600 dark:text-success-400" />;
|
||||
case 'error':
|
||||
return <XCircleIcon className="size-5 text-error-600 dark:text-error-400" />;
|
||||
case 'warning':
|
||||
case 'variance':
|
||||
return <ExclamationTriangleIcon className="size-5 text-warning-600 dark:text-warning-400" />;
|
||||
default:
|
||||
return <InfoIcon className="size-5 text-brand-600 dark:text-brand-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
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-4">Insights</h3>
|
||||
<div className="space-y-3">
|
||||
{insights.map((insight, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-start gap-3 p-4 rounded-lg border ${getInsightStyle(insight.severity)}`}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getInsightIcon(insight.type)}
|
||||
</div>
|
||||
<div className="flex-1 text-sm text-gray-800 dark:text-gray-200">
|
||||
{insight.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsPanel;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Predictive Cost Analysis Component
|
||||
* Shows estimated credits and outputs for next automation run
|
||||
*/
|
||||
import React from 'react';
|
||||
import { PredictiveAnalysis } from '../../../types/automation';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
|
||||
interface PredictiveCostAnalysisProps {
|
||||
analysis: PredictiveAnalysis;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PredictiveCostAnalysis: React.FC<PredictiveCostAnalysisProps> = ({ analysis, loading }) => {
|
||||
if (loading || !analysis) {
|
||||
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-4">
|
||||
Predictive Cost Analysis
|
||||
</h3>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const confidenceColors = {
|
||||
high: 'text-success-600 dark:text-success-400',
|
||||
medium: 'text-warning-600 dark:text-warning-400',
|
||||
low: 'text-error-600 dark:text-error-400',
|
||||
};
|
||||
|
||||
const confidenceBadges = {
|
||||
high: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400',
|
||||
medium: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400',
|
||||
low: 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400',
|
||||
};
|
||||
|
||||
// Prepare data for donut chart
|
||||
const chartData = (analysis.stages || [])
|
||||
.filter(s => (s.estimated_credits || 0) > 0)
|
||||
.map(s => s.estimated_credits || 0);
|
||||
|
||||
const chartLabels = (analysis.stages || [])
|
||||
.filter(s => (s.estimated_credits || 0) > 0)
|
||||
.map(s => s.stage_name || 'Unknown');
|
||||
|
||||
const chartOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
labels: chartLabels,
|
||||
colors: ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#06b6d4', '#ec4899', '#6366f1'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
colors: '#9ca3af',
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '70%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: {
|
||||
show: true,
|
||||
fontSize: '14px',
|
||||
color: '#9ca3af',
|
||||
},
|
||||
value: {
|
||||
show: true,
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
formatter: (val: string) => `${parseFloat(val).toFixed(0)} cr`,
|
||||
},
|
||||
total: {
|
||||
show: true,
|
||||
label: 'Est. Total',
|
||||
fontSize: '14px',
|
||||
color: '#9ca3af',
|
||||
formatter: () => `${analysis.totals?.total_estimated_credits || 0} cr`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
theme: 'dark',
|
||||
y: {
|
||||
formatter: (val: number) => `${val} credits`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Predictive Cost Analysis
|
||||
</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${confidenceBadges[analysis.confidence || 'medium']}`}>
|
||||
{(analysis.confidence || 'medium').toUpperCase()} confidence
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Stage Breakdown */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Stage Breakdown</h4>
|
||||
{(analysis.stages || []).map((stage) => (
|
||||
<div
|
||||
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 className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
~{stage.estimated_credits || 0} cr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PredictiveCostAnalysis;
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Run Statistics Summary Component
|
||||
* Displays aggregate statistics about automation runs
|
||||
*/
|
||||
import React from 'react';
|
||||
import { RunStatistics } from '../../../types/automation';
|
||||
import { BoltIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../../../icons';
|
||||
|
||||
interface RunStatisticsSummaryProps {
|
||||
statistics: RunStatistics;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const RunStatisticsSummary: React.FC<RunStatisticsSummaryProps> = ({ statistics, loading }) => {
|
||||
if (loading || !statistics) {
|
||||
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-4">Run Statistics</h3>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Runs',
|
||||
value: statistics.total_runs || 0,
|
||||
icon: BoltIcon,
|
||||
color: 'brand' as const,
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: statistics.completed_runs || 0,
|
||||
icon: CheckCircleIcon,
|
||||
color: 'success' as const,
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
value: statistics.failed_runs || 0,
|
||||
icon: XCircleIcon,
|
||||
color: 'error' as const,
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
value: statistics.running_runs || 0,
|
||||
icon: ClockIcon,
|
||||
color: 'warning' as const,
|
||||
},
|
||||
];
|
||||
|
||||
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-4">Run Statistics</h3>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
const colorClasses = {
|
||||
brand: 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400',
|
||||
success: 'bg-success-100 dark:bg-success-900/30 text-success-600 dark:text-success-400',
|
||||
error: 'bg-error-100 dark:bg-error-900/30 text-error-600 dark:text-error-400',
|
||||
warning: 'bg-warning-100 dark:bg-warning-900/30 text-warning-600 dark:text-warning-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className={`inline-flex size-12 rounded-lg items-center justify-center mb-2 ${colorClasses[stat.color]}`}>
|
||||
<Icon className="size-6" />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Additional Metrics */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total Credits Used</span>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{(statistics.total_credits_used || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Last 30 Days</span>
|
||||
<span className="text-base font-medium text-brand-600 dark:text-brand-400">
|
||||
{(statistics.total_credits_last_30_days || 0).toLocaleString()} credits
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Avg Credits/Run</span>
|
||||
<span className="text-base font-medium text-gray-900 dark:text-white">
|
||||
{Math.round(statistics.avg_credits_per_run || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Avg Duration (7 days)</span>
|
||||
<span className="text-base font-medium text-gray-900 dark:text-white">
|
||||
{formatDuration(statistics.avg_duration_last_7_days_seconds || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunStatisticsSummary;
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Run Summary Card Component
|
||||
* Displays header information about an automation run
|
||||
*/
|
||||
import React from 'react';
|
||||
import { RunDetailInfo } from '../../../types/automation';
|
||||
import { formatDateTime, formatDuration } from '../../../utils/dateUtils';
|
||||
import { CheckCircleIcon, XCircleIcon, ClockIcon, BoltIcon } from '../../../icons';
|
||||
|
||||
interface RunSummaryCardProps {
|
||||
run: RunDetailInfo;
|
||||
}
|
||||
|
||||
const RunSummaryCard: React.FC<RunSummaryCardProps> = ({ run }) => {
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||
<div className="animate-pulse h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (run.status) {
|
||||
case 'completed':
|
||||
return <CheckCircleIcon className="size-6 text-success-600 dark:text-success-400" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="size-6 text-error-600 dark:text-error-400" />;
|
||||
case 'running':
|
||||
return <ClockIcon className="size-6 text-brand-600 dark:text-brand-400" />;
|
||||
default:
|
||||
return <BoltIcon className="size-6 text-gray-600 dark:text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = () => {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400',
|
||||
running: 'bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-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',
|
||||
cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
return colors[run.status] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const totalInitialItems = run.initial_snapshot?.total_initial_items || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadge()}`}>
|
||||
{(run.status || 'unknown').toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||
{run.trigger_type || 'manual'} trigger
|
||||
</span>
|
||||
</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">Started</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatDateTime(run.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Duration</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatDuration(run.duration_seconds || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Items Processed</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{totalInitialItems}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Total Credits</div>
|
||||
<div className="text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||
{(run.total_credits_used || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunSummaryCard;
|
||||
190
frontend/src/components/Automation/DetailView/StageAccordion.tsx
Normal file
190
frontend/src/components/Automation/DetailView/StageAccordion.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Stage Accordion Component
|
||||
* Expandable sections showing detailed stage information
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { DetailedStage, InitialSnapshot } from '../../../types/automation';
|
||||
import { formatDuration } from '../../../utils/dateUtils';
|
||||
import { ChevronDownIcon, ChevronUpIcon, CheckCircleIcon, XCircleIcon, AlertCircleIcon } from '../../../icons';
|
||||
|
||||
interface StageAccordionProps {
|
||||
stages: DetailedStage[];
|
||||
initialSnapshot: InitialSnapshot;
|
||||
}
|
||||
|
||||
const StageAccordion: React.FC<StageAccordionProps> = ({ stages, initialSnapshot }) => {
|
||||
const [expandedStages, setExpandedStages] = useState<Set<number>>(new Set([1]));
|
||||
|
||||
if (!stages || stages.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">Stage Details</h3>
|
||||
<div className="text-center py-8 text-gray-600 dark:text-gray-400">No stage data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleStage = (stageNumber: number) => {
|
||||
const newExpanded = new Set(expandedStages);
|
||||
if (newExpanded.has(stageNumber)) {
|
||||
newExpanded.delete(stageNumber);
|
||||
} else {
|
||||
newExpanded.add(stageNumber);
|
||||
}
|
||||
setExpandedStages(newExpanded);
|
||||
};
|
||||
|
||||
const getStageIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleIcon className="size-5 text-success-600 dark:text-success-400" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="size-5 text-error-600 dark:text-error-400" />;
|
||||
case 'skipped':
|
||||
return <AlertCircleIcon className="size-5 text-gray-400 dark:text-gray-600" />;
|
||||
default:
|
||||
return <div className="size-5 rounded-full border-2 border-gray-300 dark:border-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getVarianceColor = (variance: number) => {
|
||||
if (Math.abs(variance) < 10) return 'text-gray-600 dark:text-gray-400';
|
||||
if (variance > 0) return 'text-error-600 dark:text-error-400';
|
||||
return 'text-success-600 dark:text-success-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Stage Details</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{stages.map((stage) => {
|
||||
const isExpanded = expandedStages.has(stage.stage_number);
|
||||
|
||||
return (
|
||||
<div key={stage.stage_number}>
|
||||
{/* Stage Header */}
|
||||
<button
|
||||
onClick={() => toggleStage(stage.stage_number)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
{getStageIcon(stage.status)}
|
||||
</div>
|
||||
<div className="text-left flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Stage {stage.stage_number}: {stage.stage_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{stage.items_processed || 0} → {stage.items_created || 0} items • {stage.credits_used || 0} credits
|
||||
{(stage.duration_seconds || 0) > 0 && ` • ${formatDuration(stage.duration_seconds || 0)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{stage.credits_used || 0} cr
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="size-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Stage Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-800/30 space-y-4">
|
||||
{/* Metrics Grid */}
|
||||
<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">Input</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{stage.items_processed || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Output</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{stage.items_created || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Credits</div>
|
||||
<div className="text-lg font-semibold text-brand-600 dark:text-brand-400">
|
||||
{stage.credits_used || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Duration</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatDuration(stage.duration_seconds || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historical Comparison */}
|
||||
{stage.comparison && (stage.comparison.historical_avg_credits || 0) > 0 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Historical Comparison
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Credits vs Average
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${getVarianceColor(stage.comparison.credit_variance_pct || 0)}`}>
|
||||
{(stage.comparison.credit_variance_pct || 0) > 0 ? '+' : ''}
|
||||
{(stage.comparison.credit_variance_pct || 0).toFixed(1)}%
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||
(avg: {(stage.comparison.historical_avg_credits || 0).toFixed(0)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Output vs Average
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${getVarianceColor(-(stage.comparison.items_variance_pct || 0))}`}>
|
||||
{(stage.comparison.items_variance_pct || 0) > 0 ? '+' : ''}
|
||||
{(stage.comparison.items_variance_pct || 0).toFixed(1)}%
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||
(avg: {(stage.comparison.historical_avg_items || 0).toFixed(0)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{stage.error && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-3">
|
||||
<div className="text-sm font-semibold text-error-900 dark:text-error-200 mb-1">
|
||||
Error
|
||||
</div>
|
||||
<div className="text-sm text-error-800 dark:text-error-300">
|
||||
{stage.error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StageAccordion;
|
||||
@@ -86,27 +86,27 @@ const RunHistory: React.FC<RunHistoryProps> = ({ siteId }) => {
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{history.map((run) => (
|
||||
<tr key={run.run_id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-900 dark:text-gray-100">{run.run_id.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-900 dark:text-gray-100">{(run.run_id || '').slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-semibold ${getStatusBadge(
|
||||
run.status
|
||||
run.status || 'unknown'
|
||||
)}`}
|
||||
>
|
||||
{run.status}
|
||||
{run.status || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 capitalize">{run.trigger_type}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 capitalize">{run.trigger_type || 'manual'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
{run.started_at ? new Date(run.started_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
{run.completed_at
|
||||
? new Date(run.completed_at).toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{run.total_credits_used}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{run.current_stage}/7</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{run.total_credits_used || 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{run.current_stage || 0}/7</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -137,6 +137,7 @@ export { BoxCubeIcon as SettingsIcon }; // Settings/cog alias
|
||||
export { InfoIcon as HelpCircleIcon }; // Help/question circle
|
||||
export { AlertIcon as AlertCircleIcon }; // Alert/warning circle
|
||||
export { AlertIcon as AlertTriangleIcon }; // Alert triangle alias
|
||||
export { AlertIcon as ExclamationTriangleIcon }; // Exclamation triangle alias
|
||||
export { CheckLineIcon as CheckIcon }; // Simple check mark
|
||||
export { TrashBinIcon as TrashIcon }; // Trash alias
|
||||
export { TrashBinIcon as Trash2Icon }; // Trash2 alias
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { automationService } from '../../services/automationService';
|
||||
import { OverviewStatsResponse } from '../../types/automation';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
@@ -18,6 +19,10 @@ import RunHistory from '../../components/Automation/RunHistory';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import RunStatisticsSummary from '../../components/Automation/DetailView/RunStatisticsSummary';
|
||||
import PredictiveCostAnalysis from '../../components/Automation/DetailView/PredictiveCostAnalysis';
|
||||
import AttentionItemsAlert from '../../components/Automation/DetailView/AttentionItemsAlert';
|
||||
import EnhancedRunHistory from '../../components/Automation/DetailView/EnhancedRunHistory';
|
||||
import {
|
||||
ListIcon,
|
||||
GroupIcon,
|
||||
@@ -31,7 +36,9 @@ const AutomationOverview: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
const [estimate, setEstimate] = useState<any>(null);
|
||||
const [overviewStats, setOverviewStats] = useState<OverviewStatsResponse | null>(null);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [historyData, setHistoryData] = useState<any>(null);
|
||||
|
||||
// Load metrics for the 5 metric cards
|
||||
const loadMetrics = async () => {
|
||||
@@ -89,28 +96,42 @@ const AutomationOverview: React.FC = () => {
|
||||
};
|
||||
|
||||
// Load cost estimate
|
||||
const loadEstimate = async () => {
|
||||
const loadOverviewStats = async () => {
|
||||
if (!activeSite) return;
|
||||
|
||||
try {
|
||||
const estimateData = await automationService.estimate(activeSite.id);
|
||||
setEstimate(estimateData);
|
||||
const stats = await automationService.getOverviewStats(activeSite.id);
|
||||
setOverviewStats(stats);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch cost estimate', 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(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([loadMetrics(), loadEstimate()]);
|
||||
await Promise.all([loadMetrics(), loadOverviewStats(), loadEnhancedHistory(historyPage)]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (activeSite) {
|
||||
loadData();
|
||||
}
|
||||
}, [activeSite]);
|
||||
}, [activeSite, historyPage]);
|
||||
|
||||
// Helper to render metric rows
|
||||
const renderMetricRow = (items: Array<{ label: string; value: number; colorCls: string }>) => {
|
||||
@@ -253,34 +274,50 @@ const AutomationOverview: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Cost Estimation Card */}
|
||||
{estimate && (
|
||||
<ComponentCard
|
||||
title="Ready to Process"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Estimated Items to Process: <span className="text-lg font-bold text-brand-600">{estimate.estimated_credits || 0}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Current Balance: <span className="text-lg font-bold text-success-600">{estimate.current_balance || 0}</span> credits
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status: {estimate.sufficient ? (
|
||||
<span className="text-success-600 font-bold">✓ Sufficient credits</span>
|
||||
) : (
|
||||
<span className="text-danger-600 font-bold">⚠ Insufficient credits</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{overviewStats ? (
|
||||
<>
|
||||
{/* Attention Items Alert */}
|
||||
{overviewStats.attention_items && (
|
||||
<AttentionItemsAlert items={overviewStats.attention_items} />
|
||||
)}
|
||||
|
||||
{/* Statistics and Predictive Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{overviewStats.run_statistics && (
|
||||
<RunStatisticsSummary statistics={overviewStats.run_statistics} loading={loading} />
|
||||
)}
|
||||
{overviewStats.predictive_analysis && (
|
||||
<PredictiveCostAnalysis analysis={overviewStats.predictive_analysis} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</>
|
||||
) : !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>
|
||||
)}
|
||||
|
||||
{/* Run History */}
|
||||
{activeSite && <RunHistory siteId={activeSite.id} />}
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
|
||||
120
frontend/src/pages/Automation/AutomationRunDetail.tsx
Normal file
120
frontend/src/pages/Automation/AutomationRunDetail.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Automation Run Detail Page
|
||||
* Comprehensive view of a single automation run
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { automationService } from '../../services/automationService';
|
||||
import { RunDetailResponse } from '../../types/automation';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import RunSummaryCard from '../../components/Automation/DetailView/RunSummaryCard';
|
||||
import StageAccordion from '../../components/Automation/DetailView/StageAccordion';
|
||||
import EfficiencyMetrics from '../../components/Automation/DetailView/EfficiencyMetrics';
|
||||
import InsightsPanel from '../../components/Automation/DetailView/InsightsPanel';
|
||||
import CreditBreakdownChart from '../../components/Automation/DetailView/CreditBreakdownChart';
|
||||
|
||||
const AutomationRunDetail: React.FC = () => {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runDetail, setRunDetail] = useState<RunDetailResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadRunDetail();
|
||||
}, [runId, activeSite]);
|
||||
|
||||
const loadRunDetail = async () => {
|
||||
if (!activeSite || !runId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await automationService.getRunDetail(activeSite.id, runId);
|
||||
setRunDetail(data);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load run detail', error);
|
||||
toast.error(error.message || 'Failed to load run detail');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeSite) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Please select a site to view automation run details.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading run details...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!runDetail) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Run not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title={`Run Detail - ${runDetail.run?.run_title || 'Automation Run'}`}
|
||||
description="Detailed automation run analysis"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={runDetail.run?.run_title || 'Automation Run'}
|
||||
breadcrumb={`Automation / Runs / ${runDetail.run?.run_title || 'Detail'}`}
|
||||
description="Comprehensive run analysis with stage breakdown and performance metrics"
|
||||
/>
|
||||
|
||||
{/* Run Summary */}
|
||||
{runDetail.run && <RunSummaryCard run={runDetail.run} />}
|
||||
|
||||
{/* Insights Panel */}
|
||||
{runDetail.insights && runDetail.insights.length > 0 && (
|
||||
<InsightsPanel insights={runDetail.insights} />
|
||||
)}
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Credit Breakdown & Efficiency */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{runDetail.stages && <CreditBreakdownChart stages={runDetail.stages} />}
|
||||
{runDetail.efficiency && runDetail.historical_comparison && (
|
||||
<EfficiencyMetrics
|
||||
efficiency={runDetail.efficiency}
|
||||
historicalComparison={runDetail.historical_comparison}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Stage Details */}
|
||||
<div className="lg:col-span-2">
|
||||
{runDetail.stages && runDetail.run?.initial_snapshot && (
|
||||
<StageAccordion
|
||||
stages={runDetail.stages}
|
||||
initialSnapshot={runDetail.run.initial_snapshot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomationRunDetail;
|
||||
@@ -232,6 +232,34 @@ export const automationService = {
|
||||
return response.runs;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get enhanced automation run history with pagination
|
||||
*/
|
||||
getEnhancedHistory: async (
|
||||
siteId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<import('../types/automation').HistoryResponse> => {
|
||||
return fetchAPI(buildUrl('/history/', { site_id: siteId, page, page_size: pageSize }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get overview statistics with predictive analysis
|
||||
*/
|
||||
getOverviewStats: async (siteId: number): Promise<import('../types/automation').OverviewStatsResponse> => {
|
||||
return fetchAPI(buildUrl('/overview_stats/', { site_id: siteId }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific run
|
||||
*/
|
||||
getRunDetail: async (
|
||||
siteId: number,
|
||||
runId: string
|
||||
): Promise<import('../types/automation').RunDetailResponse> => {
|
||||
return fetchAPI(buildUrl('/run_detail/', { site_id: siteId, run_id: runId }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get automation run logs
|
||||
*/
|
||||
|
||||
174
frontend/src/types/automation.ts
Normal file
174
frontend/src/types/automation.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Enhanced Automation Types for Detail View
|
||||
* Matches backend API responses from overview_stats, history, and run_detail endpoints
|
||||
*/
|
||||
|
||||
// Overview Stats Types
|
||||
export interface RunStatistics {
|
||||
total_runs: number;
|
||||
completed_runs: number;
|
||||
failed_runs: number;
|
||||
running_runs: number;
|
||||
total_credits_used: number;
|
||||
total_credits_last_30_days: number;
|
||||
avg_credits_per_run: number;
|
||||
avg_duration_last_7_days_seconds: number;
|
||||
}
|
||||
|
||||
export interface PredictiveStage {
|
||||
stage_number: number;
|
||||
stage_name: string;
|
||||
pending_items: number;
|
||||
estimated_credits: number;
|
||||
estimated_output: number;
|
||||
}
|
||||
|
||||
export interface PredictiveAnalysis {
|
||||
stages: PredictiveStage[];
|
||||
totals: {
|
||||
total_pending_items: number;
|
||||
total_estimated_credits: number;
|
||||
total_estimated_output: number;
|
||||
recommended_buffer_credits: number;
|
||||
};
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface AttentionItems {
|
||||
skipped_ideas: number;
|
||||
failed_content: number;
|
||||
failed_images: number;
|
||||
}
|
||||
|
||||
export interface HistoricalStageAverage {
|
||||
stage_number: number;
|
||||
stage_name: string;
|
||||
avg_credits: number;
|
||||
avg_items_created: number;
|
||||
avg_output_ratio: number;
|
||||
}
|
||||
|
||||
export interface HistoricalAverages {
|
||||
avg_total_credits: number;
|
||||
avg_duration_seconds: number;
|
||||
avg_credits_per_item: number;
|
||||
total_runs_analyzed: number;
|
||||
has_sufficient_data: boolean;
|
||||
stages: HistoricalStageAverage[];
|
||||
}
|
||||
|
||||
export interface OverviewStatsResponse {
|
||||
run_statistics: RunStatistics;
|
||||
predictive_analysis: PredictiveAnalysis;
|
||||
attention_items: AttentionItems;
|
||||
historical_averages: HistoricalAverages;
|
||||
}
|
||||
|
||||
// Enhanced History Types
|
||||
export interface RunSummary {
|
||||
items_processed: number;
|
||||
items_created: number;
|
||||
content_created: number;
|
||||
images_generated: number;
|
||||
}
|
||||
|
||||
export interface InitialSnapshot {
|
||||
stage_1_initial: number;
|
||||
stage_2_initial: number;
|
||||
stage_3_initial: number;
|
||||
stage_4_initial: number;
|
||||
stage_5_initial: number;
|
||||
stage_6_initial: number;
|
||||
stage_7_initial: number;
|
||||
total_initial_items: number;
|
||||
}
|
||||
|
||||
export type StageStatus = 'completed' | 'pending' | 'skipped' | 'failed';
|
||||
|
||||
export interface EnhancedRunHistoryItem {
|
||||
run_id: string;
|
||||
run_number: number;
|
||||
run_title: string;
|
||||
status: 'completed' | 'running' | 'paused' | 'failed' | 'cancelled';
|
||||
trigger_type: 'manual' | 'scheduled';
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
duration_seconds: number;
|
||||
total_credits_used: number;
|
||||
current_stage: number;
|
||||
stages_completed: number;
|
||||
stages_failed: number;
|
||||
initial_snapshot: InitialSnapshot;
|
||||
summary: RunSummary;
|
||||
stage_statuses: StageStatus[];
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
runs: EnhancedRunHistoryItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_count: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Run Detail Types
|
||||
export interface StageComparison {
|
||||
historical_avg_credits: number;
|
||||
historical_avg_items: number;
|
||||
credit_variance_pct: number;
|
||||
items_variance_pct: number;
|
||||
}
|
||||
|
||||
export interface DetailedStage {
|
||||
stage_number: number;
|
||||
stage_name: string;
|
||||
status: StageStatus;
|
||||
credits_used: number;
|
||||
items_processed: number;
|
||||
items_created: number;
|
||||
duration_seconds: number;
|
||||
error: string;
|
||||
comparison: StageComparison;
|
||||
}
|
||||
|
||||
export interface EfficiencyMetrics {
|
||||
credits_per_item: number;
|
||||
items_per_minute: number;
|
||||
credits_per_minute: number;
|
||||
}
|
||||
|
||||
export interface RunInsight {
|
||||
type: 'success' | 'warning' | 'variance' | 'error';
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RunDetailInfo {
|
||||
run_id: string;
|
||||
run_number: number;
|
||||
run_title: string;
|
||||
status: 'completed' | 'running' | 'paused' | 'failed' | 'cancelled';
|
||||
trigger_type: 'manual' | 'scheduled';
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
duration_seconds: number;
|
||||
current_stage: number;
|
||||
total_credits_used: number;
|
||||
initial_snapshot: InitialSnapshot;
|
||||
}
|
||||
|
||||
export interface HistoricalComparison {
|
||||
avg_credits: number;
|
||||
avg_duration_seconds: number;
|
||||
avg_credits_per_item: number;
|
||||
}
|
||||
|
||||
export interface RunDetailResponse {
|
||||
run: RunDetailInfo;
|
||||
stages: DetailedStage[];
|
||||
efficiency: EfficiencyMetrics;
|
||||
insights: RunInsight[];
|
||||
historical_comparison: HistoricalComparison;
|
||||
}
|
||||
38
frontend/src/utils/dateUtils.ts
Normal file
38
frontend/src/utils/dateUtils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Date utility functions
|
||||
*/
|
||||
|
||||
export const formatDistanceToNow = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
Reference in New Issue
Block a user