HuggingClaw-MissionControl / src /components /panels /cost-tracker-panel.tsx
nyk
feat: full i18n β€” 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified
Raw
History Blame Contribute Delete
39.8 kB
'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>
)
}