Spaces:
Runtime error
Runtime error
nyk
feat: full i18n β 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified | 'use client' | |
| import { useState, useEffect, useCallback, useRef } from 'react' | |
| import { useTranslations } from 'next-intl' | |
| import { Button } from '@/components/ui/button' | |
| import { Loader } from '@/components/ui/loader' | |
| import { useMissionControl } from '@/store' | |
| import { createClientLogger } from '@/lib/client-logger' | |
| import { | |
| PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, | |
| Tooltip, Legend, ResponsiveContainer, BarChart, Bar, | |
| } from 'recharts' | |
| const log = createClientLogger('CostTracker') | |
| // ββ Types ββββββββββββββββββββββββββββββββββββββββββ | |
| interface TokenStats { | |
| totalTokens: number; totalCost: number; requestCount: number | |
| avgTokensPerRequest: number; avgCostPerRequest: number | |
| } | |
| interface UsageStats { | |
| summary: TokenStats | |
| models: Record<string, { totalTokens: number; totalCost: number; requestCount: number }> | |
| sessions: Record<string, { totalTokens: number; totalCost: number; requestCount: number }> | |
| timeframe: string | |
| recordCount: number | |
| } | |
| interface TrendData { | |
| trends: Array<{ timestamp: string; tokens: number; cost: number; requests: number }> | |
| timeframe: string | |
| } | |
| interface ByAgentModelBreakdown { | |
| model: string; input_tokens: number; output_tokens: number; request_count: number; cost: number | |
| } | |
| interface ByAgentEntry { | |
| agent: string; total_input_tokens: number; total_output_tokens: number | |
| total_tokens: number; total_cost: number; session_count: number | |
| request_count: number; last_active: string; models: ByAgentModelBreakdown[] | |
| } | |
| interface ByAgentResponse { | |
| agents: ByAgentEntry[] | |
| summary: { total_cost: number; total_tokens: number; agent_count: number; days: number } | |
| } | |
| interface TaskCostEntry { | |
| taskId: number; title: string; status: string; priority: string | |
| assignedTo?: string | null | |
| project: { id?: number | null; name?: string | null; slug?: string | null; ticketRef?: string | null } | |
| stats: TokenStats | |
| models: Record<string, TokenStats> | |
| } | |
| interface TaskCostsResponse { | |
| summary: TokenStats | |
| tasks: TaskCostEntry[] | |
| agents: Record<string, { stats: TokenStats; taskCount: number; taskIds: number[] }> | |
| unattributed: TokenStats | |
| timeframe: string | |
| } | |
| interface SessionCostEntry { | |
| sessionId: string; sessionKey?: string; model: string | |
| totalTokens: number; inputTokens: number; outputTokens: number | |
| totalCost: number; requestCount: number; firstSeen: string; lastSeen: string | |
| } | |
| // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββ | |
| const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d', '#ffc658', '#ff6b6b'] | |
| const formatNumber = (num: number) => { | |
| if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M' | |
| if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K' | |
| return num.toString() | |
| } | |
| const formatCost = (cost: number) => '$' + cost.toFixed(4) | |
| const getModelDisplayName = (name: string) => name.split('/').pop() || name | |
| type View = 'overview' | 'agents' | 'sessions' | 'tasks' | |
| type Timeframe = 'hour' | 'day' | 'week' | 'month' | |
| // ββ Main Component ββββββββββββββββββββββββββββββββββ | |
| export function CostTrackerPanel() { | |
| const t = useTranslations('costTracker') | |
| const { sessions } = useMissionControl() | |
| const [view, setView] = useState<View>('overview') | |
| const [timeframe, setTimeframe] = useState<Timeframe>('day') | |
| const [chartMode, setChartMode] = useState<'incremental' | 'cumulative'>('incremental') | |
| const [isLoading, setIsLoading] = useState(false) | |
| const [isExporting, setIsExporting] = useState(false) | |
| // Data | |
| const [usageStats, setUsageStats] = useState<UsageStats | null>(null) | |
| const [trendData, setTrendData] = useState<TrendData | null>(null) | |
| const [byAgentData, setByAgentData] = useState<ByAgentResponse | null>(null) | |
| const [taskData, setTaskData] = useState<TaskCostsResponse | null>(null) | |
| const [sessionCosts, setSessionCosts] = useState<SessionCostEntry[]>([]) | |
| const [sessionSort, setSessionSort] = useState<'cost' | 'tokens' | 'requests' | 'recent'>('cost') | |
| const [expandedAgent, setExpandedAgent] = useState<string | null>(null) | |
| const refreshTimer = useRef<ReturnType<typeof setInterval> | null>(null) | |
| const timeframeToDays = (tf: Timeframe): number => { | |
| switch (tf) { case 'hour': case 'day': return 1; case 'week': return 7; case 'month': return 30 } | |
| } | |
| const loadData = useCallback(async () => { | |
| setIsLoading(true) | |
| try { | |
| const [statsRes, trendRes, byAgentRes, taskRes] = await Promise.all([ | |
| fetch(`/api/tokens?action=stats&timeframe=${timeframe}`), | |
| fetch(`/api/tokens?action=trends&timeframe=${timeframe}`), | |
| fetch(`/api/tokens/by-agent?days=${timeframeToDays(timeframe)}`), | |
| fetch(`/api/tokens?action=task-costs&timeframe=${timeframe}`), | |
| ]) | |
| const [statsJson, trendJson, byAgentJson, taskJson] = await Promise.all([ | |
| statsRes.json(), trendRes.json(), byAgentRes.json(), taskRes.json(), | |
| ]) | |
| setUsageStats(statsJson) | |
| setTrendData(trendJson) | |
| setByAgentData(byAgentJson) | |
| setTaskData(taskJson) | |
| } catch (err) { | |
| log.error('Failed to load cost data:', err) | |
| } finally { | |
| setIsLoading(false) | |
| } | |
| }, [timeframe]) | |
| const loadSessionCosts = useCallback(async () => { | |
| try { | |
| const res = await fetch(`/api/tokens?action=session-costs&timeframe=${timeframe}`) | |
| const data = await res.json() | |
| if (Array.isArray(data?.sessions)) { | |
| setSessionCosts(data.sessions) | |
| } else if (usageStats?.sessions) { | |
| setSessionCosts(Object.entries(usageStats.sessions).map(([id, stats]) => ({ | |
| sessionId: id, model: '', totalTokens: stats.totalTokens, inputTokens: 0, | |
| outputTokens: 0, totalCost: stats.totalCost, requestCount: stats.requestCount, | |
| firstSeen: '', lastSeen: '', | |
| }))) | |
| } | |
| } catch { | |
| if (usageStats?.sessions) { | |
| setSessionCosts(Object.entries(usageStats.sessions).map(([id, stats]) => ({ | |
| sessionId: id, model: '', totalTokens: stats.totalTokens, inputTokens: 0, | |
| outputTokens: 0, totalCost: stats.totalCost, requestCount: stats.requestCount, | |
| firstSeen: '', lastSeen: '', | |
| }))) | |
| } | |
| } | |
| }, [timeframe, usageStats]) | |
| useEffect(() => { loadData() }, [loadData]) | |
| useEffect(() => { | |
| refreshTimer.current = setInterval(loadData, 30_000) | |
| return () => { if (refreshTimer.current) clearInterval(refreshTimer.current) } | |
| }, [loadData]) | |
| useEffect(() => { if (view === 'sessions') loadSessionCosts() }, [view, loadSessionCosts]) | |
| const exportData = async (format: 'json' | 'csv') => { | |
| setIsExporting(true) | |
| try { | |
| const res = await fetch(`/api/tokens?action=export&timeframe=${timeframe}&format=${format}`) | |
| if (!res.ok) throw new Error('Export failed') | |
| const blob = await res.blob() | |
| const url = window.URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.style.display = 'none'; a.href = url | |
| a.download = `cost-tracker-${timeframe}-${new Date().toISOString().split('T')[0]}.${format}` | |
| document.body.appendChild(a); a.click() | |
| window.URL.revokeObjectURL(url); document.body.removeChild(a) | |
| } catch (err) { | |
| log.error('Export failed:', err) | |
| } finally { | |
| setIsExporting(false) | |
| } | |
| } | |
| // Derived data | |
| const summary = usageStats?.summary | |
| const agentSummary = byAgentData?.summary | |
| const agentList = byAgentData?.agents || [] | |
| const maxAgentCost = Math.max(...agentList.map(a => a.total_cost), 0.0001) | |
| const getAgentTasks = (agentName: string): TaskCostEntry[] => { | |
| if (!taskData) return [] | |
| const entry = taskData.agents[agentName] | |
| if (!entry) return [] | |
| return taskData.tasks.filter(t => entry.taskIds.includes(t.taskId)) | |
| } | |
| return ( | |
| <div className="p-6 space-y-6"> | |
| {/* Header */} | |
| <div className="border-b border-border pb-4"> | |
| <div className="flex items-center justify-between flex-wrap gap-3"> | |
| <div> | |
| <h1 className="text-3xl font-bold text-foreground">{t('title')}</h1> | |
| <p className="text-muted-foreground mt-1">{t('subtitle')}</p> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {/* View tabs */} | |
| <div className="flex rounded-lg border border-border overflow-hidden"> | |
| {(['overview', 'agents', 'sessions', 'tasks'] as const).map(v => ( | |
| <button | |
| key={v} | |
| onClick={() => setView(v)} | |
| className={`px-3 py-1.5 text-xs font-medium transition-colors ${ | |
| view === v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:text-foreground' | |
| }`} | |
| > | |
| {v.charAt(0).toUpperCase() + v.slice(1)} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Timeframe */} | |
| <div className="flex space-x-1"> | |
| {(['hour', 'day', 'week', 'month'] as const).map(tf => ( | |
| <Button key={tf} onClick={() => setTimeframe(tf)} variant={timeframe === tf ? 'default' : 'secondary'} size="sm"> | |
| {tf.charAt(0).toUpperCase() + tf.slice(1)} | |
| </Button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {isLoading && !usageStats ? ( | |
| <Loader variant="panel" label={t('loadingCostData')} /> | |
| ) : view === 'overview' ? ( | |
| <OverviewView | |
| stats={usageStats} trendData={trendData} agentSummary={agentSummary} | |
| taskData={taskData} timeframe={timeframe} chartMode={chartMode} | |
| setChartMode={setChartMode} exportData={exportData} isExporting={isExporting} | |
| onRefresh={loadData} | |
| /> | |
| ) : view === 'agents' ? ( | |
| <AgentsView | |
| agents={agentList} summary={agentSummary} maxCost={maxAgentCost} | |
| expandedAgent={expandedAgent} setExpandedAgent={setExpandedAgent} | |
| getAgentTasks={getAgentTasks} onRefresh={loadData} | |
| /> | |
| ) : view === 'sessions' ? ( | |
| <SessionsView | |
| sessionCosts={sessionCosts} sessions={sessions} | |
| sessionSort={sessionSort} setSessionSort={setSessionSort} | |
| /> | |
| ) : ( | |
| <TasksView taskData={taskData} onRefresh={loadData} /> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ββ Overview View ββββββββββββββββββββββββββββββββββ | |
| function OverviewView({ | |
| stats, trendData, agentSummary, taskData, timeframe, chartMode, setChartMode, | |
| exportData, isExporting, onRefresh, | |
| }: { | |
| stats: UsageStats | null; trendData: TrendData | null | |
| agentSummary: ByAgentResponse['summary'] | undefined; taskData: TaskCostsResponse | null | |
| timeframe: Timeframe; chartMode: 'incremental' | 'cumulative' | |
| setChartMode: (m: 'incremental' | 'cumulative') => void | |
| exportData: (f: 'json' | 'csv') => void; isExporting: boolean | |
| onRefresh: () => void | |
| }) { | |
| const t = useTranslations('costTracker') | |
| if (!stats) { | |
| return ( | |
| <div className="text-center text-muted-foreground py-12"> | |
| <div className="text-lg mb-2">{t('noUsageData')}</div> | |
| <div className="text-sm max-w-sm mx-auto"> | |
| {t('noUsageDataDesc')} | |
| </div> | |
| <Button onClick={onRefresh} variant="outline" size="sm" className="mt-4 text-xs">{t('refresh')}</Button> | |
| </div> | |
| ) | |
| } | |
| const modelData = Object.entries(stats.models) | |
| .map(([model, s]) => ({ name: getModelDisplayName(model), fullName: model, tokens: s.totalTokens, cost: s.totalCost, requests: s.requestCount })) | |
| .sort((a, b) => b.cost - a.cost) | |
| const pieData = modelData.slice(0, 6).map(m => ({ name: m.name, value: m.cost })) | |
| const trendChartData = (() => { | |
| if (!trendData?.trends) return [] | |
| const raw = trendData.trends.map(t => ({ | |
| time: new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), | |
| tokens: t.tokens, cost: t.cost, requests: t.requests, | |
| })) | |
| if (chartMode === 'cumulative') { | |
| let ct = 0, cc = 0, cr = 0 | |
| return raw.map(d => { ct += d.tokens; cc += d.cost; cr += d.requests; return { ...d, tokens: ct, cost: cc, requests: cr } }) | |
| } | |
| return raw | |
| })() | |
| // Performance metrics | |
| const models = Object.entries(stats.models) | |
| const mostEfficient = models.length > 0 | |
| ? models.reduce((best, curr) => { | |
| const c = curr[1].totalCost / Math.max(1, curr[1].totalTokens) | |
| const b = best[1].totalCost / Math.max(1, best[1].totalTokens) | |
| return c < b ? curr : best | |
| }) | |
| : null | |
| const efficientCostPerToken = mostEfficient ? mostEfficient[1].totalCost / Math.max(1, mostEfficient[1].totalTokens) : 0 | |
| const potentialSavings = Math.max(0, stats.summary.totalCost - stats.summary.totalTokens * efficientCostPerToken) | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Summary cards */} | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-4"> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatCost(stats.summary.totalCost)}</div> | |
| <div className="text-sm text-muted-foreground">{t('totalCost', { timeframe })}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatNumber(stats.summary.totalTokens)}</div> | |
| <div className="text-sm text-muted-foreground">{t('totalTokens')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatNumber(stats.summary.requestCount)}</div> | |
| <div className="text-sm text-muted-foreground">{t('apiRequests')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{agentSummary?.agent_count ?? '-'}</div> | |
| <div className="text-sm text-muted-foreground">{t('activeAgents')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground"> | |
| {taskData ? `${((1 - taskData.unattributed.totalCost / Math.max(stats.summary.totalCost, 0.0001)) * 100).toFixed(0)}%` : '-'} | |
| </div> | |
| <div className="text-sm text-muted-foreground">{t('taskAttributed')}</div> | |
| </div> | |
| </div> | |
| {/* Charts */} | |
| <div className="grid lg:grid-cols-2 gap-6"> | |
| {/* Trend chart */} | |
| <div className="bg-card border border-border rounded-lg p-6 lg:col-span-2"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h2 className="text-xl font-semibold">{t('usageTrends')}</h2> | |
| <div className="flex rounded-md border border-border overflow-hidden"> | |
| {(['incremental', 'cumulative'] as const).map(m => ( | |
| <button key={m} onClick={() => setChartMode(m)} | |
| className={`px-2 py-1 text-[10px] font-medium ${chartMode === m ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:text-foreground'}`} | |
| >{m === 'incremental' ? t('perTurn') : t('cumulative')}</button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="h-64"> | |
| {trendChartData.length === 0 ? ( | |
| <div className="h-full flex items-center justify-center text-muted-foreground text-sm">{t('noTrendData')}</div> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <LineChart data={trendChartData}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="time" /><YAxis /> | |
| <Tooltip /><Legend /> | |
| <Line type="monotone" dataKey="tokens" stroke="#8884d8" strokeWidth={2} name="Tokens" /> | |
| <Line type="monotone" dataKey="requests" stroke="#82ca9d" strokeWidth={2} name="Requests" /> | |
| </LineChart> | |
| </ResponsiveContainer> | |
| )} | |
| </div> | |
| </div> | |
| {/* Model bar chart */} | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <h2 className="text-xl font-semibold mb-4">{t('tokenUsageByModel')}</h2> | |
| <div className="h-64"> | |
| {modelData.length === 0 ? ( | |
| <div className="h-full flex items-center justify-center text-muted-foreground text-sm">{t('noModelData')}</div> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <BarChart data={modelData}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="name" angle={-45} textAnchor="end" height={80} interval={0} /> | |
| <YAxis /><Tooltip formatter={(v, n) => [formatNumber(Number(v)), n]} /> | |
| <Bar dataKey="tokens" fill="#8884d8" name="Tokens" /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| )} | |
| </div> | |
| </div> | |
| {/* Cost pie */} | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <h2 className="text-xl font-semibold mb-4">{t('costDistributionByModel')}</h2> | |
| <div className="h-64"> | |
| {pieData.length === 0 ? ( | |
| <div className="h-full flex items-center justify-center text-muted-foreground text-sm">{t('noCostData')}</div> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <PieChart> | |
| <Pie data={pieData} cx="50%" cy="50%" innerRadius={40} outerRadius={80} paddingAngle={5} dataKey="value"> | |
| {pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)} | |
| </Pie> | |
| <Tooltip formatter={(v) => formatCost(Number(v))} /><Legend /> | |
| </PieChart> | |
| </ResponsiveContainer> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Performance insights */} | |
| {models.length > 0 && ( | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <h2 className="text-xl font-semibold mb-4">{t('performanceInsights')}</h2> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> | |
| <div className="bg-secondary rounded-lg p-4"> | |
| <div className="text-xs text-muted-foreground mb-1">{t('mostEfficientModel')}</div> | |
| <div className="text-lg font-bold text-green-500">{mostEfficient ? getModelDisplayName(mostEfficient[0]) : '-'}</div> | |
| {mostEfficient && <div className="text-xs text-muted-foreground">${(efficientCostPerToken * 1000).toFixed(4)}/1K tokens</div>} | |
| </div> | |
| <div className="bg-secondary rounded-lg p-4"> | |
| <div className="text-xs text-muted-foreground mb-1">{t('avgTokensPerRequest')}</div> | |
| <div className="text-lg font-bold text-foreground">{formatNumber(stats.summary.avgTokensPerRequest)}</div> | |
| </div> | |
| <div className="bg-secondary rounded-lg p-4"> | |
| <div className="text-xs text-muted-foreground mb-1">{t('optimizationPotential')}</div> | |
| <div className="text-lg font-bold text-orange-500">{formatCost(potentialSavings)}</div> | |
| <div className="text-xs text-muted-foreground">{stats.summary.totalCost > 0 ? ((potentialSavings / stats.summary.totalCost) * 100).toFixed(1) : '0'}% {t('savingsPossible')}</div> | |
| </div> | |
| </div> | |
| {/* Model efficiency bars */} | |
| <div className="space-y-2"> | |
| {modelData.map(m => { | |
| const costPer1k = m.cost / Math.max(1, m.tokens) * 1000 | |
| const maxCostPer1k = Math.max(...modelData.map(d => d.cost / Math.max(1, d.tokens) * 1000), 0.0001) | |
| return ( | |
| <div key={m.fullName} className="flex items-center text-sm"> | |
| <div className="w-32 truncate text-muted-foreground">{m.name}</div> | |
| <div className="flex-1 mx-3"> | |
| <div className="w-full bg-secondary rounded-full h-2"> | |
| <div className="bg-green-500 h-2 rounded-full" style={{ width: `${(costPer1k / maxCostPer1k) * 100}%` }} /> | |
| </div> | |
| </div> | |
| <div className="w-20 text-right text-xs text-muted-foreground">${costPer1k.toFixed(4)}/1K</div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Export */} | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-lg font-semibold">{t('exportData')}</h2> | |
| <p className="text-sm text-muted-foreground">{t('exportDataDesc')}</p> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button onClick={() => exportData('csv')} disabled={isExporting} size="sm" variant="secondary">{isExporting ? t('exporting') : 'CSV'}</Button> | |
| <Button onClick={() => exportData('json')} disabled={isExporting} size="sm" variant="secondary">{isExporting ? t('exporting') : 'JSON'}</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ββ Agents View ββββββββββββββββββββββββββββββββββ | |
| function AgentsView({ | |
| agents, summary, maxCost, expandedAgent, setExpandedAgent, getAgentTasks, onRefresh, | |
| }: { | |
| agents: ByAgentEntry[]; summary: ByAgentResponse['summary'] | undefined | |
| maxCost: number; expandedAgent: string | null | |
| setExpandedAgent: (a: string | null) => void | |
| getAgentTasks: (name: string) => TaskCostEntry[]; onRefresh: () => void | |
| }) { | |
| const t = useTranslations('costTracker') | |
| const [expandedSection, setExpandedSection] = useState<'models' | 'tasks'>('tasks') | |
| if (!summary || agents.length === 0) { | |
| return ( | |
| <div className="text-center text-muted-foreground py-12"> | |
| <div className="text-lg mb-2">{t('noAgentData')}</div> | |
| <div className="text-sm">{t('noAgentDataDesc')}</div> | |
| <Button onClick={onRefresh} className="mt-4">{t('refresh')}</Button> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Summary row */} | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{summary.agent_count}</div> | |
| <div className="text-sm text-muted-foreground">{t('agents')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatCost(summary.total_cost)}</div> | |
| <div className="text-sm text-muted-foreground">{t('totalCostDays', { days: summary.days })}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatNumber(summary.total_tokens)}</div> | |
| <div className="text-sm text-muted-foreground">{t('totalTokens')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground"> | |
| {summary.total_tokens > 0 ? `$${(summary.total_cost / summary.total_tokens * 1000).toFixed(4)}` : '-'} | |
| </div> | |
| <div className="text-sm text-muted-foreground">{t('avgPer1kTokens')}</div> | |
| </div> | |
| </div> | |
| {/* Cost bar chart */} | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <h2 className="text-xl font-semibold mb-4">{t('perAgentCost')}</h2> | |
| <div className="h-64"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <BarChart data={agents.slice(0, 12).map(a => ({ | |
| name: a.agent.length > 12 ? a.agent.slice(0, 11) + '\u2026' : a.agent, | |
| cost: Number(a.total_cost.toFixed(4)), | |
| }))}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="name" tick={{ fontSize: 11 }} /><YAxis tick={{ fontSize: 11 }} /> | |
| <Tooltip formatter={(v) => formatCost(Number(v))} /> | |
| <Bar dataKey="cost" fill="#0088FE" name="Cost ($)" /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| {/* Agent detail rows */} | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <h2 className="text-xl font-semibold mb-4">{t('agentBreakdown')}</h2> | |
| <div className="space-y-2 max-h-[600px] overflow-y-auto"> | |
| {agents.map(agent => { | |
| const costShare = (agent.total_cost / Math.max(summary.total_cost, 0.0001)) * 100 | |
| const isExpanded = expandedAgent === agent.agent | |
| const agentTasks = getAgentTasks(agent.agent) | |
| return ( | |
| <div key={agent.agent} className="border border-border rounded-lg overflow-hidden"> | |
| <Button onClick={() => setExpandedAgent(isExpanded ? null : agent.agent)} | |
| variant="ghost" className="w-full p-4 h-auto flex items-center justify-between text-left"> | |
| <div className="flex items-center gap-3 min-w-0"> | |
| <span className="font-medium text-foreground truncate">{agent.agent}</span> | |
| <span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground shrink-0"> | |
| {agent.session_count} session{agent.session_count !== 1 ? 's' : ''} | |
| </span> | |
| <span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-500 shrink-0"> | |
| {agent.request_count} req{agent.request_count !== 1 ? 's' : ''} | |
| </span> | |
| {agentTasks.length > 0 && ( | |
| <span className="text-xs px-2 py-0.5 rounded-full bg-green-500/10 text-green-500 shrink-0"> | |
| {agentTasks.length} task{agentTasks.length !== 1 ? 's' : ''} | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-4 text-sm shrink-0"> | |
| <div className="w-24 hidden md:block"> | |
| <div className="w-full bg-secondary rounded-full h-2"> | |
| <div className="bg-blue-500 h-2 rounded-full" style={{ width: `${(agent.total_cost / maxCost) * 100}%` }} /> | |
| </div> | |
| </div> | |
| <div className="text-right"> | |
| <div className="font-medium text-foreground">{formatCost(agent.total_cost)}</div> | |
| <div className="text-xs text-muted-foreground">{costShare.toFixed(1)}%</div> | |
| </div> | |
| <div className="text-right"> | |
| <div className="text-muted-foreground">{formatNumber(agent.total_tokens)}</div> | |
| <div className="text-xs text-muted-foreground">{t('tokens')}</div> | |
| </div> | |
| <svg className={`w-4 h-4 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`} | |
| viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | |
| <polyline points="4,6 8,10 12,6" /> | |
| </svg> | |
| </div> | |
| </Button> | |
| {isExpanded && ( | |
| <div className="px-4 pb-4 border-t border-border bg-secondary/30"> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-3 mb-3"> | |
| <div><div className="text-xs text-muted-foreground">{t('inputTokens')}</div><div className="text-sm font-medium">{formatNumber(agent.total_input_tokens)}</div></div> | |
| <div><div className="text-xs text-muted-foreground">{t('outputTokens')}</div><div className="text-sm font-medium">{formatNumber(agent.total_output_tokens)}</div></div> | |
| <div><div className="text-xs text-muted-foreground">{t('ioRatio')}</div><div className="text-sm font-medium">{agent.total_output_tokens > 0 ? (agent.total_input_tokens / agent.total_output_tokens).toFixed(2) : '-'}</div></div> | |
| <div><div className="text-xs text-muted-foreground">{t('lastActive')}</div><div className="text-sm font-medium">{new Date(agent.last_active).toLocaleDateString()}</div></div> | |
| </div> | |
| <div className="flex gap-2 mb-3"> | |
| <Button variant={expandedSection === 'tasks' ? 'default' : 'ghost'} size="sm" onClick={(e) => { e.stopPropagation(); setExpandedSection('tasks') }}>Tasks ({agentTasks.length})</Button> | |
| <Button variant={expandedSection === 'models' ? 'default' : 'ghost'} size="sm" onClick={(e) => { e.stopPropagation(); setExpandedSection('models') }}>Models ({agent.models.length})</Button> | |
| </div> | |
| {expandedSection === 'tasks' && ( | |
| <div className="text-sm"> | |
| {agentTasks.length === 0 ? ( | |
| <div className="text-xs text-muted-foreground italic py-2">{t('noTaskCosts')}</div> | |
| ) : ( | |
| <div className="space-y-1.5"> | |
| {agentTasks.map(task => ( | |
| <div key={task.taskId} className="flex items-center justify-between text-xs"> | |
| <div className="flex items-center gap-2 min-w-0 flex-1"> | |
| <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${ | |
| task.priority === 'critical' ? 'bg-red-500/10 text-red-500' : | |
| task.priority === 'high' ? 'bg-orange-500/10 text-orange-500' : | |
| task.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-500' : | |
| 'bg-secondary text-muted-foreground' | |
| }`}>{task.priority}</span> | |
| {task.project.ticketRef && <span className="text-muted-foreground font-mono">{task.project.ticketRef}</span>} | |
| <span className="text-foreground truncate">{task.title}</span> | |
| </div> | |
| <span className="font-medium text-foreground w-16 text-right shrink-0">{formatCost(task.stats.totalCost)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {expandedSection === 'models' && agent.models.length > 0 && ( | |
| <div className="space-y-1.5"> | |
| {agent.models.map(m => ( | |
| <div key={m.model} className="flex items-center justify-between text-xs"> | |
| <span className="text-muted-foreground truncate">{getModelDisplayName(m.model)}</span> | |
| <div className="flex gap-4 shrink-0"> | |
| <span>{formatNumber(m.input_tokens)} in</span> | |
| <span>{formatNumber(m.output_tokens)} out</span> | |
| <span>{m.request_count} reqs</span> | |
| <span className="font-medium text-foreground w-16 text-right">{formatCost(m.cost)}</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ββ Sessions View ββββββββββββββββββββββββββββββββββ | |
| function SessionsView({ | |
| sessionCosts, sessions, sessionSort, setSessionSort, | |
| }: { | |
| sessionCosts: SessionCostEntry[]; sessions: any[] | |
| sessionSort: 'cost' | 'tokens' | 'requests' | 'recent' | |
| setSessionSort: (s: 'cost' | 'tokens' | 'requests' | 'recent') => void | |
| }) { | |
| const t = useTranslations('costTracker') | |
| const sorted = [...sessionCosts].sort((a, b) => { | |
| switch (sessionSort) { | |
| case 'cost': return b.totalCost - a.totalCost | |
| case 'tokens': return b.totalTokens - a.totalTokens | |
| case 'requests': return b.requestCount - a.requestCount | |
| case 'recent': return (b.lastSeen || '').localeCompare(a.lastSeen || '') | |
| default: return 0 | |
| } | |
| }) | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-sm text-muted-foreground">{t('sortBy')}:</span> | |
| {(['cost', 'tokens', 'requests', 'recent'] as const).map(s => ( | |
| <button key={s} onClick={() => setSessionSort(s)} | |
| className={`px-2 py-1 text-xs rounded ${sessionSort === s ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'}`} | |
| >{s.charAt(0).toUpperCase() + s.slice(1)}</button> | |
| ))} | |
| </div> | |
| {sorted.length === 0 ? ( | |
| <div className="text-center text-muted-foreground py-12"> | |
| <p className="text-lg mb-1">{t('noSessionCostData')}</p> | |
| <p className="text-sm">{t('noSessionCostDataDesc')}</p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-2"> | |
| {sorted.map(entry => { | |
| const sessionInfo = sessions.find((s: any) => s.id === entry.sessionId) | |
| return ( | |
| <div key={entry.sessionId} className="bg-card border border-border rounded-lg p-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <div className="min-w-0"> | |
| <div className="font-medium text-foreground truncate"> | |
| {entry.sessionKey || sessionInfo?.key || entry.sessionId} | |
| </div> | |
| <div className="text-xs text-muted-foreground flex items-center gap-2"> | |
| {sessionInfo?.active && <span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500" />} | |
| <span>{sessionInfo?.active ? t('activeStatus') : t('inactiveStatus')}</span> | |
| {entry.model && <span>| {getModelDisplayName(entry.model)}</span>} | |
| {sessionInfo?.kind && <span>| {sessionInfo.kind}</span>} | |
| </div> | |
| </div> | |
| <div className="text-right flex-shrink-0"> | |
| <div className="text-lg font-bold text-foreground">{formatCost(entry.totalCost)}</div> | |
| <div className="text-xs text-muted-foreground">{formatNumber(entry.totalTokens)} tokens</div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-4 gap-4 text-xs text-muted-foreground border-t border-border/50 pt-2 mt-2"> | |
| <div><span className="font-medium text-foreground">{entry.requestCount}</span> {t('requests')}</div> | |
| <div><span className="font-medium text-foreground">{formatNumber(entry.inputTokens || 0)}</span> {t('inShort')}</div> | |
| <div><span className="font-medium text-foreground">{formatNumber(entry.outputTokens || 0)}</span> {t('outShort')}</div> | |
| <div>{entry.totalTokens > 0 ? <span className="font-medium text-foreground">{formatCost(entry.totalCost / entry.requestCount)}</span> : '-'} {t('avgPerReq')}</div> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ββ Tasks View ββββββββββββββββββββββββββββββββββ | |
| function TasksView({ taskData, onRefresh }: { taskData: TaskCostsResponse | null; onRefresh: () => void }) { | |
| const t = useTranslations('costTracker') | |
| if (!taskData || taskData.tasks.length === 0) { | |
| return ( | |
| <div className="text-center text-muted-foreground py-12"> | |
| <div className="text-lg mb-2">{t('noTaskCostData')}</div> | |
| <div className="text-sm">{t('noTaskCostDataDesc')}</div> | |
| <Button onClick={onRefresh} className="mt-4">{t('refresh')}</Button> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Summary */} | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{taskData.tasks.length}</div> | |
| <div className="text-sm text-muted-foreground">{t('tasksWithCosts')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatCost(taskData.summary.totalCost)}</div> | |
| <div className="text-sm text-muted-foreground">{t('attributedCost')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-foreground">{formatNumber(taskData.summary.totalTokens)}</div> | |
| <div className="text-sm text-muted-foreground">{t('attributedTokens')}</div> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-5"> | |
| <div className="text-3xl font-bold text-orange-500">{formatCost(taskData.unattributed.totalCost)}</div> | |
| <div className="text-sm text-muted-foreground">{t('unattributed')}</div> | |
| </div> | |
| </div> | |
| {/* Task list */} | |
| <div className="bg-card border border-border rounded-lg p-6"> | |
| <h2 className="text-xl font-semibold mb-4">{t('tasksByCost')}</h2> | |
| <div className="space-y-2 max-h-[600px] overflow-y-auto"> | |
| {taskData.tasks.map(task => ( | |
| <div key={task.taskId} className="border border-border rounded-lg p-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2 min-w-0 flex-1"> | |
| <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${ | |
| task.priority === 'critical' ? 'bg-red-500/10 text-red-500' : | |
| task.priority === 'high' ? 'bg-orange-500/10 text-orange-500' : | |
| task.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-500' : | |
| 'bg-secondary text-muted-foreground' | |
| }`}>{task.priority}</span> | |
| {task.project.ticketRef && <span className="text-xs text-muted-foreground font-mono shrink-0">{task.project.ticketRef}</span>} | |
| <span className="font-medium text-foreground truncate">{task.title}</span> | |
| <span className={`px-1.5 py-0.5 rounded text-[10px] shrink-0 ${ | |
| task.status === 'done' ? 'bg-green-500/10 text-green-500' : | |
| task.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500' : | |
| 'bg-secondary text-muted-foreground' | |
| }`}>{task.status}</span> | |
| </div> | |
| <div className="text-right shrink-0 ml-3"> | |
| <div className="font-medium text-foreground">{formatCost(task.stats.totalCost)}</div> | |
| <div className="text-xs text-muted-foreground">{formatNumber(task.stats.totalTokens)} {t('tokens')} | {task.stats.requestCount} {t('reqs')}</div> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |