mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-24 18:24:20 +00:00
fix(view_users.tsx): support filtering user by role and user id
More powerful filtering on internal users table
This commit is contained in:
parent
f0158e16fe
commit
ba293220a8
3 changed files with 148 additions and 82 deletions
|
@ -902,12 +902,6 @@ async def get_user_key_counts(
|
|||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user/get_users",
|
||||
tags=["Internal User management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=UserListResponse,
|
||||
)
|
||||
@router.get(
|
||||
"/user/list",
|
||||
tags=["Internal User management"],
|
||||
|
@ -921,15 +915,19 @@ async def get_users(
|
|||
user_ids: Optional[str] = fastapi.Query(
|
||||
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_size: int = fastapi.Query(
|
||||
default=25, ge=1, le=100, description="Number of items per page"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Get a paginated list of users, optionally filtered by role.
|
||||
|
||||
Used by the UI to populate the user lists.
|
||||
Get a paginated list of users with filtering options.
|
||||
|
||||
Parameters:
|
||||
role: Optional[str]
|
||||
|
@ -940,17 +938,17 @@ async def get_users(
|
|||
- internal_user_viewer
|
||||
user_ids: Optional[str]
|
||||
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
|
||||
The page number to return
|
||||
page_size: int
|
||||
The number of items per page
|
||||
|
||||
Currently - admin-only endpoint.
|
||||
|
||||
Example curl:
|
||||
```
|
||||
http://0.0.0.0:4000/user/list?user_ids=default_user_id,693c1a4a-1cc0-4c7c-afe8-b5d2c8d52e17
|
||||
```
|
||||
Returns:
|
||||
UserListResponse with filtered and paginated users
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
|
@ -963,25 +961,30 @@ async def get_users(
|
|||
# Calculate skip and take for pagination
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Prepare the query conditions
|
||||
# Build where conditions based on provided parameters
|
||||
where_conditions: Dict[str, Any] = {}
|
||||
|
||||
if role:
|
||||
where_conditions["user_role"] = {
|
||||
"contains": role,
|
||||
"mode": "insensitive", # Case-insensitive search
|
||||
}
|
||||
where_conditions["user_role"] = role # Exact match instead of contains
|
||||
|
||||
if user_ids and isinstance(user_ids, str):
|
||||
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
|
||||
where_conditions["user_id"] = {
|
||||
"in": user_id_list, # Now passing a list of strings as required by Prisma
|
||||
"in": user_id_list,
|
||||
}
|
||||
|
||||
users: Optional[
|
||||
List[LiteLLM_UserTable]
|
||||
] = await prisma_client.db.litellm_usertable.find_many(
|
||||
if user_email:
|
||||
where_conditions["user_email"] = {
|
||||
"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,
|
||||
skip=skip,
|
||||
take=page_size,
|
||||
|
@ -989,9 +992,7 @@ async def get_users(
|
|||
)
|
||||
|
||||
# Get total count of user rows
|
||||
total_count = await prisma_client.db.litellm_usertable.count(
|
||||
where=where_conditions # type: ignore
|
||||
)
|
||||
total_count = await prisma_client.db.litellm_usertable.count(where=where_conditions)
|
||||
|
||||
# Get key count for each user
|
||||
if users is not None:
|
||||
|
@ -1014,7 +1015,7 @@ async def get_users(
|
|||
LiteLLM_UserTableWithKeyCount(
|
||||
**user.model_dump(), key_count=user_key_counts.get(user.user_id, 0)
|
||||
)
|
||||
) # Return full key object
|
||||
)
|
||||
else:
|
||||
user_list = []
|
||||
|
||||
|
|
|
@ -676,6 +676,9 @@ export const userListCall = async (
|
|||
userIDs: string[] | null = null,
|
||||
page: 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
|
||||
|
@ -698,6 +701,18 @@ export const userListCall = async (
|
|||
if (page_size) {
|
||||
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();
|
||||
if (queryString) {
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
DialogPanel,
|
||||
Icon,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
} from "@tremor/react";
|
||||
|
||||
import { message } from "antd";
|
||||
|
@ -32,7 +33,7 @@ import {
|
|||
userInfoCall,
|
||||
userUpdateUserCall,
|
||||
getPossibleUserRoles,
|
||||
userFilterUICall,
|
||||
userListCall,
|
||||
} from "./networking";
|
||||
import { Badge, BadgeDelta, Button } from "@tremor/react";
|
||||
import RequestAccess from "./request_model_access";
|
||||
|
@ -79,6 +80,16 @@ interface CreateuserProps {
|
|||
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 proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
|
||||
if (isLocal != true) {
|
||||
|
@ -110,6 +121,16 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const defaultPageSize = 25;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
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
|
||||
if (typeof window !== "undefined") {
|
||||
|
@ -124,35 +145,38 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
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
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async (searchValue: string) => {
|
||||
debounce(async (filters: FilterState) => {
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
params.append('user_email', searchValue);
|
||||
}
|
||||
const filteredUsers = await userFilterUICall(accessToken, params);
|
||||
// Make the API call using userListCall with all filter parameters
|
||||
const data = await userListCall(
|
||||
accessToken,
|
||||
filters.user_id ? [filters.user_id] : null,
|
||||
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 (filteredUsers) {
|
||||
setUserData(filteredUsers);
|
||||
setUserListResponse(prev => ({
|
||||
...prev,
|
||||
users: filteredUsers,
|
||||
total: filteredUsers.length,
|
||||
page: 1,
|
||||
page_size: defaultPageSize,
|
||||
total_pages: Math.ceil(filteredUsers.length / defaultPageSize)
|
||||
}));
|
||||
if (data) {
|
||||
setUserData(data.users);
|
||||
setUserListResponse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
}, 300), // 300ms delay
|
||||
}, 300),
|
||||
[accessToken, token, userRole, userID]
|
||||
);
|
||||
|
||||
|
@ -168,7 +192,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
if (value === "") {
|
||||
refreshUserData(); // Reset to original data when search is cleared
|
||||
} else {
|
||||
debouncedSearch(value);
|
||||
debouncedSearch(filters);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -266,14 +290,15 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
setUserListResponse(parsedData);
|
||||
setUserData(parsedData.users || []);
|
||||
} else {
|
||||
// Fetch from API if not in cache
|
||||
const userDataResponse = await userInfoCall(
|
||||
// Fetch from API using userListCall with current filters
|
||||
const userDataResponse = await userListCall(
|
||||
accessToken,
|
||||
null,
|
||||
userRole,
|
||||
true,
|
||||
filters.user_id ? [filters.user_id] : null,
|
||||
currentPage,
|
||||
defaultPageSize
|
||||
defaultPageSize,
|
||||
filters.email || null,
|
||||
filters.user_role || null,
|
||||
filters.team || null
|
||||
);
|
||||
|
||||
// Store in session storage
|
||||
|
@ -304,7 +329,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
fetchData();
|
||||
}
|
||||
|
||||
}, [accessToken, token, userRole, userID, currentPage]);
|
||||
}, [accessToken, token, userRole, userID, currentPage, filters]);
|
||||
|
||||
if (!userData) {
|
||||
return <div>Loading...</div>;
|
||||
|
@ -347,16 +372,63 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
<TabPanel>
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<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 items-center space-x-4 w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 max-w-md">
|
||||
<TextInput
|
||||
placeholder="Search by email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
value={filters.email}
|
||||
onChange={(e) => handleFilterChange('email', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</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">
|
||||
Showing{" "}
|
||||
{userListResponse && userListResponse.users && userListResponse.users.length > 0
|
||||
|
@ -371,32 +443,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
: 0}{" "}
|
||||
of {userListResponse ? userListResponse.total : 0} results
|
||||
</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>
|
||||
|
||||
<UserDataTable
|
||||
data={userData || []}
|
||||
columns={tableColumns}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue