Merge pull request #38581 from BerriAI/litellm_/internal-users-tags-usage-e58133

fix(ui): keep the usage filter visible when the caller's scope is empty
This commit is contained in:
yuneng-jiang 2026-08-28 10:13:40 -07:00 committed by GitHub
commit 65e2a1fcbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 189 additions and 10 deletions

View file

@ -65,10 +65,19 @@ vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({
}));
vi.mock("@/components/EntityUsageExport", () => ({
UsageExportHeader: ({ filterLabel, filterSlot }: { filterLabel?: string; filterSlot?: ReactNode }) => (
UsageExportHeader: ({
filterLabel,
filterSlot,
showFilters,
}: {
filterLabel?: string;
filterSlot?: ReactNode;
showFilters?: boolean;
}) => (
<div>
<span>Usage Export Header</span>
<span>{filterLabel}</span>
<span>{`show-filters:${showFilters === true}`}</span>
{filterSlot}
</div>
),
@ -739,6 +748,26 @@ describe("EntityUsage", () => {
});
});
it("should still request the filter when the caller's tag scope is empty", async () => {
render(<EntityUsage {...defaultProps} entityList={[]} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("show-filters:true")).toBeInTheDocument();
});
it("should not request the filter while the entity list is still unresolved", async () => {
render(<EntityUsage {...defaultProps} entityList={null} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("show-filters:false")).toBeInTheDocument();
});
it("should display Agent Activity tab for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);

View file

@ -661,7 +661,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={filterSlot === undefined && entityList !== null && entityList.length > 0}
showFilters={filterSlot === undefined && entityList !== null}
filterSlot={filterSlot}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}

View file

@ -586,6 +586,66 @@ describe("UsagePage", () => {
});
});
it("should withhold the tag list until it resolves so no empty state is shown while loading", async () => {
let resolveTagList: (tags: Record<string, unknown>) => void = () => {};
mockTagListCall.mockReturnValue(
new Promise((resolve) => {
resolveTagList = resolve;
}) as ReturnType<typeof networking.tagListCall>,
);
renderWithProviders(<UsagePage {...defaultProps} />);
act(() => {
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "tag" } });
});
const entityUsage = await screen.findByTestId("entity-usage");
expect(entityUsage).toHaveAttribute("data-entity-list", "null");
await act(async () => {
resolveTagList({});
});
expect(screen.getByTestId("entity-usage")).toHaveAttribute("data-entity-list", "[]");
});
it("should drop the previous range's tags as soon as the range changes", async () => {
mockTagListCall.mockResolvedValue({ "old-range-tag": { name: "old-range-tag" } } as never);
renderWithProviders(<UsagePage {...defaultProps} />);
act(() => {
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "tag" } });
});
await waitFor(() => {
expect(screen.getByTestId("entity-usage")).toHaveAttribute(
"data-entity-list",
JSON.stringify([{ label: "old-range-tag", value: "old-range-tag" }]),
);
});
let resolveNewRange: (tags: Record<string, unknown>) => void = () => {};
mockTagListCall.mockReturnValue(
new Promise((resolve) => {
resolveNewRange = resolve;
}) as ReturnType<typeof networking.tagListCall>,
);
act(() => {
fireEvent.click(screen.getByTestId("pick-a-different-range"));
});
expect(screen.getByTestId("entity-usage")).toHaveAttribute("data-entity-list", "null");
await act(async () => {
resolveNewRange({});
});
expect(screen.getByTestId("entity-usage")).toHaveAttribute("data-entity-list", "[]");
});
it("should show tag usage selector option for internal users", async () => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
@ -694,6 +754,19 @@ describe("UsagePage", () => {
});
});
it("should withhold the customer list while it is still loading", async () => {
mockUseCustomers.mockReturnValue({ data: undefined, isLoading: true, error: null } as any);
renderWithProviders(<UsagePage {...defaultProps} />);
act(() => {
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "customer" } });
});
const entityUsage = await screen.findByTestId("entity-usage");
expect(entityUsage).toHaveAttribute("data-entity-list", "null");
});
it("should show agent usage view for admins", async () => {
mockUseAgents.mockReturnValue({
data: { agents: mockAgents },

View file

@ -96,8 +96,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
to: initialToDate,
});
const [allTags, setAllTags] = useState<EntityList[]>([]);
const { data: customers = [] } = useCustomers();
const [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
// No [] default: an unresolved query must stay undefined so the customer
// filter reads as loading rather than as a range with no customers.
const { data: customers } = useCustomers();
const { data: agentsResponse } = useAgents();
const { data: currentUser } = useCurrentUser();
const isAdmin = all_admin_roles.includes(userRole || "");
@ -138,6 +140,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
// Stamped and selected during render like the request tiles below: the tag
// filter reads "no tags" from an empty list, so a list left over from the
// previous range would state that about a range nobody has measured yet.
const currentTagRangeKey = fetchedRangeKey(startTime, endTime);
const allTags = selectForRange(fetchedTags, currentTagRangeKey);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
@ -145,12 +153,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
try {
const tags = await tagListCall(accessToken, startTime, endTime);
if (cancelled) return;
setAllTags(
Object.values(tags).map((tag: Tag) => ({
setFetchedTags({
rangeKey: currentTagRangeKey,
value: Object.values(tags).map((tag: Tag) => ({
label: tag.name,
value: tag.name,
})),
);
});
} catch (e) {
if (!cancelled) {
console.error("Failed to fetch tag list", e);
@ -160,7 +169,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
return () => {
cancelled = true;
};
}, [accessToken, startTime, endTime]);
}, [accessToken, startTime, endTime, currentTagRangeKey]);
// Everything the request tiles read is stamped with the range it answers and
// selected during render, rather than cleared in an effect. An effect runs

View file

@ -84,4 +84,63 @@ describe("UsageExportHeader", () => {
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
it("should keep the filter visible and disabled with an explanation when the caller has no options", () => {
renderWithProviders(
<UsageExportHeader
{...defaultProps}
entityType="tag"
showFilters
filterLabel="Filter by tag"
filterPlaceholder="Select tag to filter..."
filterOptions={[]}
onFiltersChange={vi.fn()}
/>,
);
expect(screen.getByText("Filter by tag")).toBeInTheDocument();
const input = screen.getByPlaceholderText("No tags with usage in this range");
expect(input).toBeDisabled();
expect(screen.queryByPlaceholderText("Select tag to filter...")).not.toBeInTheDocument();
});
it("should stay usable when a carried-over selection outlives its options", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
renderWithProviders(
<UsageExportHeader
{...defaultProps}
entityType="tag"
showFilters
filterLabel="Filter by tag"
filterPlaceholder="Select tag to filter..."
filterOptions={[]}
selectedFilters={["prod"]}
onFiltersChange={onFiltersChange}
/>,
);
expect(screen.getByPlaceholderText("No tags with usage in this range")).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Clear Filter by tag" }));
expect(onFiltersChange).toHaveBeenCalledWith([]);
});
it("should leave the filter enabled with its normal placeholder when options exist", () => {
renderWithProviders(
<UsageExportHeader
{...defaultProps}
entityType="tag"
showFilters
filterLabel="Filter by tag"
filterPlaceholder="Select tag to filter..."
filterOptions={[{ label: "prod", value: "prod" }]}
onFiltersChange={vi.fn()}
/>,
);
const input = screen.getByPlaceholderText("Select tag to filter...");
expect(input).toBeEnabled();
expect(screen.queryByPlaceholderText("No tags with usage in this range")).not.toBeInTheDocument();
});
});

View file

@ -54,9 +54,14 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const hasFilters = filterSlot != null || (showFilters && filterOptions.length > 0);
const hasFilters = filterSlot != null || showFilters;
const optionValues = filterOptions.map((option) => option.value);
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
const hasNoOptions = filterOptions.length === 0;
const emptyPlaceholder = `No ${entityType}s with usage in this range`;
// A selection carried over from a range that did have options still scopes
// the data below, so the control has to stay usable long enough to clear it.
const isFilterDisabled = hasNoOptions && selectedFilters.length === 0;
const filterList = (
<ComboboxContent anchor={anchor}>
@ -74,6 +79,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
const builtInFilter = (
<Combobox
multiple
disabled={isFilterDisabled}
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
@ -88,7 +94,10 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder={filterPlaceholder} aria-label={filterPlaceholder} />
<ComboboxChipsInput
placeholder={hasNoOptions ? emptyPlaceholder : filterPlaceholder}
aria-label={hasNoOptions ? emptyPlaceholder : filterPlaceholder}
/>
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
</ComboboxChips>
{filterList}