Merge latest staging into OTEL v2 destinations

This commit is contained in:
yucheng 2026-07-22 00:15:47 +00:00
commit ddc3ac39bf
32 changed files with 3611 additions and 3064 deletions

View file

@ -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==",

View file

@ -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",
]

View file

@ -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

View file

@ -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": {

View file

@ -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",

View file

@ -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();
});
});

View file

@ -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>
),

View file

@ -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} />;
}

View file

@ -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);
});
});
});
});

View file

@ -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 */}

View file

@ -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();
});
});

View file

@ -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>
</>
)}
/>
);
}

View file

@ -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,
];
};

View file

@ -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;
};

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

@ -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();
});
});

View file

@ -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>
</>
)}
/>
);
}

View file

@ -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"
/>
),
},
];

View file

@ -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 }));

View file

@ -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>
);

View file

@ -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();

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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"
/>
);
}

View file

@ -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>
),
},
];

View file

@ -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,
},
];

View file

@ -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
View file

@ -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]]