mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
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.
This commit is contained in:
parent
3ac7f68284
commit
1dba4e0aa4
18 changed files with 1231 additions and 251 deletions
|
|
@ -1,9 +1,10 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeBurn</title>
|
||||
<link rel="icon" type="image/png" href="/codeburn-logo.png" />
|
||||
<title>CodeBurn - Local Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
BIN
dash/public/codeburn-logo.png
Normal file
BIN
dash/public/codeburn-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
523
dash/src/App.tsx
523
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 (
|
||||
<Card className="px-5 py-4">
|
||||
<h2 className="mb-3.5 text-[11px] font-semibold uppercase tracking-wider text-tertiary-foreground">{title}</h2>
|
||||
<h2 className="mb-3.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">{title}</h2>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
function SideLink({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary/40 bg-active-primary text-foreground'
|
||||
: 'border-border bg-interactive-secondary text-tertiary-foreground hover:text-foreground',
|
||||
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13.5px] transition-colors',
|
||||
active ? 'bg-interactive-secondary font-medium text-foreground' : 'font-light text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', active ? 'bg-primary' : 'bg-transparent')} />
|
||||
<span className="truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Switch({ on }: { on: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors',
|
||||
on ? 'bg-primary' : 'bg-interactive-secondary ring-1 ring-inset ring-border',
|
||||
)}
|
||||
>
|
||||
<span className={cn('inline-block h-3 w-3 transform rounded-full bg-card shadow-sm transition-transform', on ? 'translate-x-3.5' : 'translate-x-0.5')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label: lbl, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-tertiary-foreground">{lbl}</span>
|
||||
<span className="tabular-nums text-foreground">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One device's full dashboard. Remote devices arrive sanitized, so their
|
||||
// project and session detail is intentionally absent.
|
||||
function DeviceView({ payload, isRemote }: { 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 (
|
||||
<>
|
||||
<Card className="mb-4 overflow-hidden">
|
||||
<Card className="mb-3 overflow-hidden">
|
||||
<div className="flex items-end justify-between px-5 pt-4">
|
||||
<div>
|
||||
<div className="text-xs text-tertiary-foreground">
|
||||
{c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '}
|
||||
</div>
|
||||
<div className="mt-0.5 text-3xl font-semibold tracking-tight tabular-nums text-primary">
|
||||
{c ? usd(c.cost) : <Skeleton className="h-9 w-32" />}
|
||||
<div className="mt-1 font-display text-4xl tracking-tight tabular-nums text-primary">
|
||||
{c ? (unit === 'tokens' ? fmtTokens(c.inputTokens + c.outputTokens) : usd(c.cost)) : <Skeleton className="h-10 w-36" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-64 px-2 pb-2">
|
||||
{!payload ? <Skeleton className="mx-3 mb-3 h-[228px]" /> : <UsageChart daily={payload.history.daily} />}
|
||||
{!payload ? <Skeleton className="mx-3 mb-3 h-[228px]" /> : <UsageChart daily={payload.history.daily} unit={unit} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{c ? (
|
||||
<>
|
||||
<MetricCard label="Cost" value={usd(c.cost)} accent />
|
||||
|
|
@ -82,14 +117,16 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea
|
|||
<MetricCard label="Calls" value={fmtNum(c.calls)} />
|
||||
<MetricCard label="Sessions" value={fmtNum(c.sessions)} />
|
||||
<MetricCard label="Cache hit" value={`${(c.cacheHitPercent || 0).toFixed(1)}%`} />
|
||||
<MetricCard label="Cache write" value={fmtTokens(cacheWrite)} />
|
||||
<MetricCard label="Cache read" value={fmtTokens(cacheRead)} />
|
||||
<MetricCard label="One-shot" value={c.oneShotRate == null ? '—' : `${Math.round(c.oneShotRate * 100)}%`} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="By tool">
|
||||
<BarList items={toolBars} total={c?.cost} />
|
||||
</Panel>
|
||||
|
|
@ -98,7 +135,7 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea
|
|||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Top projects">
|
||||
{isRemote ? (
|
||||
<p className="py-6 text-center text-sm text-tertiary-foreground">
|
||||
|
|
@ -124,6 +161,55 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea
|
|||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Subagents">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Subagent' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
]}
|
||||
rows={(c?.subagents ?? []).slice(0, 10).map((s) => ({ name: s.name, calls: fmtNum(s.calls), cost: usd(s.cost) }))}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Skills">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Skill' },
|
||||
{ key: 'turns', label: 'Turns', num: true },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
]}
|
||||
rows={(c?.skills ?? []).slice(0, 10).map((s) => ({ name: s.name, turns: fmtNum(s.turns), cost: usd(s.cost) }))}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="MCP servers">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Server' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
]}
|
||||
rows={(c?.mcpServers ?? []).slice(0, 10).map((m) => ({ name: m.name, calls: fmtNum(m.calls) }))}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Savings & waste">
|
||||
{c ? (
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<Stat label="Local-model savings" value={usd(c.localModelSavings?.totalUSD)} />
|
||||
<Stat
|
||||
label={`Retry tax${c.retryTax?.retries ? ` (${fmtNum(c.retryTax.retries)} retries)` : ''}`}
|
||||
value={usd(c.retryTax?.totalUSD)}
|
||||
/>
|
||||
<Stat label="Routing waste (potential)" value={usd(c.routingWaste?.totalSavingsUSD)} />
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-20" />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel title="Tools">
|
||||
<DataTable
|
||||
columns={[
|
||||
|
|
@ -137,43 +223,9 @@ function DeviceView({ payload, isRemote }: { payload?: Payload; isRemote: boolea
|
|||
)
|
||||
}
|
||||
|
||||
// Merge every device's daily history by date for the combined chart, summing
|
||||
// per-model costs so the stacked bars stay correct.
|
||||
function mergeDaily(devices: DeviceUsage[]): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
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<string, number>()
|
||||
const models = new Map<string, number>()
|
||||
const activities = new Map<string, number>()
|
||||
let inTok = 0
|
||||
let outTok = 0
|
||||
let cacheWrite = 0
|
||||
let cacheRead = 0
|
||||
for (const d of devices) {
|
||||
const c = d.payload?.current
|
||||
if (!c) continue
|
||||
inTok += c.inputTokens
|
||||
outTok += c.outputTokens
|
||||
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 (
|
||||
<>
|
||||
<Card className="mb-4 overflow-hidden">
|
||||
<Card className="mb-3 overflow-hidden">
|
||||
<div className="flex items-end justify-between px-5 pt-4">
|
||||
<div>
|
||||
<div className="text-xs text-tertiary-foreground">{`${reachable} device${reachable === 1 ? '' : 's'} · ${fmtNum(total.calls)} calls`}</div>
|
||||
<div className="mt-0.5 text-3xl font-semibold tracking-tight tabular-nums text-primary">{usd(total.cost)}</div>
|
||||
<div className="mt-1 font-display text-4xl tracking-tight tabular-nums text-primary">
|
||||
{unit === 'tokens' ? fmtTokens(total.tokens) : usd(total.cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-64 px-2 pb-2">
|
||||
<UsageChart daily={mergeDaily(devices)} />
|
||||
<DeviceUsageChart devices={devices} unit={unit} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard label="Total cost" value={usd(total.cost)} accent />
|
||||
<MetricCard label="Tokens" value={fmtTokens(total.tokens)} />
|
||||
<MetricCard label="Tokens" value={fmtTokens(total.tokens)} sub={`in ${fmtTokens(inTok)} / out ${fmtTokens(outTok)}`} />
|
||||
<MetricCard label="Calls" value={fmtNum(total.calls)} />
|
||||
<MetricCard label="Sessions" value={fmtNum(total.sessions)} />
|
||||
<MetricCard label="Cache write" value={fmtTokens(cacheWrite)} />
|
||||
<MetricCard label="Cache read" value={fmtTokens(cacheRead)} />
|
||||
<MetricCard label="Devices" value={String(reachable)} />
|
||||
</div>
|
||||
|
||||
|
|
@ -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 ? <span className="text-tertiary-foreground">unreachable</span> : usd(r.cost),
|
||||
tokens: r.error ? '—' : fmtTokens(r.tokens),
|
||||
calls: r.error ? '—' : fmtNum(r.calls),
|
||||
|
|
@ -251,10 +323,16 @@ function CombinedView({ devices }: { devices: DeviceUsage[] }) {
|
|||
/>
|
||||
</Panel>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="By task (all devices)">
|
||||
<BarList items={taskBars} total={total.cost} />
|
||||
</Panel>
|
||||
<Panel title="By tool (all devices)">
|
||||
<BarList items={toolBars} total={total.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Panel title="Top models (all devices)">
|
||||
<BarList items={modelBars} total={total.cost} />
|
||||
</Panel>
|
||||
|
|
@ -267,17 +345,52 @@ export function App() {
|
|||
const [period, setPeriod] = useState<Period>('month')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [view, setView] = useState<string>('all')
|
||||
const [unit, setUnit] = useState<Unit>('cost')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [responded, setResponded] = useState<Set<string>>(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 (
|
||||
<div className="min-h-screen bg-outer-background">
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto flex max-w-[1200px] items-center gap-3 px-6 py-3.5">
|
||||
<div className="min-h-screen bg-outer-background p-2.5">
|
||||
<div className="flex h-[calc(100vh-20px)] flex-col gap-2.5">
|
||||
<header className="flex h-12 shrink-0 items-center gap-4 rounded-md border border-border bg-card px-5 shadow-[0_2px_8px_rgba(0,0,0,0.03)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg leading-none text-primary">▲</span>
|
||||
<span className="text-sm font-semibold">CodeBurn</span>
|
||||
<img src="/codeburn-logo.png" alt="CodeBurn" className="h-6 w-6" />
|
||||
<span className="text-lg font-semibold tracking-[-0.02em] text-foreground">
|
||||
Code<span className="text-[#e8553a]">Burn</span>
|
||||
</span>
|
||||
<span className="ml-1 text-[11px] font-light uppercase tracking-[0.14em] text-tertiary-foreground">usage</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-tertiary-foreground">Local usage dashboard. Nothing leaves your machine.</span>
|
||||
<span className="ml-auto text-[11px] text-tertiary-foreground">{local?.payload?.current.label ?? ''}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-[1200px] px-6 py-6">
|
||||
{multi && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<DeviceTab active={view === 'all'} onClick={() => setView('all')}>
|
||||
All devices
|
||||
</DeviceTab>
|
||||
{devices.map((d) => (
|
||||
<DeviceTab key={d.name} active={view === d.name} onClick={() => setView(d.name)}>
|
||||
{d.name}
|
||||
{d.local ? ' (this Mac)' : ''}
|
||||
</DeviceTab>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setPeriod(p.key)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
period === p.key ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
|
||||
{(['cost', 'tokens'] as Unit[]).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() => setUnit(u)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
unit === u ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{u === 'cost' ? 'Cost' : 'Tokens'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground outline-none"
|
||||
>
|
||||
<option value="all">All tools</option>
|
||||
{providerOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
<div className="flex rounded-lg border border-border bg-interactive-secondary p-1">
|
||||
{PERIODS.map((p) => (
|
||||
<div className="flex min-h-0 flex-1 gap-2.5">
|
||||
<aside className="flex w-60 shrink-0 flex-col gap-5 overflow-y-auto rounded-md border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Devices</p>
|
||||
{multi && (
|
||||
<SideLink active={view === 'all'} onClick={() => setView('all')}>
|
||||
All devices
|
||||
</SideLink>
|
||||
)}
|
||||
{devices.map((d) => (
|
||||
<SideLink
|
||||
key={d.id}
|
||||
active={view === d.id || (!multi && view === 'all' && d.local)}
|
||||
onClick={() => setView(d.id)}
|
||||
>
|
||||
{d.name}
|
||||
{d.local ? ' · this Mac' : ''}
|
||||
</SideLink>
|
||||
))}
|
||||
{devices.length === 0 && <p className="px-2.5 py-1 text-xs text-tertiary-foreground">Loading…</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-interactive-secondary"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<path d="M10.5 10.5L14 14" />
|
||||
</svg>
|
||||
Search local devices
|
||||
</button>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="mb-2 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Share</p>
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setPeriod(p.key)}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
period === p.key
|
||||
? 'bg-active-primary text-foreground shadow-sm ring-1 ring-inset ring-white/10'
|
||||
: 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => void toggleShare()}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13.5px] text-foreground transition-colors hover:bg-interactive-secondary"
|
||||
>
|
||||
{p.label}
|
||||
<span>Share this device</span>
|
||||
<Switch on={!!shareInfo?.sharing} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="ml-auto rounded-lg border border-border bg-interactive-secondary px-3 py-2 text-xs text-foreground outline-none"
|
||||
>
|
||||
<option value="all">All tools</option>
|
||||
{providerOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{shareInfo?.sharing && (
|
||||
<div className="mt-1.5 px-2.5">
|
||||
<p className="text-[11px] leading-relaxed text-tertiary-foreground">
|
||||
Discoverable as “{shareInfo.name}” · {shareInfo.peers} paired
|
||||
</p>
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareInfo.always}
|
||||
onChange={() => void toggleAlways()}
|
||||
className="h-3.5 w-3.5 accent-[#1f8a5b]"
|
||||
/>
|
||||
Keep sharing always
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t border-border pt-4">
|
||||
<p className="text-[11px] leading-relaxed text-tertiary-foreground">
|
||||
Local only. Nothing leaves your machine; only totals are shared between your devices.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<a
|
||||
href="https://codeburn.app/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="codeburn.app"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3c2.5 2.7 2.5 15.3 0 18M12 3c-2.5 2.7-2.5 15.3 0 18" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/invite/w2sw8mCqep"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Discord"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M20.317 4.369A19.79 19.79 0 0 0 16.558 3c-.2.36-.43.85-.59 1.23a18.27 18.27 0 0 0-5.93 0A12.6 12.6 0 0 0 9.44 3 19.7 19.7 0 0 0 5.68 4.37C2.9 8.46 2.14 12.45 2.52 16.38a19.9 19.9 0 0 0 6.07 3.08c.49-.67.93-1.38 1.3-2.13-.71-.27-1.4-.6-2.04-.99.17-.13.34-.26.5-.4 3.93 1.84 8.18 1.84 12.06 0 .17.14.33.27.5.4-.65.39-1.33.72-2.05.99.38.75.81 1.46 1.3 2.13a19.9 19.9 0 0 0 6.07-3.08c.45-4.55-.77-8.5-3.2-12.01zM9.69 14.5c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.34-.95 2.42-2.15 2.42zm4.62 0c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.34-.94 2.42-2.15 2.42z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/_codeburn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="X"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-xl tracking-tight text-foreground">{viewTitle}</h1>
|
||||
<span className="text-xs text-tertiary-foreground">{label}</span>
|
||||
</div>
|
||||
|
||||
{showCombined ? (
|
||||
<CombinedView devices={devices} unit={unit} />
|
||||
) : (
|
||||
<DeviceView payload={primary?.payload} isRemote={!!viewing && !viewing.local} unit={unit} />
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="mt-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message)}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCombined ? (
|
||||
<CombinedView devices={devices} />
|
||||
) : (
|
||||
<DeviceView payload={primary?.payload} isRemote={!!viewing && !viewing.local} />
|
||||
)}
|
||||
{searchOpen && <DeviceSearchModal onClose={() => setSearchOpen(false)} onPaired={() => void refetch()} />}
|
||||
|
||||
{isError && (
|
||||
<div className="mt-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message)}</div>
|
||||
)}
|
||||
</main>
|
||||
{pending.length > 0 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
||||
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-card shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]">
|
||||
<div className="border-b border-border px-5 py-3.5">
|
||||
<h2 className="text-sm font-semibold text-foreground">Incoming pairing request</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
{pending.map((p) => (
|
||||
<div key={p.id} className="rounded-md border border-border px-3.5 py-3">
|
||||
<p className="text-sm text-foreground">
|
||||
“{p.name}” wants to pair with this device.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-tertiary-foreground">
|
||||
Confirm this code matches on that device: <span className="font-mono text-foreground">{p.code}</span>
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void respondPairing(p.id, true)}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void respondPairing(p.id, false)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs text-tertiary-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
123
dash/src/components/DeviceSearchModal.tsx
Normal file
123
dash/src/components/DeviceSearchModal.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { scanDevices, pairDevice, type DiscoveredDevice } from '@/lib/api'
|
||||
|
||||
export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void; onPaired: () => void }) {
|
||||
const [scanning, setScanning] = useState(true)
|
||||
const [found, setFound] = useState<DiscoveredDevice[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pairing, setPairing] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
||||
const scan = async () => {
|
||||
setScanning(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
setFound(await scanDevices())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void scan()
|
||||
}, [])
|
||||
|
||||
const connect = async (d: DiscoveredDevice) => {
|
||||
setPairing(d.fingerprint)
|
||||
setError(null)
|
||||
setStatus(`Confirm the code ${d.code} on "${d.name}", then approve there. Waiting...`)
|
||||
try {
|
||||
const r = await pairDevice(d)
|
||||
if (r.ok) {
|
||||
setStatus(`Connected to "${r.name ?? d.name}".`)
|
||||
onPaired()
|
||||
setTimeout(onClose, 700)
|
||||
} else {
|
||||
setError(r.error ?? 'Pairing failed')
|
||||
setStatus(null)
|
||||
setPairing(null)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus(null)
|
||||
setPairing(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md overflow-hidden rounded-lg border border-border bg-card shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="text-sm font-semibold text-foreground">Search local devices</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void scan()}
|
||||
disabled={scanning}
|
||||
className="rounded-md border border-border px-2.5 py-1 text-xs text-tertiary-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Rescan
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-md px-2 py-1 text-tertiary-foreground hover:text-foreground" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
{scanning ? (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-tertiary-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
Looking for devices on your network...
|
||||
</div>
|
||||
) : found.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-tertiary-foreground">
|
||||
No devices found. On your other Mac run <span className="font-mono text-foreground">codeburn share</span> on the same Wi-Fi.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{found.map((d) => (
|
||||
<div key={d.fingerprint} className="flex items-center gap-3 rounded-md border border-border px-3.5 py-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-interactive-secondary text-primary">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{d.name}</div>
|
||||
<div className="truncate font-mono text-xs text-tertiary-foreground">
|
||||
{d.host}:{d.port}
|
||||
</div>
|
||||
</div>
|
||||
{d.paired ? (
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">Connected</span>
|
||||
) : pairing === d.fingerprint ? (
|
||||
<span className="font-mono text-xs text-tertiary-foreground">code {d.code}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void connect(d)}
|
||||
disabled={!!pairing}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && <p className="mt-3 text-xs text-tertiary-foreground">{status}</p>}
|
||||
{error && <p className="mt-3 text-xs text-[#b5403a]">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string, string>, 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 (
|
||||
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-white/5">
|
||||
<div className="mb-1.5 font-medium text-foreground">{fmtDay(String(lbl))}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{items.slice(0, 6).map((p: any) => (
|
||||
<div key={p.dataKey} className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: p.color }} />
|
||||
<span className="flex-1 truncate text-tertiary-foreground">{label(String(p.dataKey))}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{usd(p.value)}</span>
|
||||
return function ChartTooltip({ active, payload, label: lbl }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items = payload.filter((p: any) => p.value > 0).sort((a: any, b: any) => b.value - a.value)
|
||||
if (!items.length) return null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const total = items.reduce((s: number, p: any) => s + p.value, 0)
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-black/5">
|
||||
<div className="mb-1.5 font-medium text-foreground">{fmtDay(String(lbl))}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{items.slice(0, 6).map((p: any) => (
|
||||
<div key={p.dataKey} className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: p.color }} />
|
||||
<span className="flex-1 truncate text-tertiary-foreground">{labels[String(p.dataKey)] ?? String(p.dataKey)}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{fmt(p.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex items-center justify-between border-t border-border pt-1 text-foreground">
|
||||
<span>Total</span>
|
||||
<span className="font-semibold tabular-nums">{fmt(total)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex items-center justify-between border-t border-border pt-1 text-foreground">
|
||||
<span>Total</span>
|
||||
<span className="font-semibold tabular-nums">{usd(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function UsageChart({ daily }: { daily: DailyEntry[] }) {
|
||||
const { rows, series } = useMemo(() => {
|
||||
const totals = new Map<string, number>()
|
||||
for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + m.cost)
|
||||
const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k)
|
||||
const topSet = new Set(top)
|
||||
const hasOther = [...totals.keys()].some((k) => !topSet.has(k))
|
||||
const seriesKeys = hasOther ? [...top, 'Other'] : top
|
||||
const rowData = daily.map((d) => {
|
||||
const row: Record<string, number | string> = { period: d.date }
|
||||
for (const k of seriesKeys) row[k] = 0
|
||||
for (const m of d.topModels) {
|
||||
const key = topSet.has(m.name) ? m.name : 'Other'
|
||||
row[key] = (row[key] as number) + m.cost
|
||||
}
|
||||
return row
|
||||
})
|
||||
return { rows: rowData, series: seriesKeys }
|
||||
}, [daily])
|
||||
|
||||
function StackedBars({
|
||||
rows,
|
||||
series,
|
||||
labels,
|
||||
unit,
|
||||
}: {
|
||||
rows: Array<Record<string, number | string>>
|
||||
series: Series[]
|
||||
labels: Record<string, string>
|
||||
unit: Unit
|
||||
}) {
|
||||
const fmt = unit === 'tokens' ? fmtTokens : usd
|
||||
const axisFmt = (v: number | string) => (unit === 'tokens' ? fmtTokens(Number(v)) : compactUsd(Number(v)))
|
||||
const Tip = makeTooltip(labels, fmt)
|
||||
return (
|
||||
<div className="relative h-full w-full [&_.recharts-bar-rectangle]:transition-opacity [&_.recharts-bar-rectangle]:duration-75 [&:has(.recharts-bar-rectangle:hover)_.recharts-bar-rectangle:not(:hover)]:opacity-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
|
@ -80,15 +80,15 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
|
|||
axisLine={false}
|
||||
width={50}
|
||||
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
|
||||
tickFormatter={(v) => compactUsd(Number(v))}
|
||||
tickFormatter={axisFmt}
|
||||
/>
|
||||
<Tooltip cursor={{ fill: 'rgba(255,255,255,0.04)' }} content={<ChartTooltip />} />
|
||||
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<Tip />} />
|
||||
{series.map((s, i) => (
|
||||
<Bar
|
||||
key={s}
|
||||
dataKey={s}
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stackId="a"
|
||||
fill={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
fill={s.color}
|
||||
isAnimationActive={false}
|
||||
radius={i === series.length - 1 ? [3, 3, 0, 0] : undefined}
|
||||
/>
|
||||
|
|
@ -98,3 +98,66 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Spend (or tokens) per day, stacked by model (single device).
|
||||
export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) {
|
||||
const { rows, series, labels } = useMemo(() => {
|
||||
const measure = (m: { cost: number; inputTokens: number; outputTokens: number }) =>
|
||||
unit === 'tokens' ? m.inputTokens + m.outputTokens : m.cost
|
||||
const totals = new Map<string, number>()
|
||||
for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + measure(m))
|
||||
const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k)
|
||||
const topSet = new Set(top)
|
||||
const hasOther = [...totals.keys()].some((k) => !topSet.has(k))
|
||||
const keys = hasOther ? [...top, 'Other'] : top
|
||||
const rowData = daily.map((d) => {
|
||||
const row: Record<string, number | string> = { period: d.date }
|
||||
for (const k of keys) row[k] = 0
|
||||
for (const m of d.topModels) {
|
||||
const key = topSet.has(m.name) ? m.name : 'Other'
|
||||
row[key] = (row[key] as number) + measure(m)
|
||||
}
|
||||
return row
|
||||
})
|
||||
const series: Series[] = keys.map((k, i) => ({ key: k, label: label(k), color: CHART_COLORS[i % CHART_COLORS.length]! }))
|
||||
const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
|
||||
return { rows: rowData, series, labels }
|
||||
}, [daily, unit])
|
||||
|
||||
return <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
|
||||
}
|
||||
|
||||
// Spend (or tokens) per day, stacked by device (one color per device) for the All view.
|
||||
export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) {
|
||||
const { rows, series, labels } = useMemo(() => {
|
||||
const named = devices.filter((d) => d.payload)
|
||||
const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? []
|
||||
// Stable key + color per device (by unique id) so a device keeps its color
|
||||
// and its bars don't remount when another device drops/returns between
|
||||
// polls, and two devices sharing a hostname never collide.
|
||||
const keyOf = (d: DeviceUsage) => 'dev_' + d.id.replace(/[^a-zA-Z0-9]/g, '_')
|
||||
const colorOf = (id: string) => {
|
||||
let h = 0
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
|
||||
return CHART_COLORS[Math.abs(h) % CHART_COLORS.length]!
|
||||
}
|
||||
const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b))
|
||||
const series: Series[] = named.map((d) => ({
|
||||
key: keyOf(d),
|
||||
label: d.name + (d.local ? ' (this Mac)' : ''),
|
||||
color: colorOf(d.id),
|
||||
}))
|
||||
const rowData = dates.map((date) => {
|
||||
const row: Record<string, number | string> = { period: date }
|
||||
named.forEach((d) => {
|
||||
const e = dailyOf(d).find((x) => x.date === date)
|
||||
row[keyOf(d)] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0
|
||||
})
|
||||
return row
|
||||
})
|
||||
const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
|
||||
return { rows: rowData, series, labels }
|
||||
}, [devices, unit])
|
||||
|
||||
return <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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<Payl
|
|||
}
|
||||
|
||||
export type DeviceUsage = {
|
||||
id: string
|
||||
name: string
|
||||
local: boolean
|
||||
payload?: Payload
|
||||
error?: string
|
||||
}
|
||||
|
||||
// A device may run a different CodeBurn version and send a payload missing
|
||||
// fields we treat as required. Fill safe defaults at the boundary so the UI
|
||||
// can iterate them without crashing (the alternative is a white screen for an
|
||||
// innocent local user because a peer sent an old shape).
|
||||
function normalizePayload(p?: Payload): Payload | undefined {
|
||||
if (!p) return p
|
||||
const c = (p.current ?? {}) as Partial<Current>
|
||||
return {
|
||||
generated: p.generated,
|
||||
current: {
|
||||
label: c.label ?? '',
|
||||
cost: c.cost ?? 0,
|
||||
calls: c.calls ?? 0,
|
||||
sessions: c.sessions ?? 0,
|
||||
oneShotRate: c.oneShotRate ?? null,
|
||||
inputTokens: c.inputTokens ?? 0,
|
||||
outputTokens: c.outputTokens ?? 0,
|
||||
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<DiscoveredDevice[]> {
|
||||
const res = await fetch('/api/devices/scan')
|
||||
if (!res.ok) throw new Error(`Scan failed (${res.status})`)
|
||||
const json = (await res.json()) as { found: DiscoveredDevice[] }
|
||||
return json.found
|
||||
}
|
||||
|
||||
export async function pairDevice(d: DiscoveredDevice): Promise<{ ok: boolean; name?: string; error?: string }> {
|
||||
const res = await fetch('/api/devices/pair', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint }),
|
||||
})
|
||||
return res.json() as Promise<{ ok: boolean; name?: string; error?: string }>
|
||||
}
|
||||
|
||||
export type PendingPairing = { id: string; name: string; code: string }
|
||||
export type ShareStatus = {
|
||||
sharing: boolean
|
||||
name: string
|
||||
port: number
|
||||
always: boolean
|
||||
peers: number
|
||||
pending: PendingPairing[]
|
||||
}
|
||||
|
||||
const postJson = (path: string, body: unknown) =>
|
||||
fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
|
||||
export async function shareStatus(): Promise<ShareStatus> {
|
||||
const res = await fetch('/api/share/status')
|
||||
if (!res.ok) throw new Error(`share status failed (${res.status})`)
|
||||
return res.json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function startShare(always: boolean): Promise<ShareStatus> {
|
||||
return (await postJson('/api/share/start', { always })).json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function stopShare(): Promise<ShareStatus> {
|
||||
return (await postJson('/api/share/stop', {})).json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function approvePairing(id: string, approve: boolean): Promise<{ ok: boolean }> {
|
||||
return (await postJson('/api/share/approve', { id, approve })).json() as Promise<{ ok: boolean }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-outer-background p-8 text-center">
|
||||
<p className="text-sm text-foreground">Something went wrong rendering the dashboard.</p>
|
||||
<p className="max-w-md text-xs text-tertiary-foreground">{String(this.state.error.message)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => location.reload()}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground hover:bg-interactive-secondary"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ function call(
|
|||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
body?: string,
|
||||
timeoutMs = 15000,
|
||||
): Promise<Response> {
|
||||
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<Respo
|
|||
// Approve-style pairing: no PIN. The peer prompts its user to approve; this
|
||||
// request stays open until they accept or decline.
|
||||
export function pairRequest(ep: PeerEndpoint, name: string): Promise<Response> {
|
||||
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<Response> {
|
||||
|
|
|
|||
|
|
@ -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<DeviceUsage> => {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
151
src/sharing/share-controller.ts
Normal file
151
src/sharing/share-controller.ts
Normal file
|
|
@ -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<typeof advertise> | null = null
|
||||
private peers: PeerStore | null = null
|
||||
private identity: Identity | null = null
|
||||
private always = false
|
||||
private idleTimer: ReturnType<typeof setInterval> | 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<typeof setTimeout> }
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly getUsage: (q: UsageQuery) => Promise<unknown>,
|
||||
private readonly port = 7777,
|
||||
) {}
|
||||
|
||||
private async getIdentity(): Promise<Identity> {
|
||||
if (!this.identity) this.identity = await loadOrCreateIdentity(this.dir)
|
||||
return this.identity
|
||||
}
|
||||
|
||||
isSharing(): boolean {
|
||||
return !!this.server
|
||||
}
|
||||
|
||||
async start(always: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
// 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<ShareStatus> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
|
||||
// Unauthenticated: just enough for a joiner to learn who this is and whether
|
||||
// pairing is currently open. No usage data here.
|
||||
|
|
|
|||
|
|
@ -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<T>(path: string, fallback: T): Promise<T> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<RemoteDevice
|
|||
export function saveRemotes(remotes: RemoteDevice[], dir: string = getSharingDir()): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
return writeJson(join(dir, 'web-share.json'), { always })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<never>(() => {
|
||||
/* run until interrupted */
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue