mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): make per-user usage filter searchable
Reuse the Global Usage user search and pagination behavior in the Per User report, including empty-result handling. Co-Authored-By: Codex
This commit is contained in:
parent
09889e1986
commit
19eae00d71
6 changed files with 100 additions and 25 deletions
|
|
@ -37,7 +37,7 @@ import { Alert, Button, Tooltip } from "antd";
|
|||
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,
|
||||
|
|
@ -97,6 +97,7 @@ interface EntityUsageProps {
|
|||
entityList: EntityList[] | null;
|
||||
premiumUser: boolean;
|
||||
dateValue: DateRangePickerValue;
|
||||
filterSelectProps?: UsageFilterSelectProps;
|
||||
}
|
||||
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
|
|
@ -120,6 +121,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
entityList,
|
||||
userRole,
|
||||
dateValue,
|
||||
filterSelectProps,
|
||||
}) => {
|
||||
const { teams } = useTeams();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
|
@ -678,13 +680,17 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
dateValue={dateValue}
|
||||
entityType={entityType}
|
||||
spendData={spendData}
|
||||
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
|
||||
showFilters={
|
||||
entityType !== "team" &&
|
||||
(filterSelectProps?.showSearch === true || (entityList !== null && entityList.length > 0))
|
||||
}
|
||||
filterLabel={getFilterLabel(entityType)}
|
||||
filterPlaceholder={getFilterPlaceholder(entityType)}
|
||||
selectedFilters={selectedTags}
|
||||
onFiltersChange={setSelectedTags}
|
||||
filterOptions={getAllTags() || undefined}
|
||||
filterMode={entityType === "user" ? "single" : "multiple"}
|
||||
filterSelectProps={filterSelectProps}
|
||||
teams={teams || []}
|
||||
/>
|
||||
<TabGroup>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,18 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("./EntityUsage/EntityUsage", () => ({
|
||||
default: () => <div>Entity Usage</div>,
|
||||
default: ({
|
||||
entityType,
|
||||
filterSelectProps,
|
||||
}: {
|
||||
entityType?: string;
|
||||
filterSelectProps?: { showSearch?: boolean };
|
||||
}) => (
|
||||
<div>
|
||||
Entity Usage
|
||||
{entityType === "user" && filterSelectProps?.showSearch && <span>Searchable user filter</span>}
|
||||
</div>
|
||||
),
|
||||
EntityList: [],
|
||||
}));
|
||||
|
||||
|
|
@ -76,6 +87,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"),
|
||||
);
|
||||
};
|
||||
|
|
@ -924,6 +936,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: {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,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,
|
||||
|
|
@ -161,6 +161,26 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const userFilterSelectProps: UsageFilterSelectProps = {
|
||||
showSearch: true,
|
||||
filterOption: false,
|
||||
onSearch: handleUserSearchChange,
|
||||
searchValue: userSearchInput,
|
||||
onPopupScroll: handleUserPopupScroll,
|
||||
loading: isLoadingUsers,
|
||||
notFoundContent: isLoadingUsers ? <LoadingOutlined spin /> : "No users found",
|
||||
popupRender: (menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextUsersPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
|
@ -565,29 +585,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<div className="mb-4">
|
||||
<Text className="mb-2">Filter by user</Text>
|
||||
<Select
|
||||
showSearch
|
||||
{...userFilterSelectProps}
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select user to filter..."
|
||||
value={selectedUserId}
|
||||
onChange={(value) => setSelectedUserId(value ?? null)}
|
||||
filterOption={false}
|
||||
onSearch={handleUserSearchChange}
|
||||
searchValue={userSearchInput}
|
||||
onPopupScroll={handleUserPopupScroll}
|
||||
loading={isLoadingUsers}
|
||||
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
|
||||
options={userOptions}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextUsersPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1057,6 +1061,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={userOptions.length > 0 ? userOptions : null}
|
||||
filterSelectProps={userFilterSelectProps}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 onSearch = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<UsageExportHeader
|
||||
{...defaultProps}
|
||||
entityType="user"
|
||||
showFilters
|
||||
filterMode="single"
|
||||
filterLabel="User"
|
||||
filterPlaceholder="Select user to filter..."
|
||||
filterOptions={[]}
|
||||
filterSelectProps={{ showSearch: true, filterOption: false, onSearch }}
|
||||
onFiltersChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userFilter = screen.getByRole("combobox");
|
||||
await user.click(userFilter);
|
||||
await user.type(userFilter, "alice");
|
||||
|
||||
expect(onSearch).toHaveBeenLastCalledWith("alice");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import type { DateRangePickerValue } from "@tremor/react";
|
||||
import { Button, Text } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import { Select, type SelectProps } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import EntityUsageExportModal from "./EntityUsageExportModal";
|
||||
import type { EntitySpendData, EntityType } from "./types";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
export type UsageFilterSelectProps = Pick<
|
||||
SelectProps<string>,
|
||||
| "filterOption"
|
||||
| "loading"
|
||||
| "notFoundContent"
|
||||
| "onPopupScroll"
|
||||
| "onSearch"
|
||||
| "popupRender"
|
||||
| "searchValue"
|
||||
| "showSearch"
|
||||
>;
|
||||
|
||||
interface UsageExportHeaderProps {
|
||||
dateValue: DateRangePickerValue;
|
||||
entityType: EntityType;
|
||||
|
|
@ -18,6 +30,7 @@ interface UsageExportHeaderProps {
|
|||
onFiltersChange?: (filters: string[]) => void;
|
||||
filterOptions?: Array<{ label: string; value: string }>;
|
||||
filterMode?: "multiple" | "single";
|
||||
filterSelectProps?: UsageFilterSelectProps;
|
||||
customTitle?: string;
|
||||
compactLayout?: boolean;
|
||||
teams?: Team[];
|
||||
|
|
@ -34,16 +47,16 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
onFiltersChange,
|
||||
filterOptions = [],
|
||||
filterMode = "multiple",
|
||||
filterSelectProps,
|
||||
customTitle,
|
||||
compactLayout = false,
|
||||
teams = [],
|
||||
}) => {
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const hasFilters = showFilters && (filterOptions.length > 0 || filterSelectProps?.showSearch === true);
|
||||
|
||||
// Determine grid layout based on what's visible
|
||||
const getGridCols = () => {
|
||||
const hasFilters = showFilters && filterOptions.length > 0;
|
||||
|
||||
if (hasFilters) return "grid-cols-[1fr_auto]";
|
||||
return "grid-cols-[auto]";
|
||||
};
|
||||
|
|
@ -57,10 +70,11 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
* vertical drift when the right column has a label above the input.
|
||||
*/}
|
||||
<div className={`grid ${getGridCols()} items-end gap-4`}>
|
||||
{showFilters && filterOptions.length > 0 && (
|
||||
{hasFilters && (
|
||||
<div>
|
||||
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
|
||||
<Select
|
||||
{...filterSelectProps}
|
||||
mode={filterMode === "single" ? undefined : "multiple"}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={filterPlaceholder}
|
||||
|
|
|
|||
|
|
@ -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