AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.
This commit is contained in:
590
frontend/src/components/billing/AdvancedCostAnalytics.tsx
Normal file
590
frontend/src/components/billing/AdvancedCostAnalytics.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Clock,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LazyBarChart,
|
||||
LazyPieChart,
|
||||
Bar,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
// Types
|
||||
import { UsageLog } from '../../types/billing';
|
||||
|
||||
// Services
|
||||
import { billingService, formatCurrency, formatNumber } from '../../services/billingService';
|
||||
|
||||
// Components
|
||||
import DailyCostHeatmap from './DailyCostHeatmap';
|
||||
|
||||
interface AdvancedCostAnalyticsProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
interface TimeOfDayData {
|
||||
hour: number;
|
||||
label: string;
|
||||
cost: number;
|
||||
calls: number;
|
||||
avgCostPerCall: number;
|
||||
}
|
||||
|
||||
interface UserActionData {
|
||||
endpoint: string;
|
||||
action: string;
|
||||
cost: number;
|
||||
calls: number;
|
||||
avgCostPerCall: number;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
interface EfficiencyMetric {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const AdvancedCostAnalytics: React.FC<AdvancedCostAnalyticsProps> = ({
|
||||
userId,
|
||||
terminalTheme = false
|
||||
}) => {
|
||||
const [usageLogs, setUsageLogs] = useState<UsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsageLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const currentDate = new Date();
|
||||
const billingPeriod = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
console.log('[AdvancedCostAnalytics] Fetching usage logs for period:', billingPeriod);
|
||||
|
||||
// Try with billing period first, then without if no results
|
||||
let response = await billingService.getUsageLogs(2000, 0, undefined, undefined, billingPeriod);
|
||||
let logs = response.logs || [];
|
||||
|
||||
// If no logs for current period, try without billing period filter to get recent logs
|
||||
if (logs.length === 0) {
|
||||
console.log('[AdvancedCostAnalytics] No logs for current period, fetching all recent logs...');
|
||||
response = await billingService.getUsageLogs(2000, 0);
|
||||
logs = response.logs || [];
|
||||
}
|
||||
|
||||
console.log('[AdvancedCostAnalytics] Received logs:', {
|
||||
total: logs.length,
|
||||
sample: logs.slice(0, 3),
|
||||
totalCost: logs.reduce((sum, log) => sum + (log.cost_total || 0), 0),
|
||||
totalCalls: logs.length,
|
||||
logsWithCost: logs.filter(log => (log.cost_total || 0) > 0).length,
|
||||
logsWithTokens: logs.filter(log => (log.tokens_total || 0) > 0).length,
|
||||
sampleLogStructure: logs[0] ? {
|
||||
id: logs[0].id,
|
||||
cost_total: logs[0].cost_total,
|
||||
tokens_total: logs[0].tokens_total,
|
||||
status: logs[0].status,
|
||||
status_code: logs[0].status_code,
|
||||
response_time: logs[0].response_time
|
||||
} : null
|
||||
});
|
||||
|
||||
setUsageLogs(logs);
|
||||
} catch (err) {
|
||||
console.error('[AdvancedCostAnalytics] Error fetching usage logs:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch usage logs');
|
||||
// Set empty array on error to prevent showing stale data
|
||||
setUsageLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsageLogs();
|
||||
}, [userId]);
|
||||
|
||||
// Time of Day Analysis
|
||||
const timeOfDayData = useMemo(() => {
|
||||
const hourlyData: Record<number, { cost: number; calls: number }> = {};
|
||||
|
||||
usageLogs.forEach(log => {
|
||||
// Include all logs, not just those with cost > 0, for accurate call counts
|
||||
const cost = log.cost_total || 0;
|
||||
|
||||
try {
|
||||
const date = new Date(log.timestamp);
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('[AdvancedCostAnalytics] Invalid timestamp:', log.timestamp);
|
||||
return;
|
||||
}
|
||||
const hour = date.getHours();
|
||||
|
||||
if (!hourlyData[hour]) {
|
||||
hourlyData[hour] = { cost: 0, calls: 0 };
|
||||
}
|
||||
hourlyData[hour].cost += cost;
|
||||
hourlyData[hour].calls += 1;
|
||||
} catch (err) {
|
||||
console.warn('[AdvancedCostAnalytics] Error processing log timestamp:', err, log);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from({ length: 24 }, (_, hour) => {
|
||||
const data = hourlyData[hour] || { cost: 0, calls: 0 };
|
||||
return {
|
||||
hour,
|
||||
label: `${hour.toString().padStart(2, '0')}:00`,
|
||||
cost: data.cost,
|
||||
calls: data.calls,
|
||||
avgCostPerCall: data.calls > 0 ? data.cost / data.calls : 0
|
||||
} as TimeOfDayData;
|
||||
});
|
||||
}, [usageLogs]);
|
||||
|
||||
// User Action Breakdown
|
||||
const userActionData = useMemo(() => {
|
||||
const actionMap: Record<string, { cost: number; calls: number; responseTime: number }> = {};
|
||||
|
||||
usageLogs.forEach(log => {
|
||||
// Include all logs for accurate call counts
|
||||
const cost = log.cost_total || 0;
|
||||
|
||||
// Extract action from endpoint (e.g., /api/blog-writer/generate -> blog-writer)
|
||||
const endpoint = log.endpoint || '';
|
||||
const endpointParts = endpoint.split('/');
|
||||
const action = endpointParts.length > 2 ? endpointParts[2] : 'other';
|
||||
|
||||
if (!actionMap[action]) {
|
||||
actionMap[action] = { cost: 0, calls: 0, responseTime: 0 };
|
||||
}
|
||||
actionMap[action].cost += cost;
|
||||
actionMap[action].calls += 1;
|
||||
actionMap[action].responseTime += log.response_time || 0;
|
||||
});
|
||||
|
||||
return Object.entries(actionMap)
|
||||
.map(([endpoint, data]) => ({
|
||||
endpoint,
|
||||
action: endpoint.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
cost: data.cost,
|
||||
calls: data.calls,
|
||||
avgCostPerCall: data.calls > 0 ? data.cost / data.calls : 0,
|
||||
avgResponseTime: data.calls > 0 ? data.responseTime / data.calls : 0
|
||||
} as UserActionData))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 10); // Top 10 actions
|
||||
}, [usageLogs]);
|
||||
|
||||
// Cost Efficiency Metrics
|
||||
const efficiencyMetrics = useMemo(() => {
|
||||
const totalCost = usageLogs.reduce((sum, log) => sum + (log.cost_total || 0), 0);
|
||||
const totalCalls = usageLogs.length;
|
||||
const totalTokens = usageLogs.reduce((sum, log) => sum + (log.tokens_total || 0), 0);
|
||||
const totalResponseTime = usageLogs.reduce((sum, log) => sum + (log.response_time || 0), 0);
|
||||
const successfulCalls = usageLogs.filter(log => log.status === 'success' || (log.status_code && log.status_code < 400)).length;
|
||||
const failedCalls = usageLogs.filter(log => log.status === 'failed' || (log.status_code && log.status_code >= 400)).length;
|
||||
|
||||
// Debug logging
|
||||
if (usageLogs.length > 0) {
|
||||
console.log('[AdvancedCostAnalytics] Efficiency metrics calculation:', {
|
||||
totalLogs: usageLogs.length,
|
||||
totalCost,
|
||||
totalTokens,
|
||||
totalCalls,
|
||||
successfulCalls,
|
||||
failedCalls,
|
||||
sampleLog: usageLogs[0]
|
||||
});
|
||||
}
|
||||
|
||||
const avgCostPerCall = totalCalls > 0 ? totalCost / totalCalls : 0;
|
||||
const avgCostPerToken = totalTokens > 0 ? totalCost / totalTokens : 0;
|
||||
const avgResponseTime = totalCalls > 0 ? totalResponseTime / totalCalls : 0;
|
||||
const successRate = totalCalls > 0 ? (successfulCalls / totalCalls) * 100 : 0;
|
||||
// Tokens per dollar - handle division by zero
|
||||
const costEfficiency = totalCost > 0 && totalTokens > 0 ? totalTokens / totalCost : 0;
|
||||
|
||||
// Calculate trends (simplified - compare first half vs second half)
|
||||
const midPoint = Math.floor(usageLogs.length / 2);
|
||||
const firstHalf = usageLogs.slice(0, midPoint);
|
||||
const secondHalf = usageLogs.slice(midPoint);
|
||||
|
||||
const firstHalfAvgCost = firstHalf.length > 0
|
||||
? firstHalf.reduce((sum, log) => sum + (log.cost_total || 0), 0) / firstHalf.length
|
||||
: 0;
|
||||
const secondHalfAvgCost = secondHalf.length > 0
|
||||
? secondHalf.reduce((sum, log) => sum + (log.cost_total || 0), 0) / secondHalf.length
|
||||
: 0;
|
||||
|
||||
const costTrend = secondHalfAvgCost > firstHalfAvgCost * 1.05 ? 'up' :
|
||||
secondHalfAvgCost < firstHalfAvgCost * 0.95 ? 'down' : 'stable';
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Avg Cost per Call',
|
||||
value: avgCostPerCall,
|
||||
unit: '$',
|
||||
trend: costTrend,
|
||||
description: 'Average cost per API call'
|
||||
},
|
||||
{
|
||||
label: 'Cost per 1K Tokens',
|
||||
value: avgCostPerToken * 1000,
|
||||
unit: '$',
|
||||
trend: costTrend,
|
||||
description: 'Cost efficiency for token usage'
|
||||
},
|
||||
{
|
||||
label: 'Tokens per Dollar',
|
||||
value: costEfficiency,
|
||||
unit: '',
|
||||
trend: costTrend === 'down' ? 'up' : costTrend === 'up' ? 'down' : 'stable',
|
||||
description: 'How many tokens you get per dollar spent'
|
||||
},
|
||||
{
|
||||
label: 'Avg Response Time',
|
||||
value: avgResponseTime,
|
||||
unit: 's',
|
||||
trend: 'stable',
|
||||
description: 'Average API response time'
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: successRate,
|
||||
unit: '%',
|
||||
trend: successRate > 95 ? 'up' : successRate < 90 ? 'down' : 'stable',
|
||||
description: 'Percentage of successful API calls'
|
||||
},
|
||||
{
|
||||
label: 'Failed Calls',
|
||||
value: failedCalls,
|
||||
unit: '',
|
||||
trend: failedCalls > 0 ? 'down' : 'stable',
|
||||
description: 'Number of failed API calls'
|
||||
}
|
||||
] as EfficiencyMetric[];
|
||||
}, [usageLogs]);
|
||||
|
||||
const COLORS = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe', '#43e97b', '#fa709a', '#fee140', '#30cfd0', '#a8edea'];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.7)' }}>
|
||||
Loading analytics...
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Alert severity="error">
|
||||
{error}
|
||||
<Typography variant="body2" sx={{ mt: 1, color: 'rgba(255,255,255,0.7)' }}>
|
||||
Unable to load usage analytics. Please try refreshing the page.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show message if no logs available
|
||||
if (usageLogs.length === 0) {
|
||||
return (
|
||||
<Card sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Alert severity="info">
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
No Usage Data Available
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Usage analytics will appear here once you start making API calls.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', color: '#ffffff', mb: 2 }}>
|
||||
<BarChart3 size={20} />
|
||||
Advanced Cost Analytics
|
||||
</Typography>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||
sx={{
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
mb: 2,
|
||||
'& .MuiTab-root': {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
'&.Mui-selected': {
|
||||
color: '#667eea',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: '#667eea'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab icon={<Clock size={16} />} label="Time of Day" iconPosition="start" />
|
||||
<Tab icon={<Activity size={16} />} label="User Actions" iconPosition="start" />
|
||||
<Tab icon={<Zap size={16} />} label="Efficiency Metrics" iconPosition="start" />
|
||||
<Tab icon={<Calendar size={16} />} label="Daily Heatmap" iconPosition="start" />
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
{/* Time of Day Tab */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Cost Distribution by Hour of Day
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ height: 300, mb: 3 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyBarChart data={timeOfDayData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
stroke="rgba(255,255,255,0.9)"
|
||||
fontSize={10}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="rgba(255,255,255,0.9)"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `$${value.toFixed(2)}`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 8
|
||||
}}
|
||||
formatter={(value: number) => [formatCurrency(value), 'Cost']}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="#667eea" radius={[4, 4, 0, 0]} />
|
||||
</LazyBarChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
|
||||
{/* Peak Hours Summary */}
|
||||
<Grid container spacing={2}>
|
||||
{timeOfDayData
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 3)
|
||||
.map((data, idx) => (
|
||||
<Grid item xs={4} key={data.hour}>
|
||||
<Box sx={{ p: 1.5, backgroundColor: 'rgba(102, 126, 234, 0.1)', borderRadius: 1, textAlign: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Peak Hour {idx + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#667eea' }}>
|
||||
{data.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
{formatCurrency(data.cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* User Actions Tab */}
|
||||
{activeTab === 1 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Cost Breakdown by User Action
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ height: 300, mb: 3 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyPieChart>
|
||||
<Pie
|
||||
data={userActionData}
|
||||
dataKey="cost"
|
||||
nameKey="action"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label={(entry: any) => {
|
||||
const data = userActionData[entry.index];
|
||||
return data ? `${data.action}: ${(entry.percent * 100).toFixed(0)}%` : '';
|
||||
}}
|
||||
>
|
||||
{userActionData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 8
|
||||
}}
|
||||
/>
|
||||
</LazyPieChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
|
||||
{/* Top Actions Table */}
|
||||
<Box sx={{ maxHeight: 200, overflowY: 'auto' }}>
|
||||
{userActionData.map((action, idx) => (
|
||||
<Box
|
||||
key={action.endpoint}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
mb: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
{action.action}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
{action.calls} calls • {formatCurrency(action.avgCostPerCall)} avg
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: COLORS[idx % COLORS.length] }}>
|
||||
{formatCurrency(action.cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Efficiency Metrics Tab */}
|
||||
{activeTab === 2 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Cost Efficiency Metrics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{efficiencyMetrics.map((metric, idx) => (
|
||||
<Grid item xs={6} sm={4} key={metric.label}>
|
||||
<Tooltip title={metric.description} arrow>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
textAlign: 'center',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
{metric.label}
|
||||
</Typography>
|
||||
{metric.trend === 'up' && <TrendingUp size={14} color="#22c55e" />}
|
||||
{metric.trend === 'down' && <TrendingUp size={14} color="#ef4444" style={{ transform: 'rotate(180deg)' }} />}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: metric.trend === 'up' ? '#22c55e' :
|
||||
metric.trend === 'down' ? '#ef4444' : '#ffffff'
|
||||
}}
|
||||
>
|
||||
{metric.unit === '$' ? formatCurrency(metric.value) :
|
||||
metric.unit === '%' ? `${metric.value.toFixed(1)}%` :
|
||||
metric.unit === 's' ? `${metric.value.toFixed(2)}s` :
|
||||
formatNumber(metric.value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Daily Heatmap Tab */}
|
||||
{activeTab === 3 && (
|
||||
<Box>
|
||||
<DailyCostHeatmap
|
||||
usageLogs={usageLogs}
|
||||
currentMonth={new Date().getMonth()}
|
||||
currentYear={new Date().getFullYear()}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedCostAnalytics;
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Zap,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
// Services
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { monitoringService } from '../../services/monitoringService';
|
||||
|
||||
// Types
|
||||
import { DashboardData, UsageStats } from '../../types/billing';
|
||||
import { SystemHealth } from '../../types/monitoring';
|
||||
|
||||
// Components (we'll create these next)
|
||||
import BillingOverview from './BillingOverview';
|
||||
import CostBreakdown from './CostBreakdown';
|
||||
import UsageTrends from './UsageTrends';
|
||||
import SystemHealthIndicator from '../monitoring/SystemHealthIndicator';
|
||||
import UsageAlerts from './UsageAlerts';
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4 }
|
||||
}
|
||||
};
|
||||
|
||||
const BillingDashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// State management
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
// Fetch dashboard data
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔍 [DASHBOARD DEBUG] Starting data fetch...');
|
||||
|
||||
// Fetch billing and monitoring data in parallel
|
||||
const [billingData, healthData] = await Promise.all([
|
||||
billingService.getDashboardData(),
|
||||
monitoringService.getSystemHealth()
|
||||
]);
|
||||
|
||||
console.log('🔍 [DASHBOARD DEBUG] Received billing data:', billingData);
|
||||
console.log('🔍 [DASHBOARD DEBUG] Received health data:', healthData);
|
||||
console.log('🔍 [DASHBOARD DEBUG] Billing data current_usage:', billingData?.current_usage);
|
||||
console.log('🔍 [DASHBOARD DEBUG] Billing data summary:', billingData?.summary);
|
||||
console.log('🔍 [DASHBOARD DEBUG] Billing data trends:', billingData?.trends);
|
||||
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
setLastUpdated(new Date());
|
||||
console.log('✅ [DASHBOARD DEBUG] Data set successfully');
|
||||
} catch (err) {
|
||||
console.error('❌ [DASHBOARD DEBUG] Error fetching dashboard data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchDashboardData();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (loading && !dashboardData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '400px',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Loading billing dashboard...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !dashboardData) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
<motion.button
|
||||
onClick={fetchDashboardData}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
Retry
|
||||
</motion.button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboardData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{/* Section Header */}
|
||||
<motion.div variants={cardVariants}>
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
💰 Billing & Usage Dashboard
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Monitor your API usage, costs, and system performance in real-time
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Dashboard Grid */}
|
||||
<Grid container spacing={3}>
|
||||
{/* Top Row - Overview Cards */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<BillingOverview
|
||||
usageStats={dashboardData.current_usage}
|
||||
onRefresh={fetchDashboardData}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<SystemHealthIndicator
|
||||
systemHealth={systemHealth}
|
||||
onRefresh={fetchDashboardData}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<UsageAlerts
|
||||
alerts={dashboardData.alerts}
|
||||
onMarkRead={billingService.markAlertRead}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Middle Row - Cost Breakdown */}
|
||||
<Grid item xs={12} lg={6}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<CostBreakdown
|
||||
providerBreakdown={dashboardData.current_usage.provider_breakdown}
|
||||
totalCost={dashboardData.current_usage.total_cost}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Middle Row - Usage Trends */}
|
||||
<Grid item xs={12} lg={6}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<UsageTrends
|
||||
trends={dashboardData.trends}
|
||||
projections={dashboardData.projections}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Bottom Row - Detailed Metrics */}
|
||||
<Grid item xs={12}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BarChart3 size={20} />
|
||||
Detailed Usage Metrics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Usage Summary */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
|
||||
{dashboardData.current_usage.total_calls.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total API Calls
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
This month
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Token Usage */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: 'secondary.main', fontWeight: 'bold' }}>
|
||||
{(dashboardData.current_usage.total_tokens / 1000).toFixed(1)}k
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tokens Used
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
This month
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Average Response Time */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: 'warning.main', fontWeight: 'bold' }}>
|
||||
{dashboardData.current_usage.avg_response_time.toFixed(0)}ms
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Avg Response Time
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 24 hours
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Error Rate */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: dashboardData.current_usage.error_rate > 5 ? 'error.main' : 'success.main',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{dashboardData.current_usage.error_rate.toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Error Rate
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 24 hours
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingDashboard;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
calculateUsagePercentage
|
||||
} from '../../services/billingService';
|
||||
|
||||
// Shared Components
|
||||
import { AnimatedNumber } from '../shared/AnimatedNumber';
|
||||
import { AnimatedProgressBar } from '../shared/AnimatedProgressBar';
|
||||
import LiveCostCounter from './LiveCostCounter';
|
||||
|
||||
// Terminal Theme
|
||||
import {
|
||||
TerminalCard,
|
||||
@@ -55,14 +60,20 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
const CardComponent = terminalTheme ? TerminalCard : Card;
|
||||
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
|
||||
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
|
||||
// Debug logs removed to reduce console noise
|
||||
|
||||
const [previousCost, setPreviousCost] = useState<number | undefined>(undefined);
|
||||
|
||||
// Track previous cost for velocity calculation
|
||||
useEffect(() => {
|
||||
if (usageStats.total_cost !== undefined) {
|
||||
setPreviousCost(usageStats.total_cost);
|
||||
}
|
||||
}, [usageStats.total_cost]);
|
||||
|
||||
const costUsagePercentage = calculateUsagePercentage(
|
||||
usageStats.total_cost,
|
||||
usageStats.limits.limits.monthly_cost || 1
|
||||
);
|
||||
|
||||
// Debug logs removed to reduce console noise
|
||||
|
||||
const getStatusChip = () => {
|
||||
const status: string = usageStats.usage_status;
|
||||
@@ -186,28 +197,16 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
</CardContentComponent>
|
||||
|
||||
<CardContentComponent sx={{ pt: 0 }}>
|
||||
{/* Current Cost */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<TypographyComponent
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{formatCurrency(usageStats.total_cost)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)' }}>
|
||||
Total Cost This Month
|
||||
</TypographyComponent>
|
||||
</motion.div>
|
||||
{/* Live Cost Counter */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<LiveCostCounter
|
||||
currentCost={usageStats.total_cost}
|
||||
previousCost={previousCost}
|
||||
velocity={0} // TODO: Calculate from trends if available
|
||||
terminalTheme={terminalTheme}
|
||||
terminalColors={terminalColors}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Usage Metrics */}
|
||||
@@ -218,7 +217,10 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
API Calls
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{formatNumber(usageStats.total_calls)}
|
||||
<AnimatedNumber
|
||||
value={usageStats.total_calls}
|
||||
format={(n) => formatNumber(n)}
|
||||
/>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -229,7 +231,10 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
Tokens Used
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{formatNumber(usageStats.total_tokens)}
|
||||
<AnimatedNumber
|
||||
value={usageStats.total_tokens}
|
||||
format={(n) => formatNumber(n)}
|
||||
/>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -257,22 +262,16 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
{formatPercentage(costUsagePercentage)}
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
<AnimatedProgressBar
|
||||
value={Math.min(costUsagePercentage, 100)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: terminalTheme
|
||||
? (costUsagePercentage > 80 ? terminalColors.error :
|
||||
costUsagePercentage > 60 ? terminalColors.warning : terminalColors.success)
|
||||
: (costUsagePercentage > 80 ? '#ef4444' :
|
||||
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e'),
|
||||
borderRadius: 4,
|
||||
}
|
||||
}}
|
||||
height={8}
|
||||
color={terminalTheme
|
||||
? (costUsagePercentage > 80 ? terminalColors.error :
|
||||
costUsagePercentage > 60 ? terminalColors.warning : terminalColors.success)
|
||||
: (costUsagePercentage > 80 ? '#ef4444' :
|
||||
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e')
|
||||
}
|
||||
showPercentage={false}
|
||||
/>
|
||||
<TypographyComponent variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
{formatCurrency(usageStats.total_cost)} of {formatCurrency(usageStats.limits.limits.monthly_cost)} limit
|
||||
|
||||
@@ -1,837 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { DashboardData } from '../../types/billing';
|
||||
import { SystemHealth } from '../../types/monitoring';
|
||||
|
||||
// Services
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { monitoringService } from '../../services/monitoringService';
|
||||
import { onApiEvent } from '../../utils/apiEvents';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
|
||||
// Terminal Theme
|
||||
import {
|
||||
TerminalCard,
|
||||
TerminalCardContent,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
terminalColors
|
||||
} from '../SchedulerDashboard/terminalTheme';
|
||||
|
||||
interface CompactBillingDashboardProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userId, terminalTheme = false }) => {
|
||||
// Conditional component selection based on terminal theme
|
||||
const CardComponent = terminalTheme ? TerminalCard : Card;
|
||||
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
|
||||
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
|
||||
const ChipComponent = terminalTheme ? TerminalChip : Chip;
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async (showSuccessToast: boolean = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [billingData, healthData] = await Promise.all([
|
||||
billingService.getDashboardData(userId),
|
||||
monitoringService.getSystemHealth()
|
||||
]);
|
||||
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
|
||||
// Show success toast only if explicitly requested (user-initiated refresh)
|
||||
if (showSuccessToast && billingData && healthData) {
|
||||
showToastNotification(
|
||||
'Billing data refreshed successfully',
|
||||
'success',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch data';
|
||||
setError(errorMessage);
|
||||
|
||||
// Always show error toast for failures
|
||||
showToastNotification(errorMessage, 'error', { duration: 5000 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
// Event-driven refresh
|
||||
useEffect(() => {
|
||||
const lastRefreshRef = { current: 0 } as { current: number };
|
||||
const MIN_REFRESH_INTERVAL_MS = 4000;
|
||||
const unsubscribe = onApiEvent((detail) => {
|
||||
// Only react to non-billing/monitoring events to avoid feedback loops
|
||||
if (detail.source && detail.source !== 'other') return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshRef.current < MIN_REFRESH_INTERVAL_MS) return;
|
||||
lastRefreshRef.current = now;
|
||||
fetchData();
|
||||
});
|
||||
return unsubscribe;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const formatCurrency = (amount: number) => `$${amount.toFixed(4)}`;
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
|
||||
if (loading && !dashboardData) {
|
||||
const loadingCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 3
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<CardComponent sx={loadingCardStyles}>
|
||||
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.8)' }}>
|
||||
Loading billing data...
|
||||
</TypographyComponent>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.error}`,
|
||||
borderRadius: 3
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<CardComponent sx={errorCardStyles}>
|
||||
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.error : '#ff6b6b' }}>
|
||||
Error: {error}
|
||||
</TypographyComponent>
|
||||
<IconButton onClick={() => fetchData(true)} sx={{ mt: 1, color: terminalTheme ? terminalColors.text : 'inherit' }}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboardData) return null;
|
||||
|
||||
const { current_usage, limits, alerts } = dashboardData;
|
||||
|
||||
const mainCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 4,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
boxShadow: '0 0 15px rgba(0, 255, 0, 0.2)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: `linear-gradient(90deg, transparent, ${terminalColors.border}, transparent)`,
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
backdropFilter: 'blur(15px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||
zIndex: 1
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<CardComponent sx={mainCardStyles}>
|
||||
{/* Header - Removed to save space */}
|
||||
|
||||
<CardContentComponent sx={{ pt: 2 }}>
|
||||
{/* Compact Overview */}
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
{/* Total Cost */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
Monthly API Usage Cost
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Total spending across all AI providers this month
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Includes: Gemini, OpenAI, Anthropic, Mistral
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(74, 222, 128, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(74, 222, 128, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(74, 222, 128, 0.2)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{formatCurrency(current_usage.total_cost)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
Total Cost
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
{/* API Calls */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
API Request Volume
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Total number of AI API requests made this month
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Each request generates content, analyzes data, or processes information
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(59, 130, 246, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{formatNumber(current_usage.total_calls)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
API Calls
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
{/* Tokens */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
AI Processing Units
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Total tokens processed by AI models this month
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Tokens represent words, characters, and data processed by AI
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(168, 85, 247, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(168, 85, 247, 0.2)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #a855f7, #9333ea)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{(current_usage.total_tokens / 1000).toFixed(1)}k
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
Tokens
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
{/* System Health */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
System Performance Status
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Real-time monitoring of API services and system performance
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
{systemHealth?.status === 'healthy'
|
||||
? 'All systems operational and responding normally'
|
||||
: 'Some services may be experiencing issues'
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}40`,
|
||||
borderColor: systemHealth?.status === 'healthy' ? terminalColors.secondary : terminalColors.error
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.25)'
|
||||
: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: systemHealth?.status === 'healthy'
|
||||
? '0 8px 25px rgba(34, 197, 94, 0.2)'
|
||||
: '0 8px 25px rgba(239, 68, 68, 0.2)',
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: '1px solid rgba(239, 68, 68, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(90deg, #22c55e, #16a34a)'
|
||||
: 'linear-gradient(90deg, #ef4444, #dc2626)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<CheckCircle size={18} color={terminalTheme
|
||||
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
|
||||
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b')
|
||||
} />
|
||||
<TypographyComponent variant="body1" sx={{
|
||||
color: terminalTheme
|
||||
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
|
||||
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'),
|
||||
fontWeight: 700,
|
||||
textTransform: 'capitalize',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{systemHealth?.status || 'Unknown'}
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
System Health
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
||||
{/* Usage Progress */}
|
||||
{limits.limits.monthly_cost > 0 && (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
fontWeight: 600,
|
||||
mb: 0.5
|
||||
}}>
|
||||
Monthly Budget Usage
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
display: 'block'
|
||||
}}>
|
||||
Track your AI spending against monthly limits
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<TypographyComponent variant="h6" sx={{
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{formatCurrency(current_usage.total_cost)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
display: 'block'
|
||||
}}>
|
||||
of {formatCurrency(limits.limits.monthly_cost)}
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(current_usage.total_cost / limits.limits.monthly_cost) * 100}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: terminalTheme
|
||||
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
? terminalColors.error
|
||||
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
|
||||
? terminalColors.warning
|
||||
: terminalColors.success)
|
||||
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
? 'linear-gradient(90deg, #ff6b6b, #ff5252)'
|
||||
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
|
||||
? 'linear-gradient(90deg, #ffa726, #ff9800)'
|
||||
: 'linear-gradient(90deg, #4ade80, #22c55e)'),
|
||||
borderRadius: 4,
|
||||
boxShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.2)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme
|
||||
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? terminalColors.error : terminalColors.textSecondary)
|
||||
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? '#ff6b6b' : 'rgba(255,255,255,0.7)'),
|
||||
fontWeight: current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? 600 : 400
|
||||
}}>
|
||||
{current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
? '⚠️ Approaching limit'
|
||||
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
|
||||
? '⚡ Moderate usage'
|
||||
: '✅ Within budget'
|
||||
}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{((current_usage.total_cost / limits.limits.monthly_cost) * 100).toFixed(1)}% used
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
{alerts.length > 0 && (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.error}`,
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.error,
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 107, 107, 0.2)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AlertTriangle size={18} color={terminalTheme ? terminalColors.error : "#ff6b6b"} />
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
fontWeight: 700,
|
||||
color: terminalTheme ? terminalColors.error : '#ff6b6b',
|
||||
textShadow: terminalTheme ? 'none' : '0 1px 2px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
System Alerts ({alerts.length})
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
|
||||
display: 'block',
|
||||
mb: 2
|
||||
}}>
|
||||
Important notifications requiring your attention
|
||||
</TypographyComponent>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<Tooltip
|
||||
key={alert.id}
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{alert.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
{alert.message}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
{terminalTheme ? (
|
||||
<TerminalChipError
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.2)',
|
||||
color: '#ff6b6b',
|
||||
border: '1px solid rgba(255, 107, 107, 0.3)',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.3)',
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
{alerts.length > 3 && (
|
||||
terminalTheme ? (
|
||||
<TerminalChip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{ fontWeight: 500 }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactBillingDashboard;
|
||||
/**
|
||||
* @deprecated This file has been refactored into a modular structure.
|
||||
* Please import from './CompactBillingDashboard/index' instead.
|
||||
* This file is kept for backward compatibility and will be removed in a future version.
|
||||
*/
|
||||
export { default } from './CompactBillingDashboard/index';
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip } from '@mui/material';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Chip } from '@mui/material';
|
||||
import { TerminalTypography, TerminalChip, TerminalChipError } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { DashboardData } from '../../../../types/billing';
|
||||
|
||||
interface AlertsSectionProps {
|
||||
alerts: DashboardData['alerts'];
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
ChipComponent: typeof TerminalChip | typeof Chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* AlertsSection - Displays system alerts
|
||||
*/
|
||||
export const AlertsSection: React.FC<AlertsSectionProps> = ({
|
||||
alerts,
|
||||
terminalTheme = false,
|
||||
TypographyComponent,
|
||||
ChipComponent
|
||||
}) => {
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.error}`,
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.error,
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 107, 107, 0.2)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AlertTriangle size={18} color={terminalTheme ? terminalColors.error : "#ff6b6b"} />
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
fontWeight: 700,
|
||||
color: terminalTheme ? terminalColors.error : '#ff6b6b',
|
||||
textShadow: terminalTheme ? 'none' : '0 1px 2px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
System Alerts ({alerts.length})
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
|
||||
display: 'block',
|
||||
mb: 2
|
||||
}}>
|
||||
Important notifications requiring your attention
|
||||
</TypographyComponent>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<Tooltip
|
||||
key={alert.id}
|
||||
title={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{alert.title}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
{alert.message}
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
{terminalTheme ? (
|
||||
<TerminalChipError
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.2)',
|
||||
color: '#ff6b6b',
|
||||
border: '1px solid rgba(255, 107, 107, 0.3)',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.3)',
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
{alerts.length > 3 && (
|
||||
terminalTheme ? (
|
||||
<TerminalChip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{ fontWeight: 500 }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Tooltip } from '@mui/material';
|
||||
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { formatCurrency } from '../utils/formatting';
|
||||
import { DashboardData } from '../../../../types/billing';
|
||||
|
||||
interface CostEfficiencyMetricsProps {
|
||||
currentUsage: DashboardData['current_usage'];
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CostEfficiencyMetrics - Displays cost efficiency metrics (Avg Cost/Call, Cost/1K Tokens, Efficiency Score)
|
||||
*/
|
||||
export const CostEfficiencyMetrics: React.FC<CostEfficiencyMetricsProps> = ({
|
||||
currentUsage,
|
||||
terminalTheme = false,
|
||||
TypographyComponent
|
||||
}) => {
|
||||
if (currentUsage.total_calls === 0) return null;
|
||||
|
||||
const avgCostPerCall = currentUsage.total_cost / currentUsage.total_calls;
|
||||
const costPer1KTokens = currentUsage.total_tokens > 0
|
||||
? (currentUsage.total_cost / currentUsage.total_tokens) * 1000
|
||||
: 0;
|
||||
|
||||
const getEfficiencyScore = () => {
|
||||
if (avgCostPerCall < 0.01) return '⭐ Excellent';
|
||||
if (avgCostPerCall < 0.05) return '✅ Good';
|
||||
if (avgCostPerCall < 0.10) return '⚡ Fair';
|
||||
return '⚠️ High';
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
{/* Average Cost Per Call */}
|
||||
<Grid item xs={6} sm={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
Average Cost Per API Call
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Calculated as total cost divided by total API calls this month
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Lower values indicate more cost-efficient usage
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 10px ${terminalColors.border}30`,
|
||||
borderColor: terminalColors.secondary
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(79, 70, 229, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(99, 102, 241, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 20px rgba(99, 102, 241, 0.2)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.4)'
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h6" sx={{
|
||||
fontWeight: 700,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{formatCurrency(avgCostPerCall)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.7rem'
|
||||
}}>
|
||||
Avg Cost/Call
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
{/* Cost Per 1K Tokens */}
|
||||
{currentUsage.total_tokens > 0 && (
|
||||
<Grid item xs={6} sm={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
Cost Per 1,000 Tokens
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Average cost for processing 1,000 tokens (input + output)
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Useful for estimating costs of future operations
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 10px ${terminalColors.border}30`,
|
||||
borderColor: terminalColors.secondary
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(168, 85, 247, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 20px rgba(168, 85, 247, 0.2)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)'
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h6" sx={{
|
||||
fontWeight: 700,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{formatCurrency(costPer1KTokens)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.7rem'
|
||||
}}>
|
||||
Cost/1K Tokens
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Cost Efficiency Score */}
|
||||
<Grid item xs={6} sm={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
Cost Efficiency Indicator
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Based on average cost per call and token usage
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Lower cost per call = Higher efficiency
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 10px ${terminalColors.border}30`,
|
||||
borderColor: terminalColors.secondary
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(34, 197, 94, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 20px rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.4)'
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h6" sx={{
|
||||
fontWeight: 700,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{getEfficiencyScore()}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.7rem'
|
||||
}}>
|
||||
Efficiency
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip, IconButton } from '@mui/material';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
lastRefreshTime: Date | null;
|
||||
onRefresh: () => void;
|
||||
loading: boolean;
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardHeader - Displays last refresh time and refresh button
|
||||
*/
|
||||
export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
lastRefreshTime,
|
||||
onRefresh,
|
||||
loading,
|
||||
terminalTheme = false,
|
||||
TypographyComponent
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
{lastRefreshTime && (
|
||||
<Tooltip title={`Data last refreshed at ${lastRefreshTime.toLocaleTimeString()}`}>
|
||||
<TypographyComponent
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.6)',
|
||||
fontSize: '0.7rem',
|
||||
fontStyle: 'italic'
|
||||
}}
|
||||
>
|
||||
Last updated: {lastRefreshTime.toLocaleTimeString()}
|
||||
</TypographyComponent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Refresh billing data">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
'&:hover': {
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Tooltip, Typography } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { formatCurrency, formatNumber } from '../utils/formatting';
|
||||
import { DashboardData } from '../../../../types/billing';
|
||||
import { SystemHealth } from '../../../../types/monitoring';
|
||||
|
||||
interface MainMetricsGridProps {
|
||||
currentUsage: DashboardData['current_usage'];
|
||||
systemHealth: SystemHealth | null;
|
||||
healthError: string | null;
|
||||
sparklineData: {
|
||||
cost: Array<{ date: string; value: number }>;
|
||||
calls: Array<{ date: string; value: number }>;
|
||||
tokens: Array<{ date: string; value: number }>;
|
||||
};
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MainMetricsGrid - Displays the 4 main metric cards (Cost, Calls, Tokens, System Health)
|
||||
*/
|
||||
export const MainMetricsGrid: React.FC<MainMetricsGridProps> = ({
|
||||
currentUsage,
|
||||
systemHealth,
|
||||
healthError,
|
||||
sparklineData,
|
||||
terminalTheme = false,
|
||||
TypographyComponent
|
||||
}) => {
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
{/* Total Cost */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MetricCard
|
||||
title="Total Cost"
|
||||
value={currentUsage.total_cost}
|
||||
formatValue={formatCurrency}
|
||||
decimals={4}
|
||||
tooltip={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
Monthly API Usage Cost
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Total spending across all AI providers this month
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Includes: Gemini, OpenAI, Anthropic, Mistral
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
sparklineData={sparklineData.cost}
|
||||
sparklineColor={terminalTheme ? terminalColors.success : '#4ade80'}
|
||||
sparklineFormatValue={formatCurrency}
|
||||
sparklineLabel="Cost"
|
||||
gradientColors={{
|
||||
start: 'rgba(74, 222, 128, 0.12)',
|
||||
end: 'rgba(34, 197, 94, 0.08)',
|
||||
border: 'rgba(74, 222, 128, 0.25)',
|
||||
hoverBorder: 'rgba(74, 222, 128, 0.4)',
|
||||
topBar: 'linear-gradient(90deg, #4ade80, #22c55e)'
|
||||
}}
|
||||
animationDelay={0}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* API Calls */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MetricCard
|
||||
title="API Calls"
|
||||
value={currentUsage.total_calls}
|
||||
formatValue={formatNumber}
|
||||
tooltip={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
API Request Volume
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Total number of AI API requests made this month
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Each request generates content, analyzes data, or processes information
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
sparklineData={sparklineData.calls}
|
||||
sparklineColor={terminalTheme ? terminalColors.secondary : '#3b82f6'}
|
||||
sparklineFormatValue={formatNumber}
|
||||
sparklineLabel="API Calls"
|
||||
gradientColors={{
|
||||
start: 'rgba(59, 130, 246, 0.12)',
|
||||
end: 'rgba(37, 99, 235, 0.08)',
|
||||
border: 'rgba(59, 130, 246, 0.25)',
|
||||
hoverBorder: 'rgba(59, 130, 246, 0.4)',
|
||||
topBar: 'linear-gradient(90deg, #3b82f6, #2563eb)'
|
||||
}}
|
||||
animationDelay={0.1}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Tokens */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MetricCard
|
||||
title="Tokens"
|
||||
value={currentUsage.total_tokens / 1000}
|
||||
formatValue={(n) => `${n.toFixed(1)}k`}
|
||||
decimals={1}
|
||||
tooltip={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
AI Processing Units
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Total tokens processed by AI models this month
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
Tokens represent words, characters, and data processed by AI
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
}
|
||||
sparklineData={sparklineData.tokens}
|
||||
sparklineColor={terminalTheme ? terminalColors.warning : '#a855f7'}
|
||||
sparklineFormatValue={(n) => `${n.toFixed(1)}k`}
|
||||
sparklineLabel="Tokens (k)"
|
||||
gradientColors={{
|
||||
start: 'rgba(168, 85, 247, 0.12)',
|
||||
end: 'rgba(147, 51, 234, 0.08)',
|
||||
border: 'rgba(168, 85, 247, 0.25)',
|
||||
hoverBorder: 'rgba(168, 85, 247, 0.4)',
|
||||
topBar: 'linear-gradient(90deg, #a855f7, #9333ea)'
|
||||
}}
|
||||
animationDelay={0.2}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* System Health */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
System Performance Status
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Real-time monitoring of API services and system performance
|
||||
</TypographyComponent>
|
||||
{healthError && (
|
||||
<TypographyComponent variant="caption" sx={{ color: '#ff9800', mt: 1, display: 'block', fontWeight: 'bold' }}>
|
||||
⚠️ Showing last known values - Unable to fetch latest data
|
||||
</TypographyComponent>
|
||||
)}
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
|
||||
{systemHealth?.status === 'healthy'
|
||||
? 'All systems operational and responding normally'
|
||||
: systemHealth?.status === 'warning'
|
||||
? 'Some services may be experiencing issues'
|
||||
: systemHealth?.status === 'critical'
|
||||
? 'Critical issues detected'
|
||||
: 'Status unknown'
|
||||
}
|
||||
</TypographyComponent>
|
||||
{systemHealth?.timestamp && (
|
||||
<TypographyComponent variant="caption" sx={{ opacity: 0.6, mt: 0.5, display: 'block', fontSize: '0.65rem' }}>
|
||||
Last updated: {new Date(systemHealth.timestamp).toLocaleTimeString()}
|
||||
</TypographyComponent>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}40`,
|
||||
borderColor: systemHealth?.status === 'healthy' ? terminalColors.secondary : terminalColors.error
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.25)'
|
||||
: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: systemHealth?.status === 'healthy'
|
||||
? '0 8px 25px rgba(34, 197, 94, 0.2)'
|
||||
: '0 8px 25px rgba(239, 68, 68, 0.2)',
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: '1px solid rgba(239, 68, 68, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(90deg, #22c55e, #16a34a)'
|
||||
: 'linear-gradient(90deg, #ef4444, #dc2626)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<CheckCircle size={18} color={terminalTheme
|
||||
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
|
||||
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b')
|
||||
} />
|
||||
<TypographyComponent variant="body1" sx={{
|
||||
color: terminalTheme
|
||||
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
|
||||
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'),
|
||||
fontWeight: 700,
|
||||
textTransform: 'capitalize',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{systemHealth?.status || 'Unknown'}
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
System Health
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { AnimatedNumber } from '../../../shared/AnimatedNumber';
|
||||
import { MiniSparkline } from '../../../shared/MiniSparkline';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
formatValue: (n: number) => string;
|
||||
decimals?: number;
|
||||
tooltip: React.ReactNode;
|
||||
sparklineData?: Array<{ date: string; value: number }>;
|
||||
sparklineColor?: string;
|
||||
sparklineFormatValue?: (n: number) => string;
|
||||
sparklineLabel?: string;
|
||||
gradientColors: {
|
||||
start: string;
|
||||
end: string;
|
||||
border: string;
|
||||
hoverBorder: string;
|
||||
topBar: string;
|
||||
};
|
||||
animationDelay?: number;
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MetricCard - Reusable metric card component for displaying key metrics
|
||||
*/
|
||||
export const MetricCard: React.FC<MetricCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
formatValue,
|
||||
decimals = 0,
|
||||
tooltip,
|
||||
sparklineData,
|
||||
sparklineColor,
|
||||
sparklineFormatValue,
|
||||
sparklineLabel,
|
||||
gradientColors,
|
||||
animationDelay = 0,
|
||||
terminalTheme = false,
|
||||
TypographyComponent
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: animationDelay, duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
<Tooltip
|
||||
title={tooltip}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: `linear-gradient(135deg, ${gradientColors.start} 0%, ${gradientColors.end} 100%)`,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${gradientColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 8px 25px ${gradientColors.start.replace('0.12', '0.2')}`,
|
||||
border: `1px solid ${gradientColors.hoverBorder}`
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: gradientColors.topBar,
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
<AnimatedNumber
|
||||
value={value}
|
||||
format={formatValue}
|
||||
decimals={decimals}
|
||||
/>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
{title}
|
||||
</TypographyComponent>
|
||||
{sparklineData && sparklineData.length > 0 && sparklineColor && (
|
||||
<MiniSparkline
|
||||
data={sparklineData}
|
||||
color={sparklineColor}
|
||||
height={50}
|
||||
formatValue={sparklineFormatValue || formatValue}
|
||||
label={sparklineLabel || title}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip } from '@mui/material';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { AnimatedProgressBar } from '../../../shared/AnimatedProgressBar';
|
||||
import ProviderCostComparison from '../../ProviderCostComparison';
|
||||
import { formatCurrency } from '../utils/formatting';
|
||||
import { DashboardData } from '../../../../types/billing';
|
||||
|
||||
interface MonthlyBudgetUsageProps {
|
||||
currentUsage: DashboardData['current_usage'];
|
||||
limits: DashboardData['limits'];
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MonthlyBudgetUsage - Displays monthly budget usage with progress bar and provider breakdown
|
||||
*/
|
||||
export const MonthlyBudgetUsage: React.FC<MonthlyBudgetUsageProps> = ({
|
||||
currentUsage,
|
||||
limits,
|
||||
terminalTheme = false,
|
||||
TypographyComponent
|
||||
}) => {
|
||||
if (limits.limits.monthly_cost <= 0) return null;
|
||||
|
||||
const usagePercentage = (currentUsage.total_cost / limits.limits.monthly_cost) * 100;
|
||||
const remainingBudget = limits.limits.monthly_cost - currentUsage.total_cost;
|
||||
const daysInMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate();
|
||||
const currentDay = new Date().getDate();
|
||||
const daysRemaining = daysInMonth - currentDay;
|
||||
const avgDailyCost = currentUsage.total_cost / currentDay;
|
||||
const estimatedDaysUntilExhaustion = avgDailyCost > 0 ? Math.ceil(remainingBudget / avgDailyCost) : daysRemaining;
|
||||
|
||||
// Determine warning level
|
||||
const isCritical = usagePercentage >= 95;
|
||||
const isWarning = usagePercentage >= 80;
|
||||
const isModerate = usagePercentage >= 50;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* Enhanced Warning Banner */}
|
||||
{(isCritical || isWarning || isModerate) && (
|
||||
<Box sx={{
|
||||
mb: 2,
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: isCritical
|
||||
? terminalColors.error + '20'
|
||||
: isWarning
|
||||
? terminalColors.warning + '20'
|
||||
: terminalColors.secondary + '20',
|
||||
borderRadius: 3,
|
||||
border: `2px solid ${isCritical
|
||||
? terminalColors.error
|
||||
: isWarning
|
||||
? terminalColors.warning
|
||||
: terminalColors.secondary}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
background: isCritical
|
||||
? terminalColors.error
|
||||
: isWarning
|
||||
? terminalColors.warning
|
||||
: terminalColors.secondary,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: isCritical
|
||||
? 'linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(220, 38, 38, 0.15) 100%)'
|
||||
: isWarning
|
||||
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(217, 119, 6, 0.15) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(37, 99, 235, 0.15) 100%)',
|
||||
borderRadius: 3,
|
||||
border: `2px solid ${isCritical
|
||||
? 'rgba(239, 68, 68, 0.5)'
|
||||
: isWarning
|
||||
? 'rgba(245, 158, 11, 0.5)'
|
||||
: 'rgba(59, 130, 246, 0.5)'}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
background: isCritical
|
||||
? 'linear-gradient(90deg, #ef4444, #dc2626)'
|
||||
: isWarning
|
||||
? 'linear-gradient(90deg, #f59e0b, #d97706)'
|
||||
: 'linear-gradient(90deg, #3b82f6, #2563eb)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<AlertTriangle
|
||||
size={24}
|
||||
color={terminalTheme
|
||||
? (isCritical ? terminalColors.error : isWarning ? terminalColors.warning : terminalColors.secondary)
|
||||
: (isCritical ? '#ef4444' : isWarning ? '#f59e0b' : '#3b82f6')
|
||||
}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TypographyComponent variant="subtitle1" sx={{
|
||||
fontWeight: 700,
|
||||
color: terminalTheme
|
||||
? (isCritical ? terminalColors.error : isWarning ? terminalColors.warning : terminalColors.text)
|
||||
: (isCritical ? '#ef4444' : isWarning ? '#f59e0b' : '#ffffff'),
|
||||
mb: 0.5
|
||||
}}>
|
||||
{isCritical
|
||||
? '🚨 Critical: Approaching Budget Limit'
|
||||
: isWarning
|
||||
? '⚠️ Warning: High Budget Usage'
|
||||
: 'ℹ️ Notice: 50% of Budget Used'
|
||||
}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
mb: 1
|
||||
}}>
|
||||
{isCritical
|
||||
? `You've used ${usagePercentage.toFixed(1)}% of your monthly budget. Only ${formatCurrency(remainingBudget)} remaining.`
|
||||
: isWarning
|
||||
? `You've used ${usagePercentage.toFixed(1)}% of your monthly budget. ${formatCurrency(remainingBudget)} remaining.`
|
||||
: `You've used ${usagePercentage.toFixed(1)}% of your monthly budget. ${formatCurrency(remainingBudget)} remaining.`
|
||||
}
|
||||
</TypographyComponent>
|
||||
{avgDailyCost > 0 && estimatedDaysUntilExhaustion > 0 && (
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
|
||||
display: 'block',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{estimatedDaysUntilExhaustion <= daysRemaining
|
||||
? `At current spending rate, budget will be exhausted in ~${estimatedDaysUntilExhaustion} day${estimatedDaysUntilExhaustion !== 1 ? 's' : ''}.`
|
||||
: `Current spending rate is sustainable for the remainder of the month.`
|
||||
}
|
||||
</TypographyComponent>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Budget Progress Bar */}
|
||||
<Box sx={{
|
||||
p: 2.5,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box>
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
fontWeight: 600,
|
||||
mb: 0.5
|
||||
}}>
|
||||
Monthly Budget Usage
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
display: 'block'
|
||||
}}>
|
||||
Track your AI spending against monthly limits
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<TypographyComponent variant="h6" sx={{
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{formatCurrency(currentUsage.total_cost)}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
display: 'block'
|
||||
}}>
|
||||
of {formatCurrency(limits.limits.monthly_cost)}
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
<AnimatedProgressBar
|
||||
value={Math.min(usagePercentage, 100)}
|
||||
height={10}
|
||||
color={terminalTheme
|
||||
? (isCritical
|
||||
? terminalColors.error
|
||||
: isWarning
|
||||
? terminalColors.warning
|
||||
: isModerate
|
||||
? terminalColors.secondary
|
||||
: terminalColors.success)
|
||||
: (isCritical
|
||||
? '#ef4444'
|
||||
: isWarning
|
||||
? '#f59e0b'
|
||||
: isModerate
|
||||
? '#3b82f6'
|
||||
: '#4ade80')
|
||||
}
|
||||
showPercentage={false}
|
||||
/>
|
||||
|
||||
{/* Provider Cost Comparison Chart */}
|
||||
{currentUsage.provider_breakdown && Object.keys(currentUsage.provider_breakdown).length > 0 && (
|
||||
<ProviderCostComparison
|
||||
providerBreakdown={currentUsage.provider_breakdown}
|
||||
terminalTheme={terminalTheme}
|
||||
terminalColors={terminalColors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5 }}>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme
|
||||
? (isCritical ? terminalColors.error : isWarning ? terminalColors.warning : terminalColors.textSecondary)
|
||||
: (isCritical ? '#ef4444' : isWarning ? '#f59e0b' : 'rgba(255,255,255,0.7)'),
|
||||
fontWeight: (isCritical || isWarning) ? 600 : 400
|
||||
}}>
|
||||
{isCritical
|
||||
? '🚨 Critical: Approaching limit'
|
||||
: isWarning
|
||||
? '⚠️ Warning: High usage'
|
||||
: isModerate
|
||||
? 'ℹ️ Moderate usage'
|
||||
: '✅ Within budget'
|
||||
}
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{usagePercentage.toFixed(1)}% used • {formatCurrency(remainingBudget)} remaining
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
|
||||
import { UsageLimitRing } from '../../../shared/UsageLimitRing';
|
||||
import { DashboardData } from '../../../../types/billing';
|
||||
|
||||
interface UsageLimitRingsProps {
|
||||
currentUsage: DashboardData['current_usage'];
|
||||
limits: DashboardData['limits'];
|
||||
terminalTheme?: boolean;
|
||||
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UsageLimitRings - Displays circular progress rings for key usage limits
|
||||
*/
|
||||
export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
|
||||
currentUsage,
|
||||
limits,
|
||||
terminalTheme = false,
|
||||
TypographyComponent
|
||||
}) => {
|
||||
// Calculate image calls - check multiple possible sources
|
||||
const imageCalls = useMemo(() => {
|
||||
// Primary: provider_breakdown.image
|
||||
const imageFromBreakdown = currentUsage.provider_breakdown?.image?.calls ?? 0;
|
||||
const imageEditFromBreakdown = currentUsage.provider_breakdown?.image_edit?.calls ?? 0;
|
||||
|
||||
// Fallback: Check if there's a stability key (legacy)
|
||||
const stabilityFromBreakdown = currentUsage.provider_breakdown?.stability?.calls ?? 0;
|
||||
|
||||
// Sum all image-related calls
|
||||
const total = imageFromBreakdown + imageEditFromBreakdown + stabilityFromBreakdown;
|
||||
|
||||
// Debug logging (can be removed in production)
|
||||
if (total > 0 || imageFromBreakdown > 0 || stabilityFromBreakdown > 0) {
|
||||
console.log('[UsageLimitRings] Image calls calculation:', {
|
||||
image: imageFromBreakdown,
|
||||
image_edit: imageEditFromBreakdown,
|
||||
stability: stabilityFromBreakdown,
|
||||
total,
|
||||
provider_breakdown_keys: Object.keys(currentUsage.provider_breakdown || {})
|
||||
});
|
||||
}
|
||||
|
||||
return total;
|
||||
}, [currentUsage.provider_breakdown]);
|
||||
|
||||
// Calculate video calls - check multiple possible sources
|
||||
const videoCalls = useMemo(() => {
|
||||
// Primary: provider_breakdown.video
|
||||
const videoFromBreakdown = currentUsage.provider_breakdown?.video?.calls ?? 0;
|
||||
|
||||
// Debug logging (can be removed in production)
|
||||
if (videoFromBreakdown > 0) {
|
||||
console.log('[UsageLimitRings] Video calls calculation:', {
|
||||
video: videoFromBreakdown,
|
||||
provider_breakdown_keys: Object.keys(currentUsage.provider_breakdown || {})
|
||||
});
|
||||
}
|
||||
|
||||
return videoFromBreakdown;
|
||||
}, [currentUsage.provider_breakdown]);
|
||||
|
||||
const keyLimits = [
|
||||
{
|
||||
label: 'AI Calls',
|
||||
used: currentUsage.total_calls,
|
||||
limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: 'Images',
|
||||
used: imageCalls,
|
||||
limit: limits.limits.stability_calls || 50,
|
||||
color: '#a855f7'
|
||||
},
|
||||
{
|
||||
label: 'Videos',
|
||||
used: videoCalls,
|
||||
limit: limits.limits.video_calls || 30,
|
||||
color: '#ec4899'
|
||||
}
|
||||
].filter(item => item.limit > 0);
|
||||
|
||||
if (keyLimits.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
fontWeight: 600,
|
||||
mb: 2,
|
||||
color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.9)'
|
||||
}}>
|
||||
Usage Limits Overview
|
||||
</TypographyComponent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap', gap: 2 }}>
|
||||
{keyLimits.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.4 }}
|
||||
>
|
||||
<UsageLimitRing
|
||||
used={item.used}
|
||||
limit={item.limit}
|
||||
label={item.label}
|
||||
color={item.color}
|
||||
size={100}
|
||||
terminalTheme={terminalTheme}
|
||||
terminalColors={terminalColors}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { DashboardData } from '../../../../types/billing';
|
||||
import { SystemHealth } from '../../../../types/monitoring';
|
||||
import { billingService } from '../../../../services/billingService';
|
||||
import { monitoringService } from '../../../../services/monitoringService';
|
||||
import { onApiEvent } from '../../../../utils/apiEvents';
|
||||
import { showToastNotification } from '../../../../utils/toastNotifications';
|
||||
|
||||
interface UseCompactBillingDataReturn {
|
||||
dashboardData: DashboardData | null;
|
||||
systemHealth: SystemHealth | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastRefreshTime: Date | null;
|
||||
healthError: string | null;
|
||||
sparklineData: {
|
||||
cost: Array<{ date: string; value: number }>;
|
||||
calls: Array<{ date: string; value: number }>;
|
||||
tokens: Array<{ date: string; value: number }>;
|
||||
};
|
||||
refresh: (showSuccessToast?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing CompactBillingDashboard data fetching and state
|
||||
*/
|
||||
export const useCompactBillingData = (userId?: string): UseCompactBillingDataReturn => {
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const [healthError, setHealthError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async (showSuccessToast: boolean = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Use Promise.allSettled to prevent health check timeout from blocking dashboard
|
||||
const results = await Promise.allSettled([
|
||||
billingService.getDashboardData(userId),
|
||||
monitoringService.getSystemHealth()
|
||||
]);
|
||||
|
||||
// Handle billing data (required)
|
||||
if (results[0].status === 'fulfilled') {
|
||||
setDashboardData(results[0].value);
|
||||
setLastRefreshTime(new Date());
|
||||
setError(null); // Clear any previous errors
|
||||
} else {
|
||||
// Billing data is critical - show error
|
||||
const errorMessage = results[0].reason instanceof Error
|
||||
? results[0].reason.message
|
||||
: 'Failed to fetch data';
|
||||
setError(errorMessage);
|
||||
showToastNotification(
|
||||
`Unable to fetch latest billing data: ${errorMessage}. Showing last known values.`,
|
||||
'error',
|
||||
{ duration: 7000 }
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle health data (optional - don't block dashboard if it fails)
|
||||
if (results[1].status === 'fulfilled') {
|
||||
setSystemHealth(results[1].value);
|
||||
setHealthError(null); // Clear health error on success
|
||||
} else {
|
||||
// Health check failed - keep last successful value, show error toast
|
||||
const healthErrorMessage = results[1].reason instanceof Error
|
||||
? results[1].reason.message
|
||||
: 'System health check timed out or failed';
|
||||
setHealthError(healthErrorMessage);
|
||||
showToastNotification(
|
||||
`Unable to fetch latest system health data: ${healthErrorMessage}. Showing last known values.`,
|
||||
'warning',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
// Don't update systemHealth - keep last successful value
|
||||
// Only set to null if we never had a successful fetch
|
||||
if (!systemHealth) {
|
||||
setSystemHealth(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast only if explicitly requested (user-initiated refresh)
|
||||
if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'fulfilled') {
|
||||
showToastNotification(
|
||||
'Billing data refreshed successfully',
|
||||
'success',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} else if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'rejected') {
|
||||
showToastNotification(
|
||||
'Billing data refreshed, but system health check failed',
|
||||
'warning',
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback error handling (shouldn't reach here with allSettled)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch data';
|
||||
setError(errorMessage);
|
||||
showToastNotification(errorMessage, 'error', { duration: 5000 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
// Event-driven refresh
|
||||
useEffect(() => {
|
||||
const lastRefreshRef = { current: 0 } as { current: number };
|
||||
const MIN_REFRESH_INTERVAL_MS = 4000;
|
||||
const unsubscribe = onApiEvent((detail) => {
|
||||
// Only react to non-billing/monitoring events to avoid feedback loops
|
||||
if (detail.source && detail.source !== 'other') return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshRef.current < MIN_REFRESH_INTERVAL_MS) return;
|
||||
lastRefreshRef.current = now;
|
||||
Promise.allSettled([billingService.getDashboardData(userId), monitoringService.getSystemHealth()])
|
||||
.then((results) => {
|
||||
if (results[0].status === 'fulfilled') {
|
||||
setDashboardData(results[0].value);
|
||||
setLastRefreshTime(new Date());
|
||||
setError(null);
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
setSystemHealth(results[1].value);
|
||||
setHealthError(null);
|
||||
} else {
|
||||
// Keep last successful health value, don't set fake defaults
|
||||
const healthErrorMessage = results[1].reason instanceof Error
|
||||
? results[1].reason.message
|
||||
: 'System health check failed';
|
||||
setHealthError(healthErrorMessage);
|
||||
// Don't update systemHealth - keep last successful value
|
||||
}
|
||||
})
|
||||
.catch(() => {/* ignore */});
|
||||
});
|
||||
return unsubscribe;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Prepare sparkline data for last 7 days (or available data)
|
||||
const sparklineData = useMemo(() => {
|
||||
if (!dashboardData || !dashboardData.trends || !dashboardData.trends.periods || dashboardData.trends.periods.length === 0) {
|
||||
return {
|
||||
cost: [],
|
||||
calls: [],
|
||||
tokens: []
|
||||
};
|
||||
}
|
||||
|
||||
const { trends } = dashboardData;
|
||||
|
||||
// Get last 7 periods (or all if less than 7)
|
||||
const last7Periods = trends.periods.slice(-7);
|
||||
const last7Costs = trends.total_cost.slice(-7);
|
||||
const last7Calls = trends.total_calls.slice(-7);
|
||||
const last7Tokens = trends.total_tokens.slice(-7);
|
||||
|
||||
return {
|
||||
cost: last7Periods.map((period, index) => ({
|
||||
date: period,
|
||||
value: last7Costs[index] || 0
|
||||
})),
|
||||
calls: last7Periods.map((period, index) => ({
|
||||
date: period,
|
||||
value: last7Calls[index] || 0
|
||||
})),
|
||||
tokens: last7Periods.map((period, index) => ({
|
||||
date: period,
|
||||
value: (last7Tokens[index] || 0) / 1000 // Convert to thousands
|
||||
}))
|
||||
};
|
||||
}, [dashboardData]);
|
||||
|
||||
return {
|
||||
dashboardData,
|
||||
systemHealth,
|
||||
loading,
|
||||
error,
|
||||
lastRefreshTime,
|
||||
healthError,
|
||||
sparklineData,
|
||||
refresh: fetchData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Chip } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
TerminalCard,
|
||||
TerminalCardContent,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
terminalColors
|
||||
} from '../../SchedulerDashboard/terminalTheme';
|
||||
|
||||
// Hooks
|
||||
import { useCompactBillingData } from './hooks/useCompactBillingData';
|
||||
|
||||
// Components
|
||||
import { DashboardHeader } from './components/DashboardHeader';
|
||||
import { MainMetricsGrid } from './components/MainMetricsGrid';
|
||||
import { CostEfficiencyMetrics } from './components/CostEfficiencyMetrics';
|
||||
import { UsageLimitRings } from './components/UsageLimitRings';
|
||||
import { MonthlyBudgetUsage } from './components/MonthlyBudgetUsage';
|
||||
import { AlertsSection } from './components/AlertsSection';
|
||||
|
||||
interface CompactBillingDashboardProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CompactBillingDashboard - Main orchestrator component
|
||||
*
|
||||
* Refactored from monolithic component into modular structure:
|
||||
* - Data fetching: useCompactBillingData hook
|
||||
* - UI Components: Separated into focused, reusable components
|
||||
* - Utils: Formatting utilities extracted
|
||||
*/
|
||||
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({
|
||||
userId,
|
||||
terminalTheme = false
|
||||
}) => {
|
||||
// Conditional component selection based on terminal theme
|
||||
const CardComponent = terminalTheme ? TerminalCard : Card;
|
||||
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
|
||||
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
|
||||
const ChipComponent = terminalTheme ? TerminalChip : Chip;
|
||||
|
||||
// Data fetching hook
|
||||
const {
|
||||
dashboardData,
|
||||
systemHealth,
|
||||
loading,
|
||||
error,
|
||||
lastRefreshTime,
|
||||
healthError,
|
||||
sparklineData,
|
||||
refresh
|
||||
} = useCompactBillingData(userId);
|
||||
|
||||
// Loading state
|
||||
if (loading && !dashboardData) {
|
||||
const loadingCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 3
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<CardComponent sx={loadingCardStyles}>
|
||||
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.8)' }}>
|
||||
Loading billing data...
|
||||
</TypographyComponent>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
const errorCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.error}`,
|
||||
borderRadius: 3
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<CardComponent sx={errorCardStyles}>
|
||||
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.error : '#ff6b6b' }}>
|
||||
Error: {error}
|
||||
</TypographyComponent>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboardData) return null;
|
||||
|
||||
const { current_usage, limits, alerts } = dashboardData;
|
||||
|
||||
const mainCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 4,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
boxShadow: '0 0 15px rgba(0, 255, 0, 0.2)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: `linear-gradient(90deg, transparent, ${terminalColors.border}, transparent)`,
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
backdropFilter: 'blur(15px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||
zIndex: 1
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<CardComponent sx={mainCardStyles}>
|
||||
<CardContentComponent sx={{ pt: 2 }}>
|
||||
{/* Header */}
|
||||
<DashboardHeader
|
||||
lastRefreshTime={lastRefreshTime}
|
||||
onRefresh={() => refresh(true)}
|
||||
loading={loading}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
|
||||
{/* Main Metrics Grid */}
|
||||
<MainMetricsGrid
|
||||
currentUsage={current_usage}
|
||||
systemHealth={systemHealth}
|
||||
healthError={healthError}
|
||||
sparklineData={sparklineData}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
|
||||
{/* Cost Efficiency Metrics */}
|
||||
<CostEfficiencyMetrics
|
||||
currentUsage={current_usage}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
|
||||
{/* Usage Limit Rings */}
|
||||
<UsageLimitRings
|
||||
currentUsage={current_usage}
|
||||
limits={limits}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
|
||||
{/* Monthly Budget Usage */}
|
||||
<MonthlyBudgetUsage
|
||||
currentUsage={current_usage}
|
||||
limits={limits}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
/>
|
||||
|
||||
{/* Alerts Section */}
|
||||
<AlertsSection
|
||||
alerts={alerts}
|
||||
terminalTheme={terminalTheme}
|
||||
TypographyComponent={TypographyComponent}
|
||||
ChipComponent={ChipComponent}
|
||||
/>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactBillingDashboard;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Formatting utilities for CompactBillingDashboard
|
||||
*/
|
||||
|
||||
export const formatCurrency = (amount: number): string => `$${amount.toFixed(4)}`;
|
||||
|
||||
export const formatNumber = (num: number): string => num.toLocaleString();
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -20,11 +21,15 @@ import {
|
||||
Code,
|
||||
Database,
|
||||
FileText,
|
||||
BarChart3
|
||||
BarChart3,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { ProviderBreakdown } from '../../types/billing';
|
||||
import { ProviderBreakdown, APIPricing } from '../../types/billing';
|
||||
|
||||
// Services
|
||||
import { billingService } from '../../services/billingService';
|
||||
|
||||
interface ComprehensiveAPIBreakdownProps {
|
||||
providerBreakdown: ProviderBreakdown;
|
||||
@@ -151,10 +156,96 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
providerBreakdown,
|
||||
totalCost
|
||||
}) => {
|
||||
const [pricing, setPricing] = useState<APIPricing[]>([]);
|
||||
const [loadingPricing, setLoadingPricing] = useState(true);
|
||||
const [pricingError, setPricingError] = useState<string | null>(null);
|
||||
|
||||
// Fetch dynamic pricing on mount
|
||||
useEffect(() => {
|
||||
const fetchPricing = async () => {
|
||||
try {
|
||||
setLoadingPricing(true);
|
||||
setPricingError(null);
|
||||
const pricingData = await billingService.getAPIPricing();
|
||||
setPricing(pricingData);
|
||||
} catch (err) {
|
||||
console.error('[ComprehensiveAPIBreakdown] Error fetching pricing:', err);
|
||||
setPricingError(err instanceof Error ? err.message : 'Failed to fetch pricing');
|
||||
} finally {
|
||||
setLoadingPricing(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPricing();
|
||||
}, []);
|
||||
|
||||
// Get active providers from breakdown
|
||||
const activeProviders = Object.entries(providerBreakdown)
|
||||
.filter(([_, data]) => data.cost > 0)
|
||||
.map(([provider, data]) => ({ provider, ...data }));
|
||||
.filter(([_, data]) => data && data.cost > 0)
|
||||
.map(([provider, data]) => ({
|
||||
provider,
|
||||
cost: data?.cost ?? 0,
|
||||
calls: data?.calls ?? 0,
|
||||
tokens: data?.tokens ?? 0
|
||||
}));
|
||||
|
||||
// Helper function to format pricing from API or fallback to static
|
||||
const getPricingDisplay = (apiName: string, fallbackPricing: string): string => {
|
||||
if (loadingPricing) {
|
||||
return 'Loading pricing...';
|
||||
}
|
||||
|
||||
if (pricingError) {
|
||||
return fallbackPricing; // Fallback to static pricing on error
|
||||
}
|
||||
|
||||
// Find matching pricing by provider name
|
||||
const apiPricing = pricing.find(p =>
|
||||
p.provider.toLowerCase() === apiName.toLowerCase() ||
|
||||
p.model_name.toLowerCase().includes(apiName.toLowerCase())
|
||||
);
|
||||
|
||||
if (apiPricing) {
|
||||
// Format pricing based on what's available
|
||||
const parts: string[] = [];
|
||||
|
||||
if (apiPricing.cost_per_input_token > 0 || apiPricing.cost_per_output_token > 0) {
|
||||
const inputCost = apiPricing.cost_per_input_token > 0
|
||||
? `$${(apiPricing.cost_per_input_token * 1000000).toFixed(2)}/1M input`
|
||||
: '';
|
||||
const outputCost = apiPricing.cost_per_output_token > 0
|
||||
? `$${(apiPricing.cost_per_output_token * 1000000).toFixed(2)}/1M output`
|
||||
: '';
|
||||
if (inputCost && outputCost) {
|
||||
parts.push(`${inputCost}, ${outputCost}`);
|
||||
} else if (inputCost) {
|
||||
parts.push(inputCost);
|
||||
} else if (outputCost) {
|
||||
parts.push(outputCost);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiPricing.cost_per_request > 0) {
|
||||
parts.push(`$${apiPricing.cost_per_request.toFixed(4)} per request`);
|
||||
}
|
||||
|
||||
if (apiPricing.cost_per_search > 0) {
|
||||
parts.push(`$${apiPricing.cost_per_search.toFixed(4)} per search`);
|
||||
}
|
||||
|
||||
if (apiPricing.cost_per_image > 0) {
|
||||
parts.push(`$${apiPricing.cost_per_image.toFixed(2)} per image`);
|
||||
}
|
||||
|
||||
if (apiPricing.cost_per_page > 0) {
|
||||
parts.push(`$${apiPricing.cost_per_page.toFixed(4)} per page`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : fallbackPricing;
|
||||
}
|
||||
|
||||
return fallbackPricing; // Fallback to static pricing if not found
|
||||
};
|
||||
|
||||
const getProviderCategory = (providerName: string) => {
|
||||
const provider = providerName.toLowerCase();
|
||||
@@ -180,9 +271,9 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
|
||||
return {
|
||||
count: categoryProviders.length,
|
||||
totalCost: categoryProviders.reduce((sum, p) => sum + p.cost, 0),
|
||||
totalCalls: categoryProviders.reduce((sum, p) => sum + p.calls, 0),
|
||||
totalTokens: categoryProviders.reduce((sum, p) => sum + p.tokens, 0)
|
||||
totalCost: categoryProviders.reduce((sum, p) => sum + (p.cost ?? 0), 0),
|
||||
totalCalls: categoryProviders.reduce((sum, p) => sum + (p.calls ?? 0), 0),
|
||||
totalTokens: categoryProviders.reduce((sum, p) => sum + (p.tokens ?? 0), 0)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -210,9 +301,35 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
<BarChart3 size={20} />
|
||||
Comprehensive API Breakdown
|
||||
</Typography>
|
||||
<Tooltip title="Detailed breakdown of all API usage across categories">
|
||||
<Info size={16} color="rgba(255,255,255,0.7)" />
|
||||
</Tooltip>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{loadingPricing && (
|
||||
<CircularProgress size={16} sx={{ color: 'rgba(255,255,255,0.7)' }} />
|
||||
)}
|
||||
<Tooltip title={pricingError ? `Pricing error: ${pricingError}. Showing static pricing.` : "Detailed breakdown with real-time pricing from API"}>
|
||||
<Info size={16} color="rgba(255,255,255,0.7)" />
|
||||
</Tooltip>
|
||||
{!loadingPricing && !pricingError && (
|
||||
<Tooltip title="Refresh pricing">
|
||||
<RefreshCw
|
||||
size={16}
|
||||
color="rgba(255,255,255,0.7)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoadingPricing(true);
|
||||
const pricingData = await billingService.getAPIPricing();
|
||||
setPricing(pricingData);
|
||||
setPricingError(null);
|
||||
} catch (err) {
|
||||
setPricingError(err instanceof Error ? err.message : 'Failed to refresh pricing');
|
||||
} finally {
|
||||
setLoadingPricing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
@@ -253,7 +370,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
{activeProviders.reduce((sum, p) => sum + p.calls, 0)}
|
||||
{activeProviders.reduce((sum, p) => sum + (p.calls ?? 0), 0)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Total Calls
|
||||
@@ -352,9 +469,50 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
{api.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', display: 'block', mb: 1 }}>
|
||||
Pricing: {api.pricing}
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
Current Pricing
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{loadingPricing
|
||||
? 'Loading...'
|
||||
: pricingError
|
||||
? `Using fallback pricing. Error: ${pricingError}`
|
||||
: 'Real-time pricing from API'
|
||||
}
|
||||
</Typography>
|
||||
{!loadingPricing && !pricingError && (
|
||||
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
|
||||
Last updated: {pricing.find(p =>
|
||||
p.provider.toLowerCase() === api.name.toLowerCase()
|
||||
)?.effective_date || 'N/A'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: loadingPricing
|
||||
? 'rgba(255,255,255,0.5)'
|
||||
: pricingError
|
||||
? 'rgba(255,193,7,0.8)'
|
||||
: 'rgba(74, 222, 128, 0.9)',
|
||||
display: 'block',
|
||||
mb: 1,
|
||||
fontWeight: !loadingPricing && !pricingError ? 500 : 400
|
||||
}}
|
||||
>
|
||||
Pricing: {getPricingDisplay(api.name, api.pricing)}
|
||||
{!loadingPricing && !pricingError && ' ✓'}
|
||||
{pricingError && ' (static)'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{providerData && (
|
||||
<Box sx={{ mt: 2, p: 1, backgroundColor: 'rgba(0,0,0,0.2)', borderRadius: 1 }}>
|
||||
@@ -364,7 +522,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
Cost
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#4ade80', fontWeight: 'bold' }}>
|
||||
${providerData.cost.toFixed(4)}
|
||||
${(providerData.cost ?? 0).toFixed(4)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
@@ -372,7 +530,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
Calls
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#ffffff', fontWeight: 'bold' }}>
|
||||
{providerData.calls}
|
||||
{providerData.calls ?? 0}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
@@ -380,7 +538,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
|
||||
Tokens
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#ffffff', fontWeight: 'bold' }}>
|
||||
{providerData.tokens.toLocaleString()}
|
||||
{(providerData.tokens ?? 0).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -35,12 +35,12 @@ const CostBreakdown: React.FC<CostBreakdownProps> = ({
|
||||
}) => {
|
||||
// Transform data for pie chart
|
||||
const chartData = Object.entries(providerBreakdown)
|
||||
.filter(([_, data]) => data.cost > 0)
|
||||
.filter(([_, data]) => data && data.cost > 0)
|
||||
.map(([provider, data]) => ({
|
||||
name: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
value: data.cost,
|
||||
calls: data.calls,
|
||||
tokens: data.tokens,
|
||||
value: data?.cost ?? 0,
|
||||
calls: data?.calls ?? 0,
|
||||
tokens: data?.tokens ?? 0,
|
||||
color: getProviderColor(provider),
|
||||
icon: getProviderIcon(provider)
|
||||
}))
|
||||
@@ -124,9 +124,14 @@ const CostBreakdown: React.FC<CostBreakdownProps> = ({
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
animationBegin={0}
|
||||
animationDuration={1000}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
308
frontend/src/components/billing/CostEstimationModal.tsx
Normal file
308
frontend/src/components/billing/CostEstimationModal.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DollarSign,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { PreflightCheckResponse, PreflightOperation } from '../../services/billingService';
|
||||
|
||||
// Services
|
||||
import { billingService, formatCurrency, checkPreflightBatch } from '../../services/billingService';
|
||||
|
||||
interface CostEstimationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
operations: PreflightOperation[];
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const CostEstimationModal: React.FC<CostEstimationModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
operations,
|
||||
userId
|
||||
}) => {
|
||||
const [estimation, setEstimation] = useState<PreflightCheckResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && operations.length > 0) {
|
||||
fetchEstimation();
|
||||
} else {
|
||||
setEstimation(null);
|
||||
setError(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, JSON.stringify(operations)]);
|
||||
|
||||
const fetchEstimation = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await checkPreflightBatch(operations);
|
||||
setEstimation(result);
|
||||
} catch (err) {
|
||||
console.error('[CostEstimationModal] Error fetching estimation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to estimate costs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (estimation?.can_proceed) {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.98) 100%)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
pb: 1,
|
||||
borderBottom: '1px solid rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<DollarSign size={24} color="#667eea" />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
|
||||
Cost Estimation
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Estimated cost for this operation
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pt: 3 }}>
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress size={40} sx={{ color: '#667eea', mb: 2 }} />
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Calculating estimated costs...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{estimation && !loading && (
|
||||
<>
|
||||
{/* Overall Estimation */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
mb: 3,
|
||||
background: estimation.can_proceed
|
||||
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(22, 163, 74, 0.05) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.05) 100%)',
|
||||
borderRadius: 2,
|
||||
border: `2px solid ${estimation.can_proceed ? '#22c55e' : '#ef4444'}`,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{estimation.can_proceed ? (
|
||||
<CheckCircle size={24} color="#22c55e" />
|
||||
) : (
|
||||
<AlertTriangle size={24} color="#ef4444" />
|
||||
)}
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', color: estimation.can_proceed ? '#22c55e' : '#ef4444' }}>
|
||||
{formatCurrency(estimation.total_cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 1 }}>
|
||||
Estimated Total Cost
|
||||
</Typography>
|
||||
{!estimation.can_proceed && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
This operation cannot proceed. {estimation.operations.find(op => !op.allowed)?.message || 'Limit exceeded'}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Operation Breakdown */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 2, color: '#1e293b' }}>
|
||||
Operation Breakdown
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
{estimation.operations.map((op, index) => (
|
||||
<Grid item xs={12} key={index}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: op.allowed ? 'rgba(34, 197, 94, 0.05)' : 'rgba(239, 68, 68, 0.05)',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${op.allowed ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
|
||||
{op.provider}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={op.allowed ? 'Allowed' : 'Blocked'}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: op.allowed ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
|
||||
color: op.allowed ? '#22c55e' : '#ef4444',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Operation:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: '#1e293b' }}>
|
||||
{op.operation_type}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Estimated Cost:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: '#667eea' }}>
|
||||
{formatCurrency(op.cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{op.limit_info && (
|
||||
<Box sx={{ mt: 1, pt: 1, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Usage: {op.limit_info.current_usage} / {op.limit_info.limit}
|
||||
({((op.limit_info.current_usage / op.limit_info.limit) * 100).toFixed(1)}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{op.message && (
|
||||
<Typography variant="caption" sx={{ color: op.allowed ? '#22c55e' : '#ef4444', display: 'block', mt: 0.5 }}>
|
||||
{op.message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Usage Summary */}
|
||||
{estimation.usage_summary && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box sx={{ p: 2, backgroundColor: 'rgba(102, 126, 234, 0.05)', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1, color: '#1e293b' }}>
|
||||
Current Usage Summary
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Current Calls:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
|
||||
{estimation.usage_summary.current_calls}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Limit:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
|
||||
{estimation.usage_summary.limit}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Remaining:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: estimation.usage_summary.remaining > 0 ? '#22c55e' : '#ef4444'
|
||||
}}
|
||||
>
|
||||
{estimation.usage_summary.remaining}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info Note */}
|
||||
<Box sx={{ mt: 2, p: 1.5, backgroundColor: 'rgba(59, 130, 246, 0.05)', borderRadius: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Info size={16} color="#3b82f6" style={{ marginTop: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
This is an estimate. Actual costs may vary based on token usage and API response length.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Button onClick={onClose} sx={{ color: '#64748b' }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="contained"
|
||||
disabled={loading || !estimation?.can_proceed}
|
||||
sx={{
|
||||
background: estimation?.can_proceed
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: 'rgba(100, 116, 139, 0.3)',
|
||||
'&:hover': {
|
||||
background: estimation?.can_proceed
|
||||
? 'linear-gradient(135deg, #5568d3 0%, #6b3d91 100%)'
|
||||
: 'rgba(100, 116, 139, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? 'Calculating...' : estimation?.can_proceed ? 'Proceed' : 'Cannot Proceed'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostEstimationModal;
|
||||
@@ -0,0 +1,443 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Lightbulb,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
ArrowRight,
|
||||
Info,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { UsageLog } from '../../types/billing';
|
||||
|
||||
// Services
|
||||
import { billingService, formatCurrency } from '../../services/billingService';
|
||||
|
||||
interface CostOptimizationRecommendationsProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
interface OptimizationRecommendation {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
potentialSavings: number;
|
||||
savingsPercentage: number;
|
||||
category: 'model_switch' | 'provider_switch' | 'usage_pattern' | 'efficiency';
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
actionItems: string[];
|
||||
currentCost: number;
|
||||
recommendedCost: number;
|
||||
}
|
||||
|
||||
const CostOptimizationRecommendations: React.FC<CostOptimizationRecommendationsProps> = ({
|
||||
userId,
|
||||
terminalTheme = false
|
||||
}) => {
|
||||
const [usageLogs, setUsageLogs] = useState<UsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsageLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const currentDate = new Date();
|
||||
const billingPeriod = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const response = await billingService.getUsageLogs(1000, 0, undefined, undefined, billingPeriod);
|
||||
setUsageLogs(response.logs || []);
|
||||
} catch (err) {
|
||||
console.error('[CostOptimizationRecommendations] Error fetching usage logs:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch usage logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsageLogs();
|
||||
}, [userId]);
|
||||
|
||||
// Analyze usage patterns and generate recommendations
|
||||
const recommendations = useMemo(() => {
|
||||
if (usageLogs.length === 0) return [];
|
||||
|
||||
const recs: OptimizationRecommendation[] = [];
|
||||
|
||||
// 1. Model Switch Recommendations
|
||||
// Analyze if user is using expensive models when cheaper alternatives exist
|
||||
const modelUsage = usageLogs.reduce((acc, log) => {
|
||||
if (!log.model_used || log.cost_total === 0) return acc;
|
||||
|
||||
const key = `${log.provider}:${log.model_used}`;
|
||||
if (!acc[key]) {
|
||||
acc[key] = { cost: 0, calls: 0, tokens: 0 };
|
||||
}
|
||||
acc[key].cost += log.cost_total;
|
||||
acc[key].calls += 1;
|
||||
acc[key].tokens += log.tokens_total;
|
||||
return acc;
|
||||
}, {} as Record<string, { cost: number; calls: number; tokens: number }>);
|
||||
|
||||
// Check for Gemini Pro usage when Flash could work
|
||||
const geminiProUsage = Object.entries(modelUsage).find(([key]) =>
|
||||
key.includes('gemini') && key.includes('pro') && !key.includes('flash')
|
||||
);
|
||||
if (geminiProUsage) {
|
||||
const [_, data] = geminiProUsage;
|
||||
// Estimate Flash cost (typically 10-20x cheaper)
|
||||
const estimatedFlashCost = data.cost * 0.1; // Conservative estimate
|
||||
const savings = data.cost - estimatedFlashCost;
|
||||
|
||||
if (savings > 0.01) { // Only show if savings > $0.01
|
||||
recs.push({
|
||||
id: 'gemini-pro-to-flash',
|
||||
title: 'Switch Gemini Pro to Flash for Simple Tasks',
|
||||
description: `You're using Gemini Pro for ${data.calls} calls. Consider using Gemini Flash for simpler tasks - it's 10x cheaper with similar quality for most use cases.`,
|
||||
potentialSavings: savings,
|
||||
savingsPercentage: (savings / data.cost) * 100,
|
||||
category: 'model_switch',
|
||||
priority: 'high',
|
||||
actionItems: [
|
||||
'Use Gemini Flash for content generation',
|
||||
'Use Gemini Flash for simple Q&A',
|
||||
'Reserve Gemini Pro for complex reasoning tasks only'
|
||||
],
|
||||
currentCost: data.cost,
|
||||
recommendedCost: estimatedFlashCost
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Provider Cost Analysis
|
||||
const providerCosts = usageLogs.reduce((acc, log) => {
|
||||
if (log.cost_total === 0) return acc;
|
||||
if (!acc[log.provider]) {
|
||||
acc[log.provider] = { cost: 0, calls: 0, avgCostPerCall: 0 };
|
||||
}
|
||||
acc[log.provider].cost += log.cost_total;
|
||||
acc[log.provider].calls += 1;
|
||||
acc[log.provider].avgCostPerCall = acc[log.provider].cost / acc[log.provider].calls;
|
||||
return acc;
|
||||
}, {} as Record<string, { cost: number; calls: number; avgCostPerCall: number }>);
|
||||
|
||||
// Find expensive providers
|
||||
const sortedProviders = Object.entries(providerCosts)
|
||||
.sort(([, a], [, b]) => b.avgCostPerCall - a.avgCostPerCall);
|
||||
|
||||
if (sortedProviders.length > 1) {
|
||||
const [mostExpensive, secondMost] = sortedProviders;
|
||||
const [expensiveProvider, expensiveData] = mostExpensive;
|
||||
const [alternativeProvider, alternativeData] = secondMost;
|
||||
|
||||
// If expensive provider has significantly higher cost per call
|
||||
if (expensiveData.avgCostPerCall > alternativeData.avgCostPerCall * 1.5 && expensiveData.calls > 10) {
|
||||
const estimatedAlternativeCost = expensiveData.calls * alternativeData.avgCostPerCall;
|
||||
const savings = expensiveData.cost - estimatedAlternativeCost;
|
||||
|
||||
if (savings > 0.01) {
|
||||
recs.push({
|
||||
id: `provider-switch-${expensiveProvider}`,
|
||||
title: `Consider ${alternativeProvider} for Some Operations`,
|
||||
description: `${expensiveProvider} costs $${expensiveData.avgCostPerCall.toFixed(4)} per call on average, while ${alternativeProvider} costs $${alternativeData.avgCostPerCall.toFixed(4)}. Consider switching for non-critical operations.`,
|
||||
potentialSavings: savings,
|
||||
savingsPercentage: (savings / expensiveData.cost) * 100,
|
||||
category: 'provider_switch',
|
||||
priority: 'medium',
|
||||
actionItems: [
|
||||
`Use ${alternativeProvider} for batch operations`,
|
||||
`Reserve ${expensiveProvider} for high-priority tasks only`,
|
||||
'Review operation requirements to identify switchable tasks'
|
||||
],
|
||||
currentCost: expensiveData.cost,
|
||||
recommendedCost: estimatedAlternativeCost
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Usage Pattern Analysis - High Token Usage
|
||||
const highTokenUsage = usageLogs.filter(log =>
|
||||
log.tokens_total > 10000 && log.cost_total > 0.01
|
||||
);
|
||||
|
||||
if (highTokenUsage.length > 5) {
|
||||
const totalHighTokenCost = highTokenUsage.reduce((sum, log) => sum + log.cost_total, 0);
|
||||
const avgTokensPerCall = highTokenUsage.reduce((sum, log) => sum + log.tokens_total, 0) / highTokenUsage.length;
|
||||
|
||||
recs.push({
|
||||
id: 'optimize-high-token-usage',
|
||||
title: 'Optimize High Token Usage Operations',
|
||||
description: `You have ${highTokenUsage.length} operations using >10K tokens each. Consider breaking down large requests or using more efficient prompts.`,
|
||||
potentialSavings: totalHighTokenCost * 0.15, // Estimate 15% savings
|
||||
savingsPercentage: 15,
|
||||
category: 'usage_pattern',
|
||||
priority: 'medium',
|
||||
actionItems: [
|
||||
'Break down large requests into smaller chunks',
|
||||
'Use more concise prompts',
|
||||
'Implement result caching for repeated queries',
|
||||
`Average tokens per call: ${Math.round(avgTokensPerCall).toLocaleString()}`
|
||||
],
|
||||
currentCost: totalHighTokenCost,
|
||||
recommendedCost: totalHighTokenCost * 0.85
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Efficiency - Failed Requests
|
||||
const failedRequests = usageLogs.filter(log => log.status === 'failed' && log.cost_total > 0);
|
||||
if (failedRequests.length > 0) {
|
||||
const failedCost = failedRequests.reduce((sum, log) => sum + log.cost_total, 0);
|
||||
const failureRate = (failedRequests.length / usageLogs.length) * 100;
|
||||
|
||||
if (failureRate > 5) { // More than 5% failure rate
|
||||
recs.push({
|
||||
id: 'reduce-failed-requests',
|
||||
title: 'Reduce Failed API Requests',
|
||||
description: `${failedRequests.length} requests failed (${failureRate.toFixed(1)}% failure rate), costing $${failedCost.toFixed(4)}. Improve error handling and retry logic.`,
|
||||
potentialSavings: failedCost * 0.8, // Can save 80% by preventing failures
|
||||
savingsPercentage: 80,
|
||||
category: 'efficiency',
|
||||
priority: 'high',
|
||||
actionItems: [
|
||||
'Review and fix error-prone operations',
|
||||
'Implement better retry logic',
|
||||
'Add input validation before API calls',
|
||||
'Monitor error patterns and address root causes'
|
||||
],
|
||||
currentCost: failedCost,
|
||||
recommendedCost: failedCost * 0.2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority and potential savings
|
||||
return recs.sort((a, b) => {
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
|
||||
return priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
}
|
||||
return b.potentialSavings - a.potentialSavings;
|
||||
});
|
||||
}, [usageLogs]);
|
||||
|
||||
const totalPotentialSavings = recommendations.reduce((sum, rec) => sum + rec.potentialSavings, 0);
|
||||
const totalCurrentCost = usageLogs.reduce((sum, log) => sum + log.cost_total, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.7)' }}>
|
||||
Analyzing usage patterns...
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Sparkles size={48} color="#22c55e" style={{ marginBottom: 16 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff', mb: 1 }}>
|
||||
Great Job!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Your usage patterns are already optimized. No recommendations at this time.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', color: '#ffffff' }}>
|
||||
<Lightbulb size={20} />
|
||||
Cost Optimization Recommendations
|
||||
</Typography>
|
||||
<Tooltip title="AI-powered suggestions to reduce your API costs">
|
||||
<Info size={16} color="rgba(255,255,255,0.7)" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Summary */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(22, 163, 74, 0.1) 100%)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
<TrendingDown size={24} color="#22c55e" />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', color: '#22c55e' }}>
|
||||
{formatCurrency(totalPotentialSavings)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 0.5 }}>
|
||||
Potential Monthly Savings
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
{recommendations.length} recommendation{recommendations.length !== 1 ? 's' : ''} •
|
||||
{totalCurrentCost > 0 ? ` ${((totalPotentialSavings / totalCurrentCost) * 100).toFixed(1)}% of current spending` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
{/* Recommendations List */}
|
||||
{recommendations.map((rec, index) => (
|
||||
<Accordion
|
||||
key={rec.id}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 2,
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore sx={{ color: 'rgba(255,255,255,0.7)' }} />}
|
||||
sx={{ px: 2, py: 1.5 }}
|
||||
>
|
||||
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
{rec.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: rec.priority === 'high' ? 'rgba(239, 68, 68, 0.2)' :
|
||||
rec.priority === 'medium' ? 'rgba(245, 158, 11, 0.2)' :
|
||||
'rgba(100, 116, 139, 0.2)',
|
||||
color: rec.priority === 'high' ? '#ef4444' :
|
||||
rec.priority === 'medium' ? '#f59e0b' :
|
||||
'#64748b',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.7rem',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', display: 'block' }}>
|
||||
{rec.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right', minWidth: 120 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#22c55e' }}>
|
||||
{formatCurrency(rec.potentialSavings)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
{rec.savingsPercentage.toFixed(1)}% savings
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pb: 2 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Box sx={{ p: 1.5, backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Current Cost
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ef4444' }}>
|
||||
{formatCurrency(rec.currentCost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Box sx={{ p: 1.5, backgroundColor: 'rgba(34, 197, 94, 0.1)', borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Recommended Cost
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#22c55e' }}>
|
||||
{formatCurrency(rec.recommendedCost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1, color: '#ffffff' }}>
|
||||
Action Items:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||
{rec.actionItems.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 0.5 }}>
|
||||
{item}
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostOptimizationRecommendations;
|
||||
243
frontend/src/components/billing/CostVelocityChart.tsx
Normal file
243
frontend/src/components/billing/CostVelocityChart.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { Suspense, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
Tooltip as MuiTooltip,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LazyLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
// Types
|
||||
import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing';
|
||||
|
||||
// Utils
|
||||
import { formatCurrency } from '../../services/billingService';
|
||||
|
||||
interface CostVelocityChartProps {
|
||||
trends: UsageTrendsType;
|
||||
projections: CostProjections;
|
||||
monthlyLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CostVelocityChart - Shows daily spending rate (cost velocity) over time
|
||||
* with projected monthly cost and budget limit annotations
|
||||
*/
|
||||
const CostVelocityChart: React.FC<CostVelocityChartProps> = ({
|
||||
trends,
|
||||
projections,
|
||||
monthlyLimit
|
||||
}) => {
|
||||
// Calculate daily spending rate for each period
|
||||
const velocityData = useMemo(() => {
|
||||
if (!trends.periods || trends.periods.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = [];
|
||||
const currentDate = new Date();
|
||||
const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
|
||||
|
||||
for (let i = 0; i < trends.periods.length; i++) {
|
||||
const period = trends.periods[i];
|
||||
const cost = trends.total_cost[i] || 0;
|
||||
|
||||
// Parse period (assuming format like "2025-01" or day number)
|
||||
// For monthly periods, calculate daily average
|
||||
const dayNumber = i + 1; // Approximate day in month
|
||||
const dailyRate = dayNumber > 0 ? cost / dayNumber : 0;
|
||||
const projectedMonthly = dailyRate * daysInMonth;
|
||||
|
||||
data.push({
|
||||
period,
|
||||
day: dayNumber,
|
||||
dailyRate,
|
||||
projectedMonthly,
|
||||
actualCost: cost
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [trends]);
|
||||
|
||||
// Calculate 7-day moving average
|
||||
const movingAverageData = useMemo(() => {
|
||||
if (velocityData.length === 0) return [];
|
||||
|
||||
const windowSize = Math.min(7, velocityData.length);
|
||||
return velocityData.map((point, index) => {
|
||||
const start = Math.max(0, index - windowSize + 1);
|
||||
const window = velocityData.slice(start, index + 1);
|
||||
const avg = window.reduce((sum, p) => sum + p.dailyRate, 0) / window.length;
|
||||
return { ...point, movingAvg: avg };
|
||||
});
|
||||
}, [velocityData]);
|
||||
|
||||
// Current velocity metrics
|
||||
const currentVelocity = velocityData.length > 0
|
||||
? velocityData[velocityData.length - 1].dailyRate
|
||||
: 0;
|
||||
const projectedCost = projections.projected_monthly_cost || 0;
|
||||
const isOverBudget = projectedCost > monthlyLimit;
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
color: 'white',
|
||||
padding: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
|
||||
Day {data.day}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Daily Rate: {formatCurrency(data.dailyRate)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
7-Day Avg: {formatCurrency(data.movingAvg || 0)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Projected Monthly: {formatCurrency(data.projectedMonthly)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (velocityData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Cost Velocity Trend
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{isOverBudget && (
|
||||
<Chip
|
||||
icon={<AlertTriangle size={14} />}
|
||||
label="Over Budget"
|
||||
color="error"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
icon={currentVelocity > (monthlyLimit / 30) ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
label={`${formatCurrency(currentVelocity)}/day`}
|
||||
color={isOverBudget ? 'error' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
|
||||
Projected Monthly Cost: <strong style={{ color: isOverBudget ? '#ef4444' : '#4ade80' }}>
|
||||
{formatCurrency(projectedCost)}
|
||||
</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
Based on current daily spending rate
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LazyLineChart data={movingAverageData} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="rgba(255,255,255,0.7)"
|
||||
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="rgba(255,255,255,0.7)"
|
||||
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatCurrency(value)}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
|
||||
{/* Daily Rate Line */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="dailyRate"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3b82f6', r: 4 }}
|
||||
name="Daily Rate"
|
||||
animationDuration={1000}
|
||||
animationBegin={0}
|
||||
/>
|
||||
|
||||
{/* 7-Day Moving Average */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="movingAvg"
|
||||
stroke="#4ade80"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
name="7-Day Avg"
|
||||
animationDuration={1000}
|
||||
animationBegin={200}
|
||||
/>
|
||||
|
||||
{/* Budget Limit Reference Line */}
|
||||
<ReferenceLine
|
||||
y={monthlyLimit / 30}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: "Budget Limit", position: "right", fill: "#ef4444" }}
|
||||
/>
|
||||
</LazyLineChart>
|
||||
</ResponsiveContainer>
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostVelocityChart;
|
||||
231
frontend/src/components/billing/DailyCostHeatmap.tsx
Normal file
231
frontend/src/components/billing/DailyCostHeatmap.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { Suspense, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Tooltip as MuiTooltip,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { formatCurrency } from '../../services/billingService';
|
||||
import { UsageLog } from '../../types/billing';
|
||||
|
||||
interface DailyCostHeatmapProps {
|
||||
usageLogs: UsageLog[];
|
||||
currentMonth: number;
|
||||
currentYear: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DailyCostHeatmap - Calendar-style heatmap showing cost patterns by day
|
||||
*
|
||||
* Visualizes daily spending patterns to identify high-cost days
|
||||
*/
|
||||
const DailyCostHeatmap: React.FC<DailyCostHeatmapProps> = ({
|
||||
usageLogs,
|
||||
currentMonth,
|
||||
currentYear
|
||||
}) => {
|
||||
// Aggregate logs by day
|
||||
const dailyCosts = useMemo(() => {
|
||||
const costs: Record<number, number> = {};
|
||||
|
||||
usageLogs.forEach(log => {
|
||||
const logDate = new Date(log.timestamp);
|
||||
if (logDate.getMonth() === currentMonth && logDate.getFullYear() === currentYear) {
|
||||
const day = logDate.getDate();
|
||||
costs[day] = (costs[day] || 0) + (log.cost_total || 0);
|
||||
}
|
||||
});
|
||||
|
||||
return costs;
|
||||
}, [usageLogs, currentMonth, currentYear]);
|
||||
|
||||
// Get days in month
|
||||
const daysInMonth = useMemo(() => {
|
||||
return new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
}, [currentMonth, currentYear]);
|
||||
|
||||
// Calculate max cost for color scaling
|
||||
const maxCost = useMemo(() => {
|
||||
return Math.max(...Object.values(dailyCosts), 0.01);
|
||||
}, [dailyCosts]);
|
||||
|
||||
// Get color intensity based on cost
|
||||
const getColorIntensity = (cost: number) => {
|
||||
if (cost === 0) return 'rgba(255,255,255,0.05)';
|
||||
const intensity = Math.min(cost / maxCost, 1);
|
||||
// Green (low) to Red (high)
|
||||
if (intensity < 0.3) {
|
||||
return `rgba(34, 197, 94, ${0.3 + intensity * 0.4})`; // Green
|
||||
} else if (intensity < 0.7) {
|
||||
return `rgba(234, 179, 8, ${0.5 + (intensity - 0.3) * 0.3})`; // Yellow
|
||||
} else {
|
||||
return `rgba(239, 68, 68, ${0.6 + (intensity - 0.7) * 0.4})`; // Red
|
||||
}
|
||||
};
|
||||
|
||||
// Generate calendar grid
|
||||
const calendarDays = useMemo(() => {
|
||||
const firstDay = new Date(currentYear, currentMonth, 1).getDay();
|
||||
const days: Array<{ day: number; cost: number; date: Date | null }> = [];
|
||||
|
||||
// Add empty cells for days before month starts
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push({ day: 0, cost: 0, date: null });
|
||||
}
|
||||
|
||||
// Add days of month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(currentYear, currentMonth, day);
|
||||
days.push({
|
||||
day,
|
||||
cost: dailyCosts[day] || 0,
|
||||
date
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [currentMonth, currentYear, daysInMonth, dailyCosts]);
|
||||
|
||||
if (usageLogs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Calendar size={20} color="#4ade80" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Daily Cost Heatmap
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 1 }}>
|
||||
Cost intensity by day of month
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', fontSize: '0.75rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(34, 197, 94, 0.5)', borderRadius: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>Low</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(234, 179, 8, 0.7)', borderRadius: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>Medium</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>High</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: 1,
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<Typography
|
||||
key={day}
|
||||
variant="caption"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem'
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
{calendarDays.map((item, index) => (
|
||||
<MuiTooltip
|
||||
key={index}
|
||||
title={
|
||||
item.date ? (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Cost: {formatCurrency(item.cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
'No data'
|
||||
)
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
aspectRatio: '1',
|
||||
backgroundColor: getColorIntensity(item.cost),
|
||||
borderRadius: 1,
|
||||
border: item.cost > 0 ? '1px solid rgba(255,255,255,0.2)' : '1px solid rgba(255,255,255,0.05)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: item.date ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': item.date ? {
|
||||
transform: 'scale(1.1)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
|
||||
} : {},
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{item.day > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: item.cost > maxCost * 0.5 ? '#ffffff' : 'rgba(255,255,255,0.8)',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: item.cost > 0 ? 600 : 400
|
||||
}}
|
||||
>
|
||||
{item.day}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyCostHeatmap;
|
||||
@@ -38,6 +38,13 @@ import CostBreakdown from './CostBreakdown';
|
||||
import UsageTrends from './UsageTrends';
|
||||
import UsageAlerts from './UsageAlerts';
|
||||
import ComprehensiveAPIBreakdown from './ComprehensiveAPIBreakdown';
|
||||
import ToolCostBreakdown from './ToolCostBreakdown';
|
||||
import CostOptimizationRecommendations from './CostOptimizationRecommendations';
|
||||
import AdvancedCostAnalytics from './AdvancedCostAnalytics';
|
||||
import DailyCostHeatmap from './DailyCostHeatmap';
|
||||
import LiveCostCounter from './LiveCostCounter';
|
||||
import ErrorRateGauge from './ErrorRateGauge';
|
||||
import MultiSeriesCostChart from './MultiSeriesCostChart';
|
||||
|
||||
// Terminal Theme
|
||||
import {
|
||||
@@ -62,29 +69,77 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact');
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const [healthError, setHealthError] = useState<string | null>(null);
|
||||
|
||||
const fetchDashboardData = async (showSuccessToast: boolean = false) => {
|
||||
try {
|
||||
const [billingData, healthData] = await Promise.all([
|
||||
// Use Promise.allSettled to prevent health check timeout from blocking dashboard
|
||||
const results = await Promise.allSettled([
|
||||
billingService.getDashboardData(),
|
||||
monitoringService.getSystemHealth()
|
||||
]);
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
|
||||
// Handle billing data (required)
|
||||
if (results[0].status === 'fulfilled') {
|
||||
setDashboardData(results[0].value);
|
||||
setLastRefreshTime(new Date());
|
||||
setError(null); // Clear any previous errors
|
||||
} else {
|
||||
// Billing data is critical - show error
|
||||
const errorMessage = results[0].reason instanceof Error
|
||||
? results[0].reason.message
|
||||
: 'Failed to fetch dashboard data';
|
||||
setError(errorMessage);
|
||||
showToastNotification(
|
||||
`Unable to fetch latest billing data: ${errorMessage}. Showing last known values.`,
|
||||
'error',
|
||||
{ duration: 7000 }
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle health data (optional - don't block dashboard if it fails)
|
||||
if (results[1].status === 'fulfilled') {
|
||||
setSystemHealth(results[1].value);
|
||||
setHealthError(null); // Clear health error on success
|
||||
} else {
|
||||
// Health check failed - keep last successful value, show error toast
|
||||
const healthErrorMessage = results[1].reason instanceof Error
|
||||
? results[1].reason.message
|
||||
: 'System health check timed out or failed';
|
||||
setHealthError(healthErrorMessage);
|
||||
showToastNotification(
|
||||
`Unable to fetch latest system health data: ${healthErrorMessage}. Showing last known values.`,
|
||||
'warning',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
// Don't update systemHealth - keep last successful value
|
||||
// Only set to null if we never had a successful fetch
|
||||
if (!systemHealth) {
|
||||
setSystemHealth(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast only if explicitly requested (user-initiated refresh)
|
||||
if (showSuccessToast && billingData && healthData) {
|
||||
if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'fulfilled') {
|
||||
showToastNotification(
|
||||
'Billing data refreshed successfully',
|
||||
'success',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} else if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'rejected') {
|
||||
showToastNotification(
|
||||
'Billing data refreshed, but system health check failed',
|
||||
'warning',
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback error handling (shouldn't reach here with allSettled)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch dashboard data';
|
||||
setError(errorMessage);
|
||||
|
||||
// Always show error toast for failures
|
||||
showToastNotification(errorMessage, 'error', { duration: 5000 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -99,10 +154,24 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
useEffect(() => {
|
||||
const unsubscribe = onApiEvent((detail) => {
|
||||
if (detail.source && detail.source !== 'other') return;
|
||||
Promise.all([billingService.getDashboardData(), monitoringService.getSystemHealth()])
|
||||
.then(([billingData, health]) => {
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(health);
|
||||
Promise.allSettled([billingService.getDashboardData(), monitoringService.getSystemHealth()])
|
||||
.then((results) => {
|
||||
if (results[0].status === 'fulfilled') {
|
||||
setDashboardData(results[0].value);
|
||||
setLastRefreshTime(new Date());
|
||||
setError(null);
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
setSystemHealth(results[1].value);
|
||||
setHealthError(null);
|
||||
} else {
|
||||
// Keep last successful health value, don't set fake defaults
|
||||
const healthErrorMessage = results[1].reason instanceof Error
|
||||
? results[1].reason.message
|
||||
: 'System health check failed';
|
||||
setHealthError(healthErrorMessage);
|
||||
// Don't update systemHealth - keep last successful value
|
||||
}
|
||||
})
|
||||
.catch(() => {/* ignore */});
|
||||
});
|
||||
@@ -124,16 +193,39 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
useEffect(() => {
|
||||
const handleBillingRefresh = () => {
|
||||
console.log('EnhancedBillingDashboard: Billing refresh requested, refreshing data...');
|
||||
// Use a fresh call to fetchDashboardData to ensure we get latest data
|
||||
Promise.all([billingService.getDashboardData(), monitoringService.getSystemHealth()])
|
||||
.then(([billingData, healthData]) => {
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
// Use allSettled to prevent health check from blocking refresh
|
||||
Promise.allSettled([billingService.getDashboardData(), monitoringService.getSystemHealth()])
|
||||
.then((results) => {
|
||||
if (results[0].status === 'fulfilled') {
|
||||
setDashboardData(results[0].value);
|
||||
setLastRefreshTime(new Date());
|
||||
setError(null);
|
||||
} else {
|
||||
const errorMessage = results[0].reason instanceof Error
|
||||
? results[0].reason.message
|
||||
: 'Failed to refresh billing data';
|
||||
setError(errorMessage);
|
||||
showToastNotification(
|
||||
`Unable to refresh billing data: ${errorMessage}. Showing last known values.`,
|
||||
'error',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
console.error('Error refreshing billing data:', results[0].reason);
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
setSystemHealth(results[1].value);
|
||||
setHealthError(null);
|
||||
} else {
|
||||
// Keep last successful health value, don't set fake defaults
|
||||
const healthErrorMessage = results[1].reason instanceof Error
|
||||
? results[1].reason.message
|
||||
: 'System health check failed';
|
||||
setHealthError(healthErrorMessage);
|
||||
// Don't update systemHealth - keep last successful value
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to refresh billing data';
|
||||
setError(errorMessage);
|
||||
console.error('Error refreshing billing data:', error);
|
||||
console.error('Unexpected error in billing refresh:', error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -227,55 +319,73 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
{dashboardData && (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{Object.entries(dashboardData.current_usage.provider_breakdown)
|
||||
.filter(([_, data]) => data.cost > 0)
|
||||
.map(([provider, data]) => (
|
||||
<Tooltip
|
||||
key={provider}
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{provider.toUpperCase()} Usage
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Cost: ${data.cost.toFixed(4)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Calls: {data.calls.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Tokens: {data.tokens.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`${provider}: $${data.cost.toFixed(4)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.2)',
|
||||
color: '#4ade80',
|
||||
border: '1px solid rgba(74, 222, 128, 0.3)',
|
||||
fontSize: '0.7rem',
|
||||
height: 24,
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.3)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(74, 222, 128, 0.2)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
.filter(([_, data]) => data && data.cost > 0)
|
||||
.map(([provider, data]) => {
|
||||
const providerData = data!; // Safe after filter
|
||||
return (
|
||||
<Tooltip
|
||||
key={provider}
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{provider.toUpperCase()} Usage
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Cost: ${(providerData.cost ?? 0).toFixed(4)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Calls: {(providerData.calls ?? 0).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Tokens: {(providerData.tokens ?? 0).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`${provider}: $${(providerData.cost ?? 0).toFixed(4)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.2)',
|
||||
color: '#4ade80',
|
||||
border: '1px solid rgba(74, 222, 128, 0.3)',
|
||||
fontSize: '0.7rem',
|
||||
height: 24,
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.3)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(74, 222, 128, 0.2)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* View Mode Toggle and Refresh */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Last Refresh Timestamp */}
|
||||
{lastRefreshTime && (
|
||||
<Tooltip title={`Data last refreshed at ${lastRefreshTime.toLocaleTimeString()}`}>
|
||||
<TypographyComponent
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: '0.7rem',
|
||||
fontStyle: 'italic'
|
||||
}}
|
||||
>
|
||||
Last updated: {lastRefreshTime.toLocaleTimeString()}
|
||||
</TypographyComponent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Refresh billing data">
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -405,13 +515,54 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Tool-Level Cost Breakdown */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<ToolCostBreakdown
|
||||
userId={userId}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Bottom Row - Comprehensive API Breakdown */}
|
||||
<Grid item xs={12}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<ComprehensiveAPIBreakdown
|
||||
providerBreakdown={dashboardData.current_usage.provider_breakdown}
|
||||
totalCost={dashboardData.current_usage.total_cost}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Priority 3: Cost Optimization Recommendations */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<CostOptimizationRecommendations
|
||||
userId={userId}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Priority 3: Advanced Cost Analytics */}
|
||||
<Grid item xs={12}>
|
||||
<AdvancedCostAnalytics
|
||||
userId={userId}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Phase 3: Multi-Series Cost Chart */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<MultiSeriesCostChart
|
||||
trends={dashboardData.trends}
|
||||
monthlyLimit={dashboardData.projections.cost_limit || 0}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Phase 3: Error Rate Gauge */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<ErrorRateGauge
|
||||
systemHealth={systemHealth}
|
||||
terminalTheme={terminalTheme}
|
||||
terminalColors={terminalColors}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
244
frontend/src/components/billing/ErrorRateGauge.tsx
Normal file
244
frontend/src/components/billing/ErrorRateGauge.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { Suspense, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Tooltip as MuiTooltip,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
LazyPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
import { SystemHealth } from '../../types/monitoring';
|
||||
|
||||
interface ErrorRateGaugeProps {
|
||||
systemHealth: SystemHealth | null;
|
||||
terminalTheme?: boolean;
|
||||
terminalColors?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorRateGauge - Semi-circular gauge showing API error rate
|
||||
*
|
||||
* Visual gauge with:
|
||||
* - Green (0-5%): Healthy
|
||||
* - Yellow (5-10%): Warning
|
||||
* - Red (>10%): Critical
|
||||
*/
|
||||
const ErrorRateGauge: React.FC<ErrorRateGaugeProps> = ({
|
||||
systemHealth,
|
||||
terminalTheme = false,
|
||||
terminalColors
|
||||
}) => {
|
||||
const errorRate = systemHealth?.error_rate || 0;
|
||||
const recentRequests = systemHealth?.recent_requests || 0;
|
||||
const recentErrors = systemHealth?.recent_errors || 0;
|
||||
|
||||
// Determine status and color
|
||||
const { status, color, icon } = useMemo(() => {
|
||||
if (errorRate <= 5) {
|
||||
return {
|
||||
status: 'Healthy',
|
||||
color: terminalTheme ? (terminalColors?.success || '#22c55e') : '#22c55e',
|
||||
icon: <CheckCircle size={20} color="#22c55e" />
|
||||
};
|
||||
} else if (errorRate <= 10) {
|
||||
return {
|
||||
status: 'Warning',
|
||||
color: terminalTheme ? (terminalColors?.warning || '#f59e0b') : '#f59e0b',
|
||||
icon: <AlertCircle size={20} color="#f59e0b" />
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'Critical',
|
||||
color: terminalTheme ? (terminalColors?.error || '#ef4444') : '#ef4444',
|
||||
icon: <AlertTriangle size={20} color="#ef4444" />
|
||||
};
|
||||
}
|
||||
}, [errorRate, terminalTheme, terminalColors]);
|
||||
|
||||
// Data for semi-circular gauge (using Pie chart)
|
||||
const gaugeData = [
|
||||
{ name: 'Errors', value: errorRate, fill: color },
|
||||
{ name: 'Success', value: Math.max(0, 100 - errorRate), fill: 'rgba(255,255,255,0.1)' }
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: terminalTheme
|
||||
? (terminalColors?.background || 'rgba(0,0,0,0.8)')
|
||||
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: `1px solid ${terminalTheme
|
||||
? (terminalColors?.border || 'rgba(255,255,255,0.1)')
|
||||
: 'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
{icon}
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors?.text : '#ffffff' }}>
|
||||
Error Rate Gauge
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ position: 'relative', height: 200, mb: 2 }}>
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LazyPieChart>
|
||||
<Pie
|
||||
data={gaugeData}
|
||||
cx="50%"
|
||||
cy="80%"
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
animationDuration={1000}
|
||||
animationBegin={0}
|
||||
>
|
||||
<Cell fill={color} />
|
||||
<Cell fill={terminalTheme
|
||||
? (terminalColors?.backgroundLight || 'rgba(255,255,255,0.1)')
|
||||
: 'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</Pie>
|
||||
</LazyPieChart>
|
||||
</ResponsiveContainer>
|
||||
</Suspense>
|
||||
|
||||
{/* Center value display */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
mt: 2
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: color,
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
>
|
||||
{errorRate.toFixed(1)}%
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px'
|
||||
}}
|
||||
>
|
||||
Error Rate
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-around', gap: 2 }}>
|
||||
<MuiTooltip title="Total API requests in the last 5 minutes">
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: terminalTheme ? terminalColors?.text : '#ffffff'
|
||||
}}
|
||||
>
|
||||
{recentRequests.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.7rem'
|
||||
}}
|
||||
>
|
||||
Requests
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
<MuiTooltip title="Failed requests in the last 5 minutes">
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: color
|
||||
}}
|
||||
>
|
||||
{recentErrors.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.7rem'
|
||||
}}
|
||||
>
|
||||
Errors
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
<MuiTooltip title="System health status">
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: color,
|
||||
textTransform: 'capitalize'
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.7rem'
|
||||
}}
|
||||
>
|
||||
Status
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorRateGauge;
|
||||
154
frontend/src/components/billing/LiveCostCounter.tsx
Normal file
154
frontend/src/components/billing/LiveCostCounter.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { motion, useSpring, useTransform } from 'framer-motion';
|
||||
import { DollarSign, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { AnimatedNumber } from '../shared/AnimatedNumber';
|
||||
import { formatCurrency } from '../../services/billingService';
|
||||
|
||||
interface LiveCostCounterProps {
|
||||
currentCost: number;
|
||||
previousCost?: number;
|
||||
velocity?: number; // Daily spending rate
|
||||
terminalTheme?: boolean;
|
||||
terminalColors?: any;
|
||||
TypographyComponent?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveCostCounter - Animated counter showing real-time cost accumulation
|
||||
*
|
||||
* Features:
|
||||
* - Smooth number animation
|
||||
* - Velocity indicator (trending up/down)
|
||||
* - Pulse animation on cost increase
|
||||
* - Color changes based on velocity
|
||||
*/
|
||||
const LiveCostCounter: React.FC<LiveCostCounterProps> = ({
|
||||
currentCost,
|
||||
previousCost,
|
||||
velocity = 0,
|
||||
terminalTheme = false,
|
||||
terminalColors,
|
||||
TypographyComponent = Typography
|
||||
}) => {
|
||||
const [hasIncreased, setHasIncreased] = useState(false);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
|
||||
// Detect cost increase
|
||||
useEffect(() => {
|
||||
if (previousCost !== undefined && currentCost > previousCost) {
|
||||
setHasIncreased(true);
|
||||
setPulseKey(prev => prev + 1);
|
||||
const timer = setTimeout(() => setHasIncreased(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [currentCost, previousCost]);
|
||||
|
||||
// Calculate velocity trend
|
||||
const isIncreasing = velocity > 0;
|
||||
const velocityPercent = Math.abs(velocity);
|
||||
|
||||
// Color based on velocity
|
||||
const getColor = () => {
|
||||
if (terminalTheme) {
|
||||
if (velocityPercent > 20) return terminalColors?.error || '#ef4444';
|
||||
if (velocityPercent > 10) return terminalColors?.warning || '#f59e0b';
|
||||
return terminalColors?.success || '#22c55e';
|
||||
}
|
||||
if (velocityPercent > 20) return '#ef4444';
|
||||
if (velocityPercent > 10) return '#f59e0b';
|
||||
return '#22c55e';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={pulseKey}
|
||||
animate={hasIncreased ? {
|
||||
scale: [1, 1.05, 1],
|
||||
} : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors?.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors?.border || 'rgba(255,255,255,0.1)'}`
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(79, 70, 229, 0.1) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(102, 126, 234, 0.3)'
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<DollarSign
|
||||
size={24}
|
||||
color={terminalTheme ? (terminalColors?.text || '#ffffff') : '#667eea'}
|
||||
/>
|
||||
<TypographyComponent
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
Live Cost
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
|
||||
<TypographyComponent
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: getColor(),
|
||||
mb: 1,
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 0.5
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber
|
||||
value={currentCost}
|
||||
format={formatCurrency}
|
||||
decimals={4}
|
||||
duration={0.8}
|
||||
/>
|
||||
</TypographyComponent>
|
||||
|
||||
{previousCost !== undefined && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{isIncreasing ? (
|
||||
<TrendingUp size={14} color={getColor()} />
|
||||
) : (
|
||||
<TrendingDown size={14} color={getColor()} />
|
||||
)}
|
||||
<TypographyComponent
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: getColor(),
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
{isIncreasing ? '+' : ''}{velocityPercent.toFixed(1)}% daily rate
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveCostCounter;
|
||||
210
frontend/src/components/billing/MultiSeriesCostChart.tsx
Normal file
210
frontend/src/components/billing/MultiSeriesCostChart.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { Suspense, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
Tooltip as MuiTooltip,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LazyComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
// Types
|
||||
import { UsageTrends as UsageTrendsType } from '../../types/billing';
|
||||
|
||||
// Utils
|
||||
import { formatCurrency } from '../../services/billingService';
|
||||
|
||||
interface MultiSeriesCostChartProps {
|
||||
trends: UsageTrendsType;
|
||||
monthlyLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiSeriesCostChart - Multi-series area chart showing cost breakdown over time
|
||||
* Displays total cost, provider-specific costs, and budget limit
|
||||
*/
|
||||
const MultiSeriesCostChart: React.FC<MultiSeriesCostChartProps> = ({
|
||||
trends,
|
||||
monthlyLimit
|
||||
}) => {
|
||||
// Transform data for multi-series chart
|
||||
const chartData = useMemo(() => {
|
||||
if (!trends.periods || trends.periods.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return trends.periods.map((period, index) => ({
|
||||
period,
|
||||
totalCost: trends.total_cost[index] || 0,
|
||||
calls: trends.total_calls[index] || 0,
|
||||
tokens: trends.total_tokens[index] || 0,
|
||||
}));
|
||||
}, [trends]);
|
||||
|
||||
// Calculate trend
|
||||
const costTrend = useMemo(() => {
|
||||
if (chartData.length < 2) return 0;
|
||||
const first = chartData[0].totalCost;
|
||||
const last = chartData[chartData.length - 1].totalCost;
|
||||
if (first === 0) return last > 0 ? 100 : 0;
|
||||
return ((last - first) / first) * 100;
|
||||
}, [chartData]);
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
color: 'white',
|
||||
padding: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
minWidth: 200
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
backgroundColor: entry.color,
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{entry.name}:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{formatCurrency(entry.value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DollarSign size={20} color="#4ade80" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Cost Over Time
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Chip
|
||||
icon={costTrend >= 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
label={`${costTrend >= 0 ? '+' : ''}${costTrend.toFixed(1)}%`}
|
||||
color={costTrend >= 0 ? 'error' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LazyComposedChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorTotalCost" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#667eea" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#667eea" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
stroke="rgba(255,255,255,0.7)"
|
||||
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="rgba(255,255,255,0.7)"
|
||||
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatCurrency(value)}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
iconType="line"
|
||||
/>
|
||||
|
||||
{/* Total Cost Area */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="totalCost"
|
||||
stroke="#667eea"
|
||||
strokeWidth={3}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorTotalCost)"
|
||||
name="Total Cost"
|
||||
animationDuration={1000}
|
||||
animationBegin={0}
|
||||
/>
|
||||
|
||||
{/* Budget Limit Reference Line */}
|
||||
{monthlyLimit && monthlyLimit > 0 && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={() => monthlyLimit}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="Budget Limit"
|
||||
legendType="line"
|
||||
/>
|
||||
)}
|
||||
</LazyComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSeriesCostChart;
|
||||
171
frontend/src/components/billing/ProviderCostComparison.tsx
Normal file
171
frontend/src/components/billing/ProviderCostComparison.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Tooltip as MuiTooltip,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
LazyBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
// Types
|
||||
import { ProviderBreakdown } from '../../types/billing';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
formatCurrency,
|
||||
getProviderColor,
|
||||
getProviderIcon
|
||||
} from '../../services/billingService';
|
||||
|
||||
interface ProviderCostComparisonProps {
|
||||
providerBreakdown: ProviderBreakdown;
|
||||
terminalTheme?: boolean;
|
||||
terminalColors?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProviderCostComparison - Horizontal bar chart comparing costs across providers
|
||||
*
|
||||
* Usage:
|
||||
* <ProviderCostComparison providerBreakdown={breakdown} />
|
||||
*/
|
||||
const ProviderCostComparison: React.FC<ProviderCostComparisonProps> = ({
|
||||
providerBreakdown,
|
||||
terminalTheme = false,
|
||||
terminalColors
|
||||
}) => {
|
||||
// Transform data for chart
|
||||
const chartData = Object.entries(providerBreakdown)
|
||||
.filter(([_, data]) => data && (data.cost ?? 0) > 0)
|
||||
.map(([provider, data]) => ({
|
||||
name: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
cost: data?.cost ?? 0,
|
||||
calls: data?.calls ?? 0,
|
||||
tokens: data?.tokens ?? 0,
|
||||
color: getProviderColor(provider),
|
||||
icon: getProviderIcon(provider)
|
||||
}))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 5); // Top 5 providers
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
color: 'white',
|
||||
padding: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
|
||||
{data.icon} {data.name}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Cost: {formatCurrency(data.cost)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Calls: {data.calls.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Tokens: {data.tokens.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1.5,
|
||||
color: terminalTheme
|
||||
? (terminalColors?.text || '#ffffff')
|
||||
: 'rgba(255,255,255,0.9)'
|
||||
}}
|
||||
>
|
||||
Provider Cost Comparison
|
||||
</Typography>
|
||||
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LazyBarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, bottom: 5, left: 60 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalTheme
|
||||
? (terminalColors?.border || 'rgba(255,255,255,0.1)')
|
||||
: 'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
stroke={terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)'}
|
||||
tick={{ fill: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)', fontSize: 11 }}
|
||||
tickFormatter={(value) => formatCurrency(value)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
stroke={terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)'}
|
||||
tick={{ fill: terminalTheme
|
||||
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
|
||||
: 'rgba(255,255,255,0.7)', fontSize: 11 }}
|
||||
width={55}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="cost"
|
||||
radius={[0, 4, 4, 0]}
|
||||
animationDuration={800}
|
||||
animationBegin={0}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</LazyBarChart>
|
||||
</ResponsiveContainer>
|
||||
</Suspense>
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderCostComparison;
|
||||
397
frontend/src/components/billing/ToolCostBreakdown.tsx
Normal file
397
frontend/src/components/billing/ToolCostBreakdown.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Search,
|
||||
Mic,
|
||||
Code
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { UsageLog } from '../../types/billing';
|
||||
|
||||
// Services
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { formatCurrency, formatNumber } from '../../services/billingService';
|
||||
|
||||
interface ToolCostBreakdownProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
// Map endpoints to tool names
|
||||
const endpointToTool = (endpoint: string): string => {
|
||||
const endpointLower = endpoint.toLowerCase();
|
||||
|
||||
if (endpointLower.includes('blog') || endpointLower.includes('blog-writer')) {
|
||||
return 'Blog Writer';
|
||||
}
|
||||
if (endpointLower.includes('story') || endpointLower.includes('story-writer')) {
|
||||
return 'Story Writer';
|
||||
}
|
||||
if (endpointLower.includes('podcast') || endpointLower.includes('podcast-maker')) {
|
||||
return 'Podcast Maker';
|
||||
}
|
||||
if (endpointLower.includes('image') || endpointLower.includes('image-studio')) {
|
||||
return 'Image Studio';
|
||||
}
|
||||
if (endpointLower.includes('video') || endpointLower.includes('video-studio')) {
|
||||
return 'Video Studio';
|
||||
}
|
||||
if (endpointLower.includes('research') || endpointLower.includes('researcher')) {
|
||||
return 'Research Tools';
|
||||
}
|
||||
if (endpointLower.includes('linkedin')) {
|
||||
return 'LinkedIn Writer';
|
||||
}
|
||||
if (endpointLower.includes('facebook')) {
|
||||
return 'Facebook Writer';
|
||||
}
|
||||
if (endpointLower.includes('seo')) {
|
||||
return 'SEO Tools';
|
||||
}
|
||||
if (endpointLower.includes('audio') || endpointLower.includes('tts')) {
|
||||
return 'Audio Generation';
|
||||
}
|
||||
|
||||
return 'Other';
|
||||
};
|
||||
|
||||
const getToolIcon = (tool: string) => {
|
||||
const toolLower = tool.toLowerCase();
|
||||
if (toolLower.includes('blog')) return <FileText size={18} />;
|
||||
if (toolLower.includes('story')) return <FileText size={18} />;
|
||||
if (toolLower.includes('podcast')) return <Mic size={18} />;
|
||||
if (toolLower.includes('image')) return <ImageIcon size={18} />;
|
||||
if (toolLower.includes('video')) return <Video size={18} />;
|
||||
if (toolLower.includes('research')) return <Search size={18} />;
|
||||
if (toolLower.includes('audio')) return <Mic size={18} />;
|
||||
return <Code size={18} />;
|
||||
};
|
||||
|
||||
const ToolCostBreakdown: React.FC<ToolCostBreakdownProps> = ({ userId, terminalTheme = false }) => {
|
||||
const [usageLogs, setUsageLogs] = useState<UsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsageLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Get current billing period
|
||||
const currentDate = new Date();
|
||||
const billingPeriod = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
// Fetch usage logs with high limit to get comprehensive data
|
||||
const response = await billingService.getUsageLogs(1000, 0, undefined, undefined, billingPeriod);
|
||||
setUsageLogs(response.logs || []);
|
||||
} catch (err) {
|
||||
console.error('[ToolCostBreakdown] Error fetching usage logs:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch usage logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsageLogs();
|
||||
}, [userId]);
|
||||
|
||||
const toolCosts = useMemo(() => {
|
||||
const grouped = usageLogs.reduce((acc, log) => {
|
||||
const tool = endpointToTool(log.endpoint || '');
|
||||
if (!acc[tool]) {
|
||||
acc[tool] = { cost: 0, calls: 0, tokens: 0 };
|
||||
}
|
||||
acc[tool].cost += log.cost_total || 0;
|
||||
acc[tool].calls += 1;
|
||||
acc[tool].tokens += log.tokens_total || 0;
|
||||
return acc;
|
||||
}, {} as Record<string, { cost: number; calls: number; tokens: number }>);
|
||||
|
||||
return Object.entries(grouped)
|
||||
.map(([tool, data]) => ({
|
||||
tool,
|
||||
...data
|
||||
}))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.filter(item => item.cost > 0); // Only show tools with costs
|
||||
}, [usageLogs]);
|
||||
|
||||
const totalCost = toolCosts.reduce((sum, item) => sum + item.cost, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card sx={{
|
||||
background: terminalTheme
|
||||
? 'transparent'
|
||||
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
border: terminalTheme ? '1px solid #00ff00' : '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={24} sx={{ color: terminalTheme ? '#00ff00' : undefined }} />
|
||||
<Typography sx={{ mt: 2, color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.8)' }}>
|
||||
Loading tool costs...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card sx={{
|
||||
background: terminalTheme
|
||||
? 'transparent'
|
||||
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
border: terminalTheme ? '1px solid #ff0000' : '1px solid rgba(255,107,107,0.3)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Typography sx={{ color: terminalTheme ? '#ff0000' : '#ff6b6b', fontSize: '0.875rem' }}>
|
||||
{error}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolCosts.length === 0) {
|
||||
return (
|
||||
<Card sx={{
|
||||
background: terminalTheme
|
||||
? 'transparent'
|
||||
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
border: terminalTheme ? `1px solid ${terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.1)'}` : '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: terminalTheme ? '#00ff00' : '#ffffff', fontWeight: 'bold' }}>
|
||||
Cost by Tool
|
||||
</Typography>
|
||||
<Typography sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)', fontSize: '0.875rem' }}>
|
||||
No usage data available yet. Costs will appear here as you use different tools.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
background: terminalTheme
|
||||
? 'transparent'
|
||||
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: terminalTheme ? 'none' : 'blur(10px)',
|
||||
border: terminalTheme ? '1px solid #00ff00' : '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{
|
||||
mb: 3,
|
||||
color: terminalTheme ? '#00ff00' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}>
|
||||
<Code size={20} />
|
||||
Cost by Tool
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{toolCosts.map((item, index) => {
|
||||
const percentage = totalCost > 0 ? ((item.cost / totalCost) * 100).toFixed(1) : '0';
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={item.tool}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{item.tool} Usage
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Cost: {formatCurrency(item.cost)} ({percentage}%)
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Calls: {formatNumber(item.calls)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Tokens: {formatNumber(item.tokens)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
|
||||
Avg cost per call: {formatCurrency(item.cost / item.calls)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: terminalTheme
|
||||
? 'rgba(0, 255, 0, 0.05)'
|
||||
: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: terminalTheme
|
||||
? '1px solid rgba(0, 255, 0, 0.2)'
|
||||
: '1px solid rgba(255,255,255,0.1)',
|
||||
position: 'relative',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
backgroundColor: terminalTheme
|
||||
? 'rgba(0, 255, 0, 0.1)'
|
||||
: 'rgba(255,255,255,0.08)',
|
||||
border: terminalTheme
|
||||
? '1px solid rgba(0, 255, 0, 0.4)'
|
||||
: '1px solid rgba(255,255,255,0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Tool Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ color: terminalTheme ? '#00ff00' : '#ffffff' }}>
|
||||
{getToolIcon(item.tool)}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
|
||||
{item.tool}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${percentage}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: terminalTheme
|
||||
? 'rgba(0, 255, 0, 0.2)'
|
||||
: 'rgba(74, 222, 128, 0.2)',
|
||||
color: terminalTheme ? '#00ff00' : '#4ade80',
|
||||
fontWeight: 'bold',
|
||||
border: terminalTheme
|
||||
? '1px solid rgba(0, 255, 0, 0.3)'
|
||||
: '1px solid rgba(74, 222, 128, 0.3)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Metrics */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
|
||||
Cost:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
|
||||
{formatCurrency(item.cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
|
||||
Calls:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
|
||||
{formatNumber(item.calls)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
|
||||
Tokens:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
|
||||
{formatNumber(item.tokens)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Progress bar */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: 4,
|
||||
backgroundColor: terminalTheme
|
||||
? 'rgba(0, 255, 0, 0.1)'
|
||||
: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percentage}%` }}
|
||||
transition={{ duration: 1, delay: index * 0.1 }}
|
||||
style={{
|
||||
height: '100%',
|
||||
backgroundColor: terminalTheme ? '#00ff00' : '#4ade80',
|
||||
borderRadius: 2
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{/* Summary */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
backgroundColor: terminalTheme
|
||||
? 'rgba(0, 255, 0, 0.05)'
|
||||
: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: terminalTheme
|
||||
? '1px solid rgba(0, 255, 0, 0.2)'
|
||||
: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.8)' }}>
|
||||
Total Tool Costs
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
|
||||
{formatCurrency(totalCost)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
|
||||
Across {toolCosts.length} active tool{toolCosts.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolCostBreakdown;
|
||||
@@ -324,7 +324,7 @@ const UsageLogsTable: React.FC<UsageLogsTableProps> = ({ initialLimit = 50 }) =>
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" sx={{ textTransform: 'capitalize' }}>
|
||||
<TerminalTypography variant="body2" sx={{ textTransform: 'capitalize', fontWeight: 'bold' }}>
|
||||
{log.provider}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Tooltip as MuiTooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
ChartLoadingFallback
|
||||
@@ -37,6 +38,10 @@ import {
|
||||
formatPercentage
|
||||
} from '../../services/billingService';
|
||||
|
||||
// Components
|
||||
import CostVelocityChart from './CostVelocityChart';
|
||||
import MultiSeriesCostChart from './MultiSeriesCostChart';
|
||||
|
||||
interface UsageTrendsProps {
|
||||
trends: UsageTrendsType;
|
||||
projections: CostProjections;
|
||||
@@ -67,6 +72,28 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
: chartData[chartData.length - 1].calls > 0 ? 100 : 0
|
||||
: 0;
|
||||
|
||||
// Calculate cost velocity (daily spending rate)
|
||||
const currentDate = new Date();
|
||||
const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
|
||||
const currentDay = currentDate.getDate();
|
||||
const currentMonthCost = chartData.length > 0 ? chartData[chartData.length - 1].cost : 0;
|
||||
const avgDailyCost = currentDay > 0 ? currentMonthCost / currentDay : 0;
|
||||
const projectedMonthlyCost = avgDailyCost * daysInMonth;
|
||||
const daysRemaining = daysInMonth - currentDay;
|
||||
|
||||
// Calculate cost velocity trend (comparing recent vs earlier period)
|
||||
const recentPeriods = chartData.slice(-3); // Last 3 periods
|
||||
const earlierPeriods = chartData.slice(0, Math.min(3, chartData.length - 3));
|
||||
const recentAvgCost = recentPeriods.length > 0
|
||||
? recentPeriods.reduce((sum, p) => sum + p.cost, 0) / recentPeriods.length
|
||||
: 0;
|
||||
const earlierAvgCost = earlierPeriods.length > 0
|
||||
? earlierPeriods.reduce((sum, p) => sum + p.cost, 0) / earlierPeriods.length
|
||||
: 0;
|
||||
const velocityTrend = earlierAvgCost > 0
|
||||
? ((recentAvgCost - earlierAvgCost) / earlierAvgCost) * 100
|
||||
: 0;
|
||||
|
||||
// Custom tooltip for charts
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
@@ -121,126 +148,163 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
</CardContent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
{/* Growth Indicators */}
|
||||
{/* Growth Indicators & Cost Velocity */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={6} sm={4}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{costGrowth >= 0 ? (
|
||||
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cost Growth
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: costGrowth >= 0 ? '#22c55e' : '#ef4444'
|
||||
<MuiTooltip title="Percentage change in cost compared to first period" arrow>
|
||||
<Box component="span"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
textAlign: 'center',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
{costGrowth >= 0 ? '+' : ''}{costGrowth.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{costGrowth >= 0 ? (
|
||||
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Cost Growth
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: costGrowth >= 0 ? '#22c55e' : '#ef4444'
|
||||
}}
|
||||
>
|
||||
{costGrowth >= 0 ? '+' : ''}{costGrowth.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={6} sm={4}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{callsGrowth >= 0 ? (
|
||||
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Calls Growth
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: callsGrowth >= 0 ? '#22c55e' : '#ef4444'
|
||||
<MuiTooltip title="Percentage change in API calls compared to first period" arrow>
|
||||
<Box component="span"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
textAlign: 'center',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
{callsGrowth >= 0 ? '+' : ''}{callsGrowth.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{callsGrowth >= 0 ? (
|
||||
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Calls Growth
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: callsGrowth >= 0 ? '#22c55e' : '#ef4444'
|
||||
}}
|
||||
>
|
||||
{callsGrowth >= 0 ? '+' : ''}{callsGrowth.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<MuiTooltip
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
|
||||
Daily Spending Rate
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ display: 'block' }}>
|
||||
Average: {formatCurrency(avgDailyCost)}/day
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ display: 'block' }}>
|
||||
Velocity trend: {velocityTrend >= 0 ? '+' : ''}{velocityTrend.toFixed(1)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
|
||||
Based on current month's spending pattern
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box component="span"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(102, 126, 234, 0.3)',
|
||||
textAlign: 'center',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
<CalendarIcon sx={{ fontSize: 16, color: '#667eea' }} />
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500 }}>
|
||||
Cost Velocity
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: '#667eea'
|
||||
}}
|
||||
>
|
||||
{formatCurrency(avgDailyCost)}/day
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: velocityTrend >= 0 ? '#ef4444' : '#22c55e',
|
||||
display: 'block',
|
||||
mt: 0.5
|
||||
}}
|
||||
>
|
||||
{velocityTrend >= 0 ? '↑' : '↓'} {Math.abs(velocityTrend).toFixed(1)}% trend
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Cost Trend Chart */}
|
||||
{/* Multi-Series Cost Chart */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
|
||||
Monthly Cost Trend
|
||||
</Typography>
|
||||
<Box sx={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyAreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#667eea" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
stroke="rgba(255,255,255,0.9)"
|
||||
fontSize={12}
|
||||
tick={{ fill: 'rgba(255,255,255,0.9)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="rgba(255,255,255,0.9)"
|
||||
fontSize={12}
|
||||
tick={{ fill: 'rgba(255,255,255,0.9)' }}
|
||||
tickFormatter={(value) => `$${value.toFixed(0)}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cost"
|
||||
stroke="#667eea"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#costGradient)"
|
||||
/>
|
||||
</LazyAreaChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
<MultiSeriesCostChart
|
||||
trends={trends}
|
||||
monthlyLimit={projections.cost_limit || 0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* API Calls Trend Chart */}
|
||||
@@ -263,7 +327,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => formatNumber(value)}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="calls"
|
||||
@@ -271,6 +335,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }}
|
||||
animationDuration={1000}
|
||||
animationBegin={200}
|
||||
/>
|
||||
</LazyLineChart>
|
||||
</Suspense>
|
||||
@@ -278,62 +344,167 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Projections */}
|
||||
{/* Enhanced Projections */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
p: 2.5,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1, color: '#ffffff' }}>
|
||||
<CalendarIcon fontSize="small" />
|
||||
Monthly Projections
|
||||
Monthly Projections & Forecast
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Projected Cost
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{formatCurrency(projections.projected_monthly_cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MuiTooltip
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
|
||||
Projected Monthly Cost
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ display: 'block' }}>
|
||||
Based on current spending rate: {formatCurrency(avgDailyCost)}/day
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
|
||||
{daysRemaining} days remaining in month
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
|
||||
Projected Cost
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#667eea' }}>
|
||||
{formatCurrency(Math.max(projectedMonthlyCost, projections.projected_monthly_cost))}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', display: 'block', mt: 0.5 }}>
|
||||
{projectedMonthlyCost > projections.projected_monthly_cost ? '(velocity-based)' : '(trend-based)'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Usage %
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: projections.projected_usage_percentage > 80 ? 'error.main' :
|
||||
projections.projected_usage_percentage > 60 ? 'warning.main' : 'success.main'
|
||||
}}
|
||||
>
|
||||
{formatPercentage(projections.projected_usage_percentage)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MuiTooltip
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
|
||||
Projected Usage Percentage
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ display: 'block' }}>
|
||||
Based on projected cost vs monthly limit
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
|
||||
Usage %
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: projections.projected_usage_percentage > 80 ? '#ef4444' :
|
||||
projections.projected_usage_percentage > 60 ? '#f59e0b' : '#22c55e'
|
||||
}}
|
||||
>
|
||||
{formatPercentage(projections.projected_usage_percentage)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MuiTooltip
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
|
||||
Estimated Days Until Budget Exhaustion
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ display: 'block' }}>
|
||||
Based on current daily spending rate
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
|
||||
Days Remaining
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: avgDailyCost > 0 && projections.cost_limit > 0
|
||||
? (projections.cost_limit / avgDailyCost <= daysRemaining ? '#ef4444' : '#22c55e')
|
||||
: '#ffffff'
|
||||
}}
|
||||
>
|
||||
{avgDailyCost > 0 && projections.cost_limit > 0
|
||||
? Math.min(Math.ceil((projections.cost_limit - currentMonthCost) / avgDailyCost), daysRemaining)
|
||||
: daysRemaining
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<MuiTooltip
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
|
||||
Monthly Budget Limit
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ display: 'block' }}>
|
||||
Maximum spending allowed this month
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
|
||||
Budget Limit
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
{formatCurrency(projections.cost_limit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MuiTooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Chip
|
||||
label={`Limit: ${formatCurrency(projections.cost_limit)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'bold'
|
||||
|
||||
{/* Cost Velocity Warning */}
|
||||
{avgDailyCost > 0 && projectedMonthlyCost > projections.cost_limit && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 1.5,
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#ef4444', fontWeight: 500 }}>
|
||||
⚠️ At current spending rate ({formatCurrency(avgDailyCost)}/day), you'll exceed your monthly budget
|
||||
in ~{Math.ceil((projections.cost_limit - currentMonthCost) / avgDailyCost)} days.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
@@ -362,6 +533,15 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cost Velocity Chart */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<CostVelocityChart
|
||||
trends={trends}
|
||||
projections={projections}
|
||||
monthlyLimit={projections.cost_limit || 0}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user