diff --git a/package.json b/package.json index 189420c..46e3a21 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,12 @@ "scripts": { "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", + "typecheck": "tsc --noEmit && tsc -p tsconfig.dashboard.json", "test": "vitest run", "test:watch": "vitest", "test:restart": "bash tests/restart.test.sh", @@ -76,6 +77,9 @@ "@napi-rs/canvas": "^0.1.53", "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6008142..783ee9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,15 @@ 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 @@ -564,6 +573,12 @@ 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'} @@ -571,6 +586,9 @@ 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==} @@ -775,6 +793,11 @@ 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==} @@ -790,6 +813,9 @@ 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==} @@ -819,10 +845,23 @@ 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==} @@ -830,6 +869,10 @@ 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==} @@ -841,12 +884,22 @@ 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'} @@ -857,6 +910,17 @@ 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==} @@ -878,6 +942,9 @@ 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'} @@ -956,6 +1023,9 @@ 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==} @@ -1020,6 +1090,47 @@ 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==} @@ -1180,6 +1291,9 @@ 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': {} @@ -1479,10 +1593,25 @@ 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 @@ -1613,6 +1742,10 @@ 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 @@ -1631,6 +1764,8 @@ 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 @@ -1672,22 +1807,38 @@ 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 @@ -1746,6 +1897,12 @@ 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 @@ -1759,6 +1916,10 @@ 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: @@ -1810,6 +1971,8 @@ 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 @@ -1908,6 +2071,34 @@ 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: {} @@ -2022,3 +2213,5 @@ snapshots: '@speed-highlight/core': 1.2.15 cookie: 1.1.1 youch-core: 0.3.3 + + zimmerframe@1.1.4: {} diff --git a/scripts/build-dashboard-ui.mjs b/scripts/build-dashboard-ui.mjs new file mode 100644 index 0000000..dd14c32 --- /dev/null +++ b/scripts/build-dashboard-ui.mjs @@ -0,0 +1,68 @@ +// 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 -

pixelpipe

-
connecting...
- - - - - -
- - runtime kill switch · not persisted across restart -
- -
-
requests
-
0
-
— compressed
-
-
input tokens saved
-
0
-
cache-aware, input-side only
-
show calculation -
-
-
-
$ saved
-
$0.00
-
at $5/M input tokens (Opus 4.7)
-
show calculation -
-
-
-
share of total bill saved
-
0%
-
÷ (input + 5×output) — output billed at 5× input rate · input-only: 0%
-
show calculation -
-
-
-
token-equivalent total
-
0
-
input + 5×output — output billed at 5× input rate
-
show calculation -
-
-
-
- -
-
-

recent requests

- - - - - - - - - -
#statuspathccbaselineactualsaved
-
-
-

latest rendered image

-
(none yet)
-
-
-
- -
-

sessions

-
loading...
- - - - - - - - - - - - - - - - - - - -
sessionprojectclaude codefirst seenlast seenreqstokens savedcache readdisk
-
- -
-
-

stats (full history)

-
loading...
- - -
-
-
-

cleanup

-
loading...
- - -
-
- - - -
-
-
-
- - +
+ `; diff --git a/src/dashboard/App.svelte b/src/dashboard/App.svelte new file mode 100644 index 0000000..ade18dc --- /dev/null +++ b/src/dashboard/App.svelte @@ -0,0 +1,146 @@ + + +

pixelpipe

+
{sub}
+ + + + + +
+
+

recent requests

+ +
+
+

latest rendered image

+ +
+
+ +
+ +
+ +
+
+

stats (full history)

+ +
+
+

cleanup

+ +
+
+ + + + diff --git a/src/dashboard/components/Cleanup.svelte b/src/dashboard/components/Cleanup.svelte new file mode 100644 index 0000000..9dd171d --- /dev/null +++ b/src/dashboard/components/Cleanup.svelte @@ -0,0 +1,160 @@ + + +
+ {#if $disk.loading && !d} + loading… + {:else if err} + {err} + {:else if d} + {fmtBytes(d.totalBytes)} on disk + {:else} + - + {/if} +
+ + + + {#if d} + + + + + + + + + + + {/if} + +
events.jsonl{fmtBytes(d.eventsJsonlBytes)}{d.paths?.eventsFile ?? ''}
4xx-bodies{fmtBytes(d.sidecarBytes)}{d.sidecarCount ?? 0} files
+ +
+ + + +
+
{result}
+ + diff --git a/src/dashboard/components/CompressionToggle.svelte b/src/dashboard/components/CompressionToggle.svelte new file mode 100644 index 0000000..64d0cdb --- /dev/null +++ b/src/dashboard/components/CompressionToggle.svelte @@ -0,0 +1,97 @@ + + +{#if !enabled} + + +{/if} + +
+ + runtime kill switch · not persisted across restart +
+ + diff --git a/src/dashboard/components/LatestPng.svelte b/src/dashboard/components/LatestPng.svelte new file mode 100644 index 0000000..96a4684 --- /dev/null +++ b/src/dashboard/components/LatestPng.svelte @@ -0,0 +1,61 @@ + + +
+ {#if hasPreview} +
+ latest rendered +
+ {:else} +
(none yet)
+ {/if} +
+
{meta ? meta + ' - showing top-left at native resolution' : ''}
+ + diff --git a/src/dashboard/components/RecentRequests.svelte b/src/dashboard/components/RecentRequests.svelte new file mode 100644 index 0000000..b16cbab --- /dev/null +++ b/src/dashboard/components/RecentRequests.svelte @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + {#each rows.slice().reverse() as e, i (e.ts + ':' + i)} + + + + + + + + + + {:else} + + {/each} + +
#statuspathcccrbaselineactualsaved
{i + 1}{e.status}{shortPath(e.path)}{e.cc_added ? '✓' : '-'}{e.baseline_input != null ? numFmt(e.baseline_input) : '-'}{e.actual_input != null ? numFmt(e.actual_input) : '-'} + {(e.session_saved_so_far_delta ?? 0) > 0 + ? '+' + numFmt(e.session_saved_so_far_delta ?? 0) + : '-'} +
no requests yet
+ + diff --git a/src/dashboard/components/Sessions.svelte b/src/dashboard/components/Sessions.svelte new file mode 100644 index 0000000..4b7b60f --- /dev/null +++ b/src/dashboard/components/Sessions.svelte @@ -0,0 +1,525 @@ + + + + +
+ {#if $sessions.loading && rows.length === 0} + loading… + {:else if $sessions.error} + {$sessions.error} + {:else} + ({rows.length}) + {/if} +
+ +
+ + + + +
+ +{#if selCount > 0} +
+ {selCount} selected + + + Shift-click to range-select +
+{/if} + + + + + + + + + + + + + + + + + + {#each rows as r (r.id)} + + + + + + + + + + + + + {/each} + {#if rows.length === 0 && !$sessions.loading} + + {/if} + +
+ + sessionprojectcc setSort('lastSeen')}> + last seen{sortArrow('lastSeen')} + setSort('requestCount')}> + reqs{sortArrow('requestCount')} + setSort('tokensSavedEst')}> + saved{sortArrow('tokensSavedEst')} + setSort('cacheReadTokens')}> + cache read{sortArrow('cacheReadTokens')} + setSort('sidecarBytes')}> + disk{sortArrow('sidecarBytes')} +
+ onRowCheckbox(ev, r.id)} + aria-label="select session {r.id}" + /> + + {r.id.slice(0, 12)} + + {#if r.project} + {shortPath(r.project)} + {:else} + - + {/if} + + {#if r.claudeCode} + + {r.claudeCode.projectPath ? shortPath(r.claudeCode.projectPath) : 'ref'} + + {:else} + - + {/if} + {fmtTs(r.lastSeen ?? '')}{(r.requestCount ?? 0).toLocaleString('en-US')}{(r.tokensSavedEst ?? 0).toLocaleString('en-US')}{(r.cacheReadTokens ?? 0).toLocaleString('en-US')}{fmtBytes(r.sidecarBytes ?? 0)} + +
no sessions match
+ + diff --git a/src/dashboard/components/StatsHeader.svelte b/src/dashboard/components/StatsHeader.svelte new file mode 100644 index 0000000..ee05035 --- /dev/null +++ b/src/dashboard/components/StatsHeader.svelte @@ -0,0 +1,298 @@ + + +
+
+
requests
+
{numFmt(s?.requests)}
+
— {numFmt(s?.compressed_requests)} compressed
+
+
+
input tokens saved
+
{numFmt(s?.saved_input_tokens)}
+
cache-aware, input-side only
+ {#if s && pa} +
+ show calculation +
{@html savedMath}
+
+ {/if} +
+
+
$ saved
+
$ {(s?.saved_usd ?? 0).toFixed(2)}
+
at $5/M input tokens (Opus 4.7)
+ {#if s && pa} +
+ show calculation +
{@html usdMath}
+
+ {/if} +
+
+
share of total bill saved
+
{(s?.saved_pct_of_total_bill ?? 0).toFixed(1)}%
+
+ ÷ (input + 5×output) - output billed at 5× input rate · input-only: + {(s?.saved_pct_input_only ?? 0).toFixed(1)}% +
+ {#if s && pa} +
+ show calculation +
{@html pctMath}
+
+ {/if} +
+
+
token-equivalent total
+
{numFmt(s?.actual_token_equivalent)}
+
input + 5×output billed at 5× input rate
+ {#if s && pa} +
+ show calculation +
{@html tokeqMath}
+
+ {/if} +
+
+ + diff --git a/src/dashboard/components/StatsTable.svelte b/src/dashboard/components/StatsTable.svelte new file mode 100644 index 0000000..c76d759 --- /dev/null +++ b/src/dashboard/components/StatsTable.svelte @@ -0,0 +1,133 @@ + + +
+ {#if $stats.loading && !payload} + loading… + {:else if err} + {err} + {:else if payload} + {numFmt(payload.parsed)} events parsed + {:else} + - + {/if} +
+ + + + {#if s} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/if} + +
requests{numFmt(s.total)}
2xx / 4xx / 5xx{numFmt(s.ok2xx)} / {numFmt(s.err4xx)} / {numFmt(s.err5xx)}
compressed{numFmt(s.compressed)}
passthrough{numFmt(s.passthrough)}
input tokens{numFmt(s.inputTokensTotal)}
cache create{numFmt(s.cacheCreateTokensTotal)}
cache read{numFmt(s.cacheReadTokensTotal)}
cache hit (tok){hitRateTok}
cache hit (ev){hitRateEv}
orig chars{numFmt(s.origCharsTotal)}
image bytes{numFmt(s.imageBytesTotal)}
bytes/char{charRatio}
latency p50/p95{numFmt(s.durationP50)} / {numFmt(s.durationP95)} ms
first-byte p50/p95{numFmt(s.firstBytemsP50)} / {numFmt(s.firstBytemsP95)} ms
+ + diff --git a/src/dashboard/components/ToastTray.svelte b/src/dashboard/components/ToastTray.svelte new file mode 100644 index 0000000..2cf940b --- /dev/null +++ b/src/dashboard/components/ToastTray.svelte @@ -0,0 +1,63 @@ + + +
+ {#each $toasts as t (t.id)} +
+ {t.text} + +
+ {/each} +
+ + diff --git a/src/dashboard/lib/api.ts b/src/dashboard/lib/api.ts new file mode 100644 index 0000000..a67d309 --- /dev/null +++ b/src/dashboard/lib/api.ts @@ -0,0 +1,81 @@ +// 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 { + PruneResponse, + CompressionToggleResponse, +} from '../types.js'; + +export const API = { + stats: '/proxy-stats', + recent: '/proxy-recent', + latestPng: '/proxy-latest-png', + sessions: '/api/sessions.json', + sessionDetail: (id: string) => `/api/sessions/${encodeURIComponent(id)}.json`, + diskUsage: '/api/disk.json', + fullStats: '/api/stats.json', + prune: '/api/sessions/prune', + compressionToggle: '/api/compression', +} as const; + +/** Fetch JSON or throw. Errors propagate so stores can surface them. */ +export async function fetchJson(url: string, init?: RequestInit): Promise { + 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; +} + +/** POST JSON helper. */ +export async function postJson(url: string, body: unknown): Promise { + return fetchJson(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +export async function dryRunPrune(opts: { + olderThanDays: number; + keepLast: number; + sessionIds?: string[]; +}): Promise { + return postJson(API.prune, { + olderThanDays: opts.olderThanDays, + keepLast: opts.keepLast, + sessionIds: opts.sessionIds, + force: false, + }); +} + +export async function executePrune(opts: { + olderThanDays?: number; + keepLast?: number; + sessionIds?: string[]; + sessionId?: string; +}): Promise { + return postJson(API.prune, { + olderThanDays: opts.olderThanDays, + keepLast: opts.keepLast, + sessionIds: opts.sessionIds, + sessionId: opts.sessionId, + force: true, + }); +} + +export async function setCompressionEnabled(enabled: boolean): Promise { + return postJson(API.compressionToggle, { enabled }); +} diff --git a/src/dashboard/lib/format.ts b/src/dashboard/lib/format.ts new file mode 100644 index 0000000..c9c02d3 --- /dev/null +++ b/src/dashboard/lib/format.ts @@ -0,0 +1,55 @@ +// 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 + )); +} diff --git a/src/dashboard/main.ts b/src/dashboard/main.ts new file mode 100644 index 0000000..6a94d82 --- /dev/null +++ b/src/dashboard/main.ts @@ -0,0 +1,18 @@ +// Svelte entry point. Mounts 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 = 'pixelpipe dashboard: mount target #app missing'; +} diff --git a/src/dashboard/stores/index.ts b/src/dashboard/stores/index.ts new file mode 100644 index 0000000..36fcbc4 --- /dev/null +++ b/src/dashboard/stores/index.ts @@ -0,0 +1,112 @@ +// 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, derived } from 'svelte/store'; +import { pollJson } from './poll.js'; +import type { + StatsPayload, + RecentPayload, + SessionsPayload, + FullStatsPayload, + DiskPayload, + SessionFilters, +} from '../types.js'; + +// Live counters + recent table (legacy poll cadence: 2s). +export const stats = pollJson('/proxy-stats', 2000); +export const recent = pollJson('/proxy-recent', 2000); + +// Slower endpoints (legacy: 5s). +export const sessions = pollJson('/api/sessions.json', 5000); +export const fullStats = pollJson('/api/stats.json', 5000); +export const disk = pollJson('/api/disk.json', 5000); + +// Session table filter + selection state. UI-only — survives across diff +// renders but not across reloads. Persists to localStorage opportunistically. +export const sessionFilters = writable(loadFilters()); + +sessionFilters.subscribe((f) => { + try { + localStorage.setItem('pixelpipe.dashboard.filters', JSON.stringify(f)); + } catch { + /* private mode / no storage — best effort */ + } +}); + +function loadFilters(): SessionFilters { + try { + const raw = localStorage.getItem('pixelpipe.dashboard.filters'); + if (!raw) return defaultFilters(); + const parsed = JSON.parse(raw) as Partial; + return { + warmOnly: parsed.warmOnly === true, + compressedOnly: parsed.compressedOnly === true, + search: typeof parsed.search === 'string' ? parsed.search : '', + }; + } catch { + return defaultFilters(); + } +} + +function defaultFilters(): SessionFilters { + return { warmOnly: false, compressedOnly: false, search: '' }; +} + +// Multi-select bookkeeping for the session table (bulk delete pattern). +// Just the IDs — the row data lives in the `sessions` store. +export const selectedSessionIds = writable>(new Set()); + +// Filtered + sorted view that the table component reads. Re-derives whenever +// raw sessions or filter state change; this is where sort/filter/search lives +// so the rendering component stays presentational. +export const visibleSessions = derived( + [sessions, sessionFilters], + ([$sessions, $filters]) => { + const rows = $sessions.data?.sessions ?? []; + const q = $filters.search.trim().toLowerCase(); + return rows.filter((s) => { + if ($filters.warmOnly && (s.cacheReadTokens ?? 0) <= 0) return false; + if ($filters.compressedOnly && (s.tokensSavedEst ?? 0) <= 0) return false; + if (q) { + const hay = [s.id, s.project, s.claudeCode?.projectPath, s.claudeCode?.firstUserPreview] + .filter((x): x is string => typeof x === 'string') + .join(' ') + .toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + }, +); + +// The selection anchor for shift-click range select. Matches the legacy +// dashboard's Gmail/Finder/GitHub multi-select semantics. +export const lastClickedSessionId = writable(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([]); + 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(); diff --git a/src/dashboard/stores/poll.ts b/src/dashboard/stores/poll.ts new file mode 100644 index 0000000..199ed09 --- /dev/null +++ b/src/dashboard/stores/poll.ts @@ -0,0 +1,73 @@ +// 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 { + 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 extends Readable> { + run: () => void; +} + +export function pollJson(url: string, intervalMs: number): PollStore { + // `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>({ data: null, error: null, loading: true }, (set) => { + let cancelled = false; + let timer: ReturnType | null = null; + let lastData: T | null = null; + + const run = async () => { + try { + const data = await fetchJson(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(), + }; +} diff --git a/src/dashboard/types.ts b/src/dashboard/types.ts new file mode 100644 index 0000000..a9da510 --- /dev/null +++ b/src/dashboard/types.ts @@ -0,0 +1,176 @@ +// Shape of the JSON payloads the Node host emits. Kept here so the components +// don't drift from `src/dashboard.ts` — when the server contract changes, both +// sides update together. + +/** /proxy-stats payload — the live counters cards + sub-lines. */ +export interface StatsPayload { + port: number; + uptime_sec: number; + requests: number; + compressed_requests: number; + passthrough: number; + baseline_input_weighted: number; + actual_input_weighted: number; + saved_input_tokens: number; + /** Back-compat duplicate of `saved_pct_input_only`. */ + saved_pct: number; + saved_pct_input_only: number; + saved_pct_of_total_bill: number; + saved_usd: number; + output_weighted: number; + baseline_token_equivalent: number; + actual_token_equivalent: number; + pricing_assumptions: PricingAssumptions; + measured_text_chars: number; + measured_thinking_chars: number; + measured_tool_use_chars: number; + measured_redacted_block_count: number; + events_with_measurement: number; + uptime_sec_unused?: never; // future-proof + compression_enabled: boolean; +} + +export interface PricingAssumptions { + input_per_mtok: number; + output_multiplier: number; + cache_write_5m_multiplier: number; + cache_write_1h_multiplier: number; + cache_read_multiplier: number; + source: string; +} + +/** /proxy-recent payload — the table + preview pane. */ +export interface RecentPayload { + recent: RecentRow[]; + has_preview: boolean; + preview_meta: string; +} + +export interface RecentRow { + ts: number; + method: string; + path: string; + status: number; + size_in?: number; + compressed: boolean; + cc_added?: number; + input_tokens?: number; + output_tokens?: number; + cache_create?: number; + cache_read?: number; + actual_input?: number; + baseline_input?: number; + session_saved_so_far_delta?: number; +} + +/** /api/sessions.json payload — bulk session aggregate + selection table. */ +export interface SessionsPayload { + sessions: SessionRow[]; + count: number; +} + +export interface SessionRow { + // Mirrors the server's `SessionSummary` (core/sessions.ts) as serialized by + // `serveSessionsJson`. Field names MUST match the JSON payload exactly — + // the server is the source of truth here. + id: string; + project: string | null; + firstSeen: string; + lastSeen: string; + requestCount: number; + charsSaved: number; + tokensSavedEst: number; + cacheReadTokens: number; + jsonlBytes: number; + sidecarBytes: number; + claudeCode: ClaudeCodeRef | null; +} + +export interface ClaudeCodeRef { + sessionId: string; + projectPath: string; + cwd?: string; + firstUserPreview?: string; +} + +/** /api/sessions/$id.json payload. */ +export interface SessionDetailPayload { + id: string; + claudeCode: ClaudeCodeRef | null; + includeBodies: boolean; + events: SessionDetailEvent[]; +} + +export interface SessionDetailEvent { + ts: string; + method: string; + path: string; + status: number; + orig_chars?: number; + image_bytes?: number; + cache_read_tokens?: number; + [k: string]: unknown; +} + +/** /api/stats.json payload — full-history aggregate. */ +export interface FullStatsPayload { + parsed: number; + dropped: number; + summary: FullStatsSummary; + error?: string; + path?: string; +} + +export interface FullStatsSummary { + total: number; + ok2xx: number; + err4xx: number; + err5xx: number; + compressed: number; + passthrough: number; + inputTokensTotal: number; + cacheCreateTokensTotal: number; + cacheReadTokensTotal: number; + outputTokensTotal: number; + cacheHitEvents: number; + eventsWithBaseline: number; + origCharsTotal: number; + imageBytesTotal: number; + durationP50: number; + durationP95: number; + firstByteP50: number; + firstByteP95: number; +} + +/** /api/disk.json payload. */ +export interface DiskPayload { + totalBytes: number; + eventsJsonlBytes: number; + sidecarBytes: number; + sidecarCount: number; + paths: { + eventsFile: string; + sidecarDir: string; + }; + error?: string; +} + +/** /api/compression POST response. */ +export interface CompressionToggleResponse { + compression_enabled: boolean; +} + +/** /api/sessions/prune POST response. */ +export interface PruneResponse { + sessionsRemoved: { sessionId: string }[]; + eventsRemoved: number; + jsonlBytesFreed: number; + sidecarBytesFreed: number; +} + +/** UI-level filter state for the session table. */ +export interface SessionFilters { + warmOnly: boolean; + compressedOnly: boolean; + search: string; +} diff --git a/tsconfig.dashboard.json b/tsconfig.dashboard.json new file mode 100644 index 0000000..01269ce --- /dev/null +++ b/tsconfig.dashboard.json @@ -0,0 +1,28 @@ +{ + // 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"] +} diff --git a/tsconfig.json b/tsconfig.json index cd9be60..d47376a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,15 @@ "rootDir": "src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "legacy"] + "exclude": [ + "node_modules", + "dist", + "legacy", + // src/dashboard/ is the Svelte browser bundle — compiled separately by + // scripts/build-dashboard-ui.mjs with the DOM lib + esbuild-svelte. The + // root tsconfig targets Node + WebWorker (no `document` / `localStorage`), + // so type-checking dashboard sources here would always fail. They're still + // covered by `tsconfig.dashboard.json` for editor + CI type-checking. + "src/dashboard" + ] }