mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
Merge latest staging into OTEL v2 destinations
This commit is contained in:
commit
ddc3ac39bf
32 changed files with 3611 additions and 3064 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.79"
|
||||
version = "0.4.80"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.79"
|
||||
version = "0.4.80"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm"
|
||||
version = "1.94.0"
|
||||
version = "1.95.0"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10, <3.15"
|
||||
|
|
@ -62,7 +62,7 @@ proxy = [
|
|||
"azure-identity>=1.25.2,<2.0",
|
||||
"azure-storage-blob>=12.28.0,<13.0",
|
||||
"mcp>=1.28.1,<2.0",
|
||||
"litellm-proxy-extras==0.4.79",
|
||||
"litellm-proxy-extras==0.4.80",
|
||||
"litellm-enterprise==0.1.51",
|
||||
"RestrictedPython>=8.1,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
|
|
@ -289,7 +289,7 @@ members = ["enterprise", "litellm-proxy-extras"]
|
|||
profile = "black"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.94.0"
|
||||
version = "1.95.0"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1197,9 +1197,6 @@
|
|||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/view_users.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
|
|
@ -1207,22 +1204,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/view_users/columns.tsx": {
|
||||
"max-params": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/view_users/table.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/view_users/user_info_view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1516,23 +1497,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/ToolPolicies.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 7
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/UIAccessControlForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1859,17 +1823,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_dashboard/HealthCheckComponent.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_dashboard/all_models_table.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
@ -1878,25 +1831,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_dashboard/health_check_columns.tsx": {
|
||||
"max-params": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_dashboard/table.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_filters.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
308
ui/litellm-dashboard/package-lock.json
generated
308
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -802,9 +802,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1648,9 +1648,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1660,19 +1660,19 @@
|
|||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1682,19 +1682,38 @@
|
|||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1708,9 +1727,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1724,9 +1743,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1740,9 +1759,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1756,9 +1775,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1772,9 +1791,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1788,9 +1807,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1804,9 +1823,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1820,9 +1839,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1836,9 +1855,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1852,9 +1871,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1864,19 +1883,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1886,19 +1905,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1908,19 +1927,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1930,19 +1949,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1952,19 +1971,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1974,19 +1993,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1996,19 +2015,19 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2018,38 +2037,54 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2059,16 +2094,16 @@
|
|||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -2078,16 +2113,16 @@
|
|||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2097,7 +2132,7 @@
|
|||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
|
|
@ -12534,48 +12569,53 @@
|
|||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@
|
|||
"axios": "1.13.6",
|
||||
"postcss": "8.5.13",
|
||||
"esbuild": "0.28.1",
|
||||
"date-fns": "^4.4.0"
|
||||
"date-fns": "^4.4.0",
|
||||
"sharp": "^0.35.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GuardrailsPanel from "./GuardrailsPanel";
|
||||
import { getGuardrailsList } from "@/components/networking";
|
||||
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGuardrailsList: vi.fn(),
|
||||
|
|
@ -48,16 +48,13 @@ vi.mock("@/utils/roles", () => ({
|
|||
isAdminRole: vi.fn((role: string) => role === "admin"),
|
||||
}));
|
||||
|
||||
vi.mock("./guardrail_info_helpers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./guardrail_info_helpers")>();
|
||||
return {
|
||||
...actual,
|
||||
getGuardrailLogoAndName: vi.fn(() => ({
|
||||
logo: null,
|
||||
displayName: "Test Provider",
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock("./guardrail_info_helpers", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./guardrail_info_helpers")>()),
|
||||
getGuardrailLogoAndName: vi.fn(() => ({
|
||||
logo: null,
|
||||
displayName: "Test Provider",
|
||||
})),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
|
|
@ -82,6 +79,7 @@ describe("GuardrailsPanel", () => {
|
|||
};
|
||||
|
||||
const mockGetGuardrailsList = vi.mocked(getGuardrailsList);
|
||||
const mockDeleteGuardrailCall = vi.mocked(deleteGuardrailCall);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -111,4 +109,35 @@ describe("GuardrailsPanel", () => {
|
|||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
expect(screen.getByText("Add New Guardrail")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should delete the clicked guardrail after confirming in the modal", async () => {
|
||||
render(<GuardrailsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
|
||||
fireEvent.click(await screen.findByTestId("delete-button"));
|
||||
|
||||
const modal = within(await screen.findByRole("dialog"));
|
||||
expect(modal.getByText("Delete Guardrail")).toBeInTheDocument();
|
||||
expect(modal.getByText("test-guardrail-1")).toBeInTheDocument();
|
||||
expect(modal.getByText("Test Provider")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(modal.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteGuardrailCall).toHaveBeenCalledWith("test-token", "test-guardrail-1");
|
||||
});
|
||||
expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not delete anything when the modal is cancelled", async () => {
|
||||
render(<GuardrailsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
|
||||
fireEvent.click(await screen.findByTestId("delete-button"));
|
||||
const modal = within(await screen.findByRole("dialog"));
|
||||
|
||||
fireEvent.click(modal.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockDeleteGuardrailCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { transformModelData } from "./utils/modelDataTransformer";
|
|||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import type { UploadProps } from "antd";
|
||||
import { Form } from "antd";
|
||||
|
|
@ -69,13 +70,16 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
const [healthCurrentPage, setHealthCurrentPage] = useState(1);
|
||||
const [healthPagination, setHealthPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: HEALTH_PAGE_SIZE,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo();
|
||||
const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo(
|
||||
healthCurrentPage,
|
||||
HEALTH_PAGE_SIZE,
|
||||
healthPagination.pageIndex + 1,
|
||||
healthPagination.pageSize,
|
||||
);
|
||||
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
|
||||
const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials();
|
||||
|
|
@ -137,14 +141,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
return transformModelData(healthModelDataResponse, getProviderFromModel);
|
||||
}, [healthModelDataResponse?.data, getProviderFromModel]);
|
||||
|
||||
const healthPaginationMeta = useMemo(() => {
|
||||
return {
|
||||
total_count: healthModelDataResponse?.total_count ?? 0,
|
||||
current_page: healthModelDataResponse?.current_page ?? healthCurrentPage,
|
||||
total_pages: healthModelDataResponse?.total_pages ?? 1,
|
||||
size: healthModelDataResponse?.size ?? HEALTH_PAGE_SIZE,
|
||||
};
|
||||
}, [healthModelDataResponse, healthCurrentPage]);
|
||||
const healthRowCount = healthModelDataResponse?.total_count ?? 0;
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
|
|
@ -188,7 +185,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
const handleRefreshClick = () => {
|
||||
const currentDate = new Date();
|
||||
setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
setHealthCurrentPage(1);
|
||||
setHealthPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
refetchModels();
|
||||
};
|
||||
|
|
@ -413,10 +410,9 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
setSelectedModelId={setSelectedModelId}
|
||||
teams={teams}
|
||||
isLoading={isLoadingHealthModels}
|
||||
paginationMeta={healthPaginationMeta}
|
||||
currentPage={healthCurrentPage}
|
||||
pageSize={HEALTH_PAGE_SIZE}
|
||||
onPageChange={setHealthCurrentPage}
|
||||
pagination={healthPagination}
|
||||
onPaginationChange={setHealthPagination}
|
||||
rowCount={healthRowCount}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ import ToolPoliciesView from "@/components/ToolPoliciesView";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function ToolPolicies() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
return <ToolPoliciesView accessToken={accessToken} userRole={userRole} />;
|
||||
const { accessToken } = useAuthorized();
|
||||
return <ToolPoliciesView accessToken={accessToken} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,17 @@
|
|||
import React from "react";
|
||||
import { render, waitFor, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ViewUserDashboard from "./view_users";
|
||||
|
||||
const userListCall = vi.fn();
|
||||
|
||||
// Mock the networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userListCall: vi.fn().mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
user_id: "user-1",
|
||||
user_email: "test@example.com",
|
||||
user_role: "Admin",
|
||||
spend: 100.5,
|
||||
max_budget: null,
|
||||
key_count: 2,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
sso_user_id: null,
|
||||
budget_duration: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 1,
|
||||
}),
|
||||
userListCall: (...args: unknown[]) => userListCall(...args),
|
||||
userDeleteCall: vi.fn().mockResolvedValue({}),
|
||||
getPossibleUserRoles: vi.fn().mockResolvedValue({
|
||||
Admin: { ui_label: "Admin" },
|
||||
|
|
@ -44,6 +30,13 @@ vi.mock("@/components/networking", () => ({
|
|||
getInternalUserSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// The detail view has its own test; stub it so this file covers the parent's swap.
|
||||
vi.mock("./view_users/user_info_view", () => ({
|
||||
default: function UserInfoViewMock({ userId, startInEditMode }: { userId: string; startInEditMode?: boolean }) {
|
||||
return <div data-testid="user-info-view">{`detail:${userId}:${String(Boolean(startInEditMode))}`}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock NotificationsManager
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
|
|
@ -52,6 +45,21 @@ vi.mock("@/components/molecules/notifications_manager", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const makeUser = (userId: string, email: string) => ({
|
||||
user_id: userId,
|
||||
user_email: email,
|
||||
user_alias: null,
|
||||
user_role: "Admin",
|
||||
spend: 100.5,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
key_count: 2,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
sso_user_id: null,
|
||||
budget_duration: null,
|
||||
});
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -62,105 +70,194 @@ const createQueryClient = () =>
|
|||
},
|
||||
});
|
||||
|
||||
describe("ViewUserDashboard", () => {
|
||||
const defaultProps = {
|
||||
accessToken: "test-token",
|
||||
token: "test-token",
|
||||
userRole: "Admin",
|
||||
userID: "admin-user-id",
|
||||
teams: [],
|
||||
};
|
||||
const defaultProps = {
|
||||
accessToken: "test-token",
|
||||
token: "test-token",
|
||||
userRole: "Admin",
|
||||
userID: "admin-user-id",
|
||||
teams: [],
|
||||
};
|
||||
|
||||
const renderDashboard = () =>
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ViewUserDashboard {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("ViewUserDashboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userListCall.mockResolvedValue({
|
||||
users: [makeUser("user-1", "test@example.com")],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the ViewUserDashboard component", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ViewUserDashboard {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
renderDashboard();
|
||||
|
||||
// Wait for the component to load (it shows "Loading..." initially)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check if main elements are rendered
|
||||
expect(screen.getByText("Users")).toBeInTheDocument();
|
||||
// Use getAllByText since "Default User Settings" appears multiple times
|
||||
const defaultUserSettingsTabs = screen.getAllByText("Default User Settings");
|
||||
expect(defaultUserSettingsTabs.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Default User Settings").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show delete modal after clicking delete user button", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ViewUserDashboard {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
it("should show delete modal after choosing delete from the row actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
// Wait for the component to load and the table to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Users")).toBeInTheDocument();
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the user data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Initially, the delete modal should not be visible
|
||||
expect(screen.queryByText("Delete User?")).not.toBeInTheDocument();
|
||||
|
||||
// Find the row containing the user email (use the first one which is in the table)
|
||||
// The email appears in both the table and potentially in modals, so get the first one from the table
|
||||
const userEmailCells = screen.getAllByText("test@example.com");
|
||||
const userEmailCell = userEmailCells[0]; // First occurrence is in the table
|
||||
const userRow = userEmailCell.closest("tr");
|
||||
expect(userRow).toBeInTheDocument();
|
||||
|
||||
// Find clickable elements in the actions column (the last column)
|
||||
const actionCells = userRow?.querySelectorAll("td");
|
||||
const actionsCell = actionCells?.[actionCells.length - 1];
|
||||
expect(actionsCell).toBeInTheDocument();
|
||||
|
||||
// Find the action container div with flex gap-2
|
||||
const actionContainer =
|
||||
actionsCell?.querySelector("div.flex.gap-2") ||
|
||||
Array.from(actionsCell?.querySelectorAll("div") || []).find(
|
||||
(div) => div.className.includes("flex") && div.className.includes("gap"),
|
||||
);
|
||||
|
||||
expect(actionContainer).toBeInTheDocument();
|
||||
|
||||
// Get all direct children of the action container
|
||||
// These should be Tooltip components wrapping Icon components
|
||||
const tooltipWrappers = Array.from(actionContainer!.children);
|
||||
expect(tooltipWrappers.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The delete icon is the second tooltip wrapper (index 1)
|
||||
// Edit=0, Delete=1, Reset=2
|
||||
const deleteTooltipWrapper = tooltipWrappers[1] as HTMLElement;
|
||||
const clickableElement = deleteTooltipWrapper.querySelector("button, [role='button'], svg") as HTMLElement;
|
||||
|
||||
expect(clickableElement).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(clickableElement);
|
||||
await user.click(screen.getByTestId("user-actions-user-1"));
|
||||
await user.click(await screen.findByTestId("user-action-delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete User?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText("Are you sure you want to delete this user? This action cannot be undone."),
|
||||
).toBeInTheDocument();
|
||||
const userIdInstances = screen.getAllByText("user-1");
|
||||
expect(userIdInstances.length).toBeGreaterThan(0);
|
||||
const emailInstances = screen.getAllByText("test@example.com");
|
||||
expect(emailInstances.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("user-1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should swap to the detail view when the identity cell is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /user-1/ }));
|
||||
|
||||
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false");
|
||||
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the detail view in edit mode from the row actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("user-actions-user-1"));
|
||||
await user.click(await screen.findByTestId("user-action-edit"));
|
||||
|
||||
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:true");
|
||||
});
|
||||
|
||||
describe("bulk edit selection", () => {
|
||||
beforeEach(() => {
|
||||
userListCall.mockResolvedValue({
|
||||
users: [makeUser("user-1", "ada@example.com"), makeUser("user-2", "grace@example.com")],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals selection checkboxes only while selection mode is on", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ada@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("toggle-user-selection"));
|
||||
expect(screen.getByTestId("datatable-select-all")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("toggle-user-selection"));
|
||||
expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts the selected rows in the bulk edit button and enables it once a row is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ada@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("toggle-user-selection"));
|
||||
|
||||
const bulkEdit = screen.getByTestId("bulk-edit-users");
|
||||
expect(bulkEdit).toHaveTextContent("Bulk Edit (0 selected)");
|
||||
expect(bulkEdit).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-user-2"));
|
||||
expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (1 selected)");
|
||||
expect(screen.getByTestId("bulk-edit-users")).not.toBeDisabled();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-all"));
|
||||
expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (2 selected)");
|
||||
});
|
||||
|
||||
it("clears the selection when selection mode is cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ada@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("toggle-user-selection"));
|
||||
await user.click(screen.getByTestId("datatable-select-row-user-1"));
|
||||
expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (1 selected)");
|
||||
|
||||
await user.click(screen.getByTestId("toggle-user-selection"));
|
||||
await user.click(screen.getByTestId("toggle-user-selection"));
|
||||
|
||||
expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (0 selected)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("server-side query wiring", () => {
|
||||
it("requests page 1 with the default created_at desc sort", async () => {
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(userListCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [, userIds, page, pageSize, , , , , sortBy, sortOrder] = userListCall.mock.calls[0];
|
||||
expect(userIds).toBeNull();
|
||||
expect(page).toBe(1);
|
||||
expect(pageSize).toBe(25);
|
||||
expect(sortBy).toBe("created_at");
|
||||
expect(sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("sends the clicked column as sort_by and resets to the first page", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-user_email"));
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1];
|
||||
expect(latest[8]).toBe("user_email");
|
||||
expect(latest[9]).toBe("asc");
|
||||
expect(latest[2]).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "antd";
|
||||
import BulkEditUserModal from "./BulkEditUsers";
|
||||
|
|
@ -18,20 +18,24 @@ import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
|
|||
import { updateExistingKeys } from "@/utils/dataUtils";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Typography } from "antd";
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { modelAvailableCall, userDeleteCall } from "@/components/networking";
|
||||
import DefaultUserSettings from "./DefaultUserSettings";
|
||||
import { columns } from "./view_users/columns";
|
||||
import { UserDataTable } from "./view_users/table";
|
||||
import { UsersTable } from "./view_users/UsersTable";
|
||||
import UserInfoView from "./view_users/user_info_view";
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import { Skeleton } from "antd";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface ViewUserDashboardProps {
|
||||
accessToken: string | null;
|
||||
token: string | null;
|
||||
|
|
@ -41,33 +45,11 @@ interface ViewUserDashboardProps {
|
|||
orgAdminOrgIds?: Array<{ organization_id: string; organization_alias: string }> | null;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
email: string;
|
||||
user_id: string;
|
||||
user_role: string;
|
||||
sso_user_id: string;
|
||||
team: string;
|
||||
model: string;
|
||||
min_spend: number | null;
|
||||
max_spend: number | null;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
const initialFilters: FilterState = {
|
||||
email: "",
|
||||
user_id: "",
|
||||
user_role: "",
|
||||
sso_user_id: "",
|
||||
team: "",
|
||||
model: "",
|
||||
min_spend: null,
|
||||
max_spend: null,
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
const DEFAULT_SORT_BY = "created_at";
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: DEFAULT_SORT_BY, desc: true }];
|
||||
|
||||
const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||
accessToken,
|
||||
|
|
@ -79,34 +61,30 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
}) => {
|
||||
const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false;
|
||||
const queryClient = useQueryClient();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE });
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchEmail] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [openInEditMode, setOpenInEditMode] = useState(false);
|
||||
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<UserInfo | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
const [filters, setFilters] = useState<FilterState>(initialFilters);
|
||||
const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
|
||||
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserInfo[]>([]);
|
||||
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
|
||||
const handleDelete = (user: UserInfo) => {
|
||||
setUserToDelete(user);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncer.cancel();
|
||||
};
|
||||
}, [debouncer]);
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(getProxyBaseUrl());
|
||||
}, []);
|
||||
|
|
@ -130,32 +108,69 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
fetchUserModels();
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
const updateFilters = (update: Partial<FilterState>) => {
|
||||
setFilters((previousFilters) => {
|
||||
const newFilters = { ...previousFilters, ...update };
|
||||
setDebouncedFilters(newFilters);
|
||||
return newFilters;
|
||||
});
|
||||
};
|
||||
const getFilterValue = useCallback(
|
||||
(columnId: string): string | undefined => {
|
||||
const entry = columnFilters.find((filter) => filter.id === columnId);
|
||||
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
|
||||
},
|
||||
[columnFilters],
|
||||
);
|
||||
|
||||
const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => {
|
||||
updateFilters({ sort_by: sortBy, sort_order: sortOrder });
|
||||
};
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInput(value);
|
||||
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
setRowSelection({});
|
||||
}, []);
|
||||
|
||||
const handleResetPassword = async (userId: string) => {
|
||||
if (!accessToken) {
|
||||
NotificationsManager.fromBackend("Access token not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NotificationsManager.success("Generating password reset link...");
|
||||
const data = await invitationCreateCall(accessToken, userId);
|
||||
setInvitationLinkData(data);
|
||||
setIsInvitationLinkModalVisible(true);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend("Failed to generate password reset link");
|
||||
}
|
||||
};
|
||||
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
|
||||
setSorting(updaterOrValue);
|
||||
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
setRowSelection({});
|
||||
}, []);
|
||||
|
||||
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
|
||||
setColumnFilters(updaterOrValue);
|
||||
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
setRowSelection({});
|
||||
}, []);
|
||||
|
||||
const handlePaginationChange = useCallback<OnChangeFn<PaginationState>>((updaterOrValue) => {
|
||||
setPagination(updaterOrValue);
|
||||
setRowSelection({});
|
||||
}, []);
|
||||
|
||||
const handleUserClick = useCallback((userId: string, openInEdit: boolean = false) => {
|
||||
setSelectedUserId(userId);
|
||||
setOpenInEditMode(openInEdit);
|
||||
}, []);
|
||||
|
||||
const handleCloseUserInfo = useCallback(() => {
|
||||
setSelectedUserId(null);
|
||||
setOpenInEditMode(false);
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback((user: UserInfo) => {
|
||||
setUserToDelete(user);
|
||||
setIsDeleteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleResetPassword = useCallback(
|
||||
async (userId: string) => {
|
||||
if (!accessToken) {
|
||||
NotificationsManager.fromBackend("Access token not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NotificationsManager.success("Generating password reset link...");
|
||||
const data = await invitationCreateCall(accessToken, userId);
|
||||
setInvitationLinkData(data);
|
||||
setIsInvitationLinkModalVisible(true);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend("Failed to generate password reset link");
|
||||
}
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (userToDelete && accessToken) {
|
||||
|
|
@ -220,58 +235,63 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
// Close the modal
|
||||
};
|
||||
|
||||
const handlePageChange = async (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handleToggleSelectionMode = () => {
|
||||
setSelectionMode(!selectionMode);
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (users: UserInfo[]) => {
|
||||
setSelectedUsers(users);
|
||||
};
|
||||
|
||||
const handleBulkEdit = () => {
|
||||
if (selectedUsers.length === 0) {
|
||||
NotificationsManager.fromBackend("Please select users to edit");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBulkEditModalVisible(true);
|
||||
setRowSelection({});
|
||||
};
|
||||
|
||||
const handleBulkEditSuccess = () => {
|
||||
// Refresh the user list
|
||||
queryClient.invalidateQueries({ queryKey: ["userList"] });
|
||||
setSelectedUsers([]);
|
||||
setRowSelection({});
|
||||
setSelectionMode(false);
|
||||
};
|
||||
|
||||
const activeSort = sorting[0];
|
||||
const sortBy = activeSort?.id ?? DEFAULT_SORT_BY;
|
||||
const sortOrder: "asc" | "desc" = activeSort?.desc ?? true ? "desc" : "asc";
|
||||
|
||||
const userIdFilter = getFilterValue("user_id");
|
||||
const ssoUserIdFilter = getFilterValue("sso_user_id");
|
||||
const userRoleFilter = getFilterValue("user_role");
|
||||
const teamFilter = getFilterValue("team");
|
||||
const emailFilter = searchEmail.trim() || null;
|
||||
|
||||
const userListQueryFilters = {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
email: emailFilter,
|
||||
userId: userIdFilter,
|
||||
ssoUserId: ssoUserIdFilter,
|
||||
role: userRoleFilter,
|
||||
team: teamFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
orgAdminOrgIds,
|
||||
};
|
||||
|
||||
const userListQuery = useQuery({
|
||||
queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }],
|
||||
queryKey: ["userList", userListQueryFilters],
|
||||
queryFn: async () => {
|
||||
if (!accessToken) throw new Error("Access token required");
|
||||
|
||||
return await userListCall(
|
||||
accessToken,
|
||||
debouncedFilters.user_id ? [debouncedFilters.user_id] : null,
|
||||
currentPage,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
debouncedFilters.email || null,
|
||||
debouncedFilters.user_role || null,
|
||||
debouncedFilters.team || null,
|
||||
debouncedFilters.sso_user_id || null,
|
||||
debouncedFilters.sort_by,
|
||||
debouncedFilters.sort_order,
|
||||
userIdFilter ? [userIdFilter] : null,
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
emailFilter,
|
||||
userRoleFilter ?? null,
|
||||
teamFilter ?? null,
|
||||
ssoUserIdFilter ?? null,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null,
|
||||
);
|
||||
},
|
||||
enabled: Boolean(accessToken && token && userRole && userID),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const userListResponse = userListQuery.data;
|
||||
|
||||
const userRolesQuery = useQuery<Record<string, Record<string, string>>>({
|
||||
queryKey: ["userRoles"],
|
||||
|
|
@ -284,28 +304,61 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
});
|
||||
const possibleUIRoles = userRolesQuery.data;
|
||||
|
||||
const tableColumns = columns(
|
||||
possibleUIRoles,
|
||||
(user) => {
|
||||
setSelectedUser(user);
|
||||
setEditModalVisible(true);
|
||||
},
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
() => {}, // placeholder function, will be overridden in UserDataTable
|
||||
const users = useMemo<UserInfo[]>(() => userListQuery.data?.users ?? [], [userListQuery.data]);
|
||||
const totalUserCount = userListQuery.data?.total ?? 0;
|
||||
|
||||
const selectedUsers = useMemo(() => users.filter((user) => rowSelection[user.user_id]), [users, rowSelection]);
|
||||
|
||||
if (selectedUserId) {
|
||||
return (
|
||||
<UserInfoView
|
||||
userId={selectedUserId}
|
||||
onClose={handleCloseUserInfo}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
initialTab={openInEditMode ? 1 : 0}
|
||||
startInEditMode={openInEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const usersTable = (
|
||||
<UsersTable
|
||||
data={users}
|
||||
rowCount={totalUserCount}
|
||||
isLoading={userListQuery.isLoading}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
teams={teams}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={handleColumnFiltersChange}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={handleSearchChange}
|
||||
selectionEnabled={isProxyAdmin && selectionMode}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
onUserClick={handleUserClick}
|
||||
onDeleteUser={handleDelete}
|
||||
onResetPassword={handleResetPassword}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full p-8 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex space-x-3">
|
||||
{userListQuery.isLoading ? (
|
||||
{userListQuery.isLoading && (
|
||||
<>
|
||||
<Skeleton.Button active size="default" shape="default" style={{ width: 110, height: 36 }} />
|
||||
<Skeleton.Button active size="default" shape="default" style={{ width: 145, height: 36 }} />
|
||||
<Skeleton.Button active size="default" shape="default" style={{ width: 110, height: 36 }} />
|
||||
</>
|
||||
) : userID && accessToken ? (
|
||||
)}
|
||||
{!userListQuery.isLoading && userID && accessToken && (
|
||||
<>
|
||||
{isProxyAdmin && (
|
||||
<CreateUserButton
|
||||
|
|
@ -321,6 +374,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
onClick={handleToggleSelectionMode}
|
||||
type={selectionMode ? "primary" : "default"}
|
||||
className="flex items-center"
|
||||
data-testid="toggle-user-selection"
|
||||
>
|
||||
{selectionMode ? "Cancel Selection" : "Select Users"}
|
||||
</Button>
|
||||
|
|
@ -329,57 +383,28 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
{isProxyAdmin && selectionMode && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBulkEdit}
|
||||
onClick={() => setIsBulkEditModalVisible(true)}
|
||||
disabled={selectedUsers.length === 0}
|
||||
className="flex items-center"
|
||||
data-testid="bulk-edit-users"
|
||||
>
|
||||
Bulk Edit ({selectedUsers.length} selected)
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProxyAdmin ? (
|
||||
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
|
||||
<TabGroup defaultIndex={0}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Users</Tab>
|
||||
<Tab>Default User Settings</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<UserDataTable
|
||||
data={userListQuery.data?.users || []}
|
||||
columns={tableColumns}
|
||||
isLoading={userListQuery.isLoading}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={{
|
||||
sortBy: filters.sort_by,
|
||||
sortOrder: filters.sort_order,
|
||||
}}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
handleEdit={(user) => {
|
||||
setSelectedUser(user);
|
||||
setEditModalVisible(true);
|
||||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleResetPassword={handleResetPassword}
|
||||
enableSelection={selectionMode}
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
filters={filters}
|
||||
updateFilters={updateFilters}
|
||||
initialFilters={initialFilters}
|
||||
teams={teams}
|
||||
userListResponse={userListResponse}
|
||||
currentPage={currentPage}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>{usersTable}</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{!userID || !userRole || !accessToken ? (
|
||||
|
|
@ -398,35 +423,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
</TabPanels>
|
||||
</TabGroup>
|
||||
) : (
|
||||
<UserDataTable
|
||||
data={userListQuery.data?.users || []}
|
||||
columns={tableColumns}
|
||||
isLoading={userListQuery.isLoading}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={{
|
||||
sortBy: filters.sort_by,
|
||||
sortOrder: filters.sort_order,
|
||||
}}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
handleEdit={(user) => {
|
||||
setSelectedUser(user);
|
||||
setEditModalVisible(true);
|
||||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleResetPassword={handleResetPassword}
|
||||
enableSelection={false}
|
||||
selectedUsers={[]}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
filters={filters}
|
||||
updateFilters={updateFilters}
|
||||
initialFilters={initialFilters}
|
||||
teams={teams}
|
||||
userListResponse={userListResponse}
|
||||
currentPage={currentPage}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
usersTable
|
||||
)}
|
||||
|
||||
{/* Existing Modals */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import type { PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { UserInfo } from "@/components/networking";
|
||||
|
||||
import { UsersTable } from "./UsersTable";
|
||||
|
||||
const possibleUIRoles = {
|
||||
proxy_admin: { ui_label: "Admin" },
|
||||
internal_user: { ui_label: "Internal User" },
|
||||
};
|
||||
|
||||
const makeUser = (overrides: Partial<UserInfo> = {}): UserInfo =>
|
||||
({
|
||||
user_id: "user-1",
|
||||
user_email: "ada@example.com",
|
||||
user_alias: null,
|
||||
user_role: "proxy_admin",
|
||||
spend: 12.5,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
key_count: 2,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-02-01T00:00:00Z",
|
||||
sso_user_id: null,
|
||||
budget_duration: null,
|
||||
...overrides,
|
||||
}) as UserInfo;
|
||||
|
||||
interface HarnessOverrides {
|
||||
data?: UserInfo[];
|
||||
rowCount?: number;
|
||||
isLoading?: boolean;
|
||||
selectionEnabled?: boolean;
|
||||
onUserClick?: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser?: (user: UserInfo) => void;
|
||||
onResetPassword?: (userId: string) => void;
|
||||
onSortingChange?: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the table with real selection/sorting state so assertions exercise the
|
||||
* controlled wiring rather than a stubbed callback.
|
||||
*/
|
||||
function Harness({
|
||||
data = [makeUser()],
|
||||
rowCount = 1,
|
||||
isLoading = false,
|
||||
selectionEnabled = false,
|
||||
onUserClick = vi.fn(),
|
||||
onDeleteUser = vi.fn(),
|
||||
onResetPassword = vi.fn(),
|
||||
onSortingChange,
|
||||
}: HarnessOverrides) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
return (
|
||||
<>
|
||||
<span data-testid="selected-ids">
|
||||
{Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.sort()
|
||||
.join(",")}
|
||||
</span>
|
||||
<UsersTable
|
||||
data={data}
|
||||
rowCount={rowCount}
|
||||
isLoading={isLoading}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
teams={[]}
|
||||
sorting={sorting}
|
||||
onSortingChange={(updater) => {
|
||||
setSorting(updater);
|
||||
onSortingChange?.(updater);
|
||||
}}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
columnFilters={[]}
|
||||
onColumnFiltersChange={vi.fn()}
|
||||
searchValue=""
|
||||
onSearchChange={vi.fn()}
|
||||
selectionEnabled={selectionEnabled}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
onUserClick={onUserClick}
|
||||
onDeleteUser={onDeleteUser}
|
||||
onResetPassword={onResetPassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const openRowMenu = async (user: ReturnType<typeof userEvent.setup>, userId: string) => {
|
||||
await user.click(screen.getByTestId(`user-actions-${userId}`));
|
||||
};
|
||||
|
||||
describe("UsersTable", () => {
|
||||
it("renders every migrated column header", () => {
|
||||
render(<Harness />);
|
||||
|
||||
const headerRow = screen.getAllByRole("row")[0];
|
||||
|
||||
[
|
||||
"User ID",
|
||||
"Email",
|
||||
"Status",
|
||||
"Global Proxy Role",
|
||||
"User Alias",
|
||||
"Spend (USD)",
|
||||
"Budget (USD)",
|
||||
"SSO ID",
|
||||
"Virtual Keys",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
].forEach((header) => {
|
||||
expect(headerRow.textContent).toContain(header);
|
||||
});
|
||||
});
|
||||
|
||||
// Sorting is server-side and the backend only accepts these five keys, so a sort
|
||||
// control on any other column would send an invalid sort_by. Assert the exact set:
|
||||
// a missing control and an extra one both have to fail.
|
||||
it("exposes a sort control for exactly the five server-sortable columns", () => {
|
||||
render(<Harness />);
|
||||
|
||||
const sortableIds = screen
|
||||
.getAllByTestId(/^sort-header-/)
|
||||
.map((node) => (node.getAttribute("data-testid") ?? "").replace("sort-header-", ""))
|
||||
.sort();
|
||||
|
||||
expect(sortableIds).toEqual(["created_at", "spend", "user_email", "user_id", "user_role"]);
|
||||
});
|
||||
|
||||
it("reports the clicked column to the server sorting handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortingChange = vi.fn();
|
||||
render(<Harness onSortingChange={onSortingChange} />);
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-user_email"));
|
||||
|
||||
expect(onSortingChange).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("sort-header-user_email").querySelector("[data-sort-indicator]")).toHaveAttribute(
|
||||
"data-sort-indicator",
|
||||
"asc",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the detail view from the identity cell without edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUserClick = vi.fn();
|
||||
render(<Harness onUserClick={onUserClick} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /user-1/ }));
|
||||
|
||||
expect(onUserClick).toHaveBeenCalledWith("user-1", false);
|
||||
});
|
||||
|
||||
it("opens the detail view in edit mode from the row menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUserClick = vi.fn();
|
||||
render(<Harness onUserClick={onUserClick} />);
|
||||
|
||||
await openRowMenu(user, "user-1");
|
||||
await user.click(await screen.findByTestId("user-action-edit"));
|
||||
|
||||
expect(onUserClick).toHaveBeenCalledWith("user-1", true);
|
||||
});
|
||||
|
||||
it("delegates delete and reset-password from the row menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteUser = vi.fn();
|
||||
const onResetPassword = vi.fn();
|
||||
render(<Harness onDeleteUser={onDeleteUser} onResetPassword={onResetPassword} />);
|
||||
|
||||
await openRowMenu(user, "user-1");
|
||||
await user.click(await screen.findByTestId("user-action-reset-password"));
|
||||
expect(onResetPassword).toHaveBeenCalledWith("user-1");
|
||||
|
||||
await openRowMenu(user, "user-1");
|
||||
await user.click(await screen.findByTestId("user-action-delete"));
|
||||
expect(onDeleteUser).toHaveBeenCalledWith(expect.objectContaining({ user_id: "user-1" }));
|
||||
});
|
||||
|
||||
it("renders the SCIM status cell from metadata", () => {
|
||||
const { rerender } = render(<Harness data={[makeUser()]} />);
|
||||
expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Active");
|
||||
|
||||
rerender(<Harness data={[makeUser({ metadata: { scim_active: false } } as Partial<UserInfo>)]} />);
|
||||
expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Inactive");
|
||||
|
||||
rerender(<Harness data={[makeUser({ metadata: { scim_active: true } } as Partial<UserInfo>)]} />);
|
||||
expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Active");
|
||||
});
|
||||
|
||||
describe("row selection", () => {
|
||||
const twoUsers = [
|
||||
makeUser({ user_id: "user-1", user_email: "ada@example.com" }),
|
||||
makeUser({ user_id: "user-2", user_email: "grace@example.com" }),
|
||||
];
|
||||
|
||||
it("hides the selection column until selection mode is on", () => {
|
||||
const { rerender } = render(<Harness data={twoUsers} rowCount={2} selectionEnabled={false} />);
|
||||
expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("datatable-select-row-user-1")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<Harness data={twoUsers} rowCount={2} selectionEnabled />);
|
||||
expect(screen.getByTestId("datatable-select-all")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("datatable-select-row-user-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keys the controlled selection by user id", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness data={twoUsers} rowCount={2} selectionEnabled />);
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-user-2"));
|
||||
expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-2");
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-user-1"));
|
||||
expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-1,user-2");
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-user-2"));
|
||||
expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-1");
|
||||
});
|
||||
|
||||
it("selects and clears the whole page from the header checkbox", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness data={twoUsers} rowCount={2} selectionEnabled />);
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-all"));
|
||||
expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-1,user-2");
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-all"));
|
||||
expect(screen.getByTestId("selected-ids")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows an indeterminate header while only part of the page is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness data={twoUsers} rowCount={2} selectionEnabled />);
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-user-1"));
|
||||
|
||||
expect(screen.getByTestId("datatable-select-all")).toHaveAttribute("aria-checked", "mixed");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no users", () => {
|
||||
render(<Harness data={[]} rowCount={0} />);
|
||||
|
||||
expect(screen.getByText("No users found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows skeleton rows on the initial load instead of the empty state", () => {
|
||||
render(<Harness data={[]} rowCount={0} isLoading />);
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("No users found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the row menu out of the identity cell so only the name and menu act on a row", () => {
|
||||
render(<Harness />);
|
||||
|
||||
const rows = screen.getAllByRole("row");
|
||||
const dataRow = rows[rows.length - 1];
|
||||
expect(within(dataRow).getByTestId("user-actions-user-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Users } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { getUsersTableColumns } from "./UsersTableColumns";
|
||||
|
||||
export interface UsersTableTeamOption {
|
||||
team_id: string;
|
||||
team_alias?: string | null;
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
data: UserInfo[];
|
||||
rowCount: number;
|
||||
isLoading: boolean;
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
teams: UsersTableTeamOption[] | null;
|
||||
sorting: SortingState;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
columnFilters: ColumnFiltersState;
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
selectionEnabled: boolean;
|
||||
rowSelection: RowSelectionState;
|
||||
onRowSelectionChange: OnChangeFn<RowSelectionState>;
|
||||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
}
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
user_id: "User ID",
|
||||
sso_user_id: "SSO ID",
|
||||
user_role: "Role",
|
||||
team: "Team",
|
||||
};
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Users className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No users found</div>
|
||||
<div className="text-sm text-muted-foreground">Try adjusting your search or filters.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersTable({
|
||||
data,
|
||||
rowCount,
|
||||
isLoading,
|
||||
possibleUIRoles,
|
||||
teams,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
selectionEnabled,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
}: UsersTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = {
|
||||
possibleUIRoles,
|
||||
includeSelection: selectionEnabled,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
};
|
||||
return getUsersTableColumns(columnDeps);
|
||||
}, [possibleUIRoles, selectionEnabled, onUserClick, onDeleteUser, onResetPassword]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(possibleUIRoles ?? {}).map(([role, config]) => ({
|
||||
label: config.ui_label || role,
|
||||
value: role,
|
||||
})),
|
||||
[possibleUIRoles],
|
||||
);
|
||||
|
||||
const teamOptions = useMemo(
|
||||
() =>
|
||||
(teams ?? []).map((team) => ({
|
||||
label: team.team_alias || team.team_id,
|
||||
value: team.team_id,
|
||||
})),
|
||||
[teams],
|
||||
);
|
||||
|
||||
const formatFilterValue = (columnId: string, value: unknown): string => {
|
||||
const raw = String(value);
|
||||
if (columnId === "user_role") {
|
||||
return possibleUIRoles?.[raw]?.ui_label || raw;
|
||||
}
|
||||
if (columnId === "team") {
|
||||
return teams?.find((team) => team.team_id === raw)?.team_alias || raw;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.user_id}
|
||||
sortingMode="server"
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
paginationMode="server"
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
rowCount={rowCount}
|
||||
filterMode="server"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={onColumnFiltersChange}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading users…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search by email…"
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
filterLabels={FILTER_LABELS}
|
||||
formatFilterValue={formatFilterValue}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down your users"
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="User ID">
|
||||
<Input
|
||||
value={(get("user_id") as string) ?? ""}
|
||||
onChange={(event) => set("user_id", event.target.value)}
|
||||
placeholder="Enter user ID…"
|
||||
data-testid="users-filter-user-id"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="SSO ID">
|
||||
<Input
|
||||
value={(get("sso_user_id") as string) ?? ""}
|
||||
onChange={(event) => set("sso_user_id", event.target.value)}
|
||||
placeholder="Enter SSO ID…"
|
||||
data-testid="users-filter-sso-id"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Role">
|
||||
<SearchSelect
|
||||
options={roleOptions}
|
||||
value={(get("user_role") as string) || undefined}
|
||||
onValueChange={(value) => set("user_role", value)}
|
||||
placeholder="Select a role…"
|
||||
emptyText="No roles found"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team">
|
||||
<SearchSelect
|
||||
options={teamOptions}
|
||||
value={(get("team") as string) || undefined}
|
||||
onValueChange={(value) => set("team", value)}
|
||||
placeholder="Select a team…"
|
||||
emptyText="No teams found"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, IdentityCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
const SSO_ID_HINT =
|
||||
"SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null.";
|
||||
|
||||
const SCIM_INACTIVE_HINT = "Deactivated via SCIM (external identity provider). The user's virtual keys are blocked.";
|
||||
|
||||
function isScimInactive(user: UserInfo): boolean {
|
||||
return (user.metadata as Record<string, unknown> | null | undefined)?.scim_active === false;
|
||||
}
|
||||
|
||||
interface UserRowActionsProps {
|
||||
user: UserInfo;
|
||||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
}
|
||||
|
||||
function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open user actions"
|
||||
data-testid={`user-actions-${user.user_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onUserClick(user.user_id, true)} data-testid="user-action-edit">
|
||||
<Pencil />
|
||||
Edit user
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onResetPassword(user.user_id)} data-testid="user-action-reset-password">
|
||||
<KeyRound />
|
||||
Reset password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void copyToClipboard(user.user_id, "User ID copied")}
|
||||
data-testid="user-action-copy"
|
||||
>
|
||||
<Copy />
|
||||
Copy user ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDeleteUser(user)} data-testid="user-action-delete">
|
||||
<Trash2 />
|
||||
Delete user
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export interface UsersTableColumnsDeps {
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
includeSelection: boolean;
|
||||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const getUsersTableColumns = ({
|
||||
possibleUIRoles,
|
||||
includeSelection,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
}: UsersTableColumnsDeps): ColumnDef<UserInfo>[] => {
|
||||
const baseColumns: ColumnDef<UserInfo>[] = [
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
meta: { title: "User ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="User ID" variant="header-cycle" />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.user_id}
|
||||
titleClassName="font-mono text-xs text-primary"
|
||||
onClick={() => onUserClick(row.original.user_id, false)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user_email",
|
||||
meta: { title: "Email" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Email" variant="header-cycle" />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-60 truncate text-sm" title={row.original.user_email ?? undefined}>
|
||||
{row.original.user_email || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
meta: { title: "Status", skeleton: "badge" },
|
||||
header: "Status",
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (isScimInactive(row.original)) {
|
||||
return (
|
||||
<StatusBadge
|
||||
tone="error"
|
||||
label="Inactive"
|
||||
tooltip={SCIM_INACTIVE_HINT}
|
||||
dataTestId={`user-status-${row.original.user_id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <StatusBadge tone="success" label="Active" dataTestId={`user-status-${row.original.user_id}`} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_role",
|
||||
accessorKey: "user_role",
|
||||
meta: { title: "Global Proxy Role" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Global Proxy Role" variant="header-cycle" />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <span className="text-sm">{possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}</span>,
|
||||
},
|
||||
{
|
||||
id: "user_alias",
|
||||
accessorKey: "user_alias",
|
||||
meta: { title: "User Alias" },
|
||||
header: "User Alias",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-40 truncate text-sm" title={row.original.user_alias ?? undefined}>
|
||||
{row.original.user_alias || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" variant="header-cycle" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} emptyText="Unlimited" showZero />,
|
||||
},
|
||||
{
|
||||
id: "sso_user_id",
|
||||
accessorKey: "sso_user_id",
|
||||
meta: { title: "SSO ID" },
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1.5">
|
||||
SSO ID
|
||||
<CellTooltip
|
||||
content={SSO_ID_HINT}
|
||||
trigger={<Info className="size-3.5 shrink-0 text-muted-foreground" aria-label="About SSO ID" />}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-40 truncate font-mono text-xs" title={row.original.sso_user_id ?? undefined}>
|
||||
{row.original.sso_user_id ?? "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key_count",
|
||||
accessorKey: "key_count",
|
||||
meta: { title: "Virtual Keys", skeleton: "badge" },
|
||||
header: "Virtual Keys",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const keyCount = row.original.key_count;
|
||||
if (keyCount > 0) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="whitespace-nowrap border-indigo-200 bg-indigo-50 font-normal text-indigo-600"
|
||||
>
|
||||
{keyCount} {keyCount === 1 ? "Key" : "Keys"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="whitespace-nowrap border-gray-200 bg-gray-50 font-normal text-gray-600">
|
||||
No Keys
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated At" },
|
||||
header: "Updated At",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { title: "Actions", className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 60,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<UserRowActions
|
||||
user={row.original}
|
||||
onUserClick={onUserClick}
|
||||
onDeleteUser={onDeleteUser}
|
||||
onResetPassword={onResetPassword}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!includeSelection) {
|
||||
return baseColumns;
|
||||
}
|
||||
|
||||
return [
|
||||
createSelectionColumn<UserInfo>({
|
||||
rowAriaLabel: (row) => `Select ${row.original.user_email || row.original.user_id}`,
|
||||
}),
|
||||
...baseColumns,
|
||||
];
|
||||
};
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Grid, Icon } from "@tremor/react";
|
||||
import { Tooltip, Checkbox, Tag } from "antd";
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
|
||||
interface SelectionOptions {
|
||||
selectedUsers: UserInfo[];
|
||||
onSelectUser: (user: UserInfo, isSelected: boolean) => void;
|
||||
onSelectAll: (isSelected: boolean) => void;
|
||||
isUserSelected: (user: UserInfo) => boolean;
|
||||
isAllSelected: boolean;
|
||||
isIndeterminate: boolean;
|
||||
}
|
||||
|
||||
export const columns = (
|
||||
possibleUIRoles: Record<string, Record<string, string>>,
|
||||
handleEdit: (user: UserInfo) => void,
|
||||
handleDelete: (user: UserInfo) => void,
|
||||
handleResetPassword: (userId: string) => void,
|
||||
handleUserClick: (userId: string, openInEditMode?: boolean) => void,
|
||||
selectionOptions?: SelectionOptions,
|
||||
): ColumnDef<UserInfo>[] => {
|
||||
// Backend sortable columns: user_id, user_email, created_at, spend, user_alias, user_role
|
||||
const baseColumns: ColumnDef<UserInfo>[] = [
|
||||
{
|
||||
header: "User ID",
|
||||
accessorKey: "user_id",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <IdCell value={row.original.user_id} variant="plain" copyable />,
|
||||
},
|
||||
{
|
||||
header: "Email",
|
||||
accessorKey: "user_email",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.user_email || "-"}</span>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isScimInactive =
|
||||
(row.original.metadata as Record<string, unknown> | null | undefined)?.scim_active === false;
|
||||
if (isScimInactive) {
|
||||
return (
|
||||
<Tooltip title="Deactivated via SCIM (external identity provider). The user's virtual keys are blocked.">
|
||||
<Tag color="red" data-testid={`user-status-${row.original.user_id}`}>
|
||||
Inactive
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag color="green" data-testid={`user-status-${row.original.user_id}`}>
|
||||
Active
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Global Proxy Role",
|
||||
accessorKey: "user_role",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <span className="text-xs">{possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}</span>,
|
||||
},
|
||||
{
|
||||
header: "User Alias",
|
||||
accessorKey: "user_alias",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.user_alias || "-"}</span>,
|
||||
},
|
||||
{
|
||||
header: "Spend (USD)",
|
||||
accessorKey: "spend",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
header: "Budget (USD)",
|
||||
accessorKey: "max_budget",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} emptyText="Unlimited" showZero />,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>SSO ID</span>
|
||||
<Tooltip title="SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null.">
|
||||
<InformationCircleIcon className="w-4 h-4" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: "sso_user_id",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.sso_user_id !== null ? row.original.sso_user_id : "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Virtual Keys",
|
||||
accessorKey: "key_count",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Grid numItems={2}>
|
||||
{row.original.key_count > 0 ? (
|
||||
<Badge size="xs" color="indigo">
|
||||
{row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" color="gray">
|
||||
No Keys
|
||||
</Badge>
|
||||
)}
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Tooltip title="Edit user details">
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleUserClick(row.original.user_id, true)}
|
||||
className="cursor-pointer hover:text-blue-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete user">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => handleDelete(row.original)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Reset Password">
|
||||
<Icon
|
||||
icon={RefreshIcon}
|
||||
size="sm"
|
||||
onClick={() => handleResetPassword(row.original.user_id)}
|
||||
className="cursor-pointer hover:text-green-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Add selection column if selection is enabled
|
||||
if (selectionOptions) {
|
||||
const { onSelectUser, onSelectAll, isUserSelected, isAllSelected, isIndeterminate } = selectionOptions;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
enableSorting: false,
|
||||
header: () => (
|
||||
<Checkbox
|
||||
indeterminate={isIndeterminate}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={isUserSelected(row.original)}
|
||||
onChange={(e) => onSelectUser(row.original, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...baseColumns,
|
||||
];
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
};
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { columns } from "./columns";
|
||||
import { UserDataTable } from "./table";
|
||||
import { UserInfo } from "@/components/networking";
|
||||
|
||||
const defaultFilters = {
|
||||
email: "",
|
||||
user_id: "",
|
||||
user_role: "",
|
||||
sso_user_id: "",
|
||||
team: "",
|
||||
model: "",
|
||||
min_spend: null,
|
||||
max_spend: null,
|
||||
sort_by: "",
|
||||
sort_order: "asc" as const,
|
||||
};
|
||||
|
||||
const getDefaultProps = () => ({
|
||||
data: [] as any[],
|
||||
columns: [] as any[],
|
||||
accessToken: null,
|
||||
userRole: "Admin",
|
||||
possibleUIRoles: null as Record<string, Record<string, string>> | null,
|
||||
filters: defaultFilters,
|
||||
updateFilters: vi.fn(),
|
||||
initialFilters: defaultFilters,
|
||||
teams: [] as any[],
|
||||
handleEdit: vi.fn(),
|
||||
handleDelete: vi.fn(),
|
||||
handleResetPassword: vi.fn(),
|
||||
userListResponse: { users: [], total: 0, page: 1, page_size: 25, total_pages: 1 },
|
||||
currentPage: 1,
|
||||
handlePageChange: vi.fn(),
|
||||
});
|
||||
|
||||
describe("UserDataTable", () => {
|
||||
it("should render the UserDataTable component", () => {
|
||||
render(<UserDataTable {...getDefaultProps()} />);
|
||||
|
||||
expect(screen.getByText("Filters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onSortChange when clicking a sortable header", () => {
|
||||
const filters = {
|
||||
...defaultFilters,
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc" as const,
|
||||
};
|
||||
|
||||
const onSortChange = vi.fn();
|
||||
|
||||
const possibleUIRoles = {
|
||||
admin: { ui_label: "Admin" },
|
||||
user: { ui_label: "User" },
|
||||
};
|
||||
|
||||
render(
|
||||
<UserDataTable
|
||||
{...getDefaultProps()}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
filters={filters}
|
||||
initialFilters={filters}
|
||||
onSortChange={onSortChange}
|
||||
currentSort={{ sortBy: filters.sort_by, sortOrder: filters.sort_order }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailHeader = screen.getByRole("columnheader", { name: /email/i });
|
||||
act(() => {
|
||||
fireEvent.click(emailHeader);
|
||||
});
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledWith("user_email", "desc");
|
||||
});
|
||||
|
||||
it("should show skeleton loaders when isLoading is true", () => {
|
||||
render(<UserDataTable {...getDefaultProps()} isLoading={true} />);
|
||||
|
||||
expect(screen.queryByText(/Showing/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Previous/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Next/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show actual content when isLoading is false", () => {
|
||||
render(<UserDataTable {...getDefaultProps()} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText(/Showing/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Next/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all column headers", () => {
|
||||
const possibleUIRoles = {
|
||||
admin: { ui_label: "Admin" },
|
||||
user: { ui_label: "User" },
|
||||
};
|
||||
|
||||
render(<UserDataTable {...getDefaultProps()} possibleUIRoles={possibleUIRoles} />);
|
||||
|
||||
[
|
||||
"User ID",
|
||||
"Email",
|
||||
"Status",
|
||||
"Global Proxy Role",
|
||||
"User Alias",
|
||||
"Spend (USD)",
|
||||
"Budget (USD)",
|
||||
"SSO ID",
|
||||
"Virtual Keys",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
"Actions",
|
||||
].forEach((header) => {
|
||||
expect(screen.getByRole("columnheader", { name: header })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the user-row Status cell as Active when scim_active is not set to false", () => {
|
||||
const possibleUIRoles = { admin: { ui_label: "Admin" } };
|
||||
const handlers = { edit: vi.fn(), del: vi.fn(), reset: vi.fn(), click: vi.fn() };
|
||||
const cols = columns(possibleUIRoles, handlers.edit, handlers.del, handlers.reset, handlers.click);
|
||||
const statusCol = cols.find((c) => (c as { id?: string }).id === "status");
|
||||
expect(statusCol).toBeDefined();
|
||||
|
||||
const baseUser: UserInfo = {
|
||||
user_id: "u-active",
|
||||
user_email: "active@example.com",
|
||||
user_alias: null,
|
||||
user_role: "admin",
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
key_count: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
sso_user_id: null,
|
||||
budget_duration: null,
|
||||
};
|
||||
|
||||
const cellNoMetadata = (statusCol as any).cell({ row: { original: baseUser } });
|
||||
render(<>{cellNoMetadata}</>);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Inactive")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the user-row Status cell as Inactive when scim_active is false", () => {
|
||||
const possibleUIRoles = { admin: { ui_label: "Admin" } };
|
||||
const cols = columns(possibleUIRoles, vi.fn(), vi.fn(), vi.fn(), vi.fn());
|
||||
const statusCol = cols.find((c) => (c as { id?: string }).id === "status")!;
|
||||
|
||||
const inactiveUser: UserInfo = {
|
||||
user_id: "u-inactive",
|
||||
user_email: "alex@acme.io",
|
||||
user_alias: null,
|
||||
user_role: "internal_user",
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
key_count: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
sso_user_id: null,
|
||||
budget_duration: null,
|
||||
metadata: { scim_active: false },
|
||||
};
|
||||
|
||||
const cell = (statusCol as any).cell({ row: { original: inactiveUser } });
|
||||
render(<>{cell}</>);
|
||||
expect(screen.getByText("Inactive")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Active")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should treat scim_active=true as Active (not Inactive)", () => {
|
||||
const possibleUIRoles = { admin: { ui_label: "Admin" } };
|
||||
const cols = columns(possibleUIRoles, vi.fn(), vi.fn(), vi.fn(), vi.fn());
|
||||
const statusCol = cols.find((c) => (c as { id?: string }).id === "status")!;
|
||||
|
||||
const reactivated: UserInfo = {
|
||||
user_id: "u-rehired",
|
||||
user_email: "alex@acme.io",
|
||||
user_alias: null,
|
||||
user_role: "internal_user",
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
key_count: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
sso_user_id: null,
|
||||
budget_duration: null,
|
||||
metadata: { scim_active: true },
|
||||
};
|
||||
|
||||
const cell = (statusCol as any).cell({ row: { original: reactivated } });
|
||||
render(<>{cell}</>);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Inactive")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,442 +0,0 @@
|
|||
import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Select, SelectItem } from "@tremor/react";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Skeleton } from "antd";
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import UserInfoView from "./user_info_view";
|
||||
import { columns as createColumns } from "./columns";
|
||||
import { FilterInput } from "@/components/common_components/Filters/FilterInput";
|
||||
import { FiltersButton } from "@/components/common_components/Filters/FiltersButton";
|
||||
import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton";
|
||||
import { Search, User, CircleUserRound } from "lucide-react";
|
||||
|
||||
interface FilterState {
|
||||
email: string;
|
||||
user_id: string;
|
||||
user_role: string;
|
||||
sso_user_id: string;
|
||||
team: string;
|
||||
model: string;
|
||||
min_spend: number | null;
|
||||
max_spend: number | null;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface UserDataTableProps {
|
||||
data: UserInfo[];
|
||||
columns: ColumnDef<UserInfo, any>[];
|
||||
isLoading?: boolean;
|
||||
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||||
currentSort?: {
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
};
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
handleEdit: (user: UserInfo) => void;
|
||||
handleDelete: (user: UserInfo) => void;
|
||||
handleResetPassword: (userId: string) => void;
|
||||
selectedUsers?: UserInfo[];
|
||||
onSelectionChange?: (selectedUsers: UserInfo[]) => void;
|
||||
enableSelection?: boolean;
|
||||
// Filter-related props
|
||||
filters: FilterState;
|
||||
updateFilters: (update: Partial<FilterState>) => void;
|
||||
initialFilters: FilterState;
|
||||
teams: any[] | null;
|
||||
// Pagination props
|
||||
userListResponse: any;
|
||||
currentPage: number;
|
||||
handlePageChange: (newPage: number) => void;
|
||||
}
|
||||
|
||||
export function UserDataTable({
|
||||
data = [],
|
||||
columns: originalColumns,
|
||||
isLoading = false,
|
||||
onSortChange,
|
||||
currentSort,
|
||||
accessToken,
|
||||
userRole,
|
||||
possibleUIRoles,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
selectedUsers = [],
|
||||
onSelectionChange,
|
||||
enableSelection = false,
|
||||
filters,
|
||||
updateFilters,
|
||||
initialFilters,
|
||||
teams,
|
||||
userListResponse,
|
||||
currentPage,
|
||||
handlePageChange,
|
||||
}: UserDataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([
|
||||
{
|
||||
id: currentSort?.sortBy || "created_at",
|
||||
desc: currentSort?.sortOrder === "desc",
|
||||
},
|
||||
]);
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null);
|
||||
const [openInEditMode, setOpenInEditMode] = React.useState<boolean>(false);
|
||||
const [showFilters, setShowFilters] = React.useState<boolean>(false);
|
||||
|
||||
const handleUserClick = (userId: string, openInEditMode: boolean = false) => {
|
||||
setSelectedUserId(userId);
|
||||
setOpenInEditMode(openInEditMode);
|
||||
};
|
||||
|
||||
const handleCloseUserInfo = () => {
|
||||
setSelectedUserId(null);
|
||||
setOpenInEditMode(false);
|
||||
};
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectUser = (user: UserInfo, isSelected: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
if (isSelected) {
|
||||
onSelectionChange([...selectedUsers, user]);
|
||||
} else {
|
||||
onSelectionChange(selectedUsers.filter((u) => u.user_id !== user.user_id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (isSelected: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
if (isSelected) {
|
||||
onSelectionChange(data);
|
||||
} else {
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const isUserSelected = (user: UserInfo) => {
|
||||
return selectedUsers.some((u) => u.user_id === user.user_id);
|
||||
};
|
||||
|
||||
const isAllSelected = data.length > 0 && selectedUsers.length === data.length;
|
||||
const isIndeterminate = selectedUsers.length > 0 && selectedUsers.length < data.length;
|
||||
|
||||
// Create columns with the handleUserClick function
|
||||
const columns = React.useMemo(() => {
|
||||
if (possibleUIRoles) {
|
||||
return createColumns(
|
||||
possibleUIRoles,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
handleUserClick,
|
||||
enableSelection
|
||||
? {
|
||||
selectedUsers,
|
||||
onSelectUser: handleSelectUser,
|
||||
onSelectAll: handleSelectAll,
|
||||
isUserSelected,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
return originalColumns;
|
||||
}, [
|
||||
possibleUIRoles,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
handleUserClick,
|
||||
originalColumns,
|
||||
enableSelection,
|
||||
selectedUsers,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: (updaterOrValue: any) => {
|
||||
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
||||
setSorting(newSorting);
|
||||
if (newSorting && Array.isArray(newSorting) && newSorting.length > 0 && newSorting[0]) {
|
||||
const sortState = newSorting[0];
|
||||
if (sortState.id) {
|
||||
const sortBy = sortState.id;
|
||||
const sortOrder = sortState.desc ? "desc" : "asc";
|
||||
onSortChange?.(sortBy, sortOrder);
|
||||
}
|
||||
} else {
|
||||
// Reset to default sort when no sorting is selected
|
||||
onSortChange?.("created_at", "desc");
|
||||
}
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
enableSorting: true,
|
||||
});
|
||||
|
||||
// Update local sorting state when currentSort prop changes
|
||||
React.useEffect(() => {
|
||||
if (currentSort) {
|
||||
setSorting([
|
||||
{
|
||||
id: currentSort.sortBy,
|
||||
desc: currentSort.sortOrder === "desc",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [currentSort]);
|
||||
|
||||
if (selectedUserId) {
|
||||
return (
|
||||
<UserInfoView
|
||||
userId={selectedUserId}
|
||||
onClose={handleCloseUserInfo}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
initialTab={openInEditMode ? 1 : 0}
|
||||
startInEditMode={openInEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
{/* Filter Section */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Email Search */}
|
||||
<FilterInput
|
||||
placeholder="Search by email..."
|
||||
value={filters.email}
|
||||
onChange={(value) => updateFilters({ email: value })}
|
||||
icon={Search}
|
||||
/>
|
||||
|
||||
{/* Filter Button */}
|
||||
<FiltersButton
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
active={showFilters}
|
||||
hasActiveFilters={!!(filters.user_id || filters.user_role || filters.team)}
|
||||
/>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
<ResetFiltersButton
|
||||
onClick={() => {
|
||||
updateFilters(initialFilters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
{/* User ID Search */}
|
||||
<FilterInput
|
||||
placeholder="Filter by User ID"
|
||||
value={filters.user_id}
|
||||
onChange={(value) => updateFilters({ user_id: value })}
|
||||
icon={User}
|
||||
/>
|
||||
|
||||
<FilterInput
|
||||
placeholder="Filter by SSO ID"
|
||||
value={filters.sso_user_id}
|
||||
onChange={(value) => updateFilters({ sso_user_id: value })}
|
||||
icon={CircleUserRound}
|
||||
/>
|
||||
|
||||
{/* Role Dropdown */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
value={filters.user_role}
|
||||
onValueChange={(value) => updateFilters({ user_role: value })}
|
||||
placeholder="Select Role"
|
||||
>
|
||||
{possibleUIRoles &&
|
||||
Object.entries(possibleUIRoles).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value.ui_label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Team Dropdown */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
value={filters.team}
|
||||
onValueChange={(value) => updateFilters({ team: value })}
|
||||
placeholder="Select Team"
|
||||
>
|
||||
{teams?.map((team) => (
|
||||
<SelectItem key={team.team_id} value={team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Count and Pagination */}
|
||||
<div className="flex justify-between items-center">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active style={{ width: 192, height: 20 }} />
|
||||
) : (
|
||||
<span className="text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
{userListResponse && userListResponse.users && userListResponse.users.length > 0
|
||||
? (userListResponse.page - 1) * userListResponse.page_size + 1
|
||||
: 0}{" "}
|
||||
-{" "}
|
||||
{userListResponse && userListResponse.users
|
||||
? Math.min(userListResponse.page * userListResponse.page_size, userListResponse.total)
|
||||
: 0}{" "}
|
||||
of {userListResponse ? userListResponse.total : 0} results
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Pagination Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton.Button active size="small" style={{ width: 80, height: 30 }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 60, height: 30 }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!userListResponse || currentPage >= userListResponse.total_pages}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
!userListResponse || currentPage >= userListResponse.total_pages
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className="overflow-auto">
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 ${
|
||||
header.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
: ""
|
||||
} ${header.column.getCanSort() ? "cursor-pointer hover:bg-gray-50" : ""}`}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{header.id !== "actions" && header.column.getCanSort() && (
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading users...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
|
||||
cell.column.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (cell.column.id === "user_id") {
|
||||
handleUserClick(cell.getValue() as string, false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: cell.column.id === "user_id" ? "pointer" : "default",
|
||||
color: cell.column.id === "user_id" ? "#3b82f6" : "inherit",
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,553 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import FilterComponent, { FilterOption } from "./molecules/filter";
|
||||
import { MetricCard } from "./GuardrailsMonitor/MetricCard";
|
||||
import { PolicySelect, INPUT_POLICY_OPTIONS, OUTPUT_POLICY_OPTIONS } from "./ToolPolicies/PolicySelect";
|
||||
import { fetchToolsList, updateToolPolicy, ToolRow } from "./networking";
|
||||
|
||||
function getUTCDateKey(date: Date): string {
|
||||
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function isCreatedInUTCDay(createdAt: string | undefined, utcDateKey: string): boolean {
|
||||
if (!createdAt) return false;
|
||||
try {
|
||||
const d = new Date(createdAt);
|
||||
return getUTCDateKey(d) === utcDateKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number {
|
||||
return tools.filter((t) => isCreatedInUTCDay(t.created_at, utcDateKey)).length;
|
||||
}
|
||||
|
||||
function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined {
|
||||
const diff = newToday - newYesterday;
|
||||
if (diff === 0) return undefined;
|
||||
if (diff > 0) return `+${diff} since yesterday`;
|
||||
return `${diff} since yesterday`;
|
||||
}
|
||||
|
||||
type SortField = "tool_name" | "input_policy" | "output_policy" | "team_id" | "key_alias" | "created_at" | "call_count";
|
||||
|
||||
interface FilterValues {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface ToolPoliciesProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
onSelectTool?: (toolName: string) => void;
|
||||
}
|
||||
|
||||
export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelectTool }) => {
|
||||
const [tools, setTools] = useState<ToolRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingInput, setSavingInput] = useState<string | null>(null);
|
||||
const [savingOutput, setSavingOutput] = useState<string | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLiveTail, setIsLiveTail] = useState(true);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterValues>({});
|
||||
const pageSize = 50;
|
||||
|
||||
const isFetchingDeferred = useDeferredValue(isFetching);
|
||||
const isButtonLoading = isFetching || isFetchingDeferred;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setIsFetching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = await fetchToolsList(accessToken);
|
||||
setTools(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Failed to load tools");
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLiveTail) return;
|
||||
const id = setInterval(load, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, [isLiveTail, load]);
|
||||
|
||||
const handleInputPolicyChange = async (toolName: string, newPolicy: string) => {
|
||||
if (!accessToken) return;
|
||||
setSavingInput(toolName);
|
||||
try {
|
||||
await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy });
|
||||
setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, input_policy: newPolicy } : t)));
|
||||
} catch (e: any) {
|
||||
alert(`Failed to update input policy: ${e.message}`);
|
||||
} finally {
|
||||
setSavingInput(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOutputPolicyChange = async (toolName: string, newPolicy: string) => {
|
||||
if (!accessToken) return;
|
||||
setSavingOutput(toolName);
|
||||
try {
|
||||
await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy });
|
||||
setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, output_policy: newPolicy } : t)));
|
||||
} catch (e: any) {
|
||||
alert(`Failed to update output policy: ${e.message}`);
|
||||
} finally {
|
||||
setSavingOutput(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (field: SortField, newState: SortState) => {
|
||||
if (newState === false) {
|
||||
setSortField("created_at");
|
||||
setSortOrder("desc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder(newState);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleApplyFilters = (filters: FilterValues) => {
|
||||
setActiveFilters(filters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setActiveFilters({});
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const teamOptions = Array.from(new Set(tools.map((t) => t.team_id).filter(Boolean))).map((v) => ({
|
||||
label: v as string,
|
||||
value: v as string,
|
||||
}));
|
||||
const keyAliasOptions = Array.from(new Set(tools.map((t) => t.key_alias).filter(Boolean))).map((v) => ({
|
||||
label: v as string,
|
||||
value: v as string,
|
||||
}));
|
||||
|
||||
const filterOptions: FilterOption[] = [
|
||||
{
|
||||
name: "Input Policy",
|
||||
label: "Input Policy",
|
||||
options: INPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })),
|
||||
},
|
||||
{
|
||||
name: "Output Policy",
|
||||
label: "Output Policy",
|
||||
options: OUTPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })),
|
||||
},
|
||||
{
|
||||
name: "Team Name",
|
||||
label: "Team Name",
|
||||
options: teamOptions,
|
||||
},
|
||||
{
|
||||
name: "Key Name",
|
||||
label: "Key Name",
|
||||
options: keyAliasOptions,
|
||||
},
|
||||
];
|
||||
|
||||
const { newToday, newYesterday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } =
|
||||
useMemo(() => {
|
||||
const now = new Date();
|
||||
const todayKey = getUTCDateKey(now);
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const yesterdayKey = getUTCDateKey(yesterday);
|
||||
|
||||
const newToday = countToolsInUTCDay(tools, todayKey);
|
||||
const newYesterday = countToolsInUTCDay(tools, yesterdayKey);
|
||||
const trendSubtitle = getTrendSubtitle(newToday, newYesterday);
|
||||
|
||||
const totalTools = tools.length;
|
||||
const blockedCount = tools.filter((t) => t.input_policy === "blocked").length;
|
||||
const activeTeamsCount = new Set(tools.map((t) => t.team_id).filter(Boolean)).size;
|
||||
|
||||
const needsReviewTools = tools.filter(
|
||||
(t) => isCreatedInUTCDay(t.created_at, todayKey) && t.input_policy === "untrusted",
|
||||
);
|
||||
|
||||
return {
|
||||
newToday,
|
||||
newYesterday,
|
||||
trendSubtitle,
|
||||
totalTools,
|
||||
blockedCount,
|
||||
activeTeamsCount,
|
||||
needsReviewTools,
|
||||
};
|
||||
}, [tools]);
|
||||
|
||||
const SortHeader = ({ label, field }: { label: string; field: SortField }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{label}</span>
|
||||
<TableHeaderSortDropdown
|
||||
sortState={sortField === field ? sortOrder : false}
|
||||
onSortChange={(s) => handleSortChange(field, s)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filtered = tools.filter((t) => {
|
||||
if (searchTerm) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
t.tool_name.toLowerCase().includes(q) ||
|
||||
(t.team_id ?? "").toLowerCase().includes(q) ||
|
||||
(t.key_alias ?? "").toLowerCase().includes(q) ||
|
||||
(t.key_hash ?? "").toLowerCase().includes(q) ||
|
||||
t.input_policy.toLowerCase().includes(q) ||
|
||||
t.output_policy.toLowerCase().includes(q);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
if (activeFilters["Input Policy"] && t.input_policy !== activeFilters["Input Policy"]) return false;
|
||||
if (activeFilters["Output Policy"] && t.output_policy !== activeFilters["Output Policy"]) return false;
|
||||
if (activeFilters["Team Name"] && t.team_id !== activeFilters["Team Name"]) return false;
|
||||
if (activeFilters["Key Name"] && t.key_alias !== activeFilters["Key Name"]) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const av = (a as any)[sortField] ?? "";
|
||||
const bv = (b as any)[sortField] ?? "";
|
||||
if (av < bv) return sortOrder === "desc" ? 1 : -1;
|
||||
if (av > bv) return sortOrder === "desc" ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paginated = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const scrollToToolRow = (toolId: string) => {
|
||||
const idx = sorted.findIndex((t) => t.tool_id === toolId);
|
||||
if (idx >= 0) {
|
||||
const page = Math.floor(idx / pageSize) + 1;
|
||||
if (page !== currentPage) setCurrentPage(page);
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
document.getElementById(`tool-row-${toolId}`)?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
label="New Today"
|
||||
value={newToday}
|
||||
valueColor="text-green-600"
|
||||
subtitle={trendSubtitle}
|
||||
icon={
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard label="Total Tools Discovered" value={totalTools} />
|
||||
<MetricCard
|
||||
label="Blocked Tools"
|
||||
value={blockedCount}
|
||||
valueColor={blockedCount > 0 ? "text-red-600" : undefined}
|
||||
/>
|
||||
<MetricCard label="Active Teams" value={activeTeamsCount > 0 ? activeTeamsCount : "—"} />
|
||||
</div>
|
||||
|
||||
{needsReviewTools.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold text-amber-900 mb-1">Needs Review</h2>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
{needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy
|
||||
decisions.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{needsReviewTools.map((t) => (
|
||||
<span
|
||||
key={t.tool_id}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-white border border-amber-200 rounded-md text-sm"
|
||||
>
|
||||
<span className="font-mono text-amber-900 truncate max-w-[200px]" title={t.tool_name}>
|
||||
{t.tool_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToToolRow(t.tool_id)}
|
||||
className="text-amber-700 hover:text-amber-900 font-medium text-xs whitespace-nowrap"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full box-border">
|
||||
<div className="border-b px-6 py-4 w-full max-w-full box-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0 w-full max-w-full box-border">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Tool Name"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">Live Tail</span>
|
||||
<Switch checked={isLiveTail} onChange={setIsLiveTail} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={isButtonLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${isButtonLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{isButtonLoading ? "Fetching" : "Fetch"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 whitespace-nowrap">
|
||||
<span>
|
||||
Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "}
|
||||
{Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results
|
||||
</span>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<FilterComponent
|
||||
options={filterOptions}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onResetFilters={handleResetFilters}
|
||||
buttonLabel="Filters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLiveTail && (
|
||||
<div className="bg-green-50 border-b border-green-100 px-6 py-2 flex items-center justify-between">
|
||||
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
|
||||
<button onClick={() => setIsLiveTail(false)} className="text-xs text-green-600 underline">
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-sm text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1 w-full">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Discovered" field="created_at" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Tool Name" field="tool_name" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Input Policy" field="input_policy" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Output Policy" field="output_policy" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="# Calls" field="call_count" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Team Name" field="team_id" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Key Hash</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Key Name" field="key_alias" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">User Agent</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-8 text-center text-gray-500">
|
||||
Loading tools…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginated.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-8 text-center text-gray-500">
|
||||
No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginated.map((tool) => (
|
||||
<TableRow key={tool.tool_id} id={`tool-row-${tool.tool_id}`} className="h-8 hover:bg-gray-50">
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<DateCell value={tool.created_at} />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectTool?.(tool.tool_name)}
|
||||
className="text-left w-full font-mono text-xs max-w-[20ch] truncate block font-medium text-blue-600 hover:text-blue-800 hover:underline focus:outline-hidden focus:ring-0"
|
||||
>
|
||||
<Tooltip title={onSelectTool ? "Click to view details and block for team/key" : tool.tool_name}>
|
||||
<span>{tool.tool_name}</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8">
|
||||
<PolicySelect
|
||||
value={tool.input_policy}
|
||||
toolName={tool.tool_name}
|
||||
saving={savingInput === tool.tool_name}
|
||||
onChange={handleInputPolicyChange}
|
||||
policyType="input"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8">
|
||||
<PolicySelect
|
||||
value={tool.output_policy}
|
||||
toolName={tool.tool_name}
|
||||
saving={savingOutput === tool.tool_name}
|
||||
onChange={handleOutputPolicyChange}
|
||||
policyType="output"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8">
|
||||
<div className="flex items-center justify-end h-8 tabular-nums text-sm font-mono text-gray-700">
|
||||
{(tool.call_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<IdCell value={tool.team_id} variant="plain" />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<IdCell value={tool.key_hash} />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.key_alias ?? "-"}>
|
||||
<span className="max-w-[15ch] truncate block">{tool.key_alias ?? "-"}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.user_agent ?? "-"}>
|
||||
<span className="font-mono max-w-[20ch] truncate block text-xs text-gray-500">
|
||||
{tool.user_agent ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>
|
||||
Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "}
|
||||
{sorted.length}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolPolicies;
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
||||
import type { ToolRow } from "@/components/networking";
|
||||
import { ToolPoliciesPanel } from "./ToolPoliciesPanel";
|
||||
|
||||
const fetchToolsList = vi.fn();
|
||||
const updateToolPolicy = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchToolsList: (...args: unknown[]) => fetchToolsList(...args),
|
||||
updateToolPolicy: (...args: unknown[]) => updateToolPolicy(...args),
|
||||
}));
|
||||
|
||||
const fromBackend = vi.fn();
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: { fromBackend: (...args: unknown[]) => fromBackend(...args) },
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-07-21T12:00:00Z");
|
||||
|
||||
const TOOLS: ToolRow[] = [
|
||||
{
|
||||
tool_id: "tool-1",
|
||||
tool_name: "get_weather",
|
||||
input_policy: "untrusted",
|
||||
output_policy: "untrusted",
|
||||
call_count: 12,
|
||||
team_id: "team-alpha",
|
||||
key_alias: "prod-key",
|
||||
key_hash: "hash-aaa",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: "2026-07-21T10:00:00Z",
|
||||
},
|
||||
{
|
||||
tool_id: "tool-2",
|
||||
tool_name: "search_web",
|
||||
input_policy: "trusted",
|
||||
output_policy: "trusted",
|
||||
call_count: 5,
|
||||
team_id: "team-beta",
|
||||
key_alias: "dev-key",
|
||||
key_hash: "hash-bbb",
|
||||
created_at: "2026-07-20T10:00:00Z",
|
||||
},
|
||||
{
|
||||
tool_id: "tool-3",
|
||||
tool_name: "delete_file",
|
||||
input_policy: "blocked",
|
||||
output_policy: "untrusted",
|
||||
call_count: 100,
|
||||
key_hash: "hash-ccc",
|
||||
created_at: "2026-07-19T10:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const row = (toolId: string): HTMLElement => {
|
||||
const element = document.querySelector(`[data-row-id="${toolId}"]`);
|
||||
if (element === null) throw new Error(`row ${toolId} is not rendered`);
|
||||
return element as HTMLElement;
|
||||
};
|
||||
|
||||
const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement =>
|
||||
within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1];
|
||||
|
||||
/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */
|
||||
const policyValue = (toolId: string, kind: "input" | "output"): string =>
|
||||
policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? "";
|
||||
|
||||
const isSaving = (toolId: string, kind: "input" | "output"): boolean =>
|
||||
policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false;
|
||||
|
||||
const chooseOption = async (user: ReturnType<typeof userEvent.setup>, trigger: HTMLElement, label: string) => {
|
||||
await user.click(trigger);
|
||||
const option = await waitFor(() => {
|
||||
const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find(
|
||||
(element) => element.textContent === label,
|
||||
);
|
||||
if (match === undefined) throw new Error(`option ${label} not open`);
|
||||
return match as HTMLElement;
|
||||
});
|
||||
await user.click(option);
|
||||
};
|
||||
|
||||
const renderPanel = (onSelectTool = vi.fn()) =>
|
||||
renderWithProviders(<ToolPoliciesPanel accessToken="sk-token" onSelectTool={onSelectTool} />);
|
||||
|
||||
const waitForRows = () => waitFor(() => expect(document.querySelector('[data-row-id="tool-1"]')).not.toBeNull());
|
||||
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(NOW);
|
||||
fetchToolsList.mockReset().mockResolvedValue(TOOLS);
|
||||
updateToolPolicy.mockReset().mockResolvedValue({});
|
||||
fromBackend.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ToolPoliciesPanel data loading", () => {
|
||||
it("should load tools once and never auto-refresh on a timer", async () => {
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(fetchToolsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not refetch when the window regains focus", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ToolPoliciesPanel accessToken="sk-token" onSelectTool={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitForRows();
|
||||
|
||||
await act(async () => {
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
});
|
||||
|
||||
expect(fetchToolsList).toHaveBeenCalledTimes(1);
|
||||
focusManager.setFocused(undefined);
|
||||
});
|
||||
|
||||
it("should refetch when the toolbar refresh action is used", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-refresh"));
|
||||
|
||||
await waitFor(() => expect(fetchToolsList).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("should keep rows visible during a refresh instead of falling back to skeletons", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
fetchToolsList.mockReturnValue(new Promise(() => {}));
|
||||
await user.click(screen.getByTestId("datatable-refresh"));
|
||||
|
||||
expect(row("tool-1")).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should resolve the loading skeleton when there is no access token", async () => {
|
||||
renderWithProviders(<ToolPoliciesPanel accessToken={null} onSelectTool={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0));
|
||||
expect(fetchToolsList).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("No tools discovered")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should surface a load failure without wedging the skeleton", async () => {
|
||||
fetchToolsList.mockRejectedValue(new Error("boom"));
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("boom");
|
||||
expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPoliciesPanel inline policy editing", () => {
|
||||
it("should patch the input policy and update that row in place", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await chooseOption(user, policySelect("tool-1", "input"), "trusted");
|
||||
|
||||
expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { input_policy: "trusted" });
|
||||
await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted"));
|
||||
expect(fetchToolsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should patch the output policy from the output column", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await chooseOption(user, policySelect("tool-1", "output"), "trusted");
|
||||
|
||||
expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { output_policy: "trusted" });
|
||||
});
|
||||
|
||||
it("should keep every in-flight row disabled when two rows are saved at once", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
updateToolPolicy.mockReturnValue(new Promise(() => {}));
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await chooseOption(user, policySelect("tool-1", "input"), "trusted");
|
||||
await chooseOption(user, policySelect("tool-2", "input"), "blocked");
|
||||
|
||||
expect(isSaving("tool-2", "input")).toBe(true);
|
||||
expect(isSaving("tool-1", "input")).toBe(true);
|
||||
});
|
||||
|
||||
it("should re-enable only the row whose save finished", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let finishFirst = () => {};
|
||||
updateToolPolicy
|
||||
.mockImplementationOnce(() => new Promise<void>((resolve) => (finishFirst = () => resolve())))
|
||||
.mockImplementationOnce(() => new Promise(() => {}));
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await chooseOption(user, policySelect("tool-1", "input"), "trusted");
|
||||
await chooseOption(user, policySelect("tool-2", "input"), "blocked");
|
||||
await act(async () => {
|
||||
finishFirst();
|
||||
});
|
||||
|
||||
expect(isSaving("tool-1", "input")).toBe(false);
|
||||
expect(isSaving("tool-2", "input")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not let an in-flight refresh clobber a policy that just saved", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let landStaleRefresh = () => {};
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
fetchToolsList.mockImplementationOnce(
|
||||
// resolves with the PRE-save snapshot, i.e. tool-1 still "untrusted"
|
||||
() => new Promise<ToolRow[]>((resolve) => (landStaleRefresh = () => resolve(TOOLS))),
|
||||
);
|
||||
await user.click(screen.getByTestId("datatable-refresh"));
|
||||
await chooseOption(user, policySelect("tool-1", "input"), "trusted");
|
||||
await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted"));
|
||||
|
||||
await act(async () => {
|
||||
landStaleRefresh();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(policyValue("tool-1", "input")).toBe("trusted");
|
||||
});
|
||||
|
||||
it("should leave the row untouched and report the failure when the patch is rejected", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
updateToolPolicy.mockRejectedValue(new Error("nope"));
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await chooseOption(user, policySelect("tool-1", "input"), "trusted");
|
||||
|
||||
await waitFor(() => expect(fromBackend).toHaveBeenCalledWith("Failed to update input policy: nope"));
|
||||
expect(policyValue("tool-1", "input")).toBe("untrusted");
|
||||
});
|
||||
|
||||
it("should disable only the one cell that is saving", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
updateToolPolicy.mockReturnValue(new Promise(() => {}));
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
await chooseOption(user, policySelect("tool-1", "input"), "trusted");
|
||||
|
||||
await waitFor(() => expect(isSaving("tool-1", "input")).toBe(true));
|
||||
expect(isSaving("tool-1", "output")).toBe(false);
|
||||
expect(isSaving("tool-2", "input")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPoliciesPanel header chrome", () => {
|
||||
it("should summarise the loaded tools in the metric cards", async () => {
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
const metric = (label: string): HTMLElement => {
|
||||
const card = screen.getByText(label).closest("div.h-full");
|
||||
if (card === null) throw new Error(`metric ${label} missing`);
|
||||
return card as HTMLElement;
|
||||
};
|
||||
|
||||
expect(metric("Total Tools Discovered")).toHaveTextContent("3");
|
||||
expect(metric("Blocked Tools")).toHaveTextContent("1");
|
||||
expect(metric("Active Teams")).toHaveTextContent("2");
|
||||
expect(metric("New Today")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("should list only today's untrusted tools for review and scroll to the row", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
const banner = screen.getByText("Needs Review").closest("div");
|
||||
if (banner === null) throw new Error("needs review banner missing");
|
||||
expect(banner).toHaveTextContent("1 new tool discovered");
|
||||
expect(within(banner as HTMLElement).queryByText("delete_file")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(banner as HTMLElement).getByRole("button", { name: "Review" }));
|
||||
|
||||
expect(row("tool-1").scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should hide the review banner when nothing needs a decision", async () => {
|
||||
fetchToolsList.mockResolvedValue([{ ...TOOLS[0], input_policy: "trusted" }]);
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
||||
expect(screen.queryByText("Needs Review")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { fetchToolsList, ToolRow, updateToolPolicy } from "@/components/networking";
|
||||
|
||||
import { ToolPoliciesTable } from "./ToolPoliciesTable";
|
||||
|
||||
function getUTCDateKey(date: Date): string {
|
||||
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function isCreatedInUTCDay(createdAt: string | undefined, utcDateKey: string): boolean {
|
||||
if (!createdAt) return false;
|
||||
try {
|
||||
return getUTCDateKey(new Date(createdAt)) === utcDateKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number {
|
||||
return tools.filter((tool) => isCreatedInUTCDay(tool.created_at, utcDateKey)).length;
|
||||
}
|
||||
|
||||
function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined {
|
||||
const diff = newToday - newYesterday;
|
||||
if (diff === 0) return undefined;
|
||||
return diff > 0 ? `+${diff} since yesterday` : `${diff} since yesterday`;
|
||||
}
|
||||
|
||||
function toMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
const withTool = (names: ReadonlySet<string>, toolName: string): ReadonlySet<string> => new Set([...names, toolName]);
|
||||
|
||||
const withoutTool = (names: ReadonlySet<string>, toolName: string): ReadonlySet<string> =>
|
||||
new Set([...names].filter((name) => name !== toolName));
|
||||
|
||||
const TOOLS_QUERY_KEY = "tool-policies";
|
||||
|
||||
interface ToolPoliciesPanelProps {
|
||||
accessToken: string | null;
|
||||
onSelectTool: (toolName: string) => void;
|
||||
}
|
||||
|
||||
export const ToolPoliciesPanel: React.FC<ToolPoliciesPanelProps> = ({ accessToken, onSelectTool }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [savingInput, setSavingInput] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [savingOutput, setSavingOutput] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]);
|
||||
|
||||
const queryOptions: UseQueryOptions<ToolRow[]> = {
|
||||
queryKey,
|
||||
queryFn: async () => (accessToken === null ? [] : fetchToolsList(accessToken)),
|
||||
enabled: accessToken !== null,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
};
|
||||
const query = useQuery(queryOptions);
|
||||
|
||||
const tools = useMemo(() => query.data ?? [], [query.data]);
|
||||
|
||||
// Cancel first: a list fetch that started before this save would otherwise resolve afterwards
|
||||
// and overwrite the row we just wrote with its pre-save snapshot.
|
||||
const patchTool = useCallback(
|
||||
async (toolName: string, patch: Partial<ToolRow>) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
queryClient.setQueryData<ToolRow[]>(queryKey, (previous) =>
|
||||
(previous ?? []).map((tool) => (tool.tool_name === toolName ? { ...tool, ...patch } : tool)),
|
||||
);
|
||||
},
|
||||
[queryClient, queryKey],
|
||||
);
|
||||
|
||||
const handleInputPolicyChange = useCallback(
|
||||
async (toolName: string, newPolicy: string) => {
|
||||
if (accessToken === null) return;
|
||||
setSavingInput((previous) => withTool(previous, toolName));
|
||||
try {
|
||||
await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy });
|
||||
await patchTool(toolName, { input_policy: newPolicy });
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend(`Failed to update input policy: ${toMessage(e, "unknown error")}`);
|
||||
} finally {
|
||||
setSavingInput((previous) => withoutTool(previous, toolName));
|
||||
}
|
||||
},
|
||||
[accessToken, patchTool],
|
||||
);
|
||||
|
||||
const handleOutputPolicyChange = useCallback(
|
||||
async (toolName: string, newPolicy: string) => {
|
||||
if (accessToken === null) return;
|
||||
setSavingOutput((previous) => withTool(previous, toolName));
|
||||
try {
|
||||
await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy });
|
||||
await patchTool(toolName, { output_policy: newPolicy });
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend(`Failed to update output policy: ${toMessage(e, "unknown error")}`);
|
||||
} finally {
|
||||
setSavingOutput((previous) => withoutTool(previous, toolName));
|
||||
}
|
||||
},
|
||||
[accessToken, patchTool],
|
||||
);
|
||||
|
||||
const { newToday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const todayKey = getUTCDateKey(now);
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const today = countToolsInUTCDay(tools, todayKey);
|
||||
|
||||
return {
|
||||
newToday: today,
|
||||
trendSubtitle: getTrendSubtitle(today, countToolsInUTCDay(tools, getUTCDateKey(yesterday))),
|
||||
totalTools: tools.length,
|
||||
blockedCount: tools.filter((tool) => tool.input_policy === "blocked").length,
|
||||
activeTeamsCount: new Set(tools.map((tool) => tool.team_id).filter(Boolean)).size,
|
||||
needsReviewTools: tools.filter(
|
||||
(tool) => isCreatedInUTCDay(tool.created_at, todayKey) && tool.input_policy === "untrusted",
|
||||
),
|
||||
};
|
||||
}, [tools]);
|
||||
|
||||
const scrollToToolRow = (toolId: string) => {
|
||||
document.querySelector(`[data-row-id="${CSS.escape(toolId)}"]`)?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
label="New Today"
|
||||
value={newToday}
|
||||
valueColor="text-green-600"
|
||||
subtitle={trendSubtitle}
|
||||
icon={
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard label="Total Tools Discovered" value={totalTools} />
|
||||
<MetricCard
|
||||
label="Blocked Tools"
|
||||
value={blockedCount}
|
||||
valueColor={blockedCount > 0 ? "text-red-600" : undefined}
|
||||
/>
|
||||
<MetricCard label="Active Teams" value={activeTeamsCount > 0 ? activeTeamsCount : "—"} />
|
||||
</div>
|
||||
|
||||
{needsReviewTools.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold text-amber-900 mb-1">Needs Review</h2>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
{needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy
|
||||
decisions.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{needsReviewTools.map((tool) => (
|
||||
<span
|
||||
key={tool.tool_id}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-white border border-amber-200 rounded-md text-sm"
|
||||
>
|
||||
<span className="font-mono text-amber-900 truncate max-w-[200px]" title={tool.tool_name}>
|
||||
{tool.tool_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToToolRow(tool.tool_id)}
|
||||
className="text-amber-700 hover:text-amber-900 font-medium text-xs whitespace-nowrap"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-sm text-sm text-red-700" role="alert">
|
||||
{toMessage(query.error, "Failed to load tools")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolPoliciesTable
|
||||
data={tools}
|
||||
isLoading={query.isLoading}
|
||||
isRefreshing={query.isFetching}
|
||||
onRefresh={() => void query.refetch()}
|
||||
onSelectTool={onSelectTool}
|
||||
savingInput={savingInput}
|
||||
savingOutput={savingOutput}
|
||||
onInputPolicyChange={handleInputPolicyChange}
|
||||
onOutputPolicyChange={handleOutputPolicyChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import type { ToolRow } from "@/components/networking";
|
||||
import { ToolPoliciesTable } from "./ToolPoliciesTable";
|
||||
|
||||
const TOOLS: ToolRow[] = [
|
||||
{
|
||||
tool_id: "tool-1",
|
||||
tool_name: "get_weather",
|
||||
input_policy: "untrusted",
|
||||
output_policy: "untrusted",
|
||||
call_count: 12,
|
||||
team_id: "team-alpha",
|
||||
key_alias: "prod-key",
|
||||
key_hash: "hash-aaa",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: "2026-07-21T10:00:00Z",
|
||||
},
|
||||
{
|
||||
tool_id: "tool-2",
|
||||
tool_name: "search_web",
|
||||
input_policy: "trusted",
|
||||
output_policy: "trusted",
|
||||
call_count: 5,
|
||||
team_id: "team-beta",
|
||||
key_alias: "dev-key",
|
||||
key_hash: "hash-bbb",
|
||||
created_at: "2026-07-20T10:00:00Z",
|
||||
},
|
||||
{
|
||||
tool_id: "tool-3",
|
||||
tool_name: "delete_file",
|
||||
input_policy: "blocked",
|
||||
output_policy: "untrusted",
|
||||
call_count: 100,
|
||||
key_hash: "hash-ccc",
|
||||
created_at: "2026-07-19T10:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const renderTable = (overrides: Partial<React.ComponentProps<typeof ToolPoliciesTable>> = {}) => {
|
||||
const props = {
|
||||
data: TOOLS,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
onRefresh: vi.fn(),
|
||||
onSelectTool: vi.fn(),
|
||||
savingInput: new Set<string>(),
|
||||
savingOutput: new Set<string>(),
|
||||
onInputPolicyChange: vi.fn(),
|
||||
onOutputPolicyChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
renderWithProviders(<ToolPoliciesTable {...props} />);
|
||||
return props;
|
||||
};
|
||||
|
||||
const rowIds = (): (string | null)[] =>
|
||||
Array.from(document.querySelectorAll("tbody tr[data-row-id]")).map((row) => row.getAttribute("data-row-id"));
|
||||
|
||||
const pickFilter = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
triggerTestId: string,
|
||||
optionLabel: string,
|
||||
): Promise<void> => {
|
||||
await user.click(screen.getByTestId(triggerTestId));
|
||||
await user.click(await screen.findByRole("option", { name: optionLabel }));
|
||||
};
|
||||
|
||||
describe("ToolPoliciesTable sorting", () => {
|
||||
it("should default to newest discovered first", () => {
|
||||
renderTable();
|
||||
|
||||
expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]);
|
||||
});
|
||||
|
||||
it("should sort by tool name when its header is used", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-tool_name"));
|
||||
|
||||
expect(rowIds()).toEqual(["tool-3", "tool-1", "tool-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPoliciesTable search", () => {
|
||||
it("should match on tool name", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "weather");
|
||||
|
||||
await waitFor(() => expect(rowIds()).toEqual(["tool-1"]));
|
||||
});
|
||||
|
||||
it("should match on key hash", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "hash-bbb");
|
||||
|
||||
await waitFor(() => expect(rowIds()).toEqual(["tool-2"]));
|
||||
});
|
||||
|
||||
it("should not match on user agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "curl");
|
||||
|
||||
await waitFor(() => expect(rowIds()).toEqual([]));
|
||||
expect(screen.getByText("No matching tools")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPoliciesTable filters", () => {
|
||||
it("should match an input policy exactly rather than as a substring", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await pickFilter(user, "filter-input-policy", "trusted");
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(rowIds()).toEqual(["tool-2"]));
|
||||
});
|
||||
|
||||
it("should filter by team", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await pickFilter(user, "filter-team", "team-alpha");
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(rowIds()).toEqual(["tool-1"]));
|
||||
expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Team Name:");
|
||||
});
|
||||
|
||||
it("should offer only the teams and keys present in the loaded rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await user.click(screen.getByTestId("filter-team"));
|
||||
|
||||
const teams = (await screen.findAllByRole("option")).map((option) => option.textContent);
|
||||
expect(teams).toEqual(["All Teams", "team-alpha", "team-beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPoliciesTable chrome", () => {
|
||||
it("should open the detail view from the tool name cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSelectTool } = renderTable();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /get_weather/ }));
|
||||
|
||||
expect(onSelectTool).toHaveBeenCalledWith("get_weather");
|
||||
});
|
||||
|
||||
it("should refresh on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onRefresh } = renderTable();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-refresh"));
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should explain how discovery works when there are no tools at all", () => {
|
||||
renderTable({ data: [] });
|
||||
|
||||
expect(screen.getByText("No tools discovered")).toBeInTheDocument();
|
||||
expect(screen.getByText(/tool_calls to start auto-discovery/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show skeleton rows while the first load is in flight", () => {
|
||||
renderTable({ data: [], isLoading: true });
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("No tools discovered")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnFiltersState } from "@tanstack/react-table";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ToolRow } from "@/components/networking";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
import { INPUT_POLICY_OPTIONS, OUTPUT_POLICY_OPTIONS } from "./PolicySelect";
|
||||
import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
|
||||
|
||||
const ALL_VALUE = "all";
|
||||
|
||||
const toFilterValue = (value: string | null): string | undefined =>
|
||||
value === null || value === ALL_VALUE ? undefined : value;
|
||||
|
||||
interface ToolPoliciesTableProps {
|
||||
data: ToolRow[];
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onSelectTool: (toolName: string) => void;
|
||||
savingInput: ReadonlySet<string>;
|
||||
savingOutput: ReadonlySet<string>;
|
||||
onInputPolicyChange: (toolName: string, policy: string) => void;
|
||||
onOutputPolicyChange: (toolName: string, policy: string) => void;
|
||||
}
|
||||
|
||||
function ToolPoliciesEmptyState({ filtered }: { filtered: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Wrench className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{filtered ? "No matching tools" : "No tools discovered"}
|
||||
</div>
|
||||
<div className="max-w-xs text-center text-sm text-muted-foreground">
|
||||
{filtered
|
||||
? "No tools match your search or filters."
|
||||
: "Make a chat completion that returns tool_calls to start auto-discovery."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueValues(rows: ToolRow[], pick: (row: ToolRow) => string | undefined): string[] {
|
||||
return Array.from(new Set(rows.map(pick).filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
export function ToolPoliciesTable({
|
||||
data,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onSelectTool,
|
||||
savingInput,
|
||||
savingOutput,
|
||||
onInputPolicyChange,
|
||||
onOutputPolicyChange,
|
||||
}: ToolPoliciesTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { onSelectTool, savingInput, savingOutput, onInputPolicyChange, onOutputPolicyChange };
|
||||
return getToolPoliciesTableColumns(deps);
|
||||
}, [onSelectTool, savingInput, savingOutput, onInputPolicyChange, onOutputPolicyChange]);
|
||||
|
||||
const teamOptions = useMemo(() => uniqueValues(data, (row) => row.team_id), [data]);
|
||||
const keyAliasOptions = useMemo(() => uniqueValues(data, (row) => row.key_alias), [data]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.tool_id}
|
||||
sortingMode="client"
|
||||
defaultSorting={[{ id: "created_at", desc: true }]}
|
||||
paginationMode="client"
|
||||
pageSizeOptions={[50, 100]}
|
||||
filterMode="client"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
globalFilter={globalFilter}
|
||||
onGlobalFilterChange={setGlobalFilter}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading tools…"
|
||||
noDataMessage={<ToolPoliciesEmptyState filtered={columnFilters.length > 0 || globalFilter !== ""} />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Search by Tool Name"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
showViewOptions={false}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down discovered tools"
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Input Policy">
|
||||
<Select
|
||||
value={(get("input_policy") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("input_policy", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-input-policy">
|
||||
<SelectValue placeholder="All Input Policies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Input Policies</SelectItem>
|
||||
{INPUT_POLICY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Output Policy">
|
||||
<Select
|
||||
value={(get("output_policy") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("output_policy", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-output-policy">
|
||||
<SelectValue placeholder="All Output Policies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Output Policies</SelectItem>
|
||||
{OUTPUT_POLICY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team Name">
|
||||
<Select
|
||||
value={(get("team_id") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("team_id", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-team">
|
||||
<SelectValue placeholder="All Teams" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Teams</SelectItem>
|
||||
{teamOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Key Name">
|
||||
<Select
|
||||
value={(get("key_alias") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("key_alias", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-key-alias">
|
||||
<SelectValue placeholder="All Keys" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Keys</SelectItem>
|
||||
{keyAliasOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Tooltip } from "antd";
|
||||
|
||||
import { ToolRow } from "@/components/networking";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
|
||||
import { PolicySelect } from "./PolicySelect";
|
||||
|
||||
interface ToolPoliciesTableColumnsDeps {
|
||||
onSelectTool: (toolName: string) => void;
|
||||
savingInput: ReadonlySet<string>;
|
||||
savingOutput: ReadonlySet<string>;
|
||||
onInputPolicyChange: (toolName: string, policy: string) => void;
|
||||
onOutputPolicyChange: (toolName: string, policy: string) => void;
|
||||
}
|
||||
|
||||
function TruncatedText({ value, className }: { value: string | undefined; className?: string }) {
|
||||
const text = value ?? "-";
|
||||
return (
|
||||
<Tooltip title={text}>
|
||||
<span className={className}>{text}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const getToolPoliciesTableColumns = ({
|
||||
onSelectTool,
|
||||
savingInput,
|
||||
savingOutput,
|
||||
onInputPolicyChange,
|
||||
onOutputPolicyChange,
|
||||
}: ToolPoliciesTableColumnsDeps): ColumnDef<ToolRow>[] => [
|
||||
{
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? "",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Discovered" />,
|
||||
size: 170,
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "tool_name",
|
||||
accessorFn: (row) => row.tool_name,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Tool Name" />,
|
||||
minSize: 200,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.tool_name}
|
||||
titleClassName="font-mono text-xs font-normal text-primary"
|
||||
className="max-w-60"
|
||||
onClick={() => onSelectTool(row.original.tool_name)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "input_policy",
|
||||
accessorFn: (row) => row.input_policy,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Input Policy" />,
|
||||
size: 140,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Input Policy", skeleton: "badge" },
|
||||
cell: ({ row }) => (
|
||||
<PolicySelect
|
||||
value={row.original.input_policy}
|
||||
toolName={row.original.tool_name}
|
||||
saving={savingInput.has(row.original.tool_name)}
|
||||
onChange={onInputPolicyChange}
|
||||
policyType="input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "output_policy",
|
||||
accessorFn: (row) => row.output_policy,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Output Policy" />,
|
||||
size: 140,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Output Policy", skeleton: "badge" },
|
||||
cell: ({ row }) => (
|
||||
<PolicySelect
|
||||
value={row.original.output_policy}
|
||||
toolName={row.original.tool_name}
|
||||
saving={savingOutput.has(row.original.tool_name)}
|
||||
onChange={onOutputPolicyChange}
|
||||
policyType="output"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "call_count",
|
||||
accessorFn: (row) => row.call_count ?? 0,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="# Calls" />,
|
||||
size: 100,
|
||||
enableGlobalFilter: false,
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="font-mono">{(row.original.call_count ?? 0).toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorFn: (row) => row.team_id ?? "",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Team Name" />,
|
||||
size: 160,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Team Name" },
|
||||
cell: ({ row }) => <IdCell value={row.original.team_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "key_hash",
|
||||
accessorFn: (row) => row.key_hash ?? "",
|
||||
header: "Key Hash",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.key_hash} />,
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorFn: (row) => row.key_alias ?? "",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Key Name" />,
|
||||
size: 150,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Key Name" },
|
||||
cell: ({ row }) => <TruncatedText value={row.original.key_alias} className="block max-w-32 truncate" />,
|
||||
},
|
||||
{
|
||||
id: "user_agent",
|
||||
accessorFn: (row) => row.user_agent ?? "",
|
||||
header: "User Agent",
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => (
|
||||
<TruncatedText
|
||||
value={row.original.user_agent}
|
||||
className="block max-w-40 truncate font-mono text-muted-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -14,25 +14,27 @@ vi.mock("@/components/ToolDetail", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ToolPolicies", () => ({
|
||||
ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => (
|
||||
<div>
|
||||
<span>Tool Policies Overview</span>
|
||||
<button onClick={() => onSelectTool("my-tool")}>Select Tool</button>
|
||||
</div>
|
||||
),
|
||||
vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({
|
||||
ToolPoliciesPanel: function ToolPoliciesPanelMock({ onSelectTool }: { onSelectTool: (name: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<span>Tool Policies Overview</span>
|
||||
<button onClick={() => onSelectTool("my-tool")}>Select Tool</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ToolPoliciesView", () => {
|
||||
it("should render the overview by default", () => {
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to tool detail when a tool is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /select tool/i }));
|
||||
|
||||
|
|
@ -42,7 +44,7 @@ describe("ToolPoliciesView", () => {
|
|||
|
||||
it("should navigate back to overview when back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /select tool/i }));
|
||||
await user.click(screen.getByRole("button", { name: /back/i }));
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@
|
|||
|
||||
import React, { useState } from "react";
|
||||
import { ToolDetail } from "@/components/ToolDetail";
|
||||
import { ToolPolicies } from "@/components/ToolPolicies";
|
||||
import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel";
|
||||
|
||||
type View = { type: "overview" } | { type: "detail"; toolName: string };
|
||||
|
||||
interface ToolPoliciesViewProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export default function ToolPoliciesView({ accessToken, userRole }: ToolPoliciesViewProps) {
|
||||
export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) {
|
||||
const [view, setView] = useState<View>({ type: "overview" });
|
||||
|
||||
const handleSelectTool = (toolName: string) => {
|
||||
|
|
@ -27,7 +26,7 @@ export default function ToolPoliciesView({ accessToken, userRole }: ToolPolicies
|
|||
{view.type === "detail" ? (
|
||||
<ToolDetail toolName={view.toolName} onBack={handleBack} accessToken={accessToken} />
|
||||
) : (
|
||||
<ToolPolicies accessToken={accessToken} userRole={userRole} onSelectTool={handleSelectTool} />
|
||||
<ToolPoliciesPanel accessToken={accessToken} onSelectTool={handleSelectTool} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import HealthCheckComponent from "./HealthCheckComponent";
|
||||
|
||||
const mockIndividualModelHealthCheckCall = vi.fn();
|
||||
|
|
@ -11,9 +15,57 @@ vi.mock("../networking", () => ({
|
|||
latestHealthChecksCall: (...args: unknown[]) => mockLatestHealthChecksCall(...args),
|
||||
}));
|
||||
|
||||
describe("HealthCheckComponent", () => {
|
||||
const getDisplayModelName = (model: { model_name?: string }) => model.model_name ?? "";
|
||||
const getDisplayModelName = (model: { model_name?: string }) => model.model_name ?? "";
|
||||
|
||||
const makeModel = (id: string, name = "gpt-4") => ({
|
||||
model_name: name,
|
||||
model_info: { id },
|
||||
litellm_model_name: name,
|
||||
});
|
||||
|
||||
interface HarnessProps {
|
||||
modelData: { data: ReturnType<typeof makeModel>[] };
|
||||
allModelsOnProxy: string[];
|
||||
rowCount?: number;
|
||||
onPageIndexChange?: (pageIndex: number) => void;
|
||||
}
|
||||
|
||||
/** Holds pagination state so page changes exercise the real controlled wiring. */
|
||||
function Harness({ modelData, allModelsOnProxy, rowCount = 1, onPageIndexChange }: HarnessProps) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
|
||||
return (
|
||||
<>
|
||||
<span data-testid="page-index">{pagination.pageIndex}</span>
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={allModelsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(updater) => {
|
||||
setPagination((previous) => {
|
||||
const next = typeof updater === "function" ? updater(previous) : updater;
|
||||
onPageIndexChange?.(next.pageIndex);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
rowCount={rowCount}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHealthCheck = async (props: HarnessProps) => {
|
||||
await act(async () => {
|
||||
render(<Harness {...props} />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
};
|
||||
|
||||
describe("HealthCheckComponent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: {} });
|
||||
|
|
@ -26,29 +78,7 @@ describe("HealthCheckComponent", () => {
|
|||
});
|
||||
|
||||
it("should render the health check section", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "deployment-1" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["deployment-1"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
await renderHealthCheck({ modelData: { data: [makeModel("deployment-1")] }, allModelsOnProxy: ["deployment-1"] });
|
||||
|
||||
expect(screen.getByText("Model Health Status")).toBeInTheDocument();
|
||||
expect(
|
||||
|
|
@ -57,176 +87,149 @@ describe("HealthCheckComponent", () => {
|
|||
});
|
||||
|
||||
it("should call individualModelHealthCheckCall with model id when run health check is triggered", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "deployment-abc-123" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token-123"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["deployment-abc-123"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
<Harness modelData={{ data: [makeModel("deployment-abc-123")] }} allModelsOnProxy={["deployment-abc-123"]} />,
|
||||
);
|
||||
|
||||
const runButtons = screen.getAllByTestId("run-health-check-btn");
|
||||
expect(runButtons.length).toBeGreaterThanOrEqual(1);
|
||||
const runButton = runButtons[0];
|
||||
|
||||
await act(async () => {
|
||||
runButton.click();
|
||||
runButtons[0].click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token-123", "deployment-abc-123");
|
||||
expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token-123", "gpt-4");
|
||||
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "deployment-abc-123");
|
||||
expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token", "gpt-4");
|
||||
});
|
||||
|
||||
it("should show pagination controls and request the next page", async () => {
|
||||
const onPageChange = vi.fn();
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "deployment-1" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["deployment-1"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
paginationMeta={{
|
||||
total_count: 75,
|
||||
current_page: 1,
|
||||
total_pages: 2,
|
||||
size: 50,
|
||||
}}
|
||||
currentPage={1}
|
||||
pageSize={50}
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
it("should page through results with the shared pagination footer", async () => {
|
||||
const onPageIndexChange = vi.fn();
|
||||
await renderHealthCheck({
|
||||
modelData: { data: [makeModel("deployment-1")] },
|
||||
allModelsOnProxy: ["deployment-1"],
|
||||
rowCount: 75,
|
||||
onPageIndexChange,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("health-results-count")).toHaveTextContent("Showing 1 - 50 of 75 results");
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 75");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "Next" }).click();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
|
||||
expect(onPageIndexChange).toHaveBeenCalledWith(1);
|
||||
expect(screen.getByTestId("page-index")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
describe("row selection drives the bulk run", () => {
|
||||
const twoModels = { data: [makeModel("id-alpha", "alpha"), makeModel("id-beta", "beta")] };
|
||||
const bothIds = ["id-alpha", "id-beta"];
|
||||
|
||||
it("runs only the selected models and labels the button accordingly", async () => {
|
||||
await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 });
|
||||
const user = userEvent.setup();
|
||||
|
||||
expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run All Checks");
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-id-beta"));
|
||||
expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run Selected Checks");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByTestId("run-health-checks").click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "id-beta");
|
||||
expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token", "id-alpha");
|
||||
});
|
||||
|
||||
expect(onPageChange).toHaveBeenCalledWith(2);
|
||||
it("falls back to every model on the page when nothing is selected", async () => {
|
||||
await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 });
|
||||
|
||||
await act(async () => {
|
||||
screen.getByTestId("run-health-checks").click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "id-alpha");
|
||||
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "id-beta");
|
||||
});
|
||||
|
||||
it("treats a full page selection as running everything", async () => {
|
||||
await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-all"));
|
||||
|
||||
expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run All Checks");
|
||||
});
|
||||
|
||||
it("clears the selection from the Clear Selection button", async () => {
|
||||
await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 });
|
||||
const user = userEvent.setup();
|
||||
|
||||
expect(screen.queryByTestId("clear-health-selection")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-id-alpha"));
|
||||
await user.click(screen.getByTestId("clear-health-selection"));
|
||||
|
||||
expect(screen.getByTestId("datatable-select-row-id-alpha")).toHaveAttribute("aria-checked", "false");
|
||||
expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run All Checks");
|
||||
});
|
||||
|
||||
// The pager swaps the underlying rows, so a carried-over selection would target
|
||||
// models that are no longer on screen.
|
||||
it("wipes the selection when the page changes", async () => {
|
||||
await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 120 });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-select-row-id-alpha"));
|
||||
expect(screen.getByTestId("clear-health-selection")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
|
||||
expect(screen.queryByTestId("clear-health-selection")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("datatable-select-row-id-alpha")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("latest_health_checks keyed by model id", () => {
|
||||
it("should show status from latest_health_checks when keys match model ids", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "id-alpha" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "id-beta" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLatestHealthChecksCall.mockResolvedValue({
|
||||
latest_health_checks: {
|
||||
"id-alpha": {
|
||||
status: "healthy",
|
||||
checked_at: "2024-01-15T10:00:00Z",
|
||||
error_message: null,
|
||||
},
|
||||
"id-beta": {
|
||||
status: "unhealthy",
|
||||
checked_at: "2024-01-15T10:05:00Z",
|
||||
error_message: "Connection failed",
|
||||
},
|
||||
"id-alpha": { status: "healthy", checked_at: "2024-01-15T10:00:00Z", error_message: null },
|
||||
"id-beta": { status: "unhealthy", checked_at: "2024-01-15T10:05:00Z", error_message: "Connection failed" },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["id-alpha", "id-beta"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await renderHealthCheck({
|
||||
modelData: { data: [makeModel("id-alpha"), makeModel("id-beta")] },
|
||||
allModelsOnProxy: ["id-alpha", "id-beta"],
|
||||
rowCount: 2,
|
||||
});
|
||||
|
||||
expect(mockLatestHealthChecksCall).toHaveBeenCalledWith("token");
|
||||
const healthyBadges = screen.getAllByText("healthy");
|
||||
const unhealthyBadges = screen.getAllByText("unhealthy");
|
||||
expect(healthyBadges.length).toBeGreaterThanOrEqual(1);
|
||||
expect(unhealthyBadges.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("healthy").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("unhealthy").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should skip latest_health_checks entries whose key is not a known model id", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "current-model-id" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLatestHealthChecksCall.mockResolvedValue({
|
||||
latest_health_checks: {
|
||||
"current-model-id": {
|
||||
status: "healthy",
|
||||
checked_at: "2024-01-15T10:00:00Z",
|
||||
error_message: null,
|
||||
},
|
||||
"deleted-or-unknown-id": {
|
||||
status: "unhealthy",
|
||||
checked_at: "2024-01-15T10:05:00Z",
|
||||
error_message: "Stale entry",
|
||||
},
|
||||
"current-model-id": { status: "healthy", checked_at: "2024-01-15T10:00:00Z", error_message: null },
|
||||
"deleted-or-unknown-id": { status: "unhealthy", checked_at: "2024-01-15T10:05:00Z", error_message: "Stale" },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["current-model-id"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await renderHealthCheck({
|
||||
modelData: { data: [makeModel("current-model-id")] },
|
||||
allModelsOnProxy: ["current-model-id"],
|
||||
});
|
||||
|
||||
expect(screen.getByText("healthy")).toBeInTheDocument();
|
||||
|
|
@ -234,38 +237,15 @@ describe("HealthCheckComponent", () => {
|
|||
});
|
||||
|
||||
it("should not apply status when latest_health_checks key is model name not model id", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "model-id-123" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLatestHealthChecksCall.mockResolvedValue({
|
||||
latest_health_checks: {
|
||||
"gpt-4": {
|
||||
status: "healthy",
|
||||
checked_at: "2024-01-15T10:00:00Z",
|
||||
error_message: null,
|
||||
},
|
||||
"gpt-4": { status: "healthy", checked_at: "2024-01-15T10:00:00Z", error_message: null },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["model-id-123"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await renderHealthCheck({
|
||||
modelData: { data: [makeModel("model-id-123")] },
|
||||
allModelsOnProxy: ["model-id-123"],
|
||||
});
|
||||
|
||||
expect(screen.queryByText("healthy")).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,24 +1,141 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Title, Text, Button } from "@tremor/react";
|
||||
import { OnChangeFn, PaginationState, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Modal } from "antd";
|
||||
import { Button as AntdButton } from "antd";
|
||||
import { ModelDataTable } from "./table";
|
||||
import { healthCheckColumns } from "./health_check_columns";
|
||||
import { errorPatterns } from "@/utils/errorPatterns";
|
||||
import { individualModelHealthCheckCall, latestHealthChecksCall } from "../networking";
|
||||
import { Table as TableInstance } from "@tanstack/react-table";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
lastCheck: string;
|
||||
lastSuccess?: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
fullError?: string;
|
||||
successResponse?: any;
|
||||
import { errorPatterns } from "@/utils/errorPatterns";
|
||||
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { individualModelHealthCheckCall, latestHealthChecksCall } from "../networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChecksTable } from "./HealthChecksTable";
|
||||
import type { HealthCheckData, HealthStatus } from "./HealthChecksTableColumns";
|
||||
|
||||
interface LatestHealthCheck {
|
||||
status?: string;
|
||||
checked_at?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
const STATUS_TO_ERROR: Record<string, string> = {
|
||||
"400": "BadRequestError",
|
||||
"401": "AuthenticationError",
|
||||
"403": "ForbiddenError",
|
||||
"404": "NotFoundError",
|
||||
"408": "TimeoutError",
|
||||
"429": "RateLimitError",
|
||||
"500": "InternalServerError",
|
||||
"502": "BadGatewayError",
|
||||
"503": "ServiceUnavailableError",
|
||||
"504": "GatewayTimeoutError",
|
||||
};
|
||||
|
||||
const ERROR_TO_STATUS: Record<string, string> = {
|
||||
AuthenticationError: "401",
|
||||
RateLimitError: "429",
|
||||
BadRequestError: "400",
|
||||
InternalServerError: "500",
|
||||
TimeoutError: "408",
|
||||
NotFoundError: "404",
|
||||
ForbiddenError: "403",
|
||||
ServiceUnavailableError: "503",
|
||||
BadGatewayError: "502",
|
||||
GatewayTimeoutError: "504",
|
||||
ContentPolicyViolationError: "400",
|
||||
};
|
||||
|
||||
const KEYWORD_ERRORS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
|
||||
{ pattern: /missing.*api.*key|invalid.*key|unauthorized/i, label: "AuthenticationError: 401" },
|
||||
{ pattern: /rate.*limit|too.*many.*requests/i, label: "RateLimitError: 429" },
|
||||
{ pattern: /timeout|timed.*out/i, label: "TimeoutError: 408" },
|
||||
{ pattern: /not.*found/i, label: "NotFoundError: 404" },
|
||||
{ pattern: /forbidden|access.*denied/i, label: "ForbiddenError: 403" },
|
||||
{ pattern: /internal.*server.*error/i, label: "InternalServerError: 500" },
|
||||
];
|
||||
|
||||
const truncate = (value: string): string => (value.length > 100 ? `${value.substring(0, 97)}...` : value);
|
||||
|
||||
// Helper function to extract meaningful error information
|
||||
const extractMeaningfulError = (error: unknown): string => {
|
||||
if (!error) return "Health check failed";
|
||||
|
||||
const errorStr = typeof error === "string" ? error : JSON.stringify(error);
|
||||
|
||||
// First, look for explicit "ErrorType: StatusCode" patterns
|
||||
const directPatternMatch = errorStr.match(/(\w+Error):\s*(\d{3})/i);
|
||||
if (directPatternMatch) {
|
||||
return `${directPatternMatch[1]}: ${directPatternMatch[2]}`;
|
||||
}
|
||||
|
||||
// Look for error types and status codes separately, then combine them
|
||||
const errorTypeMatch = errorStr.match(
|
||||
/(AuthenticationError|RateLimitError|BadRequestError|InternalServerError|TimeoutError|NotFoundError|ForbiddenError|ServiceUnavailableError|BadGatewayError|ContentPolicyViolationError|\w+Error)/i,
|
||||
);
|
||||
const statusCodeMatch = errorStr.match(/\b(400|401|403|404|408|429|500|502|503|504)\b/);
|
||||
|
||||
if (errorTypeMatch && statusCodeMatch) {
|
||||
return `${errorTypeMatch[1]}: ${statusCodeMatch[1]}`;
|
||||
}
|
||||
|
||||
// If we have a status code but no clear error type, map it
|
||||
if (statusCodeMatch) {
|
||||
const statusCode = statusCodeMatch[1];
|
||||
return `${STATUS_TO_ERROR[statusCode]}: ${statusCode}`;
|
||||
}
|
||||
|
||||
// If we have an error type but no status code, map error type to expected status code
|
||||
if (errorTypeMatch) {
|
||||
const errorType = errorTypeMatch[1];
|
||||
const mappedStatus = ERROR_TO_STATUS[errorType];
|
||||
if (mappedStatus) {
|
||||
return `${errorType}: ${mappedStatus}`;
|
||||
}
|
||||
return errorType;
|
||||
}
|
||||
|
||||
// Check for specific error patterns from errorPatterns
|
||||
for (const { pattern, replacement } of errorPatterns) {
|
||||
if (pattern.test(errorStr)) {
|
||||
return replacement;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for common error keywords and provide meaningful names with status codes
|
||||
for (const { pattern, label } of KEYWORD_ERRORS) {
|
||||
if (pattern.test(errorStr)) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: clean up the error string and return first meaningful part
|
||||
const cleaned = errorStr
|
||||
.replace(/[\n\r]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// Try to get first meaningful sentence or phrase
|
||||
const firstSentence = cleaned.split(/[.!?]/)[0]?.trim();
|
||||
if (firstSentence && firstSentence.length > 0) {
|
||||
return truncate(firstSentence);
|
||||
}
|
||||
|
||||
return truncate(cleaned);
|
||||
};
|
||||
|
||||
const toCheckedAtLabel = (checkedAt: string | null | undefined, fallback: string): string => {
|
||||
if (!checkedAt) {
|
||||
return fallback;
|
||||
}
|
||||
return new Date(checkedAt).toLocaleString();
|
||||
};
|
||||
|
||||
const toLastSuccessLabel = (checkData: LatestHealthCheck, fallback: string): string => {
|
||||
if (checkData.status !== "healthy") {
|
||||
return fallback;
|
||||
}
|
||||
return toCheckedAtLabel(checkData.checked_at, fallback);
|
||||
};
|
||||
|
||||
interface HealthCheckComponentProps {
|
||||
accessToken: string | null;
|
||||
modelData: any;
|
||||
|
|
@ -27,15 +144,9 @@ interface HealthCheckComponentProps {
|
|||
setSelectedModelId?: (modelId: string) => void;
|
||||
teams?: Team[] | null;
|
||||
isLoading?: boolean;
|
||||
paginationMeta?: {
|
||||
total_count: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
size: number;
|
||||
};
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
|
|
@ -46,14 +157,12 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
setSelectedModelId,
|
||||
teams,
|
||||
isLoading = false,
|
||||
paginationMeta,
|
||||
currentPage = 1,
|
||||
pageSize = 50,
|
||||
onPageChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
rowCount,
|
||||
}) => {
|
||||
const [modelHealthStatuses, setModelHealthStatuses] = useState<{ [key: string]: HealthStatus }>({});
|
||||
const [selectedModelsForHealth, setSelectedModelsForHealth] = useState<string[]>([]);
|
||||
const [allModelsSelected, setAllModelsSelected] = useState<boolean>(false);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [errorModalVisible, setErrorModalVisible] = useState(false);
|
||||
const [selectedErrorDetails, setSelectedErrorDetails] = useState<{
|
||||
modelName: string;
|
||||
|
|
@ -63,11 +172,9 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
const [successModalVisible, setSuccessModalVisible] = useState(false);
|
||||
const [selectedSuccessDetails, setSelectedSuccessDetails] = useState<{
|
||||
modelName: string;
|
||||
response: any;
|
||||
response: unknown;
|
||||
} | null>(null);
|
||||
|
||||
const healthTableRef = useRef<TableInstance<any>>(null);
|
||||
|
||||
// Initialize health statuses on component mount (keyed by model id)
|
||||
useEffect(() => {
|
||||
if (!accessToken || !modelData?.data) return;
|
||||
|
|
@ -100,8 +207,9 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
latestHealthChecks.latest_health_checks &&
|
||||
typeof latestHealthChecks.latest_health_checks === "object"
|
||||
) {
|
||||
Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, checkData]: [string, any]) => {
|
||||
if (!checkData) return;
|
||||
Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, rawCheck]) => {
|
||||
if (!rawCheck) return;
|
||||
const checkData = rawCheck as LatestHealthCheck;
|
||||
|
||||
// Key is model_id from the backend (guaranteed by DB schema)
|
||||
const modelExists = modelData.data.some((m: any) => m.model_info?.id === modelId);
|
||||
|
|
@ -111,13 +219,8 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
|
||||
healthStatusMap[modelId] = {
|
||||
status: checkData.status || "unknown",
|
||||
lastCheck: checkData.checked_at ? new Date(checkData.checked_at).toLocaleString() : "None",
|
||||
lastSuccess:
|
||||
checkData.status === "healthy"
|
||||
? checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: "None"
|
||||
: "None",
|
||||
lastCheck: toCheckedAtLabel(checkData.checked_at, "None"),
|
||||
lastSuccess: toLastSuccessLabel(checkData, "None"),
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : undefined,
|
||||
fullError: fullError,
|
||||
|
|
@ -135,132 +238,73 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
initializeHealthStatuses();
|
||||
}, [accessToken, modelData]);
|
||||
|
||||
// Helper function to extract meaningful error information
|
||||
const extractMeaningfulError = (error: any): string => {
|
||||
if (!error) return "Health check failed";
|
||||
const runIndividualHealthCheck = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
let errorStr = typeof error === "string" ? error : JSON.stringify(error);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
...prev[modelId],
|
||||
loading: true,
|
||||
status: "checking",
|
||||
},
|
||||
}));
|
||||
|
||||
// First, look for explicit "ErrorType: StatusCode" patterns
|
||||
const directPatternMatch = errorStr.match(/(\w+Error):\s*(\d{3})/i);
|
||||
if (directPatternMatch) {
|
||||
return `${directPatternMatch[1]}: ${directPatternMatch[2]}`;
|
||||
}
|
||||
try {
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelId);
|
||||
const currentTime = new Date().toLocaleString();
|
||||
|
||||
// Look for error types and status codes separately, then combine them
|
||||
const errorTypeMatch = errorStr.match(
|
||||
/(AuthenticationError|RateLimitError|BadRequestError|InternalServerError|TimeoutError|NotFoundError|ForbiddenError|ServiceUnavailableError|BadGatewayError|ContentPolicyViolationError|\w+Error)/i,
|
||||
);
|
||||
const statusCodeMatch = errorStr.match(/\b(400|401|403|404|408|429|500|502|503|504)\b/);
|
||||
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
|
||||
const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed";
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: "unhealthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
fullError: rawError,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: "healthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: currentTime,
|
||||
loading: false,
|
||||
successResponse: response,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (errorTypeMatch && statusCodeMatch) {
|
||||
return `${errorTypeMatch[1]}: ${statusCodeMatch[1]}`;
|
||||
}
|
||||
try {
|
||||
const latestHealthChecks = await latestHealthChecksCall(accessToken);
|
||||
const checkData = latestHealthChecks.latest_health_checks?.[modelId] as LatestHealthCheck | undefined;
|
||||
|
||||
// If we have a status code but no clear error type, map it
|
||||
if (statusCodeMatch) {
|
||||
const statusCode = statusCodeMatch[1];
|
||||
const statusToError: { [key: string]: string } = {
|
||||
"400": "BadRequestError",
|
||||
"401": "AuthenticationError",
|
||||
"403": "ForbiddenError",
|
||||
"404": "NotFoundError",
|
||||
"408": "TimeoutError",
|
||||
"429": "RateLimitError",
|
||||
"500": "InternalServerError",
|
||||
"502": "BadGatewayError",
|
||||
"503": "ServiceUnavailableError",
|
||||
"504": "GatewayTimeoutError",
|
||||
};
|
||||
return `${statusToError[statusCode]}: ${statusCode}`;
|
||||
}
|
||||
|
||||
// If we have an error type but no status code, map error type to expected status code
|
||||
if (errorTypeMatch) {
|
||||
const errorType = errorTypeMatch[1];
|
||||
const errorToStatus: { [key: string]: string } = {
|
||||
AuthenticationError: "401",
|
||||
RateLimitError: "429",
|
||||
BadRequestError: "400",
|
||||
InternalServerError: "500",
|
||||
TimeoutError: "408",
|
||||
NotFoundError: "404",
|
||||
ForbiddenError: "403",
|
||||
ServiceUnavailableError: "503",
|
||||
BadGatewayError: "502",
|
||||
GatewayTimeoutError: "504",
|
||||
ContentPolicyViolationError: "400",
|
||||
};
|
||||
|
||||
const mappedStatus = errorToStatus[errorType];
|
||||
if (mappedStatus) {
|
||||
return `${errorType}: ${mappedStatus}`;
|
||||
}
|
||||
return errorType;
|
||||
}
|
||||
|
||||
// Check for specific error patterns from errorPatterns
|
||||
for (const { pattern, replacement } of errorPatterns) {
|
||||
if (pattern.test(errorStr)) {
|
||||
return replacement;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for common error keywords and provide meaningful names with status codes
|
||||
if (/missing.*api.*key|invalid.*key|unauthorized/i.test(errorStr)) {
|
||||
return "AuthenticationError: 401";
|
||||
}
|
||||
if (/rate.*limit|too.*many.*requests/i.test(errorStr)) {
|
||||
return "RateLimitError: 429";
|
||||
}
|
||||
if (/timeout|timed.*out/i.test(errorStr)) {
|
||||
return "TimeoutError: 408";
|
||||
}
|
||||
if (/not.*found/i.test(errorStr)) {
|
||||
return "NotFoundError: 404";
|
||||
}
|
||||
if (/forbidden|access.*denied/i.test(errorStr)) {
|
||||
return "ForbiddenError: 403";
|
||||
}
|
||||
if (/internal.*server.*error/i.test(errorStr)) {
|
||||
return "InternalServerError: 500";
|
||||
}
|
||||
|
||||
// Fallback: clean up the error string and return first meaningful part
|
||||
const cleaned = errorStr
|
||||
.replace(/[\n\r]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// Try to get first meaningful sentence or phrase
|
||||
const sentences = cleaned.split(/[.!?]/);
|
||||
const firstSentence = sentences[0]?.trim();
|
||||
|
||||
if (firstSentence && firstSentence.length > 0) {
|
||||
return firstSentence.length > 100 ? firstSentence.substring(0, 97) + "..." : firstSentence;
|
||||
}
|
||||
|
||||
return cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned;
|
||||
};
|
||||
|
||||
const runIndividualHealthCheck = async (modelId: string) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
...prev[modelId],
|
||||
loading: true,
|
||||
status: "checking",
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelId);
|
||||
const currentTime = new Date().toLocaleString();
|
||||
|
||||
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
|
||||
const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed";
|
||||
if (checkData) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: checkData.status || prev[modelId]?.status || "unknown",
|
||||
lastCheck: toCheckedAtLabel(checkData.checked_at, prev[modelId]?.lastCheck || "None"),
|
||||
lastSuccess: toLastSuccessLabel(checkData, prev[modelId]?.lastSuccess || "None"),
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : prev[modelId]?.error,
|
||||
fullError: fullError || prev[modelId]?.fullError,
|
||||
successResponse: checkData.status === "healthy" ? checkData : prev[modelId]?.successResponse,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (dbError) {}
|
||||
} catch (error) {
|
||||
const currentTime = new Date().toLocaleString();
|
||||
const rawError = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -273,66 +317,18 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
fullError: rawError,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: "healthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: currentTime,
|
||||
loading: false,
|
||||
successResponse: response,
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
try {
|
||||
const latestHealthChecks = await latestHealthChecksCall(accessToken);
|
||||
const checkData = latestHealthChecks.latest_health_checks?.[modelId];
|
||||
|
||||
if (checkData) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: checkData.status || prev[modelId]?.status || "unknown",
|
||||
lastCheck: checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: prev[modelId]?.lastCheck || "None",
|
||||
lastSuccess:
|
||||
checkData.status === "healthy"
|
||||
? checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: prev[modelId]?.lastSuccess || "None"
|
||||
: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : prev[modelId]?.error,
|
||||
fullError: fullError || prev[modelId]?.fullError,
|
||||
successResponse: checkData.status === "healthy" ? checkData : prev[modelId]?.successResponse,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (dbError) {}
|
||||
} catch (error) {
|
||||
const currentTime = new Date().toLocaleString();
|
||||
const rawError = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: "unhealthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
fullError: rawError,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
const selectedModelIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((modelId) => rowSelection[modelId]),
|
||||
[rowSelection],
|
||||
);
|
||||
|
||||
const runAllHealthChecks = async () => {
|
||||
const modelsToCheck = selectedModelsForHealth.length > 0 ? selectedModelsForHealth : all_models_on_proxy;
|
||||
const modelsToCheck = selectedModelIds.length > 0 ? selectedModelIds : all_models_on_proxy;
|
||||
|
||||
const loadingStatuses = modelsToCheck.reduce(
|
||||
(acc, modelId) => {
|
||||
|
|
@ -348,14 +344,11 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
|
||||
setModelHealthStatuses((prev) => ({ ...prev, ...loadingStatuses }));
|
||||
|
||||
const healthCheckResults: { [key: string]: any } = {};
|
||||
|
||||
const healthCheckPromises = modelsToCheck.map(async (modelId) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelId);
|
||||
healthCheckResults[modelId] = response;
|
||||
|
||||
const currentTime = new Date().toLocaleString();
|
||||
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
|
||||
|
|
@ -410,32 +403,26 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
const latestHealthChecks = await latestHealthChecksCall(accessToken);
|
||||
|
||||
if (latestHealthChecks.latest_health_checks) {
|
||||
Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, checkData]: [string, any]) => {
|
||||
if (modelsToCheck.includes(modelId) && checkData) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
setModelHealthStatuses((prev) => {
|
||||
const currentStatus = prev[modelId];
|
||||
return {
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: checkData.status || currentStatus?.status || "unknown",
|
||||
lastCheck: checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: currentStatus?.lastCheck || "None",
|
||||
lastSuccess:
|
||||
checkData.status === "healthy"
|
||||
? checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: currentStatus?.lastSuccess || "None"
|
||||
: currentStatus?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : currentStatus?.error,
|
||||
fullError: fullError || currentStatus?.fullError,
|
||||
successResponse: checkData.status === "healthy" ? checkData : currentStatus?.successResponse,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, rawCheck]) => {
|
||||
if (!modelsToCheck.includes(modelId) || !rawCheck) return;
|
||||
const checkData = rawCheck as LatestHealthCheck;
|
||||
const fullError = checkData.error_message || undefined;
|
||||
|
||||
setModelHealthStatuses((prev) => {
|
||||
const currentStatus = prev[modelId];
|
||||
return {
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: checkData.status || currentStatus?.status || "unknown",
|
||||
lastCheck: toCheckedAtLabel(checkData.checked_at, currentStatus?.lastCheck || "None"),
|
||||
lastSuccess: toLastSuccessLabel(checkData, currentStatus?.lastSuccess || "None"),
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : currentStatus?.error,
|
||||
fullError: fullError || currentStatus?.fullError,
|
||||
successResponse: checkData.status === "healthy" ? checkData : currentStatus?.successResponse,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (dbError) {
|
||||
|
|
@ -443,170 +430,116 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleModelSelection = (modelId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedModelsForHealth((prev) => [...prev, modelId]);
|
||||
} else {
|
||||
setSelectedModelsForHealth((prev) => prev.filter((id) => id !== modelId));
|
||||
setAllModelsSelected(false);
|
||||
}
|
||||
};
|
||||
// Changing the page swaps the underlying rows, so a carried-over selection would
|
||||
// point at models that are no longer on screen.
|
||||
const handlePaginationChange = useCallback<OnChangeFn<PaginationState>>(
|
||||
(updaterOrValue) => {
|
||||
setRowSelection({});
|
||||
setModelHealthStatuses({});
|
||||
onPaginationChange(updaterOrValue);
|
||||
},
|
||||
[onPaginationChange],
|
||||
);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setAllModelsSelected(checked);
|
||||
if (checked) {
|
||||
setSelectedModelsForHealth(all_models_on_proxy);
|
||||
} else {
|
||||
setSelectedModelsForHealth([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setSelectedModelsForHealth([]);
|
||||
setAllModelsSelected(false);
|
||||
setModelHealthStatuses({});
|
||||
onPageChange?.(page);
|
||||
};
|
||||
|
||||
const showErrorModal = (modelName: string, cleanedError: string, fullError: string) => {
|
||||
setSelectedErrorDetails({
|
||||
modelName,
|
||||
cleanedError,
|
||||
fullError,
|
||||
});
|
||||
const showErrorModal = useCallback((modelName: string, cleanedError: string, fullError: string) => {
|
||||
setSelectedErrorDetails({ modelName, cleanedError, fullError });
|
||||
setErrorModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeErrorModal = () => {
|
||||
setErrorModalVisible(false);
|
||||
setSelectedErrorDetails(null);
|
||||
};
|
||||
|
||||
const showSuccessModal = (modelName: string, response: any) => {
|
||||
setSelectedSuccessDetails({
|
||||
modelName,
|
||||
response,
|
||||
});
|
||||
const showSuccessModal = useCallback((modelName: string, response: unknown) => {
|
||||
setSelectedSuccessDetails({ modelName, response });
|
||||
setSuccessModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeSuccessModal = () => {
|
||||
setSuccessModalVisible(false);
|
||||
setSelectedSuccessDetails(null);
|
||||
};
|
||||
|
||||
const healthTableData = (modelData?.data ?? []).map((model: any) => {
|
||||
const modelId = model.model_info?.id;
|
||||
const healthStatus = modelId ? modelHealthStatuses[modelId] : null;
|
||||
const status = healthStatus || {
|
||||
status: "none",
|
||||
lastCheck: "None",
|
||||
loading: false,
|
||||
};
|
||||
return {
|
||||
model_name: model.model_name,
|
||||
model_info: model.model_info,
|
||||
provider: model.provider,
|
||||
litellm_model_name: model.litellm_model_name,
|
||||
health_status: status.status,
|
||||
last_check: status.lastCheck,
|
||||
last_success: status.lastSuccess || "None",
|
||||
health_loading: status.loading,
|
||||
health_error: status.error,
|
||||
health_full_error: status.fullError,
|
||||
};
|
||||
});
|
||||
const healthTableData = useMemo<HealthCheckData[]>(
|
||||
() =>
|
||||
(modelData?.data ?? []).map((model: any) => {
|
||||
const modelId = model.model_info?.id;
|
||||
const healthStatus = modelId ? modelHealthStatuses[modelId] : null;
|
||||
const status = healthStatus || {
|
||||
status: "none",
|
||||
lastCheck: "None",
|
||||
loading: false,
|
||||
};
|
||||
return {
|
||||
model_name: model.model_name,
|
||||
model_info: model.model_info,
|
||||
provider: model.provider,
|
||||
litellm_model_name: model.litellm_model_name,
|
||||
health_status: status.status,
|
||||
last_check: status.lastCheck,
|
||||
last_success: status.lastSuccess || "None",
|
||||
health_loading: status.loading,
|
||||
health_error: status.error,
|
||||
health_full_error: status.fullError,
|
||||
};
|
||||
}),
|
||||
[modelData, modelHealthStatuses],
|
||||
);
|
||||
|
||||
const shouldShowPagination = Boolean(paginationMeta && onPageChange);
|
||||
const totalCount = paginationMeta?.total_count ?? 0;
|
||||
const totalPages = paginationMeta?.total_pages ?? 1;
|
||||
const pageForDisplay = paginationMeta?.current_page ?? currentPage;
|
||||
const pageSizeForDisplay = paginationMeta?.size ?? pageSize;
|
||||
const resultsStart = shouldShowPagination && totalCount > 0 ? (pageForDisplay - 1) * pageSizeForDisplay + 1 : 0;
|
||||
const resultsEnd = shouldShowPagination ? Math.min(pageForDisplay * pageSizeForDisplay, totalCount) : 0;
|
||||
const isPartialSelection = selectedModelIds.length > 0 && selectedModelIds.length < all_models_on_proxy.length;
|
||||
const anyCheckRunning = Object.values(modelHealthStatuses).some((status) => status.loading);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Title>Model Health Status</Title>
|
||||
<Text className="text-gray-600 mt-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">Model Health Status</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Run health checks on individual models to verify they are working correctly
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedModelsForHealth.length > 0 && (
|
||||
<Button size="sm" variant="light" onClick={() => handleSelectAll(false)} className="px-3 py-1 text-sm">
|
||||
{selectedModelIds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRowSelection({})}
|
||||
data-testid="clear-health-selection"
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={runAllHealthChecks}
|
||||
disabled={Object.values(modelHealthStatuses).some((status) => status.loading)}
|
||||
className="px-3 py-1 text-sm"
|
||||
disabled={anyCheckRunning}
|
||||
data-testid="run-health-checks"
|
||||
>
|
||||
{selectedModelsForHealth.length > 0 && selectedModelsForHealth.length < all_models_on_proxy.length
|
||||
? "Run Selected Checks"
|
||||
: "Run All Checks"}
|
||||
{isPartialSelection ? "Run Selected Checks" : "Run All Checks"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{shouldShowPagination && (
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span data-testid="health-results-count" className="text-sm text-gray-700">
|
||||
{totalCount > 0
|
||||
? `Showing ${resultsStart} - ${resultsEnd} of ${totalCount} results`
|
||||
: "Showing 0 results"}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={isLoading || currentPage === 1}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
isLoading || currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={isLoading || currentPage >= totalPages}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
isLoading || currentPage >= totalPages
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ModelDataTable
|
||||
columns={healthCheckColumns(
|
||||
modelHealthStatuses,
|
||||
selectedModelsForHealth,
|
||||
allModelsSelected,
|
||||
handleModelSelection,
|
||||
handleSelectAll,
|
||||
runIndividualHealthCheck,
|
||||
getDisplayModelName,
|
||||
showErrorModal,
|
||||
showSuccessModal,
|
||||
setSelectedModelId,
|
||||
teams,
|
||||
)}
|
||||
data={healthTableData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<HealthChecksTable
|
||||
data={healthTableData}
|
||||
rowCount={rowCount}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
modelHealthStatuses={modelHealthStatuses}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
onRunHealthCheck={runIndividualHealthCheck}
|
||||
onShowError={showErrorModal}
|
||||
onShowSuccess={showSuccessModal}
|
||||
onSelectModel={setSelectedModelId}
|
||||
teams={teams}
|
||||
/>
|
||||
|
||||
{/* Error Modal */}
|
||||
<Modal
|
||||
|
|
@ -623,16 +556,16 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
{selectedErrorDetails && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Error:</Text>
|
||||
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<Text className="text-red-800">{selectedErrorDetails.cleanedError}</Text>
|
||||
<span className="font-medium">Error:</span>
|
||||
<div className="mt-2 rounded-md border border-red-200 bg-red-50 p-3">
|
||||
<span className="text-red-800">{selectedErrorDetails.cleanedError}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Full Error Details:</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md max-h-96 overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">{selectedErrorDetails.fullError}</pre>
|
||||
<span className="font-medium">Full Error Details:</span>
|
||||
<div className="mt-2 max-h-96 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<pre className="text-sm whitespace-pre-wrap text-gray-800">{selectedErrorDetails.fullError}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -656,16 +589,16 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
{selectedSuccessDetails && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Status:</Text>
|
||||
<div className="mt-2 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<Text className="text-green-800">Health check passed successfully</Text>
|
||||
<span className="font-medium">Status:</span>
|
||||
<div className="mt-2 rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<span className="text-green-800">Health check passed successfully</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Response Details:</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md max-h-96 overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
|
||||
<span className="font-medium">Response Details:</span>
|
||||
<div className="mt-2 max-h-96 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<pre className="text-sm whitespace-pre-wrap text-gray-800">
|
||||
{JSON.stringify(selectedSuccessDetails.response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import type { PaginationState, RowSelectionState } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HealthChecksTable } from "./HealthChecksTable";
|
||||
import type { HealthCheckData, HealthStatus } from "./HealthChecksTableColumns";
|
||||
|
||||
const makeRow = (overrides: Partial<HealthCheckData> & { id: string }): HealthCheckData => {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
model_name: `model-${id}`,
|
||||
model_info: { id },
|
||||
health_status: "none",
|
||||
last_check: "None",
|
||||
last_success: "None",
|
||||
health_loading: false,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
interface HarnessProps {
|
||||
data: HealthCheckData[];
|
||||
modelHealthStatuses?: Record<string, HealthStatus>;
|
||||
onRunHealthCheck?: (modelId: string) => void;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
function Harness({ data, modelHealthStatuses = {}, onRunHealthCheck = vi.fn(), onSelectModel }: HarnessProps) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
return (
|
||||
<HealthChecksTable
|
||||
data={data}
|
||||
rowCount={data.length}
|
||||
isLoading={false}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
modelHealthStatuses={modelHealthStatuses}
|
||||
getDisplayModelName={(model) => model.model_name}
|
||||
onRunHealthCheck={onRunHealthCheck}
|
||||
onShowError={vi.fn()}
|
||||
onShowSuccess={vi.fn()}
|
||||
onSelectModel={onSelectModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Row order by model id, read off the per-row selection checkbox (keyed by getRowId). */
|
||||
const rowIds = (): string[] =>
|
||||
screen
|
||||
.getAllByRole("row")
|
||||
.slice(1)
|
||||
.map((row) => row.querySelector('[data-testid^="datatable-select-row-"]'))
|
||||
.filter((node): node is Element => node !== null)
|
||||
.map((node) => (node.getAttribute("data-testid") ?? "").replace("datatable-select-row-", ""));
|
||||
|
||||
describe("HealthChecksTable client sorting", () => {
|
||||
it("orders health status healthy > checking > unknown > unhealthy", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Harness
|
||||
data={[
|
||||
makeRow({ id: "unhealthy-row", health_status: "unhealthy" }),
|
||||
makeRow({ id: "healthy-row", health_status: "healthy" }),
|
||||
makeRow({ id: "weird-row", health_status: "some-other-status" }),
|
||||
makeRow({ id: "checking-row", health_status: "checking" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-health_status"));
|
||||
|
||||
expect(rowIds()).toEqual(["healthy-row", "checking-row", "unhealthy-row", "weird-row"]);
|
||||
});
|
||||
|
||||
it("floats in-progress checks to the top, sinks never-checked, and sorts real checks most-recent-first", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Harness
|
||||
data={[
|
||||
makeRow({ id: "never", last_check: "Never checked" }),
|
||||
makeRow({ id: "older", last_check: "2024-01-01T10:00:00Z" }),
|
||||
makeRow({ id: "in-progress", last_check: "Check in progress..." }),
|
||||
makeRow({ id: "newer", last_check: "2024-06-01T10:00:00Z" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-last_check"));
|
||||
|
||||
expect(rowIds()).toEqual(["in-progress", "newer", "older", "never"]);
|
||||
});
|
||||
|
||||
// "Never succeeded" is ranked below "None" -- both sink, but not to the same slot.
|
||||
it("sinks None below real successes and Never succeeded below None", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Harness
|
||||
data={[
|
||||
makeRow({ id: "never", last_success: "Never succeeded" }),
|
||||
makeRow({ id: "none", last_success: "None" }),
|
||||
makeRow({ id: "older", last_success: "2024-01-01T10:00:00Z" }),
|
||||
makeRow({ id: "newer", last_success: "2024-06-01T10:00:00Z" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-last_success"));
|
||||
|
||||
expect(rowIds()).toEqual(["newer", "older", "none", "never"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HealthChecksTable rows", () => {
|
||||
it("renders the live checking cell while a row is loading and disables its run button", () => {
|
||||
render(<Harness data={[makeRow({ id: "busy", health_loading: true, health_status: "checking" })]} />);
|
||||
|
||||
expect(screen.getByText("Checking...")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("run-health-check-btn")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("runs a health check for the row's model id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRunHealthCheck = vi.fn();
|
||||
render(<Harness data={[makeRow({ id: "deployment-9" })]} onRunHealthCheck={onRunHealthCheck} />);
|
||||
|
||||
await user.click(screen.getByTestId("run-health-check-btn"));
|
||||
|
||||
expect(onRunHealthCheck).toHaveBeenCalledWith("deployment-9");
|
||||
});
|
||||
|
||||
it("opens the model detail from the identity cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectModel = vi.fn();
|
||||
render(<Harness data={[makeRow({ id: "deployment-9" })]} onSelectModel={onSelectModel} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /deployment-9/ }));
|
||||
|
||||
expect(onSelectModel).toHaveBeenCalledWith("deployment-9");
|
||||
});
|
||||
|
||||
it("surfaces the error detail button only when a fuller error exists", () => {
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
data={[makeRow({ id: "m1", health_status: "unhealthy" })]}
|
||||
modelHealthStatuses={{
|
||||
m1: { status: "unhealthy", lastCheck: "x", loading: false, error: "same", fullError: "same" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("view-health-error-btn")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
data={[makeRow({ id: "m1", health_status: "unhealthy" })]}
|
||||
modelHealthStatuses={{
|
||||
m1: { status: "unhealthy", lastCheck: "x", loading: false, error: "short", fullError: "the long form" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("view-health-error-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when the page has no models", () => {
|
||||
render(<Harness data={[]} />);
|
||||
|
||||
expect(screen.getByText("No models found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { OnChangeFn, PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table";
|
||||
import { HeartPulse } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
import { getHealthChecksTableColumns, type HealthCheckData, type HealthStatus } from "./HealthChecksTableColumns";
|
||||
|
||||
interface HealthChecksTableProps {
|
||||
data: HealthCheckData[];
|
||||
rowCount: number;
|
||||
isLoading: boolean;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
rowSelection: RowSelectionState;
|
||||
onRowSelectionChange: OnChangeFn<RowSelectionState>;
|
||||
modelHealthStatuses: Record<string, HealthStatus>;
|
||||
getDisplayModelName: (model: HealthCheckData) => string;
|
||||
onRunHealthCheck: (modelId: string) => void;
|
||||
onShowError: (modelName: string, cleanedError: string, fullError: string) => void;
|
||||
onShowSuccess: (modelName: string, response: unknown) => void;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
teams?: Team[] | null;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<HeartPulse className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No models found</div>
|
||||
<div className="text-sm text-muted-foreground">Models added to this proxy will show their health here.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthChecksTable({
|
||||
data,
|
||||
rowCount,
|
||||
isLoading,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
modelHealthStatuses,
|
||||
getDisplayModelName,
|
||||
onRunHealthCheck,
|
||||
onShowError,
|
||||
onShowSuccess,
|
||||
onSelectModel,
|
||||
teams,
|
||||
}: HealthChecksTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = {
|
||||
modelHealthStatuses,
|
||||
getDisplayModelName,
|
||||
onRunHealthCheck,
|
||||
onShowError,
|
||||
onShowSuccess,
|
||||
onSelectModel,
|
||||
teams,
|
||||
};
|
||||
return getHealthChecksTableColumns(columnDeps);
|
||||
}, [modelHealthStatuses, getDisplayModelName, onRunHealthCheck, onShowError, onShowSuccess, onSelectModel, teams]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row, index) => row.model_info?.id ?? String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
paginationMode="server"
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
rowCount={rowCount}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading models…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Info, Play, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
lastCheck: string;
|
||||
lastSuccess?: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
fullError?: string;
|
||||
successResponse?: unknown;
|
||||
}
|
||||
|
||||
export interface HealthCheckData {
|
||||
model_name: string;
|
||||
model_info: {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
provider?: string;
|
||||
litellm_model_name?: string;
|
||||
health_status: string;
|
||||
last_check: string;
|
||||
last_success: string;
|
||||
health_loading: boolean;
|
||||
health_error?: string;
|
||||
health_full_error?: string;
|
||||
}
|
||||
|
||||
const HEALTH_STATUS_TONES: Record<string, StatusTone> = {
|
||||
healthy: "success",
|
||||
unhealthy: "error",
|
||||
checking: "info",
|
||||
none: "neutral",
|
||||
};
|
||||
|
||||
// healthy > checking > unknown > unhealthy, matching the legacy health table ordering.
|
||||
const HEALTH_STATUS_ORDER: Record<string, number> = { healthy: 0, checking: 1, unknown: 2, unhealthy: 3 };
|
||||
|
||||
const NEVER_CHECKED = "Never checked";
|
||||
const CHECK_IN_PROGRESS = "Check in progress...";
|
||||
const NEVER_SUCCEEDED = "Never succeeded";
|
||||
const NONE = "None";
|
||||
|
||||
function HealthStatusBadge({ status }: { status: string }) {
|
||||
const tone = HEALTH_STATUS_TONES[status];
|
||||
if (!tone) {
|
||||
return <StatusBadge tone="neutral" label="unknown" />;
|
||||
}
|
||||
return <StatusBadge tone={tone} label={status} />;
|
||||
}
|
||||
|
||||
function DotPulse({ className }: { className: string }) {
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
<div className={cn("animate-pulse rounded-full", className)} />
|
||||
<div className={cn("animate-pulse rounded-full", className)} style={{ animationDelay: "0.2s" }} />
|
||||
<div className={cn("animate-pulse rounded-full", className)} style={{ animationDelay: "0.4s" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailButton({
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className: string;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className={cn("cursor-pointer rounded-sm p-1 transition-colors", className)}
|
||||
>
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function runButtonLabel(isLoading: boolean, hasExistingStatus: boolean): string {
|
||||
if (isLoading) {
|
||||
return "Checking...";
|
||||
}
|
||||
if (hasExistingStatus) {
|
||||
return "Re-run Health Check";
|
||||
}
|
||||
return "Run Health Check";
|
||||
}
|
||||
|
||||
function RunButtonIcon({ isLoading, hasExistingStatus }: { isLoading: boolean; hasExistingStatus: boolean }) {
|
||||
if (isLoading) {
|
||||
return <DotPulse className="size-1 bg-gray-400" />;
|
||||
}
|
||||
if (hasExistingStatus) {
|
||||
return <RefreshCw className="size-4" />;
|
||||
}
|
||||
return <Play className="size-4" />;
|
||||
}
|
||||
|
||||
function RunHealthCheckButton({
|
||||
model,
|
||||
onRunHealthCheck,
|
||||
}: {
|
||||
model: HealthCheckData;
|
||||
onRunHealthCheck: (modelId: string) => void;
|
||||
}) {
|
||||
const isLoading = model.health_loading;
|
||||
const hasExistingStatus = Boolean(model.health_status) && model.health_status !== "none";
|
||||
const label = runButtonLabel(isLoading, hasExistingStatus);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="run-health-check-btn"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
disabled={isLoading}
|
||||
onClick={() => onRunHealthCheck(model.model_info?.id ?? "")}
|
||||
className={cn(
|
||||
"rounded-md p-2 transition-colors",
|
||||
isLoading
|
||||
? "cursor-not-allowed bg-gray-100 text-gray-400"
|
||||
: "text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700",
|
||||
)}
|
||||
>
|
||||
<RunButtonIcon isLoading={isLoading} hasExistingStatus={hasExistingStatus} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function compareDatesDesc(rawA: string, rawB: string): number {
|
||||
const dateA = new Date(rawA).getTime();
|
||||
const dateB = new Date(rawB).getTime();
|
||||
if (isNaN(dateA) && isNaN(dateB)) {
|
||||
return 0;
|
||||
}
|
||||
if (isNaN(dateA)) {
|
||||
return 1;
|
||||
}
|
||||
if (isNaN(dateB)) {
|
||||
return -1;
|
||||
}
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the sentinel strings the health table renders in place of a real timestamp.
|
||||
* `bottom` and `top` are checked in order, so an earlier sentinel outranks a later one
|
||||
* (e.g. "Never succeeded" sorts below "None").
|
||||
*/
|
||||
function compareSentinels(
|
||||
rawA: string,
|
||||
rawB: string,
|
||||
bottom: readonly string[],
|
||||
top: readonly string[],
|
||||
): number | null {
|
||||
for (const sentinel of bottom) {
|
||||
if (rawA === sentinel && rawB === sentinel) {
|
||||
return 0;
|
||||
}
|
||||
if (rawA === sentinel) {
|
||||
return 1;
|
||||
}
|
||||
if (rawB === sentinel) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const sentinel of top) {
|
||||
if (rawA === sentinel && rawB === sentinel) {
|
||||
return 0;
|
||||
}
|
||||
if (rawA === sentinel) {
|
||||
return -1;
|
||||
}
|
||||
if (rawB === sentinel) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface HealthChecksTableColumnsDeps {
|
||||
modelHealthStatuses: Record<string, HealthStatus>;
|
||||
getDisplayModelName: (model: HealthCheckData) => string;
|
||||
onRunHealthCheck: (modelId: string) => void;
|
||||
onShowError: (modelName: string, cleanedError: string, fullError: string) => void;
|
||||
onShowSuccess: (modelName: string, response: unknown) => void;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
teams?: Team[] | null;
|
||||
}
|
||||
|
||||
export const getHealthChecksTableColumns = ({
|
||||
modelHealthStatuses,
|
||||
getDisplayModelName,
|
||||
onRunHealthCheck,
|
||||
onShowError,
|
||||
onShowSuccess,
|
||||
onSelectModel,
|
||||
teams,
|
||||
}: HealthChecksTableColumnsDeps): ColumnDef<HealthCheckData>[] => [
|
||||
createSelectionColumn<HealthCheckData>({
|
||||
rowAriaLabel: (row) => `Select ${row.original.model_info?.id ?? row.original.model_name}`,
|
||||
}),
|
||||
{
|
||||
id: "model_id",
|
||||
accessorFn: (row) => row.model_info?.id ?? "",
|
||||
meta: { title: "Model ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Model ID" variant="header-cycle" />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const modelId = row.original.model_info?.id ?? "";
|
||||
return (
|
||||
<IdentityCell
|
||||
title={modelId}
|
||||
titleClassName="font-mono text-xs text-primary"
|
||||
onClick={onSelectModel ? () => onSelectModel(modelId) : undefined}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "model_name",
|
||||
accessorKey: "model_name",
|
||||
meta: { title: "Model Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Model Name" variant="header-cycle" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const displayName = getDisplayModelName(row.original) || row.original.model_name;
|
||||
return (
|
||||
<span className="block max-w-50 truncate text-sm font-medium" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorFn: (row) => row.model_info?.team_id ?? "",
|
||||
meta: { title: "Team Alias" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Team Alias" variant="header-cycle" />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const teamId = row.original.model_info?.team_id;
|
||||
if (!teamId) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
const teamAlias = teams?.find((team) => team.team_id === teamId)?.team_alias || teamId;
|
||||
return (
|
||||
<span className="block max-w-40 truncate text-sm" title={teamAlias}>
|
||||
{teamAlias}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "health_status",
|
||||
accessorKey: "health_status",
|
||||
meta: { title: "Health Status", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Health Status" variant="header-cycle" />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const statusA = (rowA.getValue("health_status") as string) || "unknown";
|
||||
const statusB = (rowB.getValue("health_status") as string) || "unknown";
|
||||
const orderA = HEALTH_STATUS_ORDER[statusA] ?? 4;
|
||||
const orderB = HEALTH_STATUS_ORDER[statusB] ?? 4;
|
||||
return orderA - orderB;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
|
||||
if (model.health_loading) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<DotPulse className="size-2 bg-indigo-500" />
|
||||
<span className="text-sm text-muted-foreground">Checking...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
const successResponse = modelHealthStatuses[modelId]?.successResponse;
|
||||
const hasSuccessResponse = model.health_status === "healthy" && successResponse !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<HealthStatusBadge status={model.health_status} />
|
||||
{hasSuccessResponse && (
|
||||
<DetailButton
|
||||
label="View response details"
|
||||
testId="view-health-success-btn"
|
||||
className="text-green-600 hover:bg-green-50 hover:text-green-800"
|
||||
onClick={() => onShowSuccess(displayName, successResponse)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "health_error",
|
||||
accessorKey: "health_error",
|
||||
meta: { title: "Error Details" },
|
||||
header: "Error Details",
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const healthStatus = modelHealthStatuses[modelId];
|
||||
|
||||
if (!healthStatus?.error) {
|
||||
return <span className="text-sm text-muted-foreground">No errors</span>;
|
||||
}
|
||||
|
||||
const cleanedError = healthStatus.error;
|
||||
const fullError = healthStatus.fullError || healthStatus.error;
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="block max-w-50 truncate text-sm text-red-600" title={cleanedError}>
|
||||
{cleanedError}
|
||||
</span>
|
||||
{fullError !== cleanedError && (
|
||||
<DetailButton
|
||||
label="View full error details"
|
||||
testId="view-health-error-btn"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-800"
|
||||
onClick={() => onShowError(displayName, cleanedError, fullError)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "last_check",
|
||||
accessorKey: "last_check",
|
||||
meta: { title: "Last Check" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Last Check" variant="header-cycle" />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const rawA = (rowA.getValue("last_check") as string) || NEVER_CHECKED;
|
||||
const rawB = (rowB.getValue("last_check") as string) || NEVER_CHECKED;
|
||||
const sentinel = compareSentinels(rawA, rawB, [NEVER_CHECKED], [CHECK_IN_PROGRESS]);
|
||||
return sentinel ?? compareDatesDesc(rawA, rawB);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.health_loading ? CHECK_IN_PROGRESS : row.original.last_check}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "last_success",
|
||||
accessorKey: "last_success",
|
||||
meta: { title: "Last Success" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Last Success" variant="header-cycle" />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const rawA = (rowA.getValue("last_success") as string) || NEVER_SUCCEEDED;
|
||||
const rawB = (rowB.getValue("last_success") as string) || NEVER_SUCCEEDED;
|
||||
const sentinel = compareSentinels(rawA, rawB, [NEVER_SUCCEEDED, NONE], []);
|
||||
return sentinel ?? compareDatesDesc(rawA, rawB);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const modelId = row.original.model_info?.id ?? "";
|
||||
const lastSuccess = modelHealthStatuses[modelId]?.lastSuccess || NONE;
|
||||
return <span className="text-sm text-muted-foreground">{lastSuccess}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { title: "Actions", className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<RunHealthCheckButton model={row.original} onRunHealthCheck={onRunHealthCheck} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Tooltip, Checkbox } from "antd";
|
||||
import { Text } from "@tremor/react";
|
||||
import { InformationCircleIcon, PlayIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { IdCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
|
||||
interface HealthCheckData {
|
||||
model_name: string;
|
||||
model_info: {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
provider?: string;
|
||||
litellm_model_name?: string;
|
||||
health_status: string;
|
||||
last_check: string;
|
||||
last_success: string;
|
||||
health_loading: boolean;
|
||||
health_error?: string;
|
||||
health_full_error?: string;
|
||||
}
|
||||
|
||||
const HEALTH_STATUS_TONES: Record<string, StatusTone> = {
|
||||
healthy: "success",
|
||||
unhealthy: "error",
|
||||
checking: "info",
|
||||
none: "neutral",
|
||||
};
|
||||
|
||||
const healthStatusBadge = (status: string): JSX.Element => {
|
||||
const tone = HEALTH_STATUS_TONES[status];
|
||||
return tone ? <StatusBadge tone={tone} label={status} /> : <StatusBadge tone="neutral" label="unknown" />;
|
||||
};
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
lastCheck: string;
|
||||
lastSuccess?: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
fullError?: string;
|
||||
successResponse?: any;
|
||||
}
|
||||
|
||||
export const healthCheckColumns = (
|
||||
modelHealthStatuses: { [key: string]: HealthStatus },
|
||||
selectedModelsForHealth: string[],
|
||||
allModelsSelected: boolean,
|
||||
handleModelSelection: (modelId: string, checked: boolean) => void,
|
||||
handleSelectAll: (checked: boolean) => void,
|
||||
runIndividualHealthCheck: (modelId: string) => void,
|
||||
getDisplayModelName: (model: any) => string,
|
||||
showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void,
|
||||
showSuccessModal?: (modelName: string, response: any) => void,
|
||||
setSelectedModelId?: (modelId: string) => void,
|
||||
teams?: Team[] | null,
|
||||
): ColumnDef<HealthCheckData>[] => [
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allModelsSelected}
|
||||
indeterminate={selectedModelsForHealth.length > 0 && !allModelsSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span>Model ID</span>
|
||||
</div>
|
||||
),
|
||||
accessorKey: "model_info.id",
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const isSelected = selectedModelsForHealth.includes(modelId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleModelSelection(modelId, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<IdCell value={model.model_info.id} onClick={setSelectedModelId} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Model Name",
|
||||
accessorKey: "model_name",
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
|
||||
return (
|
||||
<div className="font-medium text-sm">
|
||||
<Tooltip title={displayName}>
|
||||
<div className="truncate max-w-[200px]">{displayName}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Team Alias",
|
||||
accessorKey: "model_info.team_id",
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const teamId = model.model_info?.team_id;
|
||||
|
||||
if (!teamId) {
|
||||
return <span className="text-gray-400 text-sm">-</span>;
|
||||
}
|
||||
|
||||
const team = teams?.find((t) => t.team_id === teamId);
|
||||
const teamAlias = team?.team_alias || teamId;
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<Tooltip title={teamAlias}>
|
||||
<div className="truncate max-w-[150px]">{teamAlias}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Health Status",
|
||||
accessorKey: "health_status",
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const statusA = (rowA.getValue("health_status") as string) || "unknown";
|
||||
const statusB = (rowB.getValue("health_status") as string) || "unknown";
|
||||
|
||||
// Define sorting order: healthy > checking > unknown > unhealthy
|
||||
const statusOrder = { healthy: 0, checking: 1, unknown: 2, unhealthy: 3 };
|
||||
const orderA = statusOrder[statusA as keyof typeof statusOrder] ?? 4;
|
||||
const orderB = statusOrder[statusB as keyof typeof statusOrder] ?? 4;
|
||||
|
||||
return orderA - orderB;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const healthStatus = {
|
||||
status: model.health_status,
|
||||
loading: model.health_loading,
|
||||
error: model.health_error,
|
||||
};
|
||||
|
||||
if (healthStatus.loading) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.4s" }}
|
||||
></div>
|
||||
</div>
|
||||
<Text className="text-gray-600 text-sm">Checking...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelId]?.successResponse;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{healthStatusBadge(healthStatus.status)}
|
||||
{hasSuccessResponse && showSuccessModal && (
|
||||
<Tooltip title="View response details" placement="top">
|
||||
<button
|
||||
onClick={() => showSuccessModal(displayName, modelHealthStatuses[modelId]?.successResponse)}
|
||||
className="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-sm cursor-pointer transition-colors"
|
||||
>
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Error Details",
|
||||
accessorKey: "health_error",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
const healthStatus = modelHealthStatuses[modelId];
|
||||
|
||||
if (!healthStatus?.error) {
|
||||
return <Text className="text-gray-400 text-sm">No errors</Text>;
|
||||
}
|
||||
|
||||
const cleanedError = healthStatus.error;
|
||||
const fullError = healthStatus.fullError || healthStatus.error;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="max-w-[200px]">
|
||||
<Tooltip title={cleanedError} placement="top">
|
||||
<Text className="text-red-600 text-sm truncate">{cleanedError}</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{showErrorModal && fullError !== cleanedError && (
|
||||
<Tooltip title="View full error details" placement="top">
|
||||
<button
|
||||
onClick={() => showErrorModal(displayName, cleanedError, fullError)}
|
||||
className="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-sm cursor-pointer transition-colors"
|
||||
>
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Last Check",
|
||||
accessorKey: "last_check",
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const lastCheckA = (rowA.getValue("last_check") as string) || "Never checked";
|
||||
const lastCheckB = (rowB.getValue("last_check") as string) || "Never checked";
|
||||
|
||||
// Handle special cases
|
||||
if (lastCheckA === "Never checked" && lastCheckB === "Never checked") return 0;
|
||||
if (lastCheckA === "Never checked") return 1; // Never checked goes to bottom
|
||||
if (lastCheckB === "Never checked") return -1;
|
||||
if (lastCheckA === "Check in progress..." && lastCheckB === "Check in progress...") return 0;
|
||||
if (lastCheckA === "Check in progress...") return -1; // In progress goes to top
|
||||
if (lastCheckB === "Check in progress...") return 1;
|
||||
|
||||
// Parse dates for comparison
|
||||
const dateA = new Date(lastCheckA);
|
||||
const dateB = new Date(lastCheckB);
|
||||
|
||||
// If dates are invalid, treat as never checked
|
||||
if (isNaN(dateA.getTime()) && isNaN(dateB.getTime())) return 0;
|
||||
if (isNaN(dateA.getTime())) return 1;
|
||||
if (isNaN(dateB.getTime())) return -1;
|
||||
|
||||
// Sort by date (most recent first)
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
|
||||
return (
|
||||
<Text className="text-gray-600 text-sm">
|
||||
{model.health_loading ? "Check in progress..." : model.last_check}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Last Success",
|
||||
accessorKey: "last_success",
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const lastSuccessA = (rowA.getValue("last_success") as string) || "Never succeeded";
|
||||
const lastSuccessB = (rowB.getValue("last_success") as string) || "Never succeeded";
|
||||
|
||||
// Handle special cases
|
||||
if (lastSuccessA === "Never succeeded" && lastSuccessB === "Never succeeded") return 0;
|
||||
if (lastSuccessA === "Never succeeded") return 1; // Never succeeded goes to bottom
|
||||
if (lastSuccessB === "Never succeeded") return -1;
|
||||
if (lastSuccessA === "None" && lastSuccessB === "None") return 0;
|
||||
if (lastSuccessA === "None") return 1; // None goes to bottom
|
||||
if (lastSuccessB === "None") return -1;
|
||||
|
||||
// Parse dates for comparison
|
||||
const dateA = new Date(lastSuccessA);
|
||||
const dateB = new Date(lastSuccessB);
|
||||
|
||||
// If dates are invalid, treat as never succeeded
|
||||
if (isNaN(dateA.getTime()) && isNaN(dateB.getTime())) return 0;
|
||||
if (isNaN(dateA.getTime())) return 1;
|
||||
if (isNaN(dateB.getTime())) return -1;
|
||||
|
||||
// Sort by date (most recent first)
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const healthStatus = modelHealthStatuses[modelId];
|
||||
const lastSuccess = healthStatus?.lastSuccess || "None";
|
||||
|
||||
return <Text className="text-gray-600 text-sm">{lastSuccess}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
|
||||
const hasExistingStatus = model.health_status && model.health_status !== "none";
|
||||
const tooltipText = model.health_loading
|
||||
? "Checking..."
|
||||
: hasExistingStatus
|
||||
? "Re-run Health Check"
|
||||
: "Run Health Check";
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipText} placement="top">
|
||||
<button
|
||||
data-testid="run-health-check-btn"
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
model.health_loading
|
||||
? "text-gray-400 cursor-not-allowed bg-gray-100"
|
||||
: "text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!model.health_loading) {
|
||||
runIndividualHealthCheck(modelId);
|
||||
}
|
||||
}}
|
||||
disabled={model.health_loading}
|
||||
>
|
||||
{model.health_loading ? (
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-1 h-1 bg-gray-400 rounded-full animate-pulse"></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-gray-400 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-gray-400 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.4s" }}
|
||||
></div>
|
||||
</div>
|
||||
) : hasExistingStatus ? (
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
ColumnResizeMode,
|
||||
VisibilityState,
|
||||
PaginationState,
|
||||
OnChangeFn,
|
||||
} from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
|
||||
// Extend the column meta type to include className
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData, TValue> {
|
||||
className?: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface ModelDataTableProps<TData, TValue> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
isLoading?: boolean;
|
||||
defaultSorting?: SortingState;
|
||||
pagination?: PaginationState;
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
enablePagination?: boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
export function ModelDataTable<TData, TValue>({
|
||||
data = [],
|
||||
columns,
|
||||
isLoading = false,
|
||||
defaultSorting = [],
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
enablePagination = false,
|
||||
onRowClick,
|
||||
}: ModelDataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>(defaultSorting);
|
||||
const [columnResizeMode] = React.useState<ColumnResizeMode>("onChange");
|
||||
const [columnSizing, setColumnSizing] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
|
||||
const tableInstance = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnSizing,
|
||||
columnVisibility,
|
||||
...(enablePagination && pagination ? { pagination } : {}),
|
||||
},
|
||||
columnResizeMode,
|
||||
onSortingChange: setSorting,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
...(enablePagination && onPaginationChange ? { onPaginationChange } : {}),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
enableSorting: true,
|
||||
enableColumnResizing: true,
|
||||
defaultColumn: {
|
||||
minSize: 40,
|
||||
maxSize: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const getHeaderText = (header: any): string => {
|
||||
if (typeof header === "string") {
|
||||
return header;
|
||||
}
|
||||
if (typeof header === "function") {
|
||||
const headerElement = header();
|
||||
if (headerElement && headerElement.props && headerElement.props.children) {
|
||||
const children = headerElement.props.children;
|
||||
if (typeof children === "string") {
|
||||
return children;
|
||||
}
|
||||
if (children.props && children.props.children) {
|
||||
return children.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="relative min-w-full">
|
||||
<Table
|
||||
className="[&_td]:py-2 [&_th]:py-2"
|
||||
style={{
|
||||
width: tableInstance.getTotalSize(),
|
||||
minWidth: "100%",
|
||||
tableLayout: "fixed",
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
{tableInstance.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 relative ${
|
||||
header.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
|
||||
: ""
|
||||
} ${header.column.columnDef.meta?.className || ""}`}
|
||||
style={{
|
||||
width: header.id === "actions" ? 120 : header.getSize(),
|
||||
position: header.id === "actions" ? "sticky" : "relative",
|
||||
right: header.id === "actions" ? 0 : "auto",
|
||||
}}
|
||||
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{header.id !== "actions" && header.column.getCanSort() && (
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none ${
|
||||
header.column.getIsResizing() ? "bg-blue-500" : "hover:bg-blue-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading models...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : tableInstance.getRowModel().rows.length > 0 ? (
|
||||
tableInstance.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 overflow-hidden ${
|
||||
cell.column.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
|
||||
: ""
|
||||
} ${cell.column.columnDef.meta?.className || ""}`}
|
||||
style={{
|
||||
width: cell.column.id === "actions" ? 120 : cell.column.getSize(),
|
||||
position: cell.column.id === "actions" ? "sticky" : "relative",
|
||||
right: cell.column.id === "actions" ? 0 : "auto",
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No models found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
uv.lock
generated
6
uv.lock
generated
|
|
@ -10,7 +10,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-07-18T21:57:43.13625Z"
|
||||
exclude-newer = "2026-07-19T00:00:06.091071Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -3966,7 +3966,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.94.0"
|
||||
version = "1.95.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -4350,7 +4350,7 @@ source = { editable = "enterprise" }
|
|||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.79"
|
||||
version = "0.4.80"
|
||||
source = { editable = "litellm-proxy-extras" }
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue