import { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import adminService from '../api/services/adminService'; import { Button } from '../components/ui/button'; import { Modal } from '../components/ui/modal'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, } from '../components/ui/table'; import { useDarkTheme } from '../hooks'; import { selectToken } from '../preferences/preferenceSlice'; import { formatDate } from '../utils/dateTimeUtils'; import { Loading, LoadError, StatCard, fmtNumber, fmtUsd } from './AdminUI'; import UsageChart, { usageColors } from './UsageChart'; type Bucket = { bucket: string; prompt_tokens: number; generated_tokens: number; cost: number; }; type Split = { key: string; tokens: number; cost: number }; const RANGES = [7, 30, 90]; /** Label for a token_usage.source value — what the spend was for. */ const FLOW_LABELS: Record = { agent_stream: 'Chat', schedule: 'Scheduled run', webhook: 'Webhook', workflow: 'Workflow', title: 'Title generation', compression: 'History compression', rag_condense: 'Question condensing', fallback: 'Provider fallback', }; function SplitTable({ title, rows, labels, }: { title: string; rows: Split[]; /** Only the flow table maps keys; a model named `fallback` is a model. */ labels?: Record; }) { return (

{title}

{rows.length === 0 ? (

No usage.

) : ( {title} Tokens Cost {rows.map((row) => ( {labels?.[row.key] ?? row.key} {fmtNumber(row.tokens)} {fmtUsd(row.cost)} ))}
)}
); } /** * Per-user spend drill-down. The user detail view carried a single * `tokens_30d` number, which answers neither question an operator has: what is * this person costing, and what is driving it. */ export default function UserUsageModal({ userId, onClose, }: { userId: string | null; onClose: () => void; }) { const token = useSelector(selectToken); const [isDarkTheme] = useDarkTheme(); const [days, setDays] = useState(30); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { if (!userId) { setData(null); return; } let cancelled = false; setLoading(true); // Drop the previous range's numbers rather than showing them under the // newly selected one. setData(null); adminService .getUserUsage(userId, { days }, token) .then((res) => res.json()) .then((json) => { if (!cancelled) setData(json); }) .catch(() => !cancelled && setData({ success: false })) .finally(() => !cancelled && setLoading(false)); return () => { cancelled = true; }; }, [userId, days, token]); const series: Bucket[] = data?.series ?? []; const chartData = useMemo(() => { const colors = usageColors(); return { labels: series.map((bucket) => formatDate(bucket.bucket)), datasets: [ { label: 'Prompt', data: series.map((bucket) => bucket.prompt_tokens), backgroundColor: colors.prompt, }, { label: 'Generated', data: series.map((bucket) => bucket.generated_tokens), backgroundColor: colors.generated, }, ], }; // isDarkTheme re-resolves the canvas colors when the theme toggles. }, [series, isDarkTheme]); const totals = data?.totals ?? {}; return ( !open && onClose()} title={userId ? `Usage · ${userId}` : 'Usage'} size="lg" >
{RANGES.map((range) => ( ))}
{loading ? ( ) : data && !data.success ? ( ) : (

Daily tokens

{series.length === 0 ? (

No usage in this period.

) : ( )}
)} ); }