diff --git a/README.md b/README.md index ed474cd..d0ae8c3 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,14 @@ has a sixteenth of the context window. ![Freeway demo](demo/freeway-demo.gif) -*Every frame above is a real request against a live provider — recorded by -`./scripts/demo.sh`, which you can run yourself.* +**The dashboard, reacting to that same traffic:** + +![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 @@ -404,7 +410,8 @@ gateway flatly refuses to bind one with no keys at all. ## Development ```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 test # node:test across all workspaces npm run smoke # every route against mock upstreams — run this after adding a provider diff --git a/demo/freeway-dashboard.gif b/demo/freeway-dashboard.gif new file mode 100644 index 0000000..3238ffa Binary files /dev/null and b/demo/freeway-dashboard.gif differ diff --git a/demo/freeway-dashboard.mp4 b/demo/freeway-dashboard.mp4 new file mode 100644 index 0000000..e468d60 Binary files /dev/null and b/demo/freeway-dashboard.mp4 differ diff --git a/packages/gateway/public/index.html b/packages/gateway/public/index.html index b6706e7..eac842b 100644 --- a/packages/gateway/public/index.html +++ b/packages/gateway/public/index.html @@ -317,6 +317,7 @@ td.n{font-family:var(--mono); white-space:nowrap} const POLL_MS = 3000; const KEY_STORAGE = 'freeway.key'; +const STATIC = new URLSearchParams(location.search).has('nolive'); let apiKey = localStorage.getItem(KEY_STORAGE) || ''; let paused = false; let timer = null; @@ -786,6 +787,10 @@ function setPulse(kind) { function schedule() { 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 // brings it straight back with a fresh fetch. timer = setInterval(() => { if (!paused && !document.hidden) refresh(); }, POLL_MS); @@ -802,6 +807,9 @@ let liveBackoff = 1000; let liveConnected = false; 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(); liveAbort = new AbortController(); diff --git a/scripts/record-dashboard.mjs b/scripts/record-dashboard.mjs new file mode 100644 index 0000000..7e2f036 --- /dev/null +++ b/scripts/record-dashboard.mjs @@ -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`);