UsagePanel.tsx
Language TypeScript4.2 KB104 lines
import { useEffect, useState } from 'react'import type { UsageReport } from '../ipc/gen/UsageReport'import { shortModel, thousands, tokens, usd } from '../lib/format'import { useStore } from '../state/store'export function UsagePanel() {const client = useStore((s) => s.client)const spend = useStore((s) => s.spend)const setPanel = useStore((s) => s.setPanel)const [days, setDays] = useState<number | null>(null)const [report, setReport] = useState<UsageReport | null>(null)useEffect(() => {void client?.usageReport(days).then(setReport)}, [client, days])const maxModel = Math.max(1, ...(report?.by_model.map((r) => r.cost_nanousd) ?? [1]))const maxDay = Math.max(1, ...(report?.by_day.map((r) => r.cost_nanousd) ?? [1]))const total = report?.by_model.reduce((s, r) => s + r.cost_nanousd, 0) ?? 0const estimated = report?.by_model.some((r) => r.estimated)return (<div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setPanel(null)}><div className="sheet glass-strong" role="dialog" aria-label="Usage"><header className="sheet-head"><h2>Usage</h2><div className="filters">{([[null, 'This month'],[7, '7 days'],[30, '30 days'],[365, 'Year'],] as const).map(([d, label]) => (<button key={label} className={`pill ${days === d ? 'on' : ''}`} onClick={() => setDays(d)}>{label}</button>))}</div><button className="ghost" onClick={() => setPanel(null)}>Close</button></header><div className="sheet-body"><div className="stat-row"><div className="stat glass-tint"><span className="muted small">Spent</span><strong data-testid="usage-total">{usd(total)}{estimated ? '*' : ''}</strong></div><div className="stat glass-tint"><span className="muted small">Replies</span><strong>{thousands(report?.by_model.reduce((s, r) => s + r.messages, 0) ?? 0)}</strong></div><div className="stat glass-tint"><span className="muted small">Tokens in / out</span><strong>{tokens(report?.by_model.reduce((s, r) => s + r.prompt_tokens, 0) ?? 0)} /{' '}{tokens(report?.by_model.reduce((s, r) => s + r.completion_tokens, 0) ?? 0)}</strong></div>{spend?.budget_usd != null && (<div className="stat glass-tint"><span className="muted small">Monthly budget</span><strong>${spend.budget_usd.toFixed(2)}</strong></div>)}</div><h3>By model</h3><ul className="bars">{report?.by_model.map((r) => (<li key={r.key}><span className="bar-label" title={r.key}>{shortModel(r.key)}</span><span className="bar-track"><span className="bar-fill" style={{ width: `${(r.cost_nanousd / maxModel) * 100}%` }} /></span><span className="bar-value">{r.cost_nanousd > 0 ? usd(r.cost_nanousd) : 'free'}{r.estimated ? '*' : ''}</span></li>))}</ul><h3>By day</h3><div className="days" role="img" aria-label="Spend per day">{report?.by_day.map((r) => (<div key={r.key} className="day" title={`${r.key}: ${usd(r.cost_nanousd)}`}><span className="day-fill" style={{ height: `${Math.max(3, (r.cost_nanousd / maxDay) * 100)}%` }} /><span className="day-label">{r.key.slice(5)}</span></div>))}</div>{estimated && (<p className="muted small">* includes estimates, from servers that do not report token counts.</p>)}</div></div></div>)}