mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-24 10:14:26 +00:00
UI - Users page - Enable global sorting (allows finding users with highest spend) (#10211)
* fix(view_users.tsx): add time tracking logic to debounce search - prevent new queries from being overwritten by previous ones * fix(internal_user_endpoints.py): add sort functionality to user list endpoint * feat(internal_user_endpoints.py): support sort by on `/user/list` * fix(view_users.tsx): enable global sorting allows finding user with highest spend * feat(view_users.tsx): support filtering by sso user id * test(search_users.spec.ts): add tests to ensure filtering works * test: add more unit testing
This commit is contained in:
parent
0dba2886f0
commit
5f98d4d7de
6 changed files with 287 additions and 17 deletions
|
@ -902,6 +902,42 @@ async def get_user_key_counts(
|
|||
return result
|
||||
|
||||
|
||||
def _validate_sort_params(
|
||||
sort_by: Optional[str], sort_order: str
|
||||
) -> Optional[Dict[str, str]]:
|
||||
order_by: Dict[str, str] = {}
|
||||
|
||||
if sort_by is None:
|
||||
return None
|
||||
# Validate sort_by is a valid column
|
||||
valid_columns = [
|
||||
"user_id",
|
||||
"user_email",
|
||||
"created_at",
|
||||
"spend",
|
||||
"user_alias",
|
||||
"user_role",
|
||||
]
|
||||
if sort_by not in valid_columns:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Invalid sort column. Must be one of: {', '.join(valid_columns)}"
|
||||
},
|
||||
)
|
||||
|
||||
# Validate sort_order
|
||||
if sort_order.lower() not in ["asc", "desc"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "Invalid sort order. Must be 'asc' or 'desc'"},
|
||||
)
|
||||
|
||||
order_by[sort_by] = sort_order.lower()
|
||||
|
||||
return order_by
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user/list",
|
||||
tags=["Internal User management"],
|
||||
|
@ -915,6 +951,9 @@ async def get_users(
|
|||
user_ids: Optional[str] = fastapi.Query(
|
||||
default=None, description="Get list of users by user_ids"
|
||||
),
|
||||
sso_user_ids: Optional[str] = fastapi.Query(
|
||||
default=None, description="Get list of users by sso_user_id"
|
||||
),
|
||||
user_email: Optional[str] = fastapi.Query(
|
||||
default=None, description="Filter users by partial email match"
|
||||
),
|
||||
|
@ -925,9 +964,16 @@ async def get_users(
|
|||
page_size: int = fastapi.Query(
|
||||
default=25, ge=1, le=100, description="Number of items per page"
|
||||
),
|
||||
sort_by: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Column to sort by (e.g. 'user_id', 'user_email', 'created_at', 'spend')",
|
||||
),
|
||||
sort_order: str = fastapi.Query(
|
||||
default="asc", description="Sort order ('asc' or 'desc')"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Get a paginated list of users with filtering options.
|
||||
Get a paginated list of users with filtering and sorting options.
|
||||
|
||||
Parameters:
|
||||
role: Optional[str]
|
||||
|
@ -938,6 +984,8 @@ async def get_users(
|
|||
- internal_user_viewer
|
||||
user_ids: Optional[str]
|
||||
Get list of users by user_ids. Comma separated list of user_ids.
|
||||
sso_ids: Optional[str]
|
||||
Get list of users by sso_ids. Comma separated list of sso_ids.
|
||||
user_email: Optional[str]
|
||||
Filter users by partial email match
|
||||
team: Optional[str]
|
||||
|
@ -946,9 +994,10 @@ async def get_users(
|
|||
The page number to return
|
||||
page_size: int
|
||||
The number of items per page
|
||||
|
||||
Returns:
|
||||
UserListResponse with filtered and paginated users
|
||||
sort_by: Optional[str]
|
||||
Column to sort by (e.g. 'user_id', 'user_email', 'created_at', 'spend')
|
||||
sort_order: Optional[str]
|
||||
Sort order ('asc' or 'desc')
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
|
@ -984,13 +1033,25 @@ async def get_users(
|
|||
"has": team # Array contains for string arrays in Prisma
|
||||
}
|
||||
|
||||
if sso_user_ids is not None and isinstance(sso_user_ids, str):
|
||||
sso_id_list = [sid.strip() for sid in sso_user_ids.split(",") if sid.strip()]
|
||||
where_conditions["sso_user_id"] = {
|
||||
"in": sso_id_list,
|
||||
}
|
||||
|
||||
## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}}
|
||||
where_conditions = {k: v for k, v in where_conditions.items() if v is not None}
|
||||
|
||||
# Build order_by conditions
|
||||
order_by: Optional[Dict[str, str]] = _validate_sort_params(sort_by, sort_order)
|
||||
|
||||
users = await prisma_client.db.litellm_usertable.find_many(
|
||||
where=where_conditions,
|
||||
skip=skip,
|
||||
take=page_size,
|
||||
order={"created_at": "desc"},
|
||||
order=order_by
|
||||
if order_by
|
||||
else {"created_at": "desc"}, # Default to created_at desc if no sort specified
|
||||
)
|
||||
|
||||
# Get total count of user rows
|
||||
|
|
|
@ -153,3 +153,19 @@ async def test_get_users_includes_timestamps(mocker):
|
|||
assert user_response.created_at == mock_user_data["created_at"]
|
||||
assert user_response.updated_at == mock_user_data["updated_at"]
|
||||
assert user_response.key_count == 0
|
||||
|
||||
|
||||
def test_validate_sort_params():
|
||||
"""
|
||||
Test that validate_sort_params returns None if sort_by is None
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
||||
_validate_sort_params,
|
||||
)
|
||||
|
||||
assert _validate_sort_params(None, "asc") is None
|
||||
assert _validate_sort_params(None, "desc") is None
|
||||
assert _validate_sort_params("user_id", "asc") == {"user_id": "asc"}
|
||||
assert _validate_sort_params("user_id", "desc") == {"user_id": "desc"}
|
||||
with pytest.raises(Exception):
|
||||
_validate_sort_params("user_id", "invalid")
|
||||
|
|
|
@ -7,6 +7,7 @@ Tests:
|
|||
2. Verify search input exists
|
||||
3. Test search functionality
|
||||
4. Verify results update
|
||||
5. Test filtering by email, user ID, and SSO user ID
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
@ -61,7 +62,7 @@ test("user search test", async ({ page }) => {
|
|||
console.log("Clicked Internal User tab");
|
||||
|
||||
// Wait for the page to load and table to be visible
|
||||
await page.waitForSelector("tbody tr", { timeout: 10000 });
|
||||
await page.waitForSelector("tbody tr", { timeout: 30000 });
|
||||
await page.waitForTimeout(2000); // Additional wait for table to stabilize
|
||||
console.log("Table is visible");
|
||||
|
||||
|
@ -117,3 +118,97 @@ test("user search test", async ({ page }) => {
|
|||
|
||||
expect(resetUserCount).toBe(initialUserCount);
|
||||
});
|
||||
|
||||
test("user filter test", async ({ page }) => {
|
||||
// Set a longer timeout for the entire test
|
||||
test.setTimeout(60000);
|
||||
|
||||
// Enable console logging
|
||||
page.on("console", (msg) => console.log("PAGE LOG:", msg.text()));
|
||||
|
||||
// Login first
|
||||
await page.goto("http://localhost:4000/ui");
|
||||
console.log("Navigated to login page");
|
||||
|
||||
// Wait for login form to be visible
|
||||
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
|
||||
console.log("Login form is visible");
|
||||
|
||||
await page.fill('input[name="username"]', "admin");
|
||||
await page.fill('input[name="password"]', "gm");
|
||||
console.log("Filled login credentials");
|
||||
|
||||
const loginButton = page.locator('input[type="submit"]');
|
||||
await expect(loginButton).toBeEnabled();
|
||||
await loginButton.click();
|
||||
console.log("Clicked login button");
|
||||
|
||||
// Wait for navigation to complete and dashboard to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
console.log("Page loaded after login");
|
||||
|
||||
// Navigate to Internal Users tab
|
||||
const internalUserTab = page.locator("span.ant-menu-title-content", {
|
||||
hasText: "Internal User",
|
||||
});
|
||||
await internalUserTab.waitFor({ state: "visible", timeout: 10000 });
|
||||
await internalUserTab.click();
|
||||
console.log("Clicked Internal User tab");
|
||||
|
||||
// Wait for the page to load and table to be visible
|
||||
await page.waitForSelector("tbody tr", { timeout: 30000 });
|
||||
await page.waitForTimeout(2000); // Additional wait for table to stabilize
|
||||
console.log("Table is visible");
|
||||
|
||||
// Get initial user count
|
||||
const initialUserCount = await page.locator("tbody tr").count();
|
||||
console.log(`Initial user count: ${initialUserCount}`);
|
||||
|
||||
// Click the filter button to show additional filters
|
||||
const filterButton = page.getByRole("button", {
|
||||
name: "Filters",
|
||||
exact: true,
|
||||
});
|
||||
await filterButton.click();
|
||||
console.log("Clicked filter button");
|
||||
await page.waitForTimeout(500); // Wait for filters to appear
|
||||
|
||||
// Test user ID filter
|
||||
const userIdInput = page.locator('input[placeholder="Filter by User ID"]');
|
||||
await expect(userIdInput).toBeVisible();
|
||||
console.log("User ID filter is visible");
|
||||
|
||||
await userIdInput.fill("user");
|
||||
console.log("Filled user ID filter");
|
||||
await page.waitForTimeout(1000);
|
||||
const userIdFilteredCount = await page.locator("tbody tr").count();
|
||||
console.log(`User ID filtered count: ${userIdFilteredCount}`);
|
||||
expect(userIdFilteredCount).toBeLessThan(initialUserCount);
|
||||
|
||||
// Clear user ID filter
|
||||
await userIdInput.clear();
|
||||
await page.waitForTimeout(1000);
|
||||
console.log("Cleared user ID filter");
|
||||
|
||||
// Test SSO user ID filter
|
||||
const ssoUserIdInput = page.locator('input[placeholder="Filter by SSO ID"]');
|
||||
await expect(ssoUserIdInput).toBeVisible();
|
||||
console.log("SSO user ID filter is visible");
|
||||
|
||||
await ssoUserIdInput.fill("sso");
|
||||
console.log("Filled SSO user ID filter");
|
||||
await page.waitForTimeout(1000);
|
||||
const ssoUserIdFilteredCount = await page.locator("tbody tr").count();
|
||||
console.log(`SSO user ID filtered count: ${ssoUserIdFilteredCount}`);
|
||||
expect(ssoUserIdFilteredCount).toBeLessThan(initialUserCount);
|
||||
|
||||
// Clear SSO user ID filter
|
||||
await ssoUserIdInput.clear();
|
||||
await page.waitForTimeout(5000);
|
||||
console.log("Cleared SSO user ID filter");
|
||||
|
||||
// Verify count returns to initial after clearing all filters
|
||||
const finalUserCount = await page.locator("tbody tr").count();
|
||||
console.log(`Final user count: ${finalUserCount}`);
|
||||
expect(finalUserCount).toBe(initialUserCount);
|
||||
});
|
||||
|
|
|
@ -679,6 +679,9 @@ export const userListCall = async (
|
|||
userEmail: string | null = null,
|
||||
userRole: string | null = null,
|
||||
team: string | null = null,
|
||||
sso_user_id: string | null = null,
|
||||
sortBy: string | null = null,
|
||||
sortOrder: 'asc' | 'desc' | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get all available teams on proxy
|
||||
|
@ -713,6 +716,18 @@ export const userListCall = async (
|
|||
if (team) {
|
||||
queryParams.append('team', team);
|
||||
}
|
||||
|
||||
if (sso_user_id) {
|
||||
queryParams.append('sso_user_ids', sso_user_id);
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
queryParams.append('sort_by', sortBy);
|
||||
}
|
||||
|
||||
if (sortOrder) {
|
||||
queryParams.append('sort_order', sortOrder);
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
|
|
|
@ -84,10 +84,13 @@ 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 isLocal = process.env.NODE_ENV === "development";
|
||||
|
@ -124,15 +127,19 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
email: "",
|
||||
user_id: "",
|
||||
user_role: "",
|
||||
sso_user_id: "",
|
||||
team: "",
|
||||
model: "",
|
||||
min_spend: null,
|
||||
max_spend: null
|
||||
max_spend: null,
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc"
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showColumnDropdown, setShowColumnDropdown] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState("Email");
|
||||
const filtersRef = useRef(null);
|
||||
const lastSearchTimestamp = useRef(0);
|
||||
|
||||
// check if window is not undefined
|
||||
if (typeof window !== "undefined") {
|
||||
|
@ -150,6 +157,17 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const handleFilterChange = (key: keyof FilterState, value: string | number | null) => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
console.log("called from handleFilterChange - newFilters:", JSON.stringify(newFilters));
|
||||
debouncedSearch(newFilters);
|
||||
};
|
||||
|
||||
const handleSortChange = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||
const newFilters = {
|
||||
...filters,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder
|
||||
};
|
||||
setFilters(newFilters);
|
||||
debouncedSearch(newFilters);
|
||||
};
|
||||
|
||||
|
@ -159,6 +177,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimestamp = Date.now();
|
||||
lastSearchTimestamp.current = currentTimestamp;
|
||||
|
||||
try {
|
||||
// Make the API call using userListCall with all filter parameters
|
||||
const data = await userListCall(
|
||||
|
@ -168,12 +190,19 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
defaultPageSize,
|
||||
filters.email || null,
|
||||
filters.user_role || null,
|
||||
filters.team || null
|
||||
filters.team || null,
|
||||
filters.sso_user_id || null,
|
||||
filters.sort_by,
|
||||
filters.sort_order
|
||||
);
|
||||
|
||||
if (data) {
|
||||
setUserListResponse(data);
|
||||
console.log("called from debouncedSearch");
|
||||
// Only update state if this is the most recent search
|
||||
if (currentTimestamp === lastSearchTimestamp.current) {
|
||||
if (data) {
|
||||
setUserListResponse(data);
|
||||
console.log("called from debouncedSearch filters:", JSON.stringify(filters));
|
||||
console.log("called from debouncedSearch data:", JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
|
@ -252,6 +281,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
};
|
||||
|
||||
const refreshUserData = async () => {
|
||||
console.log("called from refreshUserData");
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return;
|
||||
}
|
||||
|
@ -291,7 +321,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
defaultPageSize,
|
||||
filters.email || null,
|
||||
filters.user_role || null,
|
||||
filters.team || null
|
||||
filters.team || null,
|
||||
filters.sso_user_id || null,
|
||||
filters.sort_by,
|
||||
filters.sort_order
|
||||
);
|
||||
|
||||
// Update session storage with new data
|
||||
|
@ -328,7 +361,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
defaultPageSize,
|
||||
filters.email || null,
|
||||
filters.user_role || null,
|
||||
filters.team || null
|
||||
filters.team || null,
|
||||
filters.sso_user_id || null,
|
||||
filters.sort_by,
|
||||
filters.sort_order
|
||||
);
|
||||
|
||||
// Store in session storage
|
||||
|
@ -462,9 +498,12 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
user_id: "",
|
||||
user_role: "",
|
||||
team: "",
|
||||
sso_user_id: "",
|
||||
model: "",
|
||||
min_spend: null,
|
||||
max_spend: null
|
||||
max_spend: null,
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc"
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -541,6 +580,17 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* SSO ID Search */}
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by SSO ID"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={filters.sso_user_id}
|
||||
onChange={(e) => handleFilterChange('sso_user_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -591,9 +641,14 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
</div>
|
||||
|
||||
<UserDataTable
|
||||
data={userListResponse.users || []}
|
||||
data={userListResponse?.users || []}
|
||||
columns={tableColumns}
|
||||
isLoading={!userListResponse}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={{
|
||||
sortBy: filters.sort_by,
|
||||
sortOrder: filters.sort_order
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
|
|
@ -23,15 +23,25 @@ interface UserDataTableProps {
|
|||
data: UserInfo[];
|
||||
columns: ColumnDef<UserInfo, any>[];
|
||||
isLoading?: boolean;
|
||||
onSortChange?: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||
currentSort?: {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export function UserDataTable({
|
||||
data = [],
|
||||
columns,
|
||||
isLoading = false,
|
||||
onSortChange,
|
||||
currentSort,
|
||||
}: UserDataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([
|
||||
{ id: "created_at", desc: true }
|
||||
{
|
||||
id: currentSort?.sortBy || "created_at",
|
||||
desc: currentSort?.sortOrder === "desc"
|
||||
}
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
|
@ -40,12 +50,30 @@ export function UserDataTable({
|
|||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onSortingChange: (newSorting: any) => {
|
||||
setSorting(newSorting);
|
||||
if (newSorting.length > 0) {
|
||||
const sortState = newSorting[0];
|
||||
const sortBy = sortState.id;
|
||||
const sortOrder = sortState.desc ? 'desc' : 'asc';
|
||||
onSortChange?.(sortBy, sortOrder);
|
||||
}
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
});
|
||||
|
||||
// Update local sorting state when currentSort prop changes
|
||||
React.useEffect(() => {
|
||||
if (currentSort) {
|
||||
setSorting([{
|
||||
id: currentSort.sortBy,
|
||||
desc: currentSort.sortOrder === 'desc'
|
||||
}]);
|
||||
}
|
||||
}, [currentSort]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue