Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aca1e58f53 | |||
| 2550b3b323 | |||
| 2a1c221bb3 | |||
| 65a6d7254b | |||
| 16b1a90edf | |||
| afc667a9ce | |||
| 10e24be243 | |||
| 2596c8f2c6 | |||
| dd3fed5b14 | |||
| b009f31aae |
@@ -42,5 +42,10 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn
|
||||
MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami
|
||||
UMAMI_SSO_USERNAME=admin
|
||||
|
||||
# Rybbit 仅允许从 MemindAdm 单点登录进入(必须与 105 Rybbit 一致)
|
||||
MEMIND_RYBBIT_SSO_SECRET=replace-with-the-same-random-secret-used-by-rybbit
|
||||
RYBBIT_SSO_EMAIL=admin@tkmind.cn
|
||||
RYBBIT_URL=https://rybbit.tkmind.cn
|
||||
|
||||
# Plaza 帖子预览链接
|
||||
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
|
||||
|
||||
Generated
+400
-2
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"local_restart": "bash scripts/local_restart.sh",
|
||||
"pro_restart": "bash scripts/pro_restart.sh",
|
||||
"build": "vite build",
|
||||
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
|
||||
"preview": "node scripts/preview.mjs",
|
||||
"dev:preview": "vite preview"
|
||||
},
|
||||
@@ -28,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"
|
||||
|
||||
@@ -196,6 +196,22 @@ grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || {
|
||||
echo "memind-lib 缺少 Excel Analyst runtime 扩展映射" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f "${MEMIND_LIB}/llm-providers.mjs" ]] || {
|
||||
echo "memind-lib 缺少 llm-providers.mjs" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "'deepseek-v4-pro'" "${MEMIND_LIB}/llm-providers.mjs" || {
|
||||
echo "memind-lib/llm-providers.mjs 缺少 deepseek-v4-pro 模型目录,拒绝发布旧 DeepSeek catalog" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "'deepseek-v4-flash'" "${MEMIND_LIB}/llm-providers.mjs" || {
|
||||
echo "memind-lib/llm-providers.mjs 缺少 deepseek-v4-flash 模型目录" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "'deepseek-chat'" "${MEMIND_LIB}/llm-providers.mjs" && ! grep -q "LEGACY_DEEPSEEK_CHAT" "${MEMIND_LIB}/llm-providers.mjs"; then
|
||||
echo "memind-lib/llm-providers.mjs 仍含 legacy deepseek-chat 且无 LEGACY_DEEPSEEK_CHAT 标记,目录可能未升级" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||||
say "发布确认"
|
||||
|
||||
+113
-1
@@ -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)), '..');
|
||||
|
||||
@@ -100,6 +100,8 @@ export function createAdminApp(services) {
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
orchestratorConfigService,
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
@@ -232,6 +234,21 @@ export function createAdminApp(services) {
|
||||
res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` });
|
||||
});
|
||||
|
||||
adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => {
|
||||
const sharedSecret = process.env.MEMIND_RYBBIT_SSO_SECRET?.trim();
|
||||
if (!sharedSecret) return res.status(503).json({ message: '未配置 Rybbit 单点登录密钥' });
|
||||
const email = process.env.RYBBIT_SSO_EMAIL?.trim().toLowerCase();
|
||||
if (!email) return res.status(503).json({ message: '未配置 Rybbit 单点登录账号' });
|
||||
const baseUrl = String(process.env.RYBBIT_URL || 'https://rybbit.tkmind.cn').replace(/\/$/, '');
|
||||
const encoded = Buffer.from(JSON.stringify({
|
||||
email,
|
||||
exp: Math.floor(Date.now() / 1000) + 60,
|
||||
nonce: crypto.randomUUID(),
|
||||
})).toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||
res.json({ url: `${baseUrl}/api/auth/memind?ticket=${encoded}.${signature}` });
|
||||
});
|
||||
|
||||
adminApi.get('/users', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.listUsers({
|
||||
page: Number(req.query.page) || 1,
|
||||
@@ -290,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);
|
||||
});
|
||||
@@ -382,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());
|
||||
@@ -411,6 +458,71 @@ export function createAdminApp(services) {
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/orchestrator/config', requireAdmin, async (_req, res) => {
|
||||
if (!orchestratorConfigService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||
}
|
||||
return res.json(await orchestratorConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
const updateOrchestratorConfig = async (req, res) => {
|
||||
if (!orchestratorConfigService?.updateAdminConfig) {
|
||||
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||
}
|
||||
return res.json(await orchestratorConfigService.updateAdminConfig(
|
||||
req.body?.config ?? req.body ?? {},
|
||||
{ updatedBy: req.currentUser.id },
|
||||
));
|
||||
};
|
||||
|
||||
adminApi.put('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
|
||||
adminApi.patch('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
|
||||
|
||||
adminApi.get('/orchestrator/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!orchestratorConfigService?.getRuntimeState) {
|
||||
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||
}
|
||||
return res.json(await orchestratorConfigService.getRuntimeState({ probe: true }));
|
||||
});
|
||||
|
||||
adminApi.get('/orchestrator/shadow-runs', requireAdmin, async (req, res) => {
|
||||
if (!orchestratorObservabilityService?.listShadowRuns) {
|
||||
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
|
||||
}
|
||||
return res.json(await orchestratorObservabilityService.listShadowRuns({
|
||||
hours: req.query.hours,
|
||||
limit: req.query.limit,
|
||||
status: req.query.status,
|
||||
}));
|
||||
});
|
||||
|
||||
adminApi.get('/orchestrator/execution-plans', requireAdmin, async (req, res) => {
|
||||
if (!orchestratorObservabilityService?.listExecutionPlans) {
|
||||
return res.status(503).json({ message: 'Orchestrator Dry-run 观测服务未启用' });
|
||||
}
|
||||
return res.json(await orchestratorObservabilityService.listExecutionPlans({
|
||||
hours: req.query.hours,
|
||||
limit: req.query.limit,
|
||||
selection: req.query.selection,
|
||||
}));
|
||||
});
|
||||
|
||||
adminApi.get('/orchestrator/canary-readiness', requireAdmin, async (_req, res) => {
|
||||
if (!orchestratorObservabilityService?.getCanaryReadiness) {
|
||||
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
|
||||
}
|
||||
return res.json(await orchestratorObservabilityService.getCanaryReadiness());
|
||||
});
|
||||
|
||||
adminApi.get('/orchestrator/shadow-runs/:runId', requireAdmin, async (req, res) => {
|
||||
if (!orchestratorObservabilityService?.getShadowRun) {
|
||||
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
|
||||
}
|
||||
const result = await orchestratorObservabilityService.getShadowRun(req.params.runId);
|
||||
if (!result) return res.status(404).json({ message: 'Shadow run 不存在' });
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
||||
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
||||
try {
|
||||
|
||||
@@ -31,6 +31,12 @@ export async function bootstrapAdminServices() {
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
|
||||
const { createOrchestratorAdminConfigService } = await importMemind(
|
||||
'services/orchestrator/admin-config.mjs',
|
||||
);
|
||||
const { createOrchestratorObservabilityService } = await importMemind(
|
||||
'services/orchestrator/observability.mjs',
|
||||
);
|
||||
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
|
||||
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||
@@ -99,6 +105,14 @@ export async function bootstrapAdminServices() {
|
||||
});
|
||||
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
|
||||
await mindSearchConfigService.ensureSchema();
|
||||
const orchestratorConfigService = createOrchestratorAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
await orchestratorConfigService.ensureSchema();
|
||||
const orchestratorObservabilityService = createOrchestratorObservabilityService({
|
||||
pool,
|
||||
configService: orchestratorConfigService,
|
||||
});
|
||||
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
|
||||
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
@@ -165,6 +179,8 @@ export async function bootstrapAdminServices() {
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
orchestratorConfigService,
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
|
||||
@@ -106,6 +106,8 @@ ready
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
orchestratorConfigService,
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
@@ -132,6 +134,8 @@ ready
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
orchestratorConfigService,
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { createAdminApp } from './app.mjs';
|
||||
|
||||
function createServices({ role = 'admin' } = {}) {
|
||||
const calls = {
|
||||
update: [],
|
||||
shadow: [],
|
||||
plans: [],
|
||||
detail: [],
|
||||
};
|
||||
const configState = {
|
||||
config: {
|
||||
mode: 'shadow',
|
||||
primaryEngine: 'langgraph',
|
||||
fallbackEngine: 'native',
|
||||
serviceUrl: 'http://127.0.0.1:8093',
|
||||
requestTimeoutMs: 5000,
|
||||
rolloutPercent: 0,
|
||||
userAllowlist: [],
|
||||
workflowAllowlist: ['code-run-v1'],
|
||||
fallbackToNative: true,
|
||||
requireHealthy: true,
|
||||
executionEnabled: false,
|
||||
},
|
||||
configVersion: 4,
|
||||
updatedBy: 'admin-user',
|
||||
updatedAt: 1,
|
||||
source: 'admin-db',
|
||||
runtime: {
|
||||
effective: true,
|
||||
shadowsLangGraph: true,
|
||||
executesLangGraph: false,
|
||||
executionHandoff: { enabled: false },
|
||||
},
|
||||
engines: [],
|
||||
executors: [],
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
services: {
|
||||
ready: Promise.resolve(),
|
||||
parseCookies: () => ({ test_session: 'token' }),
|
||||
USER_COOKIE: 'test_session',
|
||||
userLoginCookies: () => [],
|
||||
clearUserSessionCookie: () => {},
|
||||
resolveCookieDomainForRequest: () => undefined,
|
||||
userAuth: {
|
||||
getMe: async () => ({ id: 'john-id', username: 'john', role }),
|
||||
},
|
||||
orchestratorConfigService: {
|
||||
getAdminConfig: async () => configState,
|
||||
updateAdminConfig: async (config, context) => {
|
||||
calls.update.push({ config, context });
|
||||
return { ...configState, config, updatedBy: context.updatedBy };
|
||||
},
|
||||
getRuntimeState: async ({ probe }) => ({
|
||||
...configState,
|
||||
serviceHealth: { ok: true, status: 'healthy', probe },
|
||||
}),
|
||||
},
|
||||
orchestratorObservabilityService: {
|
||||
listShadowRuns: async (query) => {
|
||||
calls.shadow.push(query);
|
||||
return { metrics: { observations: 0 }, runs: [] };
|
||||
},
|
||||
listExecutionPlans: async (query) => {
|
||||
calls.plans.push(query);
|
||||
return { metrics: { decisions: 0 }, plans: [] };
|
||||
},
|
||||
getCanaryReadiness: async () => ({
|
||||
ready: false,
|
||||
recommendation: 'keep_shadow',
|
||||
}),
|
||||
getShadowRun: async (runId) => {
|
||||
calls.detail.push(runId);
|
||||
return runId === 'missing' ? null : { native: { runId }, remote: { available: true } };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startApp(options) {
|
||||
const harness = createServices(options);
|
||||
const server = createAdminApp(harness.services).listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
return {
|
||||
...harness,
|
||||
request: (path, init = {}) => fetch(`http://127.0.0.1:${address.port}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Cookie: 'test_session=token',
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
}),
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('admin can read orchestrator config, runtime and observability endpoints', async (t) => {
|
||||
const app = await startApp();
|
||||
t.after(app.close);
|
||||
|
||||
const config = await app.request('/admin-api/orchestrator/config');
|
||||
assert.equal(config.status, 200);
|
||||
assert.equal((await config.json()).config.mode, 'shadow');
|
||||
|
||||
const runtime = await app.request('/admin-api/orchestrator/runtime');
|
||||
assert.equal(runtime.status, 200);
|
||||
assert.deepEqual((await runtime.json()).serviceHealth, {
|
||||
ok: true,
|
||||
status: 'healthy',
|
||||
probe: true,
|
||||
});
|
||||
|
||||
const shadow = await app.request(
|
||||
'/admin-api/orchestrator/shadow-runs?hours=24&limit=50&status=failed',
|
||||
);
|
||||
assert.equal(shadow.status, 200);
|
||||
assert.deepEqual(app.calls.shadow, [{ hours: '24', limit: '50', status: 'failed' }]);
|
||||
|
||||
const plans = await app.request(
|
||||
'/admin-api/orchestrator/execution-plans?hours=168&limit=20&selection=native',
|
||||
);
|
||||
assert.equal(plans.status, 200);
|
||||
assert.deepEqual(app.calls.plans, [{ hours: '168', limit: '20', selection: 'native' }]);
|
||||
|
||||
const readiness = await app.request('/admin-api/orchestrator/canary-readiness');
|
||||
assert.equal(readiness.status, 200);
|
||||
assert.equal((await readiness.json()).recommendation, 'keep_shadow');
|
||||
|
||||
const detail = await app.request('/admin-api/orchestrator/shadow-runs/run-1');
|
||||
assert.equal(detail.status, 200);
|
||||
assert.deepEqual(app.calls.detail, ['run-1']);
|
||||
|
||||
const missing = await app.request('/admin-api/orchestrator/shadow-runs/missing');
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
|
||||
test('admin config update records the authenticated administrator', async (t) => {
|
||||
const app = await startApp();
|
||||
t.after(app.close);
|
||||
|
||||
const response = await app.request('/admin-api/orchestrator/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config: { mode: 'off', executionEnabled: false } }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(app.calls.update, [{
|
||||
config: { mode: 'off', executionEnabled: false },
|
||||
context: { updatedBy: 'john-id' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('ordinary users cannot access or mutate orchestrator controls', async (t) => {
|
||||
const app = await startApp({ role: 'user' });
|
||||
t.after(app.close);
|
||||
|
||||
const read = await app.request('/admin-api/orchestrator/config');
|
||||
assert.equal(read.status, 403);
|
||||
|
||||
const update = await app.request('/admin-api/orchestrator/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config: { mode: 'active', executionEnabled: true } }),
|
||||
});
|
||||
assert.equal(update.status, 403);
|
||||
assert.equal(app.calls.update.length, 0);
|
||||
});
|
||||
+128
-3
@@ -17,21 +17,145 @@ 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,
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
||||
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
|
||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
||||
FROM h5_usage_records r
|
||||
JOIN h5_users u ON u.id = r.user_id
|
||||
@@ -45,6 +169,7 @@ export async function listUsagePaged(pool, query) {
|
||||
id: Number(row.id),
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
agentSessionId: row.agent_session_id,
|
||||
requestId: row.request_id,
|
||||
inputTokens: Number(row.input_tokens),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
||||
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
||||
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
||||
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
|
||||
import { OrchestratorPage } from './admin/pages/OrchestratorPage';
|
||||
import { defaultHomePath } from './lib/routes';
|
||||
import { OpsLayout } from './ops/components/OpsLayout';
|
||||
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
|
||||
@@ -127,6 +128,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||
<Route path="mindsearch" element={<MindSearchPage />} />
|
||||
<Route path="skill-runtime" element={<SkillRuntimePage />} />
|
||||
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
||||
@@ -175,6 +177,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
||||
|| pathname.startsWith('/mindspace')
|
||||
|| pathname.startsWith('/memory-v2')
|
||||
|| pathname.startsWith('/skill-runtime')
|
||||
|| pathname.startsWith('/orchestrator')
|
||||
|| pathname.startsWith('/providers')
|
||||
|| pathname.startsWith('/wechat')
|
||||
|| pathname.startsWith('/asset-gateway')
|
||||
|
||||
@@ -32,6 +32,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/mindsearch', label: 'MindSearch' },
|
||||
{ to: '/skill-runtime', label: 'Skill Runtime' },
|
||||
{ to: '/orchestrator', label: '任务编排' },
|
||||
{ to: '/system-tests', label: '系统测试验证' },
|
||||
{ to: '/capabilities', label: '能力' },
|
||||
{ to: '/skills', label: '技能' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
|
||||
import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
|
||||
|
||||
const initial = {
|
||||
enabled: false,
|
||||
@@ -16,7 +16,8 @@ export function AnalyticsConfigPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [openingUmami, setOpeningUmami] = useState(false);
|
||||
const [openingRybbit, setOpeningRybbit] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -52,23 +53,43 @@ export function AnalyticsConfigPage() {
|
||||
};
|
||||
|
||||
const openUmami = async () => {
|
||||
setOpening(true);
|
||||
setOpeningUmami(true);
|
||||
setError(null);
|
||||
try {
|
||||
window.location.assign(await getUmamiSsoUrl());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '无法打开 Umami');
|
||||
setOpening(false);
|
||||
setOpeningUmami(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRybbit = async () => {
|
||||
setOpeningRybbit(true);
|
||||
setError(null);
|
||||
try {
|
||||
window.location.assign(await getRybbitSsoUrl());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '无法打开 Rybbit');
|
||||
setOpeningRybbit(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="admin-page">
|
||||
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">配置本地 Memind 生成页面的 Umami 统计。默认只连接本机 Umami,不涉及生产环境。</p></div>
|
||||
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">进入统计后台,或配置 Memind 生成页面的 Umami 统计。</p></div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
<section className="admin-card">
|
||||
<h2>本地 Umami</h2>
|
||||
<div className="admin-actions" style={{ marginBottom: 16 }}><button type="button" className="send-btn" onClick={() => void openUmami()} disabled={opening || loading}>{opening ? '正在进入 Umami...' : '打开 Umami 分析后台'}</button></div>
|
||||
<h2>Rybbit 行为分析</h2>
|
||||
<p className="muted">查看页面访问、用户会话、停留时间、点击和会话回放等行为明细。</p>
|
||||
<div className="admin-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void openRybbit()} disabled={openingRybbit}>
|
||||
{openingRybbit ? '正在进入 Rybbit...' : '打开 Rybbit 分析后台'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="admin-card">
|
||||
<h2>Umami</h2>
|
||||
<div className="admin-actions" style={{ marginBottom: 16 }}><button type="button" className="send-btn" onClick={() => void openUmami()} disabled={openingUmami || loading}>{openingUmami ? '正在进入 Umami...' : '打开 Umami 分析后台'}</button></div>
|
||||
<form className="admin-form" onSubmit={save}>
|
||||
<label className="admin-form-row"><span>启用页面统计</span><input type="checkbox" checked={config.enabled} disabled={loading || busy} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} /></label>
|
||||
<label className="admin-form-row"><span>Website ID</span><input value={config.websiteId} disabled={loading || busy} onChange={(e) => setConfig({ ...config, websiteId: e.target.value })} placeholder="Umami Website ID" required={config.enabled} /></label>
|
||||
|
||||
+582
-60
@@ -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<TabKey, string> = {
|
||||
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,414 @@ function RechargeTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function UsageTab() {
|
||||
const { users } = useAdminUsers();
|
||||
function UsageRecordsTable({
|
||||
result,
|
||||
onPage,
|
||||
}: {
|
||||
result: PagedResult<UsageRecord>;
|
||||
onPage: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th style={{ textAlign: 'right' }}>输入 Token</th>
|
||||
<th style={{ textAlign: 'right' }}>输出 Token</th>
|
||||
<th style={{ textAlign: 'right' }}>扣费</th>
|
||||
<th style={{ textAlign: 'right' }}>余额后</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="billing-time">{formatTime(row.createdAt)}</td>
|
||||
<td>
|
||||
<span>{row.displayName || row.username}</span>
|
||||
<span className="muted"> @{row.username}</span>
|
||||
</td>
|
||||
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={result.page}
|
||||
totalPages={result.totalPages}
|
||||
total={result.total}
|
||||
pageSize={result.pageSize}
|
||||
onChange={onPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageTotalsGrid({ totals }: { totals: UsageTotals }) {
|
||||
const totalTokens = totals.inputTokens + totals.outputTokens;
|
||||
return (
|
||||
<div className="admin-stat-grid usage-summary-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">请求次数</div>
|
||||
<div className="admin-stat-value">{totals.count.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">Token 总量</div>
|
||||
<div className="admin-stat-value">{totalTokens.toLocaleString()}</div>
|
||||
<div className="muted usage-summary-sub">
|
||||
入 {totals.inputTokens.toLocaleString()} / 出 {totals.outputTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">消费金额</div>
|
||||
<div className="admin-stat-value">¥{formatYuan(totals.costCents)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageSummaryPanel({
|
||||
summary,
|
||||
rangeLabel,
|
||||
allTimeLabel = '累计(不限区间)',
|
||||
loading,
|
||||
}: {
|
||||
summary: UsageSummaryResult | null;
|
||||
rangeLabel: string;
|
||||
allTimeLabel?: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading && !summary) {
|
||||
return <p className="muted usage-summary-loading">汇总加载中…</p>;
|
||||
}
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="usage-summary-panel">
|
||||
{summary.range && (
|
||||
<section className="usage-summary-block">
|
||||
<h4 className="usage-summary-title">{rangeLabel}</h4>
|
||||
<UsageTotalsGrid totals={summary.range} />
|
||||
</section>
|
||||
)}
|
||||
<section className="usage-summary-block">
|
||||
<h4 className="usage-summary-title">{allTimeLabel}</h4>
|
||||
<UsageTotalsGrid totals={summary.allTime} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="usage-chart-panel">
|
||||
<div className="usage-chart-head">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
{hint && <p className="muted usage-chart-hint">{hint}</p>}
|
||||
</div>
|
||||
<div className="usage-chart-controls">
|
||||
<div className="usage-metric-checks" role="group" aria-label="图表指标">
|
||||
{METRIC_OPTIONS.map(({ key, label }) => {
|
||||
const checked = chartMetrics.includes(key);
|
||||
const color = METRIC_CONFIG[key].color;
|
||||
return (
|
||||
<label key={key} className={`usage-metric-check${checked ? ' checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onMetricsChange(toggleChartMetric(chartMetrics, key))}
|
||||
/>
|
||||
<span className="usage-metric-check-dot" style={{ background: color }} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="usage-chart-type-toggle" role="group" aria-label="图表类型">
|
||||
<button
|
||||
type="button"
|
||||
className={`usage-chart-type-btn${chartType === 'bar' ? ' active' : ''}`}
|
||||
onClick={() => onChartTypeChange('bar')}
|
||||
>
|
||||
柱状图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`usage-chart-type-btn${chartType === 'line' ? ' active' : ''}`}
|
||||
onClick={() => onChartTypeChange('line')}
|
||||
>
|
||||
曲线图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UsageTrendChart
|
||||
buckets={stats.buckets}
|
||||
startAt={stats.startAt}
|
||||
endAt={stats.endAt}
|
||||
metrics={chartMetrics}
|
||||
chartType={chartType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UsageView = 'overview' | 'query';
|
||||
|
||||
function UsageOverviewPanel() {
|
||||
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
|
||||
const [stats, setStats] = useState<UsageStatsResult | null>(null);
|
||||
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
|
||||
const [loadingTable, setLoadingTable] = useState(false);
|
||||
const [loadingChart, setLoadingChart] = useState(false);
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
|
||||
const [chartType, setChartType] = useState<UsageChartType>('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 data = start && end
|
||||
? await getAdminUsageSummary(dateRangeToUnix(start, end))
|
||||
: await getAdminUsageSummary();
|
||||
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})`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
<div className="usage-overview-filter">
|
||||
<span className="muted usage-overview-chart-label">日期区间</span>
|
||||
<div className="usage-date-range">
|
||||
<input
|
||||
type="date"
|
||||
className="billing-filter-select usage-date-input"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span className="muted usage-date-sep">至</span>
|
||||
<input
|
||||
type="date"
|
||||
className="billing-filter-select usage-date-input"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-preset-btns">
|
||||
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(7)}>近7天</button>
|
||||
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(30)}>近30天</button>
|
||||
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(90)}>近90天</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn-secondary usage-preset-btn${!hasDateFilter ? ' active' : ''}`}
|
||||
onClick={clearDateFilter}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UsageSummaryPanel
|
||||
summary={summary}
|
||||
rangeLabel={rangeSummaryLabel}
|
||||
allTimeLabel="累计汇总(不限区间 · 全平台)"
|
||||
loading={loadingSummary}
|
||||
/>
|
||||
{loadingChart && !stats && <p className="muted">图表加载中…</p>}
|
||||
{!loadingChart && stats && (
|
||||
<UsageChartPanel
|
||||
stats={stats}
|
||||
chartMetrics={chartMetrics}
|
||||
chartType={chartType}
|
||||
onMetricsChange={setChartMetrics}
|
||||
onChartTypeChange={setChartType}
|
||||
title="用量统计"
|
||||
hint={chartHint}
|
||||
/>
|
||||
)}
|
||||
{loadingTable && !result && <p className="muted">加载中…</p>}
|
||||
{!loadingTable && result && (
|
||||
result.items.length === 0 ? (
|
||||
<p className="muted billing-empty">暂无记录</p>
|
||||
) : (
|
||||
<UsageRecordsTable result={result} onPage={handlePage} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageQueryPanel() {
|
||||
const { users } = useAdminUsers();
|
||||
const defaultRange = presetDateRange(30);
|
||||
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
|
||||
const [stats, setStats] = useState<UsageStatsResult | null>(null);
|
||||
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
|
||||
const [chartType, setChartType] = useState<UsageChartType>('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<UsageStatsResult | null> = Promise.resolve(null);
|
||||
let summaryPromise: Promise<UsageSummaryResult | null> = 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,68 +606,165 @@ 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 (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>用量记录</h2>
|
||||
<div className="billing-toolbar">
|
||||
<UserCombobox
|
||||
users={users}
|
||||
value={pendingUserId}
|
||||
onChange={setPendingUserId}
|
||||
placeholder="全部用户"
|
||||
<>
|
||||
<div className="billing-toolbar usage-toolbar usage-query-toolbar">
|
||||
<UserCombobox
|
||||
users={users}
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
placeholder="选择用户"
|
||||
/>
|
||||
<div className="usage-date-range">
|
||||
<input
|
||||
type="date"
|
||||
className="billing-filter-select usage-date-input"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
disabled={!userId}
|
||||
/>
|
||||
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
|
||||
查询
|
||||
<span className="muted usage-date-sep">至</span>
|
||||
<input
|
||||
type="date"
|
||||
className="billing-filter-select usage-date-input"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
disabled={!userId}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-preset-btns">
|
||||
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(7)}>近7天</button>
|
||||
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(30)}>近30天</button>
|
||||
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(90)}>近90天</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn-secondary usage-preset-btn${userId && !hasDateFilter ? ' active' : ''}`}
|
||||
disabled={!userId}
|
||||
onClick={clearDateFilter}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{!result && !loading && (
|
||||
<p className="muted billing-empty">选择筛选条件后点击查询</p>
|
||||
{!userId && !loading && (
|
||||
<p className="muted billing-empty">请选择用户,将自动加载用量与趋势图</p>
|
||||
)}
|
||||
{loading && <p className="muted">加载中…</p>}
|
||||
{!loading && result && (
|
||||
{userId && loading && !result && !stats && <p className="muted">加载中…</p>}
|
||||
{userId && selectedUser && (
|
||||
<div className="usage-user-balance-bar">
|
||||
<span className="usage-user-balance-name">
|
||||
{selectedUser.displayName}
|
||||
<span className="muted"> @{selectedUser.username}</span>
|
||||
</span>
|
||||
<span className="usage-user-balance-label muted">当前余额</span>
|
||||
<span className="usage-user-balance-value">¥{formatYuan(selectedUser.balanceCents)}</span>
|
||||
</div>
|
||||
)}
|
||||
{userId && (
|
||||
<UsageSummaryPanel
|
||||
summary={summary}
|
||||
rangeLabel={rangeSummaryLabel}
|
||||
allTimeLabel={allTimeSummaryLabel}
|
||||
loading={loading && !summary}
|
||||
/>
|
||||
)}
|
||||
{userId && !loading && stats && (
|
||||
<UsageChartPanel
|
||||
stats={stats}
|
||||
chartMetrics={chartMetrics}
|
||||
chartType={chartType}
|
||||
onMetricsChange={setChartMetrics}
|
||||
onChartTypeChange={setChartType}
|
||||
title="用户用量趋势"
|
||||
hint={chartHint}
|
||||
/>
|
||||
)}
|
||||
{userId && !loading && result && (
|
||||
result.items.length === 0 ? (
|
||||
<p className="muted billing-empty">暂无记录</p>
|
||||
<p className="muted billing-empty">{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th style={{ textAlign: 'right' }}>输入 Token</th>
|
||||
<th style={{ textAlign: 'right' }}>输出 Token</th>
|
||||
<th style={{ textAlign: 'right' }}>扣费</th>
|
||||
<th style={{ textAlign: 'right' }}>余额后</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="billing-time">{formatTime(row.createdAt)}</td>
|
||||
<td>@{row.username}</td>
|
||||
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
|
||||
pageSize={result.pageSize} onChange={handlePage} />
|
||||
</>
|
||||
<UsageRecordsTable result={result} onPage={handlePage} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head usage-tab-head">
|
||||
<h2>用量记录</h2>
|
||||
<div className="usage-view-switch" role="tablist" aria-label="用量记录视图">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'overview'}
|
||||
className={`usage-view-btn${view === 'overview' ? ' active' : ''}`}
|
||||
onClick={() => setView('overview')}
|
||||
>
|
||||
全部记录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'query'}
|
||||
className={`usage-view-btn${view === 'query' ? ' active' : ''}`}
|
||||
onClick={() => setView('query')}
|
||||
>
|
||||
用户分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="usage-tab-body">
|
||||
{view === 'overview' ? <UsageOverviewPanel /> : <UsageQueryPanel />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -296,7 +816,7 @@ function LedgerTab() {
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{!result && !loading && (
|
||||
<p className="muted billing-empty">选择筛选条件后点击查询</p>
|
||||
<p className="muted billing-empty">选择用户后点击查询</p>
|
||||
)}
|
||||
{loading && <p className="muted">加载中…</p>}
|
||||
{!loading && result && (
|
||||
@@ -922,7 +1442,9 @@ function SubscriptionsTab() {
|
||||
}
|
||||
|
||||
export function BillingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('subscriptions');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = tabFromPath(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
@@ -934,7 +1456,7 @@ export function BillingPage() {
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.key} type="button" role="tab" aria-selected={activeTab === tab.key}
|
||||
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}>
|
||||
onClick={() => navigate(TAB_PATHS[tab.key])}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
getOrchestratorCanaryReadiness,
|
||||
getOrchestratorConfig,
|
||||
getOrchestratorExecutionPlans,
|
||||
getOrchestratorRuntime,
|
||||
getOrchestratorShadowRun,
|
||||
getOrchestratorShadowRuns,
|
||||
updateOrchestratorConfig,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
OrchestratorCanaryReadiness,
|
||||
OrchestratorConfig,
|
||||
OrchestratorConfigState,
|
||||
OrchestratorExecutionPlanList,
|
||||
OrchestratorServiceHealth,
|
||||
OrchestratorShadowRunDetail,
|
||||
OrchestratorShadowRunList,
|
||||
} from '../../types/orchestrator';
|
||||
|
||||
const runtimeReasons: Record<string, string> = {
|
||||
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
|
||||
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
|
||||
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
|
||||
execution_gate_disabled: '执行交接闸门被硬锁定,当前仅生成 Dry-run 决策。',
|
||||
environment_execution_gate_disabled: 'memindadm 已请求交接,但环境闸门仍关闭。',
|
||||
};
|
||||
|
||||
const readinessLabels: Record<string, string> = {
|
||||
shadow_mode: '保持 Shadow',
|
||||
service_healthy: '服务健康',
|
||||
durable_checkpoint: '持久化 checkpoint',
|
||||
durable_executor_job_store: '持久化 Executor Job Store',
|
||||
observe_only: '仅观察不执行',
|
||||
sample_volume: '有效样本量',
|
||||
shadow_success_rate: 'Shadow 成功率',
|
||||
shadow_skip_rate: 'Shadow 跳过率',
|
||||
latency_coverage: '延迟数据覆盖率',
|
||||
latency_p95: '延迟 P95',
|
||||
native_settled_rate: 'Native 终态覆盖率',
|
||||
session_coverage: '真实会话覆盖',
|
||||
sample_freshness: '最近样本新鲜度',
|
||||
complete_window: '统计窗口完整',
|
||||
};
|
||||
|
||||
function listToText(values: string[]) {
|
||||
return values.join('\n');
|
||||
}
|
||||
|
||||
function textToList(value: string) {
|
||||
return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
|
||||
}
|
||||
|
||||
function formatPercent(value?: number | null) {
|
||||
return value == null ? '—' : `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatLatency(value?: number | null) {
|
||||
return value == null ? '—' : `${value} ms`;
|
||||
}
|
||||
|
||||
function shortId(value: string) {
|
||||
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-6)}` : value;
|
||||
}
|
||||
|
||||
function readinessValue(check: OrchestratorCanaryReadiness['checks'][number]) {
|
||||
if (check.actual == null) return '无数据';
|
||||
if (['shadow_success_rate', 'latency_coverage', 'native_settled_rate'].includes(check.id)) {
|
||||
return formatPercent(Number(check.actual));
|
||||
}
|
||||
if (check.id === 'latency_p95') return formatLatency(Number(check.actual));
|
||||
if (check.id === 'sample_freshness') return `${Number(check.actual).toFixed(1)} 小时`;
|
||||
if (typeof check.actual === 'boolean') return check.actual ? '是' : '否';
|
||||
return String(check.actual);
|
||||
}
|
||||
|
||||
export function OrchestratorPage() {
|
||||
const [state, setState] = useState<OrchestratorConfigState | null>(null);
|
||||
const [draft, setDraft] = useState<OrchestratorConfig | null>(null);
|
||||
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
|
||||
const [userAllowlist, setUserAllowlist] = useState('');
|
||||
const [shadow, setShadow] = useState<OrchestratorShadowRunList | null>(null);
|
||||
const [plans, setPlans] = useState<OrchestratorExecutionPlanList | null>(null);
|
||||
const [readiness, setReadiness] = useState<OrchestratorCanaryReadiness | null>(null);
|
||||
const [health, setHealth] = useState<OrchestratorServiceHealth | null>(null);
|
||||
const [detail, setDetail] = useState<OrchestratorShadowRunDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [configState, shadowState, planState, readinessState] = await Promise.all([
|
||||
getOrchestratorConfig(),
|
||||
getOrchestratorShadowRuns({ hours: 24, limit: 100 }),
|
||||
getOrchestratorExecutionPlans({ hours: 24, limit: 100 }),
|
||||
getOrchestratorCanaryReadiness(),
|
||||
]);
|
||||
setState(configState);
|
||||
setDraft(configState.config);
|
||||
setWorkflowAllowlist(listToText(configState.config.workflowAllowlist));
|
||||
setUserAllowlist(listToText(configState.config.userAllowlist));
|
||||
setShadow(shadowState);
|
||||
setPlans(planState);
|
||||
setReadiness(readinessState);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Orchestrator 数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const normalizedDraft = useMemo(() => draft ? {
|
||||
...draft,
|
||||
workflowAllowlist: textToList(workflowAllowlist),
|
||||
userAllowlist: textToList(userAllowlist),
|
||||
} : null, [draft, workflowAllowlist, userAllowlist]);
|
||||
|
||||
const dirty = Boolean(
|
||||
state && normalizedDraft && JSON.stringify(state.config) !== JSON.stringify(normalizedDraft),
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
if (!normalizedDraft) return;
|
||||
if (['canary', 'active'].includes(normalizedDraft.mode) && !normalizedDraft.serviceUrl) {
|
||||
setError('Canary / Active 模式必须先配置 Orchestrator URL。');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
normalizedDraft.executionEnabled
|
||||
&& !window.confirm('确认请求执行交接?环境闸门关闭时仍只会 Dry-run。')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await updateOrchestratorConfig(normalizedDraft);
|
||||
setState(result);
|
||||
setDraft(result.config);
|
||||
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||
setMessage(`Orchestrator 配置 v${result.configVersion} 已保存。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async () => {
|
||||
setChecking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getOrchestratorRuntime();
|
||||
setHealth(result.serviceHealth);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '服务检查失败');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (runId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
setDetail(await getOrchestratorShadowRun(runId));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Run 详情加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="admin-page"><p>正在加载 Workflow Orchestrator…</p></div>;
|
||||
if (!state || !draft) {
|
||||
return <div className="admin-page"><p className="banner banner-error">{error ?? '配置不可用'}</p></div>;
|
||||
}
|
||||
|
||||
const runtimeMessage = state.runtime.reason
|
||||
? (runtimeReasons[state.runtime.reason] ?? state.runtime.reason)
|
||||
: state.runtime.shadowsLangGraph
|
||||
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察。'
|
||||
: state.runtime.executesLangGraph
|
||||
? '执行交接已开启。'
|
||||
: '配置有效。';
|
||||
|
||||
return (
|
||||
<div className="admin-page orchestrator-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>Workflow Orchestrator</h2>
|
||||
<p className="muted">
|
||||
LangGraph 以可插拔服务接入;Off 与 Shadow 不改变现有 Native Agent Run 执行路径。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="orchestrator-summary">
|
||||
<div><span>配置版本</span><strong>v{state.configVersion}</strong></div>
|
||||
<div><span>运行状态</span><strong>{state.runtime.effective ? '有效' : '安全回退'}</strong></div>
|
||||
<div><span>配置来源</span><strong>{state.source}</strong></div>
|
||||
<div><span>执行交接</span><strong>{state.runtime.executionHandoff.enabled ? '已开启' : 'Dry-run 锁定'}</strong></div>
|
||||
<div><span>更新时间</span><strong>{formatTime(state.updatedAt)}</strong></div>
|
||||
</div>
|
||||
<p className={`orchestrator-state ${state.runtime.reason === 'kill_switch' ? 'danger' : ''}`}>
|
||||
{runtimeMessage}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="orchestrator-section-head">
|
||||
<div>
|
||||
<h2>Executor Gateway</h2>
|
||||
<p className="muted">执行器默认关闭,最终状态以 Orchestrator 健康检查为准。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="orchestrator-executors">
|
||||
{state.executors.map((executor) => (
|
||||
<article key={executor.id}>
|
||||
<div><strong>{executor.label}</strong><span>{executor.status}</span></div>
|
||||
<p>{executor.capabilities.join(' · ') || '无能力声明'}</p>
|
||||
<small>dispatch {executor.dispatchImplemented ? 'implemented' : 'disabled'}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="orchestrator-section-head">
|
||||
<div>
|
||||
<h2>Shadow 运行观测</h2>
|
||||
<p className="muted">最近 24 小时 Native 与 LangGraph 观察结果,不改变执行权。</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>刷新</button>
|
||||
</div>
|
||||
|
||||
{readiness && (
|
||||
<div className={`orchestrator-readiness ${readiness.ready ? 'ready' : ''}`}>
|
||||
<div>
|
||||
<strong>Canary 准入评估</strong>
|
||||
<b>{readiness.ready ? '达到门槛,等待人工审批' : '继续保持 Shadow'}</b>
|
||||
</div>
|
||||
<p>
|
||||
有效样本 {readiness.samples.eligibleObservations} / {readiness.thresholds.minObservations}
|
||||
{' · '}排除 smoke {readiness.samples.excludedSynthetic}
|
||||
{' · '}真实会话 {readiness.samples.distinctSessions} / {readiness.thresholds.minDistinctSessions}
|
||||
</p>
|
||||
<div className="orchestrator-checks">
|
||||
{readiness.checks.map((check) => (
|
||||
<span key={check.id} className={check.passed ? 'passed' : 'blocked'}>
|
||||
{check.passed ? '通过' : '阻塞'} · {readinessLabels[check.id] ?? check.id}:{readinessValue(check)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orchestrator-metrics">
|
||||
<div><span>观察请求</span><strong>{shadow?.metrics.observations ?? '—'}</strong></div>
|
||||
<div><span>成功率</span><strong>{formatPercent(shadow?.metrics.successRate)}</strong></div>
|
||||
<div><span>失败数</span><strong>{shadow?.metrics.failures ?? '—'}</strong></div>
|
||||
<div><span>延迟 P95</span><strong>{formatLatency(shadow?.metrics.latencyP95Ms)}</strong></div>
|
||||
<div><span>Native 成功 / 失败</span><strong>{shadow ? `${shadow.metrics.nativeSucceeded} / ${shadow.metrics.nativeFailed}` : '—'}</strong></div>
|
||||
</div>
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>时间</th><th>Run</th><th>Shadow</th><th>Native</th><th>延迟</th><th>任务 / Adapter</th></tr></thead>
|
||||
<tbody>
|
||||
{(shadow?.runs ?? []).map((run) => (
|
||||
<tr key={run.eventId}>
|
||||
<td>{formatTime(run.observedAt)}</td>
|
||||
<td><button type="button" className="ghost-btn" onClick={() => void openDetail(run.runId)}>{shortId(run.runId)}</button></td>
|
||||
<td>{run.shadowStatus}</td>
|
||||
<td>{run.nativeStatus}</td>
|
||||
<td>{formatLatency(run.latencyMs)}</td>
|
||||
<td>{run.taskType ?? '—'}{run.synthetic ? '(smoke)' : ''}<small>{run.executorAdapter ?? '—'}</small></td>
|
||||
</tr>
|
||||
))}
|
||||
{!shadow?.runs.length && <tr><td colSpan={6}>当前没有 Shadow 结果。</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<div className="orchestrator-detail">
|
||||
<div className="orchestrator-section-head">
|
||||
<div><strong>Run 详情:{detail.native.runId}</strong><p className="muted">Native {detail.native.status} · LangGraph {detail.remote.available ? 'available' : 'unavailable'}</p></div>
|
||||
<button type="button" className="ghost-btn" onClick={() => setDetail(null)}>关闭</button>
|
||||
</div>
|
||||
<div>
|
||||
<pre>{JSON.stringify(detail.remote.state, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(detail.remote.events, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="orchestrator-section-head">
|
||||
<div><h2>执行路由 Dry-run</h2><p className="muted">候选路由仅观测,实际执行引擎必须保持 Native。</p></div>
|
||||
</div>
|
||||
{(plans?.metrics.handoffAllowed ?? 0) > 0 && (
|
||||
<p className="banner banner-error">检测到 handoffAllowed=true,请立即检查执行闸门。</p>
|
||||
)}
|
||||
<div className="orchestrator-metrics">
|
||||
<div><span>路由决策</span><strong>{plans?.metrics.decisions ?? '—'}</strong></div>
|
||||
<div><span>候选命中率</span><strong>{formatPercent(plans?.metrics.candidateSelectionRate)}</strong></div>
|
||||
<div><span>LangGraph / Native</span><strong>{plans ? `${plans.metrics.candidateSelections} / ${plans.metrics.nativeSelections}` : '—'}</strong></div>
|
||||
<div><span>Native 终态覆盖</span><strong>{formatPercent(plans?.metrics.nativeSettledRate)}</strong></div>
|
||||
<div><span>真实会话</span><strong>{plans?.metrics.distinctSessions ?? '—'}</strong></div>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>时间</th><th>Run</th><th>模式</th><th>候选 → 实际</th><th>原因</th><th>Native</th></tr></thead>
|
||||
<tbody>
|
||||
{(plans?.plans ?? []).map((plan) => (
|
||||
<tr key={plan.eventId}>
|
||||
<td>{formatTime(plan.plannedAt)}</td>
|
||||
<td className="mono">{shortId(plan.runId)}</td>
|
||||
<td>{plan.mode ?? '—'}</td>
|
||||
<td>{plan.candidateEngine} → {plan.effectiveEngine}</td>
|
||||
<td>{plan.candidateReason ?? plan.reason ?? '—'}</td>
|
||||
<td>{plan.nativeStatus}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!plans?.plans.length && <tr><td colSpan={6}>当前没有 Dry-run 路由决策。</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>路由与服务配置</h2>
|
||||
<div className="orchestrator-form-grid">
|
||||
<label><span>运行模式</span><select value={draft.mode} onChange={(event) => setDraft({ ...draft, mode: event.target.value as OrchestratorConfig['mode'] })}>
|
||||
<option value="off">Off — Native only</option>
|
||||
<option value="shadow">Shadow — Native 执行,LangGraph 观察</option>
|
||||
<option value="canary">Canary — 白名单/百分比灰度</option>
|
||||
<option value="active">Active — 工作流白名单接管</option>
|
||||
</select></label>
|
||||
<label><span>主执行引擎</span><select value={draft.primaryEngine} onChange={(event) => setDraft({ ...draft, primaryEngine: event.target.value })}>
|
||||
{state.engines.map((engine) => <option key={engine.id} value={engine.id}>{engine.label}{engine.configured ? '' : '(未配置)'}</option>)}
|
||||
</select></label>
|
||||
<label><span>灰度百分比</span><input type="number" min={0} max={100} value={draft.rolloutPercent} onChange={(event) => setDraft({ ...draft, rolloutPercent: Number(event.target.value) })} /></label>
|
||||
<label><span>请求超时(毫秒)</span><input type="number" min={500} max={60000} value={draft.requestTimeoutMs} onChange={(event) => setDraft({ ...draft, requestTimeoutMs: Number(event.target.value) })} /></label>
|
||||
<label className="wide"><span>Orchestrator URL</span><input value={draft.serviceUrl} onChange={(event) => setDraft({ ...draft, serviceUrl: event.target.value })} placeholder="http://127.0.0.1:8093" /></label>
|
||||
</div>
|
||||
<div className="orchestrator-health">
|
||||
<span>{health ? `${health.ok ? '健康' : '不可用'}(${health.status},${health.latencyMs} ms)` : '尚未检查服务连接'}</span>
|
||||
<button type="button" className="ghost-btn" onClick={() => void checkHealth()} disabled={checking}>{checking ? '检查中…' : '测试连接'}</button>
|
||||
</div>
|
||||
<div className="orchestrator-toggles">
|
||||
<label><input type="checkbox" checked={draft.requireHealthy} onChange={(event) => setDraft({ ...draft, requireHealthy: event.target.checked })} />接管前要求服务健康</label>
|
||||
<label><input type="checkbox" checked={draft.fallbackToNative} onChange={(event) => setDraft({ ...draft, fallbackToNative: event.target.checked })} />不可用时回退 Native</label>
|
||||
<label className="handoff"><input type="checkbox" checked={draft.executionEnabled} onChange={(event) => setDraft({ ...draft, executionEnabled: event.target.checked })} />请求启用执行交接(仍受环境闸门与 Kill Switch 约束)</label>
|
||||
</div>
|
||||
<div className="orchestrator-allowlists">
|
||||
<label><span>工作流白名单</span><textarea rows={6} value={workflowAllowlist} onChange={(event) => setWorkflowAllowlist(event.target.value)} /></label>
|
||||
<label><span>用户白名单</span><textarea rows={6} value={userAllowlist} onChange={(event) => setUserAllowlist(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="admin-actions orchestrator-actions">
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={saving}>放弃修改</button>
|
||||
<button type="button" className="send-btn" onClick={() => void save()} disabled={saving || !dirty}>{saving ? '保存中…' : '保存配置'}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,12 +35,17 @@ import type {
|
||||
MemoryV2AdminConfigResponse,
|
||||
MemoryV2ModelApiType,
|
||||
MemoryV2RuntimeStatusResponse,
|
||||
MemoryV2ConfigRuntimeState,
|
||||
SkillRuntimeAdminConfig,
|
||||
SkillRuntimeAdminConfigResponse,
|
||||
SkillRuntimeCatalogItem,
|
||||
SkillDefinition,
|
||||
SkillMap,
|
||||
UsageRecord,
|
||||
UsageStatsBucket,
|
||||
UsageStatsResult,
|
||||
UsageSummaryResult,
|
||||
UsageTotals,
|
||||
WechatAdminSummary,
|
||||
WechatBinding,
|
||||
WechatDeliveryLog,
|
||||
@@ -51,6 +56,15 @@ import type {
|
||||
MindSearchConfig,
|
||||
MindSearchServiceTestResult,
|
||||
} from '../types';
|
||||
import type {
|
||||
OrchestratorCanaryReadiness,
|
||||
OrchestratorConfig,
|
||||
OrchestratorConfigState,
|
||||
OrchestratorExecutionPlanList,
|
||||
OrchestratorRuntimeState,
|
||||
OrchestratorShadowRunDetail,
|
||||
OrchestratorShadowRunList,
|
||||
} from '../types/orchestrator';
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -220,11 +234,15 @@ export async function listAdminUsage(params?: {
|
||||
userId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
startAt?: number;
|
||||
endAt?: number;
|
||||
}): Promise<PagedResult<UsageRecord>> {
|
||||
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}` : ''}`,
|
||||
);
|
||||
@@ -233,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<UsageStatsResult> {
|
||||
const bucketMap = new Map<string, UsageStatsBucket>();
|
||||
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<UsageSummaryResult> {
|
||||
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<UsageSummaryResult>(`/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<UsageStatsResult> {
|
||||
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<UsageStatsResult>(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;
|
||||
@@ -327,6 +485,11 @@ export async function getUmamiSsoUrl(): Promise<string> {
|
||||
return result.url;
|
||||
}
|
||||
|
||||
export async function getRybbitSsoUrl(): Promise<string> {
|
||||
const result = await portalFetch<{ url: string }>('/admin-api/analytics/rybbit-sso');
|
||||
return result.url;
|
||||
}
|
||||
|
||||
export async function updateMindSpaceAdminConfig(
|
||||
payload: Partial<MindSpaceAdminConfig> & { analytics?: MindSpaceAnalyticsUpdate },
|
||||
): Promise<MindSpaceAdminConfig> {
|
||||
@@ -345,6 +508,10 @@ export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusR
|
||||
return portalFetch('/admin-api/memory-v2/status');
|
||||
}
|
||||
|
||||
export async function getMemoryV2ConfigRuntime(): Promise<MemoryV2ConfigRuntimeState> {
|
||||
return portalFetch('/admin-api/memory-v2/runtime');
|
||||
}
|
||||
|
||||
export async function listPersonalMemoryCandidates(
|
||||
status = 'candidate',
|
||||
): Promise<PersonalMemoryCandidateListResponse> {
|
||||
@@ -674,6 +841,63 @@ export async function testMindSearchService(serviceId: string): Promise<MindSear
|
||||
return portalFetch(`/admin-api/mindsearch/services/${encodeURIComponent(serviceId)}/test`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── Workflow Orchestrator ─────────────────────────────
|
||||
|
||||
export async function getOrchestratorConfig() {
|
||||
return portalFetch<OrchestratorConfigState>('/admin-api/orchestrator/config');
|
||||
}
|
||||
|
||||
export async function updateOrchestratorConfig(config: OrchestratorConfig) {
|
||||
return portalFetch<OrchestratorConfigState>('/admin-api/orchestrator/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrchestratorRuntime() {
|
||||
return portalFetch<OrchestratorRuntimeState>('/admin-api/orchestrator/runtime');
|
||||
}
|
||||
|
||||
export async function getOrchestratorShadowRuns(params: {
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
status?: 'all' | 'succeeded' | 'failed';
|
||||
} = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.hours) query.set('hours', String(params.hours));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.status) query.set('status', params.status);
|
||||
return portalFetch<OrchestratorShadowRunList>(
|
||||
`/admin-api/orchestrator/shadow-runs?${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrchestratorExecutionPlans(params: {
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
selection?: 'all' | 'candidate' | 'native';
|
||||
} = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.hours) query.set('hours', String(params.hours));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.selection) query.set('selection', params.selection);
|
||||
return portalFetch<OrchestratorExecutionPlanList>(
|
||||
`/admin-api/orchestrator/execution-plans?${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrchestratorCanaryReadiness() {
|
||||
return portalFetch<OrchestratorCanaryReadiness>(
|
||||
'/admin-api/orchestrator/canary-readiness',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrchestratorShadowRun(runId: string) {
|
||||
return portalFetch<OrchestratorShadowRunDetail>(
|
||||
`/admin-api/orchestrator/shadow-runs/${encodeURIComponent(runId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Policies ──────────────────────────────────────────
|
||||
|
||||
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
||||
|
||||
@@ -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 (
|
||||
<div className="usage-chart-tooltip">
|
||||
<div className="usage-chart-tooltip-title">{row.day}</div>
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric} className="usage-chart-tooltip-metric">
|
||||
<span className="usage-chart-tooltip-dot" style={{ background: METRIC_CONFIG[metric].color }} />
|
||||
{METRIC_CONFIG[metric].label}:{formatMetricValue(row[metric], metric)}
|
||||
</div>
|
||||
))}
|
||||
<div className="muted usage-chart-tooltip-detail">
|
||||
入 {row.inputTokens.toLocaleString()} / 出 {row.outputTokens.toLocaleString()} Token
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <p className="muted usage-chart-empty">所选时间范围内暂无用量数据</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="usage-chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={data} margin={{ top: 8, right: showRightAxis ? 16 : 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-subtle)" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
|
||||
interval={data.length > 14 ? Math.floor(data.length / 7) : 0}
|
||||
/>
|
||||
{showLeftAxis && (
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 11, fill: METRIC_CONFIG.tokens.color }}
|
||||
tickFormatter={(v) => (v >= 10000 ? `${(v / 10000).toFixed(0)}万` : String(v))}
|
||||
width={52}
|
||||
/>
|
||||
)}
|
||||
{showRightAxis && (
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
|
||||
tickFormatter={(v) => (Number.isInteger(v) ? String(v) : v.toFixed(2))}
|
||||
width={48}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={<UsageTooltip metrics={activeMetrics} />} />
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
|
||||
formatter={(value) => <span style={{ color: 'var(--color-text-muted)' }}>{value}</span>}
|
||||
/>
|
||||
{activeMetrics.map((metric) => {
|
||||
const cfg = METRIC_CONFIG[metric];
|
||||
const axisId = resolveYAxisId(metric);
|
||||
if (chartType === 'bar') {
|
||||
return (
|
||||
<Bar
|
||||
key={metric}
|
||||
yAxisId={axisId}
|
||||
dataKey={metric}
|
||||
name={cfg.label}
|
||||
fill={cfg.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={activeMetrics.length > 1 ? 24 : 40}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={metric}
|
||||
yAxisId={axisId}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
name={cfg.label}
|
||||
stroke={cfg.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: cfg.color }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
+497
@@ -446,6 +446,212 @@ body,
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Workflow Orchestrator ─────────────────────────── */
|
||||
|
||||
.orchestrator-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.orchestrator-page .admin-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.orchestrator-page h2 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.orchestrator-summary,
|
||||
.orchestrator-executors,
|
||||
.orchestrator-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.orchestrator-summary > div,
|
||||
.orchestrator-metrics > div,
|
||||
.orchestrator-executors article {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.orchestrator-summary span,
|
||||
.orchestrator-metrics span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-state,
|
||||
.orchestrator-readiness {
|
||||
margin: 14px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(224, 175, 91, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(224, 175, 91, 0.08);
|
||||
}
|
||||
|
||||
.orchestrator-state.danger {
|
||||
border-color: rgba(224, 86, 86, 0.4);
|
||||
background: rgba(224, 86, 86, 0.08);
|
||||
}
|
||||
|
||||
.orchestrator-section-head,
|
||||
.orchestrator-executors article > div,
|
||||
.orchestrator-readiness > div,
|
||||
.orchestrator-health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-executors article p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-executors article span {
|
||||
color: var(--color-warning);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-readiness.ready {
|
||||
border-color: rgba(70, 176, 120, 0.4);
|
||||
background: rgba(70, 176, 120, 0.08);
|
||||
}
|
||||
|
||||
.orchestrator-readiness p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.orchestrator-checks {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
align-items: start !important;
|
||||
justify-content: stretch !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.orchestrator-checks span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-checks .passed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.orchestrator-checks .blocked {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.orchestrator-metrics {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.orchestrator-table small,
|
||||
.admin-table td small {
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.orchestrator-detail {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.orchestrator-detail > div:last-child {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-detail pre {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.orchestrator-form-grid,
|
||||
.orchestrator-allowlists {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.orchestrator-form-grid label,
|
||||
.orchestrator-allowlists label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.orchestrator-form-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.orchestrator-health {
|
||||
margin: 14px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.orchestrator-toggles {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.orchestrator-toggles label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orchestrator-toggles input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.orchestrator-toggles .handoff {
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(224, 175, 91, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(224, 175, 91, 0.08);
|
||||
}
|
||||
|
||||
.orchestrator-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.orchestrator-form-grid,
|
||||
.orchestrator-allowlists {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.orchestrator-section-head,
|
||||
.orchestrator-health {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.providers-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -1891,6 +2097,297 @@ 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-user-balance-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.usage-user-balance-name {
|
||||
font-weight: 600;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.usage-user-balance-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@ export type UsageRecord = {
|
||||
id: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
agentSessionId: string;
|
||||
requestId: string | null;
|
||||
inputTokens: number;
|
||||
@@ -246,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;
|
||||
@@ -342,6 +374,14 @@ export type MemoryV2AdminConfigResponse = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type MemoryV2ConfigRuntimeState = {
|
||||
source: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
fingerprint?: string;
|
||||
overrides: Record<string, string>;
|
||||
};
|
||||
|
||||
export type MemoryV2RuntimeStatusResponse = {
|
||||
ok: boolean;
|
||||
checkedAt?: number;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
export type OrchestratorMode = 'off' | 'shadow' | 'canary' | 'active';
|
||||
|
||||
export type OrchestratorConfig = {
|
||||
mode: OrchestratorMode;
|
||||
primaryEngine: string;
|
||||
fallbackEngine: string;
|
||||
serviceUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
rolloutPercent: number;
|
||||
userAllowlist: string[];
|
||||
workflowAllowlist: string[];
|
||||
fallbackToNative: boolean;
|
||||
requireHealthy: boolean;
|
||||
executionEnabled: boolean;
|
||||
};
|
||||
|
||||
export type OrchestratorRuntime = {
|
||||
killSwitch: boolean;
|
||||
configured: boolean;
|
||||
effective: boolean;
|
||||
reason: string | null;
|
||||
executesLangGraph: boolean;
|
||||
plansLangGraph: boolean;
|
||||
shadowsLangGraph: boolean;
|
||||
executionHandoff: {
|
||||
implemented: boolean;
|
||||
requested: boolean;
|
||||
enabled: boolean;
|
||||
reason: string | null;
|
||||
environmentGate: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrchestratorConfigState = {
|
||||
config: OrchestratorConfig;
|
||||
configVersion: number;
|
||||
updatedBy: string | null;
|
||||
updatedAt: number | null;
|
||||
source: string;
|
||||
runtime: OrchestratorRuntime;
|
||||
engines: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
configured: boolean;
|
||||
capabilities: string[];
|
||||
}>;
|
||||
executors: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
dispatchImplemented: boolean;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type OrchestratorServiceHealth = {
|
||||
checkedAt: number;
|
||||
ok: boolean;
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
httpStatus: number | null;
|
||||
details: {
|
||||
service: string | null;
|
||||
checkpoint: { kind?: string; durable?: boolean } | null;
|
||||
execution: string | null;
|
||||
executorGateway: {
|
||||
dispatchImplemented: boolean;
|
||||
executionEnabled: boolean;
|
||||
store: { kind: string | null; durable: boolean } | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type OrchestratorRuntimeState = OrchestratorConfigState & {
|
||||
serviceHealth: OrchestratorServiceHealth;
|
||||
};
|
||||
|
||||
export type OrchestratorShadowRun = {
|
||||
eventId: string;
|
||||
runId: string;
|
||||
requestId: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
nativeStatus: string;
|
||||
nativeAttempts: number;
|
||||
shadowStatus: 'succeeded' | 'failed';
|
||||
engine: string;
|
||||
configVersion: number | null;
|
||||
phase: string | null;
|
||||
taskType: string | null;
|
||||
synthetic: boolean;
|
||||
executorAdapter: string | null;
|
||||
latencyMs: number | null;
|
||||
error: { code: string; message: string } | null;
|
||||
observedAt: number;
|
||||
nativeCompletedAt: number | null;
|
||||
};
|
||||
|
||||
export type OrchestratorShadowRunList = {
|
||||
generatedAt: number;
|
||||
window: { hours: number; from: number };
|
||||
metrics: {
|
||||
observations: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
successRate: number | null;
|
||||
failureRate: number | null;
|
||||
latencyP50Ms: number | null;
|
||||
latencyP95Ms: number | null;
|
||||
nativeSucceeded: number;
|
||||
nativeFailed: number;
|
||||
lastObservedAt: number | null;
|
||||
sampled: boolean;
|
||||
};
|
||||
runs: OrchestratorShadowRun[];
|
||||
};
|
||||
|
||||
export type OrchestratorExecutionPlanList = {
|
||||
generatedAt: number;
|
||||
window: { hours: number; from: number };
|
||||
metrics: {
|
||||
decisions: number;
|
||||
candidateSelections: number;
|
||||
candidateSelectionRate: number | null;
|
||||
nativeSelections: number;
|
||||
nativeSucceeded: number;
|
||||
nativeFailed: number;
|
||||
nativeSettledRate: number | null;
|
||||
handoffAllowed: number;
|
||||
distinctSessions: number;
|
||||
lastPlannedAt: number | null;
|
||||
candidateReasons: Array<{ value: string; count: number }>;
|
||||
taskTypes: Array<{ value: string; count: number }>;
|
||||
sampled: boolean;
|
||||
};
|
||||
plans: Array<{
|
||||
eventId: string;
|
||||
runId: string;
|
||||
requestId: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
nativeStatus: string;
|
||||
nativeAttempts: number;
|
||||
mode: string | null;
|
||||
candidateEngine: string;
|
||||
effectiveEngine: string;
|
||||
fallbackEngine: string;
|
||||
reason: string | null;
|
||||
candidateReason: string | null;
|
||||
configVersion: number | null;
|
||||
bucket: number | null;
|
||||
taskType: string | null;
|
||||
dryRun: boolean;
|
||||
handoffAllowed: boolean;
|
||||
plannedAt: number;
|
||||
nativeCompletedAt: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type OrchestratorCanaryReadinessCheck = {
|
||||
id: string;
|
||||
passed: boolean;
|
||||
actual: string | number | boolean | null;
|
||||
target: string | number | boolean | null;
|
||||
};
|
||||
|
||||
export type OrchestratorCanaryReadiness = {
|
||||
generatedAt: number;
|
||||
ready: boolean;
|
||||
recommendation: 'keep_shadow' | 'manual_canary_review';
|
||||
window: { hours: number; from: number };
|
||||
thresholds: {
|
||||
hours: number;
|
||||
minObservations: number;
|
||||
minSuccessRate: number;
|
||||
maxP95LatencyMs: number;
|
||||
minLatencyCoverageRate: number;
|
||||
minNativeSettledRate: number;
|
||||
minDistinctSessions: number;
|
||||
maxHoursSinceLastObservation: number;
|
||||
};
|
||||
samples: {
|
||||
totalObservations: number;
|
||||
eligibleObservations: number;
|
||||
excludedSynthetic: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
successRate: number | null;
|
||||
latencyCoverageRate: number | null;
|
||||
latencyP95Ms: number | null;
|
||||
nativeSettledRate: number | null;
|
||||
distinctSessions: number;
|
||||
lastObservedAt: number | null;
|
||||
hoursSinceLastObservation: number | null;
|
||||
sampled: boolean;
|
||||
};
|
||||
service: {
|
||||
status: string | null;
|
||||
latencyMs: number | null;
|
||||
checkpointKind: string | null;
|
||||
checkpointDurable: boolean | null;
|
||||
executorJobStoreKind: string | null;
|
||||
executorJobStoreDurable: boolean | null;
|
||||
execution: string | null;
|
||||
};
|
||||
checks: OrchestratorCanaryReadinessCheck[];
|
||||
blockers: string[];
|
||||
failureCodes: Array<{ code: string; count: number }>;
|
||||
};
|
||||
|
||||
export type OrchestratorShadowRunDetail = {
|
||||
native: {
|
||||
runId: string;
|
||||
requestId: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
shadow: OrchestratorShadowRun | null;
|
||||
remote: {
|
||||
available: boolean;
|
||||
state: Record<string, unknown> | null;
|
||||
events: Array<Record<string, unknown>>;
|
||||
error: { code: string; message: string } | null;
|
||||
executorJob: {
|
||||
available: boolean;
|
||||
state: Record<string, unknown> | null;
|
||||
events: Array<Record<string, unknown>>;
|
||||
error: { code: string; message: string } | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user