mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-26 03:04:13 +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
|
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 = []
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -698,6 +701,18 @@ export const userListCall = async (
|
||||||
if (page_size) {
|
if (page_size) {
|
||||||
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) {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue