chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,724 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
approvePairing,
|
||||
fetchDevices,
|
||||
PERIODS,
|
||||
shareStatus,
|
||||
startShare,
|
||||
stopShare,
|
||||
type DeviceUsage,
|
||||
type Payload,
|
||||
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, DeviceUsageChart, type Unit } from '@/components/UsageChart'
|
||||
import { DeviceSearchModal } from '@/components/DeviceSearchModal'
|
||||
import { ContextExplorer } from '@/components/ContextExplorer'
|
||||
|
||||
const n = (v: number | undefined): number => v ?? 0
|
||||
|
||||
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-[0.12em] text-heading">{title}</h2>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SideLink({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13.5px] transition-colors max-md:min-h-9',
|
||||
active ? 'bg-interactive-secondary font-medium text-foreground' : 'font-light text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', active ? 'bg-primary' : 'bg-transparent')} />
|
||||
<span className="truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Switch({ on }: { on: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors',
|
||||
on ? 'bg-primary' : 'bg-interactive-secondary ring-1 ring-inset ring-border',
|
||||
)}
|
||||
>
|
||||
<span className={cn('inline-block h-3 w-3 transform rounded-full bg-card shadow-sm transition-transform', on ? 'translate-x-3.5' : 'translate-x-0.5')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label: lbl, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-tertiary-foreground">{lbl}</span>
|
||||
<span className="tabular-nums text-foreground">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One device's full dashboard. Remote devices arrive sanitized, so their
|
||||
// project and session detail is intentionally absent.
|
||||
function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: boolean; unit: Unit }) {
|
||||
const c = payload?.current
|
||||
// Cache cards read the period-scoped `current` totals, matching Cost/Calls/
|
||||
// Tokens. `history.daily` is the 365-day backfill that feeds the trend chart
|
||||
// only; summing it here over-counted the cards for shorter periods (#583).
|
||||
const cacheWrite = c?.cacheWriteTokens ?? 0
|
||||
const cacheRead = c?.cacheReadTokens ?? 0
|
||||
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 (
|
||||
<>
|
||||
<Card className="mb-3 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-1 font-display text-4xl tracking-tight tabular-nums text-primary">
|
||||
{c ? (unit === 'tokens' ? fmtTokens(c.inputTokens + c.outputTokens) : usd(c.cost)) : <Skeleton className="h-10 w-36" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-64 px-2 pb-2">
|
||||
{!payload ? <Skeleton className="mx-3 mb-3 h-[228px]" /> : <UsageChart daily={payload.history.daily} unit={unit} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{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="Cache write" value={fmtTokens(cacheWrite)} />
|
||||
<MetricCard label="Cache read" value={fmtTokens(cacheRead)} />
|
||||
<MetricCard label="One-shot" value={c.oneShotRate == null ? '—' : `${Math.round(c.oneShotRate * 100)}%`} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 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-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Top projects">
|
||||
{isRemote ? (
|
||||
<p className="py-6 text-center text-sm text-tertiary-foreground">
|
||||
Project and session detail stays on that device. Only totals are shared.
|
||||
</p>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Subagents">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Subagent' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
]}
|
||||
rows={(c?.subagents ?? []).slice(0, 10).map((s) => ({ name: s.name, calls: fmtNum(s.calls), cost: usd(s.cost) }))}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Skills">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Skill' },
|
||||
{ key: 'turns', label: 'Turns', num: true },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
]}
|
||||
rows={(c?.skills ?? []).slice(0, 10).map((s) => ({ name: s.name, turns: fmtNum(s.turns), cost: usd(s.cost) }))}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="MCP servers">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Server' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
]}
|
||||
rows={(c?.mcpServers ?? []).slice(0, 10).map((m) => ({ name: m.name, calls: fmtNum(m.calls) }))}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Savings & waste">
|
||||
{c ? (
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<Stat label="Local-model savings" value={usd(c.localModelSavings?.totalUSD)} />
|
||||
<Stat
|
||||
label={`Retry tax${c.retryTax?.retries ? ` (${fmtNum(c.retryTax.retries)} retries)` : ''}`}
|
||||
value={usd(c.retryTax?.totalUSD)}
|
||||
/>
|
||||
<Stat label="Routing waste (potential)" value={usd(c.routingWaste?.totalSavingsUSD)} />
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-20" />
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The "All devices" view: combined totals plus a per-device breakdown. Devices
|
||||
// are summed for display only; nothing is merged on the server.
|
||||
function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit }) {
|
||||
const rows = devices.map((d) => {
|
||||
const c = d.payload?.current
|
||||
return {
|
||||
name: d.name,
|
||||
local: d.local,
|
||||
cost: n(c?.cost),
|
||||
tokens: n(c?.inputTokens) + n(c?.outputTokens),
|
||||
calls: n(c?.calls),
|
||||
sessions: n(c?.sessions),
|
||||
error: d.error,
|
||||
}
|
||||
})
|
||||
const total = rows.reduce(
|
||||
(a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }),
|
||||
{ cost: 0, tokens: 0, calls: 0, sessions: 0 },
|
||||
)
|
||||
const reachable = devices.filter((d) => d.payload).length
|
||||
|
||||
const providers = new Map<string, number>()
|
||||
const models = new Map<string, number>()
|
||||
const activities = new Map<string, number>()
|
||||
let inTok = 0
|
||||
let outTok = 0
|
||||
let cacheWrite = 0
|
||||
let cacheRead = 0
|
||||
for (const d of devices) {
|
||||
const c = d.payload?.current
|
||||
if (!c) continue
|
||||
inTok += c.inputTokens
|
||||
outTok += c.outputTokens
|
||||
// Period-scoped per device (was summing each device's 365-day backfill, #583).
|
||||
// `?? 0` mirrors DeviceView and guards the un-normalized bootstrap payload,
|
||||
// where an older peer may not carry these fields yet (avoids NaN).
|
||||
cacheWrite += c.cacheWriteTokens ?? 0
|
||||
cacheRead += c.cacheReadTokens ?? 0
|
||||
for (const [k, v] of Object.entries(c.providers)) providers.set(k, (providers.get(k) ?? 0) + v)
|
||||
for (const m of c.topModels) models.set(m.name, (models.get(m.name) ?? 0) + m.cost)
|
||||
for (const a of c.topActivities) activities.set(a.name, (activities.get(a.name) ?? 0) + a.cost)
|
||||
}
|
||||
const toolBars: BarItem[] = [...providers.entries()]
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
const modelBars: BarItem[] = [...models.entries()]
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
const taskBars: BarItem[] = [...activities.entries()]
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-3 overflow-hidden">
|
||||
<div className="flex items-end justify-between px-5 pt-4">
|
||||
<div>
|
||||
<div className="text-xs text-tertiary-foreground">{`${reachable} device${reachable === 1 ? '' : 's'} · ${fmtNum(total.calls)} calls`}</div>
|
||||
<div className="mt-1 font-display text-4xl tracking-tight tabular-nums text-primary">
|
||||
{unit === 'tokens' ? fmtTokens(total.tokens) : usd(total.cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-64 px-2 pb-2">
|
||||
<DeviceUsageChart devices={devices} unit={unit} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard label="Total cost" value={usd(total.cost)} accent />
|
||||
<MetricCard label="Tokens" value={fmtTokens(total.tokens)} sub={`in ${fmtTokens(inTok)} / out ${fmtTokens(outTok)}`} />
|
||||
<MetricCard label="Calls" value={fmtNum(total.calls)} />
|
||||
<MetricCard label="Sessions" value={fmtNum(total.sessions)} />
|
||||
<MetricCard label="Cache write" value={fmtTokens(cacheWrite)} />
|
||||
<MetricCard label="Cache read" value={fmtTokens(cacheRead)} />
|
||||
<MetricCard label="Devices" value={String(reachable)} />
|
||||
</div>
|
||||
|
||||
<Panel title="By device">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'device', label: 'Device' },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
{ key: 'tokens', label: 'Tokens', num: true },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
{ key: 'sessions', label: 'Sessions', num: true },
|
||||
]}
|
||||
rows={rows.map((r) => ({
|
||||
device: r.name + (r.local ? ' · this Mac' : ''),
|
||||
cost: r.error ? <span className="text-tertiary-foreground">unreachable</span> : usd(r.cost),
|
||||
tokens: r.error ? '—' : fmtTokens(r.tokens),
|
||||
calls: r.error ? '—' : fmtNum(r.calls),
|
||||
sessions: r.error ? '—' : fmtNum(r.sessions),
|
||||
}))}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="By task (all devices)">
|
||||
<BarList items={taskBars} total={total.cost} />
|
||||
</Panel>
|
||||
<Panel title="By tool (all devices)">
|
||||
<BarList items={toolBars} total={total.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Panel title="Top models (all devices)">
|
||||
<BarList items={modelBars} total={total.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<'usage' | 'context'>('usage')
|
||||
const [period, setPeriod] = useState<Period>('today')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [view, setView] = useState<string>('all')
|
||||
const [unit, setUnit] = useState<Unit>('cost')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
// Mobile only: the sidebar collapses to an off-canvas drawer below md.
|
||||
// On desktop this flag is inert (the max-md: transform classes don't apply).
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [responded, setResponded] = useState<Set<string>>(new Set())
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isError, error, refetch } = useQuery({
|
||||
queryKey: ['devices', period, provider],
|
||||
queryFn: () => fetchDevices(period, provider),
|
||||
initialData: () => (period === 'today' && provider === 'all' ? window.__CODEBURN_BOOTSTRAP__ : undefined),
|
||||
// Bootstrap paints instantly but is stale by definition, so refetch at once
|
||||
// (the default 30s staleTime would otherwise hide a live peer until then).
|
||||
initialDataUpdatedAt: 0,
|
||||
// When devices are paired, re-pull periodically so a device that briefly
|
||||
// dropped (asleep/network blip) reappears on its own instead of staying
|
||||
// gone until you switch tabs.
|
||||
refetchInterval: (q) => ((q.state.data?.devices?.some((d) => !d.local) ?? false) ? 20000 : false),
|
||||
})
|
||||
|
||||
const { data: shareInfo } = useQuery({
|
||||
queryKey: ['share'],
|
||||
queryFn: shareStatus,
|
||||
refetchInterval: (q) => (q.state.data?.sharing ? 2500 : 8000),
|
||||
})
|
||||
|
||||
const refreshShare = () => qc.invalidateQueries({ queryKey: ['share'] })
|
||||
const toggleShare = async () => {
|
||||
if (shareInfo?.sharing) await stopShare()
|
||||
else await startShare(shareInfo?.always ?? false)
|
||||
refreshShare()
|
||||
}
|
||||
const toggleAlways = async () => {
|
||||
await startShare(!(shareInfo?.always ?? false))
|
||||
refreshShare()
|
||||
}
|
||||
const respondPairing = async (id: string, approve: boolean) => {
|
||||
setResponded((s) => new Set(s).add(id)) // drop it from the prompt at once so it can't be double-clicked
|
||||
await approvePairing(id, approve)
|
||||
refreshShare()
|
||||
void refetch()
|
||||
}
|
||||
const pending = (shareInfo?.pending ?? []).filter((p) => !responded.has(p.id))
|
||||
|
||||
// Only show devices we could actually reach; an unreachable paired device is
|
||||
// hidden entirely rather than shown as an error row.
|
||||
const devices = (data?.devices ?? []).filter((d) => d.payload)
|
||||
const local = devices.find((d) => d.local)
|
||||
const multi = devices.some((d) => !d.local)
|
||||
const viewing = view === 'all' ? undefined : devices.find((d) => d.id === view)
|
||||
const primary = viewing ?? local
|
||||
const c0 = primary?.payload?.current
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
c0
|
||||
? Object.entries(c0.providers)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k]) => k)
|
||||
: [],
|
||||
[c0],
|
||||
)
|
||||
|
||||
// If the device you're viewing drops off (slept/unreachable), fall back to
|
||||
// All devices instead of showing an empty panel with nothing selected.
|
||||
useEffect(() => {
|
||||
if (view !== 'all' && data && !devices.some((d) => d.id === view)) setView('all')
|
||||
}, [view, devices, data])
|
||||
|
||||
// If the selected provider isn't present on the current view, reset to all
|
||||
// (otherwise a healthy device shows empty under a filter it has no data for).
|
||||
useEffect(() => {
|
||||
if (provider !== 'all' && c0 && !providerOptions.includes(provider)) setProvider('all')
|
||||
}, [provider, providerOptions, c0])
|
||||
|
||||
const showCombined = multi && view === 'all'
|
||||
const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…')
|
||||
const label = local?.payload?.current?.label ?? ''
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-outer-background p-2.5 max-md:min-h-[100dvh]">
|
||||
<div className="flex h-[calc(100vh-20px)] flex-col gap-2.5 max-md:h-[calc(100dvh-20px)]">
|
||||
<header className="flex h-12 shrink-0 items-center gap-4 rounded-md border border-border bg-card px-5 shadow-[0_2px_8px_rgba(0,0,0,0.03)] max-md:gap-3 max-md:px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open menu"
|
||||
aria-expanded={sidebarOpen}
|
||||
aria-controls="dashboard-sidebar"
|
||||
className="-ml-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-foreground transition-colors hover:bg-interactive-secondary md:hidden"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 max-md:shrink-0">
|
||||
<img src="/codeburn-logo.png" alt="CodeBurn" className="h-6 w-6" />
|
||||
<span className="text-lg font-semibold tracking-[-0.02em] text-foreground">
|
||||
Code<span className="text-[#e8553a]">Burn</span>
|
||||
</span>
|
||||
<span className="ml-1 text-[11px] font-light uppercase tracking-[0.14em] text-tertiary-foreground max-sm:hidden">usage</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-6 flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:ml-2 max-md:shrink-0">
|
||||
{(['usage', 'context'] as const).map((pg) => (
|
||||
<button
|
||||
key={pg}
|
||||
type="button"
|
||||
onClick={() => setPage(pg)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
page === pg ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{pg === 'usage' ? 'Usage' : 'Context'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 max-md:min-w-0 max-md:overflow-x-auto max-md:[-ms-overflow-style:none] max-md:[scrollbar-width:none] max-md:[&::-webkit-scrollbar]:hidden">
|
||||
{page === 'usage' && (
|
||||
<>
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:shrink-0">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setPeriod(p.key)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors max-md:inline-flex max-md:min-h-9 max-md:items-center max-md:justify-center',
|
||||
period === p.key ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:shrink-0">
|
||||
{(['cost', 'tokens'] as Unit[]).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() => setUnit(u)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors max-md:inline-flex max-md:min-h-9 max-md:items-center max-md:justify-center',
|
||||
unit === u ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{u === 'cost' ? 'Cost' : 'Tokens'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground outline-none max-md:min-h-9 max-md:shrink-0"
|
||||
>
|
||||
<option value="all">All tools</option>
|
||||
{providerOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-2.5">
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
id="dashboard-sidebar"
|
||||
className={cn(
|
||||
'flex w-60 shrink-0 flex-col gap-5 overflow-y-auto rounded-md border border-border bg-card p-5',
|
||||
'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-40 max-md:rounded-none max-md:shadow-2xl max-md:transition-[transform,visibility] max-md:duration-200 max-md:ease-out',
|
||||
// Closed below md: slide off-canvas AND go visibility:hidden so its
|
||||
// links leave the tab order / a11y tree (not just visually hidden).
|
||||
sidebarOpen ? 'max-md:visible max-md:translate-x-0' : 'max-md:invisible max-md:-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground md:hidden"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
{page === 'usage' && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Devices</p>
|
||||
{multi && (
|
||||
<SideLink active={view === 'all'} onClick={() => { setView('all'); setSidebarOpen(false) }}>
|
||||
All devices
|
||||
</SideLink>
|
||||
)}
|
||||
{devices.map((d) => (
|
||||
<SideLink
|
||||
key={d.id}
|
||||
active={view === d.id || (!multi && view === 'all' && d.local)}
|
||||
onClick={() => { setView(d.id); setSidebarOpen(false) }}
|
||||
>
|
||||
{d.name}
|
||||
{d.local ? ' · this Mac' : ''}
|
||||
</SideLink>
|
||||
))}
|
||||
{devices.length === 0 && <p className="px-2.5 py-1 text-xs text-tertiary-foreground">Loading…</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchOpen(true); setSidebarOpen(false) }}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-interactive-secondary max-md:min-h-9"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<path d="M10.5 10.5L14 14" />
|
||||
</svg>
|
||||
Search local devices
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="mb-2 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Share</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleShare()}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13.5px] text-foreground transition-colors hover:bg-interactive-secondary max-md:min-h-9"
|
||||
>
|
||||
<span>Share this device</span>
|
||||
<Switch on={!!shareInfo?.sharing} />
|
||||
</button>
|
||||
{shareInfo?.sharing && (
|
||||
<div className="mt-1.5 px-2.5">
|
||||
<p className="text-[11px] leading-relaxed text-tertiary-foreground">
|
||||
Discoverable as “{shareInfo.name}” · {shareInfo.peers} paired
|
||||
</p>
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareInfo.always}
|
||||
onChange={() => void toggleAlways()}
|
||||
className="h-3.5 w-3.5 accent-[#1f8a5b]"
|
||||
/>
|
||||
Keep sharing always
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t border-border pt-4">
|
||||
<p className="text-[11px] leading-relaxed text-tertiary-foreground">
|
||||
Local only. Nothing leaves your machine; only totals are shared between your devices.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<a
|
||||
href="https://codeburn.app/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="codeburn.app"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3c2.5 2.7 2.5 15.3 0 18M12 3c-2.5 2.7-2.5 15.3 0 18" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/invite/w2sw8mCqep"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Discord"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M20.317 4.369A19.79 19.79 0 0 0 16.558 3c-.2.36-.43.85-.59 1.23a18.27 18.27 0 0 0-5.93 0A12.6 12.6 0 0 0 9.44 3 19.7 19.7 0 0 0 5.68 4.37C2.9 8.46 2.14 12.45 2.52 16.38a19.9 19.9 0 0 0 6.07 3.08c.49-.67.93-1.38 1.3-2.13-.71-.27-1.4-.6-2.04-.99.17-.13.34-.26.5-.4 3.93 1.84 8.18 1.84 12.06 0 .17.14.33.27.5.4-.65.39-1.33.72-2.05.99.38.75.81 1.46 1.3 2.13a19.9 19.9 0 0 0 6.07-3.08c.45-4.55-.77-8.5-3.2-12.01zM9.69 14.5c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.34-.95 2.42-2.15 2.42zm4.62 0c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.34-.94 2.42-2.15 2.42z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/_codeburn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="X"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-xl tracking-tight text-foreground">{page === 'context' ? 'Context' : viewTitle}</h1>
|
||||
<span className="text-xs text-tertiary-foreground">{page === 'usage' ? label : ''}</span>
|
||||
</div>
|
||||
|
||||
{page === 'context' ? (
|
||||
<ContextExplorer />
|
||||
) : showCombined ? (
|
||||
<CombinedView devices={devices} unit={unit} />
|
||||
) : (
|
||||
<DeviceView payload={primary?.payload} isRemote={!!viewing && !viewing.local} unit={unit} />
|
||||
)}
|
||||
|
||||
{page === 'usage' && isError && (
|
||||
<div className="mt-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message)}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchOpen && <DeviceSearchModal onClose={() => setSearchOpen(false)} onPaired={() => void refetch()} />}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
||||
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-card shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]">
|
||||
<div className="border-b border-border px-5 py-3.5">
|
||||
<h2 className="text-sm font-semibold text-foreground">Incoming pairing request</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
{pending.map((p) => (
|
||||
<div key={p.id} className="rounded-md border border-border px-3.5 py-3">
|
||||
<p className="text-sm text-foreground">
|
||||
“{p.name}” wants to pair with this device.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-tertiary-foreground">
|
||||
Confirm this code matches on that device: <span className="font-mono text-foreground">{p.code}</span>
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void respondPairing(p.id, true)}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void respondPairing(p.id, false)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs text-tertiary-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
fetchContextSessions,
|
||||
fetchContextTree,
|
||||
type ContextProvider,
|
||||
type ContextRow,
|
||||
type ContextSessionInfo,
|
||||
} from '@/lib/api'
|
||||
import { cn, fmtNum, fmtTokens, label } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const PROVIDERS: Array<{ key: ContextProvider; label: string }> = [
|
||||
{ key: 'claude', label: 'Claude Code' },
|
||||
{ key: 'codex', label: 'Codex' },
|
||||
]
|
||||
|
||||
function ago(mtimeMs: number): string {
|
||||
const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000))
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`
|
||||
return `${Math.round(mins / (60 * 24))}d ago`
|
||||
}
|
||||
|
||||
function TreeTable({ rows }: { rows: ContextRow[] }) {
|
||||
const max = Math.max(1, ...rows.filter((r) => !r.bold).map((r) => r.tokens))
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className={cn('relative flex items-center gap-3 rounded-sm px-2 py-[3px]', r.bold && i > 0 && 'mt-2')}>
|
||||
{!r.bold && (
|
||||
<span
|
||||
className="absolute inset-y-[3px] left-0 rounded-sm bg-primary/[0.07]"
|
||||
style={{ width: `${Math.max(1, (r.tokens / max) * 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn('relative min-w-0 flex-1 truncate text-[13px]', r.bold ? 'font-semibold text-foreground' : 'text-muted-foreground')}
|
||||
style={{ paddingLeft: r.depth * 16 }}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="relative w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{fmtNum(r.count)}x</span>
|
||||
<span className={cn('relative w-20 shrink-0 text-right text-[13px] tabular-nums', r.bold ? 'font-semibold text-foreground' : 'text-foreground')}>
|
||||
{fmtTokens(r.tokens)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ label: lbl, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-interactive-secondary px-3 py-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-tertiary-foreground">{lbl}</div>
|
||||
<div className="mt-0.5 text-sm font-medium tabular-nums text-foreground">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDetails({ provider, id }: { provider: ContextProvider; id: string }) {
|
||||
const [scope, setScope] = useState<'effective' | 'full'>('effective')
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['context-tree', provider, id],
|
||||
queryFn: () => fetchContextTree(provider, id),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-4">
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-40" />
|
||||
<p className="text-xs text-tertiary-foreground">Reading the whole transcript, large sessions take a few seconds…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <p className="px-4 py-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message ?? 'unknown')}</p>
|
||||
}
|
||||
|
||||
const view = scope === 'full' ? data.full : data.effective
|
||||
const rows = scope === 'full' ? data.fullRows : data.effectiveRows
|
||||
const window = data.reported?.window ?? null
|
||||
const pct = data.reported && window ? Math.min(100, Math.round((data.reported.context / window) * 100)) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Chip label="Messages" value={fmtNum(view.messages)} />
|
||||
<Chip label="Est. tokens" value={fmtTokens(view.tokens)} />
|
||||
<Chip
|
||||
label="Context (exact)"
|
||||
value={data.reported ? (window ? `${fmtTokens(data.reported.context)} / ${fmtTokens(window)}` : fmtTokens(data.reported.context)) : '—'}
|
||||
/>
|
||||
<Chip label="Compactions" value={fmtNum(data.compactions)} />
|
||||
</div>
|
||||
|
||||
{pct !== null && (
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-[11px] text-tertiary-foreground">
|
||||
<span>{label(data.model)} · live context window</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-interactive-secondary">
|
||||
<div className={cn('h-full rounded-full', pct >= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
|
||||
{(['effective', 'full'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-2.5 py-1 text-[11px] font-medium transition-colors',
|
||||
scope === s ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{s === 'effective' ? 'Live window' : 'Full history'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-tertiary-foreground">token counts are estimates; “Context (exact)” comes from API usage</span>
|
||||
</div>
|
||||
|
||||
<TreeTable rows={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRow({ s, open, onToggle }: { s: ContextSessionInfo; open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className={cn('border-t border-border first:border-t-0', open && 'bg-interactive-secondary/30')}>
|
||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-interactive-secondary/50">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="10"
|
||||
height="10"
|
||||
className={cn('shrink-0 text-tertiary-foreground transition-transform', open && 'rotate-90')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 3l5 5-5 5" />
|
||||
</svg>
|
||||
<span className="shrink-0 font-mono text-xs text-primary">{s.sessionId.slice(0, 8)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
||||
{s.title || <span className="text-tertiary-foreground">untitled session</span>}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs text-tertiary-foreground sm:block">{s.project}</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{ago(s.mtimeMs)}</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{(s.sizeBytes / 1024 / 1024).toFixed(1)}MB</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
<SessionDetails provider={s.provider} id={s.sessionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextExplorer() {
|
||||
const [provider, setProvider] = useState<ContextProvider>('claude')
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['context-sessions', provider],
|
||||
queryFn: () => fetchContextSessions(provider),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProvider(p.key)
|
||||
setOpenId(null)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-3.5 py-1.5 text-xs font-medium transition-colors',
|
||||
provider === p.key
|
||||
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-tertiary-foreground">what fills each session’s context window, block by block</span>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isError && <p className="px-4 py-6 text-sm text-tertiary-foreground">Failed to load sessions: {String((error as Error)?.message)}</p>}
|
||||
{data && data.length === 0 && <p className="px-4 py-6 text-sm text-tertiary-foreground">No sessions found for this provider.</p>}
|
||||
{data?.map((s) => (
|
||||
<SessionRow key={s.sessionId} s={s} open={openId === s.sessionId} onToggle={() => setOpenId(openId === s.sessionId ? null : s.sessionId)} />
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
|
||||
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 {
|
||||
const [, m, day] = String(d).split('-')
|
||||
return m && day ? `${Number(day)} ${MONTHS[Number(m)]}` : d
|
||||
}
|
||||
|
||||
const TOP_N = 6
|
||||
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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%">
|
||||
<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={axisFmt}
|
||||
/>
|
||||
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<Tip />} />
|
||||
{series.map((s, i) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stackId="a"
|
||||
fill={s.color}
|
||||
isAnimationActive={false}
|
||||
radius={i === series.length - 1 ? [3, 3, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn('skeleton-shimmer rounded-md', className)} />
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@300..600&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* Archival warmth + forensic precision. Warm paper surfaces, ink text, a
|
||||
* forest-green accent, and a green -> gold -> terracotta ramp for stacked
|
||||
* charts. No pure white background, no pure black, no cold neutrals.
|
||||
*/
|
||||
:root {
|
||||
font-family: "Geist", "Geist Fallback", system-ui, sans-serif;
|
||||
--radius: 0.375rem;
|
||||
|
||||
--background: #f6f4ef;
|
||||
--outer-background: #e9e6de;
|
||||
--foreground: #16181d;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #16181d;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #16181d;
|
||||
--muted: #efece4;
|
||||
--muted-foreground: #5d626b;
|
||||
--tertiary-foreground: #8a857c;
|
||||
--heading: #2c5242;
|
||||
--border: rgba(23, 27, 32, 0.08);
|
||||
--input: rgba(23, 27, 32, 0.14);
|
||||
--interactive-secondary: rgba(23, 27, 32, 0.04);
|
||||
--interactive-secondary-hover: rgba(23, 27, 32, 0.08);
|
||||
--active-primary: #ffffff;
|
||||
--accent: #efece4;
|
||||
--accent-foreground: #16181d;
|
||||
--subtle: #8a857c;
|
||||
--primary: #1f8a5b;
|
||||
--primary-foreground: #ffffff;
|
||||
--ring: #1f8a5b;
|
||||
--positive: #1f8a5b;
|
||||
|
||||
--chart-1: #1f8a5b;
|
||||
--chart-2: #4fd394;
|
||||
--chart-3: #2c5242;
|
||||
--chart-4: #d99a3c;
|
||||
--chart-5: #c8541f;
|
||||
--chart-6: #2f5fd0;
|
||||
--chart-7: #7aa86f;
|
||||
--chart-8: #b5403a;
|
||||
--chart-9: #3f8f6b;
|
||||
--chart-10: #a98b4f;
|
||||
--chart-grid-stroke: rgba(23, 27, 32, 0.07);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@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-heading: var(--heading);
|
||||
--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);
|
||||
|
||||
--font-display: "Alga", Georgia, "Times New Roman", serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--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.006em;
|
||||
font-weight: 400;
|
||||
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) 7%, transparent) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: 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 }>
|
||||
skills: Array<{ name: string; turns: number; cost: number }>
|
||||
mcpServers: Array<{ name: string; calls: number }>
|
||||
modelEfficiency: Array<{ name: string; costPerEdit: number; oneShotRate: number }>
|
||||
localModelSavings: { totalUSD: number }
|
||||
retryTax: { totalUSD: number; retries: number }
|
||||
routingWaste: { totalSavingsUSD: 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 type DeviceUsage = {
|
||||
id: string
|
||||
name: string
|
||||
local: boolean
|
||||
payload?: Payload
|
||||
error?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CODEBURN_BOOTSTRAP__?: { devices: DeviceUsage[] }
|
||||
}
|
||||
}
|
||||
|
||||
// A device may run a different CodeBurn version and send a payload missing
|
||||
// fields we treat as required. Fill safe defaults at the boundary so the UI
|
||||
// can iterate them without crashing (the alternative is a white screen for an
|
||||
// innocent local user because a peer sent an old shape).
|
||||
function normalizePayload(p?: Payload): Payload | undefined {
|
||||
if (!p) return p
|
||||
const c = (p.current ?? {}) as Partial<Current>
|
||||
return {
|
||||
generated: p.generated,
|
||||
current: {
|
||||
label: c.label ?? '',
|
||||
cost: c.cost ?? 0,
|
||||
calls: c.calls ?? 0,
|
||||
sessions: c.sessions ?? 0,
|
||||
oneShotRate: c.oneShotRate ?? null,
|
||||
inputTokens: c.inputTokens ?? 0,
|
||||
outputTokens: c.outputTokens ?? 0,
|
||||
cacheReadTokens: c.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: c.cacheWriteTokens ?? 0,
|
||||
cacheHitPercent: c.cacheHitPercent ?? 0,
|
||||
codexCredits: c.codexCredits ?? 0,
|
||||
topActivities: c.topActivities ?? [],
|
||||
topModels: c.topModels ?? [],
|
||||
providers: c.providers ?? {},
|
||||
topProjects: c.topProjects ?? [],
|
||||
tools: c.tools ?? [],
|
||||
subagents: c.subagents ?? [],
|
||||
skills: c.skills ?? [],
|
||||
mcpServers: c.mcpServers ?? [],
|
||||
modelEfficiency: c.modelEfficiency ?? [],
|
||||
localModelSavings: c.localModelSavings ?? { totalUSD: 0 },
|
||||
retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 },
|
||||
routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 },
|
||||
},
|
||||
history: {
|
||||
daily: (p.history?.daily ?? []).map((d) => ({
|
||||
date: d.date,
|
||||
cost: d.cost ?? 0,
|
||||
calls: d.calls ?? 0,
|
||||
inputTokens: d.inputTokens ?? 0,
|
||||
outputTokens: d.outputTokens ?? 0,
|
||||
cacheReadTokens: d.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: d.cacheWriteTokens ?? 0,
|
||||
topModels: (d.topModels ?? []).map((m) => ({
|
||||
name: m.name,
|
||||
cost: m.cost ?? 0,
|
||||
calls: m.calls ?? 0,
|
||||
inputTokens: m.inputTokens ?? 0,
|
||||
outputTokens: m.outputTokens ?? 0,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDevices(period: Period, provider: string): Promise<{ devices: DeviceUsage[] }> {
|
||||
const res = await fetch(`/api/devices?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
const data = (await res.json()) as { devices: DeviceUsage[] }
|
||||
return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.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' },
|
||||
]
|
||||
|
||||
export type DiscoveredDevice = {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
fingerprint: string
|
||||
code: string
|
||||
paired: boolean
|
||||
}
|
||||
|
||||
export async function scanDevices(): Promise<DiscoveredDevice[]> {
|
||||
const res = await fetch('/api/devices/scan')
|
||||
if (!res.ok) throw new Error(`Scan failed (${res.status})`)
|
||||
const json = (await res.json()) as { found: DiscoveredDevice[] }
|
||||
return json.found
|
||||
}
|
||||
|
||||
export async function pairDevice(d: DiscoveredDevice): Promise<{ ok: boolean; name?: string; error?: string }> {
|
||||
const res = await fetch('/api/devices/pair', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint }),
|
||||
})
|
||||
return res.json() as Promise<{ ok: boolean; name?: string; error?: string }>
|
||||
}
|
||||
|
||||
export type ContextProvider = 'claude' | 'codex'
|
||||
|
||||
export type ContextSessionInfo = {
|
||||
provider: ContextProvider
|
||||
sessionId: string
|
||||
project: string
|
||||
title: string
|
||||
mtimeMs: number
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type BlockStat = { count: number; tokens: number }
|
||||
|
||||
export type ContextSnapshot = {
|
||||
messages: number
|
||||
tokens: number
|
||||
assistant: {
|
||||
count: number
|
||||
tokens: number
|
||||
text: BlockStat
|
||||
reasoning: BlockStat
|
||||
toolCall: BlockStat
|
||||
byTool: Array<{ tool: string; count: number; tokens: number }>
|
||||
}
|
||||
user: {
|
||||
count: number
|
||||
tokens: number
|
||||
text: BlockStat
|
||||
image: BlockStat
|
||||
compactSummary: BlockStat
|
||||
meta: BlockStat
|
||||
}
|
||||
toolResult: BlockStat
|
||||
system: BlockStat
|
||||
}
|
||||
|
||||
export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean }
|
||||
|
||||
export type ContextTree = {
|
||||
session: { sessionId: string; project: string; mtimeMs: number; sizeBytes: number }
|
||||
model: string
|
||||
compactions: number
|
||||
reported: { context: number; window: number | null } | null
|
||||
effective: ContextSnapshot
|
||||
full: ContextSnapshot
|
||||
effectiveRows: ContextRow[]
|
||||
fullRows: ContextRow[]
|
||||
}
|
||||
|
||||
export async function fetchContextSessions(provider: ContextProvider): Promise<ContextSessionInfo[]> {
|
||||
const res = await fetch(`/api/context/sessions?provider=${encodeURIComponent(provider)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
const json = (await res.json()) as { sessions: ContextSessionInfo[] }
|
||||
return json.sessions ?? []
|
||||
}
|
||||
|
||||
export async function fetchContextTree(provider: ContextProvider, id: string): Promise<ContextTree> {
|
||||
const res = await fetch(`/api/context/tree?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
return res.json() as Promise<ContextTree>
|
||||
}
|
||||
|
||||
export type PendingPairing = { id: string; name: string; code: string }
|
||||
export type ShareStatus = {
|
||||
sharing: boolean
|
||||
name: string
|
||||
port: number
|
||||
always: boolean
|
||||
peers: number
|
||||
pending: PendingPairing[]
|
||||
}
|
||||
|
||||
const postJson = (path: string, body: unknown) =>
|
||||
fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
|
||||
export async function shareStatus(): Promise<ShareStatus> {
|
||||
const res = await fetch('/api/share/status')
|
||||
if (!res.ok) throw new Error(`share status failed (${res.status})`)
|
||||
return res.json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function startShare(always: boolean): Promise<ShareStatus> {
|
||||
return (await postJson('/api/share/start', { always })).json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function stopShare(): Promise<ShareStatus> {
|
||||
return (await postJson('/api/share/stop', {})).json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function approvePairing(id: string, approve: boolean): Promise<{ ok: boolean }> {
|
||||
return (await postJson('/api/share/approve', { id, approve })).json() as Promise<{ ok: boolean }>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 sign = v < 0 ? '-' : ''
|
||||
const a = Math.abs(v)
|
||||
const s = a >= 1 || a === 0 ? a.toFixed(2) : a >= 0.01 ? a.toFixed(3) : a.toFixed(2)
|
||||
const [int, dec] = s.split('.')
|
||||
return sign + '$' + 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 {
|
||||
const v = n == null || !isFinite(n) ? 0 : n
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
export function compactUsd(n: number): string {
|
||||
if (!isFinite(n)) return '$0'
|
||||
const sign = n < 0 ? '-' : ''
|
||||
const a = Math.abs(n)
|
||||
if (a >= 1e6) return sign + '$' + (a / 1e6).toFixed(1) + 'M'
|
||||
if (a >= 1e3) return sign + '$' + (a / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k'
|
||||
return sign + '$' + Math.round(a)
|
||||
}
|
||||
|
||||
// Forest green -> gold -> terracotta ramp for stacked series (mirrors the
|
||||
// --chart-* tokens). Warm and on-brand, distinct enough to read when stacked.
|
||||
export const CHART_COLORS = [
|
||||
'#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f',
|
||||
'#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f',
|
||||
]
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, StrictMode, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { App } from './App'
|
||||
import './index.css'
|
||||
|
||||
// Last-resort guard: a render error (e.g. an unexpected payload from a peer on
|
||||
// a different version) shows a recoverable message instead of a blank page.
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-outer-background p-8 text-center">
|
||||
<p className="text-sm text-foreground">Something went wrong rendering the dashboard.</p>
|
||||
<p className="max-w-md text-xs text-tertiary-foreground">{String(this.state.error.message)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => location.reload()}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground hover:bg-interactive-secondary"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user