diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 666172947d1..5bb48a78437 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -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; + }) => (
Usage Export Header {filterLabel} + {`show-filters:${showFilters === true}`} {filterSlot}
), @@ -739,6 +748,26 @@ describe("EntityUsage", () => { }); }); + it("should still request the filter when the caller's tag scope is empty", async () => { + render(); + + 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(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + + expect(screen.getByText("show-filters:false")).toBeInTheDocument(); + }); + it("should display Agent Activity tab for team entity type", async () => { render(); 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 9501fa7a9a1..ef3943e5b71 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 @@ -661,7 +661,7 @@ const EntityUsage: React.FC = ({ 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)} 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 118371aa9ac..26d595f4d74 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 @@ -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) => void = () => {}; + mockTagListCall.mockReturnValue( + new Promise((resolve) => { + resolveTagList = resolve; + }) as ReturnType, + ); + + renderWithProviders(); + + 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(); + + 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) => void = () => {}; + mockTagListCall.mockReturnValue( + new Promise((resolve) => { + resolveNewRange = resolve; + }) as ReturnType, + ); + + 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(); + + 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 }, 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 c742b3af7e9..cbdfc8f39e6 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 @@ -96,8 +96,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { to: initialToDate, }); - const [allTags, setAllTags] = useState([]); - const { data: customers = [] } = useCustomers(); + const [fetchedTags, setFetchedTags] = useState | 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 = ({ 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 = ({ 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 = ({ 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 diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx index 27985c3db28..52fc7605d90 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const input = screen.getByPlaceholderText("Select tag to filter..."); + expect(input).toBeEnabled(); + expect(screen.queryByPlaceholderText("No tags with usage in this range")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index e694f905d8c..f5bb56265ed 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -54,9 +54,14 @@ const UsageExportHeader: React.FC = ({ 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 = ( @@ -74,6 +79,7 @@ const UsageExportHeader: React.FC = ({ const builtInFilter = ( onFiltersChange?.(next)} @@ -88,7 +94,10 @@ const UsageExportHeader: React.FC = ({ )) } - + {selectedFilters.length > 0 && } {filterList}