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 928337010f7..c9faaf91238 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 @@ -24,7 +24,7 @@ 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 { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; -import { UsageExportHeader } from "@/components/EntityUsageExport"; +import { UsageExportHeader, type UsageFilterSelectProps } from "@/components/EntityUsageExport"; import type { EntityType } from "@/components/EntityUsageExport/types"; import { agentDailyActivityCall, @@ -84,6 +84,7 @@ interface EntityUsageProps { entityList: EntityList[] | null; premiumUser: boolean; dateValue: DateRangePickerValue; + filterSelectProps?: UsageFilterSelectProps; } const ENTITY_FETCH_FNS: Record Promise> = { @@ -107,6 +108,7 @@ const EntityUsage: React.FC = ({ entityList, userRole, dateValue, + filterSelectProps, }) => { const { teams } = useTeams(); const [selectedTags, setSelectedTags] = useState([]); @@ -621,6 +623,9 @@ 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 && ( @@ -679,7 +684,7 @@ const EntityUsage: React.FC = ({ dateValue={dateValue} entityType={entityType} spendData={spendData} - showFilters={entityType !== "team" && entityList !== null && entityList.length > 0} + showFilters={showEntityFilters} filterSlot={ entityType === "team" ? : undefined } @@ -689,6 +694,7 @@ const EntityUsage: React.FC = ({ 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 9cd1e53c9b4..016d96cfa5c 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,7 +47,18 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ })); vi.mock("./EntityUsage/EntityUsage", () => ({ - default: () =>
Entity Usage
, + default: ({ + entityType, + filterSelectProps, + }: { + entityType?: string; + filterSelectProps?: { onSearchChange?: (query: string) => void }; + }) => ( +
+ Entity Usage + {entityType === "user" && filterSelectProps !== undefined && Searchable user filter} +
+ ), EntityList: [], })); @@ -77,6 +88,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { React.createElement("option", { value: "customer" }, "Customer Usage"), tagOption, React.createElement("option", { value: "agent" }, "Agent Usage"), + React.createElement("option", { value: "user" }, "User Usage"), React.createElement("option", { value: "user-agent-activity" }, "User Agent Activity"), ); }; @@ -744,6 +756,18 @@ 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: { 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 96cb0098a3a..ea8f02a4e70 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 @@ -28,7 +28,7 @@ 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 from "@/components/EntityUsageExport"; +import EntityUsageExportModal, { type UsageFilterSelectProps } from "@/components/EntityUsageExport"; import { Team } from "@/components/key_team_helpers/key_list"; import { gatewayDailyActivityCall, @@ -135,6 +135,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { 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); @@ -1043,6 +1051,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { userID={userID} userRole={userRole} entityList={userOptions.length > 0 ? userOptions : null} + filterSelectProps={userFilterSelectProps} 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 729d6fd3406..bfc667f60ba 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 } from "../../../tests/test-utils"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import UsageExportHeader from "./UsageExportHeader"; @@ -70,4 +70,29 @@ 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(); + + renderWithProviders( + , + ); + + const userFilter = screen.getByRole("combobox"); + await user.click(userFilter); + await user.type(userFilter, "alice"); + + await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alice")); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index db386af1ffd..2716182f702 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -1,6 +1,7 @@ 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, @@ -20,6 +21,16 @@ 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; @@ -32,6 +43,7 @@ interface UsageExportHeaderProps { onFiltersChange?: (filters: string[]) => void; filterOptions?: Array<{ label: string; value: string }>; filterMode?: "multiple" | "single"; + filterSelectProps?: UsageFilterSelectProps; filterSlot?: React.ReactNode; customTitle?: string; compactLayout?: boolean; @@ -49,6 +61,7 @@ const UsageExportHeader: React.FC = ({ onFiltersChange, filterOptions = [], filterMode = "multiple", + filterSelectProps, filterSlot, customTitle, compactLayout = false, @@ -57,7 +70,8 @@ const UsageExportHeader: React.FC = ({ const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const hasFilters = filterSlot != null || (showFilters && filterOptions.length > 0); + const hasBuiltInFilter = filterOptions.length > 0 || filterSelectProps !== undefined; + const hasFilters = filterSlot != null || (showFilters && hasBuiltInFilter); const optionValues = filterOptions.map((option) => option.value); const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value; @@ -74,45 +88,59 @@ const UsageExportHeader: React.FC = ({ ); - const builtInFilter = - filterMode === "single" ? ( - onFiltersChange?.(next ? [next] : [])} - itemToStringLabel={labelOf} - > - 0} - /> - {filterList} - - ) : ( - onFiltersChange?.(next)} - > - } className="w-full"> - - {(selected: string[]) => - selected.map((value) => ( - - {labelOf(value)} - - )) - } - - - {selectedFilters.length > 0 && } - - {filterList} - - ); + const searchableSingleFilter = + filterSelectProps !== undefined ? ( + onFiltersChange?.(next ? [next] : [])} + placeholder={filterPlaceholder} + {...filterSelectProps} + /> + ) : undefined; + + const singleFilter = searchableSingleFilter ?? ( + onFiltersChange?.(next ? [next] : [])} + itemToStringLabel={labelOf} + > + 0} + /> + {filterList} + + ); + + const multiFilter = ( + onFiltersChange?.(next)} + > + } className="w-full"> + + {(selected: string[]) => + selected.map((value) => ( + + {labelOf(value)} + + )) + } + + + {selectedFilters.length > 0 && } + + {filterList} + + ); + + 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 dca6d9e0a6a..2a5e94aabbe 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts @@ -1,3 +1,4 @@ export { default } from "./EntityUsageExportModal"; export { default as UsageExportHeader } from "./UsageExportHeader"; +export type { UsageFilterSelectProps } from "./UsageExportHeader"; export * from "./types";