diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index b1d9cf28503..bd8b6457262 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1493,9 +1493,6 @@ "max-lines": { "count": 1 }, - "no-nested-ternary": { - "count": 1 - }, "react-hooks/purity": { "count": 1 }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index 537e2c5378a..dd7209140a9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React, { ReactNode } from "react"; -import { useInfiniteUsers } from "./useUsers"; +import { useInfiniteUsers, useUserLookup } from "./useUsers"; import { userListCall } from "@/components/networking"; import type { UserListResponse } from "@/components/networking"; @@ -285,3 +285,53 @@ describe("useInfiniteUsers", () => { expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null); }); }); + +describe("useUserLookup", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue(DEFAULT_AUTH); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("fetches exactly the requested user by id", async () => { + vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1)); + + const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(userListCall).toHaveBeenCalledWith("test-access-token", ["user-1-0"], 1, 1); + expect(result.current.data?.user_id).toBe("user-1-0"); + }); + + it("resolves to null when the proxy returns a different user than requested", async () => { + vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1)); + + const { result } = renderHook(() => useUserLookup("someone-else"), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); + + it("does not query without a user id", async () => { + const { result } = renderHook(() => useUserLookup(null), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.fetchStatus).toBe("idle"); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("does not query for a non-admin role", async () => { + mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: "Internal User" }); + + const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.fetchStatus).toBe("idle"); + expect(userListCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts index 9031de3cb1a..011e43777b5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -1,10 +1,11 @@ -import { userListCall, UserListResponse } from "@/components/networking"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { userListCall, UserInfo, UserListResponse } from "@/components/networking"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { all_admin_roles } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const infiniteUsersKeys = createQueryKeys("infiniteUsers"); +const userLookupKeys = createQueryKeys("userLookup"); const DEFAULT_PAGE_SIZE = 50; @@ -36,3 +37,15 @@ export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEma enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!), }); }; + +export const useUserLookup = (userId: string | null) => { + const { accessToken, userRole } = useAuthorized(); + return useQuery({ + queryKey: userLookupKeys.detail(userId ?? ""), + queryFn: async () => { + const response = await userListCall(accessToken!, [userId!], 1, 1); + return response.users.find((user) => user.user_id === userId) ?? null; + }, + enabled: Boolean(accessToken) && Boolean(userId) && all_admin_roles.includes(userRole!), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 1f3dd5642ee..e921712732a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -1,5 +1,8 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import * as networking from "@/components/networking"; import EntityUsage from "./EntityUsage"; @@ -61,7 +64,18 @@ vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({ })); vi.mock("@/components/EntityUsageExport", () => ({ - UsageExportHeader: () =>
Usage Export Header
, + UsageExportHeader: ({ filterLabel, filterSlot }: { filterLabel?: string; filterSlot?: ReactNode }) => ( +
+ Usage Export Header + {filterLabel} + {filterSlot} +
+ ), +})); + +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: vi.fn(), + useUserLookup: vi.fn(() => ({ data: null })), })); vi.mock("@/components/common_components/team_multi_select", () => ({ @@ -83,6 +97,16 @@ describe("EntityUsage", () => { const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall); const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall); const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); + const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); + + const infiniteUsersResult = (users: { user_id: string; user_alias: string | null; user_email: string | null }[]) => + ({ + data: { pages: [{ users, page: 1, total_pages: 1, total_count: users.length }], pageParams: [1] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }) as unknown as ReturnType; const mockSpendData = { results: [ @@ -380,6 +404,13 @@ describe("EntityUsage", () => { mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData); mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData); mockUserDailyActivityCall.mockResolvedValue(mockSpendData); + mockUseInfiniteUsers.mockClear(); + mockUseInfiniteUsers.mockReturnValue( + infiniteUsersResult([ + { user_id: "user-001", user_alias: "Alice", user_email: "alice@example.com" }, + { user_id: "user-002", user_alias: null, user_email: "bob@example.com" }, + ]), + ); }); it("should render with tag entity type and display spend metrics", async () => { @@ -987,4 +1018,68 @@ describe("EntityUsage", () => { expect(screen.getByText("top-models:gpt-4o=70.25")).toBeInTheDocument(); expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument(); }); + + describe("user filter (LIT-5654)", () => { + const userDropdown = (): HTMLElement => screen.getByTestId("user-dropdown"); + const userCombobox = (): HTMLElement => within(userDropdown()).getByRole("combobox"); + + const renderUserUsage = async () => { + render(); + await waitFor(() => { + expect(mockUserDailyActivityCall).toHaveBeenCalled(); + }); + }; + + it("offers a user filter even when the caller preloaded no user page", async () => { + await renderUserUsage(); + + expect(userCombobox()).toHaveAttribute("placeholder", "Search users by email…"); + }); + + it("searches every user on the server rather than a preloaded page", async () => { + const user = userEvent.setup(); + await renderUserUsage(); + + expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined); + + await user.type(userCombobox(), "alice"); + + await waitFor(() => { + expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, "alice"); + }); + }); + + it("refetches daily activity for the picked user and drops the filter when cleared", async () => { + const user = userEvent.setup(); + await renderUserUsage(); + + expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null); + + await user.click(userCombobox()); + await user.click(await screen.findByText("Alice (user-001)")); + + await waitFor(() => { + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + 1, + "user-001", + ); + }); + + mockUserDailyActivityCall.mockClear(); + await user.click(userDropdown().querySelector('[data-slot="combobox-clear"]') as HTMLElement); + + await waitFor(() => { + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + 1, + null, + ); + }); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index c9faaf91238..66265fb41d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -23,8 +23,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; +import UserDropdown from "@/components/common_components/UserDropdown"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; -import { UsageExportHeader, type UsageFilterSelectProps } from "@/components/EntityUsageExport"; +import { UsageExportHeader } from "@/components/EntityUsageExport"; import type { EntityType } from "@/components/EntityUsageExport/types"; import { agentDailyActivityCall, @@ -84,7 +85,6 @@ interface EntityUsageProps { entityList: EntityList[] | null; premiumUser: boolean; dateValue: DateRangePickerValue; - filterSelectProps?: UsageFilterSelectProps; } const ENTITY_FETCH_FNS: Record Promise> = { @@ -108,7 +108,6 @@ const EntityUsage: React.FC = ({ entityList, userRole, dateValue, - filterSelectProps, }) => { const { teams } = useTeams(); const [selectedTags, setSelectedTags] = useState([]); @@ -256,6 +255,14 @@ const EntityUsage: React.FC = ({ return `Select ${entityType} to filter...`; }; + const entityFilterSlots: Partial> = { + team: , + user: ( + setSelectedTags(userId ? [userId] : [])} /> + ), + }; + const filterSlot = entityFilterSlots[entityType]; + const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata); const providerSpend = useMemo(() => getProviderSpend(spendData.results), [spendData.results]); @@ -623,9 +630,6 @@ const EntityUsage: React.FC = ({ { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const hasEntityFilterOptions = entityList !== null && entityList.length > 0; - const showEntityFilters = entityType !== "team" && (filterSelectProps !== undefined || hasEntityFilterOptions); - return (
{isFetchingMore && ( @@ -684,17 +688,13 @@ const EntityUsage: React.FC = ({ dateValue={dateValue} entityType={entityType} spendData={spendData} - showFilters={showEntityFilters} - filterSlot={ - entityType === "team" ? : undefined - } - filterLabel={entityType === "team" ? "Filter by team" : getFilterLabel(entityType)} + showFilters={filterSlot === undefined && entityList !== null && entityList.length > 0} + filterSlot={filterSlot} + filterLabel={getFilterLabel(entityType)} filterPlaceholder={getFilterPlaceholder(entityType)} selectedFilters={selectedTags} onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} - filterMode={entityType === "user" ? "single" : "multiple"} - filterSelectProps={filterSelectProps} teams={teams || []} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 016d96cfa5c..f560d3c2c84 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -47,16 +47,9 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ })); vi.mock("./EntityUsage/EntityUsage", () => ({ - default: ({ - entityType, - filterSelectProps, - }: { - entityType?: string; - filterSelectProps?: { onSearchChange?: (query: string) => void }; - }) => ( -
+ default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => ( +
Entity Usage - {entityType === "user" && filterSelectProps !== undefined && Searchable user filter}
), EntityList: [], @@ -154,6 +147,7 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ useInfiniteUsers: vi.fn(), + useUserLookup: vi.fn(() => ({ data: null })), })); describe("UsagePage", () => { @@ -725,7 +719,7 @@ describe("UsagePage", () => { }); expect(userSelectCombobox()).toBeInTheDocument(); - expect(promptsWith("Select user to filter...")).toBe(true); + expect(promptsWith("Search users by email…")).toBe(true); }); it("should format user options with alias when available", async () => { @@ -756,18 +750,6 @@ describe("UsagePage", () => { expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined); }); - it("should reuse the searchable user filter in the user usage view", async () => { - renderWithProviders(); - - await waitFor(() => { - expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); - }); - - fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "user" } }); - - expect(await screen.findByText("Searchable user filter")).toBeInTheDocument(); - }); - it("should deduplicate users across pages", async () => { mockUseInfiniteUsers.mockReturnValue({ data: { @@ -828,6 +810,46 @@ describe("UsagePage", () => { }); }); + describe("user usage view", () => { + it("should hand EntityUsage no user list so its own filter can search every user", async () => { + mockUseInfiniteUsers.mockReturnValue({ + data: { + pages: [ + { + users: Array.from({ length: 50 }, (_, index) => ({ + user_id: `user-${index}`, + user_alias: null, + user_email: `user${index}@example.com`, + })), + page: 1, + total_pages: 4, + total_count: 200, + }, + ], + pageParams: [1], + }, + fetchNextPage: vi.fn(), + hasNextPage: true, + isFetchingNextPage: false, + isLoading: false, + } as unknown as ReturnType); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + act(() => { + fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "user" } }); + }); + + const entityUsage = await screen.findByTestId("entity-usage"); + expect(entityUsage).toHaveAttribute("data-entity-type", "user"); + expect(entityUsage).toHaveAttribute("data-entity-list", "null"); + }); + }); + describe("non-admin user behavior", () => { it("should not render user selector for non-admin users", async () => { mockUseAuthorized.mockReturnValue({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index ea8f02a4e70..9558b9a7f76 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -12,7 +12,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { BarChart } from "@/components/shared/charts"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; -import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { Button } from "@/components/ui/button"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -22,13 +21,13 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; -import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { hasCapability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import CloudZeroExportModal from "@/components/cloudzero_export_modal"; -import EntityUsageExportModal, { type UsageFilterSelectProps } from "@/components/EntityUsageExport"; +import UserDropdown from "@/components/common_components/UserDropdown"; +import EntityUsageExportModal from "@/components/EntityUsageExport"; import { Team } from "@/components/key_team_helpers/key_list"; import { gatewayDailyActivityCall, @@ -104,45 +103,6 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage"); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); - const [settledUserSearch, setSettledUserSearch] = useState(""); - - const { - data: usersInfiniteData, - fetchNextPage: fetchNextUsersPage, - hasNextPage: hasNextUsersPage, - isFetchingNextPage: isFetchingNextUsersPage, - isLoading: isLoadingUsers, - } = useInfiniteUsers(50, settledUserSearch || undefined); - - const userOptions = useMemo(() => { - if (!usersInfiniteData?.pages) return []; - const seen = new Set(); - const result: { value: string; label: string }[] = []; - for (const page of usersInfiniteData.pages) { - for (const user of page.users) { - if (seen.has(user.user_id)) continue; - seen.add(user.user_id); - result.push({ - value: user.user_id, - label: user.user_alias - ? `${user.user_alias} (${user.user_id})` - : user.user_email - ? `${user.user_email} (${user.user_id})` - : user.user_id, - }); - } - } - return result; - }, [usersInfiniteData]); - - const userFilterSelectProps: UsageFilterSelectProps = { - onSearchChange: setSettledUserSearch, - onLoadMore: () => void fetchNextUsersPage(), - hasNextPage: hasNextUsersPage, - isLoading: isLoadingUsers, - isFetchingNextPage: isFetchingNextUsersPage, - emptyText: "No users found", - }; // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID const [selectedUserId, setSelectedUserId] = useState(isAdmin ? null : userID || null); @@ -536,18 +496,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {isAdmin && usageView === "global" && (

Filter by user

- setSelectedUserId(value === "" ? null : value)} - onSearchChange={setSettledUserSearch} - onLoadMore={fetchNextUsersPage} - hasNextPage={hasNextUsersPage} - isLoading={isLoadingUsers} - isFetchingNextPage={isFetchingNextUsersPage} - placeholder="Select user to filter..." - emptyText="No users found" - /> +
)} @@ -1050,8 +999,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { entityType="user" userID={userID} userRole={userRole} - entityList={userOptions.length > 0 ? userOptions : null} - filterSelectProps={userFilterSelectProps} + entityList={null} premiumUser={premiumUser} dateValue={dateValue} /> diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx index bfc667f60ba..27985c3db28 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx @@ -1,4 +1,4 @@ -import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import { renderWithProviders, screen } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import UsageExportHeader from "./UsageExportHeader"; @@ -71,28 +71,17 @@ describe("UsageExportHeader", () => { expect(screen.getByText("Team")).toBeInTheDocument(); }); - it("should keep a searchable single filter usable when no options match", async () => { - const user = userEvent.setup(); - const onSearchChange = vi.fn(); - + it("should render a caller-supplied filter and its label without any built-in options", () => { renderWithProviders( } />, ); - const userFilter = screen.getByRole("combobox"); - await user.click(userFilter); - await user.type(userFilter, "alice"); - - await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alice")); + expect(screen.getByText("Filter by user")).toBeInTheDocument(); + expect(screen.getByTestId("custom-filter")).toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index 2716182f702..f34c051fb9f 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -1,7 +1,6 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { Download } from "lucide-react"; import React, { useState } from "react"; -import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { Button } from "@/components/ui/button"; import { Combobox, @@ -11,7 +10,6 @@ import { ComboboxClear, ComboboxContent, ComboboxEmpty, - ComboboxInput, ComboboxItem, ComboboxList, ComboboxValue, @@ -21,16 +19,6 @@ import EntityUsageExportModal from "./EntityUsageExportModal"; import type { EntitySpendData, EntityType } from "./types"; import type { Team } from "@/components/key_team_helpers/key_list"; -export interface UsageFilterSelectProps { - onSearchChange: (query: string) => void; - onLoadMore: () => void; - hasNextPage?: boolean; - isLoading?: boolean; - isFetchingNextPage?: boolean; - emptyText?: string; - loadingText?: string; -} - interface UsageExportHeaderProps { dateValue: DateRangePickerValue; entityType: EntityType; @@ -42,8 +30,6 @@ interface UsageExportHeaderProps { selectedFilters?: string[]; onFiltersChange?: (filters: string[]) => void; filterOptions?: Array<{ label: string; value: string }>; - filterMode?: "multiple" | "single"; - filterSelectProps?: UsageFilterSelectProps; filterSlot?: React.ReactNode; customTitle?: string; compactLayout?: boolean; @@ -60,8 +46,6 @@ const UsageExportHeader: React.FC = ({ selectedFilters = [], onFiltersChange, filterOptions = [], - filterMode = "multiple", - filterSelectProps, filterSlot, customTitle, compactLayout = false, @@ -70,8 +54,7 @@ const UsageExportHeader: React.FC = ({ const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const hasBuiltInFilter = filterOptions.length > 0 || filterSelectProps !== undefined; - const hasFilters = filterSlot != null || (showFilters && hasBuiltInFilter); + const hasFilters = filterSlot != null || (showFilters && filterOptions.length > 0); const optionValues = filterOptions.map((option) => option.value); const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value; @@ -88,35 +71,7 @@ const UsageExportHeader: React.FC = ({ ); - const searchableSingleFilter = - filterSelectProps !== undefined ? ( - onFiltersChange?.(next ? [next] : [])} - placeholder={filterPlaceholder} - {...filterSelectProps} - /> - ) : undefined; - - const singleFilter = searchableSingleFilter ?? ( - onFiltersChange?.(next ? [next] : [])} - itemToStringLabel={labelOf} - > - 0} - /> - {filterList} - - ); - - const multiFilter = ( + const builtInFilter = ( = ({ ); - const builtInFilter = filterMode === "single" ? singleFilter : multiFilter; - return ( <>
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts index 2a5e94aabbe..dca6d9e0a6a 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts @@ -1,4 +1,3 @@ export { default } from "./EntityUsageExportModal"; export { default as UsageExportHeader } from "./UsageExportHeader"; -export type { UsageFilterSelectProps } from "./UsageExportHeader"; export * from "./types"; diff --git a/ui/litellm-dashboard/src/components/common_components/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/common_components/UserDropdown.test.tsx new file mode 100644 index 00000000000..82cad3999e4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/UserDropdown.test.tsx @@ -0,0 +1,220 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useInfiniteUsers, useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; +import type { UserInfo } from "@/components/networking"; +import UserDropdown, { userOptionLabel } from "./UserDropdown"; + +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: vi.fn(), + useUserLookup: vi.fn(), +})); + +const userRow = (userId: string, overrides: Partial> = {}) => ({ + user_id: userId, + user_alias: null, + user_email: "", + ...overrides, +}); + +const ALIASED = userRow("user-1", { user_alias: "Alice Admin", user_email: "alice@example.com" }); +const EMAILED = userRow("user-2", { user_email: "bob@example.com" }); +const BARE = userRow("user-3"); + +const mockUsersResult = ( + overrides: Partial<{ + pages: { users: ReturnType[] }[]; + fetchNextPage: () => void; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + }> = {}, +) => { + const { pages = [{ users: [ALIASED, EMAILED, BARE] }], ...rest } = overrides; + return { + data: { pages }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + ...rest, + } as unknown as ReturnType; +}; + +const mockLookupResult = (user: ReturnType | null) => + ({ data: user }) as unknown as ReturnType; + +function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) { + Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true }); + Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true }); +} + +describe("UserDropdown", () => { + const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); + const mockUseUserLookup = vi.mocked(useUserLookup); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteUsers.mockReturnValue(mockUsersResult()); + mockUseUserLookup.mockReturnValue(mockLookupResult(null)); + }); + + const combobox = () => screen.getByRole("combobox"); + + it("queries the first page of users with no search term", () => { + render(); + + expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined); + }); + + it("forwards the pageSize prop to the users query", () => { + render(); + + expect(mockUseInfiniteUsers).toHaveBeenCalledWith(25, undefined); + }); + + it("sends the typed query to the server instead of narrowing the loaded page", async () => { + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + await user.type(combobox(), "alice"); + + await waitFor(() => expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, "alice")); + expect(screen.getByText("bob@example.com (user-2)")).toBeInTheDocument(); + }); + + it("labels a user by alias, falling back to email and then the bare id", async () => { + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.getByText("Alice Admin (user-1)")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com (user-2)")).toBeInTheDocument(); + expect(screen.getByText("user-3")).toBeInTheDocument(); + }); + + it("deduplicates a user that appears on more than one page", async () => { + mockUseInfiniteUsers.mockReturnValue( + mockUsersResult({ + pages: [{ users: [ALIASED] }, { users: [ALIASED, EMAILED] }], + }), + ); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.getAllByText("Alice Admin (user-1)")).toHaveLength(1); + expect(screen.getByText("bob@example.com (user-2)")).toBeInTheDocument(); + }); + + it("loads the next page once the list is scrolled near the bottom", async () => { + const fetchNextPage = vi.fn(); + mockUseInfiniteUsers.mockReturnValue(mockUsersResult({ fetchNextPage, hasNextPage: true })); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + const list = await screen.findByTestId("paginated-search-select-list"); + + setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(fetchNextPage).not.toHaveBeenCalled(); + + setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it("does not load more when there is no next page or one is already in flight", async () => { + const fetchNextPage = vi.fn(); + mockUseInfiniteUsers.mockReturnValue(mockUsersResult({ fetchNextPage, hasNextPage: false })); + const user = userEvent.setup(); + const { unmount } = render(); + + await user.click(combobox()); + setListMetrics(await screen.findByTestId("paginated-search-select-list"), { + scrollTop: 900, + clientHeight: 100, + scrollHeight: 1000, + }); + fireEvent.scroll(screen.getByTestId("paginated-search-select-list")); + expect(fetchNextPage).not.toHaveBeenCalled(); + unmount(); + + mockUseInfiniteUsers.mockReturnValue( + mockUsersResult({ fetchNextPage, hasNextPage: true, isFetchingNextPage: true }), + ); + render(); + + await user.click(combobox()); + setListMetrics(await screen.findByTestId("paginated-search-select-list"), { + scrollTop: 900, + clientHeight: 100, + scrollHeight: 1000, + }); + fireEvent.scroll(screen.getByTestId("paginated-search-select-list")); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + it("reports the picked user id to onChange", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + await user.click(screen.getByText("bob@example.com (user-2)")); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("user-2"); + }); + + it("reports null to onChange when the selection is cleared", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("shows the label of the user passed in value", () => { + render(); + + expect(combobox()).toHaveValue("Alice Admin (user-1)"); + expect(mockUseUserLookup).toHaveBeenLastCalledWith(null); + }); + + it("looks up a selected user that is not on the loaded page so its label still shows", () => { + const OFF_PAGE = userRow("user-99", { user_alias: "Zed Offpage" }); + mockUseUserLookup.mockReturnValue(mockLookupResult(OFF_PAGE)); + render(); + + expect(mockUseUserLookup).toHaveBeenLastCalledWith("user-99"); + expect(combobox()).toHaveValue("Zed Offpage (user-99)"); + }); + + it("falls back to the bare id while the off-page lookup has not resolved", () => { + render(); + + expect(combobox()).toHaveValue("user-99"); + }); +}); + +describe("userOptionLabel", () => { + it("prefers the alias", () => { + expect(userOptionLabel(ALIASED)).toBe("Alice Admin (user-1)"); + }); + + it("falls back to the email", () => { + expect(userOptionLabel(EMAILED)).toBe("bob@example.com (user-2)"); + }); + + it("falls back to the bare id", () => { + expect(userOptionLabel(BARE)).toBe("user-3"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx new file mode 100644 index 00000000000..eef1a8233bc --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx @@ -0,0 +1,67 @@ +import React, { useMemo, useState } from "react"; +import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; +import type { SearchSelectOption } from "@/components/shared/SearchSelect"; +import { useInfiniteUsers, useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; +import type { UserInfo } from "@/components/networking"; + +interface UserDropdownProps { + value?: string | null; + onChange: (userId: string | null) => void; + disabled?: boolean; + pageSize?: number; + id?: string; +} + +export const userOptionLabel = (user: Pick): string => { + if (user.user_alias) return `${user.user_alias} (${user.user_id})`; + if (user.user_email) return `${user.user_email} (${user.user_id})`; + return user.user_id; +}; + +const UserDropdown: React.FC = ({ value, onChange, disabled, pageSize = 50, id }) => { + const [search, setSearch] = useState(""); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers( + pageSize, + search || undefined, + ); + + const loadedOptions = useMemo(() => { + const byId = new Map(); + for (const user of (data?.pages ?? []).flatMap((page) => page.users)) { + if (byId.has(user.user_id)) continue; + byId.set(user.user_id, { value: user.user_id, label: userOptionLabel(user) }); + } + return Array.from(byId.values()); + }, [data]); + + const selectedIsLoaded = loadedOptions.some((option) => option.value === value); + const { data: selectedUser } = useUserLookup(value && !selectedIsLoaded ? value : null); + + const options = useMemo(() => { + if (!value || selectedIsLoaded || !selectedUser) return loadedOptions; + return [{ value: selectedUser.user_id, label: userOptionLabel(selectedUser) }, ...loadedOptions]; + }, [value, selectedIsLoaded, selectedUser, loadedOptions]); + + return ( +
+ onChange(next === "" ? null : next)} + onSearchChange={setSearch} + onLoadMore={fetchNextPage} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search users by email…" + emptyText="No users found" + loadingText="Loading users…" + disabled={disabled} + inputId={id} + /> +
+ ); +}; + +export default UserDropdown;