diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c0285407855..57b6453ac43 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1911,6 +1911,10 @@ async def get_user_daily_activity( default=None, description="Filter by specific API key", ), + user_id: Optional[str] = fastapi.Query( + default=None, + description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.", + ), page: int = fastapi.Query( default=1, description="Page number for pagination", ge=1 ), @@ -1955,9 +1959,21 @@ async def get_user_daily_activity( ) try: - entity_id: Optional[str] = None - if not _user_has_admin_view(user_api_key_dict): - entity_id = user_api_key_dict.user_id + is_admin = _user_has_admin_view(user_api_key_dict) + + if is_admin: + entity_id = user_id # None means global view, otherwise filter by user + else: + if user_id is None: + user_id = user_api_key_dict.user_id + if user_id != user_api_key_dict.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Non-admin users can only view their own spend data." + }, + ) + entity_id = user_id return await get_daily_activity( prisma_client=prisma_client, @@ -1974,6 +1990,8 @@ async def get_user_daily_activity( timezone_offset_minutes=timezone, ) + except HTTPException: + raise except Exception as e: verbose_proxy_logger.exception( "/spend/daily/analytics: Exception occured - {}".format(str(e)) @@ -2008,6 +2026,10 @@ async def get_user_daily_activity_aggregated( default=None, description="Filter by specific API key", ), + user_id: Optional[str] = fastapi.Query( + default=None, + description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.", + ), timezone: Optional[int] = fastapi.Query( default=None, description="Timezone offset in minutes from UTC (e.g., 480 for PST). " @@ -2034,9 +2056,21 @@ async def get_user_daily_activity_aggregated( ) try: - entity_id: Optional[str] = None - if not _user_has_admin_view(user_api_key_dict): - entity_id = user_api_key_dict.user_id + is_admin = _user_has_admin_view(user_api_key_dict) + + if is_admin: + entity_id = user_id # None means global view, otherwise filter by user + else: + if user_id is None: + user_id = user_api_key_dict.user_id + if user_id != user_api_key_dict.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Non-admin users can only view their own spend data." + }, + ) + entity_id = user_id return await get_daily_activity_aggregated( prisma_client=prisma_client, @@ -2051,6 +2085,8 @@ async def get_user_daily_activity_aggregated( timezone_offset_minutes=timezone, ) + except HTTPException: + raise except Exception as e: verbose_proxy_logger.exception( "/user/daily/activity/aggregated: Exception occured - {}".format(str(e)) diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 919af96f760..9a417f3566c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1167,4 +1167,136 @@ def test_generate_request_base_validator(): # Test with None req = GenerateRequestBase(max_budget=None) - assert req.max_budget is None \ No newline at end of file + assert req.max_budget is None + + +@pytest.mark.asyncio +async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeypatch): + """ + Test that non-admin users cannot view another user's daily activity data. + The endpoint should raise 403 when user_id does not match the caller's own user_id. + Also verifies that omitting user_id defaults to the caller's own user_id. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + get_user_daily_activity, + ) + + # Mock the prisma client so the DB-not-connected check passes + mock_prisma_client = MagicMock() + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + + # Non-admin caller + non_admin_key_dict = UserAPIKeyAuth( + user_id="regular-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + # Case 1: Non-admin tries to view a different user's data — should get 403 + with pytest.raises(HTTPException) as exc_info: + await get_user_daily_activity( + start_date="2025-01-01", + end_date="2025-01-31", + model=None, + api_key=None, + user_id="other-user-456", + page=1, + page_size=50, + timezone=None, + user_api_key_dict=non_admin_key_dict, + ) + + assert exc_info.value.status_code == 403 + assert "Non-admin users can only view their own spend data" in str( + exc_info.value.detail + ) + + # Case 2: Non-admin omits user_id — should default to their own user_id + mock_response = MagicMock() + with patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_get_daily: + result = await get_user_daily_activity( + start_date="2025-01-01", + end_date="2025-01-31", + model=None, + api_key=None, + user_id=None, + page=1, + page_size=50, + timezone=None, + user_api_key_dict=non_admin_key_dict, + ) + + # Verify it called get_daily_activity with the caller's own user_id + mock_get_daily.assert_called_once() + call_kwargs = mock_get_daily.call_args + assert call_kwargs.kwargs["entity_id"] == "regular-user-123" + + +@pytest.mark.asyncio +async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch): + """ + Test that admin users can call the aggregated endpoint without a user_id + to get a global view. Also verifies that the correct arguments are forwarded + to the underlying get_daily_activity_aggregated helper. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + get_user_daily_activity_aggregated, + ) + + # Mock the prisma client + mock_prisma_client = MagicMock() + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + + # Mock the downstream helper so we don't need a real DB + mock_response = MagicMock() + mock_get_daily_agg = AsyncMock(return_value=mock_response) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", + mock_get_daily_agg, + ) + + # Admin caller + admin_key_dict = UserAPIKeyAuth( + user_id="admin-user-001", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + # Admin calls without user_id → global view (entity_id=None) + result = await get_user_daily_activity_aggregated( + start_date="2025-02-01", + end_date="2025-02-28", + model="gpt-4", + api_key=None, + user_id=None, + timezone=480, + user_api_key_dict=admin_key_dict, + ) + + assert result is mock_response + + # Verify the helper was called with the right parameters + mock_get_daily_agg.assert_called_once_with( + prisma_client=mock_prisma_client, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, # global view: no user_id filter + entity_metadata_field=None, + start_date="2025-02-01", + end_date="2025-02-28", + model="gpt-4", + api_key=None, + timezone_offset_minutes=480, + ) \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts new file mode 100644 index 00000000000..b0a96eff0e7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -0,0 +1,339 @@ +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 { useInfiniteUsers } from "./useUsers"; +import { userListCall } from "@/components/networking"; +import type { UserListResponse } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + userListCall: 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 DEFAULT_AUTH = { + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, +}; + +const buildUserListResponse = ( + page: number, + totalPages: number, + userCount = 2, +): UserListResponse => ({ + page, + page_size: 50, + total: totalPages * userCount, + total_pages: totalPages, + users: Array.from({ length: userCount }, (_, i) => ({ + user_id: `user-${page}-${i}`, + user_email: `user-${page}-${i}@example.com`, + user_alias: null, + user_role: "Internal User", + spend: 0, + max_budget: null, + key_count: 0, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + sso_user_id: null, + budget_duration: null, + })), +}); + +describe("useInfiniteUsers", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue(DEFAULT_AUTH); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should return paginated user data when query is successful", async () => { + const mockResponse = buildUserListResponse(1, 2); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0]).toEqual(mockResponse); + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); + + it("should use the default page size of 50", async () => { + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); + + it("should use a custom page size when provided", async () => { + const customPageSize = 25; + const mockResponse = buildUserListResponse(1, 1, 5); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(customPageSize), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + customPageSize, + null, + ); + }); + + it("should pass searchEmail to userListCall when provided", async () => { + const searchEmail = "search@example.com"; + const mockResponse = buildUserListResponse(1, 1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(50, searchEmail), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + searchEmail, + ); + }); + + it("should pass null for searchEmail when not provided", async () => { + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(50, undefined), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); + + it("should fetch the next page when more pages are available", async () => { + const page1 = buildUserListResponse(1, 3); + const page2 = buildUserListResponse(2, 3); + let callCount = 0; + (userListCall as any).mockImplementation(async () => { + callCount++; + return callCount === 1 ? page1 : page2; + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(true); + + result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.isFetchingNextPage).toBe(false); + expect(result.current.data?.pages).toHaveLength(2); + }); + + expect(result.current.data?.pages[1]).toEqual(page2); + expect(userListCall).toHaveBeenCalledTimes(2); + expect(userListCall).toHaveBeenLastCalledWith( + "test-access-token", + null, + 2, + 50, + null, + ); + }); + + it("should not have a next page when on the last page", async () => { + const lastPage = buildUserListResponse(2, 2); + (userListCall as any).mockResolvedValue(lastPage); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(false); + }); + + it("should not execute query when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ + ...DEFAULT_AUTH, + accessToken: null, + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userRole is not an admin role", async () => { + mockUseAuthorized.mockReturnValue({ + ...DEFAULT_AUTH, + userRole: "Internal User", + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when both accessToken and userRole are invalid", async () => { + mockUseAuthorized.mockReturnValue({ + ...DEFAULT_AUTH, + accessToken: null, + userRole: "App User", + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("should execute query for each admin role", async () => { + const adminRoles = [ + "Admin", + "Admin Viewer", + "proxy_admin", + "proxy_admin_viewer", + "org_admin", + ]; + + for (const role of adminRoles) { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: role }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledTimes(1); + } + }); + + it("should handle error when userListCall fails", async () => { + const testError = new Error("Failed to fetch users"); + (userListCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + }); + + it("should pass empty string searchEmail as null", async () => { + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(50, ""), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts new file mode 100644 index 00000000000..cb30299f46f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -0,0 +1,41 @@ +import { userListCall, UserListResponse } from "@/components/networking"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const infiniteUsersKeys = createQueryKeys("infiniteUsers"); + +const DEFAULT_PAGE_SIZE = 50; + +export const useInfiniteUsers = ( + pageSize: number = DEFAULT_PAGE_SIZE, + searchEmail?: string, +) => { + const { accessToken, userRole } = useAuthorized(); + return useInfiniteQuery({ + queryKey: infiniteUsersKeys.list({ + filters: { + pageSize, + ...(searchEmail && { searchEmail }), + }, + }), + queryFn: async ({ pageParam }) => { + return await userListCall( + accessToken!, + null, // userIDs + pageParam as number, // page + pageSize, // page_size + searchEmail || null, // userEmail + ); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + if (lastPage.page < lastPage.total_pages) { + return lastPage.page + 1; + } + return undefined; + }, + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!), + }); +}; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index 1a344d3dd95..5f5ffe83baa 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -2,6 +2,7 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../../tests/test-utils"; @@ -116,6 +117,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ useCurrentUser: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: vi.fn(), +})); + vi.mock("antd", async (importOriginal) => { const React = await import("react"); const actual = await importOriginal(); @@ -223,6 +228,10 @@ vi.mock("@ant-design/icons", async () => { return React.createElement("span"); } + function LoadingOutlined(props: any) { + return React.createElement("span", { "data-testid": "loading-icon", ...props }); + } + return { GlobalOutlined: Icon, BankOutlined: Icon, @@ -235,6 +244,8 @@ vi.mock("@ant-design/icons", async () => { ClockCircleOutlined: Icon, CalendarOutlined: Icon, InfoCircleOutlined: Icon, + UserOutlined: Icon, + LoadingOutlined, }; }); @@ -320,11 +331,13 @@ vi.mock("@tremor/react", async () => { describe("UsagePage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); + const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); const mockTagListCall = vi.mocked(networking.tagListCall); const mockUseCustomers = vi.mocked(useCustomers); const mockUseAgents = vi.mocked(useAgents); const mockUseAuthorized = vi.mocked(useAuthorized); const mockUseCurrentUser = vi.mocked(useCurrentUser); + const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); const mockSpendData = { results: [ @@ -487,6 +500,8 @@ describe("UsagePage", () => { beforeEach(() => { mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, token: "mock-token", accessToken: "test-token", userId: "user-123", @@ -505,8 +520,30 @@ describe("UsagePage", () => { error: null, } as any); mockUserDailyActivityAggregatedCall.mockClear(); + mockUserDailyActivityCall.mockClear(); mockTagListCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); + mockUseInfiniteUsers.mockReturnValue({ + data: { + pages: [ + { + users: [ + { user_id: "user-001", user_alias: "Alice", user_email: "alice@example.com" }, + { user_id: "user-002", user_alias: null, user_email: "bob@example.com" }, + { user_id: "user-003", user_alias: null, user_email: null }, + ], + page: 1, + total_pages: 1, + total_count: 3, + }, + ], + pageParams: [1], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); mockTagListCall.mockResolvedValue({}); mockUseCustomers.mockReturnValue({ data: [], @@ -661,4 +698,434 @@ describe("UsagePage", () => { expect(entityUsageElements.length).toBeGreaterThan(0); }); }); + + describe("admin user selector", () => { + it("should render user selector for admin users in global view", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Admin should see the user selector select element with the placeholder attribute + const userSelects = screen.getAllByRole("combobox"); + const userSelect = userSelects.find( + (el) => el.getAttribute("placeholder") === "All Users (Global View)", + ); + expect(userSelect).toBeDefined(); + }); + + it("should format user options with alias when available", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // User with alias should show "alias (id)" + expect(screen.getByText("Alice (user-001)")).toBeInTheDocument(); + // User without alias but with email should show "email (id)" + expect(screen.getByText("bob@example.com (user-002)")).toBeInTheDocument(); + // User with neither alias nor email should show just the id + expect(screen.getByText("user-003")).toBeInTheDocument(); + }); + + it("should call useInfiniteUsers with debounced search", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // useInfiniteUsers should be called with default page size + expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined); + }); + + it("should deduplicate users across pages", async () => { + mockUseInfiniteUsers.mockReturnValue({ + data: { + pages: [ + { + users: [ + { user_id: "user-dup", user_alias: "DupUser", user_email: null }, + ], + page: 1, + total_pages: 2, + total_count: 2, + }, + { + users: [ + { user_id: "user-dup", user_alias: "DupUser", user_email: null }, + { user_id: "user-unique", user_alias: "UniqueUser", user_email: null }, + ], + page: 2, + total_pages: 2, + total_count: 2, + }, + ], + pageParams: [1, 2], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Duplicate user should appear only once + const dupElements = screen.getAllByText("DupUser (user-dup)"); + expect(dupElements).toHaveLength(1); + // Unique user should also appear + expect(screen.getByText("UniqueUser (user-unique)")).toBeInTheDocument(); + }); + + it("should pass selected userId to aggregated call", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Initially called with null (global view for admin) + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + null, + ); + }); + }); + + describe("non-admin user behavior", () => { + it("should not render user selector for non-admin users", async () => { + mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "test-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Internal User", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Non-admin should not see the user selector + const userSelects = screen.getAllByRole("combobox"); + const userSelect = userSelects.find( + (el) => el.getAttribute("placeholder") === "All Users (Global View)", + ); + expect(userSelect).toBeUndefined(); + }); + + it("should always pass own userId for non-admin users", async () => { + mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "test-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Internal User", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + "user-123", + ); + }); + }); + }); + + describe("aggregated endpoint fallback", () => { + it("should fall back to paginated calls when aggregated endpoint fails", async () => { + mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available")); + mockUserDailyActivityCall.mockResolvedValue({ + ...mockSpendData, + metadata: { + ...mockSpendData.metadata, + total_pages: 1, + page: 1, + }, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockUserDailyActivityCall).toHaveBeenCalled(); + }); + + // Should still render the data from the paginated fallback + expect(screen.getByText("1,500")).toBeInTheDocument(); + }); + + it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => { + mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available")); + + const page1Data = { + results: [mockSpendData.results[0]], + metadata: { + total_spend: 60, + total_api_requests: 700, + total_successful_requests: 680, + total_failed_requests: 20, + total_tokens: 35000, + total_pages: 2, + page: 1, + }, + }; + + const page2Data = { + results: [ + { + ...mockSpendData.results[0], + date: "2025-01-02", + }, + ], + metadata: { + total_spend: 65.75, + total_api_requests: 800, + total_successful_requests: 770, + total_failed_requests: 30, + total_tokens: 40000, + total_pages: 2, + page: 2, + }, + }; + + mockUserDailyActivityCall + .mockResolvedValueOnce(page1Data) + .mockResolvedValueOnce(page2Data); + + renderWithProviders(); + + await waitFor(() => { + // Both pages should have been fetched + expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2); + }); + + // Verify first page call + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + 1, + null, + ); + + // Verify second page call + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + 2, + null, + ); + }); + }); + + describe("MCP Server Activity tab", () => { + it("should render MCP Server Activity tab", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // The tab list should contain MCP Server Activity + expect(screen.getByText("MCP Server Activity")).toBeInTheDocument(); + }); + }); + + describe("User Agent Activity view", () => { + it("should render User Agent Activity component when view is selected", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "user-agent-activity" } }); + }); + + await waitFor(() => { + // "User Agent Activity" appears both in the select option and in the rendered component + const elements = screen.getAllByText("User Agent Activity"); + expect(elements.length).toBeGreaterThanOrEqual(2); + }); + }); + }); + + describe("Export Data button", () => { + it("should render Export Data button in global view for admin", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect(screen.getByText("Export Data")).toBeInTheDocument(); + }); + }); + + describe("model view toggle", () => { + it("should show Public Model Name view by default", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Default should be "groups" view showing "Top Public Model Names" + expect(screen.getByText("Top Public Model Names")).toBeInTheDocument(); + expect(screen.getByText("Public Model Name")).toBeInTheDocument(); + expect(screen.getByText("Litellm Model Name")).toBeInTheDocument(); + }); + + it("should switch to Litellm Model Name view on toggle click", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Click the "Litellm Model Name" toggle + const litellmToggle = screen.getByText("Litellm Model Name"); + act(() => { + fireEvent.click(litellmToggle); + }); + + // Title should change to "Top Litellm Models" + await waitFor(() => { + expect(screen.getByText("Top Litellm Models")).toBeInTheDocument(); + }); + }); + + it("should switch back to Public Model Name view", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Switch to individual first + const litellmToggle = screen.getByText("Litellm Model Name"); + act(() => { + fireEvent.click(litellmToggle); + }); + + await waitFor(() => { + expect(screen.getByText("Top Litellm Models")).toBeInTheDocument(); + }); + + // Switch back to groups + const publicToggle = screen.getByText("Public Model Name"); + act(() => { + fireEvent.click(publicToggle); + }); + + await waitFor(() => { + expect(screen.getByText("Top Public Model Names")).toBeInTheDocument(); + }); + }); + }); + + describe("customer usage banner", () => { + it("should show and be dismissible in customer view", async () => { + mockUseCustomers.mockReturnValue({ + data: mockCustomers, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "customer" } }); + }); + + await waitFor(() => { + expect(screen.getByText("Customer usage is a new feature.")).toBeInTheDocument(); + }); + + // Click the close button + const closeButton = screen.getByLabelText("Close"); + act(() => { + fireEvent.click(closeButton); + }); + + await waitFor(() => { + expect(screen.queryByText("Customer usage is a new feature.")).not.toBeInTheDocument(); + }); + }); + }); + + describe("agent usage banner", () => { + it("should show agent usage banner with A2A info", async () => { + mockUseAgents.mockReturnValue({ + data: { agents: mockAgents }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "agent" } }); + }); + + await waitFor(() => { + expect(screen.getByText("Agent usage (A2A) is a new feature.")).toBeInTheDocument(); + }); + }); + }); + + describe("tab navigation in global view", () => { + it("should render all expected tabs", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + 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(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 688ee73767f..f81da6e2455 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -6,7 +6,7 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { InfoCircleOutlined } from "@ant-design/icons"; +import { InfoCircleOutlined, LoadingOutlined, UserOutlined } from "@ant-design/icons"; import { BarChart, Card, @@ -21,13 +21,15 @@ import { Text, Title } from "@tremor/react"; -import { Alert, Segmented, Tooltip } from "antd"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Segmented, Select, Tooltip } from "antd"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import React, { useCallback, useEffect, useMemo, useState, type UIEvent } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Button } from "@tremor/react"; import { all_admin_roles } from "../../../utils/roles"; @@ -81,6 +83,62 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const { data: currentUser } = useCurrentUser(); console.log(`currentUser: ${JSON.stringify(currentUser)}`); console.log(`currentUser max budget: ${currentUser?.max_budget}`); + const isAdmin = all_admin_roles.includes(userRole || ""); + + // Debounced search for user selector + const [userSearchInput, setUserSearchInput] = useState(""); + const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", { + wait: 300, + }); + + const { + data: usersInfiniteData, + fetchNextPage: fetchNextUsersPage, + hasNextPage: hasNextUsersPage, + isFetchingNextPage: isFetchingNextUsersPage, + isLoading: isLoadingUsers, + } = useInfiniteUsers(50, debouncedUserSearch || undefined); + + const userOptions = useMemo(() => { + if (!usersInfiniteData?.pages) return []; + const seen = new Set(); + const result: { value: string; label: string }[] = []; + for (const page of usersInfiniteData.pages) { + for (const user of page.users) { + if (seen.has(user.user_id)) continue; + seen.add(user.user_id); + result.push({ + value: user.user_id, + label: user.user_alias + ? `${user.user_alias} (${user.user_id})` + : user.user_email + ? `${user.user_email} (${user.user_id})` + : user.user_id, + }); + } + } + return result; + }, [usersInfiniteData]); + + const handleUserSearchChange = (value: string) => { + setUserSearchInput(value); + setDebouncedUserSearch(value); + }; + + const handleUserPopupScroll = (e: UIEvent) => { + const target = e.currentTarget; + const scrollRatio = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) { + fetchNextUsersPage(); + } + }; + + // For admins: null means global view (all users), a string means filter by that user + // For non-admins: always set to their own user ID + const [selectedUserId, setSelectedUserId] = useState( + isAdmin ? null : (userID || null) + ); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); @@ -107,6 +165,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { getAllTags(); }, [accessToken]); + // Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render) + useEffect(() => { + if (!isAdmin && userID) { + setSelectedUserId(userID); + } + }, [isAdmin, userID]); + // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -301,6 +366,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const fetchUserSpendData = useCallback(async () => { if (!accessToken || !dateValue.from || !dateValue.to) return; + // For non-admins, always pass their own user_id + const effectiveUserId = isAdmin ? selectedUserId : (userID || null); + setLoading(true); // Create new Date objects to avoid mutating the original dates @@ -310,14 +378,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { try { // Prefer aggregated endpoint to avoid many page requests try { - const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime); + const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId); setUserSpendData(aggregated); return; } catch (e) { // Fallback to paginated calls if aggregated endpoint is unavailable } - const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime); + const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId); if (firstPageData.metadata.total_pages <= 1) { setUserSpendData(firstPageData); @@ -328,7 +396,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const aggregatedMetadata = { ...firstPageData.metadata }; for (let page = 2; page <= firstPageData.metadata.total_pages; page++) { - const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page); + const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId); allResults.push(...pageData.results); if (pageData.metadata) { aggregatedMetadata.total_spend += pageData.metadata.total_spend || 0; @@ -349,7 +417,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setLoading(false); setIsDateChanging(false); } - }, [accessToken, dateValue.from, dateValue.to]); + }, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]); // Super responsive date change handler const handleDateChange = useCallback((newValue: DateRangePickerValue) => { @@ -423,12 +491,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setUsageView(value)} - isAdmin={all_admin_roles.includes(userRole || "")} + isAdmin={isAdmin} /> {/* Your Usage Panel */} {usageView === "global" && ( + <>
@@ -460,24 +529,61 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {/* Total Spend Card */} - - Project Spend{" "} - {dateValue.from && dateValue.to && ( - <> - {dateValue.from.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, - })} - {" - "} - {dateValue.to.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - +
+ + Project Spend{" "} + {dateValue.from && dateValue.to && ( + <> + {dateValue.from.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, + })} + {" - "} + {dateValue.to.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + )} + + {isAdmin && ( +
+ +