codeburn/app/renderer/sections/Models.tsx
iamtoruk cbddd3a9d7 fix(app): accuracy batch A — provider scoping, honest chart axes, range threading, stale-error banner
- Provider filter: Models-this-period sources current.topModels (history
  days carry empty topModels per provider); StackedBars falls back to a
  cost-proportional single segment like the Swift menubar
- Chart windows align to the CLI (week -7, 30days -30, all = 6 months)
  and zero-fill a contiguous calendar window; history.daily is sparse and
  both sides key local YYYY-MM-DD, so lookups are safe (verified on real
  CLI output)
- Custom range threads into Overview panels (chart, models table) and
  suppresses MTD/projected and vs-last-week comparisons; Compare states
  that custom dates fall back to the period
- usePolled clears errors per attempt and sections show a StaleBanner
  instead of silently rendering last-good data after a failed refresh
- Cache-hit denominator matches the CLI; hero savings relabeled 'Saved by
  applied fixes' + new 'Saved via local models' line; formatCompact
  everywhere; Optimize 'Fixes' count matches the rendered list

App suite 163/163 (was 147), root 1613/1613.
2026-07-16 01:14:41 -07:00

219 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { seriesColorForModel } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { DateRange, ModelReportRow, Period } from '../lib/types'
type ModelsLens = 'model' | 'task'
const LENSES = [
{ value: 'model', label: 'By model' },
{ value: 'task', label: 'By task' },
]
function fmtInt(n: number): string {
return n.toLocaleString('en-US')
}
function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Models({
period,
provider,
range = null,
refreshToken = 0,
onNavigate,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
onNavigate?: (section: Section) => void
}) {
const [lens, setLens] = useState<ModelsLens>('model')
const byTask = lens === 'task'
const report = usePolled<ModelReportRow[]>(
() => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask),
[period, provider, byTask, range?.from, range?.to, refreshToken],
)
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="model usage" />
return (
<Panel title="Models">
<EmptyNote>Scanning model usage</EmptyNote>
</Panel>
)
}
return (
<>
{report.error && <StaleBanner error={report.error} />}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'flex-start' }}>
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as ModelsLens)} />
<button type="button" className="btn btn-s" onClick={() => onNavigate?.('compare')}>
Compare
</button>
</div>
<Panel bodyStyle={{ overflowX: 'auto' }}>
{report.data.length ? (
<ModelsTable rows={report.data} byTask={byTask} />
) : (
<EmptyNote>No model usage in this range yet.</EmptyNote>
)}
</Panel>
</>
)
}
function ModelsTable({ rows, byTask }: { rows: ModelReportRow[]; byTask: boolean }) {
if (byTask) return <ModelsByTaskTable rows={rows} />
return (
<table>
<thead>
<tr>
<th>Model</th>
<th>Calls</th>
<th>Input</th>
<th>Output</th>
<th>Cache read</th>
<th>Cost</th>
<th>Saved</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<ModelTableRow key={`${row.provider}-${row.model}-${i}`} row={row} />
))}
</tbody>
</table>
)
}
function ModelsByTaskTable({ rows }: { rows: ModelReportRow[] }) {
const groups = groupTaskRows(rows)
return (
<table className="models-by-task">
<thead>
<tr>
<th>Task</th>
<th>Calls</th>
<th>Input</th>
<th>Output</th>
<th>Cache read</th>
<th>Cost</th>
<th>Saved</th>
</tr>
</thead>
{groups.map(group => (
<tbody className="model-task-group" key={`${group.provider}-${group.model}`}>
<ModelGroupRow rows={group.rows} />
{group.rows.map((row, i) => (
<ModelTaskRow key={`${row.category ?? 'all'}-${i}`} row={row} />
))}
</tbody>
))}
</table>
)
}
function ModelTableRow({ row }: { row: ModelReportRow }) {
const unpriced = row.costUSD === 0 && row.savingsUSD === 0
const cellClass = unpriced ? 'dim' : undefined
const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value))
const dotStyle = {
display: 'inline-block',
background: seriesColorForModel(row.modelDisplayName || row.model),
marginRight: 8,
}
return (
<tr>
<td className={cellClass} title={row.model}>
<span className="mdot" style={dotStyle} />
{row.modelDisplayName}
{unpriced ? (
<>
{' '}
<span className="alias">add alias </span>
</>
) : null}
</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
)
}
function ModelGroupRow({ rows }: { rows: ModelReportRow[] }) {
const model = rows[0]
const calls = rows.reduce((sum, row) => sum + row.calls, 0)
const costUSD = rows.reduce((sum, row) => sum + row.costUSD, 0)
const savingsUSD = rows.reduce((sum, row) => sum + row.savingsUSD, 0)
const unpriced = costUSD === 0 && savingsUSD === 0
return (
<tr className="model-group-row">
<td className={unpriced ? 'dim' : undefined} title={model.model}>
<span className="model-group-lead">
<span
className="mdot"
style={{ background: seriesColorForModel(model.modelDisplayName || model.model) }}
/>
<span className="model-group-name">{model.modelDisplayName}</span>
{unpriced ? <span className="alias">add alias </span> : null}
</span>
</td>
<td className={unpriced ? 'dim' : undefined}>{fmtInt(calls)}</td>
<td aria-label="No aggregate input" />
<td aria-label="No aggregate output" />
<td aria-label="No aggregate cache read" />
<td className={unpriced ? 'dim' : undefined}>{unpriced ? '—' : formatUsd(costUSD)}</td>
<td className={unpriced ? 'dim' : savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(savingsUSD)}</td>
</tr>
)
}
function ModelTaskRow({ row }: { row: ModelReportRow }) {
const unpriced = row.costUSD === 0 && row.savingsUSD === 0
const cellClass = unpriced ? 'dim' : undefined
const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value))
return (
<tr className="model-task-row">
<td className={cellClass}>{row.category ?? 'general'}</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
)
}
function groupTaskRows(rows: ModelReportRow[]) {
const groups = new Map<string, { provider: string; model: string; rows: ModelReportRow[] }>()
for (const row of rows) {
const key = `${row.provider}\u0000${row.model}`
const group = groups.get(key)
if (group) group.rows.push(row)
else groups.set(key, { provider: row.provider, model: row.model, rows: [row] })
}
return [...groups.values()]
}