feat(coding-agent): added real-time OAuth provider authentication validation with status indicators

- Added real-time OAuth provider authentication validation with visual status indicators (checking, valid, invalid).
- Added `validateAuth` and `requestRender` options to OAuthSelectorComponent for custom authentication validation and UI refresh callbacks.
- Changed OAuth provider selector to display live authentication status instead of static login state.
- Updated logout flow to refresh OAuth provider authentication state before showing selector.
This commit is contained in:
can1357
2026-02-13 18:33:21 +01:00
parent 0658940410
commit c305872ddc
20 changed files with 1537 additions and 1163 deletions
+125 -74
View File
@@ -131,16 +131,19 @@
"dependencies": {
"@oh-my-pi/pi-ai": "workspace:*",
"@oh-my-pi/pi-utils": "workspace:*",
"chart.js": "4.5.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-chartjs-2": "5.3.1",
"react-dom": "^19.2.4",
"recharts": "^3.7.0",
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "8.5.6",
"tailwindcss": "3",
},
},
"packages/swarm-extension": {
@@ -185,6 +188,8 @@
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
@@ -299,10 +304,26 @@
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@mistralai/mistralai": ["@mistralai/mistralai@1.14.0", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-6zaj2f2LCd37cRpBvCgctkDbXtYBlAC85p+u4uU/726zjtsI+sdVH34qRzkm9iE3tRb8BoaiI0/P7TD+uMvLLQ=="],
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@workspace:packages/stats"],
"@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@workspace:packages/agent"],
@@ -345,8 +366,6 @@
"@puppeteer/browsers": ["@puppeteer/browsers@2.12.1", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-fXa6uXLxfslBlus3MEpW8S6S9fe5RwmAE5Gd8u3krqOwnkZJV3/lQJiY3LaFdTctLLqJtyMgEUGkbDnRNf6vbQ=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="],
@@ -441,10 +460,6 @@
"@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -455,24 +470,6 @@
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -485,8 +482,6 @@
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260213.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260213.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260213.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260213.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260213.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260213.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260213.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260213.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-TZ/0Tv954jRpn5IHQJZQF0gaNmw//ZTvkgr3nC2H8u9t4GxUhIk5vfAHVFgdl8JnNGbl7gZks37FVsEwXjdHqg=="],
@@ -523,6 +518,12 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
@@ -551,6 +552,8 @@
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@@ -567,8 +570,14 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -577,8 +586,6 @@
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
@@ -603,46 +610,28 @@
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -669,8 +658,6 @@
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
@@ -693,10 +680,14 @@
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -717,6 +708,10 @@
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
@@ -731,6 +726,8 @@
"glob": ["glob@13.0.3", "", { "dependencies": { "minimatch": "^10.2.0", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
@@ -741,6 +738,8 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
@@ -755,20 +754,24 @@
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-network-error": ["is-network-error@1.3.0", "", {}, "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
@@ -779,6 +782,8 @@
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -797,6 +802,8 @@
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="],
@@ -817,6 +824,8 @@
"marked": ["marked@17.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
@@ -837,8 +846,12 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
@@ -849,8 +862,12 @@
"node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -879,6 +896,8 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
@@ -889,6 +908,24 @@
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
@@ -905,36 +942,38 @@
"puppeteer-core": ["puppeteer-core@24.37.3", "", { "dependencies": { "@puppeteer/browsers": "2.12.1", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-fokQ8gv+hNgsRWqVuP5rUjGp+wzV5aMTP3fcm8ekNabmLGlJdFHas1OdMscAH9Gzq4Qcf7cfI/Pe6wEcAqQhqg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"recharts": ["recharts@3.7.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -965,6 +1004,8 @@
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
@@ -985,8 +1026,14 @@
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
@@ -995,7 +1042,9 @@
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -1009,6 +1058,8 @@
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"ts-morph": ["ts-morph@27.0.2", "", { "dependencies": { "@ts-morph/common": "~0.28.1", "code-block-writer": "^13.0.3" } }, "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1025,12 +1076,8 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
@@ -1071,7 +1118,7 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -1087,6 +1134,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -1099,6 +1148,8 @@
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+8
View File
@@ -1,6 +1,14 @@
# Changelog
## [Unreleased]
### Changed
- Perplexity OAuth token refresh now uses JWT expiry extraction instead of Socket.IO RPC, improving reliability when server is unreachable
- Removed Socket.IO client implementation for Perplexity token refresh; tokens are now validated using embedded JWT expiry claims
### Removed
- Removed `refreshPerplexityToken` export; token refresh is now handled internally via JWT expiry detection
## [12.0.0] - 2026-02-12
+32 -4
View File
@@ -9,7 +9,6 @@ import { refreshAntigravityToken } from "./google-antigravity";
import { refreshGoogleCloudToken } from "./google-gemini-cli";
import { refreshKimiToken } from "./kimi";
import { refreshOpenAICodexToken } from "./openai-codex";
import { refreshPerplexityToken } from "./perplexity";
import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types";
/**
@@ -56,7 +55,7 @@ export { loginOpenAICodex, refreshOpenAICodexToken } from "./openai-codex";
// OpenCode (API key)
export { loginOpenCode } from "./opencode";
// Perplexity
export { loginPerplexity, refreshPerplexityToken } from "./perplexity";
export { loginPerplexity } from "./perplexity";
export * from "./types";
// Z.AI (API key)
export { loginZai } from "./zai";
@@ -104,8 +103,6 @@ export async function refreshOAuthToken(
newCredentials = await refreshCursorToken(credentials.refresh);
break;
case "perplexity":
newCredentials = await refreshPerplexityToken(credentials.refresh);
break;
case "opencode":
case "zai":
case "minimax-code":
@@ -120,6 +117,20 @@ export async function refreshOAuthToken(
return newCredentials;
}
function getPerplexityJwtExpiryMs(token: string): number | undefined {
const parts = token.split(".");
if (parts.length !== 3) return undefined;
const payload = parts[1];
if (!payload) return undefined;
try {
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
return decoded.exp * 1000 - 5 * 60_000;
} catch {
return undefined;
}
}
/**
* Get API key for a provider from OAuth credentials.
* Automatically refreshes expired tokens.
@@ -138,11 +149,28 @@ export async function getOAuthApiKey(
return null;
}
if (provider === "perplexity") {
const normalizedExpires =
creds.expires > 0 && creds.expires < 10_000_000_000 ? creds.expires * 1000 : creds.expires;
const jwtExpiry = getPerplexityJwtExpiryMs(creds.access);
const expires = jwtExpiry && jwtExpiry > normalizedExpires ? jwtExpiry : normalizedExpires;
if (expires !== creds.expires) {
creds = { ...creds, expires };
}
}
// Refresh if expired
if (Date.now() >= creds.expires) {
try {
creds = await refreshOAuthToken(provider, creds);
} catch {
if (provider === "perplexity") {
const jwtExpiry = getPerplexityJwtExpiryMs(creds.access);
if (jwtExpiry && Date.now() < jwtExpiry) {
const fallbackCredentials = { ...creds, expires: jwtExpiry };
return { newCredentials: fallbackCredentials, apiKey: fallbackCredentials.access };
}
}
throw new Error(`Failed to refresh OAuth token for ${provider}`);
}
}
-217
View File
@@ -16,195 +16,9 @@ import { $env } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import type { OAuthController, OAuthCredentials } from "./types";
const WS_URL = "wss://www.perplexity.ai/socket.io/?EIO=4&transport=websocket";
const API_VERSION = "2.18";
const NATIVE_APP_BUNDLE = "ai.perplexity.mac";
const APP_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
const CONNECT_TIMEOUT_MS = 10_000;
const RPC_TIMEOUT_MS = 30_000;
// ---------------------------------------------------------------------------
// Socket.IO v4 client (Engine.IO v4 transport: WebSocket only)
// ---------------------------------------------------------------------------
/**
* Minimal Socket.IO v4 client over Engine.IO v4 WebSocket transport.
*
* Supports:
* - Authenticated namespace connect (`40{...auth}`)
* - Fire-and-forget events (Socket.IO EVENT, type `42`)
* - Request-reply RPCs via ack pattern (EVENT with ack id → ACK response)
* - Engine.IO ping/pong keepalive
*/
class PerplexitySocket {
#ws: WebSocket;
#nextAckId = 0;
#pendingAcks = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: Timer }>();
#ready: Promise<void>;
#messageHandler?: (data: string) => void;
constructor(auth?: Record<string, string>) {
this.#ws = new WebSocket(WS_URL, {
headers: {
"User-Agent": "PerplexityApp",
"X-App-ApiVersion": API_VERSION,
},
} as unknown as string[]);
const { promise, resolve, reject } = Promise.withResolvers<void>();
this.#ready = promise;
const timeout = setTimeout(() => reject(new Error("Socket.IO connection timeout")), CONNECT_TIMEOUT_MS);
let phase = 0;
this.#ws.onmessage = ev => {
const data = String(ev.data);
// Engine.IO ping → pong
if (data === "2") {
this.#ws.send("3");
return;
}
// Phase 0: Engine.IO OPEN packet → send Socket.IO CONNECT
if (data.startsWith("0{") && phase === 0) {
phase = 1;
const payload = auth ? `40${JSON.stringify(auth)}` : "40";
this.#ws.send(payload);
return;
}
// Phase 1: Socket.IO CONNECT acknowledgment
if (data.startsWith("40") && phase === 1) {
clearTimeout(timeout);
phase = 2;
this.#installRpcHandler();
resolve();
return;
}
// Socket.IO CONNECT_ERROR (type 44)
if (data.startsWith("44")) {
clearTimeout(timeout);
const body = data.slice(2);
reject(new Error(`Socket.IO connect error: ${body}`));
}
};
this.#ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("WebSocket error"));
};
this.#ws.onclose = () => {
clearTimeout(timeout);
reject(new Error("WebSocket closed during handshake"));
};
}
#installRpcHandler(): void {
this.#ws.onmessage = ev => {
const data = String(ev.data);
if (data === "2") {
this.#ws.send("3");
return;
}
// Forward to external listener if set
this.#messageHandler?.(data);
// Socket.IO ACK: 43<ackId><json>
if (data.startsWith("43")) {
const match = data.match(/^43(\d+)([\s\S]*)/);
if (!match) return;
const ackId = Number.parseInt(match[1], 10);
const pending = this.#pendingAcks.get(ackId);
if (!pending) return;
this.#pendingAcks.delete(ackId);
clearTimeout(pending.timer);
let payload: unknown;
try {
payload = match[2] ? JSON.parse(match[2]) : undefined;
} catch {
pending.reject(new Error(`Malformed ACK payload for ack ${ackId}`));
return;
}
// ACK payload is a JSON array; unwrap first element
const result = Array.isArray(payload) ? (payload[0] as Record<string, unknown>) : payload;
if (result && typeof result === "object" && "error" in result) {
const r = result as Record<string, unknown>;
const msg = (r.error_message ?? r.error_code ?? r.error ?? "RPC error") as string;
pending.reject(new Error(msg));
} else {
pending.resolve(result);
}
}
};
this.#ws.onclose = () => {
for (const p of this.#pendingAcks.values()) {
clearTimeout(p.timer);
p.reject(new Error("Socket closed"));
}
this.#pendingAcks.clear();
};
}
/** Wait for the connection handshake to complete. */
ready(): Promise<void> {
return this.#ready;
}
/** Set an external message listener (for debugging or event handling). */
onMessage(handler: (data: string) => void): void {
this.#messageHandler = handler;
}
/** Fire-and-forget event (Socket.IO EVENT, no ack). */
emit(event: string, data: unknown): void {
this.#ws.send(`42${JSON.stringify([event, data])}`);
}
/** Emit event and wait for ack response (request-reply RPC). */
emitWithAck<T = unknown>(event: string, data: unknown, timeoutMs = RPC_TIMEOUT_MS): Promise<T> {
const id = this.#nextAckId++;
const { promise, resolve, reject } = Promise.withResolvers<T>();
const timer = setTimeout(() => {
this.#pendingAcks.delete(id);
reject(new Error(`RPC timeout: ${event}`));
}, timeoutMs);
this.#pendingAcks.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
timer,
});
// Socket.IO EVENT with ack: 42<ackId>["event", data]
this.#ws.send(`42${id}${JSON.stringify([event, data])}`);
return promise;
}
/** Close the socket and reject all pending RPCs. */
close(): void {
for (const p of this.#pendingAcks.values()) {
clearTimeout(p.timer);
p.reject(new Error("Socket closed"));
}
this.#pendingAcks.clear();
try {
this.#ws.close();
} catch {
// Ignore close errors
}
}
}
// ---------------------------------------------------------------------------
// JWT helpers
@@ -384,34 +198,3 @@ export async function loginPerplexity(ctrl: OAuthController): Promise<OAuthCrede
// Path 2: HTTP email OTP
return httpEmailLogin(ctrl);
}
/**
* Refresh a Perplexity JWT via Socket.IO `refreshJWT` RPC.
*
* Connects an authenticated socket using the current JWT, then requests a fresh one.
* Falls back to returning the existing token if refresh fails (e.g., server unreachable).
*/
export async function refreshPerplexityToken(currentJwt: string): Promise<OAuthCredentials> {
const socket = new PerplexitySocket({ jwt: currentJwt });
try {
await socket.ready();
const response = await socket.emitWithAck<Record<string, unknown>>("refreshJWT", { jwt: currentJwt });
// Response field is `perplexity_jwt` or `jwt`
const newJwt =
typeof response?.perplexity_jwt === "string"
? response.perplexity_jwt
: typeof response?.jwt === "string"
? response.jwt
: undefined;
if (!newJwt) {
throw new Error("No JWT in refreshJWT response");
}
return jwtToCredentials(newJwt);
} finally {
socket.close();
}
}
+9
View File
@@ -1,6 +1,15 @@
# Changelog
## [Unreleased]
### Added
- Added real-time authentication validation in OAuth provider selector with visual status indicators (checking, valid, invalid)
- Added `validateAuth` and `requestRender` options to OAuthSelectorComponent for custom authentication validation and UI refresh callbacks
### Changed
- Changed OAuth provider selector to display live authentication status instead of static login state
- Changed logout flow to refresh OAuth provider authentication state before showing selector
## [12.1.0] - 2026-02-13
@@ -3,9 +3,8 @@ import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import type { AuthStorage } from "../../session/auth-storage";
import { DynamicBorder } from "./dynamic-border";
/**
* Component that renders an OAuth provider selector
* Component that renders an OAuth provider selector.
*/
export class OAuthSelectorComponent extends Container {
#listContainer: Container;
@@ -16,62 +15,139 @@ export class OAuthSelectorComponent extends Container {
#onSelectCallback: (providerId: string) => void;
#onCancelCallback: () => void;
#statusMessage: string | undefined;
#validateAuthCallback?: (providerId: string) => Promise<boolean>;
#requestRenderCallback?: () => void;
#authState: Map<string, "checking" | "valid" | "invalid"> = new Map();
#spinnerFrame: number = 0;
#spinnerInterval?: NodeJS.Timeout;
#validationGeneration: number = 0;
constructor(
mode: "login" | "logout",
authStorage: AuthStorage,
onSelect: (providerId: string) => void,
onCancel: () => void,
options?: {
validateAuth?: (providerId: string) => Promise<boolean>;
requestRender?: () => void;
},
) {
super();
this.#mode = mode;
this.#authStorage = authStorage;
this.#onSelectCallback = onSelect;
this.#onCancelCallback = onCancel;
this.#validateAuthCallback = options?.validateAuth;
this.#requestRenderCallback = options?.requestRender;
// Load all OAuth providers
this.#loadProviders();
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
this.addChild(new TruncatedText(theme.bold(title)));
this.addChild(new Spacer(1));
// Create list container
this.#listContainer = new Container();
this.addChild(this.#listContainer);
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
// Initial render
this.#updateList();
this.#startValidation();
}
stopValidation(): void {
this.#validationGeneration += 1;
this.#stopSpinner();
}
#loadProviders(): void {
this.#allProviders = getOAuthProviders();
}
#startValidation(): void {
if (!this.#validateAuthCallback) return;
const generation = this.#validationGeneration + 1;
this.#validationGeneration = generation;
let pending = 0;
for (const provider of this.#allProviders) {
if (!this.#authStorage.hasAuth(provider.id)) {
this.#authState.delete(provider.id);
continue;
}
this.#authState.set(provider.id, "checking");
pending += 1;
void this.#validateProvider(provider.id, generation);
}
if (pending > 0) {
this.#startSpinner();
this.#updateList();
this.#requestRenderCallback?.();
}
}
async #validateProvider(providerId: string, generation: number): Promise<void> {
if (!this.#validateAuthCallback) return;
let isValid = false;
try {
isValid = await this.#validateAuthCallback(providerId);
} catch {
isValid = false;
}
if (generation !== this.#validationGeneration) return;
this.#authState.set(providerId, isValid ? "valid" : "invalid");
if (![...this.#authState.values()].includes("checking")) {
this.#stopSpinner();
}
this.#updateList();
this.#requestRenderCallback?.();
}
#startSpinner(): void {
if (this.#spinnerInterval) return;
this.#spinnerInterval = setInterval(() => {
const frameCount = theme.spinnerFrames.length;
if (frameCount > 0) {
this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
}
this.#updateList();
this.#requestRenderCallback?.();
}, 80);
}
#stopSpinner(): void {
if (this.#spinnerInterval) {
clearInterval(this.#spinnerInterval);
this.#spinnerInterval = undefined;
}
}
#getStatusIndicator(providerId: string): string {
const state = this.#authState.get(providerId);
if (state === "checking") {
const frameCount = theme.spinnerFrames.length;
const spinner = frameCount > 0 ? theme.spinnerFrames[this.#spinnerFrame % frameCount] : theme.status.pending;
return theme.fg("warning", ` ${spinner} checking`);
}
if (state === "invalid") {
return theme.fg("error", ` ${theme.status.error} invalid`);
}
if (state === "valid") {
return theme.fg("success", ` ${theme.status.success} logged in`);
}
return this.#authStorage.hasAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
}
#updateList(): void {
this.#listContainer.clear();
for (let i = 0; i < this.#allProviders.length; i++) {
const provider = this.#allProviders[i];
if (!provider) continue;
const isSelected = i === this.#selectedIndex;
const isAvailable = provider.available;
// Check if user is logged in for this provider
const isLoggedIn = this.#authStorage.hasOAuth(provider.id);
const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
const statusIndicator = this.#getStatusIndicator(provider.id);
let line = "";
if (isSelected) {
@@ -82,7 +158,6 @@ export class OAuthSelectorComponent extends Container {
const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
line = text + statusIndicator;
}
this.#listContainer.addChild(new TruncatedText(line, 0, 0));
}
@@ -92,13 +167,11 @@ export class OAuthSelectorComponent extends Container {
this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
}
if (this.#statusMessage) {
this.#listContainer.addChild(new Spacer(1));
this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
}
}
handleInput(keyData: string): void {
// Up arrow
if (matchesKey(keyData, "up")) {
@@ -121,6 +194,7 @@ export class OAuthSelectorComponent extends Container {
const selectedProvider = this.#allProviders[this.#selectedIndex];
if (selectedProvider?.available) {
this.#statusMessage = undefined;
this.stopValidation();
this.#onSelectCallback(selectedProvider.id);
} else if (selectedProvider) {
this.#statusMessage = "Provider unavailable in this environment.";
@@ -129,6 +203,7 @@ export class OAuthSelectorComponent extends Container {
}
// Escape or Ctrl+C
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
this.stopValidation();
this.#onCancelCallback();
}
}
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { OAuthProvider } from "@oh-my-pi/pi-ai";
import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
@@ -32,6 +32,16 @@ import { setPreferredImageProvider, setPreferredSearchProvider } from "../../too
export class SelectorController {
constructor(private ctx: InteractiveModeContext) {}
async #refreshOAuthProviderAuthState(): Promise<void> {
const oauthProviders = getOAuthProviders();
await Promise.all(
oauthProviders.map(provider =>
this.ctx.session.modelRegistry
.getApiKeyForProvider(provider.id, this.ctx.session.sessionId)
.catch(() => undefined),
),
);
}
/**
* Shows a selector component in place of the editor.
* @param create Factory that receives a `done` callback and returns the component and focus target
@@ -536,8 +546,11 @@ export class SelectorController {
async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
if (mode === "logout") {
const providers = this.ctx.session.modelRegistry.authStorage.list();
const loggedInProviders = providers.filter(p => this.ctx.session.modelRegistry.authStorage.hasOAuth(p));
await this.#refreshOAuthProviderAuthState();
const oauthProviders = getOAuthProviders();
const loggedInProviders = oauthProviders.filter(provider =>
this.ctx.session.modelRegistry.authStorage.hasAuth(provider.id),
);
if (loggedInProviders.length === 0) {
this.ctx.showStatus("No OAuth providers logged in. Use /login first.");
return;
@@ -545,15 +558,15 @@ export class SelectorController {
}
this.showSelector(done => {
const selector = new OAuthSelectorComponent(
let selector: OAuthSelectorComponent;
selector = new OAuthSelectorComponent(
mode,
this.ctx.session.modelRegistry.authStorage,
async (providerId: string) => {
selector.stopValidation();
done();
if (mode === "login") {
this.ctx.showStatus(`Logging in to ${providerId}…`);
try {
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
onAuth: (info: { url: string; instructions?: string }) => {
@@ -567,7 +580,6 @@ export class SelectorController {
this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
}
this.ctx.ui.requestRender();
this.ctx.openInBrowser(info.url);
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
@@ -577,7 +589,6 @@ export class SelectorController {
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
}
this.ctx.ui.requestRender();
return new Promise<string>(resolve => {
const codeInput = new Input();
codeInput.onSubmit = () => {
@@ -638,9 +649,22 @@ export class SelectorController {
}
},
() => {
selector.stopValidation();
done();
this.ctx.ui.requestRender();
},
{
validateAuth: async (providerId: string) => {
const apiKey = await this.ctx.session.modelRegistry.getApiKeyForProvider(
providerId,
this.ctx.session.sessionId,
);
return !!apiKey;
},
requestRender: () => {
this.ctx.ui.requestRender();
},
},
);
return { component: selector, focus: selector };
});
+11 -6
View File
@@ -48,22 +48,27 @@
"directory": "packages/stats"
},
"homepage": "https://github.com/can1357/oh-my-pi",
"bugs": { "url": "https://github.com/can1357/oh-my-pi/issues" },
"bugs": {
"url": "https://github.com/can1357/oh-my-pi/issues"
},
"dependencies": {
"@oh-my-pi/pi-ai": "workspace:*",
"@oh-my-pi/pi-utils": "workspace:*",
"chart.js": "4.5.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.7.0",
"@oh-my-pi/pi-utils": "workspace:*"
"react-chartjs-2": "5.3.1",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3"
"@types/react-dom": "^19.2.3",
"postcss": "8.5.6",
"tailwindcss": "3"
},
"engines": {
"bun": ">=1.3.7"
}
}
}
+58 -555
View File
@@ -1,381 +1,14 @@
import { format } from "date-fns";
import { Activity, AlertCircle, BarChart2, ChevronDown, ChevronUp, Database, RefreshCw, Server } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
import { ChartsContainer } from "./components/ChartsContainer";
import { Header } from "./components/Header";
import { ModelsTable } from "./components/ModelsTable";
import { RequestDetail } from "./components/RequestDetail";
import { RequestList } from "./components/RequestList";
import { StatCard } from "./components/StatCard";
import type { DashboardStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint } from "./types";
import { StatsGrid } from "./components/StatsGrid";
import type { DashboardStats, MessageStats } from "./types";
const MODEL_COLORS = ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#38bdf8", "#f472b6"];
function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
return (
<div
style={{
background: "var(--bg-secondary)",
borderRadius: "12px",
border: "1px solid var(--border)",
overflow: "hidden",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
<div style={{ fontSize: "1rem", fontWeight: 600 }}>{title}</div>
{subtitle ? <div style={{ color: "var(--text-secondary)", fontSize: "0.8rem" }}>{subtitle}</div> : null}
</div>
<div style={{ flex: 1, padding: "12px 16px" }}>{children}</div>
</div>
);
}
function formatDateTick(timestamp: number): string {
return format(new Date(timestamp), "MMM d");
}
function buildModelPreferenceSeries(
points: ModelTimeSeriesPoint[],
topN = 5,
): {
data: Array<Record<string, number>>;
series: string[];
} {
if (points.length === 0) return { data: [], series: [] };
const totals = new Map<string, { label: string; total: number }>();
for (const point of points) {
const key = `${point.model}::${point.provider}`;
const label = `${point.model} (${point.provider})`;
const existing = totals.get(key);
if (existing) {
existing.total += point.requests;
} else {
totals.set(key, { label, total: point.requests });
}
}
const sorted = [...totals.values()].sort((a, b) => b.total - a.total);
const topLabels = sorted.slice(0, topN).map(entry => entry.label);
const dataMap = new Map<number, Record<string, number>>();
for (const point of points) {
const label = `${point.model} (${point.provider})`;
const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 };
bucket.total += point.requests;
const key = topLabels.includes(label) ? label : "Other";
bucket[key] = (bucket[key] ?? 0) + point.requests;
dataMap.set(point.timestamp, bucket);
}
const series = [...topLabels];
if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
series.push("Other");
}
const data = [...dataMap.values()]
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
.map(row => {
const total = row.total ?? 0;
for (const key of series) {
row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
}
return row;
});
return { data, series };
}
type ModelPerformanceSeries = {
label: string;
data: Array<{
timestamp: number;
avgTtftSeconds: number | null;
avgTokensPerSecond: number | null;
requests: number;
}>;
};
function buildModelPerformanceLookup(
points: ModelPerformancePoint[],
days = 14,
): { buckets: number[]; seriesByKey: Map<string, ModelPerformanceSeries> } {
const dayMs = 24 * 60 * 60 * 1000;
const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / dayMs) * dayMs;
const start = anchor - (days - 1) * dayMs;
const buckets = Array.from({ length: days }, (_, index) => start + index * dayMs);
const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
const seriesByKey = new Map<string, ModelPerformanceSeries>();
for (const point of points) {
const key = `${point.model}::${point.provider}`;
let series = seriesByKey.get(key);
if (!series) {
series = {
label: `${point.model} (${point.provider})`,
data: buckets.map(timestamp => ({
timestamp,
avgTtftSeconds: null,
avgTokensPerSecond: null,
requests: 0,
})),
};
seriesByKey.set(key, series);
}
const index = bucketIndex.get(point.timestamp);
if (index === undefined) continue;
series.data[index] = {
timestamp: point.timestamp,
avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
avgTokensPerSecond: point.avgTokensPerSecond,
requests: point.requests,
};
}
return { buckets, seriesByKey };
}
function ModelStatsTable({
models,
performanceSeriesByKey,
}: {
models: ModelStats[];
performanceSeriesByKey: Map<string, ModelPerformanceSeries>;
}) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const sortedModels = [...models].sort(
(a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
);
return (
<div
style={{
background: "var(--bg-secondary)",
borderRadius: "12px",
border: "1px solid var(--border)",
overflow: "hidden",
}}
>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
<h3 style={{ margin: 0, fontSize: "1rem" }}>Model Statistics</h3>
</div>
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "2.4fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 160px 32px",
gap: "12px",
padding: "12px 20px",
color: "var(--text-secondary)",
fontSize: "0.75rem",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
<div>Model</div>
<div style={{ textAlign: "right" }}>Requests</div>
<div style={{ textAlign: "right" }}>Cost</div>
<div style={{ textAlign: "right" }}>Tokens</div>
<div style={{ textAlign: "right" }}>Tokens/s</div>
<div style={{ textAlign: "right" }}>TTFT</div>
<div style={{ textAlign: "center" }}>14d Trend</div>
<div />
</div>
<div style={{ maxHeight: "calc(100vh - 260px)", overflowY: "auto" }}>
{sortedModels.map((model, index) => {
const key = `${model.model}::${model.provider}`;
const performance = performanceSeriesByKey.get(key);
const trendData = performance?.data ?? [];
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
const isExpanded = expandedKey === key;
return (
<div key={key} style={{ borderTop: "1px solid var(--border)" }}>
<button
type="button"
onClick={() => setExpandedKey(isExpanded ? null : key)}
style={{
width: "100%",
background: "transparent",
border: "none",
color: "inherit",
padding: "12px 20px",
textAlign: "left",
cursor: "pointer",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "2.4fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 160px 32px",
gap: "12px",
alignItems: "center",
}}
>
<div>
<div style={{ fontWeight: 600 }}>{model.model}</div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.8rem" }}>
{model.provider}
</div>
</div>
<div style={{ textAlign: "right" }}>{model.totalRequests.toLocaleString()}</div>
<div style={{ textAlign: "right" }}>${model.totalCost.toFixed(2)}</div>
<div style={{ textAlign: "right" }}>
{(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
</div>
<div style={{ textAlign: "right" }}>{model.avgTokensPerSecond?.toFixed(1) ?? "-"}</div>
<div style={{ textAlign: "right" }}>
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</div>
<div style={{ height: 40 }}>
{trendData.length === 0 ? (
<div style={{ color: "var(--text-secondary)", textAlign: "center" }}>-</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData}>
<Line
type="monotone"
dataKey="avgTokensPerSecond"
stroke={trendColor}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
<div style={{ display: "flex", justifyContent: "center" }}>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
</button>
{isExpanded && (
<div
style={{
padding: "16px 20px 20px",
background: "rgba(0,0,0,0.2)",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "240px 1fr",
gap: "16px",
alignItems: "stretch",
}}
>
<div style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
<div style={{ marginBottom: "8px" }}>
<div style={{ color: "var(--text-primary)", fontWeight: 600 }}>Quality</div>
<div>Errors: {(model.errorRate * 100).toFixed(1)}%</div>
<div>Cache rate: {(model.cacheRate * 100).toFixed(1)}%</div>
</div>
<div>
<div style={{ color: "var(--text-primary)", fontWeight: 600 }}>Latency</div>
<div>
Avg duration:{" "}
{model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
</div>
<div>
Avg TTFT: {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</div>
</div>
</div>
<div style={{ height: 180 }}>
{trendData.length === 0 ? (
<div
style={{
color: "var(--text-secondary)",
textAlign: "center",
paddingTop: "40px",
}}
>
No data yet
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData} margin={{ left: 4, right: 8, top: 8, bottom: 4 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={formatDateTick}
stroke="var(--text-secondary)"
/>
<YAxis
yAxisId="left"
stroke="var(--text-secondary)"
tickFormatter={value => `${value}s`}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="var(--text-secondary)"
/>
<Tooltip
labelFormatter={(label: ReactNode) =>
typeof label === "number" ? formatDateTick(label) : ""
}
formatter={(
value: number | string | undefined,
name: string | undefined,
) => {
const numericValue = value ?? 0;
if (name === "avgTtftSeconds")
return [`${Number(numericValue).toFixed(2)}s`, "TTFT"];
return [`${Number(numericValue).toFixed(1)}`, "Tokens/s"];
}}
/>
<Legend
formatter={value => (value === "avgTtftSeconds" ? "TTFT" : "Tokens/s")}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="avgTtftSeconds"
stroke="#fbbf24"
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="avgTokensPerSecond"
stroke={trendColor}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
type Tab = "overview" | "requests" | "errors" | "models";
export default function App() {
const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -383,7 +16,7 @@ export default function App() {
const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<"overview" | "requests" | "errors" | "models">("overview");
const [activeTab, setActiveTab] = useState<Tab>("overview");
const loadData = useCallback(async () => {
try {
@@ -412,202 +45,72 @@ export default function App() {
return () => clearInterval(interval);
}, [loadData]);
if (!stats) return <div style={{ padding: 40, textAlign: "center" }}>Loading stats...</div>;
const { seriesByKey: performanceSeriesByKey } = buildModelPerformanceLookup(stats.modelPerformanceSeries);
const modelPreference = buildModelPreferenceSeries(stats.modelSeries);
if (!stats) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-muted)]">
<div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
<span className="text-sm">Loading analytics...</span>
</div>
</div>
);
}
return (
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "20px" }}>
<header
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "30px",
paddingBottom: "20px",
borderBottom: "1px solid var(--border)",
}}
>
<h1 style={{ margin: 0, fontSize: "1.5rem", display: "flex", alignItems: "center", gap: "10px" }}>
<Activity color="var(--accent)" />
AI Usage Statistics
</h1>
<div style={{ display: "flex", gap: "15px", alignItems: "center" }}>
<div style={{ display: "flex", background: "var(--bg-secondary)", borderRadius: "6px", padding: "4px" }}>
{(["overview", "requests", "errors", "models"] as const).map(tab => (
<button
type="button"
key={tab}
onClick={() => setActiveTab(tab)}
style={{
background: activeTab === tab ? "var(--bg-card)" : "transparent",
color: activeTab === tab ? "var(--text-primary)" : "var(--text-secondary)",
border: "none",
padding: "6px 16px",
borderRadius: "4px",
cursor: "pointer",
textTransform: "capitalize",
fontWeight: 500,
}}
>
{tab}
</button>
))}
</div>
<button
type="button"
onClick={handleSync}
disabled={syncing}
style={{
background: "var(--accent)",
color: "white",
border: "none",
padding: "8px 16px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
opacity: syncing ? 0.7 : 1,
}}
>
<RefreshCw size={16} className={syncing ? "spin" : ""} />
{syncing ? "Syncing..." : "Sync"}
</button>
</div>
</header>
<div className="min-h-screen">
<div className="max-w-[1600px] mx-auto px-6 py-6">
<Header activeTab={activeTab} onTabChange={setActiveTab} onSync={handleSync} syncing={syncing} />
{activeTab === "overview" && (
<>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "20px",
marginBottom: "30px",
}}
>
<StatCard
title="Total Requests"
value={stats.overall.totalRequests.toLocaleString()}
detail={`${stats.overall.successfulRequests} success, ${stats.overall.failedRequests} errors`}
icon={<Server size={20} />}
/>
<StatCard
title="Total Cost"
value={`$${stats.overall.totalCost.toFixed(2)}`}
detail={
stats.overall.totalRequests > 0
? `$${(stats.overall.totalCost / stats.overall.totalRequests).toFixed(4)} avg/req`
: "-"
}
icon={<Activity size={20} />}
/>
<StatCard
title="Cache Rate"
value={`${(stats.overall.cacheRate * 100).toFixed(1)}%`}
detail={`${(stats.overall.totalCacheReadTokens / 1000).toFixed(1)}k cached tokens`}
icon={<Database size={20} />}
/>
<StatCard
title="Error Rate"
value={`${(stats.overall.errorRate * 100).toFixed(1)}%`}
detail={`${stats.overall.failedRequests} failed requests`}
icon={<AlertCircle size={20} />}
color="var(--error)"
/>
<StatCard
title="Tokens/Sec"
value={stats.overall.avgTokensPerSecond?.toFixed(1) ?? "-"}
detail={`${(stats.overall.totalInputTokens + stats.overall.totalOutputTokens).toLocaleString()} total tokens`}
icon={<BarChart2 size={20} />}
/>
<StatCard
title="TTFT"
value={stats.overall.avgTtft ? `${(stats.overall.avgTtft / 1000).toFixed(2)}s` : "-"}
detail="Time to first token"
icon={<Activity size={20} />}
/>
</div>
{activeTab === "overview" && (
<div className="space-y-6 animate-fade-in">
<StatsGrid stats={stats.overall} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px", height: "400px" }}>
<div className="grid lg:grid-cols-2 gap-6">
<RequestList
title="Recent Requests"
requests={recentRequests.slice(0, 10)}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
<RequestList
title="Recent Errors"
requests={recentErrors.slice(0, 10)}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
</div>
)}
{activeTab === "requests" && (
<div className="h-[calc(100vh-140px)] animate-fade-in">
<RequestList
title="Recent Requests"
title="All Recent Requests"
requests={recentRequests}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
)}
{activeTab === "errors" && (
<div className="h-[calc(100vh-140px)] animate-fade-in">
<RequestList
title="Recent Errors"
title="Failed Requests"
requests={recentErrors}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
</>
)}
)}
{activeTab === "requests" && (
<div style={{ height: "calc(100vh - 150px)" }}>
<RequestList
title="All Recent Requests"
requests={recentRequests}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
)}
{activeTab === "models" && (
<div className="space-y-6 animate-fade-in">
<ChartsContainer modelSeries={stats.modelSeries} />
<ModelsTable models={stats.byModel} performanceSeries={stats.modelPerformanceSeries} />
</div>
)}
{activeTab === "errors" && (
<div style={{ height: "calc(100vh - 150px)" }}>
<RequestList
title="Failed Requests"
requests={recentErrors}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
)}
{activeTab === "models" && (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<ChartCard title="Model Preference" subtitle="Share of requests, last 14 days">
{modelPreference.data.length === 0 ? (
<div style={{ color: "var(--text-secondary)", textAlign: "center", paddingTop: "40px" }}>
No data yet
</div>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={modelPreference.data} margin={{ left: 4, right: 8, top: 8, bottom: 4 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="timestamp" tickFormatter={formatDateTick} stroke="var(--text-secondary)" />
<YAxis stroke="var(--text-secondary)" tickFormatter={value => `${value}%`} />
<Tooltip
labelFormatter={(label: ReactNode) =>
typeof label === "number" ? formatDateTick(label) : ""
}
formatter={(value: number | string | undefined) => [
`${Number(value ?? 0).toFixed(1)}%`,
"Share",
]}
/>
<Legend />
{modelPreference.series.map((seriesName, index) => (
<Area
key={seriesName}
dataKey={seriesName}
stackId="1"
stroke={MODEL_COLORS[index % MODEL_COLORS.length]}
fill={MODEL_COLORS[index % MODEL_COLORS.length]}
fillOpacity={0.25}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
<ModelStatsTable models={stats.byModel} performanceSeriesByKey={performanceSeriesByKey} />
</div>
)}
{selectedRequest !== null && <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />}
{selectedRequest !== null && (
<RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
)}
</div>
</div>
);
}
@@ -0,0 +1,185 @@
import {
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { format } from "date-fns";
import { useMemo } from "react";
import { Line } from "react-chartjs-2";
import type { ModelTimeSeriesPoint } from "../types";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
const MODEL_COLORS = [
"#a78bfa", // violet
"#22d3ee", // cyan
"#ec4899", // pink
"#4ade80", // green
"#fbbf24", // amber
"#f87171", // red
"#60a5fa", // blue
];
interface ChartsContainerProps {
modelSeries: ModelTimeSeriesPoint[];
}
export function ChartsContainer({ modelSeries }: ChartsContainerProps) {
const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]);
const data = {
labels: chartData.data.map(d => format(new Date(d.timestamp), "MMM d")),
datasets: chartData.series.map((seriesName, index) => ({
label: seriesName,
data: chartData.data.map(d => d[seriesName] ?? 0),
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2,
})),
};
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
plugins: {
legend: {
position: "top" as const,
align: "start" as const,
labels: {
color: "var(--text-secondary)",
usePointStyle: true,
padding: 16,
font: { size: 12 },
boxWidth: 8,
},
},
tooltip: {
backgroundColor: "var(--bg-elevated)",
titleColor: "var(--text-primary)",
bodyColor: "var(--text-secondary)",
borderColor: "var(--border-default)",
borderWidth: 1,
padding: 12,
cornerRadius: 8,
callbacks: {
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
const label = context.dataset.label ?? "";
const value = context.parsed.y;
return `${label}: ${(value ?? 0).toFixed(1)}%`;
},
},
},
},
scales: {
x: {
grid: {
color: "var(--border-subtle)",
drawBorder: false,
},
ticks: {
color: "var(--text-muted)",
font: { size: 11 },
},
},
y: {
grid: {
color: "var(--border-subtle)",
drawBorder: false,
},
ticks: {
color: "var(--text-muted)",
font: { size: 11 },
callback: (value: number | string) => `${value}%`,
},
min: 0,
max: 100,
},
},
};
return (
<div className="surface overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Preference</h3>
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over the last 14 days</p>
</div>
<div className="p-5 min-h-[320px]">
{chartData.data.length === 0 ? (
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
No data available
</div>
) : (
<div className="h-[280px]">
<Line data={data} options={options} />
</div>
)}
</div>
</div>
);
}
function buildModelPreferenceSeries(
points: ModelTimeSeriesPoint[],
topN = 5,
): {
data: Array<Record<string, number>>;
series: string[];
} {
if (points.length === 0) return { data: [], series: [] };
const totals = new Map<string, { label: string; total: number }>();
for (const point of points) {
const key = `${point.model}::${point.provider}`;
const label = `${point.model} (${point.provider})`;
const existing = totals.get(key);
if (existing) {
existing.total += point.requests;
} else {
totals.set(key, { label, total: point.requests });
}
}
const sorted = [...totals.values()].sort((a, b) => b.total - a.total);
const topLabels = sorted.slice(0, topN).map(entry => entry.label);
const dataMap = new Map<number, Record<string, number>>();
for (const point of points) {
const label = `${point.model} (${point.provider})`;
const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 };
bucket.total += point.requests;
const key = topLabels.includes(label) ? label : "Other";
bucket[key] = (bucket[key] ?? 0) + point.requests;
dataMap.set(point.timestamp, bucket);
}
const series = [...topLabels];
if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
series.push("Other");
}
const data = [...dataMap.values()]
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
.map(row => {
const total = row.total ?? 0;
for (const key of series) {
row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
}
return row;
});
return { data, series };
}
@@ -0,0 +1,48 @@
import { Activity, RefreshCw } from "lucide-react";
type Tab = "overview" | "requests" | "errors" | "models";
interface HeaderProps {
activeTab: Tab;
onTabChange: (tab: Tab) => void;
onSync: () => void;
syncing: boolean;
}
const tabs: Tab[] = ["overview", "requests", "errors", "models"];
export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) {
return (
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 mb-8 border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-gradient-to-br from-[var(--accent-pink)] to-[var(--accent-cyan)] flex items-center justify-center shadow-lg">
<Activity className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">AI Usage</h1>
<p className="text-sm text-[var(--text-muted)]">Statistics & Analytics</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
{tabs.map(tab => (
<button
key={tab}
type="button"
onClick={() => onTabChange(tab)}
className={`tab-btn capitalize ${activeTab === tab ? "active" : ""}`}
>
{tab}
</button>
))}
</div>
<button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
<RefreshCw size={16} className={syncing ? "spin" : ""} />
{syncing ? "Syncing..." : "Sync"}
</button>
</div>
</header>
);
}
@@ -0,0 +1,343 @@
import {
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { format } from "date-fns";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useMemo, useState } from "react";
import { Line } from "react-chartjs-2";
import type { ModelPerformancePoint, ModelStats } from "../types";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
const MODEL_COLORS = [
"#a78bfa", // violet
"#22d3ee", // cyan
"#ec4899", // pink
"#4ade80", // green
"#fbbf24", // amber
"#f87171", // red
"#60a5fa", // blue
];
interface ModelsTableProps {
models: ModelStats[];
performanceSeries: ModelPerformancePoint[];
}
type ModelPerformanceSeries = {
label: string;
data: Array<{
timestamp: number;
avgTtftSeconds: number | null;
avgTokensPerSecond: number | null;
requests: number;
}>;
};
export function ModelsTable({ models, performanceSeries }: ModelsTableProps) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const performanceSeriesByKey = useMemo(() => buildModelPerformanceLookup(performanceSeries), [performanceSeries]);
const sortedModels = [...models].sort(
(a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
);
return (
<div className="surface overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Statistics</h3>
</div>
<div className="overflow-x-auto">
<div
className="grid gap-3 px-5 py-3 text-[var(--text-muted)] text-xs uppercase tracking-wider font-semibold"
style={{ gridTemplateColumns: "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px" }}
>
<div>Model</div>
<div className="text-right">Requests</div>
<div className="text-right">Cost</div>
<div className="text-right">Tokens</div>
<div className="text-right">Tokens/s</div>
<div className="text-right">TTFT</div>
<div className="text-center">14d Trend</div>
<div />
</div>
<div className="max-h-[calc(100vh-300px)] overflow-y-auto">
{sortedModels.map((model, index) => {
const key = `${model.model}::${model.provider}`;
const performance = performanceSeriesByKey.get(key);
const trendData = performance?.data ?? [];
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
const isExpanded = expandedKey === key;
const errorRate = model.errorRate * 100;
return (
<div key={key} className="border-t border-[var(--border-subtle)]">
<button
type="button"
onClick={() => setExpandedKey(isExpanded ? null : key)}
className="w-full bg-transparent border-none text-left px-5 py-3 cursor-pointer hover:bg-[var(--bg-hover)] transition-colors"
>
<div
className="grid gap-3 items-center"
style={{ gridTemplateColumns: "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px" }}
>
<div>
<div className="font-medium text-[var(--text-primary)]">{model.model}</div>
<div className="text-xs text-[var(--text-muted)]">{model.provider}</div>
</div>
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.totalRequests.toLocaleString()}
</div>
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
${model.totalCost.toFixed(2)}
</div>
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
{(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
</div>
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.avgTokensPerSecond?.toFixed(1) ?? "-"}
</div>
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</div>
<div className="h-10">
{trendData.length === 0 ? (
<div className="text-[var(--text-muted)] text-center text-sm">-</div>
) : (
<TrendChart data={trendData} color={trendColor} />
)}
</div>
<div className="flex justify-center text-[var(--text-muted)]">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
</button>
{isExpanded && (
<div className="px-5 py-4 bg-[var(--bg-elevated)] border-t border-[var(--border-subtle)]">
<div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
<div className="space-y-4 text-sm">
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Error rate</span>
<span
className={
errorRate > 5
? "text-[var(--accent-red)]"
: "text-[var(--accent-green)]"
}
>
{errorRate.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between">
<span>Cache rate</span>
<span className="text-[var(--accent-cyan)]">
{(model.cacheRate * 100).toFixed(1)}%
</span>
</div>
</div>
</div>
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Avg duration</span>
<span className="font-mono">
{model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Avg TTFT</span>
<span className="font-mono">
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</span>
</div>
</div>
</div>
</div>
<div className="h-[200px]">
{trendData.length === 0 ? (
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
No data available
</div>
) : (
<PerformanceChart data={trendData} color={trendColor} />
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
function TrendChart({
data,
color,
}: {
data: Array<{ timestamp: number; avgTokensPerSecond: number | null }>;
color: string;
}) {
const chartData = {
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
datasets: [
{
data: data.map(d => d.avgTokensPerSecond ?? 0),
borderColor: color,
backgroundColor: "transparent",
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { display: false, min: 0 },
},
};
return <Line data={chartData} options={options} />;
}
function PerformanceChart({
data,
color,
}: {
data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>;
color: string;
}) {
const chartData = {
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
datasets: [
{
label: "TTFT",
data: data.map(d => d.avgTtftSeconds ?? null),
borderColor: "#fbbf24",
backgroundColor: "transparent",
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
yAxisID: "y" as const,
},
{
label: "Tokens/s",
data: data.map(d => d.avgTokensPerSecond ?? null),
borderColor: color,
backgroundColor: "transparent",
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
yAxisID: "y1" as const,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "top" as const,
labels: {
color: "var(--text-secondary)",
usePointStyle: true,
padding: 12,
font: { size: 10 },
},
},
tooltip: {
backgroundColor: "var(--bg-elevated)",
titleColor: "var(--text-primary)",
bodyColor: "var(--text-secondary)",
borderColor: "var(--border-default)",
borderWidth: 1,
cornerRadius: 8,
},
},
scales: {
x: {
grid: { color: "var(--border-subtle)" },
ticks: { color: "var(--text-muted)", font: { size: 10 } },
},
y: {
type: "linear" as const,
display: true,
position: "left" as const,
grid: { color: "var(--border-subtle)" },
ticks: { color: "var(--text-muted)", font: { size: 10 } },
},
y1: {
type: "linear" as const,
display: true,
position: "right" as const,
grid: { drawOnChartArea: false },
ticks: { color: "var(--text-muted)", font: { size: 10 } },
},
},
};
return <Line data={chartData} options={options} />;
}
function buildModelPerformanceLookup(points: ModelPerformancePoint[], days = 14): Map<string, ModelPerformanceSeries> {
const dayMs = 24 * 60 * 60 * 1000;
const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / dayMs) * dayMs;
const start = anchor - (days - 1) * dayMs;
const buckets = Array.from({ length: days }, (_, index) => start + index * dayMs);
const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
const seriesByKey = new Map<string, ModelPerformanceSeries>();
for (const point of points) {
const key = `${point.model}::${point.provider}`;
let series = seriesByKey.get(key);
if (!series) {
series = {
label: `${point.model} (${point.provider})`,
data: buckets.map(timestamp => ({
timestamp,
avgTtftSeconds: null,
avgTokensPerSecond: null,
requests: 0,
})),
};
seriesByKey.set(key, series);
}
const index = bucketIndex.get(point.timestamp);
if (index === undefined) continue;
series.data[index] = {
timestamp: point.timestamp,
avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
avgTokensPerSecond: point.avgTokensPerSecond,
requests: point.requests,
};
}
return seriesByKey;
}
@@ -1,4 +1,4 @@
import { X } from "lucide-react";
import { Clock, Coins, FileJson, Gauge, Hash, X, Zap } from "lucide-react";
import { useEffect, useState } from "react";
import { getRequestDetails } from "../api";
import type { RequestDetails } from "../types";
@@ -21,18 +21,13 @@ export function RequestDetail({ id, onClose }: RequestDetailProps) {
if (!details && loading) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 100,
}}
>
<div style={{ background: "var(--bg-secondary)", padding: "20px", borderRadius: "8px" }}>Loading...</div>
<div className="fixed inset-0 bg-[var(--bg-overlay)] flex justify-center items-center z-[100]">
<div className="surface px-8 py-6">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
<span>Loading...</span>
</div>
</div>
</div>
);
}
@@ -43,118 +38,127 @@ export function RequestDetail({ id, onClose }: RequestDetailProps) {
// biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop dismissal
<div
role="presentation"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.8)",
display: "flex",
justifyContent: "end",
zIndex: 100,
}}
className="fixed inset-0 bg-[var(--bg-overlay)] backdrop-blur-sm flex justify-end z-[100] animate-fade-in"
onClick={onClose}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation for modal content */}
<div
role="dialog"
aria-modal="true"
style={{
width: "800px",
maxWidth: "100%",
background: "var(--bg-primary)",
height: "100%",
overflowY: "auto",
borderLeft: "1px solid var(--border)",
padding: "30px",
}}
className="w-[600px] max-w-full bg-[var(--bg-page)] h-full overflow-y-auto border-l border-[var(--border-subtle)] animate-slide-up"
onClick={e => e.stopPropagation()}
>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "20px" }}>
<h2 style={{ margin: 0 }}>Request Details</h2>
{/* Header */}
<div className="sticky top-0 bg-[var(--bg-page)]/95 backdrop-blur border-b border-[var(--border-subtle)] px-6 py-4 flex justify-between items-center z-10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-gradient-to-br from-[var(--accent-pink)]/20 to-[var(--accent-cyan)]/20 flex items-center justify-center">
<FileJson size={16} className="text-[var(--accent-cyan)]" />
</div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Request Details</h2>
</div>
<button
type="button"
onClick={onClose}
style={{ background: "none", border: "none", color: "var(--text-secondary)", cursor: "pointer" }}
className="p-2 rounded-[var(--radius-sm)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-colors"
>
<X />
<X size={20} />
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px", marginBottom: "30px" }}>
<div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Model</div>
<div>
{details.model} ({details.provider})
<div className="p-6 space-y-6">
{/* Model Info */}
<div className="surface p-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">{details.model}</div>
<div className="text-sm text-[var(--text-muted)]">{details.provider}</div>
</div>
{details.errorMessage ? (
<span className="badge badge-error">Error</span>
) : (
<span className="badge badge-success">Success</span>
)}
</div>
</div>
<div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Cost</div>
<div>${details.usage.cost.total.toFixed(4)}</div>
</div>
<div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Tokens</div>
<div>
{details.usage.totalTokens} (In: {details.usage.input}, Out: {details.usage.output})
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Coins size={14} />
<span className="text-xs uppercase tracking-wide">Cost</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
${details.usage.cost.total.toFixed(4)}
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Hash size={14} />
<span className="text-xs uppercase tracking-wide">Tokens</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{details.usage.totalTokens.toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">
{details.usage.input.toLocaleString()} in · {details.usage.output.toLocaleString()} out
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Clock size={14} />
<span className="text-xs uppercase tracking-wide">Duration</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{details.duration ? `${(details.duration / 1000).toFixed(2)}s` : "-"}
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Zap size={14} />
<span className="text-xs uppercase tracking-wide">TTFT</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{details.ttft ? `${(details.ttft / 1000).toFixed(2)}s` : "-"}
</div>
</div>
</div>
<div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Duration</div>
<div>{details.duration ? `${(details.duration / 1000).toFixed(2)}s` : "-"}</div>
</div>
<div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>TTFT</div>
<div>{details.ttft ? `${(details.ttft / 1000).toFixed(2)}s` : "-"}</div>
</div>
<div>
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Tokens/sec</div>
<div>
{details.duration && details.usage.output
? ((details.usage.output * 1000) / details.duration).toFixed(1)
: "-"}
{/* Tokens/Sec */}
{details.duration && details.usage.output > 0 && (
<div className="surface p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--text-muted)]">
<Gauge size={14} />
<span className="text-xs uppercase tracking-wide">Throughput</span>
</div>
<span className="text-2xl font-bold gradient-text">
{((details.usage.output * 1000) / details.duration).toFixed(1)}
</span>
</div>
<div className="text-xs text-[var(--text-muted)] mt-1 text-right">tokens/second</div>
</div>
)}
{/* Output */}
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">Output</h3>
<pre className="surface bg-[var(--bg-elevated)] p-4 rounded-[var(--radius-md)] text-sm font-mono text-[var(--text-secondary)] overflow-x-auto">
{JSON.stringify(details.output, null, 2)}
</pre>
</div>
{/* Raw Metadata */}
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">Raw Metadata</h3>
<pre className="surface bg-[var(--bg-elevated)] p-4 rounded-[var(--radius-md)] text-xs font-mono text-[var(--text-muted)] overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
</div>
</div>
<h3 style={{ borderBottom: "1px solid var(--border)", paddingBottom: "10px", marginBottom: "15px" }}>
Output
</h3>
<pre
style={{
background: "var(--bg-secondary)",
padding: "20px",
borderRadius: "8px",
whiteSpace: "pre-wrap",
overflowX: "auto",
fontSize: "0.9rem",
fontFamily: "monospace",
}}
>
{JSON.stringify(details.output, null, 2)}
</pre>
<h3
style={{
borderBottom: "1px solid var(--border)",
paddingBottom: "10px",
marginBottom: "15px",
marginTop: "30px",
}}
>
Raw Metadata
</h3>
<pre
style={{
background: "var(--bg-secondary)",
padding: "20px",
borderRadius: "8px",
whiteSpace: "pre-wrap",
overflowX: "auto",
fontSize: "0.8rem",
fontFamily: "monospace",
color: "var(--text-secondary)",
}}
>
{JSON.stringify(details, null, 2)}
</pre>
</div>
</div>
);
@@ -1,4 +1,5 @@
import { formatDistanceToNow } from "date-fns";
import { CheckCircle2, XCircle } from "lucide-react";
import type { MessageStats } from "../types";
interface RequestListProps {
@@ -9,94 +10,20 @@ interface RequestListProps {
export function RequestList({ requests, onSelect, title }: RequestListProps) {
return (
<div
style={{
background: "var(--bg-secondary)",
borderRadius: "12px",
border: "1px solid var(--border)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
<h3 style={{ margin: 0, fontSize: "1rem" }}>{title}</h3>
<div className="surface overflow-hidden flex flex-col h-full">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
</div>
<div style={{ overflowY: "auto", flex: 1 }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.9rem" }}>
<thead style={{ background: "rgba(0,0,0,0.2)", position: "sticky", top: 0 }}>
<div className="overflow-auto flex-1">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] sticky top-0 z-10">
<tr>
<th
style={{
textAlign: "left",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Time
</th>
<th
style={{
textAlign: "left",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Model
</th>
<th
style={{
textAlign: "right",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Tokens
</th>
<th
style={{
textAlign: "right",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Cost
</th>
<th
style={{
textAlign: "right",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Duration
</th>
<th
style={{
textAlign: "right",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
TTFT
</th>
<th
style={{
textAlign: "right",
padding: "12px 20px",
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Tokens/s
</th>
<th className="text-left py-3 px-4 table-header">Model</th>
<th className="text-left py-3 px-4 table-header">Time</th>
<th className="text-right py-3 px-4 table-header">Tokens</th>
<th className="text-right py-3 px-4 table-header">Cost</th>
<th className="text-right py-3 px-4 table-header">Duration</th>
<th className="text-center py-3 px-4 table-header">Status</th>
</tr>
</thead>
<tbody>
@@ -104,42 +31,40 @@ export function RequestList({ requests, onSelect, title }: RequestListProps) {
<tr
key={`${req.sessionFile}-${req.entryId}`}
onClick={() => onSelect(req)}
style={{
cursor: "pointer",
borderBottom: "1px solid var(--border)",
transition: "background 0.1s",
}}
onMouseEnter={e => {
e.currentTarget.style.background = "rgba(255,255,255,0.05)";
}}
onMouseLeave={e => {
e.currentTarget.style.background = "transparent";
}}
className="table-row cursor-pointer border-b border-[var(--border-subtle)] last:border-b-0"
>
<td style={{ padding: "12px 20px" }}>
<td className="py-3 px-4">
<div className="font-medium text-[var(--text-primary)] text-sm">{req.model}</div>
<div className="text-xs text-[var(--text-muted)]">{req.provider}</div>
</td>
<td className="py-3 px-4 text-sm text-[var(--text-secondary)]">
{formatDistanceToNow(req.timestamp, { addSuffix: true })}
</td>
<td style={{ padding: "12px 20px" }}>
<div style={{ fontWeight: 500 }}>{req.model}</div>
<div style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}>{req.provider}</div>
</td>
<td style={{ padding: "12px 20px", textAlign: "right" }}>
<td className="py-3 px-4 text-right text-sm text-[var(--text-secondary)] font-mono">
{req.usage.totalTokens.toLocaleString()}
</td>
<td style={{ padding: "12px 20px", textAlign: "right" }}>${req.usage.cost.total.toFixed(4)}</td>
<td style={{ padding: "12px 20px", textAlign: "right" }}>
<td className="py-3 px-4 text-right text-sm text-[var(--text-secondary)] font-mono">
${req.usage.cost.total.toFixed(4)}
</td>
<td className="py-3 px-4 text-right text-sm text-[var(--text-secondary)] font-mono">
{req.duration ? `${(req.duration / 1000).toFixed(1)}s` : "-"}
</td>
<td style={{ padding: "12px 20px", textAlign: "right" }}>
{req.ttft ? `${(req.ttft / 1000).toFixed(2)}s` : "-"}
</td>
<td style={{ padding: "12px 20px", textAlign: "right" }}>
{req.duration && req.usage.output
? ((req.usage.output * 1000) / req.duration).toFixed(1)
: "-"}
<td className="py-3 px-4 text-center">
{req.errorMessage ? (
<XCircle size={16} className="text-[var(--accent-red)] mx-auto" />
) : (
<CheckCircle2 size={16} className="text-[var(--accent-green)] mx-auto" />
)}
</td>
</tr>
))}
{requests.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-[var(--text-muted)] text-sm">
No requests found
</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -1,32 +0,0 @@
import type { ReactNode } from "react";
interface StatCardProps {
title: string;
value: string | number;
detail?: string;
icon?: ReactNode;
color?: string;
}
export function StatCard({ title, value, detail, icon, color = "#4ade80" }: StatCardProps) {
return (
<div
style={{
background: "var(--bg-secondary)",
padding: "20px",
borderRadius: "12px",
border: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3 style={{ margin: 0, fontSize: "0.9rem", color: "var(--text-secondary)", fontWeight: 500 }}>{title}</h3>
{icon && <div style={{ color }}>{icon}</div>}
</div>
<div style={{ fontSize: "1.8rem", fontWeight: 700 }}>{value}</div>
{detail && <div style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>{detail}</div>}
</div>
);
}
@@ -0,0 +1,88 @@
import { Activity, AlertCircle, BarChart3, Database, Server, Zap } from "lucide-react";
import type { AggregatedStats } from "../types";
interface StatsGridProps {
stats: AggregatedStats;
}
const statConfig = [
{
key: "requests",
title: "Total Requests",
icon: Server,
color: "var(--accent-violet)",
getValue: (s: AggregatedStats) => s.totalRequests.toLocaleString(),
getDetail: (s: AggregatedStats) =>
`${s.successfulRequests.toLocaleString()} success · ${s.failedRequests.toLocaleString()} errors`,
},
{
key: "cost",
title: "Total Cost",
icon: Activity,
color: "var(--accent-pink)",
getValue: (s: AggregatedStats) => `$${s.totalCost.toFixed(2)}`,
getDetail: (s: AggregatedStats) =>
s.totalRequests > 0 ? `$${(s.totalCost / s.totalRequests).toFixed(4)} avg/req` : "-",
},
{
key: "cache",
title: "Cache Rate",
icon: Database,
color: "var(--accent-cyan)",
getValue: (s: AggregatedStats) => `${(s.cacheRate * 100).toFixed(1)}%`,
getDetail: (s: AggregatedStats) => `${(s.totalCacheReadTokens / 1000).toFixed(1)}k cached tokens`,
},
{
key: "errors",
title: "Error Rate",
icon: AlertCircle,
color: "var(--accent-red)",
getValue: (s: AggregatedStats) => `${(s.errorRate * 100).toFixed(1)}%`,
getDetail: (s: AggregatedStats) => `${s.failedRequests.toLocaleString()} failed requests`,
},
{
key: "tokens",
title: "Tokens/Sec",
icon: BarChart3,
color: "var(--accent-green)",
getValue: (s: AggregatedStats) => s.avgTokensPerSecond?.toFixed(1) ?? "-",
getDetail: (s: AggregatedStats) => `${(s.totalInputTokens + s.totalOutputTokens).toLocaleString()} total tokens`,
},
{
key: "ttft",
title: "TTFT",
icon: Zap,
color: "var(--accent-amber)",
getValue: (s: AggregatedStats) => (s.avgTtft ? `${(s.avgTtft / 1000).toFixed(2)}s` : "-"),
getDetail: () => "Time to first token",
},
];
export function StatsGrid({ stats }: StatsGridProps) {
return (
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-8">
{statConfig.map(stat => {
const Icon = stat.icon;
return (
<div key={stat.key} className="stat-card group">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-[var(--text-secondary)]">{stat.title}</span>
<div
className="p-2 rounded-[var(--radius-sm)] transition-colors"
style={{ backgroundColor: `${stat.color}15` }}
>
<Icon
size={18}
style={{ color: stat.color }}
className="transition-transform group-hover:scale-110"
/>
</div>
</div>
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">{stat.getValue(stats)}</div>
<div className="text-xs text-[var(--text-muted)] truncate">{stat.getDetail(stats)}</div>
</div>
);
})}
</div>
);
}
+1
View File
@@ -1,3 +1,4 @@
import "./styles.css";
import { createRoot } from "react-dom/client";
import App from "./App";
+277
View File
@@ -0,0 +1,277 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-page: #0a0a0f;
--bg-surface: #111118;
--bg-elevated: #16161e;
--bg-hover: rgba(255, 255, 255, 0.03);
--bg-active: rgba(255, 255, 255, 0.06);
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-pink: #ec4899;
--accent-pink-glow: rgba(236, 72, 153, 0.3);
--accent-cyan: #22d3ee;
--accent-cyan-glow: rgba(34, 211, 238, 0.3);
--accent-violet: #a78bfa;
--accent-green: #4ade80;
--accent-amber: #fbbf24;
--accent-red: #f87171;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
}
@layer base {
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg-page);
color: var(--text-primary);
margin: 0;
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-default);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
}
@layer components {
.surface {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.surface-elevated {
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-pink) 0%, #db2777 100%);
color: white;
box-shadow: 0 0 20px var(--accent-pink-glow);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 24px var(--accent-pink-glow);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border-default);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--border-default);
}
.tab-btn {
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.tab-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.tab-btn.active {
color: var(--text-primary);
background: var(--bg-elevated);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 20px;
transition: all 0.2s ease;
}
.stat-card:hover {
border-color: var(--border-default);
background: var(--bg-elevated);
}
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
font-size: 12px;
font-weight: 500;
border-radius: 100px;
}
.badge-success {
background: rgba(74, 222, 128, 0.15);
color: var(--accent-green);
}
.badge-error {
background: rgba(248, 113, 113, 0.15);
color: var(--accent-red);
}
.badge-info {
background: rgba(34, 211, 238, 0.15);
color: var(--accent-cyan);
}
.badge-warning {
background: rgba(251, 191, 36, 0.15);
color: var(--accent-amber);
}
.table-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.table-row {
transition: background 0.15s ease;
}
.table-row:hover {
background: var(--bg-hover);
}
.gradient-text {
background: linear-gradient(135deg, var(--accent-pink) 0%, var(--accent-cyan) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-border {
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: linear-gradient(135deg, var(--accent-pink), var(--accent-cyan));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.glow-pink {
box-shadow: 0 0 30px var(--accent-pink-glow);
}
.glow-cyan {
box-shadow: 0 0 30px var(--accent-cyan-glow);
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
}
+40 -31
View File
@@ -1,5 +1,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import postcss from "postcss";
import tailwindcss from "tailwindcss";
import {
getDashboardStats,
getRecentErrors,
@@ -12,6 +14,17 @@ import {
const CLIENT_DIR = path.join(import.meta.dir, "client");
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
async function buildTailwindCss(inputPath: string, outputPath: string): Promise<void> {
const sourceCss = await Bun.file(inputPath).text();
const result = await postcss([
tailwindcss({ config: path.join(import.meta.dir, "..", "tailwind.config.js") }),
]).process(sourceCss, {
from: inputPath,
to: outputPath,
});
await Bun.write(outputPath, result.css);
}
async function getLatestMtime(dir: string): Promise<number> {
let latest = 0;
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -31,12 +44,24 @@ async function getLatestMtime(dir: string): Promise<number> {
const ensureClientBuild = async () => {
const indexPath = path.join(STATIC_DIR, "index.html");
const sourceMtime = await getLatestMtime(CLIENT_DIR);
let shouldBuild = true;
const cssPath = path.join(STATIC_DIR, "styles.css");
const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
let tailwindConfigMtime = 0;
try {
const indexStats = await fs.stat(indexPath);
if (indexStats.isFile() && indexStats.mtimeMs >= sourceMtime) {
const tailwindConfigStats = await fs.stat(tailwindConfigPath);
tailwindConfigMtime = tailwindConfigStats.mtimeMs;
} catch {}
const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
let shouldBuild = true;
try {
const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
if (
indexStats.isFile() &&
cssStats.isFile() &&
indexStats.mtimeMs >= sourceMtime &&
cssStats.mtimeMs >= sourceMtime
) {
shouldBuild = false;
}
} catch {
@@ -47,6 +72,15 @@ const ensureClientBuild = async () => {
await fs.rm(STATIC_DIR, { recursive: true, force: true });
// Build Tailwind CSS with the library API
console.log("Building Tailwind CSS...");
try {
await buildTailwindCss(path.join(CLIENT_DIR, "styles.css"), path.join(STATIC_DIR, "styles.css"));
} catch (error) {
console.error("Tailwind build failed:", error);
}
console.log("Building React app...");
const result = await Bun.build({
entrypoints: [path.join(CLIENT_DIR, "index.tsx")],
outdir: STATIC_DIR,
@@ -65,32 +99,7 @@ const ensureClientBuild = async () => {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Usage Statistics</title>
<style>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--text-primary: #eee;
--text-secondary: #aaa;
--accent: #e94560;
--success: #4ade80;
--error: #f87171;
--border: #1f2937;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
}
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--bg-card); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
</style>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="root"></div>
+40
View File
@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
import * as path from "node:path";
export default {
content: [path.join(import.meta.dir, "src", "client", "**/*.{js,jsx,ts,tsx}")],
darkMode: "class",
theme: {
extend: {
colors: {
page: "var(--bg-page)",
surface: "var(--bg-surface)",
elevated: "var(--bg-elevated)",
"border-subtle": "var(--border-subtle)",
"border-default": "var(--border-default)",
"text-primary": "var(--text-primary)",
"text-secondary": "var(--text-secondary)",
"text-muted": "var(--text-muted)",
pink: "var(--accent-pink)",
cyan: "var(--accent-cyan)",
violet: "var(--accent-violet)",
},
fontFamily: {
sans: [
"-apple-system",
"BlinkMacSystemFont",
'"Segoe UI"',
"Roboto",
"Helvetica",
"Arial",
"sans-serif",
],
},
borderRadius: {
sm: "var(--radius-sm)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
},
},
},
plugins: [],
};