Files
ALwrity/frontend/src/components/billing/AdvancedCostAnalytics.tsx

591 lines
21 KiB
TypeScript

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: any) => [formatCurrency(Number(value) || 0), '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: any) => formatCurrency(Number(value) || 0)}
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;