Merge pull request #36790 from daniel-meismer-zocdoc/litellm_searchable_usage_report_user_filter

fix(ui): make per-user usage filter searchable
This commit is contained in:
Mateo Wang 2026-08-17 13:39:30 -07:00 committed by GitHub
commit f1e1c89600
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 138 additions and 45 deletions

View file

@ -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<EntityType, (...args: any[]) => Promise<any>> = {
@ -107,6 +108,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
entityList,
userRole,
dateValue,
filterSelectProps,
}) => {
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@ -621,6 +623,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];
const hasEntityFilterOptions = entityList !== null && entityList.length > 0;
const showEntityFilters = entityType !== "team" && (filterSelectProps !== undefined || hasEntityFilterOptions);
return (
<div style={{ width: "100%" }} className="relative">
{isFetchingMore && (
@ -679,7 +684,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
showFilters={showEntityFilters}
filterSlot={
entityType === "team" ? <TeamMultiSelect value={selectedTags} onChange={setSelectedTags} /> : undefined
}
@ -689,6 +694,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
filterMode={entityType === "user" ? "single" : "multiple"}
filterSelectProps={filterSelectProps}
teams={teams || []}
/>
<Tabs defaultValue={tabs[0].key}>

View file

@ -47,7 +47,18 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
}));
vi.mock("./EntityUsage/EntityUsage", () => ({
default: () => <div>Entity Usage</div>,
default: ({
entityType,
filterSelectProps,
}: {
entityType?: string;
filterSelectProps?: { onSearchChange?: (query: string) => void };
}) => (
<div>
Entity Usage
{entityType === "user" && filterSelectProps !== undefined && <span>Searchable user filter</span>}
</div>
),
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(<UsagePage {...defaultProps} />);
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: {

View file

@ -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<UsagePageProps> = ({ 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<string | null>(isAdmin ? null : userID || null);
@ -1043,6 +1051,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
userID={userID}
userRole={userRole}
entityList={userOptions.length > 0 ? userOptions : null}
filterSelectProps={userFilterSelectProps}
premiumUser={premiumUser}
dateValue={dateValue}
/>

View file

@ -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(
<UsageExportHeader
{...defaultProps}
entityType="user"
showFilters
filterMode="single"
filterLabel="User"
filterPlaceholder="Select user to filter..."
filterOptions={[]}
filterSelectProps={{ onSearchChange, onLoadMore: vi.fn() }}
onFiltersChange={vi.fn()}
/>,
);
const userFilter = screen.getByRole("combobox");
await user.click(userFilter);
await user.type(userFilter, "alice");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alice"));
});
});

View file

@ -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<UsageExportHeaderProps> = ({
onFiltersChange,
filterOptions = [],
filterMode = "multiple",
filterSelectProps,
filterSlot,
customTitle,
compactLayout = false,
@ -57,7 +70,8 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
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<UsageExportHeaderProps> = ({
</ComboboxContent>
);
const builtInFilter =
filterMode === "single" ? (
<Combobox
items={optionValues}
value={selectedFilters[0] ?? null}
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
itemToStringLabel={labelOf}
>
<ComboboxInput
className="w-full"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
showClear={selectedFilters.length > 0}
/>
{filterList}
</Combobox>
) : (
<Combobox
multiple
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="w-full">
<ComboboxValue>
{(selected: string[]) =>
selected.map((value) => (
<ComboboxChip key={value} aria-label={labelOf(value)}>
{labelOf(value)}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder={filterPlaceholder} aria-label={filterPlaceholder} />
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
</ComboboxChips>
{filterList}
</Combobox>
);
const searchableSingleFilter =
filterSelectProps !== undefined ? (
<PaginatedSearchSelect
options={filterOptions}
value={selectedFilters[0]}
onValueChange={(next) => onFiltersChange?.(next ? [next] : [])}
placeholder={filterPlaceholder}
{...filterSelectProps}
/>
) : undefined;
const singleFilter = searchableSingleFilter ?? (
<Combobox
items={optionValues}
value={selectedFilters[0] ?? null}
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
itemToStringLabel={labelOf}
>
<ComboboxInput
className="w-full"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
showClear={selectedFilters.length > 0}
/>
{filterList}
</Combobox>
);
const multiFilter = (
<Combobox
multiple
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="w-full">
<ComboboxValue>
{(selected: string[]) =>
selected.map((value) => (
<ComboboxChip key={value} aria-label={labelOf(value)}>
{labelOf(value)}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder={filterPlaceholder} aria-label={filterPlaceholder} />
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
</ComboboxChips>
{filterList}
</Combobox>
);
const builtInFilter = filterMode === "single" ? singleFilter : multiFilter;
return (
<>

View file

@ -1,3 +1,4 @@
export { default } from "./EntityUsageExportModal";
export { default as UsageExportHeader } from "./UsageExportHeader";
export type { UsageFilterSelectProps } from "./UsageExportHeader";
export * from "./types";