mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
commit
65e2a1fcbb
6 changed files with 189 additions and 10 deletions
|
|
@ -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" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue