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:
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import "./styles.css";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
Reference in New Issue
Block a user