diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index daf12d11743..b2eec2b02d3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1140,7 +1140,7 @@ "count": 1 }, "react-hooks/set-state-in-effect": { - "count": 3 + "count": 2 } }, "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { 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 6bd16095351..0193f8a0c20 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 @@ -1,7 +1,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReactNode } from "react"; +import { useState, type ComponentProps, type ReactNode } from "react"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import * as networking from "@/components/networking"; @@ -9,6 +9,13 @@ import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from import EntityUsage from "./EntityUsage"; import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations"; +type EntityUsageProps = ComponentProps; + +function StatefulEntityUsage(props: Omit) { + const [selectedEntities, setSelectedEntities] = useState([]); + return ; +} + const emptySpendMetrics: SpendMetrics = { spend: 0, prompt_tokens: 0, @@ -513,7 +520,7 @@ describe("EntityUsage", () => { }); it("should render with tag entity type and display spend metrics", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -531,7 +538,7 @@ describe("EntityUsage", () => { }); it("should render with team entity type and call team API", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -554,12 +561,12 @@ describe("EntityUsage", () => { teamsResult([{ team_id: "team-alpha" }, { team_id: "litellm-dashboard" }, { team_id: "team-beta" }]), ); - render(); + render(); expect(await screen.findByText("team-user-spend:team-alpha|team-beta")).toBeInTheDocument(); cleanup(); mockUseTeams.mockReturnValue(teamsResult([])); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); }); @@ -567,7 +574,7 @@ describe("EntityUsage", () => { }); it("should render with organization entity type and call organization API", async () => { - render(); + render(); await waitFor(() => { expect(mockOrganizationDailyActivityCall).toHaveBeenCalled(); @@ -582,7 +589,7 @@ describe("EntityUsage", () => { }); it("should render with customer entity type and call customer API", async () => { - render(); + render(); await waitFor(() => { expect(mockCustomerDailyActivityCall).toHaveBeenCalled(); @@ -597,7 +604,7 @@ describe("EntityUsage", () => { }); it("should render with agent entity type and call agent API", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -612,7 +619,7 @@ describe("EntityUsage", () => { }); it("should render with user entity type and call user API", async () => { - render(); + render(); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalled(); @@ -627,7 +634,7 @@ describe("EntityUsage", () => { }); it("should switch between tabs", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -655,7 +662,7 @@ describe("EntityUsage", () => { ...mockSpendData, metadata: { ...mockSpendData.metadata, api_key_limit: 100, total_api_keys: 3000 }, }); - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -693,7 +700,7 @@ describe("EntityUsage", () => { ]; it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -719,7 +726,7 @@ describe("EntityUsage", () => { ]; it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -750,7 +757,7 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockResolvedValue(emptyData); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -763,7 +770,7 @@ describe("EntityUsage", () => { }); it("should display Model Activity tab for non-agent entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -773,7 +780,7 @@ describe("EntityUsage", () => { }); it("should display Request / Token Consumption tab for agent entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -783,7 +790,7 @@ describe("EntityUsage", () => { }); it("should display Top Public Model Names title for non-agent entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -793,7 +800,7 @@ describe("EntityUsage", () => { }); it("defaults Model Activity to public model names and toggles to litellm models", async () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -819,7 +826,7 @@ describe("EntityUsage", () => { }); it("should display Top Agents title for agent entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -834,7 +841,7 @@ describe("EntityUsage", () => { { label: "Tag 2", value: "tag-2" }, ]; - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -848,7 +855,7 @@ describe("EntityUsage", () => { it("should fallback to team_alias when entityList is provided but entity does not exist", async () => { const customEntityList = [{ label: "Tag 2", value: "tag-2" }]; - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -860,7 +867,7 @@ describe("EntityUsage", () => { }); it("should fallback to team_alias when entityList is null", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -872,7 +879,7 @@ describe("EntityUsage", () => { }); it("should still request the filter when the caller's tag scope is empty", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -882,7 +889,7 @@ describe("EntityUsage", () => { }); it("should not request the filter while the entity list is still unresolved", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -892,7 +899,7 @@ describe("EntityUsage", () => { }); it("should display Agent Activity tab for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -902,7 +909,7 @@ describe("EntityUsage", () => { }); it("should not display Agent Activity tab for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -912,7 +919,7 @@ describe("EntityUsage", () => { }); it("should display Top Agents Driving Spend card for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -922,7 +929,7 @@ describe("EntityUsage", () => { }); it("should not display Top Agents Driving Spend card for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -932,7 +939,7 @@ describe("EntityUsage", () => { }); it("should fetch agent activity data when entity type is team", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalledWith( @@ -946,7 +953,7 @@ describe("EntityUsage", () => { }); it("should not fetch agent activity data for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -956,7 +963,7 @@ describe("EntityUsage", () => { }); it("should switch to Agent Activity tab for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -993,7 +1000,7 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1005,7 +1012,7 @@ describe("EntityUsage", () => { }); it("renders daily spend bars, per-entity bars, and the provider donut with cyan fills and a $ center total", async () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1054,7 +1061,7 @@ describe("EntityUsage", () => { mockUserDailyActivityCall.mockResolvedValue(spendDataForUser); // entityList is null to simulate a spender missing from the paginated user list - render(); + render(); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalled(); @@ -1067,7 +1074,7 @@ describe("EntityUsage", () => { }); it("renders the provider spend table logo from the bundled provider map", async () => { - render(); + render(); const logo = await screen.findByAltText("openai logo"); expect(logo).toHaveAttribute("src", expect.stringContaining("openai_small")); @@ -1078,7 +1085,7 @@ describe("EntityUsage", () => { ["organization", () => mockOrganizationDailyActivityCall, "Organization Spend Overview"], ["agent", () => mockAgentDailyActivityCall, "Agent Spend Overview"], ] as const)("fetches %s activity for an admin but not for an internal user", async (entityType, call, heading) => { - render(); + render(); await waitFor(() => { expect(call()).toHaveBeenCalled(); }); @@ -1086,7 +1093,7 @@ describe("EntityUsage", () => { cleanup(); call().mockClear(); - render(); + render(); expect(await screen.findByText(heading)).toBeInTheDocument(); expect(call()).not.toHaveBeenCalled(); }); @@ -1099,7 +1106,9 @@ describe("EntityUsage", () => { ["organization", () => mockOrganizationDailyActivityCall, true], ["agent", () => mockAgentDailyActivityCall, false], ] as const)("fetches %s activity for an org admin: %s", async (entityType, call, expected) => { - render(); + render( + , + ); if (expected) { await waitFor(() => { @@ -1112,7 +1121,7 @@ describe("EntityUsage", () => { }); it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -1125,7 +1134,7 @@ describe("EntityUsage", () => { }); it("keeps the tag breakdown for an internal user", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1151,7 +1160,7 @@ describe("EntityUsage", () => { }; mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider); - render(); + render(); await waitFor(() => { expect(screen.getAllByText("zzz-internal").length).toBeGreaterThan(0); @@ -1191,7 +1200,7 @@ describe("EntityUsage", () => { ], }); - render(); + render(); await waitFor(() => { expect(screen.getByText("top-keys:sk-abc=30.75=alice@example.com")).toBeInTheDocument(); @@ -1201,7 +1210,7 @@ describe("EntityUsage", () => { }); it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -1216,7 +1225,7 @@ describe("EntityUsage", () => { it("falls back to the paginated team endpoint when the aggregated call fails", async () => { mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -1232,12 +1241,54 @@ describe("EntityUsage", () => { const userCombobox = (): HTMLElement => within(userDropdown()).getByRole("combobox"); const renderUserUsage = async () => { - render(); + render(); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalled(); }); }; + it("links the single picked user back to their Internal Users record", async () => { + render( + , + ); + + expect(await screen.findByRole("link", { name: "View user record" })).toHaveAttribute( + "href", + "/ui/users?user=user-001", + ); + }); + + it("shows no user record link until a user is picked", async () => { + await renderUserUsage(); + + expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument(); + }); + + it("reports the picked user to the owner of the selection", async () => { + const user = userEvent.setup(); + const onSelectedEntitiesChange = vi.fn(); + render( + , + ); + + await user.click(userCombobox()); + await user.click(await screen.findByText("Alice (user-001)")); + + expect(onSelectedEntitiesChange).toHaveBeenCalledWith(["user-001"]); + }); + it("offers a user filter even when the caller preloaded no user page", async () => { await renderUserUsage(); 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 d1ebd8b98a0..4909926289f 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 @@ -23,6 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import React, { type ReactNode, useCallback, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; +import UserRecordLink from "../UserRecordLink"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import { UsageExportHeader } from "@/components/EntityUsageExport"; import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; @@ -93,6 +94,8 @@ interface EntityUsageProps { premiumUser: boolean; dateValue: DateRangePickerValue; isOrgAdmin?: boolean; + selectedEntities: readonly string[]; + onSelectedEntitiesChange: (ids: readonly string[]) => void; } const ENTITY_FETCH_FNS: Record Promise> = { @@ -123,9 +126,10 @@ const EntityUsage: React.FC = ({ userRole, dateValue, isOrgAdmin = false, + selectedEntities: selectedTags, + onSelectedEntitiesChange: setSelectedTags, }) => { const { teams } = useTeams(); - const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); @@ -289,7 +293,10 @@ const EntityUsage: React.FC = ({ const entityFilterSlots: Partial> = { team: , user: ( - setSelectedTags(userId ? [userId] : [])} /> +
+ setSelectedTags(userId ? [userId] : [])} /> + {selectedTags.length === 1 && } +
), }; const filterSlot = entityFilterSlots[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 7702488f5bf..f5436223c7c 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 @@ -7,7 +7,9 @@ import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; 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"; +import type { UrlUpdateEvent } from "nuqs/adapters/testing"; import { renderWithProviders } from "@/../tests/test-utils"; +import { toast } from "@/lib/toast"; import type { Organization } from "@/components/networking"; import * as networking from "@/components/networking"; import UsagePage from "./UsagePageView"; @@ -48,9 +50,27 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ })); vi.mock("./EntityUsage/EntityUsage", () => ({ - default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => ( -
+ default: ({ + entityType, + entityList, + selectedEntities, + onSelectedEntitiesChange, + }: { + entityType: string; + entityList: unknown; + selectedEntities: readonly string[]; + onSelectedEntitiesChange: (ids: readonly string[]) => void; + }) => ( +
Entity Usage +
), EntityList: [], @@ -64,7 +84,8 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({ default: () =>
Endpoint Usage
, })); -vi.mock("./UsageViewSelect/UsageViewSelect", async () => { +vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => { + const actual = await importOriginal(); const React = await import("react"); const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => { const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null; @@ -87,10 +108,11 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { ); }; UsageViewSelect.displayName = "UsageViewSelect"; - return { UsageViewSelect }; + return { ...actual, UsageViewSelect }; }); -vi.mock("@/components/shared/advanced_date_picker", async () => { +vi.mock("@/components/shared/advanced_date_picker", async (importOriginal) => { + const actual = await importOriginal(); const React = await import("react"); // The button is how a test drives a range change; the real picker's own UI is // not what any test here is asserting on. @@ -110,7 +132,7 @@ vi.mock("@/components/shared/advanced_date_picker", async () => { ), ); AdvancedDatePicker.displayName = "AdvancedDatePicker"; - return { default: AdvancedDatePicker }; + return { ...actual, default: AdvancedDatePicker }; }); vi.mock("@/components/user_agent_activity", () => ({ @@ -147,6 +169,10 @@ vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({ default: vi.fn(() => false), })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn(() => ({ data: [], isPending: false })), +})); + vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ useCurrentUser: vi.fn(), })); @@ -1365,4 +1391,142 @@ describe("UsagePage", () => { expect(screen.getByText("Endpoint Activity")).toBeInTheDocument(); }); }); + describe("URL state", () => { + const renderAt = (searchParams: string) => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const view = renderWithProviders(, { + searchParams, + onUrlUpdate, + resetUrlUpdateQueueOnMount: false, + }); + const lastQuery = (): string | undefined => onUrlUpdate.mock.lastCall?.[0].queryString; + return { ...view, onUrlUpdate, lastQuery }; + }; + + it("preselects the user filter from ?user= and links back to that user's record", async () => { + renderAt("?user=user-001"); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + "user-001", + ); + }); + expect(screen.getByRole("link", { name: "View user record" })).toHaveAttribute("href", "/ui/users?user=user-001"); + }); + + it("shows no user record link while the global view is unfiltered", async () => { + renderAt(""); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + null, + ); + }); + expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument(); + }); + + it("opens the linked entity view with its selection and writes new picks back", async () => { + const { lastQuery } = renderAt("?view=team&team=team-1"); + + const entityUsage = await screen.findByTestId("entity-usage"); + expect(entityUsage).toHaveAttribute("data-entity-type", "team"); + expect(entityUsage).toHaveAttribute("data-selected", JSON.stringify(["team-1"])); + + fireEvent.click(screen.getByRole("button", { name: "pick entities" })); + + await waitFor(() => { + expect(lastQuery()).toBe("?view=team&team=picked-1&team=picked-2"); + }); + }); + + it("writes the chosen view and drops the previous view's filters", async () => { + const { lastQuery } = renderAt("?user=user-001&tab=keys&range=30d"); + + fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "customer" } }); + + await waitFor(() => { + expect(lastQuery()).toBe("?range=30d&view=customer"); + }); + }); + + it("restores the inner tab from the URL and writes tab changes", async () => { + const { lastQuery } = renderAt("?tab=models"); + + expect(await screen.findByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true"); + + fireEvent.click(screen.getByRole("tab", { name: "Key Activity" })); + + await waitFor(() => { + expect(lastQuery()).toBe("?tab=keys"); + }); + }); + + it("fetches the custom range in the URL and writes a newly picked one", async () => { + const { lastQuery } = renderAt("?from=2025-03-01T00:00:00.000Z&to=2025-03-05T00:00:00.000Z"); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + new Date("2025-03-01T00:00:00.000Z"), + new Date("2025-03-05T00:00:00.000Z"), + null, + ); + }); + + fireEvent.click(screen.getByTestId("pick-a-different-range")); + + await waitFor(() => { + expect(lastQuery()).toBe("?from=2024-01-01T00:00:00.000Z&to=2024-01-08T00:00:00.000Z"); + }); + }); + + it("tells a viewer they lack access and sends them to the default view", async () => { + const warning = vi.spyOn(toast, "warning"); + mockUseAuthorized.mockReturnValue(nonAdminSession); + + const { lastQuery } = renderAt("?view=organization&org=org-1&range=30d"); + + await waitFor(() => { + expect(lastQuery()).toBe("?range=30d"); + }); + expect(warning).toHaveBeenCalledWith("You don't have access to that usage view"); + expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument(); + }); + + it("never fetches another user's usage for a non-admin who opens their link", async () => { + const warning = vi.spyOn(toast, "warning"); + mockUseAuthorized.mockReturnValue(nonAdminSession); + + const { lastQuery } = renderAt("?user=user-001"); + + await waitFor(() => { + expect(lastQuery()).toBe(""); + }); + expect(warning).toHaveBeenCalledWith("You don't have access to that usage view"); + expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + "user-001", + ); + }); + + it("copies the current page URL as the share link", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + renderAt("?tab=models"); + + fireEvent.click(await screen.findByRole("button", { name: "Copy Share Link" })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(window.location.href); + }); + }); + }); }); 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 4c5db0def62..956f7af9885 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 @@ -6,7 +6,8 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react"; +import { ChevronDown, ChevronRight, Download, Info, Link2, Sparkles, X } from "lucide-react"; +import { useQueryStates } from "nuqs"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -23,14 +24,17 @@ 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 { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { toast } from "@/lib/toast"; import { hasCapability } from "@/utils/capabilities"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; +import type { EntityType } from "@/components/EntityUsageExport/types"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; import { @@ -66,7 +70,20 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations"; import UsageAIChatPanel from "./UsageAIChatPanel"; -import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import { allowedUsageOptions, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import UserRecordLink from "./UserRecordLink"; +import { + cleanUsageUrl, + dateRangeFromParams, + dateRangePatch, + entitySelectionPatch, + selectedEntitiesFromParams, + usageTabFromParams, + usageTabPatch, + usageUrlParsers, + usageViewFromParams, + usageViewPatch, +} from "../usageUrlState"; interface UsagePageProps { teams: Team[]; @@ -94,11 +111,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []); const initialToDate = useMemo(() => new Date(), []); - // Single date state that directly triggers data fetching - const [dateValue, setDateValue] = useState({ - from: initialFromDate, - to: initialToDate, - }); + const [urlParams, setUrlParams] = useQueryStates(usageUrlParsers); + const { range, from, to } = urlParams; + const dateValue = useMemo( + () => dateRangeFromParams({ range, from, to }) ?? { from: initialFromDate, to: initialToDate }, + [range, from, to, initialFromDate, initialToDate], + ); const [fetchedTags, setFetchedTags] = useState | null>(null); // No [] default: an unresolved query must stay undefined so the customer @@ -112,14 +130,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); - // 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"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); - const [selectedUsageView, setUsageView] = useState("global"); + const selectedUsageView = usageViewFromParams(urlParams); + const selectedTab = usageTabFromParams(urlParams); // Org-admin membership is read from the server, so unlike the other usage // views this one can be revoked while the page is open. Derive the view in // render rather than storing it, so the fallback lands on the same paint and @@ -131,15 +147,27 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); const [showTokenBreakdown, setShowTokenBreakdown] = useState(false); - // Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render) - useEffect(() => { - if (!isAdmin && userID) { - setSelectedUserId(userID); - } - }, [isAdmin, userID]); - // For non-admins or "my-usage" view, always pass their own user_id - const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId; + const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user; + + const { isPending: organizationsPending } = useOrganizations(); + const allowedViews = useMemo( + () => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin), + [userRole, canViewTagUsage, isOrgAdmin], + ); + const accessSettled = userRole !== null && !organizationsPending; + useEffect(() => { + if (!accessSettled) return; + const cleanup = cleanUsageUrl(urlParams, { allowedViews, isAdmin }); + if (!cleanup) return; + if (cleanup.deniedAccess) toast.warning("You don't have access to that usage view"); + void setUrlParams(cleanup.patch); + }, [accessSettled, urlParams, allowedViews, isAdmin, setUrlParams]); + + const entitySelection = (entityType: EntityType) => ({ + selectedEntities: selectedEntitiesFromParams(urlParams, entityType), + onSelectedEntitiesChange: (ids: readonly string[]) => void setUrlParams(entitySelectionPatch(entityType, ids)), + }); const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); @@ -273,13 +301,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]); // Super responsive date change handler - const handleDateChange = useCallback((newValue: DateRangePickerValue) => { - // Instant visual feedback - setIsDateChanging(true); + const handleDateChange = useCallback( + (newValue: DateRangePickerValue) => { + // Instant visual feedback + setIsDateChanging(true); - // Update date immediately for UI responsiveness - setDateValue(newValue); - }, []); + void setUrlParams(dateRangePatch(newValue)); + }, + [setUrlParams], + ); // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -461,7 +491,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
setUsageView(value)} + onChange={(value) => void setUrlParams(usageViewPatch(value))} userRole={userRole} canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} @@ -481,10 +511,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {isAdmin && usageView === "global" && (

Filter by user

- +
+ void setUrlParams({ user })} /> + {urlParams.user && } +
)} - + void setUrlParams(usageTabPatch(tab))}>
@@ -504,6 +537,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
+