From cbac167a4a0f97cc42e0bb14faa6b4b7f4107bbe Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 14:51:40 -0700 Subject: [PATCH 1/4] fix(ui): keep the usage filter visible when the caller's scope is empty The tag usage filter was removed from the DOM whenever the tag list came back empty, so an internal user whose traffic all runs through team keys saw a blank Tag Usage panel with no filter and no explanation. Their scope is legitimately empty, but the page gave them no way to tell that apart from a broken or gated feature. Render the filter whenever the entity list has resolved, disabling it and swapping in an entity-specific empty message when there are no options. A still-unresolved list keeps hiding the control as before. --- .../EntityUsage/EntityUsage.test.tsx | 31 +++++++++++++++- .../components/EntityUsage/EntityUsage.tsx | 2 +- .../UsageExportHeader.test.tsx | 37 +++++++++++++++++++ .../EntityUsageExport/UsageExportHeader.tsx | 10 ++++- 4 files changed, 76 insertions(+), 4 deletions(-) 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/components/EntityUsageExport/UsageExportHeader.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx index 27985c3db28..6c824459c08 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx @@ -84,4 +84,41 @@ 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 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..0d040e7c30b 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -54,9 +54,11 @@ 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`; const filterList = ( @@ -74,6 +76,7 @@ const UsageExportHeader: React.FC = ({ const builtInFilter = ( onFiltersChange?.(next)} @@ -88,7 +91,10 @@ const UsageExportHeader: React.FC = ({ )) } - + {selectedFilters.length > 0 && } {filterList} From 13b5c80c90cc01ab3705576b57939ff230bf2d7a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 15:38:14 -0700 Subject: [PATCH 2/4] fix(ui): withhold the tag list until it resolves The usage page seeded its tag list as an empty array, so between first paint and the tag request landing the filter had a resolved-looking empty list and showed "No tags with usage in this range" for a scope nobody had measured yet. Seed it as null instead, which the filter already treats as unresolved and hides, so the empty state only appears once the list has actually come back. --- .../components/UsagePageView.test.tsx | 24 +++++++++++++++++++ .../_components/components/UsagePageView.tsx | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) 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..f2dc56af9c5 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,30 @@ 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 show tag usage selector option for internal users", async () => { mockUseAuthorized.mockReturnValue({ isLoading: false, 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..0d1e557a833 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,7 +96,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { to: initialToDate, }); - const [allTags, setAllTags] = useState([]); + const [allTags, setAllTags] = useState(null); const { data: customers = [] } = useCustomers(); const { data: agentsResponse } = useAgents(); const { data: currentUser } = useCurrentUser(); From beed32eb6050d306b55545d632340dececa347ec Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 15:43:39 -0700 Subject: [PATCH 3/4] fix(ui): stamp the tag list with the range it answers Changing the date range left the previous range's tags in state until the new request landed, so the filter either offered tags that range no longer has or, when the old range was empty, stated "no tags" about a range nobody had measured yet. Stamp the fetched list with its range key and select it during render, the same way the request tiles above already guard against a superseded range. Clearing in the effect would be a render too late. --- .../components/UsagePageView.test.tsx | 36 +++++++++++++++++++ .../_components/components/UsagePageView.tsx | 17 ++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) 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 f2dc56af9c5..7d9bee735b4 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 @@ -610,6 +610,42 @@ describe("UsagePage", () => { 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, 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 0d1e557a833..19d7a752938 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,7 +96,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { to: initialToDate, }); - const [allTags, setAllTags] = useState(null); + const [fetchedTags, setFetchedTags] = useState | null>(null); const { data: customers = [] } = useCustomers(); const { data: agentsResponse } = useAgents(); const { data: currentUser } = useCurrentUser(); @@ -138,6 +138,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 +151,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 +167,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 From ceb2fa61c41b7c76eef8eadb3f267761c1c60783 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 15:49:27 -0700 Subject: [PATCH 4/4] fix(ui): keep an outlived filter selection clearable, and defer the customer list Two loading/empty transitions the disabled empty state got wrong. A selection made in a range that had options survives a move to a range that has none, and it still scopes the data below, so disabling the combobox outright took away the only control that could clear it. Disable it only when there is nothing selected to clear. The customer list defaulted to an empty array while its query was in flight, so the filter announced a range with no customers before anything had been read. Leave it undefined until the query resolves, as the tag list now does. --- .../components/UsagePageView.test.tsx | 13 +++++++++++ .../_components/components/UsagePageView.tsx | 4 +++- .../UsageExportHeader.test.tsx | 22 +++++++++++++++++++ .../EntityUsageExport/UsageExportHeader.tsx | 5 ++++- 4 files changed, 42 insertions(+), 2 deletions(-) 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 7d9bee735b4..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 @@ -754,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 19d7a752938..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 @@ -97,7 +97,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }); const [fetchedTags, setFetchedTags] = useState | null>(null); - const { data: customers = [] } = useCustomers(); + // 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 || ""); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx index 6c824459c08..52fc7605d90 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx @@ -104,6 +104,28 @@ describe("UsageExportHeader", () => { 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 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 = ( @@ -76,7 +79,7 @@ const UsageExportHeader: React.FC = ({ const builtInFilter = ( onFiltersChange?.(next)}