feat(dashboard): replace Svelte bundle with AHA stack (htmx + Alpine, server-rendered fragments)

- src/dashboard/fragments.ts: server-side HTML fragment renderers reusing the
  same JSON payloads (HTML and JSON surfaces can't drift)
- node.ts/dashboard.ts: /fragments/<name> routes (header, session-summary,
  recent, latest, sessions, stats, toggle) with htmx polling + Alpine for
  local state; toggle is a POST-returning-fragment htmx swap
- vendored htmx 2.0.4 + Alpine 3.14.9 in src/dashboard/vendor.ts (offline,
  single-file npm package, no CDN)
- deleted Svelte components/stores/bundle build step; dropped svelte/vite
  devDeps; build is now just tsc + esbuild
- tests: +5 fragment contract tests (routing, toggle state, HTML escaping of
  source text, 404) - 339 total
This commit is contained in:
teamchong
2026-06-09 22:17:53 -04:00
parent 40a3eb5cdd
commit cb522a70fe
25 changed files with 785 additions and 2083 deletions
+5 -8
View File
@@ -44,18 +44,18 @@
"LICENSE"
],
"scripts": {
"build": "npm run build:dashboard-ui && node scripts/build.mjs",
"build": "node scripts/build.mjs",
"build:atlas": "tsx scripts/gen-atlas.ts",
"build:dashboard-ui": "node scripts/build-dashboard-ui.mjs",
"dev:node": "tsx watch src/node.ts",
"dev:worker": "wrangler dev",
"deploy:worker": "wrangler deploy",
"restart": "bash scripts/restart.sh",
"typecheck": "tsc --noEmit && tsc -p tsconfig.dashboard.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:restart": "bash tests/restart.test.sh",
"prepublishOnly": "pnpm run typecheck && pnpm test && pnpm run build"
"prepublishOnly": "pnpm run typecheck && pnpm test && pnpm run build",
"vendor:ui": "node scripts/vendor-ui.mjs"
},
"keywords": [
"claude",
@@ -80,9 +80,6 @@
"@cloudflare/workers-types": "^4.20240512.0",
"@types/node": "^20.12.0",
"esbuild": "^0.28.0",
"esbuild-svelte": "^0.9.5",
"svelte": "^5.55.7",
"svelte-preprocess": "^6.0.3",
"tsx": "^4.10.0",
"typescript": "^5.4.0",
"vite": "8.0.13",
@@ -97,4 +94,4 @@
"dependencies": {
"@napi-rs/canvas": "^0.1.53"
}
}
}
-193
View File
@@ -26,15 +26,6 @@ importers:
esbuild:
specifier: ^0.28.0
version: 0.28.0
esbuild-svelte:
specifier: ^0.9.5
version: 0.9.5(esbuild@0.28.0)(svelte@5.55.7)
svelte:
specifier: ^5.55.7
version: 5.55.7
svelte-preprocess:
specifier: ^6.0.3
version: 6.0.3(postcss@8.5.14)(svelte@5.55.7)(typescript@5.9.3)
tsx:
specifier: ^4.10.0
version: 4.22.2
@@ -577,12 +568,6 @@ packages:
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -590,9 +575,6 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
@@ -797,11 +779,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@sveltejs/acorn-typescript@1.0.9':
resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==}
peerDependencies:
acorn: ^8.9.0
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -817,9 +794,6 @@ packages:
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vitest/expect@4.1.6':
resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
@@ -849,23 +823,10 @@ packages:
'@vitest/utils@4.1.6':
resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
aria-query@5.3.1:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
@@ -873,10 +834,6 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -888,22 +845,12 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
devalue@5.8.1:
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
esbuild-svelte@0.9.5:
resolution: {integrity: sha512-16FUSj64aiS28CCxYeK3hVxCyU4PwGBSkeArawAtnWSV7l0Gc+frIOP88MNj8Q7tDAGyEJAHT6OpOvM24BG46w==}
engines: {node: '>=18'}
peerDependencies:
esbuild: '>=0.17.0'
svelte: '>=4.2.1 <6'
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -914,17 +861,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esrap@2.2.9:
resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==}
peerDependencies:
'@typescript-eslint/types': ^8.2.0
peerDependenciesMeta:
'@typescript-eslint/types':
optional: true
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -946,9 +882,6 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
@@ -1027,9 +960,6 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1094,47 +1024,6 @@ packages:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
svelte-preprocess@6.0.3:
resolution: {integrity: sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==}
engines: {node: '>= 18.0.0'}
peerDependencies:
'@babel/core': ^7.10.2
coffeescript: ^2.5.1
less: ^3.11.3 || ^4.0.0
postcss: ^7 || ^8
postcss-load-config: '>=3'
pug: ^3.0.0
sass: ^1.26.8
stylus: '>=0.55'
sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0
svelte: ^4.0.0 || ^5.0.0-next.100 || ^5.0.0
typescript: ^5.0.0
peerDependenciesMeta:
'@babel/core':
optional: true
coffeescript:
optional: true
less:
optional: true
postcss:
optional: true
postcss-load-config:
optional: true
pug:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
typescript:
optional: true
svelte@5.55.7:
resolution: {integrity: sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==}
engines: {node: '>=18'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1295,9 +1184,6 @@ packages:
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
@@ -1597,25 +1483,10 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -1746,10 +1617,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)':
dependencies:
acorn: 8.16.0
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
@@ -1768,8 +1635,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/trusted-types@2.0.7': {}
'@vitest/expect@4.1.6':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -1811,38 +1676,22 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
acorn@8.16.0: {}
aria-query@5.3.1: {}
assertion-error@2.0.1: {}
axobject-query@4.1.0: {}
blake3-wasm@2.1.5: {}
chai@6.2.2: {}
clsx@2.1.1: {}
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
devalue@5.8.1: {}
error-stack-parser-es@1.0.5: {}
es-module-lexer@2.1.0: {}
esbuild-svelte@0.9.5(esbuild@0.28.0)(svelte@5.55.7):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
esbuild: 0.28.0
svelte: 5.55.7
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@@ -1901,12 +1750,6 @@ snapshots:
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
esm-env@1.2.2: {}
esrap@2.2.9:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
@@ -1920,10 +1763,6 @@ snapshots:
fsevents@2.3.3:
optional: true
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.9
kleur@4.1.5: {}
lightningcss-android-arm64@1.32.0:
@@ -1975,8 +1814,6 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
locate-character@3.0.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2075,34 +1912,6 @@ snapshots:
supports-color@10.2.2: {}
svelte-preprocess@6.0.3(postcss@8.5.14)(svelte@5.55.7)(typescript@5.9.3):
dependencies:
svelte: 5.55.7
optionalDependencies:
postcss: 8.5.14
typescript: 5.9.3
svelte@5.55.7:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@types/estree': 1.0.9
'@types/trusted-types': 2.0.7
acorn: 8.16.0
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.8.1
esm-env: 1.2.2
esrap: 2.2.9
is-reference: 3.0.3
locate-character: 3.0.0
magic-string: 0.30.21
zimmerframe: 1.1.4
transitivePeerDependencies:
- '@typescript-eslint/types'
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
@@ -2217,5 +2026,3 @@ snapshots:
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
zimmerframe@1.1.4: {}
-68
View File
@@ -1,68 +0,0 @@
// Build the Svelte dashboard UI into a single JS bundle string.
//
// Writes `src/dashboard-bundle.ts` with an exported `DASHBOARD_JS` constant
// containing the compiled IIFE bundle. `src/dashboard.ts` embeds this in the
// HTML template it serves; no runtime deps on the Node host.
//
// CSS is compiled inline (each component injects its own <style> on mount via
// the svelte compiler's `css: 'injected'` mode), so we ship ONE string.
import { build } from 'esbuild';
import sveltePlugin from 'esbuild-svelte';
import { sveltePreprocess } from 'svelte-preprocess';
import { writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
const ENTRY = 'src/dashboard/main.ts';
const OUT_FILE = 'src/dashboard-bundle.ts';
const result = await build({
entryPoints: [ENTRY],
bundle: true,
format: 'iife',
platform: 'browser',
target: 'es2022',
// Svelte 5 ships ESM; we IIFE-wrap so the bundle is a single self-contained
// script tag in the dashboard HTML. No globals leak — the IIFE returns void.
minify: true,
write: false,
sourcemap: false,
// The dashboard runs in the operator's browser, not Workers — `console` is fine.
// We keep the bundle small by stripping debug logs but leaving warn/error.
drop: ['debugger'],
plugins: [
sveltePlugin({
preprocess: sveltePreprocess(),
compilerOptions: {
// Inject CSS via JS so we ship ONE artifact, not a separate stylesheet.
css: 'injected',
// Legacy reactivity (`$:`, `export let`) — Svelte 5 still supports it.
// Runes mode would require rewriting every component to `$state`/`$derived`/
// `$props`. Worth doing eventually; not blocking this rewrite.
runes: false,
},
}),
],
});
const js = result.outputFiles[0].text;
await mkdir(dirname(OUT_FILE), { recursive: true });
// Embed as a backtick string. JS bundle output is safe inside backticks EXCEPT
// for `${`, `` ` ``, and `\`. Escape all three. The compiled IIFE can contain
// any of these inside string literals (e.g. an HTML template).
const escaped = js
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
const header = `// AUTO-GENERATED by scripts/build-dashboard-ui.mjs — do not edit by hand.
// Source: src/dashboard/ (Svelte). Run \`pnpm run build:dashboard-ui\` to regenerate.
// Size: ${js.length} chars (${(js.length / 1024).toFixed(1)} KB).
export const DASHBOARD_JS = \`${escaped}\`;
`;
await writeFile(OUT_FILE, header);
console.log(`✓ built ${OUT_FILE} (${(js.length / 1024).toFixed(1)} KB)`);
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// Regenerates src/dashboard/vendor.ts from pinned htmx + Alpine releases.
// Run manually when bumping versions; the output is checked in so the
// build has no network dependency.
import { writeFileSync } from 'node:fs';
const PINS = [
['HTMX_JS', 'https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js'],
['ALPINE_JS', 'https://unpkg.com/alpinejs@3.14.9/dist/cdn.min.js'],
];
let out =
'// GENERATED by scripts/vendor-ui.mjs - do not edit by hand.\n' +
'// htmx 2.0.4 (BSD-2) + Alpine.js 3.14.9 (MIT), inlined so the dashboard\n' +
'// works offline and ships inside the single-file npm package.\n';
for (const [name, url] of PINS) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: ${res.status}`);
const js = await res.text();
out += `\nexport const ${name} = ${JSON.stringify(js)};\n`;
}
writeFileSync('src/dashboard/vendor.ts', out);
console.log('wrote src/dashboard/vendor.ts');
File diff suppressed because one or more lines are too long
+82 -31
View File
@@ -45,11 +45,26 @@ import {
type SessionsPaths,
} from './sessions.js';
import { aggregateEventsFile, summaryToJson } from './stats.js';
// Compiled Svelte UI - generated by `scripts/build-dashboard-ui.mjs`.
// Importing the .ts source lets esbuild inline the bundle string when it
// compiles `src/node.ts`. The generated file is committed so a clean install
// works without a separate UI build step at install time.
import { DASHBOARD_JS } from './dashboard-bundle.js';
// Server-rendered UI (htmx + Alpine, vendored). No client bundle - the
// fragments module renders finished HTML from the same payloads the JSON
// endpoints serve.
import {
renderPage,
renderToggleFragment,
renderSessionSummaryFragment,
renderHeaderFragment,
renderRecentFragment,
renderLatestFragment,
renderSessionsFragment,
renderStatsTableFragment,
} from './dashboard/fragments.js';
import type {
StatsPayload,
RecentPayload,
SessionsPayload,
FullStatsPayload,
CurrentSessionPayload,
} from './dashboard/types.js';
const RECENT_CAP = 50;
@@ -926,9 +941,58 @@ export class DashboardState {
}
serveHtml(port: number): Response {
return new Response(DASHBOARD_HTML.replace(/__PORT__/g, String(port)), {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
return htmlResponse(renderPage(port));
}
/** GET /fragments/<name> server-rendered htmx fragments. Each one reuses
* the corresponding JSON endpoint's payload (via Response.json()) so the
* HTML and JSON surfaces can't drift apart. */
async serveFragment(name: string, url: URL, port: number): Promise<Response> {
switch (name) {
case 'toggle':
return htmlResponse(renderToggleFragment(this.compressionEnabled));
case 'session-summary': {
const cur = (await this.serveCurrentSessionJson().json()) as CurrentSessionPayload;
return htmlResponse(renderSessionSummaryFragment(cur));
}
case 'header': {
const s = (await this.serveStats().json()) as StatsPayload;
return htmlResponse(renderHeaderFragment(s, port));
}
case 'recent': {
const r = (await this.serveRecent().json()) as RecentPayload;
return htmlResponse(renderRecentFragment(r));
}
case 'latest': {
const r = (await this.serveRecent().json()) as RecentPayload;
const pinRaw = url.searchParams.get('pin');
const pinNum = pinRaw != null && pinRaw !== '' ? Number(pinRaw) : NaN;
const pin = Number.isFinite(pinNum) ? pinNum : null;
const showSource = url.searchParams.get('source') === '1';
let sourceText: string | null = null;
if (showSource) {
const entry =
pin != null
? this.images.find((im) => im.id === pin)
: this.images[this.images.length - 1];
sourceText = entry?.sourceText ?? null;
}
return htmlResponse(renderLatestFragment({ payload: r, pin, showSource, sourceText }));
}
case 'sessions': {
const res = await this.serveSessionsJson();
if (!res.ok) return htmlResponse(`<div class="status">sessions unavailable</div>`);
const p = (await res.json()) as SessionsPayload;
return htmlResponse(renderSessionsFragment(p));
}
case 'stats': {
const res = await this.serveApiStats();
const p = (await res.json()) as FullStatsPayload;
return htmlResponse(renderStatsTableFragment(p));
}
default:
return new Response('unknown fragment', { status: 404 });
}
}
// ---- session / cleanup endpoints --------------------------------------
@@ -1020,7 +1084,8 @@ export type DashboardRoute =
| { kind: 'api-stats' } // /api/stats.json
| { kind: 'current-session' } // /api/current-session.json
| { kind: 'api-compression' } // /api/compression (POST {enabled}) — runtime kill switch
| { kind: 'api-image-source' }; // /api/image-source[?id=N] — source text behind a rendered PNG
| { kind: 'api-image-source' } // /api/image-source[?id=N] — source text behind a rendered PNG
| { kind: 'fragment'; name: string }; // /fragments/<name> — server-rendered htmx panels
/** Match dashboard paths (handle query strings on /proxy-latest-png). */
export function dashboardPath(pathname: string): DashboardRoute | null {
@@ -1033,28 +1098,14 @@ export function dashboardPath(pathname: string): DashboardRoute | null {
if (pathname === '/api/current-session.json') return { kind: 'current-session' };
if (pathname === '/api/compression') return { kind: 'api-compression' };
if (pathname === '/api/image-source') return { kind: 'api-image-source' };
if (pathname.startsWith('/fragments/')) {
return { kind: 'fragment', name: pathname.slice('/fragments/'.length) };
}
return null;
}
// ---- inline HTML template -------------------------------------------------
const DASHBOARD_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>pxpipe Live dashboard</title>
<style>
/* Pre-mount body styling so the loading flash matches the Svelte UI. The
real stylesheet is injected by Svelte components on mount. */
* { box-sizing: border-box; }
body { margin: 0; padding: 0; background: #0d1117; color: #c9d1d9;
font: 14px/1.45 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace; }
#app:empty::before { content: 'pxpipe dashboard loading…';
display: block; padding: 24px; color: #6e7681; }
</style>
</head>
<body>
<div id="app"></div>
<script>${DASHBOARD_JS}</script>
</body></html>
`;
function htmlResponse(body: string): Response {
return new Response(body, {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
}
-140
View File
@@ -1,140 +0,0 @@
<script lang="ts">
// Root component. Owns no state of its own — every panel subscribes to its
// own slice of the polling stores. The legacy dashboard polled everything
// on the same 2s/5s cadences and rebuilt all DOM on every tick; we keep
// the cadences but let Svelte's reactivity diff at the field level.
import RecentRequests from './components/RecentRequests.svelte';
import LatestPng from './components/LatestPng.svelte';
import Sessions from './components/Sessions.svelte';
import StatsTable from './components/StatsTable.svelte';
import CompressionToggle from './components/CompressionToggle.svelte';
import SessionSummary from './components/SessionSummary.svelte';
import ToastTray from './components/ToastTray.svelte';
import { stats } from './stores/index.js';
// The footer's `port · uptime · live` line lives at the top of the
// legacy template — it tells the operator the dashboard is actually
// receiving polls. We surface it via the same store everyone else uses.
$: sub = $stats.data
? `port ${location.port || '80'} · uptime ${formatDuration($stats.data.uptime_sec)} · live`
: ($stats.error ? 'proxy unreachable' : 'connecting...');
function formatDuration(s: number): string {
s = Math.floor(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return (h ? h + 'h ' : '') + (m || h ? m + 'm ' : '') + sec + 's';
}
</script>
<h1><span class="dot"></span>pxpipe</h1>
<div class="sub">{sub}</div>
<CompressionToggle />
<SessionSummary />
<div class="row">
<div class="panel">
<h2>recent requests</h2>
<RecentRequests />
</div>
<div class="panel">
<h2>latest rendered image</h2>
<LatestPng />
</div>
</div>
<div class="panel" style="margin-bottom:22px">
<h2>sessions <span class="small" style="color:#6e7681">(top savers)</span></h2>
<Sessions />
</div>
<div class="panel" style="margin-bottom:22px">
<h2>stats <span class="small" style="color:#6e7681">(full history)</span></h2>
<StatsTable />
</div>
<ToastTray />
<style>
/* Match the legacy dashboard 1:1 — same fonts, colors, spacing. The page
looks identical pre/post rewrite to keep visual regression cheap. */
:global(body) {
margin: 0;
padding: 24px;
background: #0d1117;
color: #c9d1d9;
font:
14px/1.45 -apple-system,
BlinkMacSystemFont,
'SF Mono',
Menlo,
monospace;
}
h1 {
font-size: 18px;
font-weight: 600;
margin: 0 0 6px;
letter-spacing: -0.01em;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #3fb950;
margin-right: 6px;
vertical-align: middle;
animation: pulse 2s infinite;
}
.sub {
color: #8b949e;
font-size: 12px;
margin-bottom: 22px;
}
.row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 14px;
margin-bottom: 22px;
}
@media (max-width: 1200px) {
.row {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
.row {
grid-template-columns: 1fr;
}
}
.panel {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
padding: 14px 16px;
/* Grid items default to `min-width: auto`, which refuses to shrink a
track below its content's min-content size. A replaced element (the
latest-render <img>) has a large intrinsic width that propagates into
that minimum even through an `overflow: hidden` ancestor, blowing the
`1fr` track past the viewport. `min-width: 0` lets the track shrink so
the `width: 100%` crop box actually constrains the image. */
min-width: 0;
}
.panel :global(h2) {
font-size: 13px;
font-weight: 600;
margin: 0 0 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8b949e;
}
@keyframes pulse {
50% {
opacity: 0.4;
}
}
</style>
@@ -1,97 +0,0 @@
<script lang="ts">
// Runtime kill switch for compression. POSTs to /api/compression with the
// new state. Server returns the new state and the next tick repaints. The
// optimistic update + final reconciliation keeps the UI from flickering
// when the user mashes the button.
//
// The legacy dashboard read the current state from the button text
// ("Enable compression" / "Disable") which broke as soon as anyone
// re-styled the button. The store carries the boolean explicitly.
import { recent } from '../stores/index.js';
import { setCompressionEnabled } from '../lib/api.js';
import { toasts } from '../stores/index.js';
$: enabled = $recent.data?.compression_enabled !== false;
let busy = false;
async function toggle() {
if (busy) return;
const next = !enabled;
if (!next) {
if (
!window.confirm(
'Disable compression?\n\n/v1/messages will forward unchanged to upstream. Use this when upstream is unhealthy or to A/B test the proxy. Restart resets to enabled.',
)
) {
return;
}
}
busy = true;
try {
await setCompressionEnabled(next);
// Force an immediate refresh so the banner + button update without
// waiting for the next tick.
recent.run();
} catch (e) {
toasts.push({ level: 'error', text: 'failed to toggle: ' + (e as Error).message });
} finally {
busy = false;
}
}
</script>
{#if !enabled}
<!-- Passthrough banner. Hidden by default; shown in red when compression
is off. Matches the visual weight of the legacy dashboard so operators
glancing at the page can tell at a distance. -->
<div class="banner">
<strong>PASSTHROUGH MODE</strong> - compression disabled. Every /v1/messages
forwards unchanged to upstream. No image encoding, no break-even gate, no
transforms.
</div>
{/if}
<div class="toggle-wrap">
<button class="toggle" type="button" disabled={busy} on:click={toggle}>
{busy ? 'loading…' : enabled ? 'Disable compression' : 'Enable compression'}
</button>
<span class="hint">runtime kill switch · not persisted across restart</span>
</div>
<style>
.banner {
display: inline-block;
margin: 8px 0;
padding: 10px 14px;
background: #21262d;
border: 1px solid #f85149;
border-radius: 6px;
color: #f85149;
font-size: 12px;
}
.toggle-wrap {
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.toggle {
background: #21262d;
color: #c9d1d9;
border: 1px solid #30363d;
padding: 6px 12px;
cursor: pointer;
border-radius: 6px;
font: inherit;
font-size: 12px;
}
.toggle:disabled {
opacity: 0.5;
cursor: wait;
}
.hint {
color: #6e7681;
font-size: 11px;
}
</style>
-182
View File
@@ -1,182 +0,0 @@
<script lang="ts">
// Image viewer. When selectedImageId is null, follows the latest render
// via cache-busting on preview_meta. When pinned to an id, fetches that
// specific image from the ring; degrades gracefully if the id has been
// evicted.
import { recent, selectedImageId } from '../stores/index.js';
$: hasPreview = $recent.data?.has_preview === true;
$: meta = $recent.data?.preview_meta ?? '';
$: imageIds = $recent.data?.image_ids ?? [];
$: pinned = $selectedImageId;
// when pinned, check if the id is still resident in the ring
$: pinnedEvicted = pinned != null && !imageIds.includes(pinned);
// url for the <img> element
$: imgUrl = pinned != null
? `/proxy-latest-png?id=${pinned}`
: (hasPreview ? '/proxy-latest-png?t=' + encodeURIComponent(meta) : '');
// caption text
$: caption = pinned != null
? `image #${pinned}`
: (meta ? meta + ' - showing top-left at native resolution' : '');
// --- source-text viewer ---------------------------------------------
// Fetches /api/image-source so the operator can see the JSON/tool-result
// text that was rendered into the PNG, side by side with the image.
let showSource = false;
let sourceText: string | null = null;
let sourceErr: string | null = null;
let sourceForKey = '';
// key identifying the currently displayed image (pin id or latest meta)
$: sourceKey = pinned != null ? `id:${pinned}` : `latest:${meta}`;
async function loadSource() {
sourceForKey = sourceKey;
sourceText = null;
sourceErr = null;
try {
const url = pinned != null ? `/api/image-source?id=${pinned}` : '/api/image-source';
const res = await fetch(url);
if (!res.ok) {
sourceErr = 'source text not captured for this image';
return;
}
const body = await res.json();
sourceText = body.source_text ?? '';
} catch {
sourceErr = 'failed to fetch source text';
}
}
function toggleSource() {
showSource = !showSource;
if (showSource) void loadSource();
}
// refetch when the displayed image changes while the panel is open
$: if (showSource && sourceKey !== sourceForKey) void loadSource();
</script>
<div class="wrap">
{#if pinned != null}
<div class="pin-bar">
<button class="back-btn" on:click={() => selectedImageId.set(null)}> latest</button>
</div>
{#if pinnedEvicted}
<div class="evicted">(image #{pinned} no longer in buffer)</div>
{:else}
<div class="preview-crop">
<img src={imgUrl} alt="image #{pinned}" />
</div>
{/if}
{:else if hasPreview}
<div class="preview-crop">
<img src={imgUrl} alt="latest rendered" />
</div>
{:else}
<div class="sub">(none yet)</div>
{/if}
</div>
<div class="small">
{caption}
{#if pinned != null ? !pinnedEvicted : hasPreview}
<button class="src-btn" on:click={toggleSource}>{showSource ? 'hide source text' : 'view source text'}</button>
{/if}
</div>
{#if showSource}
{#if sourceErr}
<div class="evicted">{sourceErr}</div>
{:else if sourceText == null}
<div class="evicted">loading…</div>
{:else}
<pre class="src-pane">{sourceText}</pre>
{/if}
{/if}
<style>
.wrap {
margin-top: 0;
}
/* Crop is done client-side via CSS (object-position + overflow:hidden).
The legacy dashboard pulled a separately-cropped PNG which doubled
image traffic. The full 1466×1568 image lives on disk; we just show
the top-left corner at native res. */
.preview-crop {
width: 100%;
height: 400px;
overflow: hidden;
background: #fff;
border: 1px solid #30363d;
border-radius: 4px;
padding: 4px;
box-sizing: border-box;
}
.preview-crop img {
display: block;
width: auto;
height: auto;
max-width: none;
image-rendering: pixelated;
}
.sub {
color: #6e7681;
font-size: 12px;
}
.small {
font-size: 11px;
color: #6e7681;
margin-top: 8px;
}
.pin-bar {
margin-bottom: 8px;
}
.back-btn {
font-size: 11px;
background: #21262d;
color: #58a6ff;
border: 1px solid #30363d;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
}
.back-btn:hover {
background: #30363d;
}
.src-btn {
font-size: 11px;
background: #21262d;
color: #58a6ff;
border: 1px solid #30363d;
border-radius: 4px;
padding: 1px 6px;
margin-left: 8px;
cursor: pointer;
}
.src-btn:hover {
background: #30363d;
}
.src-pane {
margin-top: 8px;
max-height: 400px;
overflow: auto;
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 8px;
font-size: 11px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: #c9d1d9;
}
.evicted {
font-size: 11px;
color: #6e7681;
padding: 12px 0;
}
</style>
@@ -1,150 +0,0 @@
<script lang="ts">
// Ring buffer of the last N requests, polled every 2s. The legacy
// dashboard wiped the whole `<tbody>` on every tick which made shift+click
// selection and scroll position fight each other. Svelte's keyed each
// block diffs by ts and only touches changed rows.
import { recent, selectedImageId } from '../stores/index.js';
import { numFmt } from '../lib/format.js';
$: rows = $recent.data?.recent ?? [];
function statusCls(status: number): string {
if (status >= 500) return 'bad';
if (status >= 400) return 'warn';
return 'good';
}
function shortPath(p: string): string {
if (!p) return '-';
const parts = p.split('/');
return parts[parts.length - 1] || p;
}
</script>
<table>
<thead>
<tr>
<th>#</th>
<th class="num">status</th>
<th>path</th>
<th class="num">cc</th>
<th class="num">cr</th>
<th class="num">baseline</th>
<th class="num">actual</th>
<th class="num">saved</th>
<th class="num">img</th>
</tr>
</thead>
<tbody>
{#each rows.slice().reverse() as e, i (e.ts + ':' + i)}
<tr>
<td>{i + 1}</td>
<td class="num {statusCls(e.status)}">{e.status}</td>
<td class="small">{shortPath(e.path)}</td>
<td class="num">{e.cc_added ? '✓' : '-'}</td>
<td class="num">{e.cache_read != null ? numFmt(e.cache_read) : '-'}</td>
<td class="num">{e.baseline_input != null ? numFmt(e.baseline_input) : '-'}</td>
<td class="num">{e.actual_input != null ? numFmt(e.actual_input) : '-'}</td>
<td class="num pos">
{(e.session_saved_so_far_delta ?? 0) > 0
? '+' + numFmt(e.session_saved_so_far_delta ?? 0)
: '-'}
</td>
<td class="img-cell">
{#if (e.img_ids && e.img_ids.length > 0) || e.img_id != null}
{@const ids = e.img_ids ?? (e.img_id != null ? [e.img_id] : [])}
<div class="thumb-strip">
{#each ids as id}
<button type="button" class="thumb-btn" title="image #{id}"
on:click={() => selectedImageId.set(id)}>
<img class="thumb" src="/proxy-latest-png?id={id}" alt="img {id}" />
</button>
{/each}
</div>
{:else}
<span class="muted">-</span>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="9" class="small" style="color:#6e7681">no requests yet</td></tr>
{/each}
</tbody>
</table>
<style>
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th {
text-align: left;
color: #6e7681;
font-weight: 500;
padding: 6px 8px;
border-bottom: 1px solid #30363d;
}
td {
padding: 6px 8px;
border-bottom: 1px solid #21262d;
font-variant-numeric: tabular-nums;
}
tr:last-child td {
border-bottom: none;
}
th.num,
td.num {
text-align: right;
}
.small {
font-size: 11px;
color: #6e7681;
}
td.good {
color: #3fb950;
}
td.warn {
color: #d29922;
}
td.bad {
color: #f85149;
}
td.pos {
color: #3fb950;
}
.muted {
color: #6e7681;
}
.thumb-strip {
display: flex;
gap: 3px;
align-items: center;
justify-content: flex-end;
}
.thumb-btn {
padding: 0;
border: 1px solid #30363d;
border-radius: 3px;
background: #fff;
cursor: pointer;
line-height: 0;
}
.thumb-btn:hover,
.thumb-btn:focus-visible {
border-color: #58a6ff;
outline: none;
}
.thumb {
height: 28px;
width: auto;
max-width: 28px;
object-fit: cover;
object-position: top left;
display: block;
image-rendering: pixelated;
}
.img-cell {
text-align: right;
}
</style>
@@ -1,87 +0,0 @@
<script lang="ts">
// One-line headline for the current session: dollar-weighted savings ratio.
//
// Math (no cherry-pick):
// saved_$ = Σ baseline_$ Σ actual_$ over MEASURED rows only
// (honest numerator — we can only know what we saved on
// requests where the baseline probe ran)
// totalBill_$ = Σ actual_input_$ + Σ output_$ over ALL session rows
// (measured + unmeasured + passthrough — what Anthropic
// actually billed for this session)
// saved_% = saved_$ / totalBill_$ × 100
//
// `baseline_$` is the cache-aware bill Anthropic would have charged for the
// uncompressed body with the SAME cache_control markers Claude Code sent —
// measured by the counterfactual baseline probe on a sampled subset of
// requests. Caching savings stay credited to Claude Code; what remains is
// proxy-attributable savings only.
//
// Why this denominator and not Σ baseline_$? Σ baseline_$ is filtered to
// measured rows only, which cherry-picks the wins (the slice where we
// proved savings) and ignores the rest of the bill the user actually
// paid. Dividing by the full session bill matches the global
// `saved_pct_of_all_spend` math and produces a number the user can
// reconcile against an invoice.
import { currentSession } from '../stores/index.js';
$: data = $currentSession.data;
$: err = $currentSession.error;
// backend exposes raw weighted tokens; convert to $ at Opus 4.x rates.
// These MUST stay in lockstep with the server-side constant
// `ASSUMED_INPUT_USD_PER_MTOK` in src/dashboard.ts.
const INPUT_USD_PER_MTOK = 5.0;
// Numerator: honest savings over the MEASURED slice.
$: baselineTok = data?.baselineInputWeighted ?? 0;
$: actualTok = data?.actualInputWeighted ?? 0;
$: savedTok = Math.max(0, baselineTok - actualTok);
$: savedUsd = (savedTok * INPUT_USD_PER_MTOK) / 1_000_000;
// Denominator: BASELINE input $ over the same MEASURED slice. The proxy
// only touches input tokens — output is identical with/without proxy — so
// scoping both sides of the ratio to baseline input $ on measured rows
// gives an apples-to-apples "what fraction of the baseline did we save".
$: baselineUsd = (baselineTok * INPUT_USD_PER_MTOK) / 1_000_000;
$: savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;
$: measuredReqs = data?.baselineMeasuredCount ?? 0;
function fmtUsd(n: number): string {
return '$' + n.toFixed(2);
}
</script>
{#if err}
<div class="line muted">session: {err}</div>
{:else if measuredReqs > 0}
<div class="line">
<span class="label">THIS SESSION</span>
— saved <span class="num">{fmtUsd(savedUsd)}</span>
(<span class="num">{savedPct.toFixed(1)}%</span> of
<span class="muted">{fmtUsd(baselineUsd)}</span> baseline)
<span class="muted">{measuredReqs} requests</span>
</div>
{/if}
<style>
.line {
font-size: 14px;
color: #c9d1d9;
margin-bottom: 12px;
padding: 8px 12px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.label {
font-weight: 600;
color: #8b949e;
letter-spacing: 0.04em;
}
.num {
font-variant-numeric: tabular-nums;
color: #3fb950;
font-weight: 600;
}
.muted {
color: #6e7681;
}
</style>
-139
View File
@@ -1,139 +0,0 @@
<script lang="ts">
// Compact horizontal bar chart of the conversations the proxy helped most.
// Top-N sessions by estimated input tokens saved. Read-only — no navigation,
// no detail page; the bars ARE the view. Data comes from the same
// /api/sessions.json poll every other panel already pays for.
import { sessions } from '../stores/index.js';
import { shortPath } from '../lib/format.js';
import type { SessionRow } from '../types.js';
const TOP_N = 8;
// Label a session by the most human-readable handle we have: the Claude
// Code project path, then the proxy-observed cwd, then the synthetic
// first_user_sha8 prefix.
function label(s: SessionRow): string {
const proj = s.claudeCode?.projectPath || s.project;
return proj ? shortPath(proj) : s.id.slice(0, 8);
}
function fmt(n: number): string {
return Math.round(n).toLocaleString('en-US');
}
// Sort by tokens saved descending; keep the top N. The bar scale is pinned
// to the largest saver so the bars are comparable within the panel.
$: allRows = $sessions.data?.sessions ?? [];
$: rows = [...allRows]
.sort((a, b) => (b.tokensSavedEst ?? 0) - (a.tokensSavedEst ?? 0))
.slice(0, TOP_N);
$: max = rows.reduce((m, s) => Math.max(m, s.tokensSavedEst ?? 0), 0);
// Negative savings (a turn where compression net-lost) and zeros get no
// bar — the red value still tells the story.
function barPct(v: number): number {
if (max <= 0 || v <= 0) return 0;
return (v / max) * 100;
}
</script>
<div class="status">
{#if $sessions.loading && allRows.length === 0}
loading…
{:else if $sessions.error}
{$sessions.error}
{:else}
{allRows.length} session{allRows.length === 1 ? '' : 's'}
{/if}
</div>
{#if rows.length > 0}
<div class="chart">
{#each rows as s (s.id)}
<div class="bar-row">
<div
class="label"
title={s.claudeCode?.projectPath || s.project || s.id}
>{label(s)}</div>
<div class="track">
{#if barPct(s.tokensSavedEst ?? 0) > 0}
<div
class="fill"
style="width:max(3px,{barPct(s.tokensSavedEst ?? 0)}%)"
></div>
{/if}
</div>
<div class="value" class:neg={(s.tokensSavedEst ?? 0) < 0}>
{fmt(s.tokensSavedEst ?? 0)}
</div>
</div>
{/each}
</div>
<div class="axis">
input tokens saved (cache-aware) · top {rows.length} of {allRows.length}
</div>
{:else if !$sessions.loading && !$sessions.error}
<div class="empty">no sessions yet</div>
{/if}
<style>
.status {
margin-bottom: 12px;
color: #6e7681;
font-size: 12px;
}
.chart {
display: flex;
flex-direction: column;
gap: 8px;
}
.bar-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.label {
width: 132px;
flex: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #c9d1d9;
}
.track {
flex: 1;
min-width: 0;
height: 14px;
background: #21262d;
border-radius: 3px;
overflow: hidden;
}
.fill {
height: 100%;
background: #3fb950;
border-radius: 3px;
}
.value {
width: 72px;
flex: none;
text-align: right;
font-variant-numeric: tabular-nums;
color: #3fb950;
}
.value.neg {
color: #f85149;
}
.axis {
margin-top: 12px;
color: #6e7681;
font-size: 11px;
}
.empty {
text-align: center;
color: #6e7681;
padding: 24px;
font-size: 12px;
}
</style>
-432
View File
@@ -1,432 +0,0 @@
<script lang="ts">
// The five savings cards at the top of the dashboard. Each card has a
// collapsible "show calculation" block that shows the formula and the
// actual numbers that produced this turn's headline figure. The math is
// explained in src/core/baseline.ts — this component just renders it.
import { stats } from '../stores/index.js';
import { numFmt, escapeHtml, round1 } from '../lib/format.js';
$: s = $stats.data;
$: pa = s?.pricing_assumptions ?? null;
// Each card's "show calculation" body. Computed lazily so we don't pay
// for the string concat when the operator never opens the <details>.
function row(key: string, val: number | string | undefined, note?: string): string {
const v = typeof val === 'number' ? numFmt(val) : String(val ?? '-');
return (
`<div><span class="k">` +
key +
`:</span> <span class="v">` +
escapeHtml(v) +
`</span> <span class="k">` +
(note || '') +
`</span></div>`
);
}
$: savedMath = s && pa
? `<div><span class="k">formula:</span> <span class="v">saved = baseline - actual</span></div>` +
`<div><span class="k">weights:</span> <span class="v">input×1.0, cache_create×1.25, cache_read×0.10</span></div>` +
`<div style="height:6px"></div>` +
row('baseline', s.baseline_input_weighted, '(cache-aware: cacheable×weight + cold_tail)') +
row('actual', s.actual_input_weighted, '(input + cc×1.25 + cr×0.10 from usage)') +
row('saved', s.saved_input_tokens, `<span class="op">=</span> baseline - actual`) +
`<span class="src">output excluded - identical with/without compression</span>`
: '';
$: inRate = pa ? pa.input_per_mtok : 0;
$: usdMath = s && pa
? `<div><span class="k">formula:</span> <span class="v">$ saved = $</span>` +
` × ` +
inRate +
`/Mtok</div>` +
`<div style="height:6px"></div>` +
row('saved_tokens', s.saved_input_tokens, '(cache-aware, input-side)') +
row(
'saved_usd',
`$${(s.saved_usd || 0).toFixed(4)} `,
`<span class="op">=</span> saved_tokens × input_rate / 1e6`,
) +
`<span class="src">source: ${escapeHtml(pa.source || 'docs.anthropic.com pricing')}</span>`
: '';
// Diagnostic only — no longer the headline. See `split` math below for
// the headline.
$: pctMath = s && pa
? `<div><span class="k">formula:</span> <span class="v">` +
` share_of_spend = saved / (all_baseline_equivalent + all_output × ` +
(pa.output_multiplier ?? 5) +
`)</span></div>` +
`<div><span class="k">why this is diagnostic, not the headline:</span> <span class="v">` +
`this is a counterfactual ("what the user WOULD have paid"). It depends on the count_tokens probe, ` +
`the cache-aware baseline split, and an Opus 4.7 input-rate assumption. Useful as a sanity check, ` +
`but the operator's real question is "did the compressed path cost less per request than the ` +
`passthrough path on real traffic" — that's the headline split above, no counterfactuals.</span></div>` +
`<div style="height:6px"></div>` +
row('saved', s.saved_input_tokens, '(measured-rows numerator; cache-aware)') +
row(
'all_baseline_equivalent',
s.all_baseline_equivalent_weighted,
'(every paid request, weighted; baseline on measured + actual on the rest)',
) +
row(
'all_output × ' + (pa.output_multiplier ?? 5),
s.all_output_weighted,
'(every paid request, output × ' + (pa.output_multiplier ?? 5) + ')',
) +
row(
'all_counterfactual_total',
s.all_baseline_equivalent_weighted + s.all_output_weighted,
`<span class="op">=</span> all_baseline_equivalent + all_output`,
) +
row(
'share_of_spend',
(s.saved_pct_of_all_spend || 0).toFixed(1) + '%',
`<span class="op">=</span> saved / all_counterfactual_total × 100`,
) +
row(
'all_usage_requests',
s.all_usage_requests,
'(denominator request count - compressed + passthrough + probe-failed)',
) +
`<span class="src">measured numerator, all-rows counterfactual denominator - bounded at 100%</span>`
: '';
// Headline "compressed vs passthrough" math. Direct observed split — no
// counterfactuals, no probe gating. Each bucket is the sum of actual
// billed token-equivalents (input + cc×1.25 + cr×0.10 + out×5) for that
// path, converted to $ at the assumed input rate.
$: splitMath = s && pa
? `<div><span class="k">formula:</span> <span class="v">` +
`bucket_$ = (Σ actual_input + Σ output × ` + (pa.output_multiplier ?? 5) +
`) × $` + (pa.input_per_mtok ?? 5) + `/Mtok</span></div>` +
`<div><span class="k">why:</span> <span class="v">` +
`partition the paid-rows set by which path actually ran this turn ` +
`(\`info.compressed = true\` for slab/history compression; false for ` +
`passthrough or bypassed). Same $/Mtok rate on both sides so the ` +
`rate-assumption bias cancels in the delta. Selection bias (the ` +
`gate routes each turn) does NOT cancel — interpret with sample ` +
`counts.</span></div>` +
`<div style="height:6px"></div>` +
row(
'compressed (n=' + s.compressed_paid_requests + ')',
'$' + (s.compressed_actual_usd || 0).toFixed(4),
'total · avg $' + (s.compressed_avg_usd_per_request || 0).toFixed(4) + '/req',
) +
row(
'passthrough (n=' + s.passthrough_paid_requests + ')',
'$' + (s.passthrough_actual_usd || 0).toFixed(4),
'total · avg $' + (s.passthrough_avg_usd_per_request || 0).toFixed(4) + '/req',
) +
row(
'compressed passthrough',
'$' + (s.compressed_minus_passthrough_avg_usd || 0).toFixed(4) + '/req',
s.split_sufficient_sample
? '(both buckets ≥ ' + s.split_min_sample_per_bucket + ' — delta is meaningful)'
: '(small sample: need ≥ ' + s.split_min_sample_per_bucket +
' per bucket; treat delta as noisy)',
) +
`<span class="src">no counterfactual, no probe gate — pure observed $/req on each path</span>`
: '';
$: tokeqMath = s && pa
? `<div><span class="k">formula:</span> <span class="v">` +
`token_equivalent = input + output × ` +
(pa.output_multiplier ?? 5) +
`</span></div>` +
`<div><span class="k">why:</span> <span class="v">` +
`matches Anthropic's per-Mtok price ratio ($` +
(pa.input_per_mtok ?? 5) +
` input vs $` +
((pa.input_per_mtok ?? 5) * (pa.output_multiplier ?? 5)) +
` output)</span></div>` +
`<div style="height:6px"></div>` +
row('actual_input', s.actual_input_weighted, '(weighted upstream usage)') +
`<div><span class="k">+</span> <span class="v">` +
`<span class="op">=</span> raw output_tokens (already weighted)</span></div>` +
row('actual_token_equivalent', s.actual_token_equivalent) +
row(
'baseline_token_equivalent',
s.baseline_token_equivalent,
'(unproxied counterfactual, same ' + ' × ' + (pa.output_multiplier ?? 5) + ' on output)',
) +
`<div style="height:6px"></div>` +
`<div><span class="k">measured vs billed:</span> <span class="v">` +
`we now SSE-tee response bodies + count text_delta / thinking_delta / tool_use chars ` +
`so you can compare what we actually saw on the wire against output_tokens. ` +
`The redacted_thinking block count is included because Anthropic ships those ` +
`as opaque server-encrypted bytes with no char count — output_tokens ` +
`invisibly. This is what surfaced the May-2026 weekly-meter gap.</span></div>` +
`<div style="height:6px"></div>` +
row(
'events_with_measurement',
s.events_with_measurement,
'(events where SSE/JSON scanner produced char counts)',
) +
row(
'measured_text_chars',
s.measured_text_chars,
'(content_block_delta · text_delta + response content[].text)',
) +
row(
'measured_thinking_chars',
s.measured_thinking_chars,
'(content_block_delta · thinking_delta + response reasoning text)',
) +
row(
'measured_tool_use_chars',
s.measured_tool_use_chars,
'(content_block_delta · input_json_delta + tool_use blocks)',
) +
row(
'measured_redacted_blocks',
s.measured_redacted_block_count,
'(opaque encrypted blocks - chars unavailable, billed but unmeasurable)',
) +
`<span class="src">measured - no estimation</span>`
: '';
</script>
<div class="grid">
<div class="card">
<div class="label">requests</div>
<div class="value">{numFmt(s?.requests)}</div>
<div class="small">{numFmt(s?.compressed_requests)} compressed</div>
</div>
<div class="card">
<div class="label">input tokens saved</div>
<div class="value pos">{numFmt(s?.saved_input_tokens)}</div>
<div class="small">cache-aware, input-side only</div>
{#if s && pa}
<details class="math">
<summary>show calculation</summary>
<div class="formula">{@html savedMath}</div>
</details>
{/if}
</div>
<div class="card">
<div class="label">$ saved</div>
<div class="value">$ {(s?.saved_usd ?? 0).toFixed(2)}</div>
<div class="small">at $5/M input tokens (Opus 4.7)</div>
{#if s && pa}
<details class="math">
<summary>show calculation</summary>
<div class="formula">{@html usdMath}</div>
</details>
{/if}
</div>
<div class="card">
<div class="label">compressed $/req</div>
<div class="value">$ {(s?.compressed_avg_usd_per_request ?? 0).toFixed(4)}</div>
<div class="small">
n={numFmt(s?.compressed_paid_requests)} · total $ {(s?.compressed_actual_usd ?? 0).toFixed(4)}
</div>
</div>
<div class="card">
<div class="label">passthrough $/req</div>
<div class="value">$ {(s?.passthrough_avg_usd_per_request ?? 0).toFixed(4)}</div>
<div class="small">
n={numFmt(s?.passthrough_paid_requests)} · total $ {(s?.passthrough_actual_usd ?? 0).toFixed(4)}
</div>
</div>
<div class="card">
<div class="label">compressed passthrough $/req</div>
{#if s?.split_sufficient_sample}
<div class="value" class:pos={(s?.compressed_minus_passthrough_avg_usd ?? 0) <= 0} class:neg={(s?.compressed_minus_passthrough_avg_usd ?? 0) > 0}>
{((s?.compressed_minus_passthrough_avg_usd ?? 0) >= 0 ? '+$ ' : '-$ ')}{Math.abs(s?.compressed_minus_passthrough_avg_usd ?? 0).toFixed(4)}
</div>
<div class="small">negative = compressed path cheaper · both buckets ≥ {s?.split_min_sample_per_bucket}</div>
{:else}
<div class="value small-sample">small sample</div>
<div class="small">
need ≥ {s?.split_min_sample_per_bucket} requests per bucket · have
{numFmt(s?.compressed_paid_requests)} / {numFmt(s?.passthrough_paid_requests)}
</div>
{/if}
{#if s && pa}
<details class="math">
<summary>show calculation</summary>
<div class="formula">{@html splitMath}</div>
</details>
{/if}
</div>
<div class="card">
<div class="label">token-equivalent total</div>
<div class="value">{numFmt(s?.actual_token_equivalent)}</div>
<div class="small">input + 5×output billed at 5× input rate</div>
{#if s && pa}
<details class="math">
<summary>show calculation</summary>
<div class="formula">{@html tokeqMath}</div>
</details>
{/if}
</div>
</div>
{#if s && pa}
<details class="diagnostic">
<summary>diagnostic: counterfactual "share of spend saved"</summary>
<div class="diagnostic-body">
<div class="diag-headline">
share of spend saved (counterfactual):
<span class:pos={(s?.saved_pct_of_all_spend ?? 0) >= 0} class:neg={(s?.saved_pct_of_all_spend ?? 0) < 0}>
{(s?.saved_pct_of_all_spend ?? 0).toFixed(1)}%
</span>
<span class="small">
({numFmt(s?.all_usage_requests)} paid req · compressed + passthrough + probe-failed)
</span>
</div>
<div class="formula">{@html pctMath}</div>
</div>
</details>
{/if}
<style>
.grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
margin-bottom: 22px;
}
@media (max-width: 1400px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
padding: 14px 16px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8b949e;
margin-bottom: 10px;
}
.value {
font-size: 24px;
font-weight: 600;
color: #e6edf3;
font-variant-numeric: tabular-nums;
}
.value.pos {
color: #3fb950;
}
.value.neg {
color: #f85149;
}
.value.small-sample {
color: #8b949e;
font-size: 18px;
font-weight: 500;
}
.diagnostic {
margin: 0 0 22px;
font-size: 12px;
color: #8b949e;
}
.diagnostic :global(summary) {
cursor: pointer;
user-select: none;
color: #58a6ff;
padding: 6px 0;
}
.diagnostic :global(summary::-webkit-details-marker) {
display: none;
}
.diagnostic :global(summary::before) {
content: '▸ ';
color: #6e7681;
font-size: 9px;
}
.diagnostic :global([open] summary::before) {
content: '▾ ';
}
.diagnostic-body {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px 14px;
margin-top: 6px;
}
.diag-headline {
color: #c9d1d9;
margin-bottom: 8px;
}
.diag-headline .pos {
color: #3fb950;
font-weight: 600;
}
.diag-headline .neg {
color: #f85149;
font-weight: 600;
}
.small {
font-size: 11px;
color: #6e7681;
margin-top: 4px;
}
.math {
margin-top: 10px;
font-size: 11px;
}
.math :global(summary) {
cursor: pointer;
user-select: none;
color: #58a6ff;
}
.math :global(summary::-webkit-details-marker) {
display: none;
}
.math :global(summary::before) {
content: '▸ ';
color: #6e7681;
font-size: 9px;
}
.math :global([open] summary::before) {
content: '▾ ';
}
.math :global(summary:hover) {
color: #79c0ff;
}
.formula {
background: #0d1117;
border: 1px solid #21262d;
border-radius: 6px;
padding: 8px 10px;
margin-top: 6px;
font:
11px/1.5 'SF Mono',
Menlo,
monospace;
color: #c9d1d9;
white-space: pre-wrap;
word-break: break-word;
}
.formula :global(.k) {
color: #8b949e;
}
.formula :global(.v) {
color: #e6edf3;
}
.formula :global(.op) {
color: #f0883e;
}
.formula :global(.src) {
color: #6e7681;
font-size: 10px;
display: block;
margin-top: 6px;
border-top: 1px solid #21262d;
padding-top: 6px;
}
</style>
-133
View File
@@ -1,133 +0,0 @@
<script lang="ts">
// Full-history aggregate table - same shape as the legacy "stats" panel.
// Sourced from /api/stats.json which re-runs the aggregation on every
// poll. Cheap because the JSONL is line-streamed.
import { stats } from '../stores/index.js';
import { numFmt, fmtBytes, round1 } from '../lib/format.js';
$: payload = $stats.data;
$: err = payload?.error;
$: s = payload?.summary;
// Hit rate / char ratio are derived numbers - keep the math here rather
// than baking them into the API response so the formulas are visible.
$: hitRateTok = (() => {
if (!s) return '-';
const totalIn =
(s.inputTokensTotal || 0) + (s.cacheCreateTokensTotal || 0) + (s.cacheReadTokensTotal || 0);
return totalIn > 0 ? ((s.cacheReadTokensTotal / totalIn) * 100).toFixed(1) + '%' : '-';
})();
$: hitRateEv = (() => {
if (!s) return '-';
return s.eventsWithBaseline > 0
? ((s.cacheHitEvents / s.eventsWithBaseline) * 100).toFixed(1) + '%'
: '-';
})();
$: charRatio = (() => {
if (!s) return '-';
return s.origCharsTotal > 0
? ((s.imageBytesTotal / s.origCharsTotal) * 100).toFixed(3) + 'x'
: '-';
})();
</script>
<div class="status">
{#if $stats.loading && !payload}
loading…
{:else if err}
{err}
{:else if payload}
{numFmt(payload.parsed)} events parsed
{:else}
-
{/if}
</div>
<table>
<tbody>
{#if s}
<tr>
<td>requests</td>
<td class="num">{numFmt(s.total)}</td>
</tr>
<tr>
<td>2xx / 4xx / 5xx</td>
<td class="num">{numFmt(s.ok2xx)} / {numFmt(s.err4xx)} / {numFmt(s.err5xx)}</td>
</tr>
<tr>
<td>compressed</td>
<td class="num">{numFmt(s.compressed)}</td>
</tr>
<tr>
<td>passthrough</td>
<td class="num">{numFmt(s.passthrough)}</td>
</tr>
<tr>
<td>input tokens</td>
<td class="num">{numFmt(s.inputTokensTotal)}</td>
</tr>
<tr>
<td>cache create</td>
<td class="num">{numFmt(s.cacheCreateTokensTotal)}</td>
</tr>
<tr>
<td>cache read</td>
<td class="num">{numFmt(s.cacheReadTokensTotal)}</td>
</tr>
<tr>
<td>cache hit (tok)</td>
<td class="num">{hitRateTok}</td>
</tr>
<tr>
<td>cache hit (ev)</td>
<td class="num">{hitRateEv}</td>
</tr>
<tr>
<td>orig chars</td>
<td class="num">{numFmt(s.origCharsTotal)}</td>
</tr>
<tr>
<td>image bytes</td>
<td class="num">{numFmt(s.imageBytesTotal)}</td>
</tr>
<tr>
<td>bytes/char</td>
<td class="num">{charRatio}</td>
</tr>
<tr>
<td>latency p50/p95</td>
<td class="num">{numFmt(s.durationP50)} / {numFmt(s.durationP95)} ms</td>
</tr>
<tr>
<td>first-byte p50/p95</td>
<td class="num">{numFmt(s.firstBytemsP50)} / {numFmt(s.firstBytemsP95)} ms</td>
</tr>
{/if}
</tbody>
</table>
<style>
.status {
margin-bottom: 12px;
color: #6e7681;
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
td {
padding: 6px 8px;
border-bottom: 1px solid #21262d;
vertical-align: top;
font-variant-numeric: tabular-nums;
}
td:last-child {
border-bottom: none;
}
.num {
text-align: right;
}
</style>
-63
View File
@@ -1,63 +0,0 @@
<script lang="ts">
// Bottom-right toast tray. Subscribes to the toast store and renders
// up to N visible items. Each toast carries its own timeout (set by the
// store) so we don't need a timer here.
import { toasts } from '../stores/index.js';
</script>
<div class="tray">
{#each $toasts as t (t.id)}
<div class="toast {t.level}">
<span>{t.text}</span>
<button type="button" on:click={() => toasts.dismiss(t.id)} aria-label="dismiss">×</button>
</div>
{/each}
</div>
<style>
.tray {
position: fixed;
bottom: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
pointer-events: none;
}
.toast {
background: #21262d;
color: #c9d1d9;
border: 1px solid #30363d;
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
max-width: 360px;
}
.toast.error {
border-color: #f85149;
color: #f85149;
}
.toast.warn {
border-color: #d29922;
color: #d29922;
}
.toast.info {
border-color: #58a6ff;
}
button {
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
}
</style>
+594
View File
@@ -0,0 +1,594 @@
// Server-rendered HTML for the dashboard - the AHA-without-the-A stack:
// HTML fragments over the wire (htmx polls + swaps), Alpine for the little
// bits of client state (toast tray). No client bundle, no build step - the
// browser receives finished markup from the same process that owns the data.
//
// Layout, classes, colors and copy match the retired Svelte components 1:1
// so the page is a visual no-op pre/post rewrite. Each former component is
// now a render*Fragment() function; the polling cadence (2s live, 5s slow)
// moved from Svelte stores to `hx-trigger="every Ns"` attributes.
import { HTMX_JS, ALPINE_JS } from './vendor.js';
import type {
StatsPayload,
RecentPayload,
RecentRow,
SessionsPayload,
SessionRow,
FullStatsPayload,
CurrentSessionPayload,
} from './types.js';
// ---- tiny helpers (formerly src/dashboard/lib/format.ts) ----------------
export function escapeHtml(s: string | null | undefined): string {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string
));
}
function numFmt(n: number | null | undefined): string {
const v = Math.round(Number(n) || 0);
return v.toLocaleString('en-US');
}
function formatDuration(s: number): string {
s = Math.floor(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return (h ? h + 'h ' : '') + (m || h ? m + 'm ' : '') + sec + 's';
}
function shortPath(p: string | null | undefined): string {
if (!p) return '-';
const parts = String(p).split('/');
return parts[parts.length - 1] || p;
}
// ---- compression toggle + passthrough banner -----------------------------
export function renderToggleFragment(enabled: boolean): string {
const banner = enabled
? ''
: `<div class="banner"><strong>PASSTHROUGH MODE</strong> - compression disabled. Every /v1/messages forwards unchanged to upstream. No image encoding, no break-even gate, no transforms.</div>`;
// The button POSTs the OPPOSITE of the rendered state - the fragment is
// re-rendered with fresh state on every poll, so the value can't go stale
// (the legacy UI read state out of the button label; the Svelte UI carried
// it in a store; here the server just bakes it into the markup).
const confirm = enabled
? ` hx-confirm="Disable compression?\n\n/v1/messages will forward unchanged to upstream. Use this when upstream is unhealthy or to A/B test the proxy. Restart resets to enabled."`
: '';
return (
banner +
`<div class="toggle-wrap">` +
`<button class="toggle" type="button" hx-post="/fragments/toggle" hx-target="#frag-toggle" hx-vals='{"enabled": ${!enabled}}'${confirm}>` +
(enabled ? 'Disable compression' : 'Enable compression') +
`</button>` +
`<span class="hint">runtime kill switch &middot; not persisted across restart</span>` +
`</div>`
);
}
// ---- current-session headline --------------------------------------------
// MUST stay in lockstep with the server-side `ASSUMED_INPUT_USD_PER_MTOK`
// in src/dashboard.ts (see that constant's comment for the rate rationale).
const INPUT_USD_PER_MTOK = 10.0;
export function renderSessionSummaryFragment(data: CurrentSessionPayload): string {
const measured = data.baselineMeasuredCount ?? 0;
if (measured <= 0) return '';
const baselineTok = data.baselineInputWeighted ?? 0;
const actualTok = data.actualInputWeighted ?? 0;
const savedTok = Math.max(0, baselineTok - actualTok);
const savedUsd = (savedTok * INPUT_USD_PER_MTOK) / 1e6;
const baselineUsd = (baselineTok * INPUT_USD_PER_MTOK) / 1e6;
const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;
return (
`<div class="line">` +
`<span class="label">THIS SESSION</span>` +
` &mdash; saved <span class="num">$${savedUsd.toFixed(2)}</span>` +
` (<span class="num">${savedPct.toFixed(1)}%</span> of` +
` <span class="muted">$${baselineUsd.toFixed(2)}</span> baseline)` +
` &mdash; <span class="muted">${measured} requests</span>` +
`</div>`
);
}
// ---- stats header: sub-line + savings cards + diagnostic ------------------
function mathRow(key: string, val: number | string | undefined, note = ''): string {
const v = typeof val === 'number' ? numFmt(val) : String(val ?? '-');
return `<div><span class="k">${key}:</span> <span class="v">${escapeHtml(v)}</span> <span class="k">${note}</span></div>`;
}
// `id` keeps <details> open state across htmx swaps (see glue script in the
// page shell - open ids are recorded before swap and restored after).
function details(id: string, summary: string, body: string, cls = 'math'): string {
return `<details class="${cls}" id="${id}"><summary>${summary}</summary><div class="formula">${body}</div></details>`;
}
export function renderHeaderFragment(s: StatsPayload, port: number): string {
const pa = s.pricing_assumptions;
const sub = `port ${port} &middot; uptime ${formatDuration(s.uptime_sec)} &middot; live`;
const savedMath =
`<div><span class="k">formula:</span> <span class="v">saved = baseline - actual</span></div>` +
`<div><span class="k">weights:</span> <span class="v">input&times;1.0, cache_create&times;1.25, cache_read&times;0.10</span></div>` +
`<div style="height:6px"></div>` +
mathRow('baseline', s.baseline_input_weighted, '(cache-aware: cacheable&times;weight + cold_tail)') +
mathRow('actual', s.actual_input_weighted, '(input + cc&times;1.25 + cr&times;0.10 from usage)') +
mathRow('saved', s.saved_input_tokens, `<span class="op">=</span> baseline - actual`) +
`<span class="src">output excluded - identical with/without compression</span>`;
const usdMath =
`<div><span class="k">formula:</span> <span class="v">$ saved = saved_tokens &times; $${pa.input_per_mtok}/Mtok</span></div>` +
`<div style="height:6px"></div>` +
mathRow('saved_tokens', s.saved_input_tokens, '(cache-aware, input-side)') +
mathRow('saved_usd', `$${(s.saved_usd || 0).toFixed(4)} `, `<span class="op">=</span> saved_tokens &times; input_rate / 1e6`) +
`<span class="src">source: ${escapeHtml(pa.source || 'docs.anthropic.com pricing')}</span>`;
const splitMath =
`<div><span class="k">formula:</span> <span class="v">bucket_$ = (&Sigma; actual_input + &Sigma; output &times; ${pa.output_multiplier}) &times; $${pa.input_per_mtok}/Mtok</span></div>` +
`<div><span class="k">why:</span> <span class="v">partition the paid-rows set by which path actually ran this turn (\`info.compressed = true\` for slab/history compression; false for passthrough or bypassed). Same $/Mtok rate on both sides so the rate-assumption bias cancels in the delta. Selection bias (the gate routes each turn) does NOT cancel - interpret with sample counts.</span></div>` +
`<div style="height:6px"></div>` +
mathRow(`compressed (n=${s.compressed_paid_requests})`, `$${(s.compressed_actual_usd || 0).toFixed(4)}`, `total &middot; avg $${(s.compressed_avg_usd_per_request || 0).toFixed(4)}/req`) +
mathRow(`passthrough (n=${s.passthrough_paid_requests})`, `$${(s.passthrough_actual_usd || 0).toFixed(4)}`, `total &middot; avg $${(s.passthrough_avg_usd_per_request || 0).toFixed(4)}/req`) +
mathRow(
'compressed - passthrough',
`$${(s.compressed_minus_passthrough_avg_usd || 0).toFixed(4)}/req`,
s.split_sufficient_sample
? `(both buckets &ge; ${s.split_min_sample_per_bucket} - delta is meaningful)`
: `(small sample: need &ge; ${s.split_min_sample_per_bucket} per bucket; treat delta as noisy)`,
) +
`<span class="src">no counterfactual, no probe gate - pure observed $/req on each path</span>`;
const tokeqMath =
`<div><span class="k">formula:</span> <span class="v">token_equivalent = input + output &times; ${pa.output_multiplier}</span></div>` +
`<div><span class="k">why:</span> <span class="v">matches Anthropic's per-Mtok price ratio ($${pa.input_per_mtok} input vs $${pa.input_per_mtok * pa.output_multiplier} output)</span></div>` +
`<div style="height:6px"></div>` +
mathRow('actual_input', s.actual_input_weighted, '(weighted upstream usage)') +
mathRow('actual_token_equivalent', s.actual_token_equivalent) +
mathRow('baseline_token_equivalent', s.baseline_token_equivalent, `(unproxied counterfactual, same &times;${pa.output_multiplier} on output)`) +
`<div style="height:6px"></div>` +
mathRow('events_with_measurement', s.events_with_measurement, '(events where SSE/JSON scanner produced char counts)') +
mathRow('measured_text_chars', s.measured_text_chars, '(content_block_delta &middot; text_delta + response content[].text)') +
mathRow('measured_thinking_chars', s.measured_thinking_chars, '(content_block_delta &middot; thinking_delta + response reasoning text)') +
mathRow('measured_tool_use_chars', s.measured_tool_use_chars, '(content_block_delta &middot; input_json_delta + tool_use blocks)') +
mathRow('measured_redacted_blocks', s.measured_redacted_block_count, '(opaque encrypted blocks - chars unavailable, billed but unmeasurable)') +
`<span class="src">measured - no estimation</span>`;
const pctMath =
`<div><span class="k">formula:</span> <span class="v">share_of_spend = saved / (all_baseline_equivalent + all_output &times; ${pa.output_multiplier})</span></div>` +
`<div><span class="k">why this is diagnostic, not the headline:</span> <span class="v">this is a counterfactual ("what the user WOULD have paid"). It depends on the count_tokens probe, the cache-aware baseline split, and an input-rate assumption. Useful as a sanity check, but the operator's real question is "did the compressed path cost less per request than the passthrough path on real traffic" - that's the headline split above, no counterfactuals.</span></div>` +
`<div style="height:6px"></div>` +
mathRow('saved', s.saved_input_tokens, '(measured-rows numerator; cache-aware)') +
mathRow('all_baseline_equivalent', s.all_baseline_equivalent_weighted, '(every paid request, weighted; baseline on measured + actual on the rest)') +
mathRow(`all_output &times; ${pa.output_multiplier}`, s.all_output_weighted, `(every paid request, output &times; ${pa.output_multiplier})`) +
mathRow('all_counterfactual_total', s.all_baseline_equivalent_weighted + s.all_output_weighted, `<span class="op">=</span> all_baseline_equivalent + all_output`) +
mathRow('share_of_spend', (s.saved_pct_of_all_spend || 0).toFixed(1) + '%', `<span class="op">=</span> saved / all_counterfactual_total &times; 100`) +
mathRow('all_usage_requests', s.all_usage_requests, '(denominator request count - compressed + passthrough + probe-failed)') +
`<span class="src">measured numerator, all-rows counterfactual denominator - bounded at 100%</span>`;
const delta = s.compressed_minus_passthrough_avg_usd ?? 0;
const splitCard = s.split_sufficient_sample
? `<div class="value ${delta <= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+$ ' : '-$ '}${Math.abs(delta).toFixed(4)}</div>` +
`<div class="small">negative = compressed path cheaper &middot; both buckets &ge; ${s.split_min_sample_per_bucket}</div>`
: `<div class="value small-sample">small sample</div>` +
`<div class="small">need &ge; ${s.split_min_sample_per_bucket} requests per bucket &middot; have ${numFmt(s.compressed_paid_requests)} / ${numFmt(s.passthrough_paid_requests)}</div>`;
return (
`<div class="sub">${sub}</div>` +
`<div class="grid">` +
`<div class="card"><div class="label">requests</div><div class="value">${numFmt(s.requests)}</div><div class="small">&mdash; ${numFmt(s.compressed_requests)} compressed</div></div>` +
`<div class="card"><div class="label">input tokens saved</div><div class="value pos">${numFmt(s.saved_input_tokens)}</div><div class="small">cache-aware, input-side only</div>${details('math-saved', 'show calculation', savedMath)}</div>` +
`<div class="card"><div class="label">$ saved</div><div class="value">$ ${(s.saved_usd ?? 0).toFixed(2)}</div><div class="small">at $${pa.input_per_mtok}/M input tokens</div>${details('math-usd', 'show calculation', usdMath)}</div>` +
`<div class="card"><div class="label">compressed $/req</div><div class="value">$ ${(s.compressed_avg_usd_per_request ?? 0).toFixed(4)}</div><div class="small">n=${numFmt(s.compressed_paid_requests)} &middot; total $ ${(s.compressed_actual_usd ?? 0).toFixed(4)}</div></div>` +
`<div class="card"><div class="label">passthrough $/req</div><div class="value">$ ${(s.passthrough_avg_usd_per_request ?? 0).toFixed(4)}</div><div class="small">n=${numFmt(s.passthrough_paid_requests)} &middot; total $ ${(s.passthrough_actual_usd ?? 0).toFixed(4)}</div></div>` +
`<div class="card"><div class="label">compressed - passthrough $/req</div>${splitCard}${details('math-split', 'show calculation', splitMath)}</div>` +
`</div>` +
details(
'diag-pct',
'diagnostic: counterfactual "share of spend saved"',
`<div class="diag-headline">share of spend saved (counterfactual): <span class="${(s.saved_pct_of_all_spend ?? 0) >= 0 ? 'pos' : 'neg'}">${(s.saved_pct_of_all_spend ?? 0).toFixed(1)}%</span> <span class="small">(${numFmt(s.all_usage_requests)} paid req &middot; compressed + passthrough + probe-failed)</span></div>` +
pctMath,
'diagnostic',
) +
details('diag-tokeq', `token-equivalent total: ${numFmt(s.actual_token_equivalent)} (input + ${pa.output_multiplier}&times;output)`, tokeqMath, 'diagnostic')
);
}
// ---- recent requests table ------------------------------------------------
function statusCls(status: number): string {
if (status >= 500) return 'bad';
if (status >= 400) return 'warn';
return 'good';
}
export function renderRecentFragment(p: RecentPayload): string {
const rows = (p.recent ?? []).slice().reverse();
const body =
rows.length === 0
? `<tr><td colspan="9" class="small" style="color:#6e7681">no requests yet</td></tr>`
: rows
.map((e: RecentRow, i: number) => {
const ids = e.img_ids ?? (e.img_id != null ? [e.img_id] : []);
const thumbs =
ids.length > 0
? `<div class="thumb-strip">` +
ids
.map(
(id) =>
`<button type="button" class="thumb-btn" title="image #${id}" onclick="ppPin(${id})"><img class="thumb" src="/proxy-latest-png?id=${id}" alt="img ${id}" /></button>`,
)
.join('') +
`</div>`
: `<span class="muted">-</span>`;
const saved = e.session_saved_so_far_delta ?? 0;
return (
`<tr>` +
`<td>${i + 1}</td>` +
`<td class="num ${statusCls(e.status)}">${e.status}</td>` +
`<td class="small">${escapeHtml(shortPath(e.path))}</td>` +
`<td class="num">${e.cc_added ? '&check;' : '-'}</td>` +
`<td class="num">${e.cache_read != null ? numFmt(e.cache_read) : '-'}</td>` +
`<td class="num">${e.baseline_input != null ? numFmt(e.baseline_input) : '-'}</td>` +
`<td class="num">${e.actual_input != null ? numFmt(e.actual_input) : '-'}</td>` +
`<td class="num pos">${saved > 0 ? '+' + numFmt(saved) : '-'}</td>` +
`<td class="img-cell">${thumbs}</td>` +
`</tr>`
);
})
.join('');
return (
`<table><thead><tr><th>#</th><th class="num">status</th><th>path</th><th class="num">cc</th><th class="num">cr</th><th class="num">baseline</th><th class="num">actual</th><th class="num">saved</th><th class="num">img</th></tr></thead>` +
`<tbody>${body}</tbody></table>`
);
}
// ---- latest rendered image ------------------------------------------------
export interface LatestFragmentInput {
payload: RecentPayload;
/** pinned image id, or null to follow the latest render */
pin: number | null;
/** render the source-text pane inline */
showSource: boolean;
/** resolved source text for the displayed image; null = not captured */
sourceText: string | null;
}
export function renderLatestFragment(inp: LatestFragmentInput): string {
const { payload, pin, showSource, sourceText } = inp;
const hasPreview = payload.has_preview === true;
const meta = payload.preview_meta ?? '';
const imageIds = payload.image_ids ?? [];
const pinnedEvicted = pin != null && !imageIds.includes(pin);
let viewer: string;
if (pin != null) {
viewer =
`<div class="pin-bar"><button class="back-btn" type="button" onclick="ppPin(null)">&larr; latest</button></div>` +
(pinnedEvicted
? `<div class="evicted">(image #${pin} no longer in buffer)</div>`
: `<div class="preview-crop"><img src="/proxy-latest-png?id=${pin}" alt="image #${pin}" /></div>`);
} else if (hasPreview) {
viewer = `<div class="preview-crop"><img src="/proxy-latest-png?t=${encodeURIComponent(meta)}" alt="latest rendered" /></div>`;
} else {
viewer = `<div class="sub">(none yet)</div>`;
}
const caption = pin != null ? `image #${pin}` : meta ? escapeHtml(meta) + ' - showing top-left at native resolution' : '';
const showBtn = pin != null ? !pinnedEvicted : hasPreview;
const srcBtn = showBtn
? `<button class="src-btn" type="button" onclick="ppSource(${showSource ? 'false' : 'true'})">${showSource ? 'hide source text' : 'view source text'}</button>`
: '';
let srcPane = '';
if (showSource) {
srcPane =
sourceText == null
? `<div class="evicted">source text not captured for this image</div>`
: `<pre class="src-pane">${escapeHtml(sourceText)}</pre>`;
}
return `<div class="wrap">${viewer}</div><div class="small">${caption} ${srcBtn}</div>${srcPane}`;
}
// ---- sessions bar chart ---------------------------------------------------
const TOP_N = 8;
export function renderSessionsFragment(p: SessionsPayload): string {
const all = p.sessions ?? [];
const rows = [...all]
.sort((a, b) => (b.tokensSavedEst ?? 0) - (a.tokensSavedEst ?? 0))
.slice(0, TOP_N);
const max = rows.reduce((m, s) => Math.max(m, s.tokensSavedEst ?? 0), 0);
const label = (s: SessionRow) => {
const proj = s.claudeCode?.projectPath || s.project;
return proj ? shortPath(proj) : s.id.slice(0, 8);
};
const barPct = (v: number) => (max <= 0 || v <= 0 ? 0 : (v / max) * 100);
const status = `<div class="status">${all.length} session${all.length === 1 ? '' : 's'}</div>`;
if (rows.length === 0) return status + `<div class="empty">no sessions yet</div>`;
const chart = rows
.map((s) => {
const v = s.tokensSavedEst ?? 0;
const pct = barPct(v);
const fill = pct > 0 ? `<div class="fill" style="width:max(3px,${pct}%)"></div>` : '';
return (
`<div class="bar-row">` +
`<div class="blabel" title="${escapeHtml(s.claudeCode?.projectPath || s.project || s.id)}">${escapeHtml(label(s))}</div>` +
`<div class="track">${fill}</div>` +
`<div class="bvalue${v < 0 ? ' neg' : ''}">${numFmt(v)}</div>` +
`</div>`
);
})
.join('');
return (
status +
`<div class="chart">${chart}</div>` +
`<div class="axis">input tokens saved (cache-aware) &middot; top ${rows.length} of ${all.length}</div>`
);
}
// ---- full-history stats table ---------------------------------------------
export function renderStatsTableFragment(p: FullStatsPayload): string {
if (p.error || !p.summary) {
return `<div class="status">${escapeHtml(p.error || 'no data')}</div><table><tbody></tbody></table>`;
}
const s = p.summary;
const totalIn = (s.inputTokensTotal || 0) + (s.cacheCreateTokensTotal || 0) + (s.cacheReadTokensTotal || 0);
const hitRateTok = totalIn > 0 ? ((s.cacheReadTokensTotal / totalIn) * 100).toFixed(1) + '%' : '-';
const hitRateEv =
s.eventsWithBaseline > 0 ? ((s.cacheHitEvents / s.eventsWithBaseline) * 100).toFixed(1) + '%' : '-';
const charRatio =
s.origCharsTotal > 0 ? ((s.imageBytesTotal / s.origCharsTotal) * 100).toFixed(3) + 'x' : '-';
const tr = (k: string, v: string) => `<tr><td>${k}</td><td class="num">${v}</td></tr>`;
return (
`<div class="status">${numFmt(p.parsed)} events parsed</div>` +
`<table><tbody>` +
tr('requests', numFmt(s.total)) +
tr('2xx / 4xx / 5xx', `${numFmt(s.ok2xx)} / ${numFmt(s.err4xx)} / ${numFmt(s.err5xx)}`) +
tr('compressed', numFmt(s.compressed)) +
tr('passthrough', numFmt(s.passthrough)) +
tr('input tokens', numFmt(s.inputTokensTotal)) +
tr('cache create', numFmt(s.cacheCreateTokensTotal)) +
tr('cache read', numFmt(s.cacheReadTokensTotal)) +
tr('cache hit (tok)', hitRateTok) +
tr('cache hit (ev)', hitRateEv) +
tr('orig chars', numFmt(s.origCharsTotal)) +
tr('image bytes', numFmt(s.imageBytesTotal)) +
tr('bytes/char', charRatio) +
tr('latency p50/p95', `${numFmt(s.durationP50)} / ${numFmt(s.durationP95)} ms`) +
tr('first-byte p50/p95', `${numFmt(s.firstByteP50)} / ${numFmt(s.firstByteP95)} ms`) +
`</tbody></table>`
);
}
// ---- page shell -------------------------------------------------------------
const CSS = `
body { margin: 0; padding: 24px; background: #0d1117; color: #c9d1d9;
font: 14px/1.45 -apple-system, BlinkMacSystemFont, 'SF Mono', Menlo, monospace; }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: #3fb950; margin-right: 6px; vertical-align: middle; animation: pulse 2s infinite; }
@keyframes pulse { 50% { opacity: 0.4; } }
.sub { color: #8b949e; font-size: 12px; margin-bottom: 22px; }
.row { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; margin-bottom: 22px; }
@media (max-width: 1200px) { .row { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
padding: 14px 16px; min-width: 0; }
.panel h2 { font-size: 13px; font-weight: 600; margin: 0 0 14px; text-transform: uppercase;
letter-spacing: 0.08em; color: #8b949e; }
.small { font-size: 11px; color: #6e7681; margin-top: 4px; }
.muted { color: #6e7681; }
/* toggle */
.banner { display: inline-block; margin: 8px 0; padding: 10px 14px; background: #21262d;
border: 1px solid #f85149; border-radius: 6px; color: #f85149; font-size: 12px; }
.toggle-wrap { margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
.toggle { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 6px 12px;
cursor: pointer; border-radius: 6px; font: inherit; font-size: 12px; }
.toggle:disabled { opacity: 0.5; cursor: wait; }
.hint { color: #6e7681; font-size: 11px; }
/* session summary */
#frag-session .line { font-size: 14px; color: #c9d1d9; margin-bottom: 12px; padding: 8px 12px;
background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
#frag-session .label { font-weight: 600; color: #8b949e; letter-spacing: 0.04em; }
#frag-session .num { font-variant-numeric: tabular-nums; color: #3fb950; font-weight: 600; }
/* stats header cards */
.grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin-bottom: 22px; }
@media (max-width: 1400px) { .grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 900px) { .grid { grid-template-columns: repeat(2, 1fr); } }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 14px 16px; }
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
color: #8b949e; margin-bottom: 10px; }
.value { font-size: 24px; font-weight: 600; color: #e6edf3; font-variant-numeric: tabular-nums; }
.value.pos { color: #3fb950; } .value.neg { color: #f85149; }
.value.small-sample { color: #8b949e; font-size: 18px; font-weight: 500; }
details.math { margin-top: 10px; font-size: 11px; }
details.diagnostic { margin: 0 0 22px; font-size: 12px; color: #8b949e; }
details summary { cursor: pointer; user-select: none; color: #58a6ff; }
details summary::-webkit-details-marker { display: none; }
details summary::before { content: '\\25B8 '; color: #6e7681; font-size: 9px; }
details[open] summary::before { content: '\\25BE '; }
details summary:hover { color: #79c0ff; }
details.diagnostic summary { padding: 6px 0; }
.formula { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; padding: 8px 10px;
margin-top: 6px; font: 11px/1.5 'SF Mono', Menlo, monospace; color: #c9d1d9;
white-space: pre-wrap; word-break: break-word; }
.formula .k { color: #8b949e; } .formula .v { color: #e6edf3; } .formula .op { color: #f0883e; }
.formula .src { color: #6e7681; font-size: 10px; display: block; margin-top: 6px;
border-top: 1px solid #21262d; padding-top: 6px; }
.diag-headline { color: #c9d1d9; margin-bottom: 8px; }
.diag-headline .pos { color: #3fb950; font-weight: 600; }
.diag-headline .neg { color: #f85149; font-weight: 600; }
/* tables (recent + stats) */
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { text-align: left; color: #6e7681; font-weight: 500; padding: 6px 8px;
border-bottom: 1px solid #30363d; }
td { padding: 6px 8px; border-bottom: 1px solid #21262d; font-variant-numeric: tabular-nums;
vertical-align: top; }
tr:last-child td { border-bottom: none; }
th.num, td.num { text-align: right; }
td.good { color: #3fb950; } td.warn { color: #d29922; } td.bad { color: #f85149; }
td.pos { color: #3fb950; }
.thumb-strip { display: flex; gap: 3px; align-items: center; justify-content: flex-end; }
.thumb-btn { padding: 0; border: 1px solid #30363d; border-radius: 3px; background: #fff;
cursor: pointer; line-height: 0; }
.thumb-btn:hover, .thumb-btn:focus-visible { border-color: #58a6ff; outline: none; }
.thumb { height: 28px; width: auto; max-width: 28px; object-fit: cover;
object-position: top left; display: block; image-rendering: pixelated; }
.img-cell { text-align: right; }
/* latest image viewer */
.preview-crop { width: 100%; height: 400px; overflow: hidden; background: #fff;
border: 1px solid #30363d; border-radius: 4px; padding: 4px; box-sizing: border-box; }
.preview-crop img { display: block; width: auto; height: auto; max-width: none;
image-rendering: pixelated; }
.pin-bar { margin-bottom: 8px; }
.back-btn, .src-btn { font-size: 11px; background: #21262d; color: #58a6ff;
border: 1px solid #30363d; border-radius: 4px; padding: 2px 8px; cursor: pointer; }
.src-btn { padding: 1px 6px; margin-left: 8px; }
.back-btn:hover, .src-btn:hover { background: #30363d; }
.src-pane { margin-top: 8px; max-height: 400px; overflow: auto; background: #161b22;
border: 1px solid #30363d; border-radius: 4px; padding: 8px; font-size: 11px;
line-height: 1.4; white-space: pre-wrap; word-break: break-word; color: #c9d1d9; }
.evicted { font-size: 11px; color: #6e7681; padding: 12px 0; }
/* sessions chart */
.status { margin-bottom: 12px; color: #6e7681; font-size: 12px; }
.chart { display: flex; flex-direction: column; gap: 8px; }
.bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
.blabel { width: 132px; flex: none; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: #c9d1d9; }
.track { flex: 1; min-width: 0; height: 14px; background: #21262d; border-radius: 3px;
overflow: hidden; }
.fill { height: 100%; background: #3fb950; border-radius: 3px; }
.bvalue { width: 72px; flex: none; text-align: right; font-variant-numeric: tabular-nums;
color: #3fb950; }
.bvalue.neg { color: #f85149; }
.axis { margin-top: 12px; color: #6e7681; font-size: 11px; }
.empty { text-align: center; color: #6e7681; padding: 24px; font-size: 12px; }
/* toast tray (Alpine) */
.tray { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column;
gap: 8px; z-index: 1000; pointer-events: none; }
.toast { background: #21262d; color: #f85149; border: 1px solid #f85149; border-radius: 6px;
padding: 10px 14px; font-size: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
display: flex; align-items: center; gap: 12px; pointer-events: auto; max-width: 360px; }
.toast button { background: transparent; color: inherit; border: 0; cursor: pointer;
font-size: 16px; line-height: 1; padding: 0; }
`;
// Glue between htmx swaps and the page's two bits of client state:
// - window.pp: image-pin + source-pane state, sent to /fragments/latest as
// query params via hx-vals (evaluated at request time). ppPin/ppSource
// mutate it and force an immediate refresh instead of waiting for the
// next 2s tick.
// - <details> open state: htmx innerHTML swaps would close every
// "show calculation" panel on each poll; record open ids before the swap
// and restore them after.
// - toasts: htmx request errors are pushed into the Alpine tray.
const GLUE_JS = `
window.pp = { pin: null, src: false };
function ppPin(id) {
window.pp.pin = id;
htmx.trigger('#frag-latest', 'pp-refresh');
}
function ppSource(on) {
window.pp.src = on;
htmx.trigger('#frag-latest', 'pp-refresh');
}
document.body.addEventListener('htmx:beforeSwap', function (ev) {
const open = [];
ev.detail.target.querySelectorAll('details[open][id]').forEach(function (d) { open.push(d.id); });
ev.detail.target.__ppOpen = open;
});
document.body.addEventListener('htmx:afterSwap', function (ev) {
(ev.detail.target.__ppOpen || []).forEach(function (id) {
const d = document.getElementById(id);
if (d) d.setAttribute('open', '');
});
});
document.body.addEventListener('htmx:responseError', function (ev) {
window.dispatchEvent(new CustomEvent('pp-toast', {
detail: { text: ev.detail.xhr.status + ' ' + ev.detail.requestConfig.path }
}));
});
document.body.addEventListener('htmx:sendError', function (ev) {
window.dispatchEvent(new CustomEvent('pp-toast', {
detail: { text: 'proxy unreachable: ' + ev.detail.requestConfig.path }
}));
});
`;
export function renderPage(port: number): string {
// Each fragment div polls its own endpoint. `hx-trigger="load, every Ns"`
// paints immediately on page load, then keeps the legacy cadence
// (2s live counters, 5s slow aggregates).
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>pxpipe - live dashboard</title>
<style>${CSS}</style>
</head>
<body>
<h1><span class="dot"></span>pxpipe</h1>
<div id="frag-toggle" hx-get="/fragments/toggle" hx-trigger="load, every 2s" hx-swap="innerHTML"></div>
<div id="frag-session" hx-get="/fragments/session-summary" hx-trigger="load, every 2s" hx-swap="innerHTML"></div>
<div id="frag-header" hx-get="/fragments/header" hx-trigger="load, every 2s" hx-swap="innerHTML"><div class="sub">connecting&hellip;</div></div>
<div class="row">
<div class="panel">
<h2>recent requests</h2>
<div id="frag-recent" hx-get="/fragments/recent" hx-trigger="load, every 2s" hx-swap="innerHTML"></div>
</div>
<div class="panel">
<h2>latest rendered image</h2>
<div id="frag-latest" hx-get="/fragments/latest" hx-trigger="load, every 2s, pp-refresh" hx-swap="innerHTML"
hx-vals='js:{pin: window.pp.pin == null ? "" : window.pp.pin, source: window.pp.src ? "1" : ""}'></div>
</div>
</div>
<div class="panel" style="margin-bottom:22px">
<h2>sessions <span class="small" style="color:#6e7681">(top savers)</span></h2>
<div id="frag-sessions" hx-get="/fragments/sessions" hx-trigger="load, every 5s" hx-swap="innerHTML"></div>
</div>
<div class="panel" style="margin-bottom:22px">
<h2>stats <span class="small" style="color:#6e7681">(full history)</span></h2>
<div id="frag-stats" hx-get="/fragments/stats" hx-trigger="load, every 5s" hx-swap="innerHTML"></div>
</div>
<div class="tray" x-data="{ toasts: [], next: 1 }"
@pp-toast.window="const id = next++; toasts.push({ id, text: $event.detail.text }); setTimeout(() => toasts = toasts.filter(t => t.id !== id), 5000)">
<template x-for="t in toasts" :key="t.id">
<div class="toast"><span x-text="t.text"></span><button type="button" @click="toasts = toasts.filter(x => x.id !== t.id)" aria-label="dismiss">&times;</button></div>
</template>
</div>
<script>${HTMX_JS}</script>
<script>${GLUE_JS}</script>
<script>${ALPINE_JS}</script>
</body>
</html>`;
}
-50
View File
@@ -1,50 +0,0 @@
// Fetch helpers + endpoint URL constants + mutation wrappers.
//
// The dashboard talks to the same Node host that serves it, so all URLs are
// path-relative. The handlers live in `src/dashboard.ts` — keep this file in
// sync with the routing table there.
import type {
CompressionToggleResponse,
} from '../types.js';
export const API = {
stats: '/proxy-stats',
recent: '/proxy-recent',
latestPng: '/proxy-latest-png',
sessions: '/api/sessions.json',
fullStats: '/api/stats.json',
currentSession: '/api/current-session.json',
compressionToggle: '/api/compression',
} as const;
/** Fetch JSON or throw. Errors propagate so stores can surface them. */
export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
// Try to surface server error JSON if it exists; fall back to status.
let body = '';
try {
body = await res.text();
} catch {
/* ignore */
}
throw new Error(
`${url}: ${res.status} ${res.statusText}${body ? `${body.slice(0, 200)}` : ''}`,
);
}
return res.json() as Promise<T>;
}
/** POST JSON helper. */
export async function postJson<T>(url: string, body: unknown): Promise<T> {
return fetchJson<T>(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
export async function setCompressionEnabled(enabled: boolean): Promise<CompressionToggleResponse> {
return postJson<CompressionToggleResponse>(API.compressionToggle, { enabled });
}
-55
View File
@@ -1,55 +0,0 @@
// Number / size / time formatters. Identical output to the legacy dashboard
// so visual regression is a non-issue.
export function numFmt(n: number | null | undefined): string {
const v = Math.round(Number(n) || 0);
return v.toLocaleString();
}
export function fmtBytes(n: number | null | undefined): string {
if (n == null) return '-';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
export function fmtTs(iso: string | null | undefined): string {
if (!iso) return '-';
return String(iso).replace('T', ' ').slice(0, 19);
}
export function formatDuration(s: number): string {
s = Math.floor(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return (h ? h + 'h ' : '') + (m || h ? m + 'm ' : '') + sec + 's';
}
export function shortPath(p: string | null | undefined): string {
if (!p) return '-';
const parts = String(p).split('/');
return parts[parts.length - 1] || p;
}
// Rounding helpers used by the savings math panels — the headline tokens
// number is rounded to whole tokens, the dollar number to four decimal places.
export function round1(n: number): number {
return Math.round(n * 10) / 10;
}
export function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}
// HTML-escape for the few places we interpolate operator-provided strings
// (session ids, project paths) into rendered markup. Svelte's `{value}` form
// already escapes, but anchor titles / attribute interpolations through `{@html}`
// would not.
export function escapeHtml(s: string | null | undefined): string {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string
));
}
-18
View File
@@ -1,18 +0,0 @@
// Svelte entry point. Mounts <App> on #app. The bundler injects the compiled
// IIFE as a string into dashboard.ts; at runtime the browser just sees a
// regular module script that imports App from the bundle root.
import { mount } from 'svelte';
import App from './App.svelte';
const target = document.getElementById('app');
if (target) {
// Svelte 5: components are mounted with `mount()`, not `new Component()`.
// The legacy `new App({ target })` API is gone unless the build sets
// `compatibility.componentApi: 4` - and that shim isn't worth carrying.
mount(App, { target });
} else {
// Should never happen - the HTML template always emits #app. But if a
// stale cached page is served alongside a new bundle, fail loud rather
// than silent so the operator notices.
document.body.textContent = 'pxpipe dashboard: mount target #app missing';
}
-52
View File
@@ -1,52 +0,0 @@
// Reactive stores for each dashboard panel. The polling cadence matches the
// legacy dashboard exactly: 2s for the live counters + recent table,
// 5s for the slower aggregates. The Svelte stores are dev-time only — the
// proxy still serves a single static HTML string at runtime (built by
// scripts/build-dashboard-ui.mjs from these sources).
import { writable } from 'svelte/store';
import { pollJson } from './poll.js';
import type {
StatsPayload,
RecentPayload,
SessionsPayload,
FullStatsPayload,
CurrentSessionPayload,
} from '../types.js';
// Live counters + recent table (legacy poll cadence: 2s).
export const stats = pollJson<StatsPayload>('/proxy-stats', 2000);
export const recent = pollJson<RecentPayload>('/proxy-recent', 2000);
// Slower endpoints (legacy: 5s).
export const sessions = pollJson<SessionsPayload>('/api/sessions.json', 5000);
export const fullStats = pollJson<FullStatsPayload>('/api/stats.json', 5000);
export const currentSession = pollJson<CurrentSessionPayload>('/api/current-session.json', 2000);
// when null the image viewer follows the latest render; when set it pins that image id. ui-only state.
export const selectedImageId = writable<number | null>(null);
// Toast-style messages for confirm/error feedback. Components can push and
// the App-level component renders them. Tiny on purpose — full toast library
// would blow the zero-dep budget.
export interface Toast {
id: number;
level: 'info' | 'error';
text: string;
}
function makeToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
let nextId = 1;
return {
subscribe,
push(level: Toast['level'], text: string) {
const id = nextId++;
update((arr) => [...arr, { id, level, text }]);
setTimeout(() => update((arr) => arr.filter((t) => t.id !== id)), 5000);
},
dismiss(id: number) {
update((arr) => arr.filter((t) => t.id !== id));
},
};
}
export const toasts = makeToastStore();
-73
View File
@@ -1,73 +0,0 @@
// Polling helper. Replaces the legacy `tick()` / `tickSlow()` setInterval pair
// with a Svelte-friendly writable store that auto-refreshes on an interval.
//
// Each panel subscribes to the store it cares about; the fetch only runs
// when something is listening, which means the session-detail page (separate
// HTML doc) doesn't pay the cost of the dashboard's polling loop.
import { readable, type Readable } from 'svelte/store';
import { fetchJson } from '../lib/api.js';
export interface Polled<T> {
data: T | null;
error: string | null;
loading: boolean;
}
/**
* Polling readable store. Fetches `url` immediately and every `intervalMs`
* while subscribed. Stops the interval when the last subscriber leaves so a
* navigation away from the dashboard tab doesn't keep hitting the proxy.
*/
/**
* A polling store plus an imperative `run()` to force an out-of-band refresh
* (e.g. right after a mutation, so the table doesn't wait for the next tick).
*/
export interface PollStore<T> extends Readable<Polled<T>> {
run: () => void;
}
export function pollJson<T>(url: string, intervalMs: number): PollStore<T> {
// `refresh` is rebound to the live fetch while a subscriber is attached, and
// reset to a no-op once the last subscriber leaves (interval stopped).
let refresh: () => void = () => {};
const store = readable<Polled<T>>({ data: null, error: null, loading: true }, (set) => {
let cancelled = false;
let timer: ReturnType<typeof setInterval> | null = null;
let lastData: T | null = null;
const run = async () => {
try {
const data = await fetchJson<T>(url);
if (cancelled) return;
lastData = data;
set({ data, error: null, loading: false });
} catch (e) {
if (cancelled) return;
// Keep the last successful payload visible so transient errors don't
// wipe the table. Surface the message in the sub-line.
set({
data: lastData,
error: (e as Error).message,
loading: false,
});
}
};
refresh = () => {
void run();
};
run();
timer = setInterval(run, intervalMs);
return () => {
cancelled = true;
refresh = () => {};
if (timer) clearInterval(timer);
};
});
return {
subscribe: store.subscribe,
run: () => refresh(),
};
}
File diff suppressed because one or more lines are too long
+22
View File
@@ -240,6 +240,28 @@ async function dispatchDashboard(
case 'current-session':
if (method !== 'GET') return undefined;
return dashboard.serveCurrentSessionJson();
case 'fragment': {
// /fragments/toggle is the one mutating fragment - htmx POSTs the next
// state (urlencoded hx-vals or JSON), the server flips the switch and
// returns the re-rendered toggle markup.
if (route.name === 'toggle' && method === 'POST') {
let enabled = false;
try {
const raw = await readRequestBody(req);
try {
enabled = (JSON.parse(raw) as { enabled?: unknown }).enabled === true;
} catch {
enabled = new URLSearchParams(raw).get('enabled') === 'true';
}
} catch {
return new Response('bad request body', { status: 400 });
}
dashboard.handleCompressionToggle({ enabled });
return dashboard.serveFragment('toggle', url, port);
}
if (method !== 'GET') return undefined;
return dashboard.serveFragment(route.name, url, port);
}
case 'api-compression': {
if (method !== 'POST') {
return new Response(
+52
View File
@@ -151,3 +151,55 @@ describe('serveApiStats', () => {
expect(res.status).toBe(404);
});
});
// ---- /fragments/* (htmx server-rendered HTML) ------------------------
describe('serveFragment', () => {
const url = new URL('http://localhost/fragments/x');
it('routes /fragments/<name> via dashboardPath', () => {
expect(dashboardPath('/fragments/header')).toEqual({ kind: 'fragment', name: 'header' });
expect(dashboardPath('/fragments/latest')).toEqual({ kind: 'fragment', name: 'latest' });
});
it('renders the toggle fragment reflecting compression state', async () => {
const on = await dash.serveFragment('toggle', url, 1234);
expect(on.headers.get('content-type')).toContain('text/html');
expect(await on.text()).toContain('Disable compression');
dash.handleCompressionToggle({ enabled: false });
const off = await dash.serveFragment('toggle', url, 1234);
const offHtml = await off.text();
expect(offHtml).toContain('PASSTHROUGH MODE');
expect(offHtml).toContain('Enable compression');
dash.handleCompressionToggle({ enabled: true });
});
it('renders header + recent + stats fragments from the same payloads as JSON', async () => {
writeEvents(tmp, [
ev({ status: 200, compressed: true, orig_chars: 1000, image_bytes: 200 }),
]);
const header = await (await dash.serveFragment('header', url, 4711)).text();
expect(header).toContain('4711');
const recent = await (await dash.serveFragment('recent', url, 4711)).text();
expect(recent).toContain('<table');
const stats = await (await dash.serveFragment('stats', url, 4711)).text();
expect(stats).toContain('requests');
});
it('escapes HTML in latest source text', async () => {
dash.captureImage({
imagePngs: [new Uint8Array([137, 80, 78, 71])],
imageDims: [{ width: 100, height: 80 }],
imageSourceText: '<script>alert(1)</script>',
} as never);
const srcUrl = new URL('http://localhost/fragments/latest?source=1');
const html = await (await dash.serveFragment('latest', srcUrl, 1)).text();
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
});
it('404s unknown fragments', async () => {
const res = await dash.serveFragment('nope', url, 1);
expect(res.status).toBe(404);
});
});
-28
View File
@@ -1,28 +0,0 @@
{
// Type-check the Svelte browser bundle separately from the root tsconfig.
//
// The root config targets Node + WebWorker (`lib: ES2022, WebWorker`) which
// is wrong for the dashboard: it uses `document`, `localStorage`, and
// browser-only fetch APIs. We need `lib: DOM` here without polluting the
// proxy bundle's type universe.
//
// Usage:
// pnpm exec tsc -p tsconfig.dashboard.json --noEmit
//
// The actual JS output is produced by scripts/build-dashboard-ui.mjs via
// esbuild + esbuild-svelte; this config exists only so editors + CI can
// verify the .ts files in src/dashboard/ are sound. Svelte 5's compiler
// handles .svelte files itself.
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"outDir": null
},
"include": ["src/dashboard/**/*.ts"],
"exclude": ["node_modules", "dist", "legacy"]
}