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}