mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
fix(ui): show selected user's Max Budget on Usage page, not admin's
The Usage page Max Budget tile fed `userMaxBudget` from `currentUser` (the logged-in admin, via a self-only `/v2/user/info`) instead of the selected user's budget. This made every filtered user display the admin's cap, showed a number for users with no limit, and never rendered the budget period. Fetch the selected user's info via a new `useUserInfo(userId)` hook (mirroring `useCurrentUser`) and feed its `max_budget` + `budget_duration` to `ViewUserSpend`. The global view still falls back to `currentUser`. `ViewUserSpend` gains an optional `budgetDuration` prop and renders "over <period>" via the canonical `getBudgetDurationLabel`. Adds tests for the hook and the period-aware tile rendering.
This commit is contained in:
parent
b04d530ecf
commit
3c7d3e0c57
5 changed files with 196 additions and 3 deletions
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useUserInfo } from "./useUserInfo";
|
||||
import { userGetInfoV2 } from "@/components/networking";
|
||||
import type { UserInfoV2Response } from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userGetInfoV2: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../common/queryKeysFactory", () => ({
|
||||
createQueryKeys: vi.fn((resource: string) => ({
|
||||
all: [resource],
|
||||
lists: () => [resource, "list"],
|
||||
list: (params?: any) => [resource, "list", { params }],
|
||||
details: () => [resource, "detail"],
|
||||
detail: (uid: string) => [resource, "detail", uid],
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
const mockSelectedUser: UserInfoV2Response = {
|
||||
user_id: "selected-user-id",
|
||||
user_email: "selected@example.com",
|
||||
user_alias: "Selected User",
|
||||
user_role: "internal_user",
|
||||
spend: 42.0,
|
||||
max_budget: 600.0,
|
||||
models: [],
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: null,
|
||||
metadata: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
sso_user_id: null,
|
||||
teams: [],
|
||||
};
|
||||
|
||||
describe("useUserInfo", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "test-access-token",
|
||||
userId: "admin-user-id",
|
||||
userRole: "Admin",
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("fetches the specified user's info by user_id (not the caller's)", async () => {
|
||||
(userGetInfoV2 as any).mockResolvedValue(mockSelectedUser);
|
||||
|
||||
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual(mockSelectedUser);
|
||||
// The whole point of the fix: the target user_id is passed through.
|
||||
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "selected-user-id");
|
||||
expect(userGetInfoV2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not execute when userId is null (global view has no single user)", async () => {
|
||||
const { result } = renderHook(() => useUserInfo(null), { wrapper });
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not execute when accessToken is missing", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ accessToken: null, userId: "admin-user-id", userRole: "Admin" });
|
||||
|
||||
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
|
||||
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the error when the lookup fails", async () => {
|
||||
const err = new Error("forbidden");
|
||||
(userGetInfoV2 as any).mockRejectedValue(err);
|
||||
|
||||
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(result.current.error).toEqual(err);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { UserInfoV2Response, userGetInfoV2 } from "@/components/networking";
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
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.
|
||||
*/
|
||||
export const useUserInfo = (userId: string | null): UseQueryResult<UserInfoV2Response> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<UserInfoV2Response>({
|
||||
queryKey: userKeys.detail(userId ?? ""),
|
||||
queryFn: async () => {
|
||||
return await userGetInfoV2(accessToken!, userId!);
|
||||
},
|
||||
enabled: Boolean(accessToken && userId),
|
||||
});
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
|
||||
|
|
@ -140,6 +141,16 @@ 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 effectiveMaxBudget =
|
||||
effectiveUserId !== null ? selectedUserInfo?.max_budget ?? null : currentUser?.max_budget ?? null;
|
||||
const effectiveBudgetDuration = effectiveUserId !== null ? selectedUserInfo?.budget_duration ?? null : null;
|
||||
|
||||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
||||
|
|
@ -578,7 +589,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ViewUserSpend
|
||||
userSpend={totalSpend}
|
||||
selectedTeam={null}
|
||||
userMaxBudget={currentUser?.max_budget || null}
|
||||
userMaxBudget={effectiveMaxBudget}
|
||||
budgetDuration={effectiveBudgetDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
36
ui/litellm-dashboard/src/components/view_user_spend.test.tsx
Normal file
36
ui/litellm-dashboard/src/components/view_user_spend.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
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: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({ accessToken: "tok", userRole: "Admin", userId: "admin-id" }),
|
||||
}));
|
||||
|
||||
describe("ViewUserSpend — Max Budget tile", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows "No limit" for an unlimited (null) budget and no period', () => {
|
||||
render(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetDuration="30d" />);
|
||||
expect(screen.getByText("No limit")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a finite cap with no period when budgetDuration is absent", () => {
|
||||
render(<ViewUserSpend userSpend={10} userMaxBudget={300} selectedTeam={null} />);
|
||||
expect(screen.getByText(/\$300\.0000 limit/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { modelAvailableCall } from "./networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
// Define the props type
|
||||
|
|
@ -9,8 +10,16 @@ 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;
|
||||
}
|
||||
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget, selectedTeam }) => {
|
||||
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({
|
||||
userSpend,
|
||||
userMaxBudget,
|
||||
selectedTeam,
|
||||
budgetDuration = null,
|
||||
}) => {
|
||||
const { accessToken, userRole, userId: userID } = useAuthorized();
|
||||
let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0);
|
||||
const [maxBudget, setMaxBudget] = useState(
|
||||
|
|
@ -109,6 +118,11 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
|
|||
|
||||
const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "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 roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null;
|
||||
|
||||
return (
|
||||
|
|
@ -120,7 +134,12 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Max Budget</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{displayMaxBudget}</p>
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
{displayMaxBudget}
|
||||
{budgetPeriodSuffix && (
|
||||
<span className="text-sm font-normal text-muted-foreground">{budgetPeriodSuffix}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="ml-auto">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue