Record the dashboard, and make the page capturable

node scripts/record-dashboard.mjs drives the gateway with real requests and
screenshots the real dashboard between them with headless Chrome. Screen
recording was the obvious route and the wrong one: it would photograph whatever
else happened to be on screen and needs a permission grant. Headless captures
only the page, needs no permission, and writes frames straight to disk.

The dashboard never finished loading under a headless browser, because it holds
an SSE connection open and polls on an interval — so ?nolive=1 now renders once
and goes quiet. That also makes the page screenshot-testable in general.

Chrome does not reliably exit after --screenshot, so the recorder waits for the
PNG to appear and stop growing rather than guessing a sleep, and treats profile
cleanup as best-effort since Chrome keeps flushing after SIGKILL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-16 23:18:22 -05:00
1 parent eeb5cad4ce
commit 6c41b6d804
5 files changed
+160 -3

No files matched your search

+10 -3
View File
@@ -10,8 +10,14 @@ has a sixteenth of the context window.
![Freeway demo](demo/freeway-demo.gif) ![Freeway demo](demo/freeway-demo.gif)
*Every frame above is a real request against a live provider — recorded by **The dashboard, reacting to that same traffic:**
`./scripts/demo.sh`, which you can run yourself.*
![Freeway dashboard](demo/freeway-dashboard.gif)
*Both are real. The terminal is `./scripts/demo.sh`; the dashboard is headless
Chrome screenshotting the actual page via `node scripts/record-dashboard.mjs`,
between real requests. Watch the token totals, cache hit rate and per-model
counts climb — nothing is mocked up.*
``` ```
your app ──▶ Freeway ──┬──▶ Mistral 1B tokens/month your app ──▶ Freeway ──┬──▶ Mistral 1B tokens/month
@@ -404,7 +410,8 @@ gateway flatly refuses to bind one with no keys at all.
## Development ## Development
```bash ```bash
./scripts/demo.sh # scripted walkthrough against your real providers ./scripts/demo.sh # scripted walkthrough against your providers
node scripts/record-dashboard.mjs # re-record the dashboard GIF
npm run build # tsc --build, must be clean npm run build # tsc --build, must be clean
npm test # node:test across all workspaces npm test # node:test across all workspaces
npm run smoke # every route against mock upstreams — run this after adding a provider npm run smoke # every route against mock upstreams — run this after adding a provider
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.
+8
View File
@@ -317,6 +317,7 @@ td.n{font-family:var(--mono); white-space:nowrap}
const POLL_MS = 3000; const POLL_MS = 3000;
const KEY_STORAGE = 'freeway.key'; const KEY_STORAGE = 'freeway.key';
const STATIC = new URLSearchParams(location.search).has('nolive');
let apiKey = localStorage.getItem(KEY_STORAGE) || ''; let apiKey = localStorage.getItem(KEY_STORAGE) || '';
let paused = false; let paused = false;
let timer = null; let timer = null;
@@ -786,6 +787,10 @@ function setPulse(kind) {
function schedule() { function schedule() {
clearInterval(timer); clearInterval(timer);
// ?nolive=1 renders once and then goes quiet: no polling interval, no event
// stream. A page that never stops working never finishes loading, which is
// what screenshot tools and headless browsers wait for.
if (STATIC) return;
// Polling a hidden tab burns CPU for nobody; the visibilitychange handler // Polling a hidden tab burns CPU for nobody; the visibilitychange handler
// brings it straight back with a fresh fetch. // brings it straight back with a fresh fetch.
timer = setInterval(() => { if (!paused && !document.hidden) refresh(); }, POLL_MS); timer = setInterval(() => { if (!paused && !document.hidden) refresh(); }, POLL_MS);
@@ -802,6 +807,9 @@ let liveBackoff = 1000;
let liveConnected = false; let liveConnected = false;
async function connectLive() { async function connectLive() {
// ?nolive=1 keeps the page from holding an open SSE connection. Headless
// browsers and screenshot tools otherwise wait for a stream that never ends.
if (STATIC) { setLiveState(false); return; }
if (liveAbort) liveAbort.abort(); if (liveAbort) liveAbort.abort();
liveAbort = new AbortController(); liveAbort = new AbortController();
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Record the dashboard as it reacts to real traffic.
*
* node scripts/record-dashboard.mjs
*
* Headless Chrome screenshots the real page rather than capturing the screen:
* nothing but the dashboard is ever photographed, it needs no recording
* permission, and frames land straight on disk for ffmpeg.
*
* Chrome does not reliably exit after `--screenshot`, so rather than guessing a
* sleep we wait for the PNG to appear and stop growing, then kill it. Each frame
* takes about as long as the page needs and no longer.
*/
import { spawn } from 'node:child_process';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = process.env.DEMO_PORT ?? '8790';
const BASE = `http://127.0.0.1:${PORT}`;
const FRAMES = process.env.FRAMES_DIR ?? '/tmp/fwdash';
const [W, H] = [1280, 860];
if (!existsSync(CHROME)) {
console.error(`Google Chrome not found at ${CHROME}`);
process.exit(1);
}
rmSync(FRAMES, { recursive: true, force: true });
mkdirSync(FRAMES, { recursive: true });
let shotIndex = 0;
const profiles = [];
process.on('exit', () => {
for (const p of profiles) {
try { rmSync(p, { recursive: true, force: true }); } catch { /* leave it to the OS */ }
}
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const pad = (n) => `f${String(n).padStart(4, '0')}.png`;
async function shot() {
const out = join(FRAMES, pad(shotIndex));
// A fresh profile per shot: a reused one can still be locked by the previous
// Chrome, which then exits without writing anything.
const profile = mkdtempSync(join(tmpdir(), 'fwchrome-'));
const child = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-sandbox',
'--no-first-run', '--no-default-browser-check',
'--virtual-time-budget=2500',
`--user-data-dir=${profile}`,
`--window-size=${W},${H}`,
`--screenshot=${out}`,
`${BASE}/?nolive=1&t=${shotIndex}`,
], { stdio: 'ignore' });
// Wait for the file to appear and settle, rather than for Chrome to exit.
let stable = 0;
let lastSize = -1;
for (let i = 0; i < 200; i++) {
await sleep(100);
if (existsSync(out)) {
const size = statSync(out).size;
if (size > 0 && size === lastSize) stable += 1;
else stable = 0;
lastSize = size;
if (stable >= 3) break;
}
}
child.kill('SIGKILL');
// Chrome keeps flushing profile files for a moment after SIGKILL, so removing
// the directory here races it. The frame is already on disk; cleanup is
// best-effort and happens again at exit.
profiles.push(profile);
try {
rmSync(profile, { recursive: true, force: true });
} catch { /* retried on exit */ }
if (!existsSync(out)) throw new Error(`frame ${shotIndex} was never written`);
process.stdout.write(`\r frame ${shotIndex} (${Math.round(lastSize / 1024)}KB) `);
shotIndex += 1;
}
/** Repeat the previous frame, so a state stays on screen long enough to read. */
function hold(n = 6) {
const src = join(FRAMES, pad(shotIndex - 1));
for (let i = 0; i < n; i++) {
copyFileSync(src, join(FRAMES, pad(shotIndex)));
shotIndex += 1;
}
}
async function ask(model, content, maxTokens = 25) {
try {
await fetch(`${BASE}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, temperature: 0, messages: [{ role: 'user', content }], max_tokens: maxTokens }),
});
} catch { /* the dashboard showing the failure is also a valid frame */ }
}
console.log(`recording dashboard → ${FRAMES}`);
// 1. resting state
await shot(); hold(8);
// 2. traffic arrives: the log fills, quota bars move, usage totals climb
for (const q of ['what is a rate limit', 'name a colour', 'what is 2+2']) {
await ask('fast', q);
await shot();
}
hold(5);
// 3. aliases land on different models
for (const m of ['cheap', 'code', 'reason']) {
await ask(m, 'reply with one word: ok', 10);
await shot();
}
hold(6);
// 4. the same question again — a cache hit, costing nothing
await ask('fast', 'what is 2+2');
await shot(); hold(10);
// 5. an unknown model, then a server-side session
await fetch(`${BASE}/v1/chat/completions`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'gpt-5-turbo-ultra', messages: [{ role: 'user', content: 'hi' }] }),
}).catch(() => {});
await shot(); hold(6);
await ask('fast', 'My name is Ada.');
await ask('fast', 'What is my name?');
await shot(); hold(12);
console.log(`\n ${shotIndex} frames`);