fix(view_users.tsx): support filtering user by role and user id

More powerful filtering on internal users table
This commit is contained in:
Krrish Dholakia 2025-04-21 18:10:22 -07:00
parent f0158e16fe
commit ba293220a8
3 changed files with 148 additions and 82 deletions

View file

@ -902,12 +902,6 @@ async def get_user_key_counts(
return result return result
@router.get(
"/user/get_users",
tags=["Internal User management"],
dependencies=[Depends(user_api_key_auth)],
response_model=UserListResponse,
)
@router.get( @router.get(
"/user/list", "/user/list",
tags=["Internal User management"], tags=["Internal User management"],
@ -921,15 +915,19 @@ async def get_users(
user_ids: Optional[str] = fastapi.Query( user_ids: Optional[str] = fastapi.Query(
default=None, description="Get list of users by user_ids" default=None, description="Get list of users by user_ids"
), ),
user_email: Optional[str] = fastapi.Query(
default=None, description="Filter users by partial email match"
),
team: Optional[str] = fastapi.Query(
default=None, description="Filter users by team id"
),
page: int = fastapi.Query(default=1, ge=1, description="Page number"), page: int = fastapi.Query(default=1, ge=1, description="Page number"),
page_size: int = fastapi.Query( page_size: int = fastapi.Query(
default=25, ge=1, le=100, description="Number of items per page" default=25, ge=1, le=100, description="Number of items per page"
), ),
): ):
""" """
Get a paginated list of users, optionally filtered by role. Get a paginated list of users with filtering options.
Used by the UI to populate the user lists.
Parameters: Parameters:
role: Optional[str] role: Optional[str]
@ -940,17 +938,17 @@ async def get_users(
- internal_user_viewer - internal_user_viewer
user_ids: Optional[str] user_ids: Optional[str]
Get list of users by user_ids. Comma separated list of user_ids. Get list of users by user_ids. Comma separated list of user_ids.
user_email: Optional[str]
Filter users by partial email match
team: Optional[str]
Filter users by team id. Will match if user has this team in their teams array.
page: int page: int
The page number to return The page number to return
page_size: int page_size: int
The number of items per page The number of items per page
Currently - admin-only endpoint. Returns:
UserListResponse with filtered and paginated users
Example curl:
```
http://0.0.0.0:4000/user/list?user_ids=default_user_id,693c1a4a-1cc0-4c7c-afe8-b5d2c8d52e17
```
""" """
from litellm.proxy.proxy_server import prisma_client from litellm.proxy.proxy_server import prisma_client
@ -963,25 +961,30 @@ async def get_users(
# Calculate skip and take for pagination # Calculate skip and take for pagination
skip = (page - 1) * page_size skip = (page - 1) * page_size
# Prepare the query conditions
# Build where conditions based on provided parameters # Build where conditions based on provided parameters
where_conditions: Dict[str, Any] = {} where_conditions: Dict[str, Any] = {}
if role: if role:
where_conditions["user_role"] = { where_conditions["user_role"] = role # Exact match instead of contains
"contains": role,
"mode": "insensitive", # Case-insensitive search
}
if user_ids and isinstance(user_ids, str): if user_ids and isinstance(user_ids, str):
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()] user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
where_conditions["user_id"] = { where_conditions["user_id"] = {
"in": user_id_list, # Now passing a list of strings as required by Prisma "in": user_id_list,
} }
users: Optional[ if user_email:
List[LiteLLM_UserTable] where_conditions["user_email"] = {
] = await prisma_client.db.litellm_usertable.find_many( "contains": user_email,
"mode": "insensitive", # Case-insensitive search
}
if team:
where_conditions["teams"] = {
"has": team # Array contains for string arrays in Prisma
}
users = await prisma_client.db.litellm_usertable.find_many(
where=where_conditions, where=where_conditions,
skip=skip, skip=skip,
take=page_size, take=page_size,
@ -989,9 +992,7 @@ async def get_users(
) )
# Get total count of user rows # Get total count of user rows
total_count = await prisma_client.db.litellm_usertable.count( total_count = await prisma_client.db.litellm_usertable.count(where=where_conditions)
where=where_conditions # type: ignore
)
# Get key count for each user # Get key count for each user
if users is not None: if users is not None:
@ -1014,7 +1015,7 @@ async def get_users(
LiteLLM_UserTableWithKeyCount( LiteLLM_UserTableWithKeyCount(
**user.model_dump(), key_count=user_key_counts.get(user.user_id, 0) **user.model_dump(), key_count=user_key_counts.get(user.user_id, 0)
) )
) # Return full key object )
else: else:
user_list = [] user_list = []

View file

@ -676,6 +676,9 @@ export const userListCall = async (
userIDs: string[] | null = null, userIDs: string[] | null = null,
page: number | null = null, page: number | null = null,
page_size: number | null = null, page_size: number | null = null,
userEmail: string | null = null,
userRole: string | null = null,
team: string | null = null,
) => { ) => {
/** /**
* Get all available teams on proxy * Get all available teams on proxy
@ -699,6 +702,18 @@ export const userListCall = async (
queryParams.append('page_size', page_size.toString()); queryParams.append('page_size', page_size.toString());
} }
if (userEmail) {
queryParams.append('user_email', userEmail);
}
if (userRole) {
queryParams.append('role', userRole);
}
if (team) {
queryParams.append('team', team);
}
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (queryString) { if (queryString) {
url += `?${queryString}`; url += `?${queryString}`;

View file

@ -23,6 +23,7 @@ import {
DialogPanel, DialogPanel,
Icon, Icon,
TextInput, TextInput,
NumberInput,
} from "@tremor/react"; } from "@tremor/react";
import { message } from "antd"; import { message } from "antd";
@ -32,7 +33,7 @@ import {
userInfoCall, userInfoCall,
userUpdateUserCall, userUpdateUserCall,
getPossibleUserRoles, getPossibleUserRoles,
userFilterUICall, userListCall,
} from "./networking"; } from "./networking";
import { Badge, BadgeDelta, Button } from "@tremor/react"; import { Badge, BadgeDelta, Button } from "@tremor/react";
import RequestAccess from "./request_model_access"; import RequestAccess from "./request_model_access";
@ -79,6 +80,16 @@ interface CreateuserProps {
onUserCreated: () => Promise<void>; onUserCreated: () => Promise<void>;
} }
interface FilterState {
email: string;
user_id: string;
user_role: string;
team: string;
model: string;
min_spend: number | null;
max_spend: number | null;
}
const isLocal = process.env.NODE_ENV === "development"; const isLocal = process.env.NODE_ENV === "development";
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; const proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
if (isLocal != true) { if (isLocal != true) {
@ -110,6 +121,16 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const defaultPageSize = 25; const defaultPageSize = 25;
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("users"); const [activeTab, setActiveTab] = useState("users");
const [filters, setFilters] = useState<FilterState>({
email: "",
user_id: "",
user_role: "",
team: "",
model: "",
min_spend: null,
max_spend: null
});
const [showFilters, setShowFilters] = useState(false);
// check if window is not undefined // check if window is not undefined
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -124,35 +145,38 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}; };
const handleFilterChange = (key: keyof FilterState, value: string | number | null) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
debouncedSearch(newFilters);
};
// Create a debounced version of the search function // Create a debounced version of the search function
const debouncedSearch = useCallback( const debouncedSearch = useCallback(
debounce(async (searchValue: string) => { debounce(async (filters: FilterState) => {
if (!accessToken || !token || !userRole || !userID) { if (!accessToken || !token || !userRole || !userID) {
return; return;
} }
try { try {
const params = new URLSearchParams(); // Make the API call using userListCall with all filter parameters
if (searchValue) { const data = await userListCall(
params.append('user_email', searchValue); accessToken,
} filters.user_id ? [filters.user_id] : null,
const filteredUsers = await userFilterUICall(accessToken, params); 1, // Reset to first page when searching
defaultPageSize,
filters.email || null,
filters.user_role || null,
filters.team || null
);
// Update the user list with filtered results if (data) {
if (filteredUsers) { setUserData(data.users);
setUserData(filteredUsers); setUserListResponse(data);
setUserListResponse(prev => ({
...prev,
users: filteredUsers,
total: filteredUsers.length,
page: 1,
page_size: defaultPageSize,
total_pages: Math.ceil(filteredUsers.length / defaultPageSize)
}));
} }
} catch (error) { } catch (error) {
console.error("Error searching users:", error); console.error("Error searching users:", error);
} }
}, 300), // 300ms delay }, 300),
[accessToken, token, userRole, userID] [accessToken, token, userRole, userID]
); );
@ -168,7 +192,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
if (value === "") { if (value === "") {
refreshUserData(); // Reset to original data when search is cleared refreshUserData(); // Reset to original data when search is cleared
} else { } else {
debouncedSearch(value); debouncedSearch(filters);
} }
}; };
@ -266,14 +290,15 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
setUserListResponse(parsedData); setUserListResponse(parsedData);
setUserData(parsedData.users || []); setUserData(parsedData.users || []);
} else { } else {
// Fetch from API if not in cache // Fetch from API using userListCall with current filters
const userDataResponse = await userInfoCall( const userDataResponse = await userListCall(
accessToken, accessToken,
null, filters.user_id ? [filters.user_id] : null,
userRole,
true,
currentPage, currentPage,
defaultPageSize defaultPageSize,
filters.email || null,
filters.user_role || null,
filters.team || null
); );
// Store in session storage // Store in session storage
@ -304,7 +329,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
fetchData(); fetchData();
} }
}, [accessToken, token, userRole, userID, currentPage]); }, [accessToken, token, userRole, userID, currentPage, filters]);
if (!userData) { if (!userData) {
return <div>Loading...</div>; return <div>Loading...</div>;
@ -347,16 +372,63 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
<TabPanel> <TabPanel>
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="border-b px-6 py-4"> <div className="border-b px-6 py-4">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0"> <div className="flex flex-col space-y-4">
<div className="flex items-center space-x-4 w-full"> {/* Search and Filter Controls */}
<div className="flex items-center justify-between">
<div className="flex-1 max-w-md"> <div className="flex-1 max-w-md">
<TextInput <TextInput
placeholder="Search by email..." placeholder="Search by email..."
value={searchTerm} value={filters.email}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleFilterChange('email', e.target.value)}
className="w-full" className="w-full"
/> />
</div> </div>
<Button
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="ml-4"
>
{showFilters ? "Hide Filters" : "Show Filters"}
</Button>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<TextInput
placeholder="Filter by User ID"
value={filters.user_id}
onChange={(e) => handleFilterChange('user_id', e.target.value)}
/>
<Select
value={filters.user_role}
onValueChange={(value) => handleFilterChange('user_role', value)}
placeholder="Select Role"
>
{Object.entries(possibleUIRoles).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value.ui_label}
</SelectItem>
))}
</Select>
{/* <Select
value={filters.team}
onValueChange={(value) => handleFilterChange('team', value)}
placeholder="Select Team"
>
{teams?.map((team) => (
<SelectItem key={team.team_id} value={team.team_id}>
{team.team_alias || team.team_id}
</SelectItem>
))}
</Select> */}
</div>
)}
{/* Results Count */}
<div className="flex justify-end">
<span className="text-sm text-gray-700"> <span className="text-sm text-gray-700">
Showing{" "} Showing{" "}
{userListResponse && userListResponse.users && userListResponse.users.length > 0 {userListResponse && userListResponse.users && userListResponse.users.length > 0
@ -371,32 +443,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
: 0}{" "} : 0}{" "}
of {userListResponse ? userListResponse.total : 0} results of {userListResponse ? userListResponse.total : 0} results
</span> </span>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={!userListResponse || currentPage <= 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page {userListResponse ? userListResponse.page : "-"} of{" "}
{userListResponse ? userListResponse.total_pages : "-"}
</span>
<button
onClick={() => setCurrentPage((p) => p + 1)}
disabled={
!userListResponse ||
currentPage >= userListResponse.total_pages
}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<UserDataTable <UserDataTable
data={userData || []} data={userData || []}
columns={tableColumns} columns={tableColumns}