mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
fix(ui): don't show loading/error budget as "No limit"; add wiring test
Addresses review findings on the Max Budget tile fix: - P1: while the selected user's info is loading or errored, the tile fed `null` and falsely rendered "No limit". Thread the query's loading/error state through a `budgetLoading` prop so the tile shows a neutral placeholder until the budget is resolved. - Add a UsagePageView wiring regression test proving the tile receives the selected user's budget/duration (not the admin's) and is marked loading while unresolved, plus a ViewUserSpend loading-state test. - Remove explanatory source comments per the repo comment policy.
This commit is contained in:
parent
3c7d3e0c57
commit
c19001ce2e
5 changed files with 93 additions and 31 deletions
|
|
@ -5,14 +5,7 @@ import { createQueryKeys } from "../common/queryKeysFactory";
|
|||
|
||||
const userKeys = createQueryKeys("users");
|
||||
|
||||
/**
|
||||
* Fetch a SPECIFIC user's info from /v2/user/info?user_id=<userId>.
|
||||
*
|
||||
* Companion to useCurrentUser (which self-looks-up the caller): pass the user
|
||||
* you actually want to display. Disabled when userId is null so the caller can
|
||||
* fall back to a global/unfiltered view without firing a request. Admins may
|
||||
* query any user; the backend authorizes the lookup.
|
||||
*/
|
||||
/** Fetch a specific user's info from `/v2/user/info?user_id=`; disabled when userId is null. */
|
||||
export const useUserInfo = (userId: string | null): UseQueryResult<UserInfoV2Response> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<UserInfoV2Response>({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -40,7 +41,20 @@ vi.mock("@/components/activity_metrics", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/view_user_spend", () => ({
|
||||
default: () => <div>View User Spend</div>,
|
||||
default: ({ userMaxBudget, budgetDuration, budgetLoading }: any) => (
|
||||
<div
|
||||
data-testid="view-user-spend"
|
||||
data-max-budget={userMaxBudget === null || userMaxBudget === undefined ? "null" : String(userMaxBudget)}
|
||||
data-budget-duration={budgetDuration ?? "null"}
|
||||
data-budget-loading={budgetLoading ? "true" : "false"}
|
||||
>
|
||||
View User Spend
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUserInfo", () => ({
|
||||
useUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
||||
|
|
@ -166,6 +180,7 @@ describe("UsagePage", () => {
|
|||
const mockUseAuthorized = vi.mocked(useAuthorized);
|
||||
const mockUseCurrentUser = vi.mocked(useCurrentUser);
|
||||
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
|
||||
const mockUseUserInfo = vi.mocked(useUserInfo);
|
||||
|
||||
const mockSpendData = {
|
||||
results: [
|
||||
|
|
@ -411,6 +426,11 @@ describe("UsagePage", () => {
|
|||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockUseUserInfo.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render and fetch usage data on mount", async () => {
|
||||
|
|
@ -1350,19 +1370,57 @@ describe("UsagePage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("tab navigation in global view", () => {
|
||||
it("should render all expected tabs", async () => {
|
||||
describe("selected-user budget wiring", () => {
|
||||
const userSelectCombobox = (): HTMLElement => {
|
||||
let node: HTMLElement | null = screen.getByText("Filter by user");
|
||||
while (node && !node.querySelector('[role="combobox"]')) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
return node!.querySelector('[role="combobox"]') as HTMLElement;
|
||||
};
|
||||
|
||||
it("feeds the tile the selected user's budget and duration, not the admin's", async () => {
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
data: { user_id: "user-123", max_budget: 3000 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockUseUserInfo.mockReturnValue({
|
||||
data: { user_id: "user-001", max_budget: 600, budget_duration: "30d" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled());
|
||||
|
||||
await userEvent.setup().click(userSelectCombobox());
|
||||
await userEvent.setup().click(screen.getByText("Alice (user-001)"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-max-budget", "600");
|
||||
});
|
||||
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-duration", "30d");
|
||||
expect(mockUseUserInfo).toHaveBeenCalledWith("user-001");
|
||||
});
|
||||
|
||||
expect(screen.getByText("Cost")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
|
||||
it("marks the tile loading (not unlimited) while the selected user's info is unresolved", async () => {
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
data: { user_id: "user-123", max_budget: 3000 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockUseUserInfo.mockReturnValue({ data: undefined, isLoading: true, isError: false } as any);
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled());
|
||||
|
||||
await userEvent.setup().click(userSelectCombobox());
|
||||
await userEvent.setup().click(screen.getByText("Alice (user-001)"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-loading", "true");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,12 +141,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
// For non-admins or "my-usage" view, always pass their own user_id
|
||||
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
|
||||
|
||||
// Budget shown in the header tile must reflect the SELECTED user, not the
|
||||
// logged-in admin (currentUser). Fetch the effective user's own record so the
|
||||
// Max Budget tile and its reset period track "Filter by user". Falls back to
|
||||
// currentUser only while the per-user fetch is unresolved or when no user is
|
||||
// selected (global view has no single budget to show).
|
||||
const { data: selectedUserInfo } = useUserInfo(effectiveUserId);
|
||||
const {
|
||||
data: selectedUserInfo,
|
||||
isLoading: isSelectedUserLoading,
|
||||
isError: isSelectedUserError,
|
||||
} = useUserInfo(effectiveUserId);
|
||||
const isSelectedUserResolved = effectiveUserId === null || (!isSelectedUserLoading && !isSelectedUserError);
|
||||
const effectiveMaxBudget =
|
||||
effectiveUserId !== null ? selectedUserInfo?.max_budget ?? null : currentUser?.max_budget ?? null;
|
||||
const effectiveBudgetDuration = effectiveUserId !== null ? selectedUserInfo?.budget_duration ?? null : null;
|
||||
|
|
@ -591,6 +591,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
selectedTeam={null}
|
||||
userMaxBudget={effectiveMaxBudget}
|
||||
budgetDuration={effectiveBudgetDuration}
|
||||
budgetLoading={!isSelectedUserResolved}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { render, screen } from "@testing-library/react";
|
|||
import React from "react";
|
||||
import ViewUserSpend from "./view_user_spend";
|
||||
|
||||
// ViewUserSpend fetches available models in an effect; stub the network call.
|
||||
vi.mock("./networking", () => ({
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
|
@ -18,7 +17,6 @@ describe("ViewUserSpend — Max Budget tile", () => {
|
|||
it("shows a finite cap with its reset period when budgetDuration is set", () => {
|
||||
render(<ViewUserSpend userSpend={10} userMaxBudget={600} selectedTeam={null} budgetDuration="30d" />);
|
||||
expect(screen.getByText(/\$600\.0000 limit/)).toBeInTheDocument();
|
||||
// 30d maps to "monthly" via getBudgetDurationLabel
|
||||
expect(screen.getByText(/over monthly/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -33,4 +31,10 @@ describe("ViewUserSpend — Max Budget tile", () => {
|
|||
expect(screen.getByText(/\$300\.0000 limit/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "No limit" while the budget is loading', () => {
|
||||
render(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetLoading={true} />);
|
||||
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ interface ViewUserSpendProps {
|
|||
userSpend: number | null;
|
||||
userMaxBudget: number | null;
|
||||
selectedTeam: any | null;
|
||||
// Optional reset period paired with userMaxBudget (e.g. "24h", "30d"), rendered
|
||||
// as a human-readable window next to the cap. Omitted/null → no period shown.
|
||||
budgetDuration?: string | null;
|
||||
budgetLoading?: boolean;
|
||||
}
|
||||
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({
|
||||
userSpend,
|
||||
userMaxBudget,
|
||||
selectedTeam,
|
||||
budgetDuration = null,
|
||||
budgetLoading = false,
|
||||
}) => {
|
||||
const { accessToken, userRole, userId: userID } = useAuthorized();
|
||||
let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0);
|
||||
|
|
@ -116,12 +116,18 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({
|
|||
modelsToDisplay = userModels;
|
||||
}
|
||||
|
||||
const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "No limit";
|
||||
let displayMaxBudget: string;
|
||||
if (budgetLoading) {
|
||||
displayMaxBudget = "—";
|
||||
} else if (maxBudget !== null) {
|
||||
displayMaxBudget = `$${formatNumberWithCommas(Number(maxBudget), 4)} limit`;
|
||||
} else {
|
||||
displayMaxBudget = "No limit";
|
||||
}
|
||||
|
||||
// Show the reset window (e.g. "over monthly") only for a finite cap that has a
|
||||
// paired duration; an unlimited budget or a cap with no duration shows nothing.
|
||||
const durationLabel = maxBudget !== null && budgetDuration ? getBudgetDurationLabel(budgetDuration) : null;
|
||||
const budgetPeriodSuffix = durationLabel && durationLabel !== "Not set" ? ` over ${durationLabel}` : "";
|
||||
const budgetPeriodSuffix =
|
||||
!budgetLoading && durationLabel && durationLabel !== "Not set" ? ` over ${durationLabel}` : "";
|
||||
|
||||
const roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue