mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
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:
+5
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-193
@@ -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: {}
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 · 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>` +
|
||||
` — saved <span class="num">$${savedUsd.toFixed(2)}</span>` +
|
||||
` (<span class="num">${savedPct.toFixed(1)}%</span> of` +
|
||||
` <span class="muted">$${baselineUsd.toFixed(2)}</span> baseline)` +
|
||||
` — <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} · uptime ${formatDuration(s.uptime_sec)} · 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×1.0, cache_create×1.25, cache_read×0.10</span></div>` +
|
||||
`<div style="height:6px"></div>` +
|
||||
mathRow('baseline', s.baseline_input_weighted, '(cache-aware: cacheable×weight + cold_tail)') +
|
||||
mathRow('actual', s.actual_input_weighted, '(input + cc×1.25 + cr×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 × $${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 × 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_$ = (Σ actual_input + Σ output × ${pa.output_multiplier}) × $${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 · 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 · 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 ≥ ${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>`;
|
||||
|
||||
const tokeqMath =
|
||||
`<div><span class="k">formula:</span> <span class="v">token_equivalent = input + output × ${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 ×${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 · text_delta + response content[].text)') +
|
||||
mathRow('measured_thinking_chars', s.measured_thinking_chars, '(content_block_delta · thinking_delta + response reasoning text)') +
|
||||
mathRow('measured_tool_use_chars', s.measured_tool_use_chars, '(content_block_delta · 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 × ${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 × ${pa.output_multiplier}`, s.all_output_weighted, `(every paid request, output × ${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 × 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 · both buckets ≥ ${s.split_min_sample_per_bucket}</div>`
|
||||
: `<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>`;
|
||||
|
||||
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">— ${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)} · 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>${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 · compressed + passthrough + probe-failed)</span></div>` +
|
||||
pctMath,
|
||||
'diagnostic',
|
||||
) +
|
||||
details('diag-tokeq', `token-equivalent total: ${numFmt(s.actual_token_equivalent)} (input + ${pa.output_multiplier}×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 ? '✓' : '-'}</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)">← 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) · 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…</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">×</button></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>${HTMX_JS}</script>
|
||||
<script>${GLUE_JS}</script>
|
||||
<script>${ALPINE_JS}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string
|
||||
));
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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('<script>');
|
||||
});
|
||||
|
||||
it('404s unknown fragments', async () => {
|
||||
const res = await dash.serveFragment('nope', url, 1);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user