diff --git a/package-lock.json b/package-lock.json index de1d6b5..ed4e11e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.10.1", "redis": "^4.7.1", "sharp": "0.35.2", "undici": "^6.26.0" @@ -1711,6 +1712,32 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -2295,6 +2322,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2350,6 +2389,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2379,7 +2481,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2395,6 +2497,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2645,6 +2753,15 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -2723,9 +2840,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2752,6 +2990,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -2867,6 +3111,17 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3321,12 +3576,31 @@ "node": ">=0.10.0" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4023,6 +4297,36 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4071,6 +4375,42 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/redis": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", @@ -4088,6 +4428,21 @@ "@redis/time-series": "1.1.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4109,6 +4464,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -4465,6 +4826,12 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4592,6 +4959,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4610,6 +4986,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "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" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/package.json b/package.json index 2c8a283..5dd95eb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.10.1", "redis": "^4.7.1", "sharp": "0.35.2", "undici": "^6.26.0" diff --git a/server/app.mjs b/server/app.mjs index 24d9281..768ef3d 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import express from 'express'; -import { listUsagePaged, listLedgerPaged } from './pagination.mjs'; +import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs'; const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -307,7 +307,29 @@ export function createAdminApp(services) { res.json({ user: result.user }); }); + async function handleUsageStats(req, res) { + try { + const result = await getUsageStats(pool, req.query); + res.json(result); + } catch (err) { + if (err?.code === 'INVALID_TIME_RANGE') { + return res.status(400).json({ message: '无效的时间范围' }); + } + throw err; + } + } + + adminApi.get('/usage/summary', requireAdmin, async (req, res) => { + const result = await getUsageSummary(pool, req.query); + res.json(result); + }); + + adminApi.get('/usage/stats', requireAdmin, handleUsageStats); + adminApi.get('/usage', requireAdmin, async (req, res) => { + if (req.query.stats === '1' || req.query.stats === 'true') { + return handleUsageStats(req, res); + } const result = await listUsagePaged(pool, req.query); res.json(result); }); @@ -399,6 +421,14 @@ export function createAdminApp(services) { res.json(result); }); + adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => { + if (!memoryV2ConfigService?.getRuntimeState) { + return res.status(503).json({ message: 'Memory V2 配置服务未启用' }); + } + const result = await memoryV2ConfigService.getRuntimeState(); + return res.json(result); + }); + adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => { if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' }); res.json(await mindSearchConfigService.getAdminConfig()); diff --git a/server/pagination.mjs b/server/pagination.mjs index c5bab39..f867299 100644 --- a/server/pagination.mjs +++ b/server/pagination.mjs @@ -17,15 +17,139 @@ export function paginateArray(items, query, defaultPageSize = 20) { }; } +const MS_THRESHOLD = 1_000_000_000_000; + +function parseUsageTimeFilters(query) { + const startAt = Number(query.startAt); + const endAt = Number(query.endAt); + const hasStart = Number.isFinite(startAt) && startAt > 0; + const hasEnd = Number.isFinite(endAt) && endAt > 0; + if (!hasStart || !hasEnd || startAt > endAt) { + return null; + } + // Frontend sends unix seconds; h5_usage_records.created_at is stored in milliseconds. + const startAtMs = startAt < MS_THRESHOLD ? startAt * 1000 : startAt; + const endAtMs = endAt < MS_THRESHOLD ? endAt * 1000 + 999 : endAt; + return { startAt, endAt, startAtMs, endAtMs }; +} + +function formatSqlDay(value) { + if (value instanceof Date) { + const y = value.getFullYear(); + const m = String(value.getMonth() + 1).padStart(2, '0'); + const d = String(value.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + return String(value).slice(0, 10); +} + +export async function getUsageTotals(pool, { userId = null, startAtMs = null, endAtMs = null } = {}) { + const params = []; + const clauses = []; + if (userId) { + clauses.push('r.user_id = ?'); + params.push(userId); + } + if (startAtMs != null && endAtMs != null) { + clauses.push('r.created_at >= ?', 'r.created_at <= ?'); + params.push(startAtMs, endAtMs); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [[row]] = await pool.query( + `SELECT COUNT(*) AS count, + COALESCE(SUM(r.input_tokens), 0) AS input_tokens, + COALESCE(SUM(r.output_tokens), 0) AS output_tokens, + COALESCE(SUM(r.cost_cents), 0) AS cost_cents + FROM h5_usage_records r + ${where}`, + params, + ); + return { + count: Number(row.count), + inputTokens: Number(row.input_tokens), + outputTokens: Number(row.output_tokens), + costCents: Number(row.cost_cents), + }; +} + +export async function getUsageSummary(pool, query) { + const userId = typeof query.userId === 'string' && query.userId ? query.userId : null; + const range = parseUsageTimeFilters(query); + const allTime = await getUsageTotals(pool, { userId }); + if (!range) { + return { range: null, allTime }; + } + const rangeTotals = await getUsageTotals(pool, { + userId, + startAtMs: range.startAtMs, + endAtMs: range.endAtMs, + }); + return { + range: { + ...rangeTotals, + startAt: range.startAt, + endAt: range.endAt, + }, + allTime, + }; +} + +export async function getUsageStats(pool, query) { + const userId = typeof query.userId === 'string' && query.userId ? query.userId : null; + const range = parseUsageTimeFilters(query); + if (!range) { + const err = new Error('INVALID_TIME_RANGE'); + err.code = 'INVALID_TIME_RANGE'; + throw err; + } + const { startAt, endAt, startAtMs, endAtMs } = range; + const params = [startAtMs, endAtMs]; + const clauses = ['r.created_at >= ?', 'r.created_at <= ?']; + if (userId) { + clauses.push('r.user_id = ?'); + params.push(userId); + } + const where = `WHERE ${clauses.join(' AND ')}`; + const [rows] = await pool.query( + `SELECT DATE(FROM_UNIXTIME(r.created_at / 1000)) AS day, + COUNT(*) AS count, + COALESCE(SUM(r.input_tokens), 0) AS input_tokens, + COALESCE(SUM(r.output_tokens), 0) AS output_tokens, + COALESCE(SUM(r.cost_cents), 0) AS cost_cents + FROM h5_usage_records r + ${where} + GROUP BY day + ORDER BY day ASC`, + params, + ); + return { + buckets: rows.map((row) => ({ + day: formatSqlDay(row.day), + count: Number(row.count), + inputTokens: Number(row.input_tokens), + outputTokens: Number(row.output_tokens), + costCents: Number(row.cost_cents), + })), + startAt, + endAt, + }; +} + export async function listUsagePaged(pool, query) { const { page, pageSize, offset } = parsePageQuery(query, 20); const userId = typeof query.userId === 'string' && query.userId ? query.userId : null; + const range = parseUsageTimeFilters(query); const params = []; - let where = ''; + const clauses = []; if (userId) { - where = 'WHERE r.user_id = ?'; + clauses.push('r.user_id = ?'); params.push(userId); } + if (range) { + clauses.push('r.created_at >= ?', 'r.created_at <= ?'); + params.push(range.startAtMs, range.endAtMs); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const [[{ total }]] = await pool.query( `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`, params, diff --git a/src/admin/pages/BillingPage.tsx b/src/admin/pages/BillingPage.tsx index 434c3df..960b5c0 100644 --- a/src/admin/pages/BillingPage.tsx +++ b/src/admin/pages/BillingPage.tsx @@ -1,8 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { cancelUserSubscription, createSubscriptionPlan, deleteSubscriptionPlan, + getAdminUsageStats, + getAdminUsageSummary, grantUserSubscription, listAdminLedger, listAdminSubscriptions, @@ -13,8 +16,19 @@ import { updateSubscriptionPlan, } from '../../api/client'; import type { PagedResult } from '../../api/client'; -import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types'; +import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord, UsageStatsResult, UsageSummaryResult, UsageTotals } from '../../types'; import { Pagination } from '../../components/Pagination'; +import { + dateRangeToUnix, + DEFAULT_CHART_METRICS, + METRIC_CONFIG, + METRIC_OPTIONS, + presetDateRange, + toggleChartMetric, + UsageTrendChart, + type UsageChartMetric, + type UsageChartType, +} from '../../components/UsageTrendChart'; import { useAdminUsers } from '../hooks/useAdminUsers'; import { formatTime, formatYuan } from '../utils/format'; @@ -111,6 +125,22 @@ const TABS: { key: TabKey; label: string }[] = [ { key: 'ledger', label: '资金流水' }, ]; +const TAB_PATHS: Record = { + subscriptions: '/billing', + recharge: '/billing/recharge', + usage: '/billing/usage', + ledger: '/billing/ledger', +}; + +function tabFromPath(pathname: string): TabKey { + const suffix = pathname.replace(/^\/billing\/?/, ''); + if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage'; + if (suffix === 'recharge') return 'recharge'; + if (suffix === 'ledger') return 'ledger'; + if (suffix === 'subscriptions') return 'subscriptions'; + return 'subscriptions'; +} + const PAGE_SIZE = 20; function RechargeTab() { @@ -161,21 +191,416 @@ function RechargeTab() { ); } -function UsageTab() { - const { users } = useAdminUsers(); +function UsageRecordsTable({ + result, + onPage, +}: { + result: PagedResult; + onPage: (page: number) => void; +}) { + return ( + <> +
+ + + + + + + + + + + + + {result.items.map((row) => ( + + + + + + + + + ))} + +
时间用户输入 Token输出 Token扣费余额后
{formatTime(row.createdAt)} + {row.displayName || row.username} + @{row.username} + {row.inputTokens.toLocaleString()}{row.outputTokens.toLocaleString()}¥{formatYuan(row.costCents)}¥{formatYuan(row.balanceAfterCents)}
+
+ + + ); +} + +function UsageTotalsGrid({ totals }: { totals: UsageTotals }) { + const totalTokens = totals.inputTokens + totals.outputTokens; + return ( +
+
+
请求次数
+
{totals.count.toLocaleString()}
+
+
+
Token 总量
+
{totalTokens.toLocaleString()}
+
+ 入 {totals.inputTokens.toLocaleString()} / 出 {totals.outputTokens.toLocaleString()} +
+
+
+
消费金额
+
¥{formatYuan(totals.costCents)}
+
+
+ ); +} + +function UsageSummaryPanel({ + summary, + rangeLabel, + allTimeLabel = '累计(不限区间)', + loading, +}: { + summary: UsageSummaryResult | null; + rangeLabel: string; + allTimeLabel?: string; + loading?: boolean; +}) { + if (loading && !summary) { + return

汇总加载中…

; + } + if (!summary) return null; + + return ( +
+ {summary.range && ( +
+

{rangeLabel}

+ +
+ )} +
+

{allTimeLabel}

+ +
+
+ ); +} + +function UsageChartPanel({ + stats, + chartMetrics, + chartType, + onMetricsChange, + onChartTypeChange, + title = '用量趋势', + hint, +}: { + stats: UsageStatsResult; + chartMetrics: UsageChartMetric[]; + chartType: UsageChartType; + onMetricsChange: (metrics: UsageChartMetric[]) => void; + onChartTypeChange: (type: UsageChartType) => void; + title?: string; + hint?: string; +}) { + return ( +
+
+
+

{title}

+ {hint &&

{hint}

} +
+
+
+ {METRIC_OPTIONS.map(({ key, label }) => { + const checked = chartMetrics.includes(key); + const color = METRIC_CONFIG[key].color; + return ( + + ); + })} +
+
+ + +
+
+
+ +
+ ); +} + +type UsageView = 'overview' | 'query'; + +function UsageOverviewPanel() { const [result, setResult] = useState | null>(null); + const [stats, setStats] = useState(null); + const [summary, setSummary] = useState(null); + const [loadingTable, setLoadingTable] = useState(false); + const [loadingChart, setLoadingChart] = useState(false); + const [loadingSummary, setLoadingSummary] = useState(false); + const [error, setError] = useState(null); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [chartMetrics, setChartMetrics] = useState(DEFAULT_CHART_METRICS); + const [chartType, setChartType] = useState('bar'); + + const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate); + + const loadTable = useCallback(async (page: number, start: string, end: string) => { + if (start && end && start > end) { + setError('请选择有效的时间范围'); + return; + } + setLoadingTable(true); + setError(null); + try { + const params: { page: number; pageSize: number; startAt?: number; endAt?: number } = { + page, + pageSize: PAGE_SIZE, + }; + if (start && end) { + const range = dateRangeToUnix(start, end); + params.startAt = range.startAt; + params.endAt = range.endAt; + } + const data = await listAdminUsage(params); + setResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoadingTable(false); + } + }, []); + + const loadChart = useCallback(async (start: string, end: string) => { + if (start && end && start > end) return; + setLoadingChart(true); + setError(null); + try { + const chartRange = start && end + ? { startDate: start, endDate: end } + : presetDateRange(30); + const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate); + const statsData = await getAdminUsageStats({ startAt, endAt }); + setStats(statsData); + } catch (err) { + setError(err instanceof Error ? err.message : '图表加载失败'); + } finally { + setLoadingChart(false); + } + }, []); + + const loadSummary = useCallback(async (start: string, end: string) => { + if (start && end && start > end) return; + setLoadingSummary(true); + setError(null); + try { + const chartRange = start && end + ? { startDate: start, endDate: end } + : presetDateRange(30); + const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate); + const data = await getAdminUsageSummary({ startAt, endAt }); + setSummary(data); + } catch (err) { + setError(err instanceof Error ? err.message : '汇总加载失败'); + } finally { + setLoadingSummary(false); + } + }, []); + + useEffect(() => { + void loadTable(1, startDate, endDate); + }, [startDate, endDate, loadTable]); + + useEffect(() => { + void loadChart(startDate, endDate); + }, [startDate, endDate, loadChart]); + + useEffect(() => { + void loadSummary(startDate, endDate); + }, [startDate, endDate, loadSummary]); + + const applyPreset = (days: number) => { + const range = presetDateRange(days); + setStartDate(range.startDate); + setEndDate(range.endDate); + }; + + const clearDateFilter = () => { + setStartDate(''); + setEndDate(''); + }; + + const handlePage = (page: number) => void loadTable(page, startDate, endDate); + + const chartHint = hasDateFilter + ? `${startDate} 至 ${endDate} 全平台用量(列表与图表同步筛选)` + : '近 30 天全平台用量(列表展示全部记录;设置日期后列表与图表同步筛选)'; + + const rangeSummaryLabel = hasDateFilter + ? `区间汇总(${startDate} 至 ${endDate})` + : '区间汇总(近 30 天)'; + + return ( + <> + {error &&

{error}

} +
+ 日期区间 +
+ setStartDate(e.target.value)} + /> + + setEndDate(e.target.value)} + /> +
+
+ + + + +
+
+ + {loadingChart && !stats &&

图表加载中…

} + {!loadingChart && stats && ( + + )} + {loadingTable && !result &&

加载中…

} + {!loadingTable && result && ( + result.items.length === 0 ? ( +

暂无记录

+ ) : ( + + ) + )} + + ); +} + +function UsageQueryPanel() { + const { users } = useAdminUsers(); + const defaultRange = presetDateRange(30); + const [result, setResult] = useState | null>(null); + const [stats, setStats] = useState(null); + const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [filterUserId, setFilterUserId] = useState(''); - const [pendingUserId, setPendingUserId] = useState(''); + const [userId, setUserId] = useState(''); + const [startDate, setStartDate] = useState(defaultRange.startDate); + const [endDate, setEndDate] = useState(defaultRange.endDate); + const [chartMetrics, setChartMetrics] = useState(DEFAULT_CHART_METRICS); + const [chartType, setChartType] = useState('bar'); - const load = useCallback(async (p: number, userId: string) => { + const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate); + + const load = useCallback(async (p: number, uid: string, start: string, end: string, withChart: boolean) => { + if (!uid) return; + if (start && end && start > end) { + setError('请选择有效的时间范围'); + return; + } + if ((start && !end) || (!start && end)) { + setError('请完整选择起止日期'); + return; + } + const filtered = Boolean(start && end); setLoading(true); setError(null); try { - const data = await listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE }); + const listParams: { userId: string; page: number; pageSize: number; startAt?: number; endAt?: number } = { + userId: uid, + page: p, + pageSize: PAGE_SIZE, + }; + let statsPromise: Promise = Promise.resolve(null); + let summaryPromise: Promise = Promise.resolve(null); + + if (filtered) { + const { startAt, endAt } = dateRangeToUnix(start, end); + listParams.startAt = startAt; + listParams.endAt = endAt; + if (withChart) { + statsPromise = getAdminUsageStats({ userId: uid, startAt, endAt }); + summaryPromise = getAdminUsageSummary({ userId: uid, startAt, endAt }); + } + } else if (withChart) { + const endAt = Math.floor(Date.now() / 1000); + statsPromise = getAdminUsageStats({ userId: uid, startAt: 1, endAt }); + summaryPromise = getAdminUsageSummary({ userId: uid }); + } + + const [data, statsData, summaryData] = await Promise.all([ + listAdminUsage(listParams), + statsPromise, + summaryPromise, + ]); setResult(data); - setFilterUserId(userId); + if (statsData) setStats(statsData); + if (summaryData) setSummary(summaryData); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { @@ -183,71 +608,155 @@ function UsageTab() { } }, []); - useEffect(() => { void load(1, ''); }, [load]); + useEffect(() => { + if (!userId) { + setResult(null); + setStats(null); + setSummary(null); + setError(null); + return; + } + void load(1, userId, startDate, endDate, true); + }, [userId, startDate, endDate, load]); - const handleQuery = () => void load(1, pendingUserId); - const handlePage = (p: number) => void load(p, filterUserId); + const applyPreset = (days: number) => { + const range = presetDateRange(days); + setStartDate(range.startDate); + setEndDate(range.endDate); + }; + + const clearDateFilter = () => { + setStartDate(''); + setEndDate(''); + }; + + const handlePage = (p: number) => void load(p, userId, startDate, endDate, false); + + const selectedUser = users.find((u) => u.id === userId); + const chartHint = hasDateFilter + ? `${startDate} 至 ${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}` + : `全部日期${selectedUser ? ` · ${selectedUser.displayName}` : ''}`; + + const rangeSummaryLabel = hasDateFilter + ? `区间汇总(${startDate} 至 ${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''})` + : ''; + + const allTimeSummaryLabel = selectedUser + ? `累计汇总(${selectedUser.displayName} · 全部日期)` + : '累计汇总(全部日期)'; return ( -
-
-

用量记录

-
- +
+ +
+ setStartDate(e.target.value)} + disabled={!userId} /> -
+
+ + + +
{error &&

{error}

} - {!result && !loading && ( -

选择筛选条件后点击查询

+ {!userId && !loading && ( +

请选择用户,将自动加载用量与趋势图

)} - {loading &&

加载中…

} - {!loading && result && ( + {userId && loading && !result && !stats &&

加载中…

} + {userId && ( + + )} + {userId && !loading && stats && ( + + )} + {userId && !loading && result && ( result.items.length === 0 ? ( -

暂无记录

+

{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}

) : ( - <> -
- - - - - - - - - - - - - {result.items.map((row) => ( - - - - - - - - - ))} - -
时间用户输入 Token输出 Token扣费余额后
{formatTime(row.createdAt)} - {row.displayName || row.username} - @{row.username} - {row.inputTokens.toLocaleString()}{row.outputTokens.toLocaleString()}¥{formatYuan(row.costCents)}¥{formatYuan(row.balanceAfterCents)}
-
- - + ) )} + + ); +} + +function UsageTab() { + const location = useLocation(); + const navigate = useNavigate(); + const view: UsageView = location.pathname.endsWith('/query') ? 'query' : 'overview'; + + const setView = (next: UsageView) => { + navigate(next === 'query' ? '/billing/usage/query' : '/billing/usage'); + }; + + return ( +
+
+

用量记录

+
+ + +
+
+
+ {view === 'overview' ? : } +
); } @@ -299,7 +808,7 @@ function LedgerTab() {
{error &&

{error}

} {!result && !loading && ( -

选择筛选条件后点击查询

+

选择用户后点击查询

)} {loading &&

加载中…

} {!loading && result && ( @@ -925,7 +1434,9 @@ function SubscriptionsTab() { } export function BillingPage() { - const [activeTab, setActiveTab] = useState('subscriptions'); + const location = useLocation(); + const navigate = useNavigate(); + const activeTab = tabFromPath(location.pathname); return (
@@ -937,7 +1448,7 @@ export function BillingPage() { {TABS.map((tab) => ( ))} diff --git a/src/api/client.ts b/src/api/client.ts index 637cf3e..b3a552e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -35,12 +35,17 @@ import type { MemoryV2AdminConfigResponse, MemoryV2ModelApiType, MemoryV2RuntimeStatusResponse, + MemoryV2ConfigRuntimeState, SkillRuntimeAdminConfig, SkillRuntimeAdminConfigResponse, SkillRuntimeCatalogItem, SkillDefinition, SkillMap, UsageRecord, + UsageStatsBucket, + UsageStatsResult, + UsageSummaryResult, + UsageTotals, WechatAdminSummary, WechatBinding, WechatDeliveryLog, @@ -229,11 +234,15 @@ export async function listAdminUsage(params?: { userId?: string; page?: number; pageSize?: number; + startAt?: number; + endAt?: number; }): Promise> { const q = new URLSearchParams(); if (params?.userId) q.set('userId', params.userId); if (params?.page) q.set('page', String(params.page)); if (params?.pageSize) q.set('pageSize', String(params.pageSize)); + if (params?.startAt) q.set('startAt', String(params.startAt)); + if (params?.endAt) q.set('endAt', String(params.endAt)); const result = await portalFetch<{ records: UsageRecord[]; total: number; page: number; pageSize: number }>( `/admin-api/usage${q.toString() ? `?${q}` : ''}`, ); @@ -242,6 +251,146 @@ export async function listAdminUsage(params?: { return { items: result.records ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) }; } +function toMillis(ts: number) { + return ts >= 1_000_000_000_000 ? ts : ts * 1000; +} + +function usageDayKey(createdAt: number) { + const d = new Date(toMillis(createdAt)); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +async function aggregateUsageStatsFromRecords(params: { + userId?: string; + startAt: number; + endAt: number; +}): Promise { + const bucketMap = new Map(); + let page = 1; + const pageSize = 200; + const maxPages = 50; + + const startMs = params.startAt * 1000; + const endMs = params.endAt * 1000 + 999; + + while (page <= maxPages) { + const result = await listAdminUsage({ + userId: params.userId, + page, + pageSize, + startAt: params.startAt, + endAt: params.endAt, + }); + for (const record of result.items) { + const createdMs = toMillis(record.createdAt); + if (createdMs < startMs || createdMs > endMs) continue; + const day = usageDayKey(record.createdAt); + const bucket = bucketMap.get(day) ?? { + day, + count: 0, + inputTokens: 0, + outputTokens: 0, + costCents: 0, + }; + bucket.count += 1; + bucket.inputTokens += record.inputTokens; + bucket.outputTokens += record.outputTokens; + bucket.costCents += record.costCents; + bucketMap.set(day, bucket); + } + if (page >= result.totalPages || result.items.length === 0) break; + page += 1; + } + + return { + buckets: [...bucketMap.values()].sort((a, b) => a.day.localeCompare(b.day)), + startAt: params.startAt, + endAt: params.endAt, + }; +} + +function sumUsageBuckets(buckets: UsageStatsBucket[]): UsageTotals { + return buckets.reduce( + (acc, b) => ({ + count: acc.count + b.count, + inputTokens: acc.inputTokens + b.inputTokens, + outputTokens: acc.outputTokens + b.outputTokens, + costCents: acc.costCents + b.costCents, + }), + { count: 0, inputTokens: 0, outputTokens: 0, costCents: 0 }, + ); +} + +export async function getAdminUsageSummary(params?: { + userId?: string; + startAt?: number; + endAt?: number; +}): Promise { + const q = new URLSearchParams(); + if (params?.userId) q.set('userId', params.userId); + if (params?.startAt) q.set('startAt', String(params.startAt)); + if (params?.endAt) q.set('endAt', String(params.endAt)); + const suffix = q.toString() ? `?${q}` : ''; + try { + return await portalFetch(`/admin-api/usage/summary${suffix}`); + } catch (err) { + if (!(err instanceof ApiError) || err.status !== 404) throw err; + } + const allTime = sumUsageBuckets( + (await aggregateUsageStatsFromRecords({ + userId: params?.userId, + startAt: 0, + endAt: Math.floor(Date.now() / 1000), + })).buckets, + ); + if (!params?.startAt || !params?.endAt) { + return { range: null, allTime }; + } + const rangeRows = await getAdminUsageStats({ + userId: params.userId, + startAt: params.startAt, + endAt: params.endAt, + }); + return { + range: { + ...sumUsageBuckets(rangeRows.buckets), + startAt: params.startAt, + endAt: params.endAt, + }, + allTime, + }; +} + +export async function getAdminUsageStats(params: { + userId?: string; + startAt: number; + endAt: number; +}): Promise { + const q = new URLSearchParams(); + if (params.userId) q.set('userId', params.userId); + q.set('startAt', String(params.startAt)); + q.set('endAt', String(params.endAt)); + + const attempts = [ + `/admin-api/usage/stats?${q}`, + `/admin-api/usage?${q}&stats=1`, + ]; + + for (const path of attempts) { + try { + return await portalFetch(path); + } catch (err) { + if (err instanceof ApiError && err.status === 404) continue; + throw err; + } + } + + return aggregateUsageStatsFromRecords(params); +} + export async function listAdminLedger(params?: { userId?: string; page?: number; @@ -359,6 +508,10 @@ export async function getMemoryV2RuntimeStatus(): Promise { + return portalFetch('/admin-api/memory-v2/runtime'); +} + export async function listPersonalMemoryCandidates( status = 'candidate', ): Promise { diff --git a/src/components/UsageTrendChart.tsx b/src/components/UsageTrendChart.tsx new file mode 100644 index 0000000..c9b3114 --- /dev/null +++ b/src/components/UsageTrendChart.tsx @@ -0,0 +1,251 @@ +import { useMemo } from 'react'; +import { + Bar, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { UsageStatsBucket } from '../types'; +import { formatYuan } from '../admin/utils/format'; + +export type UsageChartMetric = 'cost' | 'tokens' | 'count'; +export type UsageChartType = 'bar' | 'line'; + +export const METRIC_OPTIONS: { key: UsageChartMetric; label: string }[] = [ + { key: 'tokens', label: 'Token 总量' }, + { key: 'cost', label: '扣费金额' }, + { key: 'count', label: '请求次数' }, +]; + +export const METRIC_CONFIG: Record< + UsageChartMetric, + { label: string; color: string; yAxisId: 'left' | 'right' } +> = { + tokens: { label: 'Token 总量', color: '#3d8bfd', yAxisId: 'left' }, + cost: { label: '扣费金额', color: '#22c55e', yAxisId: 'right' }, + count: { label: '请求次数', color: '#f59e0b', yAxisId: 'right' }, +}; + +export const DEFAULT_CHART_METRICS: UsageChartMetric[] = ['tokens']; + +function formatDayLabel(day: string) { + const [, month, date] = day.split('-'); + return `${Number(month)}/${Number(date)}`; +} + +function fillDailyBuckets(buckets: UsageStatsBucket[], startAt: number, endAt: number) { + const map = new Map(buckets.map((b) => [b.day, b])); + const start = new Date(startAt * 1000); + start.setHours(0, 0, 0, 0); + const end = new Date(endAt * 1000); + end.setHours(0, 0, 0, 0); + + const filled: UsageStatsBucket[] = []; + for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) { + const y = cursor.getFullYear(); + const m = String(cursor.getMonth() + 1).padStart(2, '0'); + const d = String(cursor.getDate()).padStart(2, '0'); + const day = `${y}-${m}-${d}`; + filled.push( + map.get(day) ?? { + day, + count: 0, + inputTokens: 0, + outputTokens: 0, + costCents: 0, + }, + ); + } + return filled; +} + +function formatMetricValue(value: number, metric: UsageChartMetric) { + if (metric === 'cost') return `¥${formatYuan(Math.round(value * 100))}`; + return value.toLocaleString(); +} + +type ChartPoint = { + day: string; + label: string; + tokens: number; + cost: number; + count: number; + inputTokens: number; + outputTokens: number; + costCents: number; +}; + +function UsageTooltip({ + active, + payload, + metrics, +}: { + active?: boolean; + payload?: Array<{ payload: ChartPoint; dataKey?: string; color?: string }>; + metrics: UsageChartMetric[]; +}) { + if (!active || !payload?.length) return null; + const row = payload[0].payload; + return ( +
+
{row.day}
+ {metrics.map((metric) => ( +
+ + {METRIC_CONFIG[metric].label}:{formatMetricValue(row[metric], metric)} +
+ ))} +
+ 入 {row.inputTokens.toLocaleString()} / 出 {row.outputTokens.toLocaleString()} Token +
+
+ ); +} + +export function UsageTrendChart({ + buckets, + startAt, + endAt, + metrics, + chartType, +}: { + buckets: UsageStatsBucket[]; + startAt: number; + endAt: number; + metrics: UsageChartMetric[]; + chartType: UsageChartType; +}) { + const activeMetrics = metrics.length > 0 ? metrics : DEFAULT_CHART_METRICS; + + const data = useMemo(() => { + const filled = fillDailyBuckets(buckets, startAt, endAt); + return filled.map((bucket) => ({ + day: bucket.day, + label: formatDayLabel(bucket.day), + tokens: bucket.inputTokens + bucket.outputTokens, + cost: bucket.costCents / 100, + count: bucket.count, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + costCents: bucket.costCents, + })); + }, [buckets, startAt, endAt]); + + const hasData = data.some((row) => activeMetrics.some((metric) => row[metric] > 0)); + + const showLeftAxis = activeMetrics.includes('tokens'); + const showRightAxis = activeMetrics.some((m) => m === 'cost' || m === 'count'); + + const resolveYAxisId = (metric: UsageChartMetric): 'left' | 'right' => { + if (showLeftAxis && showRightAxis) return METRIC_CONFIG[metric].yAxisId; + if (showLeftAxis) return 'left'; + return 'right'; + }; + + if (!hasData) { + return

所选时间范围内暂无用量数据

; + } + + return ( +
+ + + + 14 ? Math.floor(data.length / 7) : 0} + /> + {showLeftAxis && ( + (v >= 10000 ? `${(v / 10000).toFixed(0)}万` : String(v))} + width={52} + /> + )} + {showRightAxis && ( + (Number.isInteger(v) ? String(v) : v.toFixed(2))} + width={48} + /> + )} + } /> + {value}} + /> + {activeMetrics.map((metric) => { + const cfg = METRIC_CONFIG[metric]; + const axisId = resolveYAxisId(metric); + if (chartType === 'bar') { + return ( + 1 ? 24 : 40} + /> + ); + } + return ( + + ); + })} + + +
+ ); +} + +export function toggleChartMetric(current: UsageChartMetric[], metric: UsageChartMetric): UsageChartMetric[] { + if (current.includes(metric)) { + if (current.length === 1) return current; + return current.filter((m) => m !== metric); + } + return [...current, metric]; +} + +export function dateRangeToUnix(startDate: string, endDate: string) { + const [sy, sm, sd] = startDate.split('-').map(Number); + const [ey, em, ed] = endDate.split('-').map(Number); + const startAt = Math.floor(new Date(sy, sm - 1, sd, 0, 0, 0, 0).getTime() / 1000); + const endAt = Math.floor(new Date(ey, em - 1, ed, 23, 59, 59, 999).getTime() / 1000); + return { startAt, endAt }; +} + +export function formatDateInput(date: Date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +export function presetDateRange(days: number) { + const end = new Date(); + end.setHours(0, 0, 0, 0); + const start = new Date(end); + start.setDate(start.getDate() - (days - 1)); + return { startDate: formatDateInput(start), endDate: formatDateInput(end) }; +} diff --git a/src/index.css b/src/index.css index 8e05ef7..55fbe30 100644 --- a/src/index.css +++ b/src/index.css @@ -2097,6 +2097,274 @@ body, text-align: center; } +.usage-toolbar { + flex-wrap: wrap; +} + +.usage-tab-head { + flex-wrap: wrap; + gap: 12px; +} + +.usage-view-switch { + display: inline-flex; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + overflow: hidden; +} + +.usage-view-btn { + padding: 6px 14px; + font-size: 13px; + border: none; + background: var(--color-bg-base); + color: var(--color-text-muted); + cursor: pointer; +} + +.usage-view-btn.active { + background: var(--color-accent); + color: #fff; +} + +.usage-tab-body { + margin-top: 4px; +} + +.usage-query-toolbar { + margin-bottom: 16px; +} + +.usage-summary-panel { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +} + +.usage-summary-block { + padding: 14px 16px; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); +} + +.usage-summary-title { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; +} + +.usage-summary-grid { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.usage-summary-grid .admin-stat-card { + padding: 10px 12px; +} + +.usage-summary-grid .admin-stat-value { + font-size: 20px; +} + +.usage-summary-sub { + margin-top: 4px; + font-size: 11px; +} + +.usage-summary-loading { + margin-bottom: 12px; +} + +.usage-overview-filter, +.usage-overview-chart-bar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.usage-overview-chart-label { + font-size: 13px; +} + +.usage-chart-hint { + margin: 4px 0 0; + font-size: 12px; +} + +.usage-preset-btn.active { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.usage-date-range { + display: flex; + align-items: center; + gap: 6px; +} + +.usage-date-input { + width: 138px; +} + +.usage-date-sep { + font-size: 12px; +} + +.usage-preset-btns { + display: flex; + gap: 4px; +} + +.usage-preset-btn { + padding: 5px 10px; + font-size: 12px; + min-width: unset; +} + +.usage-chart-panel { + margin: 16px 0 20px; + padding: 16px; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); +} + +.usage-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.usage-chart-head h3 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.usage-chart-controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.usage-metric-checks { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.usage-metric-check { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + font-size: 12px; + cursor: pointer; + user-select: none; + background: var(--color-bg-base); + color: var(--color-text-muted); +} + +.usage-metric-check.checked { + border-color: var(--color-border-subtle); + color: var(--color-text-primary); +} + +.usage-metric-check input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.usage-metric-check-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.usage-chart-tooltip-metric { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; +} + +.usage-chart-tooltip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.usage-chart-tooltip-detail { + margin-top: 6px; + font-size: 11px; +} + +.usage-chart-select { + min-width: 120px; + padding: 5px 8px; + font-size: 12px; +} + +.usage-chart-type-toggle { + display: inline-flex; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + overflow: hidden; +} + +.usage-chart-type-btn { + padding: 5px 12px; + font-size: 12px; + border: none; + background: var(--color-bg-base); + color: var(--color-text-muted); + cursor: pointer; +} + +.usage-chart-type-btn.active { + background: var(--color-accent); + color: #fff; +} + +.usage-chart-wrap { + width: 100%; +} + +.usage-chart-empty { + padding: 40px 0; + text-align: center; +} + +.usage-chart-tooltip { + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--color-bg-base); + border: 1px solid var(--color-border-subtle); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + font-size: 12px; + line-height: 1.5; +} + +.usage-chart-tooltip-title { + font-weight: 600; + margin-bottom: 4px; +} + .text-income { color: #22c55e; } diff --git a/src/types.ts b/src/types.ts index 61def3c..7f0ece4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -247,6 +247,37 @@ export type UsageRecord = { createdAt: number; }; +export type UsageStatsBucket = { + day: string; + count: number; + inputTokens: number; + outputTokens: number; + costCents: number; +}; + +export type UsageTotals = { + count: number; + inputTokens: number; + outputTokens: number; + costCents: number; +}; + +export type UsageRangeTotals = UsageTotals & { + startAt: number; + endAt: number; +}; + +export type UsageSummaryResult = { + range: UsageRangeTotals | null; + allTime: UsageTotals; +}; + +export type UsageStatsResult = { + buckets: UsageStatsBucket[]; + startAt: number; + endAt: number; +}; + export type LedgerEntry = { id: number; userId: string; @@ -343,6 +374,14 @@ export type MemoryV2AdminConfigResponse = { updatedBy: string | null; }; +export type MemoryV2ConfigRuntimeState = { + source: string; + updatedAt: number | null; + updatedBy: string | null; + fingerprint?: string; + overrides: Record; +}; + export type MemoryV2RuntimeStatusResponse = { ok: boolean; checkedAt?: number;