diff --git a/dash/src/App.tsx b/dash/src/App.tsx index f50f89a6..e2feb19f 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, type ReactNode } from 'react' import { keepPreviousData, useQuery } from '@tanstack/react-query' -import { fetchUsage, PERIODS, type Period } from '@/lib/api' +import { fetchDevices, PERIODS, type DailyEntry, 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' @@ -10,6 +10,8 @@ import { BarList, type BarItem } from '@/components/BarList' import { DataTable } from '@/components/DataTable' import { UsageChart } from '@/components/UsageChart' +const n = (v: number | undefined): number => v ?? 0 + function Panel({ title, children }: { title: string; children: ReactNode }) { return ( @@ -19,41 +21,278 @@ function Panel({ title, children }: { title: string; children: ReactNode }) { ) } +function DeviceTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ) +} + +// One device's full dashboard. Remote devices arrive sanitized, so their +// project and session detail is intentionally absent. +function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolean }) { + const c = payload?.current + 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 ( + <> + +
+
+
+ {c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '} +
+
+ {c ? usd(c.cost) : } +
+
+
+
+ {!payload ? : } +
+
+ +
+ {c ? ( + <> + + + + + + + + ) : ( + Array.from({ length: 6 }).map((_, i) => ) + )} +
+ +
+ + + + + + +
+ +
+ + {isRemote ? ( +

+ Project and session detail stays on that device. Only totals are shared. +

+ ) : ( + ({ + name: p.name, + cost: usd(p.cost), + sessions: fmtNum(p.sessions), + }))} + /> + )} +
+ + + +
+ + + ({ name: t.name, calls: fmtNum(t.calls) }))} + /> + + + ) +} + +// Merge every device's daily history by date for the combined chart, summing +// per-model costs so the stacked bars stay correct. +function mergeDaily(devices: DeviceUsage[]): DailyEntry[] { + const byDate = new Map() + for (const d of devices) { + for (const e of d.payload?.history.daily ?? []) { + const cur = + byDate.get(e.date) ?? + { date: e.date, cost: 0, calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [] } + cur.cost += e.cost + cur.calls += e.calls + cur.inputTokens += e.inputTokens + cur.outputTokens += e.outputTokens + cur.cacheReadTokens += e.cacheReadTokens + cur.cacheWriteTokens += e.cacheWriteTokens + const m = new Map(cur.topModels.map((x) => [x.name, { ...x }])) + for (const tm of e.topModels) { + const ex = m.get(tm.name) + if (ex) { + ex.cost += tm.cost + ex.calls += tm.calls + ex.inputTokens += tm.inputTokens + ex.outputTokens += tm.outputTokens + } else { + m.set(tm.name, { ...tm }) + } + } + cur.topModels = [...m.values()] + byDate.set(e.date, cur) + } + } + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)) +} + +// 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 }: { devices: DeviceUsage[] }) { + 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() + const models = new Map() + for (const d of devices) { + const c = d.payload?.current + if (!c) continue + 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) + } + 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) })) + + return ( + <> + +
+
+
{`${reachable} device${reachable === 1 ? '' : 's'} · ${fmtNum(total.calls)} calls`}
+
{usd(total.cost)}
+
+
+
+ +
+
+ +
+ + + + + +
+ + + ({ + device: r.name + (r.local ? ' (this Mac)' : ''), + cost: r.error ? unreachable : usd(r.cost), + tokens: r.error ? '—' : fmtTokens(r.tokens), + calls: r.error ? '—' : fmtNum(r.calls), + sessions: r.error ? '—' : fmtNum(r.sessions), + }))} + /> + + +
+ + + + + + +
+ + ) +} + export function App() { const [period, setPeriod] = useState('month') const [provider, setProvider] = useState('all') + const [view, setView] = useState('all') - const { data, isLoading, isError, error } = useQuery({ - queryKey: ['usage', period, provider], - queryFn: () => fetchUsage(period, provider), + const { data, isError, error } = useQuery({ + queryKey: ['devices', period, provider], + queryFn: () => fetchDevices(period, provider), placeholderData: keepPreviousData, }) - const c = data?.current + const devices = data?.devices ?? [] + const local = devices.find((d) => d.local) + const multi = devices.some((d) => !d.local) + const viewing = view === 'all' ? undefined : devices.find((d) => d.name === view) + const primary = viewing ?? local + const c0 = primary?.payload?.current const providerOptions = useMemo( () => - c - ? Object.entries(c.providers) + c0 + ? Object.entries(c0.providers) .filter(([, v]) => v > 0) .sort((a, b) => b[1] - a[1]) .map(([k]) => k) : [], - [c], + [c0], ) - 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) })) - : [] + const showCombined = multi && view === 'all' return (
@@ -64,11 +303,25 @@ export function App() { CodeBurn
Local usage dashboard. Nothing leaves your machine. - {c?.label ?? ''} + {local?.payload?.current.label ?? ''}
+ {multi && ( +
+ setView('all')}> + All devices + + {devices.map((d) => ( + setView(d.name)}> + {d.name} + {d.local ? ' (this Mac)' : ''} + + ))} +
+ )} +
{PERIODS.map((p) => ( @@ -101,83 +354,11 @@ export function App() {
- -
-
-
- {c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '} -
-
- {c ? usd(c.cost) : } -
-
-
-
- {isLoading || !data ? ( - - ) : ( - - )} -
-
- -
- {c ? ( - <> - - - - - - - - ) : ( - Array.from({ length: 6 }).map((_, i) => ) - )} -
- -
- - - - - - -
- -
- - ({ - name: p.name, - cost: usd(p.cost), - sessions: fmtNum(p.sessions), - }))} - /> - - - - -
- - - ({ name: t.name, calls: fmtNum(t.calls) }))} - /> - + {showCombined ? ( + + ) : ( + + )} {isError && (
Failed to load: {String((error as Error)?.message)}
diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts index cd6a9ba1..42d9c226 100644 --- a/dash/src/lib/api.ts +++ b/dash/src/lib/api.ts @@ -49,6 +49,19 @@ export async function fetchUsage(period: Period, provider: string): Promise } +export type DeviceUsage = { + name: string + local: boolean + payload?: Payload + error?: string +} + +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})`) + return res.json() as Promise<{ devices: DeviceUsage[] }> +} + export const PERIODS: Array<{ key: Period; label: string }> = [ { key: 'today', label: 'Today' }, { key: 'week', label: '7 days' }, diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index 9e3f98c8..fe442058 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -6,9 +6,12 @@ import { join, normalize, extname, dirname, sep } from 'path' import { fileURLToPath } from 'url' import { AddressInfo } from 'net' +import { hostname } from 'os' + import { loadPricing } from './models.js' import { buildMenubarPayloadForRange } from './usage-aggregator.js' import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from './cli-date.js' +import { pullDevices } from './sharing/host.js' const HERE = dirname(fileURLToPath(import.meta.url)) @@ -94,6 +97,26 @@ export async function runWebDashboard(opts: { return } + // This machine plus every paired device, each kept separate. Remote + // payloads arrive already sanitized (aggregate numbers only). + if (url.pathname === '/api/devices') { + 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 localGetUsage = async (q: { period?: string; from?: string; to?: string }) => { + const customRange = parseDateRangeFlags(q.from, q.to) + const periodInfo = customRange + ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) } + : getDateRange(toPeriod(q.period ?? period)) + return buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false }) + } + const results = await pullDevices(localGetUsage, { period, from, to }, hostname(), {}) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ devices: results })) + return + } + if (!dashDir) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) res.end(NOT_BUILT_PAGE)