Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions dash/index.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeBurn</title>
<link rel="icon" type="image/png" href="/codeburn-logo.png" />
<title>CodeBurn - Local Dashboard</title>
</head>
<body>
<div id="root"></div>
Expand Down
Binary file added dash/public/codeburn-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
525 changes: 402 additions & 123 deletions dash/src/App.tsx

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions dash/src/components/DeviceSearchModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'

import { scanDevices, pairDevice, type DiscoveredDevice } from '@/lib/api'

export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void; onPaired: () => void }) {
const [scanning, setScanning] = useState(true)
const [found, setFound] = useState<DiscoveredDevice[]>([])
const [error, setError] = useState<string | null>(null)
const [pairing, setPairing] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)

const scan = async () => {
setScanning(true)
setError(null)
setStatus(null)
try {
setFound(await scanDevices())
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setScanning(false)
}
}

useEffect(() => {
void scan()
}, [])

const connect = async (d: DiscoveredDevice) => {
setPairing(d.fingerprint)
setError(null)
setStatus(`Confirm the code ${d.code} on "${d.name}", then approve there. Waiting...`)
try {
const r = await pairDevice(d)
if (r.ok) {
setStatus(`Connected to "${r.name ?? d.name}".`)
onPaired()
setTimeout(onClose, 700)
} else {
setError(r.error ?? 'Pairing failed')
setStatus(null)
setPairing(null)
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setStatus(null)
setPairing(null)
}
}

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" onClick={onClose}>
<div
className="w-full max-w-md overflow-hidden rounded-lg border border-border bg-card shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="text-sm font-semibold text-foreground">Search local devices</h2>
<div className="flex items-center gap-2">
<button
onClick={() => void scan()}
disabled={scanning}
className="rounded-md border border-border px-2.5 py-1 text-xs text-tertiary-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Rescan
</button>
<button onClick={onClose} className="rounded-md px-2 py-1 text-tertiary-foreground hover:text-foreground" aria-label="Close">
</button>
</div>
</div>

<div className="px-5 py-4">
{scanning ? (
<div className="flex items-center gap-3 py-6 text-sm text-tertiary-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-primary" />
Looking for devices on your network...
</div>
) : found.length === 0 ? (
<p className="py-6 text-center text-sm text-tertiary-foreground">
No devices found. On your other Mac run <span className="font-mono text-foreground">codeburn share</span> on the same Wi-Fi.
</p>
) : (
<div className="flex flex-col gap-2">
{found.map((d) => (
<div key={d.fingerprint} className="flex items-center gap-3 rounded-md border border-border px-3.5 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-interactive-secondary text-primary">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{d.name}</div>
<div className="truncate font-mono text-xs text-tertiary-foreground">
{d.host}:{d.port}
</div>
</div>
{d.paired ? (
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">Connected</span>
) : pairing === d.fingerprint ? (
<span className="font-mono text-xs text-tertiary-foreground">code {d.code}</span>
) : (
<button
onClick={() => void connect(d)}
disabled={!!pairing}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
>
Connect
</button>
)}
</div>
))}
</div>
)}

{status && <p className="mt-3 text-xs text-tertiary-foreground">{status}</p>}
{error && <p className="mt-3 text-xs text-[#b5403a]">{error}</p>}
</div>
</div>
</div>
)
}
163 changes: 113 additions & 50 deletions dash/src/components/UsageChart.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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'
import type { DailyEntry, DeviceUsage } from '@/lib/api'
import { CHART_COLORS, compactUsd, fmtTokens, label, usd } from '@/lib/utils'

export type Unit = 'cost' | 'tokens'

const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function fmtDay(d: string): string {
Expand All @@ -12,55 +14,53 @@ function fmtDay(d: string): string {

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
type Series = { key: string; label: string; color: string }

function makeTooltip(labels: Record<string, string>, fmt: (n: number) => string) {
// 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>
return 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-black/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">{labels[String(p.dataKey)] ?? String(p.dataKey)}</span>
<span className="tabular-nums text-muted-foreground">{fmt(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">{fmt(total)}</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])

function StackedBars({
rows,
series,
labels,
unit,
}: {
rows: Array<Record<string, number | string>>
series: Series[]
labels: Record<string, string>
unit: Unit
}) {
const fmt = unit === 'tokens' ? fmtTokens : usd
const axisFmt = (v: number | string) => (unit === 'tokens' ? fmtTokens(Number(v)) : compactUsd(Number(v)))
const Tip = makeTooltip(labels, fmt)
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%">
Expand All @@ -80,15 +80,15 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
axisLine={false}
width={50}
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
tickFormatter={(v) => compactUsd(Number(v))}
tickFormatter={axisFmt}
/>
<Tooltip cursor={{ fill: 'rgba(255,255,255,0.04)' }} content={<ChartTooltip />} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<Tip />} />
{series.map((s, i) => (
<Bar
key={s}
dataKey={s}
key={s.key}
dataKey={s.key}
stackId="a"
fill={CHART_COLORS[i % CHART_COLORS.length]}
fill={s.color}
isAnimationActive={false}
radius={i === series.length - 1 ? [3, 3, 0, 0] : undefined}
/>
Expand All @@ -98,3 +98,66 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
</div>
)
}

// Spend (or tokens) per day, stacked by model (single device).
export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) {
const { rows, series, labels } = useMemo(() => {
const measure = (m: { cost: number; inputTokens: number; outputTokens: number }) =>
unit === 'tokens' ? m.inputTokens + m.outputTokens : m.cost
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) + measure(m))
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 keys = hasOther ? [...top, 'Other'] : top
const rowData = daily.map((d) => {
const row: Record<string, number | string> = { period: d.date }
for (const k of keys) row[k] = 0
for (const m of d.topModels) {
const key = topSet.has(m.name) ? m.name : 'Other'
row[key] = (row[key] as number) + measure(m)
}
return row
})
const series: Series[] = keys.map((k, i) => ({ key: k, label: label(k), color: CHART_COLORS[i % CHART_COLORS.length]! }))
const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
return { rows: rowData, series, labels }
}, [daily, unit])

return <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
}

// Spend (or tokens) per day, stacked by device (one color per device) for the All view.
export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) {
const { rows, series, labels } = useMemo(() => {
const named = devices.filter((d) => d.payload)
const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? []
// Stable key + color per device (by unique id) so a device keeps its color
// and its bars don't remount when another device drops/returns between
// polls, and two devices sharing a hostname never collide.
const keyOf = (d: DeviceUsage) => 'dev_' + d.id.replace(/[^a-zA-Z0-9]/g, '_')
const colorOf = (id: string) => {
let h = 0
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
return CHART_COLORS[Math.abs(h) % CHART_COLORS.length]!
}
const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b))
const series: Series[] = named.map((d) => ({
key: keyOf(d),
label: d.name + (d.local ? ' (this Mac)' : ''),
color: colorOf(d.id),
}))
const rowData = dates.map((date) => {
const row: Record<string, number | string> = { period: date }
named.forEach((d) => {
const e = dailyOf(d).find((x) => x.date === date)
row[keyOf(d)] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0
})
return row
})
const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
return { rows: rowData, series, labels }
}, [devices, unit])

return <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
}
Loading