mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
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:
parent
75c32e6d65
commit
2d44aeaedb
19 changed files with 3875 additions and 1 deletions
12
dash/index.html
Normal file
12
dash/index.html
Normal 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
2949
dash/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
dash/package.json
Normal file
30
dash/package.json
Normal 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
188
dash/src/App.tsx
Normal 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">▲</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>
|
||||
)
|
||||
}
|
||||
28
dash/src/components/BarList.tsx
Normal file
28
dash/src/components/BarList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
dash/src/components/DataTable.tsx
Normal file
38
dash/src/components/DataTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
dash/src/components/MetricCard.tsx
Normal file
24
dash/src/components/MetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
dash/src/components/UsageChart.tsx
Normal file
100
dash/src/components/UsageChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
dash/src/components/ui/card.tsx
Normal file
6
dash/src/components/ui/card.tsx
Normal 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} />
|
||||
}
|
||||
5
dash/src/components/ui/skeleton.tsx
Normal file
5
dash/src/components/ui/skeleton.tsx
Normal 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
134
dash/src/index.css
Normal 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
58
dash/src/lib/api.ts
Normal 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
62
dash/src/lib/utils.ts
Normal 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
18
dash/src/main.tsx
Normal 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
23
dash/tsconfig.json
Normal 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
25
dash/vite.config.ts
Normal 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' },
|
||||
},
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
25
src/main.ts
25
src/main.ts
|
|
@ -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
148
src/web-dashboard.ts
Normal 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 && npm install && 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 */
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue