mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
perf(web): instant dashboard load, default to today, fast-fail offline peers (#573)
Some checks failed
CI / semgrep (push) Has been cancelled
Some checks failed
CI / semgrep (push) Has been cancelled
The web server is long-lived, so cache what the per-invocation CLI cannot: - Cache the parsed local payload in-memory (single-flight, 180s TTL matched to the parser session cache, expired entries pruned on write). /api/usage and the local half of /api/devices now return from a Map hit after the first parse. - Prewarm today at startup and inline that payload into index.html as a bootstrap, so the SPA paints today's numbers with no round-trip. Only the local device is embedded; '<' is escaped so a name cannot break the script tag; served no-store; the seeded view refetches at once so live peers appear. - Default the web command and dashboard to today. - Cap the peer connect phase at 3s. req.setTimeout does not abort a stalled TCP connect, so an offline paired device hung ~75s on the OS timeout; it now degrades to an unreachable row in ~3s. The cap clears on TCP connect, so the TLS handshake and the 65s pairing-approval wait are unaffected. Measured: /api/usage 0.0007s warm (was ~0.22s), /api/devices ~3s with an offline peer (was ~75s), first paint instant.
This commit is contained in:
parent
805d2ffa49
commit
22d5fc1743
6 changed files with 94 additions and 23 deletions
|
|
@ -342,7 +342,7 @@ function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit })
|
|||
}
|
||||
|
||||
export function App() {
|
||||
const [period, setPeriod] = useState<Period>('month')
|
||||
const [period, setPeriod] = useState<Period>('today')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [view, setView] = useState<string>('all')
|
||||
const [unit, setUnit] = useState<Unit>('cost')
|
||||
|
|
@ -354,6 +354,10 @@ export function App() {
|
|||
const { data, isError, error, refetch } = useQuery({
|
||||
queryKey: ['devices', period, provider],
|
||||
queryFn: () => fetchDevices(period, provider),
|
||||
initialData: () => (period === 'today' && provider === 'all' ? window.__CODEBURN_BOOTSTRAP__ : undefined),
|
||||
// Bootstrap paints instantly but is stale by definition, so refetch at once
|
||||
// (the default 30s staleTime would otherwise hide a live peer until then).
|
||||
initialDataUpdatedAt: 0,
|
||||
// When devices are paired, re-pull periodically so a device that briefly
|
||||
// dropped (asleep/network blip) reappears on its own instead of staying
|
||||
// gone until you switch tabs.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export type DeviceUsage = {
|
|||
error?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CODEBURN_BOOTSTRAP__?: { devices: DeviceUsage[] }
|
||||
}
|
||||
}
|
||||
|
||||
// A device may run a different CodeBurn version and send a payload missing
|
||||
// fields we treat as required. Fill safe defaults at the boundary so the UI
|
||||
// can iterate them without crashing (the alternative is a white screen for an
|
||||
|
|
|
|||
|
|
@ -614,7 +614,7 @@ program
|
|||
program
|
||||
.command('web')
|
||||
.description('Open the local web dashboard in your browser')
|
||||
.option('-p, --period <period>', 'Initial period: today, week, 30days, month, all', 'month')
|
||||
.option('-p, --period <period>', 'Initial period: today, week, 30days, month, all', 'today')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD)')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD)')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, copilot)', 'all')
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import { certFingerprint } from './pairing.js'
|
|||
import type { Identity } from './identity.js'
|
||||
import type { UsageQuery } from './share-server.js'
|
||||
|
||||
// req.setTimeout only arms once connected; it does not abort a stalled TCP
|
||||
// connect, so an unreachable peer would otherwise ride the OS connect timeout
|
||||
// (~75s on macOS). Cap the TCP-connect phase separately; it clears the instant
|
||||
// the socket connects, so the TLS handshake and the read/approval timeouts
|
||||
// (req.setTimeout) are unaffected even over a slow VPN link.
|
||||
const CONNECT_TIMEOUT_MS = 3000
|
||||
|
||||
export type PeerEndpoint = {
|
||||
identity: Identity // our own identity (we present our cert so the peer can bind a token to us)
|
||||
host: string
|
||||
|
|
@ -59,6 +66,14 @@ function call(
|
|||
)
|
||||
req.on('error', reject)
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error('peer timed out')))
|
||||
const connectTimer = setTimeout(() => req.destroy(new Error('peer unreachable')), CONNECT_TIMEOUT_MS)
|
||||
connectTimer.unref()
|
||||
req.once('socket', (socket) => {
|
||||
const clear = () => clearTimeout(connectTimer)
|
||||
socket.once('connect', clear)
|
||||
socket.once('secureConnect', clear)
|
||||
})
|
||||
req.once('close', () => clearTimeout(connectTimer))
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { hostname } from 'os'
|
|||
|
||||
import { loadPricing } from './models.js'
|
||||
import { buildMenubarPayloadForRange } from './usage-aggregator.js'
|
||||
import type { MenubarPayload } from './menubar-json.js'
|
||||
import { periodInfoFromQuery, UsageQueryError } from './cli-date.js'
|
||||
import { pullDevices, linkRemote } from './sharing/host.js'
|
||||
import { browse } from './sharing/discovery.js'
|
||||
|
|
@ -36,6 +37,10 @@ function writeJsonError(res: import('http').ServerResponse, status: number, erro
|
|||
res.end(JSON.stringify({ error }))
|
||||
}
|
||||
|
||||
// Cap on the cached local payload, matched to the parser's own session cache
|
||||
// (parser.ts) so the assembled payload is never staler than its source data.
|
||||
const LOCAL_PAYLOAD_TTL_MS = 180_000
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Locate the built React dashboard (dist/dash). Works both when running from a
|
||||
|
|
@ -105,6 +110,37 @@ export async function runWebDashboard(opts: {
|
|||
const share = new ShareController(shareGetUsage)
|
||||
if (await loadShareAlways()) await share.start(true).catch(() => {})
|
||||
|
||||
// The server is long-lived, so cache this machine's parsed payload (the CLI
|
||||
// process cannot). Store the promise before any await so concurrent identical
|
||||
// requests collapse into one parse instead of racing.
|
||||
const localPayloadCache = new Map<string, { at: number; payload: Promise<MenubarPayload> }>()
|
||||
const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise<MenubarPayload> => {
|
||||
const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}`
|
||||
const hit = localPayloadCache.get(key)
|
||||
if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) return hit.payload
|
||||
const periodInfo = periodInfoFromQuery({ period, from, to }, opts.period)
|
||||
const payload = buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false })
|
||||
const now = Date.now()
|
||||
localPayloadCache.set(key, { at: now, payload })
|
||||
for (const [k, v] of localPayloadCache) if (now - v.at >= LOCAL_PAYLOAD_TTL_MS) localPayloadCache.delete(k)
|
||||
void payload.catch(() => localPayloadCache.delete(key))
|
||||
return payload
|
||||
}
|
||||
|
||||
// Embed this machine's prewarmed payload in index.html for an instant first
|
||||
// paint with no data round-trip. Only the local device is inlined: no remote
|
||||
// network wait, and paired devices stream in via the live fetch right after.
|
||||
const serveIndexHtml = async (res: import('http').ServerResponse, filePath: string): Promise<void> => {
|
||||
const html = await readFile(filePath, 'utf8')
|
||||
const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to)
|
||||
const devices = [{ id: 'local', name: hostname(), local: true, payload }]
|
||||
// Escape every '<' so a device/model/project name can't close the <script>.
|
||||
const json = JSON.stringify({ devices }).replace(/</g, String.fromCharCode(92) + 'u003c')
|
||||
const injected = html.replace('<script type="module"', `<script>window.__CODEBURN_BOOTSTRAP__=${json}</script>\n <script type="module"`)
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
||||
res.end(injected)
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
|
|
@ -128,20 +164,14 @@ export async function runWebDashboard(opts: {
|
|||
const provider = url.searchParams.get('provider') ?? opts.provider
|
||||
const from = url.searchParams.get('from') ?? opts.from
|
||||
const to = url.searchParams.get('to') ?? opts.to
|
||||
let periodInfo
|
||||
let payload
|
||||
try {
|
||||
periodInfo = periodInfoFromQuery({ period, from, to }, opts.period)
|
||||
payload = await getLocalPayload(period, provider, from, to)
|
||||
} catch (err) {
|
||||
if (!(err instanceof UsageQueryError)) throw err
|
||||
writeJsonError(res, 400, err instanceof Error ? err.message : String(err))
|
||||
writeJsonError(res, 400, err.message)
|
||||
return
|
||||
}
|
||||
const payload = await buildMenubarPayloadForRange(periodInfo, {
|
||||
provider,
|
||||
project: opts.project,
|
||||
exclude: opts.exclude,
|
||||
optimize: false,
|
||||
})
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
||||
res.end(JSON.stringify(payload))
|
||||
return
|
||||
|
|
@ -154,17 +184,14 @@ export async function runWebDashboard(opts: {
|
|||
const provider = url.searchParams.get('provider') ?? opts.provider
|
||||
const from = url.searchParams.get('from') ?? opts.from
|
||||
const to = url.searchParams.get('to') ?? opts.to
|
||||
let periodInfo
|
||||
let results
|
||||
try {
|
||||
periodInfo = periodInfoFromQuery({ period, from, to }, opts.period)
|
||||
results = await pullDevices(() => getLocalPayload(period, provider, from, to), { period, from, to }, hostname(), {})
|
||||
} catch (err) {
|
||||
if (!(err instanceof UsageQueryError)) throw err
|
||||
writeJsonError(res, 400, err instanceof Error ? err.message : String(err))
|
||||
writeJsonError(res, 400, err.message)
|
||||
return
|
||||
}
|
||||
const localGetUsage = async () =>
|
||||
buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false })
|
||||
const results = await pullDevices(localGetUsage, { period, from, to }, hostname(), {})
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
||||
res.end(JSON.stringify({ devices: results }))
|
||||
return
|
||||
|
|
@ -271,14 +298,16 @@ export async function runWebDashboard(opts: {
|
|||
return
|
||||
}
|
||||
try {
|
||||
const buf = await readFile(filePath)
|
||||
res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' })
|
||||
res.end(buf)
|
||||
if (extname(filePath) === '.html') {
|
||||
await serveIndexHtml(res, filePath)
|
||||
} else {
|
||||
const buf = await readFile(filePath)
|
||||
res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' })
|
||||
res.end(buf)
|
||||
}
|
||||
} catch {
|
||||
// Unknown path: serve index.html so the SPA can route it.
|
||||
const buf = await readFile(join(dashDir, 'index.html'))
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.end(buf)
|
||||
await serveIndexHtml(res, join(dashDir, 'index.html'))
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'content-type': 'application/json' })
|
||||
|
|
@ -299,6 +328,8 @@ export async function runWebDashboard(opts: {
|
|||
// Durable handler so a post-bind socket error never crashes the process.
|
||||
server.on('error', () => {})
|
||||
|
||||
void Promise.resolve().then(() => getLocalPayload(opts.period, opts.provider, opts.from, opts.to)).catch(() => {})
|
||||
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
if (!dashDir) {
|
||||
process.stdout.write(`\n Dashboard UI is not built. Run: cd dash && npm install && npm run build\n`)
|
||||
|
|
|
|||
15
tests/sharing/connect-timeout.test.ts
Normal file
15
tests/sharing/connect-timeout.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { generateIdentity } from '../../src/sharing/identity.js'
|
||||
import { hello } from '../../src/sharing/client.js'
|
||||
|
||||
describe('peer connect timeout', () => {
|
||||
it('fails fast when a peer is unreachable instead of riding the OS connect timeout', async () => {
|
||||
const id = await generateIdentity('Test')
|
||||
// RFC5737 TEST-NET-1 is reserved and black-holed: the SYN gets no answer,
|
||||
// so without the connect-phase cap this would hang ~75s on macOS.
|
||||
const start = Date.now()
|
||||
await expect(hello({ identity: id, host: '192.0.2.1', port: 7777 })).rejects.toThrow()
|
||||
expect(Date.now() - start).toBeLessThan(6000)
|
||||
}, 15000)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue