diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 3288f7fd584..ccca88c9996 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -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==", diff --git a/pyproject.toml b/pyproject.toml index 9e2f5c4e3ac..080d06258ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index c8a2883e729..6072c8c725b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 49d289879d3..742c1e4a63f 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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": { diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index c29b53cd818..0f54c536297 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 6d05dfaba58..29655e5910a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -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(); - return { - ...actual, - getGuardrailLogoAndName: vi.fn(() => ({ - logo: null, - displayName: "Test Provider", - })), - }; -}); +vi.mock("./guardrail_info_helpers", async (importOriginal) => ({ + ...(await importOriginal()), + 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(); + 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(); + 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(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 672bcc2aa95..72f4fb8caa3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -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 = ({ premiumUser, te const [selectedModelId, setSelectedModelId] = useState(null); const [selectedTeamId, setSelectedTeamId] = useState(null); const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const [healthCurrentPage, setHealthCurrentPage] = useState(1); + const [healthPagination, setHealthPagination] = useState({ + 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 = ({ 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 = ({ 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 = ({ premiumUser, te setSelectedModelId={setSelectedModelId} teams={teams} isLoading={isLoadingHealthModels} - paginationMeta={healthPaginationMeta} - currentPage={healthCurrentPage} - pageSize={HEALTH_PAGE_SIZE} - onPageChange={setHealthCurrentPage} + pagination={healthPagination} + onPaginationChange={setHealthPagination} + rowCount={healthRowCount} /> ), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx index 6aaebaab959..08fded8dca6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx @@ -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 ; + const { accessToken } = useAuthorized(); + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 996fd58efc1..8dc11babd72 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -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
{`detail:${userId}:${String(Boolean(startInEditMode))}`}
; + }, +})); + // 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( + + + , + ); + +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( - - - , - ); + 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( - - - , - ); + 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); + }); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index db3b17d6af3..ce912c09373 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -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 = ({ accessToken, @@ -79,34 +61,30 @@ const ViewUserDashboard: React.FC = ({ }) => { const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; const queryClient = useQueryClient(); - const [currentPage, setCurrentPage] = useState(1); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }); + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [columnFilters, setColumnFilters] = useState([]); + const [searchInput, setSearchInput] = useState(""); + const [searchEmail] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); + + const [rowSelection, setRowSelection] = useState({}); + const [selectionMode, setSelectionMode] = useState(false); + const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); + + const [selectedUserId, setSelectedUserId] = useState(null); + const [openInEditMode, setOpenInEditMode] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeletingUser, setIsDeletingUser] = useState(false); const [userToDelete, setUserToDelete] = useState(null); - const [activeTab, setActiveTab] = useState("users"); - const [filters, setFilters] = useState(initialFilters); - const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: DEBOUNCE_WAIT_MS }); const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); - const [selectedUsers, setSelectedUsers] = useState([]); - const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); - const [selectionMode, setSelectionMode] = useState(false); const [userModels, setUserModels] = useState([]); - const handleDelete = (user: UserInfo) => { - setUserToDelete(user); - setIsDeleteModalOpen(true); - }; - - useEffect(() => { - return () => { - debouncer.cancel(); - }; - }, [debouncer]); - useEffect(() => { setBaseUrl(getProxyBaseUrl()); }, []); @@ -130,32 +108,69 @@ const ViewUserDashboard: React.FC = ({ fetchUserModels(); }, [accessToken, userID, userRole]); - const updateFilters = (update: Partial) => { - 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>((updaterOrValue) => { + setSorting(updaterOrValue); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + setRowSelection({}); + }, []); + + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + setRowSelection({}); + }, []); + + const handlePaginationChange = useCallback>((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 = ({ // 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>>({ queryKey: ["userRoles"], @@ -284,28 +304,61 @@ const ViewUserDashboard: React.FC = ({ }); 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(() => 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 ( + + ); + } + + const usersTable = ( + ); return (
- {userListQuery.isLoading ? ( + {userListQuery.isLoading && ( <> - ) : userID && accessToken ? ( + )} + {!userListQuery.isLoading && userID && accessToken && ( <> {isProxyAdmin && ( = ({ onClick={handleToggleSelectionMode} type={selectionMode ? "primary" : "default"} className="flex items-center" + data-testid="toggle-user-selection" > {selectionMode ? "Cancel Selection" : "Select Users"} @@ -329,57 +383,28 @@ const ViewUserDashboard: React.FC = ({ {isProxyAdmin && selectionMode && ( )} - ) : null} + )}
{isProxyAdmin ? ( - setActiveTab(index === 0 ? "users" : "settings")}> + Users Default User Settings - - { - 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} - /> - + {usersTable} {!userID || !userRole || !accessToken ? ( @@ -398,35 +423,7 @@ const ViewUserDashboard: React.FC = ({ ) : ( - { - 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 */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx new file mode 100644 index 00000000000..4689ef7cf96 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx @@ -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 => + ({ + 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; +} + +/** + * 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([{ id: "created_at", desc: true }]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [rowSelection, setRowSelection] = useState({}); + + return ( + <> + + {Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .sort() + .join(",")} + + { + 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, userId: string) => { + await user.click(screen.getByTestId(`user-actions-${userId}`)); +}; + +describe("UsersTable", () => { + it("renders every migrated column header", () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Active"); + + rerender()]} />); + expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Inactive"); + + rerender()]} />); + 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(); + expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument(); + expect(screen.queryByTestId("datatable-select-row-user-1")).not.toBeInTheDocument(); + + rerender(); + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText("No users found")).toBeInTheDocument(); + }); + + it("shows skeleton rows on the initial load instead of the empty state", () => { + render(); + + 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(); + + const rows = screen.getAllByRole("row"); + const dataRow = rows[rows.length - 1]; + expect(within(dataRow).getByTestId("user-actions-user-1")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx new file mode 100644 index 00000000000..26663f5d9e2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx @@ -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> | null; + teams: UsersTableTeamOption[] | null; + sorting: SortingState; + onSortingChange: OnChangeFn; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + searchValue: string; + onSearchChange: (value: string) => void; + selectionEnabled: boolean; + rowSelection: RowSelectionState; + onRowSelectionChange: OnChangeFn; + onUserClick: (userId: string, openInEditMode?: boolean) => void; + onDeleteUser: (user: UserInfo) => void; + onResetPassword: (userId: string) => void; +} + +const FILTER_LABELS: Record = { + user_id: "User ID", + sso_user_id: "SSO ID", + user_role: "Role", + team: "Team", +}; + +function EmptyState() { + return ( +
+
+ +
+
No users found
+
Try adjusting your search or filters.
+
+ ); +} + +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 ( + 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={} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + /> + + {({ get, set }) => ( + <> + + set("user_id", event.target.value)} + placeholder="Enter user ID…" + data-testid="users-filter-user-id" + /> + + + set("sso_user_id", event.target.value)} + placeholder="Enter SSO ID…" + data-testid="users-filter-sso-id" + /> + + + set("user_role", value)} + placeholder="Select a role…" + emptyText="No roles found" + /> + + + set("team", value)} + placeholder="Select a team…" + emptyText="No teams found" + /> + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx new file mode 100644 index 00000000000..6c569f205e5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx @@ -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 | 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 ( + + + + + + onUserClick(user.user_id, true)} data-testid="user-action-edit"> + + Edit user + + onResetPassword(user.user_id)} data-testid="user-action-reset-password"> + + Reset password + + void copyToClipboard(user.user_id, "User ID copied")} + data-testid="user-action-copy" + > + + Copy user ID + + + onDeleteUser(user)} data-testid="user-action-delete"> + + Delete user + + + + ); +} + +export interface UsersTableColumnsDeps { + possibleUIRoles: Record> | 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[] => { + const baseColumns: ColumnDef[] = [ + { + id: "user_id", + accessorKey: "user_id", + meta: { title: "User ID" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + onUserClick(row.original.user_id, false)} + /> + ), + }, + { + id: "user_email", + accessorKey: "user_email", + meta: { title: "Email" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.user_email || "-"} + + ), + }, + { + id: "status", + meta: { title: "Status", skeleton: "badge" }, + header: "Status", + size: 110, + enableSorting: false, + cell: ({ row }) => { + if (isScimInactive(row.original)) { + return ( + + ); + } + return ; + }, + }, + { + id: "user_role", + accessorKey: "user_role", + meta: { title: "Global Proxy Role" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + cell: ({ row }) => {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}, + }, + { + id: "user_alias", + accessorKey: "user_alias", + meta: { title: "User Alias" }, + header: "User Alias", + size: 150, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.user_alias || "-"} + + ), + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Spend (USD)", numeric: true }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "max_budget", + accessorKey: "max_budget", + meta: { title: "Budget (USD)", numeric: true }, + header: "Budget (USD)", + size: 130, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "sso_user_id", + accessorKey: "sso_user_id", + meta: { title: "SSO ID" }, + header: () => ( + + SSO ID + } + /> + + ), + size: 160, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.sso_user_id ?? "-"} + + ), + }, + { + 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 ( + + {keyCount} {keyCount === 1 ? "Key" : "Keys"} + + ); + } + return ( + + No Keys + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated At" }, + header: "Updated At", + size: 130, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 60, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, + ]; + + if (!includeSelection) { + return baseColumns; + } + + return [ + createSelectionColumn({ + rowAriaLabel: (row) => `Select ${row.original.user_email || row.original.user_id}`, + }), + ...baseColumns, + ]; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx deleted file mode 100644 index fc680cb5b1b..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx +++ /dev/null @@ -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>, - handleEdit: (user: UserInfo) => void, - handleDelete: (user: UserInfo) => void, - handleResetPassword: (userId: string) => void, - handleUserClick: (userId: string, openInEditMode?: boolean) => void, - selectionOptions?: SelectionOptions, -): ColumnDef[] => { - // Backend sortable columns: user_id, user_email, created_at, spend, user_alias, user_role - const baseColumns: ColumnDef[] = [ - { - header: "User ID", - accessorKey: "user_id", - enableSorting: true, - cell: ({ row }) => , - }, - { - header: "Email", - accessorKey: "user_email", - enableSorting: true, - cell: ({ row }) => {row.original.user_email || "-"}, - }, - { - id: "status", - header: "Status", - enableSorting: false, - cell: ({ row }) => { - const isScimInactive = - (row.original.metadata as Record | null | undefined)?.scim_active === false; - if (isScimInactive) { - return ( - - - Inactive - - - ); - } - return ( - - Active - - ); - }, - }, - { - header: "Global Proxy Role", - accessorKey: "user_role", - enableSorting: true, - cell: ({ row }) => {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}, - }, - { - header: "User Alias", - accessorKey: "user_alias", - enableSorting: false, - cell: ({ row }) => {row.original.user_alias || "-"}, - }, - { - header: "Spend (USD)", - accessorKey: "spend", - enableSorting: true, - cell: ({ row }) => , - }, - { - header: "Budget (USD)", - accessorKey: "max_budget", - enableSorting: false, - cell: ({ row }) => , - }, - { - header: () => ( -
- SSO ID - - - -
- ), - accessorKey: "sso_user_id", - enableSorting: false, - cell: ({ row }) => ( - {row.original.sso_user_id !== null ? row.original.sso_user_id : "-"} - ), - }, - { - header: "Virtual Keys", - accessorKey: "key_count", - enableSorting: false, - cell: ({ row }) => ( - - {row.original.key_count > 0 ? ( - - {row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"} - - ) : ( - - No Keys - - )} - - ), - }, - { - header: "Created At", - accessorKey: "created_at", - enableSorting: true, - cell: ({ row }) => , - }, - { - header: "Updated At", - accessorKey: "updated_at", - enableSorting: false, - cell: ({ row }) => , - }, - { - id: "actions", - header: "Actions", - enableSorting: false, - cell: ({ row }) => ( -
- - handleUserClick(row.original.user_id, true)} - className="cursor-pointer hover:text-blue-600" - /> - - - handleDelete(row.original)} - className="cursor-pointer hover:text-red-600" - /> - - - handleResetPassword(row.original.user_id)} - className="cursor-pointer hover:text-green-600" - /> - -
- ), - }, - ]; - - // Add selection column if selection is enabled - if (selectionOptions) { - const { onSelectUser, onSelectAll, isUserSelected, isAllSelected, isIndeterminate } = selectionOptions; - - return [ - { - id: "select", - enableSorting: false, - header: () => ( - onSelectAll(e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - ), - cell: ({ row }) => ( - onSelectUser(row.original, e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - ), - }, - ...baseColumns, - ]; - } - - return baseColumns; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx deleted file mode 100644 index 695aaa30cd7..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx +++ /dev/null @@ -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> | 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(); - - 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( - , - ); - - 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(); - - 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(); - - 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(); - - [ - "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(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx deleted file mode 100644 index 1ba09243a08..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx +++ /dev/null @@ -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[]; - isLoading?: boolean; - onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; - currentSort?: { - sortBy: string; - sortOrder: "asc" | "desc"; - }; - accessToken: string | null; - userRole: string | null; - possibleUIRoles: Record> | 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) => 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([ - { - id: currentSort?.sortBy || "created_at", - desc: currentSort?.sortOrder === "desc", - }, - ]); - const [selectedUserId, setSelectedUserId] = React.useState(null); - const [openInEditMode, setOpenInEditMode] = React.useState(false); - const [showFilters, setShowFilters] = React.useState(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 ( - - ); - } - - return ( -
- {/* Filter Section */} -
-
- {/* Search and Filter Controls */} -
- {/* Email Search */} - updateFilters({ email: value })} - icon={Search} - /> - - {/* Filter Button */} - setShowFilters(!showFilters)} - active={showFilters} - hasActiveFilters={!!(filters.user_id || filters.user_role || filters.team)} - /> - - {/* Reset Filters Button */} - { - updateFilters(initialFilters); - }} - /> -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* User ID Search */} - updateFilters({ user_id: value })} - icon={User} - /> - - updateFilters({ sso_user_id: value })} - icon={CircleUserRound} - /> - - {/* Role Dropdown */} -
- -
- - {/* Team Dropdown */} -
- -
-
- )} - - {/* Results Count and Pagination */} -
- {isLoading ? ( - - ) : ( - - 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 - - )} - - {/* Pagination Buttons */} -
- {isLoading ? ( - <> - - - - ) : ( - <> - - - - )} -
-
-
-
- - {/* Table Section */} -
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

🚅 Loading users...

-
-
-
- ) : data.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - { - 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())} - - ))} - - )) - ) : ( - - -
-

No users found

-
-
-
- )} -
-
-
-
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/ToolPolicies.tsx b/ui/litellm-dashboard/src/components/ToolPolicies.tsx deleted file mode 100644 index 4468334f813..00000000000 --- a/ui/litellm-dashboard/src/components/ToolPolicies.tsx +++ /dev/null @@ -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 = ({ accessToken, onSelectTool }) => { - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(true); - const [isFetching, setIsFetching] = useState(false); - const [error, setError] = useState(null); - const [savingInput, setSavingInput] = useState(null); - const [savingOutput, setSavingOutput] = useState(null); - - const [searchTerm, setSearchTerm] = useState(""); - const [sortField, setSortField] = useState("created_at"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const [currentPage, setCurrentPage] = useState(1); - const [isLiveTail, setIsLiveTail] = useState(true); - const [activeFilters, setActiveFilters] = useState({}); - 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 }) => ( -
- {label} - handleSortChange(field, s)} - /> -
- ); - - 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 ( -
-

Tool Policies

- -
- - - - } - /> - - 0 ? "text-red-600" : undefined} - /> - 0 ? activeTeamsCount : "—"} /> -
- - {needsReviewTools.length > 0 && ( -
-

Needs Review

-

- {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy - decisions. -

-
- {needsReviewTools.map((t) => ( - - - {t.tool_name} - - - - ))} -
-
- )} - -
-
-
-
-
- { - setSearchTerm(e.target.value); - setCurrentPage(1); - }} - /> - - - -
- -
- Live Tail - -
- - -
- -
- - Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "} - {Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results - - - Page {currentPage} of {totalPages} - -
- - -
-
-
- -
- -
-
- - {isLiveTail && ( -
- Auto-refreshing every 15 seconds - -
- )} - - {error && ( -
{error}
- )} - - - - - - - - - - - - - - - - - - - - - - - Key Hash - - - - User Agent - - - - {loading ? ( - - - Loading tools… - - - ) : paginated.length === 0 ? ( - - - No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery. - - - ) : ( - paginated.map((tool) => ( - - - - - - - - - - - - - - -
- {(tool.call_count ?? 0).toLocaleString()} -
-
- - - - - - - - - {tool.key_alias ?? "-"} - - - - - - {tool.user_agent ?? "-"} - - - -
- )) - )} -
-
- - {totalPages > 1 && ( -
- - Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "} - {sorted.length} - -
- - -
-
- )} -
-
- ); -}; - -export default ToolPolicies; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx new file mode 100644 index 00000000000..721c215cfb1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -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, 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(); + +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( + + + , + ); + 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(); + + 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((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((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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx new file mode 100644 index 00000000000..1b559352469 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx @@ -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, toolName: string): ReadonlySet => new Set([...names, toolName]); + +const withoutTool = (names: ReadonlySet, toolName: string): ReadonlySet => + 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 = ({ accessToken, onSelectTool }) => { + const queryClient = useQueryClient(); + const [savingInput, setSavingInput] = useState>(() => new Set()); + const [savingOutput, setSavingOutput] = useState>(() => new Set()); + + const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]); + + const queryOptions: UseQueryOptions = { + 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) => { + await queryClient.cancelQueries({ queryKey }); + queryClient.setQueryData(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 ( +
+

Tool Policies

+ +
+ + + + } + /> + + 0 ? "text-red-600" : undefined} + /> + 0 ? activeTeamsCount : "—"} /> +
+ + {needsReviewTools.length > 0 && ( +
+

Needs Review

+

+ {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy + decisions. +

+
+ {needsReviewTools.map((tool) => ( + + + {tool.tool_name} + + + + ))} +
+
+ )} + + {query.isError && ( +
+ {toMessage(query.error, "Failed to load tools")} +
+ )} + + void query.refetch()} + onSelectTool={onSelectTool} + savingInput={savingInput} + savingOutput={savingOutput} + onInputPolicyChange={handleInputPolicyChange} + onOutputPolicyChange={handleOutputPolicyChange} + /> +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx new file mode 100644 index 00000000000..9d32ad667bd --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx @@ -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> = {}) => { + const props = { + data: TOOLS, + isLoading: false, + isRefreshing: false, + onRefresh: vi.fn(), + onSelectTool: vi.fn(), + savingInput: new Set(), + savingOutput: new Set(), + onInputPolicyChange: vi.fn(), + onOutputPolicyChange: vi.fn(), + ...overrides, + }; + renderWithProviders(); + 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, + triggerTestId: string, + optionLabel: string, +): Promise => { + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx new file mode 100644 index 00000000000..bbffb0d5ff5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx @@ -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; + savingOutput: ReadonlySet; + onInputPolicyChange: (toolName: string, policy: string) => void; + onOutputPolicyChange: (toolName: string, policy: string) => void; +} + +function ToolPoliciesEmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
+ +
+
+ {filtered ? "No matching tools" : "No tools discovered"} +
+
+ {filtered + ? "No tools match your search or filters." + : "Make a chat completion that returns tool_calls to start auto-discovery."} +
+
+ ); +} + +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([]); + 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 ( + 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={ 0 || globalFilter !== ""} />} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + showViewOptions={false} + /> + + {({ get, set }) => ( + <> + + + + + + + + + + + + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx new file mode 100644 index 00000000000..29a4708a470 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx @@ -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; + savingOutput: ReadonlySet; + 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 ( + + {text} + + ); +} + +export const getToolPoliciesTableColumns = ({ + onSelectTool, + savingInput, + savingOutput, + onInputPolicyChange, + onOutputPolicyChange, +}: ToolPoliciesTableColumnsDeps): ColumnDef[] => [ + { + id: "created_at", + accessorFn: (row) => row.created_at ?? "", + header: ({ column }) => , + size: 170, + enableGlobalFilter: false, + cell: ({ row }) => , + }, + { + id: "tool_name", + accessorFn: (row) => row.tool_name, + header: ({ column }) => , + minSize: 200, + cell: ({ row }) => ( + onSelectTool(row.original.tool_name)} + /> + ), + }, + { + id: "input_policy", + accessorFn: (row) => row.input_policy, + header: ({ column }) => , + size: 140, + filterFn: "equalsString", + meta: { title: "Input Policy", skeleton: "badge" }, + cell: ({ row }) => ( + + ), + }, + { + id: "output_policy", + accessorFn: (row) => row.output_policy, + header: ({ column }) => , + size: 140, + filterFn: "equalsString", + meta: { title: "Output Policy", skeleton: "badge" }, + cell: ({ row }) => ( + + ), + }, + { + id: "call_count", + accessorFn: (row) => row.call_count ?? 0, + header: ({ column }) => , + size: 100, + enableGlobalFilter: false, + meta: { numeric: true }, + cell: ({ row }) => {(row.original.call_count ?? 0).toLocaleString()}, + }, + { + id: "team_id", + accessorFn: (row) => row.team_id ?? "", + header: ({ column }) => , + size: 160, + filterFn: "equalsString", + meta: { title: "Team Name" }, + cell: ({ row }) => , + }, + { + id: "key_hash", + accessorFn: (row) => row.key_hash ?? "", + header: "Key Hash", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "key_alias", + accessorFn: (row) => row.key_alias ?? "", + header: ({ column }) => , + size: 150, + filterFn: "equalsString", + meta: { title: "Key Name" }, + cell: ({ row }) => , + }, + { + id: "user_agent", + accessorFn: (row) => row.user_agent ?? "", + header: "User Agent", + size: 180, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => ( + + ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx index 8b2b1d0e4b7..34c697a98d1 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -14,25 +14,27 @@ vi.mock("@/components/ToolDetail", () => ({ ), })); -vi.mock("@/components/ToolPolicies", () => ({ - ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => ( -
- Tool Policies Overview - -
- ), +vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({ + ToolPoliciesPanel: function ToolPoliciesPanelMock({ onSelectTool }: { onSelectTool: (name: string) => void }) { + return ( +
+ Tool Policies Overview + +
+ ); + }, })); describe("ToolPoliciesView", () => { it("should render the overview by default", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); }); it("should navigate to tool detail when a tool is selected", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); 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(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: /select tool/i })); await user.click(screen.getByRole("button", { name: /back/i })); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx index 31ea7c7f956..bdff40153b9 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx @@ -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({ type: "overview" }); const handleSelectTool = (toolName: string) => { @@ -27,7 +26,7 @@ export default function ToolPoliciesView({ accessToken, userRole }: ToolPolicies {view.type === "detail" ? ( ) : ( - + )}
); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx index 1a4c0ed9ff5..011d0568afd 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx @@ -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[] }; + 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({ pageIndex: 0, pageSize: 50 }); + + return ( + <> + {pagination.pageIndex} + { + 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(); + }); + 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( - , - ); - }); - 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( - , + , ); 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( - , - ); - }); - 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( - , - ); - }); - 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( - , - ); - }); - 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( - , - ); - }); - 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(); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index 6497f2686cb..46c4a726927 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -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 = { + "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 = { + 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; + rowCount: number; } const HealthCheckComponent: React.FC = ({ @@ -46,14 +157,12 @@ const HealthCheckComponent: React.FC = ({ setSelectedModelId, teams, isLoading = false, - paginationMeta, - currentPage = 1, - pageSize = 50, - onPageChange, + pagination, + onPaginationChange, + rowCount, }) => { const [modelHealthStatuses, setModelHealthStatuses] = useState<{ [key: string]: HealthStatus }>({}); - const [selectedModelsForHealth, setSelectedModelsForHealth] = useState([]); - const [allModelsSelected, setAllModelsSelected] = useState(false); + const [rowSelection, setRowSelection] = useState({}); const [errorModalVisible, setErrorModalVisible] = useState(false); const [selectedErrorDetails, setSelectedErrorDetails] = useState<{ modelName: string; @@ -63,11 +172,9 @@ const HealthCheckComponent: React.FC = ({ const [successModalVisible, setSuccessModalVisible] = useState(false); const [selectedSuccessDetails, setSelectedSuccessDetails] = useState<{ modelName: string; - response: any; + response: unknown; } | null>(null); - const healthTableRef = useRef>(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ } }; - 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>( + (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( + () => + (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 (
-
+
- Model Health Status - +

Model Health Status

+

Run health checks on individual models to verify they are working correctly - +

- {selectedModelsForHealth.length > 0 && ( - )}
-
- {shouldShowPagination && ( -
- - {totalCount > 0 - ? `Showing ${resultsStart} - ${resultsEnd} of ${totalCount} results` - : "Showing 0 results"} - - -
- - -
-
- )} - -
+ {/* Error Modal */} = ({ {selectedErrorDetails && (
- Error: -
- {selectedErrorDetails.cleanedError} + Error: +
+ {selectedErrorDetails.cleanedError}
- Full Error Details: -
-
{selectedErrorDetails.fullError}
+ Full Error Details: +
+
{selectedErrorDetails.fullError}
@@ -656,16 +589,16 @@ const HealthCheckComponent: React.FC = ({ {selectedSuccessDetails && (
- Status: -
- Health check passed successfully + Status: +
+ Health check passed successfully
- Response Details: -
-
+              Response Details:
+              
+
                   {JSON.stringify(selectedSuccessDetails.response, null, 2)}
                 
diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx new file mode 100644 index 00000000000..fc5606d46b4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx @@ -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 & { 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; + onRunHealthCheck?: (modelId: string) => void; + onSelectModel?: (modelId: string) => void; +} + +function Harness({ data, modelHealthStatuses = {}, onRunHealthCheck = vi.fn(), onSelectModel }: HarnessProps) { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const [rowSelection, setRowSelection] = useState({}); + + return ( + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + + 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(); + + 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(); + + 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( + , + ); + expect(screen.queryByTestId("view-health-error-btn")).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("view-health-error-btn")).toBeInTheDocument(); + }); + + it("renders the empty state when the page has no models", () => { + render(); + + expect(screen.getByText("No models found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx new file mode 100644 index 00000000000..5c0470315bb --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx @@ -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; + rowSelection: RowSelectionState; + onRowSelectionChange: OnChangeFn; + modelHealthStatuses: Record; + 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 ( +
+
+ +
+
No models found
+
Models added to this proxy will show their health here.
+
+ ); +} + +export function HealthChecksTable({ + data, + rowCount, + isLoading, + pagination, + onPaginationChange, + rowSelection, + onRowSelectionChange, + modelHealthStatuses, + getDisplayModelName, + onRunHealthCheck, + onShowError, + onShowSuccess, + onSelectModel, + teams, +}: HealthChecksTableProps) { + const [sorting, setSorting] = useState([]); + + const columns = useMemo(() => { + const columnDeps = { + modelHealthStatuses, + getDisplayModelName, + onRunHealthCheck, + onShowError, + onShowSuccess, + onSelectModel, + teams, + }; + return getHealthChecksTableColumns(columnDeps); + }, [modelHealthStatuses, getDisplayModelName, onRunHealthCheck, onShowError, onShowSuccess, onSelectModel, teams]); + + return ( + 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={} + size="compact" + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx new file mode 100644 index 00000000000..95857488b20 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx @@ -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 = { + healthy: "success", + unhealthy: "error", + checking: "info", + none: "neutral", +}; + +// healthy > checking > unknown > unhealthy, matching the legacy health table ordering. +const HEALTH_STATUS_ORDER: Record = { 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 ; + } + return ; +} + +function DotPulse({ className }: { className: string }) { + return ( +
+
+
+
+
+ ); +} + +function DetailButton({ + label, + onClick, + className, + testId, +}: { + label: string; + onClick: () => void; + className: string; + testId: string; +}) { + return ( + + ); +} + +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 ; + } + if (hasExistingStatus) { + return ; + } + return ; +} + +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 ( + + ); +} + +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; + 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[] => [ + createSelectionColumn({ + 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 }) => , + size: 220, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const modelId = row.original.model_info?.id ?? ""; + return ( + onSelectModel(modelId) : undefined} + /> + ); + }, + }, + { + id: "model_name", + accessorKey: "model_name", + meta: { title: "Model Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const displayName = getDisplayModelName(row.original) || row.original.model_name; + return ( + + {displayName} + + ); + }, + }, + { + id: "team_id", + accessorFn: (row) => row.model_info?.team_id ?? "", + meta: { title: "Team Alias" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const teamId = row.original.model_info?.team_id; + if (!teamId) { + return -; + } + const teamAlias = teams?.find((team) => team.team_id === teamId)?.team_alias || teamId; + return ( + + {teamAlias} + + ); + }, + }, + { + id: "health_status", + accessorKey: "health_status", + meta: { title: "Health Status", skeleton: "badge" }, + header: ({ column }) => , + 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 ( +
+ + Checking... +
+ ); + } + + 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 ( +
+ + {hasSuccessResponse && ( + onShowSuccess(displayName, successResponse)} + /> + )} +
+ ); + }, + }, + { + 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 No errors; + } + + const cleanedError = healthStatus.error; + const fullError = healthStatus.fullError || healthStatus.error; + const displayName = getDisplayModelName(model) || model.model_name; + + return ( +
+ + {cleanedError} + + {fullError !== cleanedError && ( + onShowError(displayName, cleanedError, fullError)} + /> + )} +
+ ); + }, + }, + { + id: "last_check", + accessorKey: "last_check", + meta: { title: "Last Check" }, + header: ({ column }) => , + 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 }) => ( + + {row.original.health_loading ? CHECK_IN_PROGRESS : row.original.last_check} + + ), + }, + { + id: "last_success", + accessorKey: "last_success", + meta: { title: "Last Success" }, + header: ({ column }) => , + 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 {lastSuccess}; + }, + }, + { + id: "actions", + meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 80, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx deleted file mode 100644 index 33c97236f97..00000000000 --- a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx +++ /dev/null @@ -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 = { - healthy: "success", - unhealthy: "error", - checking: "info", - none: "neutral", -}; - -const healthStatusBadge = (status: string): JSX.Element => { - const tone = HEALTH_STATUS_TONES[status]; - return tone ? : ; -}; - -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[] => [ - { - header: () => ( -
- 0 && !allModelsSelected} - onChange={(e) => handleSelectAll(e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - Model ID -
- ), - 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 ( -
- handleModelSelection(modelId, e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - -
- ); - }, - }, - { - header: "Model Name", - accessorKey: "model_name", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const model = row.original; - const displayName = getDisplayModelName(model) || model.model_name; - - return ( -
- -
{displayName}
-
-
- ); - }, - }, - { - 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 -; - } - - const team = teams?.find((t) => t.team_id === teamId); - const teamAlias = team?.team_alias || teamId; - - return ( -
- -
{teamAlias}
-
-
- ); - }, - }, - { - 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 ( -
-
-
-
-
-
- Checking... -
- ); - } - - const modelId = model.model_info?.id ?? ""; - const displayName = getDisplayModelName(model) || model.model_name; - const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelId]?.successResponse; - - return ( -
- {healthStatusBadge(healthStatus.status)} - {hasSuccessResponse && showSuccessModal && ( - - - - )} -
- ); - }, - }, - { - 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 No errors; - } - - const cleanedError = healthStatus.error; - const fullError = healthStatus.fullError || healthStatus.error; - - return ( -
-
- - {cleanedError} - -
- {showErrorModal && fullError !== cleanedError && ( - - - - )} -
- ); - }, - }, - { - 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 ( - - {model.health_loading ? "Check in progress..." : model.last_check} - - ); - }, - }, - { - 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 {lastSuccess}; - }, - }, - { - 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 ( - - - - ); - }, - enableSorting: false, - }, -]; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx b/ui/litellm-dashboard/src/components/model_dashboard/table.tsx deleted file mode 100644 index cd451af31e4..00000000000 --- a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx +++ /dev/null @@ -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 { - className?: string; - } -} - -interface ModelDataTableProps { - data: TData[]; - columns: ColumnDef[]; - isLoading?: boolean; - defaultSorting?: SortingState; - pagination?: PaginationState; - onPaginationChange?: OnChangeFn; - enablePagination?: boolean; - onRowClick?: (row: TData) => void; -} - -export function ModelDataTable({ - data = [], - columns, - isLoading = false, - defaultSorting = [], - pagination, - onPaginationChange, - enablePagination = false, - onRowClick, -}: ModelDataTableProps) { - const [sorting, setSorting] = React.useState(defaultSorting); - const [columnResizeMode] = React.useState("onChange"); - const [columnSizing, setColumnSizing] = React.useState({}); - const [columnVisibility, setColumnVisibility] = React.useState({}); - - 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 ( -
-
-
- - - {tableInstance.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
- {header.column.getCanResize() && ( -
- )} - - ))} - - ))} - - - {isLoading ? ( - - -
-

🚅 Loading models...

-
-
-
- ) : tableInstance.getRowModel().rows.length > 0 ? ( - tableInstance.getRowModel().rows.map((row) => ( - onRowClick?.(row.original)} - className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No models found

-
-
-
- )} -
-
-
-
-
- ); -} diff --git a/uv.lock b/uv.lock index 0a90682a187..6de7bf20bd6 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]