From 8cf03d6fd95b5a805f8e6ce9dba0b913de081e15 Mon Sep 17 00:00:00 2001 From: Jesus Velarde Date: Sat, 26 Sep 2026 17:17:46 -0700 Subject: [PATCH] feat(ui): shareable URL state for usage views and users-to-usage deeplinks The Usage page now keeps the view, global view tab, date range and entity filters in the URL, so links can be bookmarked and shared. Relative presets are stored as range=7d and keep rolling, custom ranges as UTC from/to. Invalid params are dropped, and a link to a view or user the viewer cannot access shows a toast and falls back to the default view. Internal Users rows get a "View usage" action that opens usage filtered to that user, and the per-user usage views link back to the user's record. A "Copy Share Link" button sits next to "Ask AI" --- ui/litellm-dashboard/eslint-suppressions.json | 2 +- .../EntityUsage/EntityUsage.test.tsx | 141 +++++++++----- .../components/EntityUsage/EntityUsage.tsx | 11 +- .../components/UsagePageView.test.tsx | 176 ++++++++++++++++- .../_components/components/UsagePageView.tsx | 101 +++++++--- .../UsageViewSelect/UsageViewSelect.tsx | 34 ++-- .../_components/components/UserRecordLink.tsx | 16 ++ .../usage/_components/usageUrlState.test.ts | 172 +++++++++++++++++ .../usage/_components/usageUrlState.ts | 177 ++++++++++++++++++ .../view_users/UsersTable.test.tsx | 12 ++ .../view_users/UsersTableColumns.tsx | 8 +- .../shared/advanced_date_picker.tsx | 35 ++-- ui/litellm-dashboard/tests/test-utils.tsx | 10 +- 13 files changed, 777 insertions(+), 118 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UserRecordLink.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts 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 }) => {
+