feat(web): local React dashboard served by codeburn web (#531)

Add a Vite + React 19 + Tailwind v4 + Recharts SPA (dash/) and a 'web'
command that serves the built UI plus a local /api/usage endpoint backed
by the existing menubar aggregation. Midnight theme with CodeBurn's
orange chart ramp: hero cost, a stacked-by-model daily bar chart with a
custom tooltip and hover-dim, metric cards, by-tool and by-activity bar
lists, and top-projects and tools tables. Period and provider filters,
react-query with skeleton loading. Stays 100% local.

build:dash builds the SPA into dist/dash (shipped via package files,
served at runtime); a missing build falls back to a helpful message.
This commit is contained in:
Resham Joshi 2026-06-20 16:21:25 +02:00 committed by GitHub
parent 75c32e6d65
commit 2d44aeaedb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 3875 additions and 1 deletions

12
dash/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeBurn</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2949
dash/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
dash/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "codeburn-dash",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.7",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.3.0",
"tailwind-merge": "^3.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.13",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.13",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

188
dash/src/App.tsx Normal file
View file

@ -0,0 +1,188 @@
import { useMemo, useState, type ReactNode } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { fetchUsage, PERIODS, type Period } from '@/lib/api'
import { cn, fmtNum, fmtTokens, usd } from '@/lib/utils'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { MetricCard } from '@/components/MetricCard'
import { BarList, type BarItem } from '@/components/BarList'
import { DataTable } from '@/components/DataTable'
import { UsageChart } from '@/components/UsageChart'
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<Card className="px-5 py-4">
<h2 className="mb-3.5 text-[11px] font-semibold uppercase tracking-wider text-tertiary-foreground">{title}</h2>
{children}
</Card>
)
}
export function App() {
const [period, setPeriod] = useState<Period>('month')
const [provider, setProvider] = useState('all')
const { data, isLoading, isError, error } = useQuery({
queryKey: ['usage', period, provider],
queryFn: () => fetchUsage(period, provider),
placeholderData: keepPreviousData,
})
const c = data?.current
const providerOptions = useMemo(
() =>
c
? Object.entries(c.providers)
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1])
.map(([k]) => k)
: [],
[c],
)
const toolBars: BarItem[] = c
? Object.entries(c.providers)
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
: []
const modelBars: BarItem[] = c
? c.topModels.filter((m) => m.cost > 0).slice(0, 8).map((m) => ({ name: m.name, value: m.cost, display: usd(m.cost) }))
: []
const activityBars: BarItem[] = c
? c.topActivities.filter((a) => a.cost > 0).map((a) => ({ name: a.name, value: a.cost, display: usd(a.cost) }))
: []
return (
<div className="min-h-screen bg-outer-background">
<header className="border-b border-border">
<div className="mx-auto flex max-w-[1200px] items-center gap-3 px-6 py-3.5">
<div className="flex items-center gap-2">
<span className="text-lg leading-none text-primary">&#9650;</span>
<span className="text-sm font-semibold">CodeBurn</span>
</div>
<span className="text-[11px] text-tertiary-foreground">Local usage dashboard. Nothing leaves your machine.</span>
<span className="ml-auto text-[11px] text-tertiary-foreground">{c?.label ?? ''}</span>
</div>
</header>
<main className="mx-auto max-w-[1200px] px-6 py-6">
<div className="mb-5 flex flex-wrap items-center gap-2">
<div className="flex rounded-lg border border-border bg-interactive-secondary p-1">
{PERIODS.map((p) => (
<button
key={p.key}
type="button"
onClick={() => setPeriod(p.key)}
className={cn(
'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
period === p.key
? 'bg-active-primary text-foreground shadow-sm ring-1 ring-inset ring-white/10'
: 'text-tertiary-foreground hover:text-foreground',
)}
>
{p.label}
</button>
))}
</div>
<select
value={provider}
onChange={(e) => setProvider(e.target.value)}
className="ml-auto rounded-lg border border-border bg-interactive-secondary px-3 py-2 text-xs text-foreground outline-none"
>
<option value="all">All tools</option>
{providerOptions.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<Card className="mb-4 overflow-hidden">
<div className="flex items-end justify-between px-5 pt-4">
<div>
<div className="text-xs text-tertiary-foreground">
{c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '}
</div>
<div className="mt-0.5 text-3xl font-semibold tracking-tight tabular-nums text-primary">
{c ? usd(c.cost) : <Skeleton className="h-9 w-32" />}
</div>
</div>
</div>
<div className="mt-3 h-64 px-2 pb-2">
{isLoading || !data ? (
<Skeleton className="mx-3 mb-3 h-[228px]" />
) : (
<UsageChart daily={data.history.daily} />
)}
</div>
</Card>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{c ? (
<>
<MetricCard label="Cost" value={usd(c.cost)} accent />
<MetricCard
label="Tokens"
value={fmtTokens(c.inputTokens + c.outputTokens)}
sub={`in ${fmtTokens(c.inputTokens)} / out ${fmtTokens(c.outputTokens)}`}
/>
<MetricCard label="Calls" value={fmtNum(c.calls)} />
<MetricCard label="Sessions" value={fmtNum(c.sessions)} />
<MetricCard label="Cache hit" value={`${(c.cacheHitPercent || 0).toFixed(1)}%`} />
<MetricCard label="One-shot" value={c.oneShotRate == null ? '—' : `${Math.round(c.oneShotRate * 100)}%`} />
</>
) : (
Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20" />)
)}
</div>
<div className="mb-4 grid gap-4 lg:grid-cols-2">
<Panel title="By tool">
<BarList items={toolBars} total={c?.cost} />
</Panel>
<Panel title="Top models">
<BarList items={modelBars} total={c?.cost} />
</Panel>
</div>
<div className="mb-4 grid gap-4 lg:grid-cols-2">
<Panel title="Top projects">
<DataTable
columns={[
{ key: 'name', label: 'Project' },
{ key: 'cost', label: 'Cost', num: true },
{ key: 'sessions', label: 'Sessions', num: true },
]}
rows={(c?.topProjects ?? []).slice(0, 10).map((p) => ({
name: p.name,
cost: usd(p.cost),
sessions: fmtNum(p.sessions),
}))}
/>
</Panel>
<Panel title="By activity">
<BarList items={activityBars} total={c?.cost} />
</Panel>
</div>
<Panel title="Tools">
<DataTable
columns={[
{ key: 'name', label: 'Tool' },
{ key: 'calls', label: 'Calls', num: true },
]}
rows={(c?.tools ?? []).slice(0, 14).map((t) => ({ name: t.name, calls: fmtNum(t.calls) }))}
/>
</Panel>
{isError && (
<div className="mt-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message)}</div>
)}
</main>
</div>
)
}

View file

@ -0,0 +1,28 @@
export type BarItem = { name: string; value: number; display: string }
export function BarList({ items, total }: { items: BarItem[]; total?: number }) {
if (!items.length) return <div className="py-8 text-center text-sm text-tertiary-foreground">No data.</div>
const max = Math.max(...items.map((i) => i.value), 1)
return (
<div className="flex flex-col gap-2.5">
{items.map((it) => {
const pct = Math.max(2, Math.round((it.value / max) * 100))
const share = total ? Math.round((it.value / total) * 100) + '%' : ''
return (
<div key={it.name} className="grid grid-cols-[minmax(80px,130px)_1fr_auto] items-center gap-3 text-sm">
<div className="truncate text-foreground">{it.name}</div>
<div className="h-2 overflow-hidden rounded-full bg-interactive-secondary">
<div
className="h-full rounded-full"
style={{ width: pct + '%', background: 'linear-gradient(90deg, var(--color-chart-1), var(--color-chart-4))' }}
/>
</div>
<div className="min-w-[88px] text-right tabular-nums text-tertiary-foreground">
<span className="font-medium text-foreground">{it.display}</span> {share}
</div>
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type Column = { key: string; label: string; num?: boolean }
export function DataTable({ columns, rows }: { columns: Column[]; rows: Array<Record<string, ReactNode>> }) {
if (!rows.length) return <div className="py-8 text-center text-sm text-tertiary-foreground">No data.</div>
return (
<table className="w-full text-sm">
<thead>
<tr>
{columns.map((c) => (
<th
key={c.key}
className={cn(
'pb-2 text-[11px] font-medium uppercase tracking-wider text-tertiary-foreground',
c.num ? 'text-right' : 'text-left',
)}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-border">
{columns.map((c) => (
<td key={c.key} className={cn('py-2 tabular-nums', c.num ? 'text-right' : 'text-left')}>
{r[c.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}

View file

@ -0,0 +1,24 @@
import { Card } from './ui/card'
import { cn } from '@/lib/utils'
export function MetricCard({
label,
value,
sub,
accent,
}: {
label: string
value: string
sub?: string
accent?: boolean
}) {
return (
<Card className="px-4 py-3.5">
<div className="text-[11px] uppercase tracking-wider text-tertiary-foreground">{label}</div>
<div className={cn('mt-1.5 text-2xl font-semibold tabular-nums tracking-tight', accent && 'text-primary')}>
{value}
</div>
{sub ? <div className="mt-1 text-xs text-tertiary-foreground">{sub}</div> : null}
</Card>
)
}

View file

@ -0,0 +1,100 @@
import { useMemo } from 'react'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import type { DailyEntry } from '@/lib/api'
import { CHART_COLORS, compactUsd, label, usd } from '@/lib/utils'
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function fmtDay(d: string): string {
const [, m, day] = String(d).split('-')
return m && day ? `${Number(day)} ${MONTHS[Number(m)]}` : d
}
const TOP_N = 6
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function ChartTooltip({ active, payload, label: lbl }: any) {
if (!active || !payload?.length) return null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = payload.filter((p: any) => p.value > 0).sort((a: any, b: any) => b.value - a.value)
if (!items.length) return null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const total = items.reduce((s: number, p: any) => s + p.value, 0)
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-white/5">
<div className="mb-1.5 font-medium text-foreground">{fmtDay(String(lbl))}</div>
<div className="flex flex-col gap-1">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{items.slice(0, 6).map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: p.color }} />
<span className="flex-1 truncate text-tertiary-foreground">{label(String(p.dataKey))}</span>
<span className="tabular-nums text-muted-foreground">{usd(p.value)}</span>
</div>
))}
<div className="mt-1 flex items-center justify-between border-t border-border pt-1 text-foreground">
<span>Total</span>
<span className="font-semibold tabular-nums">{usd(total)}</span>
</div>
</div>
</div>
)
}
export function UsageChart({ daily }: { daily: DailyEntry[] }) {
const { rows, series } = useMemo(() => {
const totals = new Map<string, number>()
for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + m.cost)
const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k)
const topSet = new Set(top)
const hasOther = [...totals.keys()].some((k) => !topSet.has(k))
const seriesKeys = hasOther ? [...top, 'Other'] : top
const rowData = daily.map((d) => {
const row: Record<string, number | string> = { period: d.date }
for (const k of seriesKeys) row[k] = 0
for (const m of d.topModels) {
const key = topSet.has(m.name) ? m.name : 'Other'
row[key] = (row[key] as number) + m.cost
}
return row
})
return { rows: rowData, series: seriesKeys }
}, [daily])
return (
<div className="relative h-full w-full [&_.recharts-bar-rectangle]:transition-opacity [&_.recharts-bar-rectangle]:duration-75 [&:has(.recharts-bar-rectangle:hover)_.recharts-bar-rectangle:not(:hover)]:opacity-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -6 }} barCategoryGap="16%">
<CartesianGrid vertical={false} strokeDasharray="2 2" stroke="var(--color-chart-grid-stroke)" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="equidistantPreserveStart"
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
tickFormatter={fmtDay}
/>
<YAxis
tickLine={false}
axisLine={false}
width={50}
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
tickFormatter={(v) => compactUsd(Number(v))}
/>
<Tooltip cursor={{ fill: 'rgba(255,255,255,0.04)' }} content={<ChartTooltip />} />
{series.map((s, i) => (
<Bar
key={s}
dataKey={s}
stackId="a"
fill={CHART_COLORS[i % CHART_COLORS.length]}
isAnimationActive={false}
radius={i === series.length - 1 ? [3, 3, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
)
}

View file

@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-lg border border-border bg-card', className)} {...props} />
}

View file

@ -0,0 +1,5 @@
import { cn } from '@/lib/utils'
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('skeleton-shimmer rounded-md', className)} />
}

134
dash/src/index.css Normal file
View file

@ -0,0 +1,134 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
/*
* Midnight theme for CodeBurn. Near-black surfaces, a single warm orange
* accent, and a warm orange -> gold 10-stop ramp for stacked charts.
*/
:root {
font-family: "Geist", "Geist Fallback", system-ui, sans-serif;
--radius: 0.5rem;
--background: #0a0a0b;
--outer-background: #000000;
--foreground: #e7e7ea;
--card: #0b0b0d;
--card-foreground: #ffffff;
--popover: #131316;
--popover-foreground: #ffffff;
--muted: #121215;
--muted-foreground: #cfcfd4;
--tertiary-foreground: #8a8a92;
--border: #1b1b1f;
--input: #2a2a2e;
--interactive-secondary: #141417;
--interactive-secondary-hover: #1d1d21;
--active-primary: #221a12;
--accent: #1c1c20;
--accent-foreground: #ffffff;
--subtle: #6b6b73;
--primary: #ff8c42;
--primary-foreground: #1a0f06;
--ring: #ff8c42;
--positive: #5bf58c;
--chart-1: #ff8c42;
--chart-2: #ffa94d;
--chart-3: #f97316;
--chart-4: #ffc35e;
--chart-5: #fb923c;
--chart-6: #fbbf24;
--chart-7: #f59e0b;
--chart-8: #fdba74;
--chart-9: #eab308;
--chart-10: #d97742;
--chart-grid-stroke: #19191c;
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-outer-background: var(--outer-background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-tertiary-foreground: var(--tertiary-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-interactive-secondary: var(--interactive-secondary);
--color-interactive-secondary-hover: var(--interactive-secondary-hover);
--color-active-primary: var(--active-primary);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-subtle: var(--subtle);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-ring: var(--ring);
--color-positive: var(--positive);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
--color-chart-7: var(--chart-7);
--color-chart-8: var(--chart-8);
--color-chart-9: var(--chart-9);
--color-chart-10: var(--chart-10);
--color-chart-grid-stroke: var(--chart-grid-stroke);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--text-xs: 12px;
--text-sm: 13px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-outer-background text-foreground;
letter-spacing: -0.011em;
font-weight: 450;
margin: 0;
}
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklch, var(--foreground) 16%, transparent) transparent;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-shimmer {
background-color: var(--muted);
background-image: linear-gradient(
90deg,
transparent 0%,
color-mix(in oklch, var(--foreground) 8%, transparent) 50%,
transparent 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: shimmer 1.6s ease-in-out infinite;
}

58
dash/src/lib/api.ts Normal file
View file

@ -0,0 +1,58 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
export type ModelDay = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyEntry = {
date: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels: ModelDay[]
}
export type Current = {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheHitPercent: number
codexCredits: number
topActivities: Array<{ name: string; cost: number; turns: number; oneShotRate: number | null }>
topModels: Array<{ name: string; cost: number; calls: number; savingsUSD: number }>
providers: Record<string, number>
topProjects: Array<{ name: string; cost: number; sessions: number; avgCostPerSession: number }>
tools: Array<{ name: string; calls: number }>
subagents: Array<{ name: string; calls: number; cost: number }>
}
export type Payload = {
generated: string
current: Current
history: { daily: DailyEntry[] }
}
export async function fetchUsage(period: Period, provider: string): Promise<Payload> {
const res = await fetch(`/api/usage?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`)
if (!res.ok) throw new Error(`Request failed (${res.status})`)
return res.json() as Promise<Payload>
}
export const PERIODS: Array<{ key: Period; label: string }> = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: '7 days' },
{ key: '30days', label: '30 days' },
{ key: 'month', label: 'Month' },
{ key: 'all', label: 'All' },
]

62
dash/src/lib/utils.ts Normal file
View file

@ -0,0 +1,62 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
export function usd(n: number | undefined | null): string {
const v = n == null || !isFinite(n) ? 0 : n
const s = v >= 1 || v === 0 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v.toFixed(2)
const [int, dec] = s.split('.')
return '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '')
}
export function fmtTokens(n: number | undefined | null): string {
const v = n == null || !isFinite(n) ? 0 : n
if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B'
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K'
return String(Math.round(v))
}
export function fmtNum(n: number | undefined | null): string {
return (n ?? 0).toLocaleString()
}
export function compactUsd(n: number): string {
if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return '$' + (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'k'
return '$' + Math.round(n)
}
// Warm orange -> gold ramp for stacked series (mirrors --chart-* tokens).
export const CHART_COLORS = [
'#ff8c42', '#ffa94d', '#f97316', '#ffc35e', '#fb923c',
'#fbbf24', '#f59e0b', '#fdba74', '#eab308', '#d97742',
]
const MODEL_LABELS: Record<string, string> = {
'claude-opus-4-8': 'Opus 4.8',
'claude-opus-4-6': 'Opus 4.6',
'claude-opus-4-7': 'Opus 4.7',
'claude-sonnet-4-6': 'Sonnet 4.6',
'claude-sonnet-4-5': 'Sonnet 4.5',
'claude-haiku-4-5-20251001': 'Haiku 4.5',
'grok-build-0.1': 'Grok Build',
'cursor-auto': 'Cursor',
'composer-2.5': 'Composer 2.5',
}
// Prettify a model id for chart legends. Display-name fields (current.topModels)
// already arrive clean; history rows carry raw ids, so we map the common ones
// and lightly clean the rest.
export function label(key: string): string {
if (MODEL_LABELS[key]) return MODEL_LABELS[key]
if (key === 'Other' || key === 'unknown') return key
return key
.replace(/^gpt-/i, 'GPT-')
.replace(/-(\d{8,})$/, '')
.replace(/-/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}

18
dash/src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

23
dash/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}

25
dash/vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// base './' so the built assets load with relative URLs when the CLI serves
// dist/dash from the local server root. Built output goes straight into the
// package's dist/dash so `codeburn web` can serve it after `npm run build`.
export default defineConfig({
plugins: [react(), tailwindcss()],
base: './',
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
outDir: '../dist/dash',
emptyOutDir: true,
},
server: {
port: 5173,
// During frontend dev, run `codeburn web` (CLI api on 4747) and `npm run dev`
// here; this proxies the data calls to the CLI.
proxy: { '/api': 'http://127.0.0.1:4747' },
},
})

View file

@ -12,7 +12,8 @@
],
"scripts": {
"bundle-litellm": "node scripts/bundle-litellm.mjs",
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
"build:dash": "cd dash && npm install --no-audit --no-fund --silent && npm run build",
"dev": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts",
"test": "vitest",
"prepublishOnly": "npm run build"

View file

@ -14,6 +14,7 @@ import { aggregateModelEfficiency } from './model-efficiency.js'
import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js'
import { renderDashboard } from './dashboard.js'
import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { renderCompare } from './compare.js'
@ -504,6 +505,30 @@ program
process.stdout.write(renderOverview(projects, { label, color: opts.color }))
})
program
.command('web')
.description('Open the local web dashboard in your browser')
.option('-p, --period <period>', 'Initial period: today, week, 30days, month, all', 'month')
.option('--from <date>', 'Start date (YYYY-MM-DD)')
.option('--to <date>', 'End date (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--port <number>', 'Port to listen on (falls back to a free port if taken)', parseInteger, 4747)
.option('--no-open', 'Do not open the browser automatically')
.action(async (opts) => {
assertProvider(opts.provider, 'web')
await runWebDashboard({
period: opts.period,
provider: opts.provider,
from: opts.from,
to: opts.to,
project: opts.project,
exclude: opts.exclude,
port: opts.port,
open: opts.open,
})
})
program
.command('status')

148
src/web-dashboard.ts Normal file
View file

@ -0,0 +1,148 @@
import { createServer } from 'http'
import { exec } from 'child_process'
import { readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join, normalize, extname, dirname, sep } from 'path'
import { fileURLToPath } from 'url'
import { AddressInfo } from 'net'
import { loadPricing } from './models.js'
import { buildMenubarPayloadForRange } from './usage-aggregator.js'
import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from './cli-date.js'
const HERE = dirname(fileURLToPath(import.meta.url))
// Locate the built React dashboard (dist/dash). Works both when running from a
// published package (dist/dash next to the bundled CLI) and from source.
function resolveDashDir(): string | null {
const candidates = [
process.env['CODEBURN_DASH_DIR'],
join(HERE, 'dash'),
join(HERE, '..', 'dist', 'dash'),
join(HERE, '..', 'dash', 'dist'),
].filter(Boolean) as string[]
for (const dir of candidates) {
if (existsSync(join(dir, 'index.html'))) return dir
}
return null
}
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json; charset=utf-8',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.map': 'application/json',
}
const NOT_BUILT_PAGE =
'<!doctype html><meta charset="utf-8">' +
'<body style="font-family:system-ui;background:#0a0a0b;color:#e7e7ea;padding:48px;line-height:1.6">' +
'<h2>Dashboard not built yet</h2>' +
'<p>Build the web UI once, then reload:</p>' +
'<pre style="background:#141417;padding:12px 16px;border-radius:8px;color:#ff8c42">cd dash &amp;&amp; npm install &amp;&amp; npm run build</pre>' +
'<p>The CLI keeps serving the live data API in the meantime.</p></body>'
function openBrowser(url: string): void {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open'
try {
exec(`${cmd} ${url}`)
} catch {
/* user can open it manually */
}
}
export async function runWebDashboard(opts: {
period: string
provider: string
from?: string
to?: string
project: string[]
exclude: string[]
port: number
open: boolean
}): Promise<void> {
await loadPricing()
const dashDir = resolveDashDir()
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost')
if (url.pathname === '/api/usage') {
const period = url.searchParams.get('period') ?? opts.period
const provider = url.searchParams.get('provider') ?? opts.provider
const from = url.searchParams.get('from') ?? opts.from
const to = url.searchParams.get('to') ?? opts.to
const customRange = parseDateRangeFlags(from, to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(from, to) }
: getDateRange(toPeriod(period))
const payload = await buildMenubarPayloadForRange(periodInfo, {
provider,
project: opts.project,
exclude: opts.exclude,
optimize: false,
})
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(payload))
return
}
if (!dashDir) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(NOT_BUILT_PAGE)
return
}
let pathname = decodeURIComponent(url.pathname)
if (pathname === '/' || pathname === '') pathname = '/index.html'
const filePath = normalize(join(dashDir, pathname))
if (filePath !== dashDir && !filePath.startsWith(dashDir + sep)) {
res.writeHead(403)
res.end('Forbidden')
return
}
try {
const buf = await readFile(filePath)
res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' })
res.end(buf)
} catch {
// Unknown path: serve index.html so the SPA can route it.
const buf = await readFile(join(dashDir, 'index.html'))
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(buf)
}
} catch (err) {
res.writeHead(500, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
}
})
const port = await new Promise<number>((resolve, reject) => {
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
} else {
reject(err)
}
})
server.listen(opts.port, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
})
const url = `http://127.0.0.1:${port}`
if (!dashDir) {
process.stdout.write(`\n Dashboard UI is not built. Run: cd dash && npm install && npm run build\n`)
}
process.stdout.write(`\n CodeBurn dashboard at ${url}\n Press Ctrl+C to stop.\n\n`)
if (opts.open) openBrowser(url)
await new Promise<never>(() => {
/* run until interrupted */
})
}