mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
commit
f1e1c89600
6 changed files with 138 additions and 45 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { default } from "./EntityUsageExportModal";
|
||||
export { default as UsageExportHeader } from "./UsageExportHeader";
|
||||
export type { UsageFilterSelectProps } from "./UsageExportHeader";
|
||||
export * from "./types";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue