diff --git a/dash/index.html b/dash/index.html index 017df763..978f7b92 100644 --- a/dash/index.html +++ b/dash/index.html @@ -1,9 +1,10 @@ - + - CodeBurn + + CodeBurn - Local Dashboard
diff --git a/dash/public/codeburn-logo.png b/dash/public/codeburn-logo.png new file mode 100644 index 00000000..e3167950 Binary files /dev/null and b/dash/public/codeburn-logo.png differ diff --git a/dash/src/App.tsx b/dash/src/App.tsx index e2feb19f..1cb6ad18 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -1,47 +1,82 @@ -import { useMemo, useState, type ReactNode } from 'react' -import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' -import { fetchDevices, PERIODS, type DailyEntry, type DeviceUsage, type Payload, type Period } from '@/lib/api' +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 } from '@/components/UsageChart' +import { UsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart' +import { DeviceSearchModal } from '@/components/DeviceSearchModal' const n = (v: number | undefined): number => v ?? 0 function Panel({ title, children }: { title: string; children: ReactNode }) { return ( -

{title}

+

{title}

{children}
) } -function DeviceTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { +function SideLink({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { return ( ) } +function Switch({ on }: { on: boolean }) { + return ( + + + + ) +} + +function Stat({ label: lbl, value }: { label: string; value: string }) { + return ( +
+ {lbl} + {value} +
+ ) +} + // 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 }) { +function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: boolean; unit: Unit }) { const c = payload?.current + const daily = payload?.history.daily ?? [] + const cacheWrite = daily.reduce((s, d) => s + d.cacheWriteTokens, 0) + const cacheRead = daily.reduce((s, d) => s + d.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) })) : [] @@ -54,23 +89,23 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea return ( <> - +
{c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '}
-
- {c ? usd(c.cost) : } +
+ {c ? (unit === 'tokens' ? fmtTokens(c.inputTokens + c.outputTokens) : usd(c.cost)) : }
- {!payload ? : } + {!payload ? : }
-
+
{c ? ( <> @@ -82,14 +117,16 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea + + ) : ( - Array.from({ length: 6 }).map((_, i) => ) + Array.from({ length: 8 }).map((_, i) => ) )}
-
+
@@ -98,7 +135,7 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea
-
+
{isRemote ? (

@@ -124,6 +161,55 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea

+
+ + ({ name: s.name, calls: fmtNum(s.calls), cost: usd(s.cost) }))} + /> + + + ({ name: s.name, turns: fmtNum(s.turns), cost: usd(s.cost) }))} + /> + +
+ +
+ + ({ name: m.name, calls: fmtNum(m.calls) }))} + /> + + + {c ? ( +
+ + + +
+ ) : ( + + )} +
+
+ () - 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[] }) { +function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit }) { const rows = devices.map((d) => { const c = d.payload?.current return { @@ -194,11 +246,23 @@ function CombinedView({ devices }: { devices: DeviceUsage[] }) { const providers = new Map() const models = new Map() + const activities = new Map() + 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 + for (const e of d.payload?.history.daily ?? []) { + cacheWrite += e.cacheWriteTokens + cacheRead += e.cacheReadTokens + } 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) @@ -209,26 +273,34 @@ function CombinedView({ devices }: { devices: DeviceUsage[] }) { .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 ( <> - +
{`${reachable} device${reachable === 1 ? '' : 's'} · ${fmtNum(total.calls)} calls`}
-
{usd(total.cost)}
+
+ {unit === 'tokens' ? fmtTokens(total.tokens) : usd(total.cost)} +
- +
-
+
- + + +
@@ -242,7 +314,7 @@ function CombinedView({ devices }: { devices: DeviceUsage[] }) { { key: 'sessions', label: 'Sessions', num: true }, ]} rows={rows.map((r) => ({ - device: r.name + (r.local ? ' (this Mac)' : ''), + 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), @@ -251,10 +323,16 @@ function CombinedView({ devices }: { devices: DeviceUsage[] }) { /> -
+
+ + + +
+ +
@@ -267,17 +345,52 @@ export function App() { const [period, setPeriod] = useState('month') const [provider, setProvider] = useState('all') const [view, setView] = useState('all') + const [unit, setUnit] = useState('cost') + const [searchOpen, setSearchOpen] = useState(false) + const [responded, setResponded] = useState>(new Set()) + + const qc = useQueryClient() - const { data, isError, error } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: ['devices', period, provider], queryFn: () => fetchDevices(period, provider), placeholderData: keepPreviousData, + // 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 devices = data?.devices ?? [] + 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.name === view) + const viewing = view === 'all' ? undefined : devices.find((d) => d.id === view) const primary = viewing ?? local const c0 = primary?.payload?.current @@ -292,78 +405,244 @@ export function App() { [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 ( -
-
-
+
+
+
- - CodeBurn + CodeBurn + + CodeBurn + + usage
- Local usage dashboard. Nothing leaves your machine. - {local?.payload?.current.label ?? ''} -
-
- -
- {multi && ( -
- setView('all')}> - All devices - - {devices.map((d) => ( - setView(d.name)}> - {d.name} - {d.local ? ' (this Mac)' : ''} - - ))} + +
+
+ {PERIODS.map((p) => ( + + ))} +
+
+ {(['cost', 'tokens'] as Unit[]).map((u) => ( + + ))} +
+
- )} + + +
+ + +
+
+

{viewTitle}

+ {label} +
+ + {showCombined ? ( + + ) : ( + + )} + + {isError && ( +
Failed to load: {String((error as Error)?.message)}
+ )} +
+
- {showCombined ? ( - - ) : ( - - )} + {searchOpen && setSearchOpen(false)} onPaired={() => void refetch()} />} - {isError && ( -
Failed to load: {String((error as Error)?.message)}
- )} -
+ {pending.length > 0 && ( +
+
+
+

Incoming pairing request

+
+
+ {pending.map((p) => ( +
+

+ “{p.name}” wants to pair with this device. +

+

+ Confirm this code matches on that device: {p.code} +

+
+ + +
+
+ ))} +
+
+
+ )}
) } diff --git a/dash/src/components/DeviceSearchModal.tsx b/dash/src/components/DeviceSearchModal.tsx new file mode 100644 index 00000000..8181c371 --- /dev/null +++ b/dash/src/components/DeviceSearchModal.tsx @@ -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([]) + const [error, setError] = useState(null) + const [pairing, setPairing] = useState(null) + const [status, setStatus] = useState(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 ( +
+
e.stopPropagation()} + > +
+

Search local devices

+
+ + +
+
+ +
+ {scanning ? ( +
+ + Looking for devices on your network... +
+ ) : found.length === 0 ? ( +

+ No devices found. On your other Mac run codeburn share on the same Wi-Fi. +

+ ) : ( +
+ {found.map((d) => ( +
+
+ + + + +
+
+
{d.name}
+
+ {d.host}:{d.port} +
+
+ {d.paired ? ( + Connected + ) : pairing === d.fingerprint ? ( + code {d.code} + ) : ( + + )} +
+ ))} +
+ )} + + {status &&

{status}

} + {error &&

{error}

} +
+
+
+ ) +} diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 65c9796c..8408b5ee 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -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 { @@ -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, 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 ( -
-
{fmtDay(String(lbl))}
-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {items.slice(0, 6).map((p: any) => ( -
- - {label(String(p.dataKey))} - {usd(p.value)} + 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 ( +
+
{fmtDay(String(lbl))}
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {items.slice(0, 6).map((p: any) => ( +
+ + {labels[String(p.dataKey)] ?? String(p.dataKey)} + {fmt(p.value)} +
+ ))} +
+ Total + {fmt(total)}
- ))} -
- Total - {usd(total)}
-
- ) + ) + } } -export function UsageChart({ daily }: { daily: DailyEntry[] }) { - const { rows, series } = useMemo(() => { - const totals = new Map() - 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 = { 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> + series: Series[] + labels: Record + 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 (
@@ -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} /> - } /> + } /> {series.map((s, i) => ( @@ -98,3 +98,66 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
) } + +// 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() + 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 = { 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 +} + +// 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 = { 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 +} diff --git a/dash/src/index.css b/dash/src/index.css index 94793e3f..d44ea94f 100644 --- a/dash/src/index.css +++ b/dash/src/index.css @@ -1,52 +1,54 @@ -@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap"); +@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 *)); /* - * Midnight theme for CodeBurn. Near-black surfaces, a single warm orange - * accent, and a warm orange -> gold 10-stop ramp for stacked charts. + * 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.5rem; + --radius: 0.375rem; - --background: #0a0a0b; - --outer-background: #000000; - --foreground: #e7e7ea; - --card: #0b0b0d; - --card-foreground: #ffffff; - --popover: #131316; - --popover-foreground: #ffffff; - --muted: #121215; - --muted-foreground: #cfcfd4; - --tertiary-foreground: #8a8a92; - --border: #1b1b1f; - --input: #2a2a2e; - --interactive-secondary: #141417; - --interactive-secondary-hover: #1d1d21; - --active-primary: #221a12; - --accent: #1c1c20; - --accent-foreground: #ffffff; - --subtle: #6b6b73; - --primary: #ff8c42; - --primary-foreground: #1a0f06; - --ring: #ff8c42; - --positive: #5bf58c; + --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: #ff8c42; - --chart-2: #ffa94d; - --chart-3: #f97316; - --chart-4: #ffc35e; - --chart-5: #fb923c; - --chart-6: #fbbf24; - --chart-7: #f59e0b; - --chart-8: #fdba74; - --chart-9: #eab308; - --chart-10: #d97742; - --chart-grid-stroke: #19191c; + --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: dark; + color-scheme: light; } @theme inline { @@ -60,6 +62,7 @@ --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); @@ -85,6 +88,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); @@ -102,8 +108,8 @@ } body { @apply bg-outer-background text-foreground; - letter-spacing: -0.011em; - font-weight: 450; + letter-spacing: -0.006em; + font-weight: 400; margin: 0; } ::selection { @@ -125,7 +131,7 @@ background-image: linear-gradient( 90deg, transparent 0%, - color-mix(in oklch, var(--foreground) 8%, transparent) 50%, + color-mix(in oklch, var(--foreground) 7%, transparent) 50%, transparent 100% ); background-size: 200% 100%; diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts index 42d9c226..6e04d1ed 100644 --- a/dash/src/lib/api.ts +++ b/dash/src/lib/api.ts @@ -35,6 +35,12 @@ export type Current = { 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 = { @@ -50,16 +56,71 @@ export async function fetchUsage(period: Period, provider: string): Promise + 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, + 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})`) - return res.json() as Promise<{ devices: DeviceUsage[] }> + 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 }> = [ @@ -69,3 +130,56 @@ export const PERIODS: Array<{ key: Period; label: string }> = [ { 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 { + 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 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 { + const res = await fetch('/api/share/status') + if (!res.ok) throw new Error(`share status failed (${res.status})`) + return res.json() as Promise +} +export async function startShare(always: boolean): Promise { + return (await postJson('/api/share/start', { always })).json() as Promise +} +export async function stopShare(): Promise { + return (await postJson('/api/share/stop', {})).json() as Promise +} +export async function approvePairing(id: string, approve: boolean): Promise<{ ok: boolean }> { + return (await postJson('/api/share/approve', { id, approve })).json() as Promise<{ ok: boolean }> +} diff --git a/dash/src/lib/utils.ts b/dash/src/lib/utils.ts index 2e0605aa..95f28c15 100644 --- a/dash/src/lib/utils.ts +++ b/dash/src/lib/utils.ts @@ -7,9 +7,11 @@ export function cn(...inputs: ClassValue[]): string { export function usd(n: number | undefined | null): string { const v = n == null || !isFinite(n) ? 0 : n - const s = v >= 1 || v === 0 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v.toFixed(2) + const 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 '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '') + return sign + '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '') } export function fmtTokens(n: number | undefined | null): string { @@ -21,19 +23,24 @@ export function fmtTokens(n: number | undefined | null): string { } export function fmtNum(n: number | undefined | null): string { - return (n ?? 0).toLocaleString() + const v = n == null || !isFinite(n) ? 0 : n + return v.toLocaleString() } export function compactUsd(n: number): string { - if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M' - if (n >= 1e3) return '$' + (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'k' - return '$' + Math.round(n) + 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) } -// Warm orange -> gold ramp for stacked series (mirrors --chart-* tokens). +// 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 = [ - '#ff8c42', '#ffa94d', '#f97316', '#ffc35e', '#fb923c', - '#fbbf24', '#f59e0b', '#fdba74', '#eab308', '#d97742', + '#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f', + '#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f', ] const MODEL_LABELS: Record = { diff --git a/dash/src/main.tsx b/dash/src/main.tsx index b8ce191b..68fcb0ff 100644 --- a/dash/src/main.tsx +++ b/dash/src/main.tsx @@ -1,18 +1,47 @@ -import { StrictMode } from 'react' +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 ( +
+

Something went wrong rendering the dashboard.

+

{String(this.state.error.message)}

+ +
+ ) + } + return this.props.children + } +} + const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } }, }) createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/src/sharing/client.ts b/src/sharing/client.ts index 60d2886e..41b8ddd9 100644 --- a/src/sharing/client.ts +++ b/src/sharing/client.ts @@ -24,6 +24,7 @@ function call( path: string, headers: Record = {}, body?: string, + timeoutMs = 15000, ): Promise { return new Promise((resolve, reject) => { const req = request( @@ -36,6 +37,9 @@ function call( cert: ep.identity.cert, rejectUnauthorized: false, checkServerIdentity: () => undefined, + // Fresh socket per request so the pinned-fingerprint check always reads + // this connection's certificate, never a pooled/keep-alive one. + agent: false, headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) }, }, (res) => { @@ -54,6 +58,7 @@ function call( }, ) req.on('error', reject) + req.setTimeout(timeoutMs, () => req.destroy(new Error('peer timed out'))) if (body) req.write(body) req.end() }) @@ -70,7 +75,9 @@ export function pair(ep: PeerEndpoint, pin: string, name: string): Promise { - return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name })) + // Stays open while the peer's user decides; give it longer than the server's + // 60s approval prompt. + return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }), 65_000) } export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise { diff --git a/src/sharing/host.ts b/src/sharing/host.ts index 158782b4..8009a351 100644 --- a/src/sharing/host.ts +++ b/src/sharing/host.ts @@ -1,9 +1,11 @@ import { hello, pair, pairRequest, fetchUsage } from './client.js' import { loadOrCreateIdentity } from './identity.js' import { pairingCode } from './pairing.js' +import { sanitizeForSharing } from './sanitize.js' import type { DiscoveredDevice } from './discovery.js' import type { UsageQuery } from './share-server.js' import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js' +import type { MenubarPayload } from '../menubar-json.js' import { formatCost } from '../currency.js' import { formatTokens } from '../format.js' @@ -13,6 +15,7 @@ type DevicePayload = { } export type DeviceUsage = { + id: string // stable unique id (cert fingerprint for remotes, 'local' for this device) name: string local: boolean payload?: DevicePayload @@ -89,17 +92,24 @@ export async function pullDevices( const identity = await loadOrCreateIdentity(dir) const remotes = await loadRemotes(dir) - const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }] - for (const r of remotes) { - try { - const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query) - if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload }) - else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` }) - } catch (e) { - results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) }) - } - } - return results + const local: DeviceUsage = { id: 'local', name: localName, local: true, payload: await localGetUsage(query) } + // Pull every remote concurrently and isolate failures, so one slow or + // powered-off device degrades to an error row instead of blocking the rest. + const remoteResults = await Promise.all( + remotes.map(async (r): Promise => { + try { + const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query) + // Re-sanitize on receipt: do not trust the sender to have stripped its + // own project names/sessions (it may run an older build). Belt and + // suspenders alongside the sender-side sanitize. + if (res.status === 200) return { id: r.fingerprint, name: r.name, local: false, payload: sanitizeForSharing(res.json as MenubarPayload) } + return { id: r.fingerprint, name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` } + } catch (e) { + return { id: r.fingerprint, name: r.name, local: false, error: e instanceof Error ? e.message : String(e) } + } + }), + ) + return [local, ...remoteResults] } export function renderDevices(results: DeviceUsage[]): string { diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts index faec7778..7aab37cd 100644 --- a/src/sharing/pairing.ts +++ b/src/sharing/pairing.ts @@ -44,23 +44,32 @@ export class PairingWindow { readonly pin: string readonly openedAt: number private used = false + private attempts = 0 - constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin()) { + constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin(), maxAttempts = 5) { this.ttlMs = ttlMs this.pin = pin this.openedAt = now + this.maxAttempts = maxAttempts } private readonly ttlMs: number + private readonly maxAttempts: number isOpen(now: number = Date.now()): boolean { return !this.used && now - this.openedAt <= this.ttlMs } // Verify a submitted PIN. A correct match consumes the window (one-time use). + // Wrong guesses are counted and the window closes after maxAttempts, so a + // 6-digit PIN cannot be brute-forced by a LAN peer within the TTL. verify(pin: string, now: number = Date.now()): boolean { if (!this.isOpen(now)) return false - if (!constantTimeEqual(pin, this.pin)) return false + if (!constantTimeEqual(pin, this.pin)) { + this.attempts += 1 + if (this.attempts >= this.maxAttempts) this.used = true + return false + } this.used = true return true } diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts index d0b77cd9..a09f5118 100644 --- a/src/sharing/sanitize.ts +++ b/src/sharing/sanitize.ts @@ -1,9 +1,11 @@ import type { MenubarPayload } from '../menubar-json.js' -// Strip identifying detail before usage leaves the device. We share aggregate -// numbers (cost, tokens, models, tools, activities, daily) but never project -// names, paths, or per-session detail, so "what you are working on" stays on -// the machine that produced it. Only the totals travel. +// Strip identifying detail before usage leaves the device. We never share +// project names, file paths, or per-session detail (the strongest signal of +// "what you are working on"). We DO share aggregate numbers plus model, tool, +// task, subagent, skill, and MCP-server usage, since the dashboard surfaces +// those per device. If a user names a subagent/skill after a client, that name +// would travel; revisit if that becomes a concern. export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { return { ...payload, diff --git a/src/sharing/share-controller.ts b/src/sharing/share-controller.ts new file mode 100644 index 00000000..b6c1917b --- /dev/null +++ b/src/sharing/share-controller.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'crypto' + +import { loadOrCreateIdentity, type Identity } from './identity.js' +import { PeerStore } from './pairing.js' +import { ShareServer, type PairRequest, type UsageQuery } from './share-server.js' +import { advertise } from './discovery.js' +import { getSharingDir, loadPeers, savePeers } from './store.js' + +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 IDLE_TIMEOUT_MS = 10 * 60_000 + +// Runs the secure share server inside the dashboard process so the user can +// turn sharing on/off from the browser. Incoming approve-style pairings are +// queued and surfaced to the UI instead of prompting a terminal. +export class ShareController { + private server: ShareServer | null = null + private ad: ReturnType | null = null + private peers: PeerStore | null = null + private identity: Identity | null = null + private always = false + private idleTimer: ReturnType | null = null + private lastActivity = 0 + private readonly dir = getSharingDir() + private readonly pending = new Map< + string, + { name: string; code: string; fingerprint: string; resolve: (ok: boolean) => void; timer: ReturnType } + >() + + constructor( + private readonly getUsage: (q: UsageQuery) => Promise, + private readonly port = 7777, + ) {} + + private async getIdentity(): Promise { + if (!this.identity) this.identity = await loadOrCreateIdentity(this.dir) + return this.identity + } + + isSharing(): boolean { + return !!this.server + } + + async start(always: boolean): Promise { + if (this.server) { + this.always = always + this.refreshIdleWatch() + return + } + const identity = await this.getIdentity() + this.peers = new PeerStore(await loadPeers(this.dir)) + const server = new ShareServer({ + identity, + peers: this.peers, + getUsage: this.getUsage, + onPaired: () => { + if (this.peers) void savePeers(this.peers.list(), this.dir) + }, + approve: (req) => this.enqueueApproval(req), + }) + // listen() can reject (e.g. EADDRINUSE); only commit state after it binds, + // so a failed start never leaves us reporting always/sharing incorrectly. + await server.listen(this.port, '0.0.0.0') + this.always = always + this.server = server + this.ad = advertise({ name: identity.name, port: this.port, fingerprint: identity.fingerprint }) + this.lastActivity = Date.now() + server.server.on('request', () => { + this.lastActivity = Date.now() + }) + this.refreshIdleWatch() + } + + private refreshIdleWatch(): void { + if (this.idleTimer) { + clearInterval(this.idleTimer) + this.idleTimer = null + } + if (this.always) return + this.idleTimer = setInterval(() => { + if (Date.now() - this.lastActivity > IDLE_TIMEOUT_MS) void this.stop() + }, 30_000) + this.idleTimer.unref?.() + } + + async stop(): Promise { + if (this.idleTimer) { + clearInterval(this.idleTimer) + this.idleTimer = null + } + for (const p of this.pending.values()) { + clearTimeout(p.timer) + p.resolve(false) + } + this.pending.clear() + await this.ad?.stop().catch(() => {}) + await this.server?.close().catch(() => {}) + this.ad = null + this.server = null + } + + private enqueueApproval(req: PairRequest): Promise { + // One outstanding request per device, and a hard cap, so a LAN peer cannot + // flood the approval prompt or bury a legitimate request. + for (const p of this.pending.values()) if (p.fingerprint === req.fingerprint) return Promise.resolve(false) + if (this.pending.size >= 8) return Promise.resolve(false) + return new Promise((resolve) => { + const id = randomUUID() + const timer = setTimeout(() => { + this.pending.delete(id) + resolve(false) + }, 60_000) + timer.unref?.() + this.pending.set(id, { name: req.name, code: req.code, fingerprint: req.fingerprint, resolve, timer }) + }) + } + + listPending(): PendingPairing[] { + return [...this.pending.entries()].map(([id, p]) => ({ id, name: p.name, code: p.code })) + } + + resolvePending(id: string, approve: boolean): boolean { + const p = this.pending.get(id) + if (!p) return false + clearTimeout(p.timer) + this.pending.delete(id) + p.resolve(approve) + return true + } + + async status(): Promise { + const identity = await this.getIdentity() + const peers = this.peers ? this.peers.list().length : (await loadPeers(this.dir)).length + return { + sharing: this.isSharing(), + name: identity.name, + port: this.port, + always: this.always, + peers, + pending: this.listPending(), + } + } +} diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts index bc6c9899..5c5c7bca 100644 --- a/src/sharing/share-server.ts +++ b/src/sharing/share-server.ts @@ -37,6 +37,11 @@ export class ShareServer { void this.handle(req, res) }, ) + // Swallow server-level socket/TLS errors (e.g. a malformed handshake from a + // LAN peer) so they can never crash the host process. `listen()` attaches + // its own one-time handler for bind failures. + this.server.on('error', () => {}) + this.server.on('tlsClientError', () => {}) } // Open a one-time pairing window and return the PIN to show the user. @@ -72,6 +77,21 @@ export class ShareServer { res.writeHead(code, { 'content-type': 'application/json' }) res.end(JSON.stringify(body)) } + try { + await this.route(url, req, res, json) + } catch (err) { + // Never leave a request hanging (a hung peer makes the caller time out + // and drop this device); always answer, even on an internal error. + if (!res.headersSent) json(500, { error: err instanceof Error ? err.message : String(err) }) + } + } + + private async route( + url: URL, + req: IncomingMessage, + res: ServerResponse, + json: (code: number, body: unknown) => void, + ): Promise { // Unauthenticated: just enough for a joiner to learn who this is and whether // pairing is currently open. No usage data here. diff --git a/src/sharing/store.ts b/src/sharing/store.ts index 717222e7..2f52178f 100644 --- a/src/sharing/store.ts +++ b/src/sharing/store.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir } from 'fs/promises' +import { readFile, writeFile, mkdir, chmod } from 'fs/promises' import { join, dirname } from 'path' import { getConfigFilePath } from '../config.js' @@ -28,9 +28,13 @@ async function readJson(path: string, fallback: T): Promise { } } +// These files hold bearer tokens, so keep them owner-only (0600) like the TLS +// private key. mkdir/writeFile modes only apply on creation, so chmod enforces +// it on files that already exist from an earlier version. async function writeJson(path: string, data: unknown): Promise { - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(data, null, 2)) + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 }) + await chmod(path, 0o600).catch(() => {}) } // Peers allowed to pull from this device (the sharing side, used by ShareServer). @@ -48,3 +52,13 @@ export function loadRemotes(dir: string = getSharingDir()): Promise { return writeJson(join(dir, 'remote-devices.json'), remotes) } + +// Whether the dashboard should keep sharing on (opt-in always-live). Persisted +// so `codeburn web` resumes the chosen state on launch. +export async function loadShareAlways(dir: string = getSharingDir()): Promise { + const s = await readJson(join(dir, 'web-share.json'), { always: false } as { always?: boolean }) + return !!s.always +} +export function saveShareAlways(always: boolean, dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'web-share.json'), { always }) +} diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index fe442058..16e0e754 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -11,7 +11,25 @@ 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' +import { pullDevices, linkRemote } from './sharing/host.js' +import { browse } from './sharing/discovery.js' +import { loadOrCreateIdentity } from './sharing/identity.js' +import { pairingCode } from './sharing/pairing.js' +import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js' +import { ShareController } from './sharing/share-controller.js' +import { sanitizeForSharing } from './sharing/sanitize.js' + +function readBody(req: import('http').IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + req.on('data', (c) => { + body += c + if (body.length > 1_000_000) reject(new Error('request body too large')) + }) + req.on('end', () => resolve(body)) + req.on('error', reject) + }) +} const HERE = dirname(fileURLToPath(import.meta.url)) @@ -73,10 +91,36 @@ export async function runWebDashboard(opts: { await loadPricing() const dashDir = resolveDashDir() + // Sharing this device serves the SANITIZED aggregate (no project names/paths + // or per-session detail), unlike the local /api/usage which shows everything. + const shareGetUsage = 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 ?? opts.period)) + return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })) + } + const share = new ShareController(shareGetUsage) + if (await loadShareAlways()) await share.start(true).catch(() => {}) + const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? '/', 'http://localhost') + // Loopback-only server. Reject any request not addressed to localhost + // (defeats DNS rebinding, which would otherwise let a website you visit + // read your local usage) and any cross-origin request (CSRF). The local + // payload is unsanitized, so this guard is what keeps it on your machine. + const reqHost = (req.headers.host ?? '').replace(/:\d+$/, '') + const loopback = reqHost === '127.0.0.1' || reqHost === 'localhost' || reqHost === '::1' || reqHost === '[::1]' + const origin = req.headers.origin + const originOk = !origin || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(origin) + if (!loopback || !originOk) { + res.writeHead(403, { 'content-type': 'text/plain' }) + res.end('Forbidden') + return + } + if (url.pathname === '/api/usage') { const period = url.searchParams.get('period') ?? opts.period const provider = url.searchParams.get('provider') ?? opts.provider @@ -117,6 +161,92 @@ export async function runWebDashboard(opts: { return } + // This device's own identity (name + fingerprint) for the pairing UI. + if (url.pathname === '/api/identity') { + const id = await loadOrCreateIdentity(getSharingDir()) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ name: id.name, fingerprint: id.fingerprint })) + return + } + + // Discover devices currently sharing on the local network (mDNS). Each + // carries the confirm code to match, and whether it is already paired. + if (url.pathname === '/api/devices/scan') { + const dir = getSharingDir() + const id = await loadOrCreateIdentity(dir) + const pairedFps = new Set((await loadRemotes(dir)).map((r) => r.fingerprint)) + const found = await browse(2500) + const list = found + .filter((d) => d.fingerprint !== id.fingerprint) + .map((d) => ({ + name: d.name, + host: d.host, + port: d.port, + fingerprint: d.fingerprint, + code: pairingCode(id.fingerprint, d.fingerprint), + paired: pairedFps.has(d.fingerprint), + })) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ found: list })) + return + } + + // Pair with a chosen discovered device. Blocks until the other device + // approves (or declines / times out), then stores the link. + if (url.pathname === '/api/devices/pair' && req.method === 'POST') { + if (!(req.headers['content-type'] ?? '').includes('application/json')) { + res.writeHead(415, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'content-type must be application/json' })) + return + } + const body = JSON.parse((await readBody(req)) || '{}') as { name: string; host: string; port: number; fingerprint: string } + try { + const device = await linkRemote(body) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: true, name: device.name })) + } catch (err) { + res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) })) + } + return + } + + // Share-this-device controls. Status carries the pending pairing requests + // so the SPA can poll one endpoint and surface approvals in the browser. + if (url.pathname === '/api/share/status') { + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify(await share.status())) + return + } + if (url.pathname === '/api/share/start' && req.method === 'POST') { + const body = JSON.parse((await readBody(req)) || '{}') as { always?: boolean } + let startError: string | undefined + try { + await share.start(!!body.always) + await saveShareAlways(!!body.always) + } catch (err) { + // e.g. EADDRINUSE when a CLI `codeburn share` already holds the port. + startError = err instanceof Error ? err.message : String(err) + } + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ...(await share.status()), error: startError })) + return + } + if (url.pathname === '/api/share/stop' && req.method === 'POST') { + await share.stop() + await saveShareAlways(false) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(await share.status())) + return + } + if (url.pathname === '/api/share/approve' && req.method === 'POST') { + const body = JSON.parse((await readBody(req)) || '{}') as { id?: string; approve?: boolean } + const ok = typeof body.id === 'string' && share.resolvePending(body.id, !!body.approve) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok })) + return + } + if (!dashDir) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) res.end(NOT_BUILT_PAGE) @@ -157,6 +287,8 @@ export async function runWebDashboard(opts: { }) server.listen(opts.port, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)) }) + // Durable handler so a post-bind socket error never crashes the process. + server.on('error', () => {}) const url = `http://127.0.0.1:${port}` if (!dashDir) { @@ -165,6 +297,11 @@ export async function runWebDashboard(opts: { process.stdout.write(`\n CodeBurn dashboard at ${url}\n Press Ctrl+C to stop.\n\n`) if (opts.open) openBrowser(url) + // Withdraw the mDNS advertisement and close the share server cleanly on exit. + process.on('SIGINT', () => { + void share.stop().finally(() => process.exit(0)) + }) + await new Promise(() => { /* run until interrupted */ }) diff --git a/tests/sharing/pairing.test.ts b/tests/sharing/pairing.test.ts index 5410cc9e..cd950c13 100644 --- a/tests/sharing/pairing.test.ts +++ b/tests/sharing/pairing.test.ts @@ -66,6 +66,14 @@ describe('PairingWindow', () => { expect(w.verify('123456', 1100)).toBe(true) expect(w.verify('123456', 1200)).toBe(false) }) + it('closes after too many wrong guesses (no brute force within the window)', () => { + const w = new PairingWindow(10_000, 1000, '123456', 5) + for (let i = 0; i < 5; i++) expect(w.verify('000000', 1000 + i)).toBe(false) + // window is now locked even though the TTL has not expired + expect(w.isOpen(1100)).toBe(false) + // and the correct PIN no longer works + expect(w.verify('123456', 1100)).toBe(false) + }) }) describe('PeerStore', () => {