mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-26 00:44:41 +00:00
feat(app): Spend section — daily-by-model stacked chart, by-project, model→project Sankey
Period-sliced StackedBars (history.daily[].topModels within the selected window via a new shared lib/period.ts), the By-project list, and a Sankey rendered dynamically from getSpendFlow (per-model hue ribbons). Lens tabs; series palette shared with the legend. Adds a neutral .s-other class for the rollup segment. Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
This commit is contained in:
parent
343156618f
commit
d7f3ccd1f0
7 changed files with 619 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ import { Window } from './components/Window'
|
|||
import { usePolled } from './hooks/usePolled'
|
||||
import { codeburn } from './lib/ipc'
|
||||
import { Overview } from './sections/Overview'
|
||||
import { Spend } from './sections/Spend'
|
||||
import type { MenubarPayload, Period } from './lib/types'
|
||||
|
||||
const SECTION_TITLES: Record<Section, string> = {
|
||||
|
|
@ -68,6 +69,8 @@ export function App() {
|
|||
<div className="body">
|
||||
{section === 'overview' ? (
|
||||
<Overview period={period} provider={provider} />
|
||||
) : section === 'spend' ? (
|
||||
<Spend period={period} provider={provider} />
|
||||
) : (
|
||||
<SectionPlaceholder title={SECTION_TITLES[section]} />
|
||||
)}
|
||||
|
|
|
|||
123
app/renderer/components/Sankey.tsx
Normal file
123
app/renderer/components/Sankey.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { isOtherNode, seriesHexForModel } from './StackedBars'
|
||||
import type { SpendFlow, SpendFlowNode } from '../lib/types'
|
||||
|
||||
type LayoutNode = SpendFlowNode & {
|
||||
x: number
|
||||
y: number
|
||||
h: number
|
||||
fill: string
|
||||
}
|
||||
|
||||
const VIEW_W = 640
|
||||
const VIEW_H = 190
|
||||
const TOP = 14
|
||||
const BOTTOM = 18
|
||||
const LEFT_X = 72
|
||||
const RIGHT_X = 562
|
||||
const NODE_W = 5
|
||||
const GAP = 8
|
||||
|
||||
export function Sankey({ flow }: { flow: SpendFlow }) {
|
||||
const models = layoutNodes(flow.models, LEFT_X, true)
|
||||
const projects = layoutNodes(flow.projects, RIGHT_X, false)
|
||||
const modelById = new Map(models.map(node => [node.id, node]))
|
||||
const projectById = new Map(projects.map(node => [node.id, node]))
|
||||
const sourceOffset = new Map<string, number>()
|
||||
const targetOffset = new Map<string, number>()
|
||||
|
||||
const ribbons = flow.links.flatMap((link, i) => {
|
||||
const source = modelById.get(link.model)
|
||||
const target = projectById.get(link.project)
|
||||
if (!source || !target || link.cost <= 0) return []
|
||||
|
||||
const sourceSegment = segmentSize(source, link.cost)
|
||||
const targetSegment = segmentSize(target, link.cost)
|
||||
const width = Math.max(2, Math.min(28, (sourceSegment + targetSegment) / 2))
|
||||
const sy = source.y + (sourceOffset.get(source.id) ?? 0) + sourceSegment / 2
|
||||
const ty = target.y + (targetOffset.get(target.id) ?? 0) + targetSegment / 2
|
||||
sourceOffset.set(source.id, (sourceOffset.get(source.id) ?? 0) + sourceSegment)
|
||||
targetOffset.set(target.id, (targetOffset.get(target.id) ?? 0) + targetSegment)
|
||||
|
||||
const gradId = gradientId(source.id)
|
||||
return [
|
||||
<path
|
||||
key={`${link.model}-${link.project}-${i}`}
|
||||
data-testid="sankey-ribbon"
|
||||
d={`M ${LEFT_X + NODE_W + 1} ${round(sy)} C 300 ${round(sy)} 380 ${round(ty)} ${RIGHT_X - 1} ${round(ty)}`}
|
||||
stroke={`url(#${gradId})`}
|
||||
strokeWidth={round(width)}
|
||||
fill="none"
|
||||
strokeOpacity=".40"
|
||||
/>,
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} width="100%" style={{ minWidth: 560, display: 'block' }}>
|
||||
<defs>
|
||||
{models.map(model => (
|
||||
<linearGradient key={model.id} id={gradientId(model.id)} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stopColor={model.fill} />
|
||||
<stop offset="1" stopColor={model.fill} stopOpacity=".25" />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{ribbons}
|
||||
|
||||
{models.map(node => (
|
||||
<rect key={node.id} x={node.x} y={round(node.y)} width={NODE_W} height={round(node.h)} rx="2.5" fill={node.fill} />
|
||||
))}
|
||||
{projects.map(node => (
|
||||
<rect key={node.id} x={node.x} y={round(node.y)} width={NODE_W} height={round(node.h)} rx="2.5" fill={node.fill} />
|
||||
))}
|
||||
|
||||
{models.map(node => (
|
||||
<text key={node.id} x="64" y={round(node.y + node.h / 2 + 3)} textAnchor="end" fontSize="10" fill="#9BA3B7">
|
||||
{node.label} · {fmtUsd(node.cost)}
|
||||
</text>
|
||||
))}
|
||||
{projects.map(node => (
|
||||
<text key={node.id} x="575" y={round(node.y + node.h / 2 + 3)} fontSize="10" fill="#9BA3B7">
|
||||
{node.label} · {fmtUsd(node.cost)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function layoutNodes(nodes: SpendFlowNode[], x: number, modelSide: boolean): LayoutNode[] {
|
||||
if (nodes.length === 0) return []
|
||||
const usable = VIEW_H - TOP - BOTTOM - GAP * Math.max(0, nodes.length - 1)
|
||||
const total = nodes.reduce((sum, node) => sum + Math.max(0, node.cost), 0)
|
||||
const rawHeights = nodes.map(node => (total > 0 ? (Math.max(0, node.cost) / total) * usable : usable / nodes.length))
|
||||
const minH = Math.min(10, usable / nodes.length)
|
||||
const inflated = rawHeights.map(h => Math.max(minH, h))
|
||||
const scale = inflated.reduce((sum, h) => sum + h, 0) > usable ? usable / inflated.reduce((sum, h) => sum + h, 0) : 1
|
||||
|
||||
let y = TOP
|
||||
return nodes.map((node, i) => {
|
||||
const h = Math.max(2, inflated[i] * scale)
|
||||
const neutral = isOtherNode(node.id) || isOtherNode(node.label)
|
||||
const fill = modelSide && !neutral ? seriesHexForModel(node.label || node.id) : neutral ? '#5F6780' : '#3A4258'
|
||||
const laidOut = { ...node, x, y, h, fill }
|
||||
y += h + GAP
|
||||
return laidOut
|
||||
})
|
||||
}
|
||||
|
||||
function segmentSize(node: LayoutNode, cost: number): number {
|
||||
return node.cost > 0 ? Math.max(1, (Math.max(0, cost) / node.cost) * node.h) : 1
|
||||
}
|
||||
|
||||
function gradientId(id: string): string {
|
||||
return `sankey-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
95
app/renderer/components/StackedBars.tsx
Normal file
95
app/renderer/components/StackedBars.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { DailyHistoryEntry } from '../lib/types'
|
||||
|
||||
export const SERIES_HEX = {
|
||||
opus: '#5B8CFF',
|
||||
sonnet: '#8B7CF6',
|
||||
haiku: '#B5A8FF',
|
||||
gpt: '#4DD8E6',
|
||||
other: '#5F6780',
|
||||
} as const
|
||||
|
||||
export type SeriesKey = keyof typeof SERIES_HEX
|
||||
|
||||
export function seriesKeyForModel(model?: string): SeriesKey {
|
||||
const m = (model ?? '').toLowerCase()
|
||||
if (m.includes('opus')) return 'opus'
|
||||
if (m.includes('sonnet')) return 'sonnet'
|
||||
if (m.includes('haiku')) return 'haiku'
|
||||
if (m.includes('gpt') || m.includes('codex')) return 'gpt'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function seriesClassForModel(model?: string): string {
|
||||
switch (seriesKeyForModel(model)) {
|
||||
case 'opus':
|
||||
return 's-opus'
|
||||
case 'sonnet':
|
||||
return 's-son'
|
||||
case 'haiku':
|
||||
return 's-hai'
|
||||
case 'gpt':
|
||||
return 's-gpt'
|
||||
case 'other':
|
||||
return 's-other'
|
||||
}
|
||||
}
|
||||
|
||||
export function seriesHexForModel(model?: string): string {
|
||||
return SERIES_HEX[seriesKeyForModel(model)]
|
||||
}
|
||||
|
||||
export function isOtherNode(idOrLabel?: string): boolean {
|
||||
const value = (idOrLabel ?? '').trim().toLowerCase()
|
||||
return value === '__other__' || value === 'other' || value === 'others'
|
||||
}
|
||||
|
||||
export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
|
||||
const maxTotal = Math.max(
|
||||
1,
|
||||
...daily.map(day => day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sbars" aria-label="Daily spend by model">
|
||||
{daily.map(day => (
|
||||
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${fmtUsd(day.cost)}`}>
|
||||
{day.topModels.map(model => {
|
||||
const pct = Math.max(2, (Math.max(0, model.cost) / maxTotal) * 100)
|
||||
return (
|
||||
<span
|
||||
key={`${day.date}-${model.name}`}
|
||||
className={`s ${seriesClassForModel(model.name)}`}
|
||||
style={{ height: `${pct}%` }}
|
||||
title={`${model.name} · ${fmtUsd(model.cost)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="legend">
|
||||
<span>
|
||||
<i style={{ background: 'var(--blue)' }} />
|
||||
Opus 4.8
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: 'var(--purple)' }} />
|
||||
Sonnet 5
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: 'var(--lav)' }} />
|
||||
Haiku 4.5
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: 'var(--cyan)' }} />
|
||||
GPT-5.5 Codex
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
54
app/renderer/lib/period.ts
Normal file
54
app/renderer/lib/period.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { DailyHistoryEntry, Period } from './types'
|
||||
|
||||
/** Local calendar date key "YYYY-MM-DD", matching the CLI's `dateKey` (src/day-aggregator.ts). */
|
||||
export function localDateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared period-window helper for backfilled `history.daily` arrays. T8 should
|
||||
* migrate Overview.tsx to this helper so both sections use one source of truth.
|
||||
*/
|
||||
export function periodWindowStart(period: Period, now = new Date()): string | null {
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return localDateKey(now)
|
||||
case 'week':
|
||||
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6))
|
||||
case '30days':
|
||||
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29))
|
||||
case 'month':
|
||||
return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
case 'all':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** `history.daily` entries within the selected period's date window. */
|
||||
export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, now = new Date()): DailyHistoryEntry[] {
|
||||
const start = periodWindowStart(period, now)
|
||||
const todayKey = localDateKey(now)
|
||||
return daily.filter(d => (start === null || d.date >= start) && d.date <= todayKey)
|
||||
}
|
||||
|
||||
/** Length of the selected period in days; `all` spans available history when provided. */
|
||||
export function periodLengthDays(period: Period, daily: DailyHistoryEntry[] = [], now = new Date()): number {
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return 1
|
||||
case 'week':
|
||||
return 7
|
||||
case '30days':
|
||||
return 30
|
||||
case 'month':
|
||||
return now.getDate()
|
||||
case 'all': {
|
||||
if (daily.length === 0) return 1
|
||||
const earliest = daily.reduce((min, d) => (d.date < min ? d.date : min), daily[0].date)
|
||||
const [y, m, d] = earliest.split('-').map(Number)
|
||||
const start = new Date(y, m - 1, d)
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
return Math.max(1, Math.round((today.getTime() - start.getTime()) / 86_400_000) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/renderer/sections/Spend.test.tsx
Normal file
149
app/renderer/sections/Spend.test.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// @vitest-environment jsdom
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MenubarPayload, SpendFlow } from '../lib/types'
|
||||
import { Spend } from './Spend'
|
||||
|
||||
const { getOverview, getSpendFlow } = vi.hoisted(() => ({
|
||||
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
|
||||
getSpendFlow: vi.fn<(period: string, provider: string) => Promise<SpendFlow>>(),
|
||||
}))
|
||||
vi.mock('../lib/ipc', async orig => {
|
||||
const actual = await orig<typeof import('../lib/ipc')>()
|
||||
return { ...actual, codeburn: { getOverview, getSpendFlow } }
|
||||
})
|
||||
|
||||
function daily(date: string, cost: number, models: Array<{ name: string; cost: number }>) {
|
||||
return {
|
||||
date,
|
||||
cost,
|
||||
savingsUSD: 0,
|
||||
calls: 10,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: models.map(m => ({
|
||||
name: m.name,
|
||||
cost: m.cost,
|
||||
savingsUSD: 0,
|
||||
calls: 5,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function makePayload(now: Date): MenubarPayload {
|
||||
return {
|
||||
generated: now.toISOString(),
|
||||
current: {
|
||||
label: 'Last 30 days',
|
||||
cost: 612.48,
|
||||
calls: 1220,
|
||||
sessions: 88,
|
||||
oneShotRate: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheHitPercent: 0,
|
||||
codexCredits: 0,
|
||||
topActivities: [{ name: 'coding', cost: 42, savingsUSD: 0, turns: 12, oneShotRate: null }],
|
||||
topModels: [],
|
||||
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
|
||||
providers: {},
|
||||
topProjects: [
|
||||
{
|
||||
name: 'codeburn',
|
||||
cost: 246.1,
|
||||
savingsUSD: 0,
|
||||
sessions: 124,
|
||||
avgCostPerSession: 1.98,
|
||||
sessionDetails: [],
|
||||
},
|
||||
{
|
||||
name: 'agentseal-dash',
|
||||
cost: 141.3,
|
||||
savingsUSD: 0,
|
||||
sessions: 74,
|
||||
avgCostPerSession: 1.91,
|
||||
sessionDetails: [],
|
||||
},
|
||||
],
|
||||
modelEfficiency: [],
|
||||
topSessions: [],
|
||||
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: [{ name: 'Read', calls: 30 }],
|
||||
skills: [{ name: 'imagegen', turns: 3, cost: 1.25 }],
|
||||
subagents: [{ name: 'reviewer', calls: 2, cost: 2.5 }],
|
||||
mcpServers: [{ name: 'filesystem', calls: 9 }],
|
||||
},
|
||||
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
|
||||
history: {
|
||||
daily: [
|
||||
daily('2026-06-30', 11, [{ name: 'claude-opus-4', cost: 11 }]),
|
||||
daily('2026-07-01', 12, [{ name: 'gpt-5.5-codex', cost: 12 }]),
|
||||
daily('2026-07-04', 13, [{ name: 'claude-opus-4', cost: 9 }, { name: 'claude-sonnet-5', cost: 4 }]),
|
||||
daily('2026-07-06', 8, [{ name: 'claude-haiku-4', cost: 8 }]),
|
||||
daily('2026-07-10', 15, [{ name: 'gpt-5.5-codex', cost: 15 }]),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeFlow(): SpendFlow {
|
||||
return {
|
||||
period: { label: 'Last 7 days', start: '2026-07-04', end: '2026-07-10' },
|
||||
models: [
|
||||
{ id: 'claude-opus-4', label: 'Opus 4.8', cost: 22 },
|
||||
{ id: 'gpt-5.5-codex', label: 'GPT-5.5 Codex', cost: 18 },
|
||||
],
|
||||
projects: [
|
||||
{ id: 'codeburn', label: 'codeburn', cost: 30 },
|
||||
{ id: '__other__', label: 'Other', cost: 10 },
|
||||
],
|
||||
links: [
|
||||
{ model: 'claude-opus-4', project: 'codeburn', cost: 18 },
|
||||
{ model: 'claude-opus-4', project: '__other__', cost: 4 },
|
||||
{ model: 'gpt-5.5-codex', project: 'codeburn', cost: 12 },
|
||||
{ model: 'gpt-5.5-codex', project: '__other__', cost: 6 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Spend', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date(2026, 6, 10, 12, 0, 0))
|
||||
getOverview.mockReset()
|
||||
getSpendFlow.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('slices stacked spend bars to the selected period, renders projects, and draws one Sankey ribbon per link', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(new Date()))
|
||||
getSpendFlow.mockResolvedValue(makeFlow())
|
||||
|
||||
const { container } = render(<Spend period="week" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('codeburn')).toBeInTheDocument()
|
||||
expect(screen.getByText('$246.10')).toBeInTheDocument()
|
||||
expect(screen.getByText('agentseal-dash')).toBeInTheDocument()
|
||||
|
||||
const barColumns = container.querySelectorAll('.sbars .c')
|
||||
expect(barColumns).toHaveLength(3)
|
||||
expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([
|
||||
'2026-07-04',
|
||||
'2026-07-06',
|
||||
'2026-07-10',
|
||||
])
|
||||
|
||||
expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length)
|
||||
})
|
||||
})
|
||||
194
app/renderer/sections/Spend.tsx
Normal file
194
app/renderer/sections/Spend.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { ListRow } from '../components/ListRow'
|
||||
import { Panel } from '../components/Panel'
|
||||
import { Sankey } from '../components/Sankey'
|
||||
import { SegTabs } from '../components/SegTabs'
|
||||
import { StackedBars } from '../components/StackedBars'
|
||||
import { usePolled } from '../hooks/usePolled'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { sliceDailyToPeriod } from '../lib/period'
|
||||
import type { MenubarPayload, Period, SpendFlow } from '../lib/types'
|
||||
|
||||
type Lens = 'projects' | 'activity' | 'tools' | 'mcp' | 'subagents'
|
||||
|
||||
const LENSES = [
|
||||
{ value: 'projects', label: 'Projects' },
|
||||
{ value: 'activity', label: 'Activity' },
|
||||
{ value: 'tools', label: 'Tools' },
|
||||
{ value: 'mcp', label: 'MCP' },
|
||||
{ value: 'subagents', label: 'Subagents' },
|
||||
]
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function EmptyNote({ children }: { children: React.ReactNode }) {
|
||||
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
|
||||
}
|
||||
|
||||
export function Spend({ period, provider }: { period: Period; provider: string }) {
|
||||
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
|
||||
const flow = usePolled<SpendFlow>(() => codeburn.getSpendFlow(period, provider), [period, provider])
|
||||
const [lens, setLens] = useState<Lens>('projects')
|
||||
|
||||
if (!overview.data) {
|
||||
if (overview.error?.kind === 'not-found') {
|
||||
return (
|
||||
<Panel title="Locate the codeburn CLI">
|
||||
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
|
||||
CodeBurn Desktop reads your usage by running the{' '}
|
||||
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn't
|
||||
on your PATH yet.
|
||||
</p>
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
|
||||
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
|
||||
then reopen this window.
|
||||
</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
if (overview.error) {
|
||||
return (
|
||||
<Panel title="Couldn't read spend">
|
||||
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{overview.error.message}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Panel title="Spend">
|
||||
<EmptyNote>Scanning spend…</EmptyNote>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as Lens)} style={{ alignSelf: 'flex-start' }} />
|
||||
{lens === 'projects' ? (
|
||||
<ProjectsLens data={overview.data} flow={flow} period={period} />
|
||||
) : (
|
||||
<DetailLens data={overview.data} lens={lens} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectsLens({
|
||||
data,
|
||||
flow,
|
||||
period,
|
||||
}: {
|
||||
data: MenubarPayload
|
||||
flow: ReturnType<typeof usePolled<SpendFlow>>
|
||||
period: Period
|
||||
}) {
|
||||
const daily = sliceDailyToPeriod(data.history.daily, period)
|
||||
const projects = data.current.topProjects
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<Panel title="Daily spend by model">
|
||||
{daily.length ? <StackedBars daily={daily} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
|
||||
</Panel>
|
||||
<Panel title="By project" right={`${projects.length} ${projects.length === 1 ? 'project' : 'projects'}`}>
|
||||
{projects.length ? (
|
||||
projects.map((project, i) => (
|
||||
<ListRow
|
||||
key={project.name}
|
||||
no={String(i + 1).padStart(2, '0')}
|
||||
title={project.name}
|
||||
sub={`${project.sessions.toLocaleString('en-US')} ${project.sessions === 1 ? 'session' : 'sessions'}`}
|
||||
value={fmtUsd(project.cost)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyNote>No project spend in this range yet.</EmptyNote>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel title="Cost flow · model → project" right="click a ribbon to filter" bodyStyle={{ overflowX: 'auto' }}>
|
||||
{flow.data && flow.data.links.length ? (
|
||||
<Sankey flow={flow.data} />
|
||||
) : flow.error ? (
|
||||
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{flow.error.message}</p>
|
||||
) : (
|
||||
<EmptyNote>{flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'}</EmptyNote>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude<Lens, 'projects'> }) {
|
||||
if (lens === 'activity') {
|
||||
const rows = [
|
||||
...data.current.topActivities.map(row => ({
|
||||
key: `activity-${row.name}`,
|
||||
title: row.name,
|
||||
sub: `${row.turns.toLocaleString('en-US')} turns`,
|
||||
value: fmtUsd(row.cost),
|
||||
})),
|
||||
...data.current.skills.map(row => ({
|
||||
key: `skill-${row.name}`,
|
||||
title: row.name,
|
||||
sub: `${row.turns.toLocaleString('en-US')} turns · skill`,
|
||||
value: fmtUsd(row.cost),
|
||||
})),
|
||||
]
|
||||
return <RowsPanel title="Activity" rows={rows} empty="No activity or skill spend in this range yet." />
|
||||
}
|
||||
|
||||
if (lens === 'tools') {
|
||||
const rows = data.current.tools.map(row => ({
|
||||
key: row.name,
|
||||
title: row.name,
|
||||
sub: `${row.calls.toLocaleString('en-US')} calls`,
|
||||
value: undefined,
|
||||
}))
|
||||
return <RowsPanel title="Tools" rows={rows} empty="No tool calls in this range yet." />
|
||||
}
|
||||
|
||||
if (lens === 'mcp') {
|
||||
const rows = data.current.mcpServers.map(row => ({
|
||||
key: row.name,
|
||||
title: row.name,
|
||||
sub: `${row.calls.toLocaleString('en-US')} calls`,
|
||||
value: undefined,
|
||||
}))
|
||||
return <RowsPanel title="MCP" rows={rows} empty="No MCP server calls in this range yet." />
|
||||
}
|
||||
|
||||
const rows = data.current.subagents.map(row => ({
|
||||
key: row.name,
|
||||
title: row.name,
|
||||
sub: `${row.calls.toLocaleString('en-US')} calls`,
|
||||
value: fmtUsd(row.cost),
|
||||
}))
|
||||
return <RowsPanel title="Subagents" rows={rows} empty="No subagent spend in this range yet." />
|
||||
}
|
||||
|
||||
function RowsPanel({
|
||||
title,
|
||||
rows,
|
||||
empty,
|
||||
}: {
|
||||
title: string
|
||||
rows: Array<{ key: string; title: string; sub: string; value?: string }>
|
||||
empty: string
|
||||
}) {
|
||||
return (
|
||||
<Panel title={title}>
|
||||
{rows.length ? (
|
||||
rows.map((row, i) => (
|
||||
<ListRow key={row.key} no={String(i + 1).padStart(2, '0')} title={row.title} sub={row.sub} value={row.value} />
|
||||
))
|
||||
) : (
|
||||
<EmptyNote>{empty}</EmptyNote>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
|
@ -121,6 +121,7 @@ h2 { font-size: 20px; font-weight: 650; letter-spacing: -.015em; margin: 0 0 6px
|
|||
.s-son { background: var(--purple); }
|
||||
.s-hai { background: var(--lav); }
|
||||
.s-gpt { background: var(--cyan); }
|
||||
.s-other { background: var(--t3); }
|
||||
.legend { display: flex; gap: 16px; padding: 9px 4px 0; font-size: 10.5px; color: var(--t2); flex-wrap: wrap; }
|
||||
.legend i { width: 8px; height: 8px; border-radius: 2.5px; display: inline-block; margin-right: 6px; vertical-align: -1px; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue