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:
1 parent
eeb5cad4ce
commit
6c41b6d804
5 files changed
+160
-3
No files matched your search
@@ -10,8 +10,14 @@ has a sixteenth of the context window.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*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.*
|
|
||||||
|

|
||||||
|
|
||||||
|
*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.
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
Reference in new issue
Block a user