From 1dba4e0aa4a8ae981023228361aabd56e382f397 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:02:03 +0200 Subject: [PATCH] feat(web): in-dashboard device discovery, share-from-browser, redesign + hardening (#534) * feat(web): device discovery + pairing endpoints for the dashboard Add /api/identity, /api/devices/scan (mDNS browse with confirm code and paired status), and /api/devices/pair (approve-style pairing via linkRemote). Backs the Search local devices button so pairing can happen from the browser instead of the CLI. * feat(web): two-panel dashboard with eywa-inspired warm theme Redesign the dashboard into a left sidebar (device switcher + Search local devices) and a right content panel, restyled to a warm-paper, forest-green archival theme (serif display numbers, ink text, hairline borders) in place of the dark midnight theme. The Search local devices modal discovers nearby devices over mDNS and pairs in one click (approve on the other device), backed by /api/devices/scan and /api/devices/pair. * feat(web): use the CodeBurn flame logo in the dashboard header Replace the placeholder triangle with the flame mark (the repo's codeburn-symbolic vector), tinted the theme's forest green, and set the same flame as the favicon. * feat(web): color the All-devices chart by device, add task-level combined views In the All devices view the daily chart now stacks by device (one color per device) instead of by model, and the combined panels add a By task breakdown plus in/out token detail. Devices that cannot be reached are hidden entirely rather than shown as error rows. * feat(web): use the flame image as the dashboard logo and favicon Replace the inline vector flame with the brand flame PNG (public/codeburn-flame.png) in the header and as the favicon. * fix(web): trim flame logo padding and tighten brand spacing Crop the transparent border off the flame PNG (it was 184px of padding each side) and reduce the header gap so the mark sits close to the wordmark. * feat(web): cost/tokens toggle, cache read/write, full feature panels Add a Cost/Tokens unit toggle that switches the hero number and the chart between dollars and tokens. Surface cache write/read token totals as metric cards. Add per-device panels for subagents, skills, MCP servers, and a savings / retry-tax / routing-waste summary. The combined view gains cache totals and the unit toggle too. * feat(web): use CodeBurn website logos and add community links Use the website's navbar flame (logo.png) with the Code+Burn wordmark in the header, and the three-flame app icon (icon.png) as the favicon. Add Website, Discord, and X links to the sidebar footer. * fix(web): use the single-flame logo for the favicon too The three-flame icon was wrong for the tab; use the same single flame as the navbar everywhere and drop the unused three-flame asset. * chore(web): set dashboard title to CodeBurn - Local Dashboard * harden sharing + dashboard for public launch Security: - pairing: cap PIN attempts (close window after 5 wrong guesses) so a 6-digit PIN cannot be brute-forced within the TTL on a 0.0.0.0 listener. - web dashboard: reject non-loopback Host (defeats DNS rebinding that could read unsanitized local usage) and cross-origin requests (CSRF); require application/json on the pair endpoint. - store tokens with 0600 perms (was world-readable), 0700 dir. Robustness: - client: per-request timeout so a hung/asleep peer cannot hang a pull; pullDevices fetches remotes concurrently and isolates failures. - dashboard: normalize peer payloads at the boundary and add an error boundary so a peer on a different version cannot white-screen the SPA; finite-guard fmtNum/compactUsd. Tests: PIN attempt-cap test added (1269 pass). * feat(web): share this device from the dashboard, with browser approval Add a Share this device toggle to the dashboard sidebar. It runs the secure share server in-process (mTLS + mDNS advertise) so no terminal is needed, with a Keep sharing always option (persisted; otherwise it stops after idle). Incoming approve-style pairings are queued and surfaced in the browser as an Approve/Deny prompt with the matching code, instead of a terminal prompt. The shared payload is sanitized; start degrades gracefully if the port is already held by a CLI share. * fix(sharing): keep a paired device from dropping on a transient pull - client: 8s -> 15s timeout and a fresh socket per request (agent:false) so the pinned-fingerprint check always reads this connection's cert. - share server: wrap request handling so a getUsage error returns a fast 500 instead of hanging the caller (which times out and drops the device). - dashboard: re-pull paired devices every 20s so a brief drop self-heals instead of staying gone until you change tabs. * fix(sharing): re-sanitize remote payloads on receipt A peer might run an older build that does not strip its own project names/sessions. Sanitize every remote payload when we receive it too, so project names never cross into our dashboard regardless of the sender's version. Aggregate numbers (cost, tokens, models, tools, daily) are kept. * harden dashboard sharing: version-skew, lifecycle, server errors - normalize remote daily-history entries so a peer on an older build (daily rows missing topModels) can no longer crash the chart / brick the page. - ShareController: commit always/sharing state only after listen() binds, so a port-in-use start no longer reports sharing when it is not. - attach durable error/tlsClientError handlers to both HTTPS servers so a malformed peer connection cannot crash the process. - reset view/provider selection when the viewed device disappears or lacks the selected provider (no more empty/no-selection state on sleep-wake). - by-device chart: stable per-device color/key so bars do not reshuffle when a device drops or returns between polls. * polish: device identity, approval guard, queue cap, formatting - give each device a stable unique id (cert fingerprint for remotes, 'local' for this device); key the sidebar, selection, and by-device chart by id so two devices sharing a hostname no longer collide. - approval prompt: drop a request from the UI the moment it is answered so it cannot be double-clicked. - share controller: cap concurrent pending approvals and allow only one per device, so a LAN peer cannot flood the prompt. - usd(): render negatives as -$5.00, consistent with compactUsd. --- dash/index.html | 5 +- dash/public/codeburn-logo.png | Bin 0 -> 75672 bytes dash/src/App.tsx | 523 +++++++++++++++++----- dash/src/components/DeviceSearchModal.tsx | 123 +++++ dash/src/components/UsageChart.tsx | 163 ++++--- dash/src/index.css | 88 ++-- dash/src/lib/api.ts | 116 ++++- dash/src/lib/utils.ts | 25 +- dash/src/main.tsx | 37 +- src/sharing/client.ts | 9 +- src/sharing/host.ts | 32 +- src/sharing/pairing.ts | 13 +- src/sharing/sanitize.ts | 10 +- src/sharing/share-controller.ts | 151 +++++++ src/sharing/share-server.ts | 20 + src/sharing/store.ts | 20 +- src/web-dashboard.ts | 139 +++++- tests/sharing/pairing.test.ts | 8 + 18 files changed, 1231 insertions(+), 251 deletions(-) create mode 100644 dash/public/codeburn-logo.png create mode 100644 dash/src/components/DeviceSearchModal.tsx create mode 100644 src/sharing/share-controller.ts diff --git a/dash/index.html b/dash/index.html index 017df76..978f7b9 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 0000000000000000000000000000000000000000..e3167950c2563ee383390e9e30cdaa31d85c8e45 GIT binary patch literal 75672 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 literal 0 HcmV?d00001 diff --git a/dash/src/App.tsx b/dash/src/App.tsx index e2feb19..1cb6ad1 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 { data, isError, error } = useQuery({ + const qc = useQueryClient() + + 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 devices = data?.devices ?? [] + 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.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) => ( + + ))} +
+
- )} + -
-
- {PERIODS.map((p) => ( +
+
+ +
+

+ Local only. Nothing leaves your machine; only totals are shared between your devices. +

+ +
+ + +
+
+

{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 0000000..8181c37 --- /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 65c9796..8408b5e 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 +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 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) => ( -
- - {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 94793e3..d44ea94 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 42d9c22..6e04d1e 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 2e0605a..95f28c1 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 b8ce191..68fcb0f 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 60d2886..41b8ddd 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 158782b..8009a35 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 faec777..7aab37c 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 d0b77cd..a09f511 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 0000000..b6c1917 --- /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 bc6c989..5c5c7bc 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 717222e..2f52178 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 fe44205..16e0e75 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 5410cc9..cd950c1 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', () => {