From 257af14293d59c3767adb140926e73653c9a3427 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:25:41 -0500 Subject: [PATCH 1/9] chore(deps): update dependencies --- ui/bun.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++++-- ui/package.json | 2 ++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ui/bun.lock b/ui/bun.lock index 4a0d6dae..2f28cb7b 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -5,6 +5,8 @@ "name": "ui", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -201,6 +203,20 @@ "@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=="], + "@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="], + + "@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="], + + "@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="], + + "@nivo/sankey": ["@nivo/sankey@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-sankey": "^0.11.2", "@types/d3-shape": "^3.1.6", "d3-sankey": "^0.12.3", "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw=="], + + "@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="], + + "@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="], + + "@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -279,6 +295,18 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="], + + "@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="], + + "@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="], + + "@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="], + + "@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="], + + "@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], @@ -383,8 +411,12 @@ "@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="], + "@types/d3-sankey": ["@types/d3-sankey@0.11.2", "", { "dependencies": { "@types/d3-shape": "^1" } }, "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="], "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], @@ -479,19 +511,23 @@ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + "d3-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="], "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-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + "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-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + "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-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="], "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], @@ -725,6 +761,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="], + "react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -783,6 +821,8 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "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-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], @@ -807,6 +847,10 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@nivo/core/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@nivo/sankey/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -845,12 +889,34 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "d3-scale/d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-scale/d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "victory-vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@nivo/core/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@nivo/sankey/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "d3-time-format/d3-time/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + "victory-vendor/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "d3-time-format/d3-time/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], } } diff --git a/ui/package.json b/ui/package.json index e44e696d..9c39d5d1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,8 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", From b97c3bfda4ad47787fbce53617a7ee5d5267c2bf Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:26:02 -0500 Subject: [PATCH 2/9] feat(ui): implement auth monitor components & pages --- ui/src/components/account-flow-viz.tsx | 350 ++++++++++++++++++++++ ui/src/components/auth-monitor.tsx | 387 +++++++++++++++++++++++++ ui/src/components/hero-section.tsx | 83 +----- ui/src/components/provider-icon.tsx | 95 ++++++ ui/src/lib/provider-config.ts | 37 +++ ui/src/lib/utils.ts | 52 +++- ui/src/pages/home.tsx | 274 ++++++++--------- 7 files changed, 1041 insertions(+), 237 deletions(-) create mode 100644 ui/src/components/account-flow-viz.tsx create mode 100644 ui/src/components/auth-monitor.tsx create mode 100644 ui/src/components/provider-icon.tsx create mode 100644 ui/src/lib/provider-config.ts diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx new file mode 100644 index 00000000..8ed2a6b0 --- /dev/null +++ b/ui/src/components/account-flow-viz.tsx @@ -0,0 +1,350 @@ +/** + * Account Flow Visualization + * Custom SVG bezier curve visualization showing request flow from accounts to providers + * Inspired by modern dark theme design with glass panels and glow effects + */ + +import { useRef, useEffect, useState, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ProviderIcon } from '@/components/provider-icon'; +import { PROVIDER_COLORS } from '@/lib/provider-config'; +import { ChevronRight, X, CheckCircle2, XCircle, Clock } from 'lucide-react'; + +interface AccountData { + id: string; + email: string; + provider: string; + successCount: number; + failureCount: number; + lastUsedAt?: string; + color: string; +} + +interface ProviderData { + provider: string; + displayName: string; + totalRequests: number; + accounts: AccountData[]; +} + +interface AccountFlowVizProps { + providerData: ProviderData; + onBack?: () => void; +} + +/** Strip common email domains for cleaner display */ +function cleanEmail(email: string): string { + return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); +} + +function getTimeAgo(dateStr?: string): string { + if (!dateStr) return 'never'; + const date = new Date(dateStr); + if (isNaN(date.getTime())) return 'unknown'; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + if (diffMs < 0) return 'just now'; + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [hoveredAccount, setHoveredAccount] = useState(null); + const [selectedAccount, setSelectedAccount] = useState(null); + const [paths, setPaths] = useState([]); + + const { accounts } = providerData; + const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); + const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); + + // Calculate SVG paths for bezier curves + const calculatePaths = useCallback(() => { + if (!containerRef.current || !svgRef.current) return; + + const container = containerRef.current; + const svg = svgRef.current; + const svgRect = svg.getBoundingClientRect(); + + const destEl = container.querySelector('[data-provider-node]'); + if (!destEl) return; + const destRect = destEl.getBoundingClientRect(); + + // Destination point (left center of provider card) + const destX = destRect.left - svgRect.left; + const destY = destRect.top + destRect.height / 2 - svgRect.top; + + const newPaths: string[] = []; + + accounts.forEach((_, i) => { + const sourceEl = container.querySelector(`[data-account-index="${i}"]`); + if (!sourceEl) return; + const sourceRect = sourceEl.getBoundingClientRect(); + + // Source point (right center of account card) + const startX = sourceRect.right - svgRect.left; + const startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + + // Bezier control points + const cp1X = startX + (destX - startX) * 0.5; + const cp1Y = startY; + const cp2X = destX - (destX - startX) * 0.5; + const cp2Y = destY; + + newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`); + }); + + setPaths(newPaths); + }, [accounts]); + + useEffect(() => { + // Initial calculation after render + const timer = setTimeout(calculatePaths, 50); + window.addEventListener('resize', calculatePaths); + return () => { + clearTimeout(timer); + window.removeEventListener('resize', calculatePaths); + }; + }, [calculatePaths]); + + const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; + + return ( +
+ {/* Back button */} + {onBack && ( + + )} + + {/* Main visualization area */} +
+ {/* SVG Canvas (Background) */} + + + + + + + + {paths.map((d, i) => { + const account = accounts[i]; + const total = account.successCount + account.failureCount; + const strokeWidth = Math.max(2, (total / maxRequests) * 10); + const isHovered = hoveredAccount === i; + const isDimmed = hoveredAccount !== null && hoveredAccount !== i; + + return ( + + ); + })} + + + {/* Left Column: Source Accounts */} +
+ {accounts.map((account, i) => { + const total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === i; + + return ( +
setSelectedAccount(account)} + onMouseEnter={() => setHoveredAccount(i)} + onMouseLeave={() => setHoveredAccount(null)} + className={cn( + 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', + 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', + 'border border-border/50 dark:border-white/[0.08]', + 'border-l-2 hover:translate-x-1', + isHovered && 'bg-muted/50 dark:bg-zinc-800/60' + )} + style={{ borderLeftColor: account.color }} + > +
+ + {cleanEmail(account.email)} + + +
+
+ + {total.toLocaleString()} reqs + +
+ {account.failureCount > 0 && ( +
+ )} +
+
+
+ {/* Connector Dot */} +
+
+ ); + })} +
+ + {/* Right Column: Destination Provider */} +
+
+ {/* Connector Point */} +
+ +
+ +
+

+ {providerData.displayName} +

+

Provider

+
+
+ +
+
+ Total Requests + {totalRequests.toLocaleString()} +
+
+ Accounts + {accounts.length} +
+
+
+
+
+
+
+
+ + {/* Detail Panel (Bottom slide-up) */} +
+
+ + + {selectedAccount && ( +
+ {/* Account Info */} +
+
+
+ + {cleanEmail(selectedAccount.email)} + +
+
+ Source Account +
+
+ + {/* Stats */} +
+
+ + SUCCESSFUL +
+
+ {selectedAccount.successCount.toLocaleString()} +
+
+ +
+
+ + FAILED +
+
+ {selectedAccount.failureCount.toLocaleString()} +
+
+ +
+
+ + LAST SYNC +
+
+ {getTimeAgo(selectedAccount.lastUsedAt)} +
+
+
+ )} +
+
+
+ ); +} diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx new file mode 100644 index 00000000..26d83769 --- /dev/null +++ b/ui/src/components/auth-monitor.tsx @@ -0,0 +1,387 @@ +/** + * Auth Monitor Component with Account Flow Visualization + * Shows request flow from accounts to providers using custom SVG bezier curves + * Uses glass panel aesthetic with hover interactions and glow effects + */ + +import { useState, useMemo } from 'react'; +import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { cn, STATUS_COLORS } from '@/lib/utils'; +import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ProviderIcon } from '@/components/provider-icon'; +import { AccountFlowViz } from '@/components/account-flow-viz'; +import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import { Activity, CheckCircle2, XCircle, ChevronRight } from 'lucide-react'; + +interface AccountRow { + id: string; + email: string; + provider: string; + displayName: string; + isDefault: boolean; + successCount: number; + failureCount: number; + lastUsedAt?: string; + color: string; +} + +interface ProviderStats { + provider: string; + displayName: string; + totalRequests: number; + successCount: number; + failureCount: number; + accountCount: number; + accounts: AccountRow[]; +} + +function getSuccessRate(success: number, failure: number): number { + const total = success + failure; + if (total === 0) return 100; + return Math.round((success / total) * 100); +} + +/** Strip common email domains for cleaner display */ +function cleanEmail(email: string): string { + return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); +} + +// Vibrant colors for account segments +const ACCOUNT_COLORS = [ + '#277da1', // Cerulean + '#43aa8b', // Seaweed + '#f9c74f', // Tuscan Sun + '#f94144', // Strawberry + '#f3722c', // Pumpkin + '#90be6d', // Willow + '#577590', // Blue Slate + '#f8961e', // Carrot + '#4d908e', // Dark Cyan + '#a78bfa', // Purple +]; + +export function AuthMonitor() { + const { data, isLoading, error } = useCliproxyAuth(); + const [selectedProvider, setSelectedProvider] = useState(null); + const [hoveredProvider, setHoveredProvider] = useState(null); + + // Transform auth status data into account rows + const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { + if (!data?.authStatus) { + return { + accounts: [], + totalSuccess: 0, + totalFailure: 0, + totalRequests: 0, + providerStats: [], + }; + } + + const accountsList: AccountRow[] = []; + const providerMap = new Map< + string, + { success: number; failure: number; accounts: AccountRow[] } + >(); + let tSuccess = 0; + let tFailure = 0; + let colorIndex = 0; + + data.authStatus.forEach((status: AuthStatus) => { + const providerKey = status.provider; + if (!providerMap.has(providerKey)) { + providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] }); + } + const providerData = providerMap.get(providerKey); + if (!providerData) return; + + status.accounts?.forEach((account: OAuthAccount) => { + // Mock stats - in production, fetch from CLIProxy /usage endpoint + const success = Math.floor(Math.random() * 2000) + 100; + const failure = account.isDefault ? Math.floor(Math.random() * 50) : 0; + tSuccess += success; + tFailure += failure; + providerData.success += success; + providerData.failure += failure; + + const row: AccountRow = { + id: account.id, + email: account.email || account.id, + provider: status.provider, + displayName: status.displayName, + isDefault: account.isDefault, + successCount: success, + failureCount: failure, + lastUsedAt: account.lastUsedAt, + color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], + }; + accountsList.push(row); + providerData.accounts.push(row); + colorIndex++; + }); + }); + + // Build provider stats array + const providerStatsArr: ProviderStats[] = []; + providerMap.forEach((pData, provider) => { + if (pData.accounts.length === 0) return; + providerStatsArr.push({ + provider, + displayName: getProviderDisplayName(provider), + totalRequests: pData.success + pData.failure, + successCount: pData.success, + failureCount: pData.failure, + accountCount: pData.accounts.length, + accounts: pData.accounts, + }); + }); + providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests); + + return { + accounts: accountsList, + totalSuccess: tSuccess, + totalFailure: tFailure, + totalRequests: tSuccess + tFailure, + providerStats: providerStatsArr, + }; + }, [data?.authStatus]); + + const overallSuccessRate = + totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; + + // Get selected provider data for detail view + const selectedProviderData = selectedProvider + ? providerStats.find((ps) => ps.provider === selectedProvider) + : null; + + if (isLoading) { + return ( +
+
+ + +
+
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ +
+
+ ); + } + + if (error || !data?.authStatus || accounts.length === 0) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+ + Live Stream + +
+
+ {accounts.length} accounts + {totalRequests.toLocaleString()} req +
+
+ + {/* Summary Stats Row */} +
+ } + label="Accounts" + value={accounts.length} + color="var(--accent)" + /> + } + label="Success" + value={totalSuccess.toLocaleString()} + color={STATUS_COLORS.success} + /> + } + label="Failed" + value={totalFailure.toLocaleString()} + color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} + /> + } + label="Success Rate" + value={`${overallSuccessRate}%`} + color={ + overallSuccessRate === 100 + ? STATUS_COLORS.success + : overallSuccessRate >= 95 + ? STATUS_COLORS.degraded + : STATUS_COLORS.failed + } + /> +
+ + {/* Flow Visualization */} +
+ {selectedProviderData ? ( + // Account-level flow view + setSelectedProvider(null)} + /> + ) : ( + // Provider cards view +
+
+ Request Distribution by Provider +
+
+ {providerStats.map((ps) => { + const successRate = getSuccessRate(ps.successCount, ps.failureCount); + const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280'; + const isHovered = hoveredProvider === ps.provider; + + return ( + + ); + })} +
+
+ )} +
+
+ ); +} + +// Summary Card Component +function SummaryCard({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + color?: string; +}) { + return ( +
+
+ {icon} +
+
+
{label}
+
+ {value} +
+
+
+ ); +} diff --git a/ui/src/components/hero-section.tsx b/ui/src/components/hero-section.tsx index 17418826..47b49b91 100644 --- a/ui/src/components/hero-section.tsx +++ b/ui/src/components/hero-section.tsx @@ -1,85 +1,22 @@ import { Badge } from '@/components/ui/badge'; import { CcsLogo } from '@/components/ccs-logo'; -import { CheckCircle2, AlertCircle, XCircle } from 'lucide-react'; -import { cn } from '@/lib/utils'; interface HeroSectionProps { version?: string; - healthStatus?: 'ok' | 'warning' | 'error'; - healthPassed?: number; - healthTotal?: number; } -const statusConfig = { - ok: { - icon: CheckCircle2, - label: 'All Systems Operational', - color: 'text-green-600', - badgeBg: 'bg-green-500/10 text-green-600 border-green-500/20', - }, - warning: { - icon: AlertCircle, - label: 'Some Issues Detected', - color: 'text-yellow-500', - badgeBg: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', - }, - error: { - icon: XCircle, - label: 'Action Required', - color: 'text-red-500', - badgeBg: 'bg-red-500/10 text-red-500 border-red-500/20', - }, -}; - -export function HeroSection({ - version = '5.0.0', - healthStatus = 'ok', - healthPassed = 0, - healthTotal = 0, -}: HeroSectionProps) { - const status = statusConfig[healthStatus]; - const StatusIcon = status.icon; - +export function HeroSection({ version = '5.0.0' }: HeroSectionProps) { return ( -
- {/* Subtle background pattern */} -
-
-
- -
- {/* Left: Logo and Welcome */} -
- -
-
-

CCS Config

- - v{version} - -
-

Claude Code Switch Dashboard

-
-
- - {/* Right: Health Status */} -
- -
-

{status.label}

- {healthTotal > 0 && ( -

- {healthPassed}/{healthTotal} checks passed -

- )} -
+
+ +
+
+

CCS Config

+ + v{version} +
+

Claude Code Switch Dashboard

); diff --git a/ui/src/components/provider-icon.tsx b/ui/src/components/provider-icon.tsx new file mode 100644 index 00000000..828f649e --- /dev/null +++ b/ui/src/components/provider-icon.tsx @@ -0,0 +1,95 @@ +/** + * Provider Icon Component + * Renders provider logos from /assets/providers/ + * Supports white background circle variant for dark themes + */ + +import { cn } from '@/lib/utils'; +import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config'; + +interface ProviderIconProps { + provider: string; + className?: string; + size?: number; + /** White background circle variant for better visibility */ + withBackground?: boolean; +} + +export function ProviderIcon({ + provider, + className, + size = 18, + withBackground = false, +}: ProviderIconProps) { + const normalized = provider.toLowerCase(); + const assetPath = PROVIDER_ASSETS[normalized]; + + // Icon size is smaller when inside background circle + const iconSize = withBackground ? Math.floor(size * 0.65) : size; + + const iconElement = assetPath ? ( + {`${provider} + ) : ( + // Fallback: colored text letter + + {provider.charAt(0).toUpperCase()} + + ); + + if (withBackground) { + return ( +
+ {iconElement} +
+ ); + } + + // Without background - original behavior for logos, colored circle for fallback + if (assetPath) { + return ( + {`${provider} + ); + } + + const bgColor = PROVIDER_COLORS[normalized] || '#6b7280'; + return ( +
+ {provider.charAt(0).toUpperCase()} +
+ ); +} diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts new file mode 100644 index 00000000..299e8145 --- /dev/null +++ b/ui/src/lib/provider-config.ts @@ -0,0 +1,37 @@ +/** + * Provider Configuration + * Shared constants for provider branding and assets + */ + +// Map provider names to asset filenames (only providers with actual logos) +export const PROVIDER_ASSETS: Record = { + gemini: '/assets/providers/gemini-color.svg', + agy: '/assets/providers/agy.png', + codex: '/assets/providers/openai.svg', + qwen: '/assets/providers/qwen-color.svg', +}; + +// Provider brand colors +export const PROVIDER_COLORS: Record = { + gemini: '#4285F4', + agy: '#f3722c', + codex: '#10a37f', + vertex: '#4285F4', + iflow: '#f94144', + qwen: '#6236FF', +}; + +// Provider display names +const PROVIDER_NAMES: Record = { + gemini: 'Gemini', + agy: 'Antigravity', + codex: 'Codex', + vertex: 'Vertex AI', + iflow: 'iFlow', + qwen: 'Qwen', +}; + +// Map provider to display name +export function getProviderDisplayName(provider: string): string { + return PROVIDER_NAMES[provider.toLowerCase()] || provider; +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index d683130e..417ed674 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -5,21 +5,38 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function getModelColor(model: string): string { - // Vibrant Tones Palette - const colors = [ - '#f94144', // Strawberry Red - '#f3722c', // Pumpkin Spice - '#f8961e', // Carrot Orange - '#f9844a', // Atomic Tangerine - '#f9c74f', // Tuscan Sun - '#90be6d', // Willow Green - '#43aa8b', // Seaweed - '#4d908e', // Dark Cyan - '#577590', // Blue Slate - '#277da1', // Cerulean - ]; +// Vibrant Tones Palette +const VIBRANT_TONES = [ + '#f94144', // Strawberry Red + '#f3722c', // Pumpkin Spice + '#f8961e', // Carrot Orange + '#f9844a', // Atomic Tangerine + '#f9c74f', // Tuscan Sun + '#90be6d', // Willow Green + '#43aa8b', // Seaweed + '#4d908e', // Dark Cyan + '#577590', // Blue Slate + '#277da1', // Cerulean +]; +// Provider color mapping (fixed colors for consistency) +const PROVIDER_COLORS: Record = { + agy: '#f3722c', // Pumpkin + gemini: '#277da1', // Cerulean + codex: '#f8961e', // Carrot + vertex: '#577590', // Blue Slate + iflow: '#f94144', // Strawberry + qwen: '#f9c74f', // Tuscan +}; + +// Status colors (from Analytics Cost breakdown) +export const STATUS_COLORS = { + success: '#43aa8b', // Seaweed + degraded: '#e09f3e', // Ochre + failed: '#9e2a2b', // Merlot +} as const; + +export function getModelColor(model: string): string { // FNV-1a hash algorithm let hash = 0x811c9dc5; for (let i = 0; i < model.length; i++) { @@ -28,5 +45,10 @@ export function getModelColor(model: string): string { } // Ensure positive index - return colors[(hash >>> 0) % colors.length]; + return VIBRANT_TONES[(hash >>> 0) % VIBRANT_TONES.length]; +} + +export function getProviderColor(provider: string): string { + const normalized = provider.toLowerCase(); + return PROVIDER_COLORS[normalized] || getModelColor(provider); } diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 31b2bcb7..1a8adc5c 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,25 +1,13 @@ import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { StatCard } from '@/components/stat-card'; import { HeroSection } from '@/components/hero-section'; -import { QuickCommands } from '@/components/quick-commands'; +import { AuthMonitor } from '@/components/auth-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; -import { - Key, - Zap, - Users, - Activity, - Plus, - Stethoscope, - BookOpen, - FolderOpen, - AlertTriangle, - ArrowRight, -} from 'lucide-react'; +import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; +import { cn } from '@/lib/utils'; +import type { LucideIcon } from 'lucide-react'; const HEALTH_VARIANTS = { ok: 'success', @@ -27,6 +15,51 @@ const HEALTH_VARIANTS = { error: 'error', } as const; +type StatVariant = 'default' | 'success' | 'warning' | 'error' | 'accent'; + +const variantStyles: Record = { + default: { iconBg: 'bg-muted', iconColor: 'text-muted-foreground' }, + success: { iconBg: 'bg-green-500/10', iconColor: 'text-green-600' }, + warning: { iconBg: 'bg-yellow-500/10', iconColor: 'text-yellow-500' }, + error: { iconBg: 'bg-red-500/10', iconColor: 'text-red-500' }, + accent: { iconBg: 'bg-accent/10', iconColor: 'text-accent' }, +}; + +function InlineStat({ + title, + value, + icon: Icon, + variant = 'default', + onClick, +}: { + title: string; + value: number | string; + icon: LucideIcon; + variant?: StatVariant; + onClick?: () => void; +}) { + const styles = variantStyles[variant]; + + return ( + + ); +} + export function HomePage() { const navigate = useNavigate(); const { data: overview, isLoading: isOverviewLoading } = useOverview(); @@ -35,8 +68,8 @@ export function HomePage() { if (isOverviewLoading || isSharedLoading) { return (
- {/* Hero Skeleton */} -
+ {/* Hero Row Skeleton */} +
@@ -44,42 +77,31 @@ export function HomePage() {
-
- - {/* Stats Skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
-
- -
- - -
-
-
- ))} -
- - {/* Quick Actions Skeleton */} -
- -
- - - -
-
- - {/* Quick Commands Skeleton */} -
- -
+
{[1, 2, 3, 4].map((i) => ( - + ))}
+ + {/* Auth Monitor Skeleton */} +
+
+ + +
+
+ +
+ {[1, 2, 3].map((i) => ( +
+ + + + +
+ ))} +
); } @@ -90,13 +112,57 @@ export function HomePage() { return (
- {/* Hero Section */} - + {/* Hero Row: Logo/Title + Inline Stats */} +
+ {/* Subtle background pattern */} +
+
+
+ + {/* Single Row Layout */} +
+ {/* Left: Logo + Title */} + + + {/* Right: Inline Stats */} +
+ navigate('/api')} + /> + navigate('/cliproxy')} + /> + navigate('/accounts')} + /> + navigate('/health')} + /> +
+
+
{/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && ( @@ -107,98 +173,8 @@ export function HomePage() { )} - {/* Stats Grid */} -
- navigate('/api')} - /> - navigate('/cliproxy')} - /> - navigate('/accounts')} - /> - navigate('/health')} - /> -
- - {/* Quick Actions */} - - - Quick Actions - - - - - - - - - {/* Quick Commands */} - - - {/* Shared Data Summary */} - - - - - Shared Data - - - - -
-
- {shared?.commands ?? 0} - Commands -
-
- {shared?.skills ?? 0} - Skills -
-
- {shared?.agents ?? 0} - Agents -
-
-
-
+ {/* Auth Monitor */} +
); } From 127e0e60437f47cd445c1ce9ddf03415d51a06c7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:26:21 -0500 Subject: [PATCH 3/9] feat(web): update account manager and web routes --- src/cliproxy/account-manager.ts | 4 +++- src/web-server/routes.ts | 35 ++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index b1fb5dc7..23ffbb7d 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -473,12 +473,14 @@ export function discoverExistingAccounts(): void { const stats = fs.statSync(filePath); // Register account with auto-generated nickname + // Use mtime as lastUsedAt (when token was last modified = last auth/refresh) + const lastModified = stats.mtime || stats.birthtime || new Date(); providerAccounts.accounts[accountId] = { email, nickname: generateNickname(email), tokenFile: file, createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), - lastUsedAt: stats.mtime?.toISOString(), + lastUsedAt: lastModified.toISOString(), }; } catch { // Skip invalid files diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 8108563e..ccde0ab3 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -36,6 +36,7 @@ import { getProviderAccounts, setDefaultAccount as setDefaultAccountFn, removeAccount as removeAccountFn, + touchAccount, } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { getClaudeEnvVars } from '../cliproxy/config-generator'; @@ -469,11 +470,43 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => { /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles + * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers */ -apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => { +apiRoutes.get('/cliproxy/auth', async (_req: Request, res: Response) => { // Initialize accounts from existing tokens on first request initializeAccounts(); + // Fetch CLIProxyAPI usage stats to determine active providers + const stats = await fetchCliproxyStats(); + + // Map CLIProxyAPI provider names to our internal provider names + const statsProviderMap: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', + }; + + // Update lastUsedAt for providers with recent activity + if (stats?.requestsByProvider) { + for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { + if (requestCount > 0) { + const provider = statsProviderMap[statsProvider.toLowerCase()]; + if (provider) { + // Touch the default account for this provider (or all accounts) + const accounts = getProviderAccounts(provider); + for (const account of accounts) { + // Only touch if this is the default account (most likely being used) + if (account.isDefault) { + touchAccount(provider, account.id); + } + } + } + } + } + } + const statuses = getAllAuthStatus(); const authStatus = statuses.map((status) => { From 349d81a1b0b38a55ad4f245ec88552e5b1ba4359 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:26:31 -0500 Subject: [PATCH 4/9] chore(ui): update ui build config and typings --- ui/src/vite-env.d.ts | 1 + ui/tsconfig.app.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 ui/src/vite-env.d.ts diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 2ad9a724..99fbd39c 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -5,7 +5,6 @@ "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ From 27de6af8aa5a5de72b2c34137cd1a5343e175bc9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 15 Dec 2025 01:23:26 -0500 Subject: [PATCH 5/9] feat(ui): add connection timeline and improve account flow layout - Add ConnectionTimeline component showing recent connection events - Restructure layout to 2-column design: flow viz (flex) | timeline (fixed) - Flow chart now expands to fill available space with justify-between - Timeline has compact fixed width (w-56) on the right - Detail panel now uses flow-based positioning instead of absolute - Generate mock connection events based on account success/failure data --- ui/src/components/account-flow-viz.tsx | 550 ++++++++++++++++--------- 1 file changed, 359 insertions(+), 191 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 8ed2a6b0..3951dd40 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -4,11 +4,12 @@ * Inspired by modern dark theme design with glass panels and glow effects */ -import { useRef, useEffect, useState, useCallback } from 'react'; +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { cn } from '@/lib/utils'; import { ProviderIcon } from '@/components/provider-icon'; import { PROVIDER_COLORS } from '@/lib/provider-config'; -import { ChevronRight, X, CheckCircle2, XCircle, Clock } from 'lucide-react'; +import { STATUS_COLORS } from '@/lib/utils'; +import { ChevronRight, X, CheckCircle2, XCircle, Clock, Activity } from 'lucide-react'; interface AccountData { id: string; @@ -32,6 +33,157 @@ interface AccountFlowVizProps { onBack?: () => void; } +interface ConnectionEvent { + id: string; + timestamp: Date; + accountEmail: string; + status: 'success' | 'failed' | 'pending'; + latencyMs?: number; +} + +/** Generate mock connection events based on account data */ +function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { + const events: ConnectionEvent[] = []; + const now = new Date(); + + accounts.forEach((account) => { + // Generate events based on success/failure counts + const successEvents = Math.min(account.successCount, 3); + const failEvents = Math.min(account.failureCount, 2); + + for (let i = 0; i < successEvents; i++) { + events.push({ + id: `${account.id}-s-${i}`, + timestamp: new Date(now.getTime() - Math.random() * 3600000), // Last hour + accountEmail: account.email, + status: 'success', + latencyMs: Math.floor(Math.random() * 500) + 50, + }); + } + + for (let i = 0; i < failEvents; i++) { + events.push({ + id: `${account.id}-f-${i}`, + timestamp: new Date(now.getTime() - Math.random() * 7200000), // Last 2 hours + accountEmail: account.email, + status: 'failed', + }); + } + }); + + // Sort by timestamp descending (most recent first) + return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); +} + +/** Format timestamp for timeline display */ +function formatTimelineTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'now'; + if (diffMins < 60) return `${diffMins}m`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h`; + return `${Math.floor(diffHours / 24)}d`; +} + +/** Connection Timeline Component - right sidebar panel */ +function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) { + if (events.length === 0) { + return ( +
+
No recent connections
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Connection Timeline + +
+ + {/* Timeline container */} +
+
+ {/* Vertical line */} +
+ + {/* Events */} +
+ {events.slice(0, 8).map((event) => { + const statusColor = + event.status === 'success' + ? STATUS_COLORS.success + : event.status === 'failed' + ? STATUS_COLORS.failed + : STATUS_COLORS.degraded; + + return ( +
+ {/* Timeline dot */} +
+ + {/* Event content */} +
+
+ + {cleanEmail(event.accountEmail)} + + + {formatTimelineTime(event.timestamp)} + +
+
+ + {event.status} + + {event.latencyMs && ( + + {event.latencyMs}ms + + )} +
+
+
+ ); + })} +
+ + {/* Show more indicator */} + {events.length > 8 && ( +
+ + +{events.length - 8} more events + +
+ )} +
+
+
+ ); +} + /** Strip common email domains for cleaner display */ function cleanEmail(email: string): string { return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); @@ -64,6 +216,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); + // Generate connection events for timeline + const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]); + // Calculate SVG paths for bezier curves const calculatePaths = useCallback(() => { if (!containerRef.current || !svgRef.current) return; @@ -116,233 +271,246 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; return ( -
+
{/* Back button */} {onBack && ( )} - {/* Main visualization area */} -
- {/* SVG Canvas (Background) */} - - - - - - - - {paths.map((d, i) => { - const account = accounts[i]; - const total = account.successCount + account.failureCount; - const strokeWidth = Math.max(2, (total / maxRequests) * 10); - const isHovered = hoveredAccount === i; - const isDimmed = hoveredAccount !== null && hoveredAccount !== i; + {/* Main visualization area - 2 column layout: Flow viz (left) | Timeline (right) */} +
+ {/* Left Section: Account → Provider Flow - expands to fill available space */} +
+ {/* SVG Canvas (Background) */} + + + + + + + + {paths.map((d, i) => { + const account = accounts[i]; + const total = account.successCount + account.failureCount; + const strokeWidth = Math.max(2, (total / maxRequests) * 10); + const isHovered = hoveredAccount === i; + const isDimmed = hoveredAccount !== null && hoveredAccount !== i; - return ( - - ); - })} - + return ( + + ); + })} + - {/* Left Column: Source Accounts */} -
- {accounts.map((account, i) => { - const total = account.successCount + account.failureCount; - const isHovered = hoveredAccount === i; + {/* Source Accounts */} +
+ {accounts.map((account, i) => { + const total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === i; - return ( -
setSelectedAccount(account)} - onMouseEnter={() => setHoveredAccount(i)} - onMouseLeave={() => setHoveredAccount(null)} - className={cn( - 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border border-border/50 dark:border-white/[0.08]', - 'border-l-2 hover:translate-x-1', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60' - )} - style={{ borderLeftColor: account.color }} - > -
- - {cleanEmail(account.email)} - - setSelectedAccount(account)} + onMouseEnter={() => setHoveredAccount(i)} + onMouseLeave={() => setHoveredAccount(null)} + className={cn( + 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', + 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', + 'border border-border/50 dark:border-white/[0.08]', + 'border-l-2 hover:translate-x-1', + isHovered && 'bg-muted/50 dark:bg-zinc-800/60' + )} + style={{ borderLeftColor: account.color }} + > +
+ + {cleanEmail(account.email)} + + +
+
+ + {total.toLocaleString()} reqs + +
+ {account.failureCount > 0 && ( +
+ )} +
+
+
+ {/* Connector Dot */} +
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
-
- {/* Connector Dot */} -
-
- ); - })} -
+ ); + })} +
- {/* Right Column: Destination Provider */} -
-
- {/* Connector Point */} + {/* Destination Provider */} +
+ > + {/* Connector Point */} +
-
- -
-

- {providerData.displayName} -

-

Provider

+
+ +
+

+ {providerData.displayName} +

+

+ Provider +

+
-
-
-
- Total Requests - {totalRequests.toLocaleString()} -
-
- Accounts - {accounts.length} -
-
-
+
+
+ Total Requests + + {totalRequests.toLocaleString()} + +
+
+ Accounts + {accounts.length} +
+
+
+
+ + {/* Right Section: Connection Timeline - Fixed compact width */} +
+ +
- {/* Detail Panel (Bottom slide-up) */} + {/* Detail Panel - slides in from bottom, pushes content */}
-
- +
+
+ - {selectedAccount && ( -
- {/* Account Info */} -
-
-
- - {cleanEmail(selectedAccount.email)} - + {selectedAccount && ( +
+ {/* Account Info */} +
+
+
+ + {cleanEmail(selectedAccount.email)} + +
+
+ Source Account +
-
- Source Account + + {/* Stats */} +
+
+ + SUCCESSFUL +
+
+ {selectedAccount.successCount.toLocaleString()} +
+
+ +
+
+ + FAILED +
+
+ {selectedAccount.failureCount.toLocaleString()} +
+
+ +
+
+ + LAST SYNC +
+
+ {getTimeAgo(selectedAccount.lastUsedAt)} +
- - {/* Stats */} -
-
- - SUCCESSFUL -
-
- {selectedAccount.successCount.toLocaleString()} -
-
- -
-
- - FAILED -
-
- {selectedAccount.failureCount.toLocaleString()} -
-
- -
-
- - LAST SYNC -
-
- {getTimeAgo(selectedAccount.lastUsedAt)} -
-
-
- )} + )} +
From 6e4ee805da23ac8969f4161d27047b5b82a07abc Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 15 Dec 2025 16:14:59 -0500 Subject: [PATCH 6/9] feat(ui): add documentation button to header --- ui/src/components/docs-link.tsx | 20 ++++++++++++++++++++ ui/src/components/layout.tsx | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 ui/src/components/docs-link.tsx diff --git a/ui/src/components/docs-link.tsx b/ui/src/components/docs-link.tsx new file mode 100644 index 00000000..72e16014 --- /dev/null +++ b/ui/src/components/docs-link.tsx @@ -0,0 +1,20 @@ +/** + * Docs Link Button + * + * Links to CCS documentation site for guides and reference. + */ + +import { BookOpen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCS_URL = 'https://docs.ccs.kaitran.ca'; + +export function DocsLink() { + return ( + + ); +} diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx index fba044b2..2872d377 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout.tsx @@ -4,6 +4,7 @@ import { SidebarProvider } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { ThemeToggle } from '@/components/theme-toggle'; import { GitHubLink } from '@/components/github-link'; +import { DocsLink } from '@/components/docs-link'; import { ConnectionIndicator } from '@/components/connection-indicator'; import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Skeleton } from '@/components/ui/skeleton'; @@ -26,6 +27,7 @@ export function Layout() {
CCS Config
+
From 3216a0e8478ba9224b1c8ea36582a5166c8c01e0 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 05:57:27 -0500 Subject: [PATCH 7/9] feat(cliproxy-stats): Implement detailed stats fetching and integrate into UI --- src/cliproxy/stats-fetcher.ts | 84 +++++++++++++++++++++++++- ui/src/components/account-flow-viz.tsx | 35 ++++------- ui/src/components/auth-monitor.tsx | 22 +++++-- ui/src/components/profile-deck.tsx | 9 +-- ui/src/hooks/use-cliproxy-stats.ts | 18 ++++++ 5 files changed, 131 insertions(+), 37 deletions(-) diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 2002151a..aaf00058 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -7,10 +7,28 @@ import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator'; +/** Per-account usage statistics */ +export interface AccountUsageStats { + /** Account email or identifier */ + source: string; + /** Number of successful requests */ + successCount: number; + /** Number of failed requests */ + failureCount: number; + /** Total tokens used */ + totalTokens: number; + /** Last request timestamp */ + lastUsedAt?: string; +} + /** Usage statistics from CLIProxyAPI */ export interface CliproxyStats { /** Total number of requests processed */ totalRequests: number; + /** Total successful requests */ + successCount: number; + /** Total failed requests */ + failureCount: number; /** Token counts */ tokens: { input: number; @@ -21,6 +39,8 @@ export interface CliproxyStats { requestsByModel: Record; /** Requests grouped by provider */ requestsByProvider: Record; + /** Per-account usage breakdown */ + accountStats: Record; /** Number of quota exceeded (429) events */ quotaExceededCount: number; /** Number of request retries */ @@ -29,6 +49,21 @@ export interface CliproxyStats { collectedAt: string; } +/** Request detail from CLIProxyAPI */ +interface RequestDetail { + timestamp: string; + source: string; + auth_index: number; + tokens: { + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cached_tokens: number; + total_tokens: number; + }; + failed: boolean; +} + /** Usage API response from CLIProxyAPI /v0/management/usage endpoint */ interface UsageApiResponse { failed_requests?: number; @@ -47,6 +82,7 @@ interface UsageApiResponse { { total_requests?: number; total_tokens?: number; + details?: RequestDetail[]; } >; } @@ -83,9 +119,14 @@ export async function fetchCliproxyStats( const data = (await response.json()) as UsageApiResponse; const usage = data.usage; - // Extract models and providers from the nested API structure + // Extract models, providers, and per-account stats from the nested API structure const requestsByModel: Record = {}; const requestsByProvider: Record = {}; + const accountStats: Record = {}; + let totalSuccessCount = 0; + let totalFailureCount = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; if (usage?.apis) { for (const [provider, providerData] of Object.entries(usage.apis)) { @@ -93,6 +134,40 @@ export async function fetchCliproxyStats( if (providerData.models) { for (const [model, modelData] of Object.entries(providerData.models)) { requestsByModel[model] = modelData.total_requests ?? 0; + + // Aggregate per-account stats from request details + if (modelData.details) { + for (const detail of modelData.details) { + const source = detail.source || 'unknown'; + + // Initialize account stats if not exists + if (!accountStats[source]) { + accountStats[source] = { + source, + successCount: 0, + failureCount: 0, + totalTokens: 0, + }; + } + + // Update account stats + if (detail.failed) { + accountStats[source].failureCount++; + totalFailureCount++; + } else { + accountStats[source].successCount++; + totalSuccessCount++; + } + + const tokens = detail.tokens?.total_tokens ?? 0; + accountStats[source].totalTokens += tokens; + accountStats[source].lastUsedAt = detail.timestamp; + + // Aggregate token breakdowns + totalInputTokens += detail.tokens?.input_tokens ?? 0; + totalOutputTokens += detail.tokens?.output_tokens ?? 0; + } + } } } } @@ -101,13 +176,16 @@ export async function fetchCliproxyStats( // Normalize the response to our interface return { totalRequests: usage?.total_requests ?? 0, + successCount: totalSuccessCount, + failureCount: totalFailureCount, tokens: { - input: 0, // API doesn't provide input/output breakdown - output: 0, + input: totalInputTokens, + output: totalOutputTokens, total: usage?.total_tokens ?? 0, }, requestsByModel, requestsByProvider, + accountStats, quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, retryCount: 0, // API doesn't track retries separately collectedAt: new Date().toISOString(), diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 3951dd40..f12fb059 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -41,34 +41,25 @@ interface ConnectionEvent { latencyMs?: number; } -/** Generate mock connection events based on account data */ +/** Generate connection events from real account data */ function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { const events: ConnectionEvent[] = []; - const now = new Date(); accounts.forEach((account) => { - // Generate events based on success/failure counts - const successEvents = Math.min(account.successCount, 3); - const failEvents = Math.min(account.failureCount, 2); + // Only show events for accounts that have actual request data + const hasActivity = account.successCount > 0 || account.failureCount > 0; + if (!hasActivity) return; - for (let i = 0; i < successEvents; i++) { - events.push({ - id: `${account.id}-s-${i}`, - timestamp: new Date(now.getTime() - Math.random() * 3600000), // Last hour - accountEmail: account.email, - status: 'success', - latencyMs: Math.floor(Math.random() * 500) + 50, - }); - } + // Create a single consolidated event per account showing its current status + const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); + const hasFailures = account.failureCount > 0; - for (let i = 0; i < failEvents; i++) { - events.push({ - id: `${account.id}-f-${i}`, - timestamp: new Date(now.getTime() - Math.random() * 7200000), // Last 2 hours - accountEmail: account.email, - status: 'failed', - }); - } + events.push({ + id: `${account.id}-status`, + timestamp: lastUsed, + accountEmail: account.email, + status: hasFailures && account.failureCount > account.successCount ? 'failed' : 'success', + }); }); // Sort by timestamp descending (most recent first) diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx index 26d83769..ae17e7c7 100644 --- a/ui/src/components/auth-monitor.tsx +++ b/ui/src/components/auth-monitor.tsx @@ -6,6 +6,7 @@ import { useState, useMemo } from 'react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats'; import { cn, STATUS_COLORS } from '@/lib/utils'; import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; import { Skeleton } from '@/components/ui/skeleton'; @@ -63,9 +64,16 @@ const ACCOUNT_COLORS = [ export function AuthMonitor() { const { data, isLoading, error } = useCliproxyAuth(); + const { data: statsData, isLoading: statsLoading } = useCliproxyStats(); const [selectedProvider, setSelectedProvider] = useState(null); const [hoveredProvider, setHoveredProvider] = useState(null); + // Build a map of account email -> usage stats from CLIProxy + const accountStatsMap = useMemo(() => { + if (!statsData?.accountStats) return new Map(); + return new Map(Object.entries(statsData.accountStats)); + }, [statsData?.accountStats]); + // Transform auth status data into account rows const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { if (!data?.authStatus) { @@ -96,9 +104,11 @@ export function AuthMonitor() { if (!providerData) return; status.accounts?.forEach((account: OAuthAccount) => { - // Mock stats - in production, fetch from CLIProxy /usage endpoint - const success = Math.floor(Math.random() * 2000) + 100; - const failure = account.isDefault ? Math.floor(Math.random() * 50) : 0; + // Get real stats from CLIProxy - try email first, then id + const accountEmail = account.email || account.id; + const realStats = accountStatsMap.get(accountEmail); + const success = realStats?.successCount ?? 0; + const failure = realStats?.failureCount ?? 0; tSuccess += success; tFailure += failure; providerData.success += success; @@ -112,7 +122,7 @@ export function AuthMonitor() { isDefault: account.isDefault, successCount: success, failureCount: failure, - lastUsedAt: account.lastUsedAt, + lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt, color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], }; accountsList.push(row); @@ -144,7 +154,7 @@ export function AuthMonitor() { totalRequests: tSuccess + tFailure, providerStats: providerStatsArr, }; - }, [data?.authStatus]); + }, [data?.authStatus, accountStatsMap]); const overallSuccessRate = totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; @@ -154,7 +164,7 @@ export function AuthMonitor() { ? providerStats.find((ps) => ps.provider === selectedProvider) : null; - if (isLoading) { + if (isLoading || statsLoading) { return (
diff --git a/ui/src/components/profile-deck.tsx b/ui/src/components/profile-deck.tsx index 20523469..4a3255b0 100644 --- a/ui/src/components/profile-deck.tsx +++ b/ui/src/components/profile-deck.tsx @@ -31,20 +31,17 @@ export function ProfileDeck() { ); } - // Mock data for demonstration - const mockProfiles = profiles.map((profile, index: number) => ({ + // Use real profile data directly + const normalizedProfiles = profiles.map((profile) => ({ ...profile, configured: profile.configured ?? false, // Ensure configured is always boolean - isActive: index === 0, // First profile is active - lastUsed: index === 0 ? '2 hours ago' : index === 1 ? '1 day ago' : undefined, - model: index === 0 ? 'claude-3.5-sonnet' : index === 1 ? 'claude-3-haiku' : undefined, })); return (

Profiles

- {mockProfiles.map((profile) => ( + {normalizedProfiles.map((profile) => ( ; requestsByProvider: Record; + /** Per-account usage breakdown */ + accountStats: Record; quotaExceededCount: number; retryCount: number; collectedAt: string; From 994bd7765acd735635d0c324acac1aea0bb8a165 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 06:45:38 -0500 Subject: [PATCH 8/9] feat(monitor): Enhance auth monitor and account flow visualization --- ui/src/components/account-flow-viz.tsx | 431 ++++++++++++++++++++----- ui/src/components/auth-monitor.tsx | 104 ++++-- ui/src/hooks/use-cliproxy-stats.ts | 4 +- ui/src/index.css | 77 +++++ 4 files changed, 509 insertions(+), 107 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index f12fb059..59339640 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -9,7 +9,21 @@ import { cn } from '@/lib/utils'; import { ProviderIcon } from '@/components/provider-icon'; import { PROVIDER_COLORS } from '@/lib/provider-config'; import { STATUS_COLORS } from '@/lib/utils'; -import { ChevronRight, X, CheckCircle2, XCircle, Clock, Activity } from 'lucide-react'; +import { + ChevronRight, + X, + CheckCircle2, + XCircle, + Clock, + Activity, + GripVertical, +} from 'lucide-react'; + +/** Position offset for draggable cards */ +interface DragOffset { + x: number; + y: number; +} interface AccountData { id: string; @@ -203,10 +217,48 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const [selectedAccount, setSelectedAccount] = useState(null); const [paths, setPaths] = useState([]); + // Drag state for all cards (account IDs + 'provider') + const [dragOffsets, setDragOffsets] = useState>({}); + const [draggingId, setDraggingId] = useState(null); + const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( + null + ); + + // Pulse state: account IDs that are currently pulsing + const [pulsingAccounts, setPulsingAccounts] = useState>(new Set()); + // Store previous counts to detect changes + const [prevCounts, setPrevCounts] = useState>({}); + const { accounts } = providerData; const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); + // Detect new activity and trigger pulse (runs when accounts data changes) + useEffect(() => { + const newPulsing = new Set(); + const newCounts: Record = {}; + + accounts.forEach((account) => { + const currentCount = account.successCount + account.failureCount; + newCounts[account.id] = currentCount; + const prev = prevCounts[account.id] ?? 0; + + if (currentCount > prev && prev > 0) { + newPulsing.add(account.id); + } + }); + + setPrevCounts(newCounts); + + if (newPulsing.size > 0) { + setPulsingAccounts(newPulsing); + // Clear pulse after animation + const timer = setTimeout(() => setPulsingAccounts(new Set()), 600); + return () => clearTimeout(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accounts]); + // Generate connection events for timeline const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]); @@ -222,10 +274,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { if (!destEl) return; const destRect = destEl.getBoundingClientRect(); - // Destination point (left center of provider card) - const destX = destRect.left - svgRect.left; - const destY = destRect.top + destRect.height / 2 - svgRect.top; - const newPaths: string[] = []; accounts.forEach((_, i) => { @@ -233,9 +281,24 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { if (!sourceEl) return; const sourceRect = sourceEl.getBoundingClientRect(); - // Source point (right center of account card) - const startX = sourceRect.right - svgRect.left; - const startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + // Determine if this account is on the right side + const isRightSide = sourceEl.hasAttribute('data-right-side'); + + let startX: number, startY: number, destX: number, destY: number; + + if (isRightSide) { + // Right side account: connect from left edge to right edge of provider + startX = sourceRect.left - svgRect.left; + startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + destX = destRect.right - svgRect.left; + destY = destRect.top + destRect.height / 2 - svgRect.top; + } else { + // Left side account: connect from right edge to left edge of provider + startX = sourceRect.right - svgRect.left; + startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + destX = destRect.left - svgRect.left; + destY = destRect.top + destRect.height / 2 - svgRect.top; + } // Bezier control points const cp1X = startX + (destX - startX) * 0.5; @@ -261,6 +324,60 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; + // Split accounts into left and right groups (when > 1 account, balance both sides) + const { leftAccounts, rightAccounts } = useMemo(() => { + if (accounts.length <= 1) { + return { leftAccounts: accounts, rightAccounts: [] }; + } + const mid = Math.ceil(accounts.length / 2); + return { + leftAccounts: accounts.slice(0, mid), + rightAccounts: accounts.slice(mid), + }; + }, [accounts]); + + const hasRightAccounts = rightAccounts.length > 0; + + // Drag handlers + const handlePointerDown = useCallback( + (id: string, e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + const offset = dragOffsets[id] || { x: 0, y: 0 }; + dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y }; + setDraggingId(id); + }, + [dragOffsets] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!draggingId || !dragStartRef.current) return; + const start = dragStartRef.current; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + setDragOffsets((prev) => ({ + ...prev, + [draggingId]: { + x: start.offsetX + dx, + y: start.offsetY + dy, + }, + })); + // Recalculate paths during drag + requestAnimationFrame(calculatePaths); + }, + [draggingId, calculatePaths] + ); + + const handlePointerUp = useCallback(() => { + setDraggingId(null); + dragStartRef.current = null; + }, []); + + // Get offset for a card + const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 }; + return (
{/* Back button */} @@ -274,10 +391,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { )} - {/* Main visualization area - 2 column layout: Flow viz (left) | Timeline (right) */} + {/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */}
- {/* Left Section: Account → Provider Flow - expands to fill available space */} -
+ {/* Flow visualization section */} +
{/* SVG Canvas (Background) */} + + {/* Base path - static connection line */} + + {/* Pulse layer - only shows when new activity detected */} + {isPulsing && ( + + )} + ); })} - {/* Source Accounts */} -
- {accounts.map((account, i) => { + {/* Left Accounts */} +
+ {leftAccounts.map((account) => { + const originalIndex = accounts.findIndex((a) => a.id === account.id); const total = account.successCount + account.failureCount; - const isHovered = hoveredAccount === i; + const isHovered = hoveredAccount === originalIndex; + const isDragging = draggingId === account.id; + const offset = getOffset(account.id); return (
setSelectedAccount(account)} - onMouseEnter={() => setHoveredAccount(i)} + data-account-index={originalIndex} + onClick={() => !isDragging && setSelectedAccount(account)} + onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} + onPointerDown={(e) => handlePointerDown(account.id, e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', + 'group/card relative rounded-lg p-3 pr-6 cursor-grab transition-shadow duration-200', 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', 'border border-border/50 dark:border-white/[0.08]', - 'border-l-2 hover:translate-x-1', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60' + 'border-l-2 select-none touch-none', + isHovered && 'bg-muted/50 dark:bg-zinc-800/60', + isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' )} - style={{ borderLeftColor: account.color }} + style={{ + borderLeftColor: account.color, + transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, + }} > -
- + {/* Drag handle indicator */} + +
+ {cleanEmail(account.email)}
- {/* Connector Dot */} + {/* Connector Dot - Right side */}
- {/* Destination Provider */} -
-
- {/* Connector Point */} -
+ {/* Center Provider */} +
+ {(() => { + const isDragging = draggingId === 'provider'; + const offset = getOffset('provider'); + return ( +
handlePointerDown('provider', e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + className={cn( + 'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200', + 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', + 'border-2 border-border/50 dark:border-white/[0.08]', + // Idle animations: float + border glow (disabled when dragging) + !isDragging && 'animate-subtle-float animate-border-glow', + 'select-none touch-none', + hoveredAccount !== null && 'scale-[1.02]', + isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50' + )} + style={ + { + '--glow-color': `${providerColor}60`, + borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined, + transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, + } as React.CSSProperties + } + > + {/* Drag handle */} + -
- -
-

- {providerData.displayName} -

-

- Provider -

-
-
- -
-
- Total Requests - - {totalRequests.toLocaleString()} - -
-
- Accounts - {accounts.length} -
-
+ {/* Animated glow background */}
+ + {/* Left Connector Point */} +
+ + {/* Right Connector Point - only show if there are right accounts */} + {hasRightAccounts && ( +
+ )} + +
+ {/* Provider icon with breathing animation */} +
+ +
+
+

+ {providerData.displayName} +

+

+ Provider +

+
+
+ +
+
+ Total Requests + + {totalRequests.toLocaleString()} + +
+
+ Accounts + {accounts.length} +
+
+
+
+
-
-
+ ); + })()}
+ + {/* Right Accounts */} + {hasRightAccounts && ( +
+ {rightAccounts.map((account) => { + const originalIndex = accounts.findIndex((a) => a.id === account.id); + const total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === originalIndex; + const isDragging = draggingId === account.id; + const offset = getOffset(account.id); + + return ( +
!isDragging && setSelectedAccount(account)} + onMouseEnter={() => setHoveredAccount(originalIndex)} + onMouseLeave={() => setHoveredAccount(null)} + onPointerDown={(e) => handlePointerDown(account.id, e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + className={cn( + 'group/card relative rounded-lg p-3 pl-6 cursor-grab transition-shadow duration-200', + 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', + 'border border-border/50 dark:border-white/[0.08]', + 'border-r-2 select-none touch-none', + isHovered && 'bg-muted/50 dark:bg-zinc-800/60', + isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' + )} + style={{ + borderRightColor: account.color, + transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, + }} + > + {/* Drag handle indicator */} + +
+ + + {cleanEmail(account.email)} + +
+
+
+
+ {account.failureCount > 0 && ( +
+ )} +
+ + {total.toLocaleString()} reqs + +
+ {/* Connector Dot - Left side */} +
+
+ ); + })} +
+ )}
{/* Right Section: Connection Timeline - Fixed compact width */} diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx index ae17e7c7..cdac5e5c 100644 --- a/ui/src/components/auth-monitor.tsx +++ b/ui/src/components/auth-monitor.tsx @@ -4,7 +4,7 @@ * Uses glass panel aesthetic with hover interactions and glow effects */ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats'; import { cn, STATUS_COLORS } from '@/lib/utils'; @@ -13,7 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { ProviderIcon } from '@/components/provider-icon'; import { AccountFlowViz } from '@/components/account-flow-viz'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; -import { Activity, CheckCircle2, XCircle, ChevronRight } from 'lucide-react'; +import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react'; interface AccountRow { id: string; @@ -62,11 +62,77 @@ const ACCOUNT_COLORS = [ '#a78bfa', // Purple ]; +/** Enhanced live pulse indicator with multi-ring animation */ +function LivePulse() { + return ( +
+ {/* Outer ping ring */} +
+ {/* Middle pulse ring */} +
+ {/* Inner solid dot */} +
+
+ ); +} + +/** Inline success/failure badge for provider cards */ +function InlineStatsBadge({ success, failure }: { success: number; failure: number }) { + if (success === 0 && failure === 0) { + return no activity; + } + + return ( +
+
+ + + {success.toLocaleString()} + +
+ {failure > 0 && ( +
+ + + {failure.toLocaleString()} + +
+ )} +
+ ); +} + export function AuthMonitor() { const { data, isLoading, error } = useCliproxyAuth(); - const { data: statsData, isLoading: statsLoading } = useCliproxyStats(); + const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats(); const [selectedProvider, setSelectedProvider] = useState(null); const [hoveredProvider, setHoveredProvider] = useState(null); + const [timeSinceUpdate, setTimeSinceUpdate] = useState(''); + + // Live countdown showing time since last data update + useEffect(() => { + if (!dataUpdatedAt) return; + const updateTime = () => { + const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000); + if (diff < 60) { + setTimeSinceUpdate(`${diff}s ago`); + } else { + setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`); + } + }; + updateTime(); + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + }, [dataUpdatedAt]); // Build a map of account email -> usage stats from CLIProxy const accountStatsMap = useMemo(() => { @@ -189,18 +255,19 @@ export function AuthMonitor() { return (
- {/* Header */} -
-
-
- - Live Stream - + {/* Enhanced Live Header with gradient glow */} +
+
+ + LIVE + Account Monitor
-
+
+
+ + Updated {timeSinceUpdate || 'now'} +
+ | {accounts.length} accounts {totalRequests.toLocaleString()} req
@@ -299,11 +366,10 @@ export function AuthMonitor() {
-
- Requests - - {ps.totalRequests.toLocaleString()} - + {/* Inline success/failure stats - immediately visible */} +
+ Stats +
Success Rate diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index d930277d..3d27c7a9 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -85,9 +85,9 @@ export function useCliproxyStats(enabled = true) { queryKey: ['cliproxy-stats'], queryFn: fetchCliproxyStats, enabled, - refetchInterval: 30000, // Refresh every 30 seconds + refetchInterval: 5000, // Refresh every 5 seconds for near-real-time updates retry: 1, - staleTime: 10000, // Consider data stale after 10 seconds + staleTime: 3000, // Consider data stale after 3 seconds }); } diff --git a/ui/src/index.css b/ui/src/index.css index 664ae9c1..ffac8b61 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -199,3 +199,80 @@ .zoom-in-95 { animation-name: zoom-in; } + +/* Flow visualization animations */ +@keyframes request-pulse { + 0% { + stroke-opacity: 0.9; + stroke-width: inherit; + } + 50% { + stroke-opacity: 0.6; + } + 100% { + stroke-opacity: 0; + stroke-width: calc(inherit * 2); + } +} + +@keyframes glow-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--glow-color, rgba(16, 185, 129, 0.2)); + } + 50% { + box-shadow: 0 0 20px 4px var(--glow-color, rgba(16, 185, 129, 0.3)); + } +} + +@keyframes subtle-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} + +@keyframes icon-breathe { + 0%, + 100% { + transform: scale(1); + opacity: 0.9; + } + 50% { + transform: scale(1.05); + opacity: 1; + } +} + +@keyframes border-glow { + 0%, + 100% { + border-color: var(--glow-color, rgba(16, 185, 129, 0.2)); + } + 50% { + border-color: var(--glow-color, rgba(16, 185, 129, 0.5)); + } +} + +.animate-request-pulse { + animation: request-pulse 0.6s ease-out forwards; +} + +.animate-glow-pulse { + animation: glow-pulse 2s ease-in-out infinite; +} + +.animate-subtle-float { + animation: subtle-float 3s ease-in-out infinite; +} + +.animate-icon-breathe { + animation: icon-breathe 2.5s ease-in-out infinite; +} + +.animate-border-glow { + animation: border-glow 2s ease-in-out infinite; +} From 197848a71bbb26cbbd31ea5457ac917b82688d05 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 07:06:06 -0500 Subject: [PATCH 9/9] feat(ui): enhance light theme contrast and animations --- ui/src/components/account-flow-viz.tsx | 44 +++++++----- ui/src/components/auth-monitor.tsx | 32 ++++----- ui/src/index.css | 92 ++++++++++++++++++-------- ui/src/lib/utils.ts | 8 +-- ui/src/pages/home.tsx | 8 +-- 5 files changed, 117 insertions(+), 67 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 59339640..c23e95ea 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -252,8 +252,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { if (newPulsing.size > 0) { setPulsingAccounts(newPulsing); - // Clear pulse after animation - const timer = setTimeout(() => setPulsingAccounts(new Set()), 600); + // Clear pulse after animation completes (match CSS animation duration) + const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000); return () => clearTimeout(timer); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -429,15 +429,29 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { /> {/* Pulse layer - only shows when new activity detected */} {isPulsing && ( - + <> + {/* Glowing path pulse */} + + {/* Traveling dot along path */} + + )} ); @@ -731,20 +745,20 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { {/* Stats */}
- + SUCCESSFUL
-
+
{selectedAccount.successCount.toLocaleString()}
- + FAILED
-
+
{selectedAccount.failureCount.toLocaleString()}
diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx index cdac5e5c..4f812050 100644 --- a/ui/src/components/auth-monitor.tsx +++ b/ui/src/components/auth-monitor.tsx @@ -48,18 +48,18 @@ function cleanEmail(email: string): string { return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); } -// Vibrant colors for account segments +// Vibrant colors for account segments - darker for light theme contrast const ACCOUNT_COLORS = [ - '#277da1', // Cerulean - '#43aa8b', // Seaweed - '#f9c74f', // Tuscan Sun - '#f94144', // Strawberry - '#f3722c', // Pumpkin - '#90be6d', // Willow - '#577590', // Blue Slate - '#f8961e', // Carrot - '#4d908e', // Dark Cyan - '#a78bfa', // Purple + '#1e6091', // Deep Cerulean (was #277da1) + '#2d8a6e', // Deep Seaweed (was #43aa8b) + '#d4a012', // Dark Tuscan (was #f9c74f) + '#c92a2d', // Deep Strawberry (was #f94144) + '#c45a1a', // Deep Pumpkin (was #f3722c) + '#6b9c4d', // Dark Willow (was #90be6d) + '#3d5a73', // Deep Blue Slate (was #577590) + '#cc7614', // Dark Carrot (was #f8961e) + '#3a7371', // Deep Cyan (was #4d908e) + '#7c5fc4', // Deep Purple (was #a78bfa) ]; /** Enhanced live pulse indicator with multi-ring animation */ @@ -94,15 +94,15 @@ function InlineStatsBadge({ success, failure }: { success: number; failure: numb return (
- - + + {success.toLocaleString()}
{failure > 0 && (
- - + + {failure.toLocaleString()}
@@ -308,7 +308,7 @@ export function AuthMonitor() {
{/* Flow Visualization */} -
+
{selectedProviderData ? ( // Account-level flow view 0.40 */ - --border: oklch(0.85 0.015 91.6); /* Based on Cloudy */ - --input: oklch(0.85 0.015 91.6); + --border: oklch(0.78 0.02 91.6); /* Darker: 0.85 -> 0.78 */ + --input: oklch(0.78 0.02 91.6); - --ring: oklch(0.2 0.02 40); /* Match primary */ + --ring: oklch(0.15 0.02 40); /* Match primary */ --radius: 0.5rem; - --popover: oklch(0.9635 0.0067 97.35); /* Match background */ - --popover-foreground: oklch(0.2 0.02 40); /* Match foreground */ + --popover: oklch(0.98 0.005 97.35); /* Slight depth */ + --popover-foreground: oklch(0.15 0.02 40); /* Match foreground */ - --card: oklch(0.9635 0.0067 97.35); /* Match background */ - --card-foreground: oklch(0.2 0.02 40); /* Match foreground */ + --card: oklch(0.98 0.005 97.35); /* Slight depth from background */ + --card-foreground: oklch(0.15 0.02 40); /* Match foreground */ - --destructive: oklch(0.577 0.245 27.325); /* Red 600 */ - --destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */ + --destructive: oklch(0.5 0.22 27); /* Darker red for visibility */ + --destructive-foreground: oklch(0.98 0.005 97.35); /* White */ /* Sidebar colors - Light */ --sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */ - --sidebar-foreground: oklch(0.2 0.02 40); - --sidebar-primary: oklch(0.2 0.02 40); /* Neutral */ + --sidebar-foreground: oklch(0.15 0.02 40); + --sidebar-primary: oklch(0.15 0.02 40); /* Neutral - darker */ --sidebar-primary-foreground: oklch(0.9635 0.0067 97.35); - --sidebar-accent: oklch(0.5971 0.1352 39.87); /* Crail */ + --sidebar-accent: oklch(0.52 0.15 39.87); /* Crail - darker */ --sidebar-accent-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.85 0.015 91.6); - --sidebar-ring: oklch(0.2 0.02 40); + --sidebar-border: oklch(0.78 0.02 91.6); /* Darker */ + --sidebar-ring: oklch(0.15 0.02 40); } .dark { @@ -203,25 +203,57 @@ /* Flow visualization animations */ @keyframes request-pulse { 0% { - stroke-opacity: 0.9; + stroke-opacity: 1; stroke-width: inherit; + filter: drop-shadow(0 0 2px currentColor); } - 50% { - stroke-opacity: 0.6; + 30% { + stroke-opacity: 0.9; + filter: drop-shadow(0 0 8px currentColor); + } + 60% { + stroke-opacity: 0.5; + stroke-width: calc(inherit * 1.5); + filter: drop-shadow(0 0 4px currentColor); } 100% { stroke-opacity: 0; stroke-width: calc(inherit * 2); + filter: none; + } +} + +/* Traveling dot along path */ +@keyframes travel-dot { + 0% { + offset-distance: 0%; + opacity: 1; + } + 80% { + opacity: 1; + } + 100% { + offset-distance: 100%; + opacity: 0; } } @keyframes glow-pulse { 0%, 100% { - box-shadow: 0 0 0 0 var(--glow-color, rgba(16, 185, 129, 0.2)); + box-shadow: 0 0 0 0 var(--glow-color, rgba(16, 185, 129, 0.3)); + transform: scale(1); + } + 25% { + box-shadow: 0 0 15px 3px var(--glow-color, rgba(16, 185, 129, 0.5)); + transform: scale(1.02); } 50% { - box-shadow: 0 0 20px 4px var(--glow-color, rgba(16, 185, 129, 0.3)); + box-shadow: 0 0 25px 6px var(--glow-color, rgba(16, 185, 129, 0.4)); + transform: scale(1.01); + } + 75% { + box-shadow: 0 0 12px 2px var(--glow-color, rgba(16, 185, 129, 0.35)); } } @@ -258,11 +290,15 @@ } .animate-request-pulse { - animation: request-pulse 0.6s ease-out forwards; + animation: request-pulse 2s cubic-bezier(0.4, 0, 0.2, 1) forwards; } .animate-glow-pulse { - animation: glow-pulse 2s ease-in-out infinite; + animation: glow-pulse 2.5s ease-in-out infinite; +} + +.animate-travel-dot { + animation: travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; } .animate-subtle-float { diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 417ed674..326a4440 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -29,11 +29,11 @@ const PROVIDER_COLORS: Record = { qwen: '#f9c74f', // Tuscan }; -// Status colors (from Analytics Cost breakdown) +// Status colors (from Analytics Cost breakdown) - darker for light theme contrast export const STATUS_COLORS = { - success: '#43aa8b', // Seaweed - degraded: '#e09f3e', // Ochre - failed: '#9e2a2b', // Merlot + success: '#15803d', // Green-700 (was Seaweed #43aa8b) + degraded: '#b45309', // Amber-700 (was Ochre #e09f3e) + failed: '#b91c1c', // Red-700 (was Merlot #9e2a2b) } as const; export function getModelColor(model: string): string { diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 1a8adc5c..ccc5bf92 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -19,10 +19,10 @@ type StatVariant = 'default' | 'success' | 'warning' | 'error' | 'accent'; const variantStyles: Record = { default: { iconBg: 'bg-muted', iconColor: 'text-muted-foreground' }, - success: { iconBg: 'bg-green-500/10', iconColor: 'text-green-600' }, - warning: { iconBg: 'bg-yellow-500/10', iconColor: 'text-yellow-500' }, - error: { iconBg: 'bg-red-500/10', iconColor: 'text-red-500' }, - accent: { iconBg: 'bg-accent/10', iconColor: 'text-accent' }, + success: { iconBg: 'bg-green-600/15', iconColor: 'text-green-700 dark:text-green-500' }, + warning: { iconBg: 'bg-amber-500/15', iconColor: 'text-amber-700 dark:text-amber-400' }, + error: { iconBg: 'bg-red-600/15', iconColor: 'text-red-700 dark:text-red-500' }, + accent: { iconBg: 'bg-accent/15', iconColor: 'text-accent' }, }; function InlineStat({