Skip to content

Commit 66680c4

Browse files
Add global filtering to Users tab (#10195)
* style(internal_user_endpoints.py): add response model to `/user/list` endpoint make sure we maintain consistent response spec * fix(key_management_endpoints.py): return 'created_at' and 'updated_at' on `/key/generate` Show 'created_at' on UI when key created * test(test_keys.py): add e2e test to ensure created at is always returned * fix(view_users.tsx): support global search by user email allows easier search * test(search_users.spec.ts): add e2e test ensure user search works on admin ui * fix(view_users.tsx): support filtering user by role and user id More powerful filtering on internal users table * fix(view_users.tsx): allow filtering users by team * style(view_users.tsx): cleanup ui to show filters in consistent style * refactor(view_users.tsx): cleanup to just use 1 variable for the data * fix(view_users.tsx): cleanup use effect hooks * fix(internal_user_endpoints.py): fix check to pass testing * test: update tests * test: update tests * Revert "test: update tests" This reverts commit 6553eeb. * fix(view_userts.tsx): add back in 'previous' and 'next' tabs for pagination
1 parent b2955a2 commit 66680c4

File tree

12 files changed

+556
-88
lines changed

12 files changed

+556
-88
lines changed

litellm/proxy/_experimental/out/onboarding.html

Lines changed: 0 additions & 1 deletion
This file was deleted.

litellm/proxy/_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,8 @@ class GenerateKeyResponse(KeyRequestBase):
687687
token: Optional[str] = None
688688
created_by: Optional[str] = None
689689
updated_by: Optional[str] = None
690+
created_at: Optional[datetime] = None
691+
updated_at: Optional[datetime] = None
690692

691693
@model_validator(mode="before")
692694
@classmethod

litellm/proxy/management_endpoints/internal_user_endpoints.py

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
SpendAnalyticsPaginatedResponse,
4444
SpendMetrics,
4545
)
46+
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
47+
UserListResponse,
48+
)
4649

4750
router = APIRouter()
4851

@@ -899,15 +902,11 @@ async def get_user_key_counts(
899902
return result
900903

901904

902-
@router.get(
903-
"/user/get_users",
904-
tags=["Internal User management"],
905-
dependencies=[Depends(user_api_key_auth)],
906-
)
907905
@router.get(
908906
"/user/list",
909907
tags=["Internal User management"],
910908
dependencies=[Depends(user_api_key_auth)],
909+
response_model=UserListResponse,
911910
)
912911
async def get_users(
913912
role: Optional[str] = fastapi.Query(
@@ -916,15 +915,19 @@ async def get_users(
916915
user_ids: Optional[str] = fastapi.Query(
917916
default=None, description="Get list of users by user_ids"
918917
),
918+
user_email: Optional[str] = fastapi.Query(
919+
default=None, description="Filter users by partial email match"
920+
),
921+
team: Optional[str] = fastapi.Query(
922+
default=None, description="Filter users by team id"
923+
),
919924
page: int = fastapi.Query(default=1, ge=1, description="Page number"),
920925
page_size: int = fastapi.Query(
921926
default=25, ge=1, le=100, description="Number of items per page"
922927
),
923928
):
924929
"""
925-
Get a paginated list of users, optionally filtered by role.
926-
927-
Used by the UI to populate the user lists.
930+
Get a paginated list of users with filtering options.
928931
929932
Parameters:
930933
role: Optional[str]
@@ -935,17 +938,17 @@ async def get_users(
935938
- internal_user_viewer
936939
user_ids: Optional[str]
937940
Get list of users by user_ids. Comma separated list of user_ids.
941+
user_email: Optional[str]
942+
Filter users by partial email match
943+
team: Optional[str]
944+
Filter users by team id. Will match if user has this team in their teams array.
938945
page: int
939946
The page number to return
940947
page_size: int
941948
The number of items per page
942949
943-
Currently - admin-only endpoint.
944-
945-
Example curl:
946-
```
947-
http://0.0.0.0:4000/user/list?user_ids=default_user_id,693c1a4a-1cc0-4c7c-afe8-b5d2c8d52e17
948-
```
950+
Returns:
951+
UserListResponse with filtered and paginated users
949952
"""
950953
from litellm.proxy.proxy_server import prisma_client
951954

@@ -958,35 +961,40 @@ async def get_users(
958961
# Calculate skip and take for pagination
959962
skip = (page - 1) * page_size
960963

961-
# Prepare the query conditions
962964
# Build where conditions based on provided parameters
963965
where_conditions: Dict[str, Any] = {}
964966

965967
if role:
966-
where_conditions["user_role"] = {
967-
"contains": role,
968-
"mode": "insensitive", # Case-insensitive search
969-
}
968+
where_conditions["user_role"] = role # Exact match instead of contains
970969

971970
if user_ids and isinstance(user_ids, str):
972971
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
973972
where_conditions["user_id"] = {
974-
"in": user_id_list, # Now passing a list of strings as required by Prisma
973+
"in": user_id_list,
975974
}
976975

977-
users: Optional[
978-
List[LiteLLM_UserTable]
979-
] = await prisma_client.db.litellm_usertable.find_many(
976+
if user_email is not None and isinstance(user_email, str):
977+
where_conditions["user_email"] = {
978+
"contains": user_email,
979+
"mode": "insensitive", # Case-insensitive search
980+
}
981+
982+
if team is not None and isinstance(team, str):
983+
where_conditions["teams"] = {
984+
"has": team # Array contains for string arrays in Prisma
985+
}
986+
987+
## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}}
988+
where_conditions = {k: v for k, v in where_conditions.items() if v is not None}
989+
users = await prisma_client.db.litellm_usertable.find_many(
980990
where=where_conditions,
981991
skip=skip,
982992
take=page_size,
983993
order={"created_at": "desc"},
984994
)
985995

986996
# Get total count of user rows
987-
total_count = await prisma_client.db.litellm_usertable.count(
988-
where=where_conditions # type: ignore
989-
)
997+
total_count = await prisma_client.db.litellm_usertable.count(where=where_conditions)
990998

991999
# Get key count for each user
9921000
if users is not None:
@@ -1009,7 +1017,7 @@ async def get_users(
10091017
LiteLLM_UserTableWithKeyCount(
10101018
**user.model_dump(), key_count=user_key_counts.get(user.user_id, 0)
10111019
)
1012-
) # Return full key object
1020+
)
10131021
else:
10141022
user_list = []
10151023

litellm/proxy/management_endpoints/key_management_endpoints.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,10 +1347,13 @@ async def generate_key_helper_fn( # noqa: PLR0915
13471347
create_key_response = await prisma_client.insert_data(
13481348
data=key_data, table_name="key"
13491349
)
1350+
13501351
key_data["token_id"] = getattr(create_key_response, "token", None)
13511352
key_data["litellm_budget_table"] = getattr(
13521353
create_key_response, "litellm_budget_table", None
13531354
)
1355+
key_data["created_at"] = getattr(create_key_response, "created_at", None)
1356+
key_data["updated_at"] = getattr(create_key_response, "updated_at", None)
13541357
except Exception as e:
13551358
verbose_proxy_logger.error(
13561359
"litellm.proxy.proxy_server.generate_key_helper_fn(): Exception occured - {}".format(
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Any, Dict, List, Literal, Optional, Union
2+
3+
from fastapi import HTTPException
4+
from pydantic import BaseModel, EmailStr
5+
6+
from litellm.proxy._types import LiteLLM_UserTableWithKeyCount
7+
8+
9+
class UserListResponse(BaseModel):
10+
"""
11+
Response model for the user list endpoint
12+
"""
13+
14+
users: List[LiteLLM_UserTableWithKeyCount]
15+
total: int
16+
page: int
17+
page_size: int
18+
total_pages: int

test-results/.last-run.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"status": "failed",
3+
"failedTests": []
4+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
Search Users in Admin UI
3+
E2E Test for user search functionality
4+
5+
Tests:
6+
1. Navigate to Internal Users tab
7+
2. Verify search input exists
8+
3. Test search functionality
9+
4. Verify results update
10+
*/
11+
12+
import { test, expect } from "@playwright/test";
13+
14+
test("user search test", async ({ page }) => {
15+
// Set a longer timeout for the entire test
16+
test.setTimeout(60000);
17+
18+
// Enable console logging
19+
page.on("console", (msg) => console.log("PAGE LOG:", msg.text()));
20+
21+
// Login first
22+
await page.goto("http://localhost:4000/ui");
23+
console.log("Navigated to login page");
24+
25+
// Wait for login form to be visible
26+
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
27+
console.log("Login form is visible");
28+
29+
await page.fill('input[name="username"]', "admin");
30+
await page.fill('input[name="password"]', "gm");
31+
console.log("Filled login credentials");
32+
33+
const loginButton = page.locator('input[type="submit"]');
34+
await expect(loginButton).toBeEnabled();
35+
await loginButton.click();
36+
console.log("Clicked login button");
37+
38+
// Wait for navigation to complete and dashboard to load
39+
await page.waitForLoadState("networkidle");
40+
console.log("Page loaded after login");
41+
42+
// Take a screenshot for debugging
43+
await page.screenshot({ path: "after-login.png" });
44+
console.log("Took screenshot after login");
45+
46+
// Try to find the Internal User tab with more debugging
47+
console.log("Looking for Internal User tab...");
48+
const internalUserTab = page.locator("span.ant-menu-title-content", {
49+
hasText: "Internal User",
50+
});
51+
52+
// Wait for the tab to be visible
53+
await internalUserTab.waitFor({ state: "visible", timeout: 10000 });
54+
console.log("Internal User tab is visible");
55+
56+
// Take another screenshot before clicking
57+
await page.screenshot({ path: "before-tab-click.png" });
58+
console.log("Took screenshot before tab click");
59+
60+
await internalUserTab.click();
61+
console.log("Clicked Internal User tab");
62+
63+
// Wait for the page to load and table to be visible
64+
await page.waitForSelector("tbody tr", { timeout: 10000 });
65+
await page.waitForTimeout(2000); // Additional wait for table to stabilize
66+
console.log("Table is visible");
67+
68+
// Take a final screenshot
69+
await page.screenshot({ path: "after-tab-click.png" });
70+
console.log("Took screenshot after tab click");
71+
72+
// Verify search input exists
73+
const searchInput = page.locator('input[placeholder="Search by email..."]');
74+
await expect(searchInput).toBeVisible();
75+
console.log("Search input is visible");
76+
77+
// Test search functionality
78+
const initialUserCount = await page.locator("tbody tr").count();
79+
console.log(`Initial user count: ${initialUserCount}`);
80+
81+
// Perform a search
82+
const testEmail = "test@";
83+
await searchInput.fill(testEmail);
84+
console.log("Filled search input");
85+
86+
// Wait for the debounced search to complete
87+
await page.waitForTimeout(500);
88+
console.log("Waited for debounce");
89+
90+
// Wait for the results count to update
91+
await page.waitForFunction((initialCount) => {
92+
const currentCount = document.querySelectorAll("tbody tr").length;
93+
return currentCount !== initialCount;
94+
}, initialUserCount);
95+
console.log("Results updated");
96+
97+
const filteredUserCount = await page.locator("tbody tr").count();
98+
console.log(`Filtered user count: ${filteredUserCount}`);
99+
100+
expect(filteredUserCount).toBeDefined();
101+
102+
// Clear the search
103+
await searchInput.clear();
104+
console.log("Cleared search");
105+
106+
await page.waitForTimeout(500);
107+
console.log("Waited for debounce after clear");
108+
109+
await page.waitForFunction((initialCount) => {
110+
const currentCount = document.querySelectorAll("tbody tr").length;
111+
return currentCount === initialCount;
112+
}, initialUserCount);
113+
console.log("Results reset");
114+
115+
const resetUserCount = await page.locator("tbody tr").count();
116+
console.log(`Reset user count: ${resetUserCount}`);
117+
118+
expect(resetUserCount).toBe(initialUserCount);
119+
});

0 commit comments

Comments
 (0)