From 8efb99b4ac61e7ec7f93bf418fd1d138803c8426 Mon Sep 17 00:00:00 2001 From: jesus-berri Date: Fri, 16 Oct 2026 16:42:29 -0700 Subject: [PATCH] feat(ui): shareable URL state for usage views and users-to-usage deeplinks (#43425) * 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" * fix(ui): clean usage links for sessions without the organizations query The organizations query only runs for premium sessions, and a disabled query stays pending forever, so the URL cleanup never ran for other sessions. Gate on isLoading instead, and give the access denied toast an id so a re-run effect shows it once * fix(ui): type-check usage entity filters and address review findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep team usage available to internal users Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format users table usage action Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): sync date picker preset with the current usage range Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): restore dashboard AGENTS.md from main and drop unused usage state Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): share custom usage date ranges as calendar days Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep per-user agent usage page across rerenders Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/eslint-suppressions.json | 11 - .../EntityUsage/EntityUsage.test.tsx | 151 ++++++--- .../components/EntityUsage/EntityUsage.tsx | 11 +- .../EntityUsage/TeamUserSpendCard.tsx | 2 +- .../components/UsagePageView.test.tsx | 288 +++++++++++++++++- .../_components/components/UsagePageView.tsx | 135 +++++--- .../UsageViewSelect/UsageViewSelect.tsx | 34 ++- .../_components/components/UserRecordLink.tsx | 16 + .../usage/_components/usageUrlState.test.ts | 267 ++++++++++++++++ .../usage/_components/usageUrlState.ts | 180 +++++++++++ .../users/_components/view_users.tsx | 3 +- .../view_users/UsersTable.test.tsx | 24 ++ .../_components/view_users/UsersTable.tsx | 5 +- .../view_users/UsersTableColumns.tsx | 19 +- .../EntityUsageExport/ExportSummary.tsx | 2 +- .../EntityUsageExport/UsageExportHeader.tsx | 4 +- .../src/components/EntityUsageExport/types.ts | 2 +- .../common_components/team_multi_select.tsx | 2 +- .../src/components/networking.tsx | 2 +- .../src/components/per_user_usage.tsx | 6 +- .../shared/PaginatedMultiSelect.tsx | 2 +- .../shared/advanced_date_picker.test.tsx | 77 +++++ .../shared/advanced_date_picker.tsx | 59 ++-- .../components/user_agent_activity.test.tsx | 80 ++++- .../src/components/user_agent_activity.tsx | 29 +- ui/litellm-dashboard/src/lib/toast.test.ts | 5 + ui/litellm-dashboard/src/lib/toast.ts | 2 + ui/litellm-dashboard/tests/test-utils.tsx | 10 +- 28 files changed, 1236 insertions(+), 192 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 9eead41a102..423287e7c0f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -231,9 +231,6 @@ "max-lines": { "count": 1 }, - "react-hooks/set-state-in-effect": { - "count": 1 - }, "react/no-unescaped-entities": { "count": 2 } @@ -1127,9 +1124,6 @@ "src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": { "react-hooks/purity": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/users/_components/BulkEditUsers.tsx": { @@ -1775,11 +1769,6 @@ "count": 1 } }, - "src/components/molecules/cost_optimization_feedback_banner.tsx": { - "local/filename-pascal-case": { - "count": 1 - } - }, "src/components/navbar.tsx": { "local/filename-pascal-case": { "count": 1 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 b546b2c0558..cdee86ed559 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"; @@ -15,6 +15,13 @@ import type { 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, @@ -110,7 +117,7 @@ vi.mock("./TopModelView", () => ({ })); vi.mock("./TeamUserSpendCard", () => ({ - default: ({ teamIds }: { teamIds: string[] }) =>
{`team-user-spend:${teamIds.join("|")}`}
, + default: ({ teamIds }: { teamIds: readonly string[] }) =>
{`team-user-spend:${teamIds.join("|")}`}
, })); vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({ @@ -549,7 +556,7 @@ describe("EntityUsage", () => { }); it("should render with tag entity type and display spend metrics", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -568,7 +575,7 @@ describe("EntityUsage", () => { }); it("should render with team entity type and call team API", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -591,12 +598,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(); }); @@ -604,7 +611,7 @@ describe("EntityUsage", () => { }); it("should render with organization entity type and call organization API", async () => { - render(); + render(); await waitFor(() => { expect(mockOrganizationDailyActivityCall).toHaveBeenCalled(); @@ -619,7 +626,7 @@ describe("EntityUsage", () => { }); it("should render with customer entity type and call customer API", async () => { - render(); + render(); await waitFor(() => { expect(mockCustomerDailyActivityCall).toHaveBeenCalled(); @@ -634,7 +641,7 @@ describe("EntityUsage", () => { }); it("should render with agent entity type and call agent API", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -649,7 +656,7 @@ describe("EntityUsage", () => { }); it("should render with user entity type and call user API", async () => { - render(); + render(); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalled(); @@ -664,7 +671,7 @@ describe("EntityUsage", () => { }); it("should switch between tabs", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -688,7 +695,7 @@ describe("EntityUsage", () => { }); it("loads key pages separately from the aggregate using the current entity scope", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(1); @@ -743,7 +750,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(); @@ -769,7 +776,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(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -800,7 +807,7 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockResolvedValue(emptyData); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -814,7 +821,7 @@ describe("EntityUsage", () => { }); it("should display Model Activity tab for non-agent entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -824,7 +831,7 @@ describe("EntityUsage", () => { }); it("should display Request / Token Consumption tab for agent entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -834,7 +841,7 @@ describe("EntityUsage", () => { }); it("should display Top Public Model Names title for non-agent entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -844,7 +851,7 @@ describe("EntityUsage", () => { }); it("defaults Model Activity to public model names and toggles to litellm models", async () => { - const { container } = render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -870,7 +877,7 @@ describe("EntityUsage", () => { }); it("should display Top Agents title for agent entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -885,7 +892,7 @@ describe("EntityUsage", () => { { label: "Tag 2", value: "tag-2" }, ]; - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -899,7 +906,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(); @@ -911,7 +918,7 @@ describe("EntityUsage", () => { }); it("should fallback to team_alias when entityList is null", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -923,7 +930,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(); @@ -933,7 +940,7 @@ describe("EntityUsage", () => { }); it("should not request the filter while the entity list is still unresolved", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -943,7 +950,7 @@ describe("EntityUsage", () => { }); it("should display Agent Activity tab for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -953,7 +960,7 @@ describe("EntityUsage", () => { }); it("should not display Agent Activity tab for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -963,7 +970,7 @@ describe("EntityUsage", () => { }); it("should display Top Agents Driving Spend card for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -973,7 +980,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(); @@ -983,7 +990,7 @@ describe("EntityUsage", () => { }); it("should fetch agent activity data when entity type is team", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "test-token" })); @@ -991,7 +998,7 @@ describe("EntityUsage", () => { }); it("offers per-model top keys in Model Activity but not in the agent breakdown", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -1016,7 +1023,7 @@ describe("EntityUsage", () => { resolveSpend = resolve; }), ); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1037,7 +1044,7 @@ describe("EntityUsage", () => { }); it("should not fetch agent activity data for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1047,7 +1054,7 @@ describe("EntityUsage", () => { }); it("should switch to Agent Activity tab for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -1084,7 +1091,7 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1096,7 +1103,7 @@ describe("EntityUsage", () => { }); it("renders the stacked daily spend chart, the per-entity table, and the provider share bar with a $ total", async () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1140,7 +1147,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(); @@ -1153,7 +1160,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")); @@ -1164,7 +1171,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(); }); @@ -1172,7 +1179,7 @@ describe("EntityUsage", () => { cleanup(); call().mockClear(); - render(); + render(); expect(await screen.findByText(heading)).toBeInTheDocument(); expect(call()).not.toHaveBeenCalled(); }); @@ -1185,7 +1192,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(() => { @@ -1198,7 +1207,7 @@ describe("EntityUsage", () => { }); it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -1211,7 +1220,7 @@ describe("EntityUsage", () => { }); it("keeps the tag breakdown for an internal user", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1237,7 +1246,7 @@ describe("EntityUsage", () => { }; mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider); - render(); + render(); await waitFor(() => { expect(screen.getAllByText("zzz-internal").length).toBeGreaterThan(0); @@ -1277,7 +1286,7 @@ describe("EntityUsage", () => { ], }); - render(); + render(); await waitFor(() => { expect(screen.getByText("top-keys:sk-abc=30.75=alice@example.com")).toBeInTheDocument(); @@ -1287,7 +1296,7 @@ describe("EntityUsage", () => { }); it("uses the aggregated team endpoint and makes a single bounded request", async () => { - render(); + render(); await waitFor(() => { expect(mockDailyActivityAggregatedCall).toHaveBeenCalledWith("team", expect.anything()); @@ -1300,7 +1309,7 @@ describe("EntityUsage", () => { }); it("does not scope the agent breakdown by the selected team ids", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -1322,7 +1331,7 @@ describe("EntityUsage", () => { }); it("does not refetch agent activity when the team selection changes", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -1345,7 +1354,7 @@ describe("EntityUsage", () => { it("surfaces a failure alert when the aggregated call fails instead of retrying other routes", async () => { mockTeamDailyActivityCall.mockRejectedValue(new Error("aggregated unavailable")); - render(); + render(); await waitFor(() => { expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0); @@ -1358,12 +1367,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 38a3f0f84e1..8518f52e165 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 @@ -22,6 +22,7 @@ import { cn } from "@/lib/cva.config"; 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 type { EntityType } from "@/components/EntityUsageExport/types"; @@ -122,6 +123,8 @@ interface EntityUsageProps { premiumUser: boolean; dateValue: DateRangePickerValue; isOrgAdmin?: boolean; + selectedEntities: readonly string[]; + onSelectedEntitiesChange: (ids: readonly string[]) => void; } const ENTITY_CAPABILITIES: Partial> = { @@ -137,10 +140,11 @@ const EntityUsage: React.FC = ({ userRole, dateValue, isOrgAdmin = false, + selectedEntities: selectedTags, + onSelectedEntitiesChange: setSelectedTags, }) => { const { teams } = useTeams(); const teamList = useMemo(() => teams ?? [], [teams]); - const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); @@ -339,7 +343,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/EntityUsage/TeamUserSpendCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx index cd488cbd4ea..e9de3d22110 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx @@ -25,7 +25,7 @@ interface TeamUserSpendCardProps { accessToken: string | null; startTime: Date | null; endTime: Date | null; - teamIds: string[]; + teamIds: readonly string[]; } const QUIET_HEADER = { headerClassName: "font-normal" }; 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 b4134157821..c37e8e3306b 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, within } 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 { processActivityData } from "@/components/activity_metrics"; import type { Organization } from "@/components/networking"; import type { ModelActivityData } from "@/components/UsagePage/types"; @@ -16,6 +18,8 @@ import { STACKED_USAGE_PALETTE } from "@/components/shared/charts"; import { OTHER_COLOR } from "./overview/overviewData"; import UsagePage from "./UsagePageView"; +const mockEntityUsageMounted = vi.hoisted(() => vi.fn()); + // The Overview tab. The Key Activity tab stays mounted and carries its own spend-derived totals, // so assertions about the overview's request tiles are scoped here rather than to the whole page. const overview = () => within(screen.getByRole("tabpanel", { name: "Overview" })); @@ -86,14 +90,38 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ default: () =>
Top Keys
, })); -vi.mock("./EntityUsage/EntityUsage", () => ({ - default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => ( -
- Entity Usage -
- ), - EntityList: [], -})); +vi.mock("./EntityUsage/EntityUsage", async () => { + const React = await import("react"); + const EntityUsage = ({ + entityType, + entityList, + selectedEntities, + onSelectedEntitiesChange, + }: { + entityType: string; + entityList: unknown; + selectedEntities: readonly string[]; + onSelectedEntitiesChange: (ids: readonly string[]) => void; + }) => { + React.useEffect(() => { + mockEntityUsageMounted(entityType); + }, [entityType]); + return ( +
+ Entity Usage + +
+ ); + }; + return { default: EntityUsage, EntityList: [] }; +}); vi.mock("./EntityUsage/SpendByProvider", () => ({ default: () =>
Spend By Provider
, @@ -103,7 +131,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, @@ -135,14 +164,19 @@ 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. - const AdvancedDatePicker = ({ onValueChange }: { onValueChange?: (value: unknown) => void }) => + const AdvancedDatePicker = ({ + onValueChange, + }: { + onValueChange?: (value: unknown, presetShortLabel: string | null) => void; + }) => React.createElement( "div", { "data-testid": "advanced-date-picker" }, @@ -152,17 +186,33 @@ vi.mock("@/components/shared/advanced_date_picker", async () => { { "data-testid": "pick-a-different-range", onClick: () => - onValueChange?.({ from: new Date("2024-01-01T00:00:00Z"), to: new Date("2024-01-08T00:00:00Z") }), + onValueChange?.( + { from: new Date(2024, 0, 1, 0, 0, 0, 0), to: new Date(2024, 0, 8, 23, 59, 59, 999) }, + null, + ), }, "pick", ), ); AdvancedDatePicker.displayName = "AdvancedDatePicker"; - return { default: AdvancedDatePicker }; + return { ...actual, default: AdvancedDatePicker }; }); vi.mock("@/components/user_agent_activity", () => ({ - default: () =>
User Agent Activity
, + default: ({ + selectedTags, + onSelectedTagsChange, + }: { + selectedTags?: readonly string[]; + onSelectedTagsChange?: (tags: readonly string[]) => void; + }) => ( +
+ User Agent Activity + +
+ ), })); vi.mock("@/components/cloudzero_export_modal", () => ({ @@ -195,6 +245,10 @@ vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({ default: vi.fn(() => false), })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn(() => ({ data: undefined, isPending: true, isLoading: false })), +})); + vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ useCurrentUser: vi.fn(), })); @@ -367,8 +421,10 @@ describe("UsagePage", () => { userRole: "Internal User", userRoleLabel: "Internal User", isViewOnly: false, + loginMethod: null, premiumUser: true, disabledPersonalKeyCreation: false, + passwordResetRequired: false, showSSOBanner: false, }; @@ -486,6 +542,7 @@ describe("UsagePage", () => { mockDailyActivityAggregatedCall.mockImplementation((entity: string, request: unknown) => entity === "user" ? mockUserDailyActivityAggregatedCall(request) : Promise.resolve({ results: [], metadata: {} }), ); + mockEntityUsageMounted.mockClear(); mockTagListCall.mockClear(); mockGatewayDailyActivityCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); @@ -1026,6 +1083,7 @@ describe("UsagePage", () => { act(() => { fireEvent.change(usageSelect, { target: { value: usageView } }); }); + expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument(); }); @@ -1518,4 +1576,204 @@ 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"); + + const expectedRequest = { + accessToken: "test-token", + endTime: expect.any(Date), + entityIds: ["user-001"], + startTime: expect.any(Date), + }; + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(expect.objectContaining(expectedRequest)); + }); + 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(""); + + const expectedRequest = { + accessToken: "test-token", + endTime: expect.any(Date), + entityIds: null, + startTime: expect.any(Date), + }; + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(expect.objectContaining(expectedRequest)); + }); + 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.each([ + ["users", "User Activity"], + ["errors", "Errors"], + ])("restores the %s tab from the URL", async (tab, label) => { + renderAt(`?tab=${tab}`); + + expect(await screen.findByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true"); + }); + + it("renders User Agent Activity from the URL and writes selected tags back", async () => { + const { lastQuery } = renderAt("?view=user-agent-activity&tag=User-Agent%3A+Chrome%2F1.0"); + const activity = await screen.findByTestId("user-agent-activity"); + + expect(activity).toHaveAttribute("data-selected-tags", JSON.stringify(["User-Agent: Chrome/1.0"])); + fireEvent.click(screen.getByRole("button", { name: "Select agent tag" })); + + await waitFor(() => { + expect(lastQuery()).toContain("tag=User-Agent"); + }); + expect(await screen.findByTestId("user-agent-activity")).toHaveAttribute( + "data-selected-tags", + JSON.stringify(["User-Agent: Firefox/2.0"]), + ); + }); + + it("fetches the custom range in the URL and writes a newly picked one", async () => { + const { lastQuery } = renderAt("?from=2025-03-01&to=2025-03-05"); + + const expectedRequest = { + accessToken: "test-token", + endTime: new Date(2025, 2, 5, 23, 59, 59, 999), + entityIds: null, + startTime: new Date(2025, 2, 1, 0, 0, 0, 0), + }; + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(expect.objectContaining(expectedRequest)); + }); + + fireEvent.click(screen.getByTestId("pick-a-different-range")); + + await waitFor(() => { + expect(lastQuery()).toBe("?from=2024-01-01&to=2024-01-08"); + }); + }); + + 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", { + id: "usage-url-access-denied", + }); + expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument(); + }); + + it("does not render a disallowed customer view for an internal user", async () => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + + renderAt("?view=customer&customer=c-1"); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + expect(mockEntityUsageMounted).not.toHaveBeenCalledWith("customer"); + expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument(); + }); + + it("does not select the admin-only Errors tab for an internal user", async () => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + + const { lastQuery } = renderAt("?tab=errors"); + + expect(await screen.findByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); + await waitFor(() => { + expect(lastQuery()).toBe(""); + }); + expect(screen.queryByRole("tab", { name: "Errors" })).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", { + id: "usage-url-access-denied", + }); + 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); + }); + }); + + it("shows the share link on an entity view", async () => { + renderAt("?view=team"); + + expect(await screen.findByRole("button", { name: "Copy Share Link" })).toBeInTheDocument(); + }); + }); }); 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 f91510b9f09..84cc9359ded 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 { Download, Search, Sparkles, X } from "lucide-react"; +import { Download, Link2, Search, 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"; @@ -21,12 +22,16 @@ 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 { copyToClipboard } 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 type { EntityType } from "@/components/EntityUsageExport/types"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import TopUsersView from "@/components/UsagePage/components/TopUsersView"; import UserActivityPanel from "@/components/UsagePage/components/UserActivityPanel"; @@ -67,7 +72,21 @@ import ModelViewToggle, { ModelViewType } from "./ModelViewToggle"; 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, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import UserRecordLink from "./UserRecordLink"; +import { + cleanUsageUrl, + dateRangeFromParams, + dateRangePatch, + entitySelectionPatch, + selectedEntitiesFromParams, + DEFAULT_USAGE_VIEW, + usageTabFromParams, + usageTabPatch, + usageUrlParsers, + usageViewFromParams, + usageViewPatch, +} from "../usageUrlState"; import { overviewTotals, rollUpBreakdown } from "./overview/overviewData"; import TopAgents from "./overview/TopAgents"; import type { AgentRow } from "./overview/agentCatalog"; @@ -89,11 +108,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 @@ -104,36 +124,41 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const isAdmin = all_admin_roles.includes(userRole || ""); const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); const isOrgAdmin = useIsOrgAdmin(); - const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); + const allowedViews = useMemo( + () => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin), + [userRole, canViewTagUsage, isOrgAdmin], + ); - // 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 [modelQuery, setModelQuery] = useState(""); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); - const [selectedUsageView, setUsageView] = useState("global"); - // 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 - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const selectedUsageView = usageViewFromParams(urlParams); + const selectedTab = usageTabFromParams(urlParams, isAdmin); + const usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); - // 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 { isLoading: organizationsLoading } = useOrganizations(); + const accessSettled = userRole !== null && !organizationsLoading; + 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", { id: "usage-url-access-denied" }); + 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]); @@ -211,11 +236,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { deps: [accessToken, startTime, endTime, showTopAgents], }); const tagDaily = useMemo(() => toDailyData(tagDailyRaw), [tagDailyRaw]); - const [agentActivityTags, setAgentActivityTags] = useState(undefined); - const openAgentActivity = useCallback((agent: AgentRow) => { - setAgentActivityTags(agent.tags); - setUsageView("user-agent-activity"); - }, []); + const openAgentActivity = useCallback( + (agent: AgentRow) => { + void setUrlParams({ + ...usageViewPatch("user-agent-activity"), + ...entitySelectionPatch("tag", agent.tags), + }); + }, + [setUrlParams], + ); // Gateway request counts (SGR). Admin-only: the source table is // deployment-wide, so a non-admin must not see it. @@ -277,9 +306,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { [userSpendData], ); - const handleDateChange = useCallback((newValue: DateRangePickerValue) => { - setDateValue(newValue); - }, []); + const handleDateChange = useCallback( + (newValue: DateRangePickerValue, presetShortLabel: string | null) => + void setUrlParams(dateRangePatch(newValue, presetShortLabel)), + [setUrlParams], + ); const totals = useMemo( () => overviewTotals(userSpendData.metadata, gatewayActivity), @@ -360,18 +391,27 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
setUsageView(value)} + onChange={(value) => void setUrlParams(usageViewPatch(value))} userRole={userRole} canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} />
{isAdmin && usageView === "global" && ( -
- +
+
+ void setUrlParams({ user })} /> +
+ {urlParams.user && }
)} - +
+ + +
{aggregatedFailed && ( @@ -387,7 +427,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {/* Your Usage / Global Usage Panel */} {(usageView === "global" || usageView === "my-usage") && ( <> - + void setUrlParams(usageTabPatch(tab))} + className="gap-3" + >
@@ -555,10 +599,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { )} {/* Organization Usage Panel */} - {usageView === "organization" && canViewOrganizationUsage && ( + {usageView === "organization" && ( = ({ teams, organizations }) => { = ({ teams, organizations }) => { = ({ teams, organizations }) => { = ({ teams, organizations }) => { = ({ teams, organizations }) => { = ({ teams, organizations }) => { {/* User Agent Activity Panel */} {usageView === "user-agent-activity" && ( void setUrlParams(entitySelectionPatch("tag", tags))} /> )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx index 42ae8407d3f..70cfcbc7950 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx @@ -104,6 +104,27 @@ const OPTIONS: OptionConfig[] = [ adminOnly: true, }, ]; +const visibleOptions = (userRole: string | null, canViewTagUsage: boolean, isOrgAdmin: boolean): OptionConfig[] => { + const isAdmin = all_admin_roles.includes(userRole ?? ""); + return OPTIONS.filter((option) => { + if (option.capability) { + return hasCapability(userRole, option.capability, isOrgAdmin); + } + if (option.value === "tag" && canViewTagUsage) { + return true; + } + return !(option.adminOnly && !isAdmin); + }); +}; + +export const USAGE_OPTIONS: readonly UsageOption[] = OPTIONS.map((option) => option.value); + +export const allowedUsageOptions = ( + userRole: string | null, + canViewTagUsage: boolean, + isOrgAdmin: boolean, +): readonly UsageOption[] => visibleOptions(userRole, canViewTagUsage, isOrgAdmin).map((option) => option.value); + export const UsageViewSelect: React.FC = ({ value, onChange, @@ -116,18 +137,7 @@ export const UsageViewSelect: React.FC = ({ }) => { const isAdmin = all_admin_roles.includes(userRole ?? ""); const getFilteredOptions = () => { - return OPTIONS.filter((option) => { - if (option.capability) { - return hasCapability(userRole, option.capability, isOrgAdmin); - } - if (option.value === "tag" && canViewTagUsage) { - return true; - } - if (option.adminOnly && !isAdmin) { - return false; - } - return true; - }).map((option) => { + return visibleOptions(userRole, canViewTagUsage, isOrgAdmin).map((option) => { let label = option.label; let desc = option.description; if (option.showForAdmin && option.showForNonAdmin) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UserRecordLink.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UserRecordLink.tsx new file mode 100644 index 00000000000..492a0f75686 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UserRecordLink.tsx @@ -0,0 +1,16 @@ +import { SquareArrowOutUpRight } from "lucide-react"; +import Link from "next/link"; + +import { userRecordHref } from "../usageUrlState"; + +export default function UserRecordLink({ userId }: { userId: string }) { + return ( + + View user record + + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts new file mode 100644 index 00000000000..dbb5c44cc35 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts @@ -0,0 +1,267 @@ +import moment from "moment"; +import { createLoader, createSerializer } from "nuqs"; +import { describe, expect, it } from "vitest"; + +import type { UsageOption } from "./components/UsageViewSelect/UsageViewSelect"; +import { USAGE_OPTIONS } from "./components/UsageViewSelect/UsageViewSelect"; +import { + cleanUsageUrl, + dateRangeFromParams, + dateRangePatch, + entitySelectionPatch, + selectedEntitiesFromParams, + usageHrefForUser, + usageTabFromParams, + usageTabPatch, + usageUrlParsers, + usageViewFromParams, + usageViewPatch, + type UsageUrlParams, + type UsageUrlPatch, +} from "./usageUrlState"; + +const load = createLoader(usageUrlParsers); +const serialize = createSerializer(usageUrlParsers); + +const applyPatch = (query: string, patch: UsageUrlPatch): string => serialize(query, patch); + +const withTimezone = (timezone: string, callback: () => T): T => { + const originalTimezone = process.env.TZ; + process.env.TZ = timezone; + try { + return callback(); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } +}; + +const adminAccess = { allowedViews: USAGE_OPTIONS, isAdmin: true }; +const internalUserAccess = { + allowedViews: ["global", "tag"] as readonly UsageOption[], + isAdmin: false, +}; + +const clean = (query: string, access = adminAccess): { query: string; deniedAccess: boolean } | null => { + const cleanup = cleanUsageUrl(load(query), access); + return cleanup && { query: applyPatch(query, cleanup.patch), deniedAccess: cleanup.deniedAccess }; +}; + +describe("usage view and tab", () => { + it("reads a known view and tab from the URL", () => { + const params = load("?view=team&tab=models"); + + expect(usageViewFromParams(params)).toBe("team"); + expect(usageTabFromParams(params)).toBe("models"); + }); + + it("falls back to the global view and cost tab when the URL names neither", () => { + const params = load("?view=bogus&tab=bogus"); + + expect(usageViewFromParams(params)).toBe("global"); + expect(usageTabFromParams(params)).toBe("cost"); + }); + + it("switching views drops the previous view's filters and tab but keeps the date range", () => { + expect(applyPatch("?user=u1&tab=keys&range=30d", usageViewPatch("team"))).toBe("?range=30d&view=team"); + expect(applyPatch("?view=team&team=t1", usageViewPatch("global"))).toBe(""); + }); + + it("round-trips the User Agent Activity view", () => { + const query = applyPatch("", usageViewPatch("user-agent-activity")); + + expect(query).toBe("?view=user-agent-activity"); + expect(usageViewFromParams(load(query))).toBe("user-agent-activity"); + }); + + it.each(["users", "errors"] as const)("round-trips the %s tab", (tab) => { + const query = applyPatch("", usageTabPatch(tab)); + + expect(query).toBe(`?tab=${tab}`); + expect(usageTabFromParams(load(query))).toBe(tab); + }); + + it("falls back from the admin-only Errors tab for an internal user", () => { + expect(usageTabFromParams(load("?tab=errors"), false)).toBe("cost"); + expect(clean("?tab=errors", internalUserAccess)).toEqual({ query: "", deniedAccess: false }); + }); + + it("keeps the default tab out of the URL", () => { + expect(applyPatch("?tab=models", usageTabPatch("cost"))).toBe(""); + expect(applyPatch("", usageTabPatch("mcp"))).toBe("?tab=mcp"); + }); +}); + +describe("entity selection", () => { + it("stores teams as repeated keys so ids with commas survive", () => { + const query = applyPatch("?view=tag", entitySelectionPatch("tag", ["Credential: a,b", "prod"])); + + expect(query).toBe("?view=tag&tag=Credential:+a,b&tag=prod"); + expect(selectedEntitiesFromParams(load(query), "tag")).toEqual(["Credential: a,b", "prod"]); + }); + + it("maps the organization view to the org key", () => { + const query = applyPatch("?view=organization", entitySelectionPatch("organization", ["o1"])); + + expect(query).toBe("?view=organization&org=o1"); + expect(selectedEntitiesFromParams(load(query), "organization")).toEqual(["o1"]); + }); + + it("stores the user view's selection in the single user key", () => { + const query = applyPatch("?view=user", entitySelectionPatch("user", ["u1"])); + + expect(query).toBe("?view=user&user=u1"); + expect(selectedEntitiesFromParams(load(query), "user")).toEqual(["u1"]); + }); + + it("keeps User Agent Activity tag filters in the URL", () => { + const query = applyPatch("?view=user-agent-activity", entitySelectionPatch("tag", ["User-Agent: Chrome/1.0"])); + + expect(query).toBe("?view=user-agent-activity&tag=User-Agent:+Chrome/1.0"); + expect(clean(query)).toBeNull(); + expect(selectedEntitiesFromParams(load(query), "tag")).toEqual(["User-Agent: Chrome/1.0"]); + }); + + it("removes the key once the selection is cleared", () => { + expect(applyPatch("?view=team&team=t1", entitySelectionPatch("team", []))).toBe("?view=team"); + expect(applyPatch("?view=user&user=u1", entitySelectionPatch("user", []))).toBe("?view=user"); + }); +}); + +describe("date range", () => { + it("stores a preset as a rolling range key", () => { + const lastThirtyDays = { from: moment().subtract(30, "days").startOf("day").toDate(), to: new Date() }; + + expect(applyPatch("?from=a&to=b", dateRangePatch(lastThirtyDays, "30d"))).toBe("?range=30d"); + }); + + it("stores a custom range matching today as explicit dates", () => { + const today = moment(); + const value = { from: today.clone().startOf("day").toDate(), to: today.clone().endOf("day").toDate() }; + + expect(dateRangePatch(value, null)).toEqual({ + range: null, + from: today.format("YYYY-MM-DD"), + to: today.format("YYYY-MM-DD"), + }); + }); + + it("stores an unknown preset label as explicit dates", () => { + const today = moment(); + const value = { from: today.clone().startOf("day").toDate(), to: today.clone().endOf("day").toDate() }; + + expect(dateRangePatch(value, "not-a-preset")).toEqual({ + range: null, + from: today.format("YYYY-MM-DD"), + to: today.format("YYYY-MM-DD"), + }); + }); + + it("keeps the custom calendar day when opened in a different timezone", () => { + const query = withTimezone("Australia/Sydney", () => { + const value = { + from: new Date(2026, 0, 5, 0, 0, 0, 0), + to: new Date(2026, 0, 5, 23, 59, 59, 999), + }; + + expect(value.from.getTimezoneOffset()).toBe(-660); + expect(moment(value.from).format("YYYY-MM-DD")).toBe("2026-01-05"); + expect(moment(value.to).format("YYYY-MM-DD")).toBe("2026-01-05"); + + const serialized = applyPatch("", dateRangePatch(value, null)); + expect(serialized).toBe("?from=2026-01-05&to=2026-01-05"); + return serialized; + }); + + withTimezone("America/New_York", () => { + expect(new Date(2026, 0, 5).getTimezoneOffset()).toBe(300); + const range = dateRangeFromParams(load(query)); + expect(range).toBeDefined(); + if (!range) return; + + expect(moment(range.from).format("YYYY-MM-DD")).toBe("2026-01-05"); + expect(moment(range.to).format("YYYY-MM-DD")).toBe("2026-01-05"); + }); + }); + + it("re-resolves a rolling range against the current day", () => { + const range = dateRangeFromParams(load("?range=7d")); + + expect(moment(range?.from).isSame(moment().subtract(7, "days"), "day")).toBe(true); + expect(moment(range?.to).isSame(moment(), "day")).toBe(true); + }); + + it("round-trips a custom range as whole local calendar days", () => { + const custom = { from: new Date(2026, 0, 5, 8, 0, 0, 0), to: new Date(2026, 0, 9, 20, 30, 0, 0) }; + const query = applyPatch("?range=7d", dateRangePatch(custom, null)); + + expect(query).toBe("?from=2026-01-05&to=2026-01-09"); + expect(dateRangeFromParams(load(query))).toEqual({ + from: new Date(2026, 0, 5, 0, 0, 0, 0), + to: new Date(2026, 0, 9, 23, 59, 59, 999), + }); + }); + + it.each([ + ["an unknown preset", "?range=90d"], + ["an ISO timestamp", "?from=2026-01-04T13:00:00.000Z&to=2026-01-05"], + ["an impossible date", "?from=2026-02-30&to=2026-03-01"], + ["a malformed date", "?from=yesterday&to=2026-01-09"], + ["a range that ends before it starts", "?from=2026-01-09&to=2026-01-05"], + ["only one end of a custom range", "?from=2026-01-05"], + ])("ignores %s", (_label, query) => { + expect(dateRangeFromParams(load(query))).toBeUndefined(); + }); +}); + +describe("cleanUsageUrl", () => { + it("leaves a valid URL alone", () => { + expect(clean("?view=team&team=t1&team=t2&range=30d")).toBeNull(); + expect(clean("?user=u1&tab=keys&from=2026-01-05&to=2026-01-09")).toBeNull(); + expect(clean("?view=tag&tag=prod", internalUserAccess)).toBeNull(); + }); + + it("silently drops params that name nothing real", () => { + expect(clean("?view=bogus&tab=bogus&range=90d")).toEqual({ query: "", deniedAccess: false }); + expect(clean("?from=yesterday&to=today&team=t1")).toEqual({ query: "", deniedAccess: false }); + }); + + it("drops a tab on a view that has no tabs", () => { + expect(clean("?view=team&tab=models")).toEqual({ query: "?view=team", deniedAccess: false }); + }); + + it("drops filters that belong to a different view", () => { + expect(clean("?view=team&team=t1&org=o1&user=u1")).toEqual({ query: "?view=team&team=t1", deniedAccess: false }); + expect(clean("?view=my-usage&user=u1")).toEqual({ query: "?view=my-usage", deniedAccess: false }); + }); + + it("prefers the preset when a URL carries both a preset and a custom range", () => { + expect(clean("?range=7d&from=2026-01-05&to=2026-01-09")).toEqual({ + query: "?range=7d", + deniedAccess: false, + }); + }); + + it("sends a viewer to the default view, keeping the dates, when the linked view is not theirs", () => { + expect(clean("?view=team&team=t1&range=30d", internalUserAccess)).toEqual({ + query: "?range=30d", + deniedAccess: true, + }); + }); + + it("denies another user's usage to a non-admin", () => { + expect(clean("?user=someone-else&tab=models", internalUserAccess)).toEqual({ query: "", deniedAccess: true }); + }); +}); + +describe("cross-page links", () => { + it("encodes the user id so it survives the round trip into the usage URL", () => { + const href = usageHrefForUser("alice+ops@example.com"); + const params: UsageUrlParams = load(href.slice(href.indexOf("?"))); + + expect(params.user).toBe("alice+ops@example.com"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts new file mode 100644 index 00000000000..a1cd04b723a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts @@ -0,0 +1,180 @@ +import moment from "moment"; +import { parseAsNativeArrayOf, parseAsString, type inferParserType } from "nuqs"; + +import type { EntityType } from "@/components/EntityUsageExport/types"; +import { relativeTimeOptions } from "@/components/shared/advanced_date_picker"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; +import { uiHref } from "@/utils/uiHref"; +import { USAGE_OPTIONS, type UsageOption } from "./components/UsageViewSelect/UsageViewSelect"; + +export const DEFAULT_USAGE_VIEW: UsageOption = "global"; +export const USAGE_TABS = ["cost", "models", "keys", "users", "mcp", "endpoints", "errors"] as const; +export type UsageTab = (typeof USAGE_TABS)[number]; +export const DEFAULT_USAGE_TAB: UsageTab = "cost"; + +const entityIds = parseAsNativeArrayOf(parseAsString); + +export const usageUrlParsers = { + view: parseAsString, + tab: parseAsString, + range: parseAsString, + from: parseAsString, + to: parseAsString, + user: parseAsString, + team: entityIds, + org: entityIds, + customer: entityIds, + tag: entityIds, + agent: entityIds, +}; + +export type UsageUrlParams = inferParserType; +export type UsageUrlPatch = { readonly [K in keyof UsageUrlParams]?: UsageUrlParams[K] | null }; + +type MultiEntityType = Exclude; +type EntityKey = "user" | (typeof MULTI_ENTITY_KEYS)[MultiEntityType]; + +const MULTI_ENTITY_KEYS = { + organization: "org", + team: "team", + customer: "customer", + tag: "tag", + agent: "agent", +} as const satisfies Record; + +const VIEW_ENTITY_KEYS: Readonly> = { + global: ["user"], + "my-usage": [], + organization: ["org"], + team: ["team"], + customer: ["customer"], + tag: ["tag"], + agent: ["agent"], + user: ["user"], + "user-agent-activity": ["tag"], +}; + +const TABBED_VIEWS: readonly UsageOption[] = ["global", "my-usage"]; + +const RESET_TO_DEFAULT_VIEW: UsageUrlPatch = { + view: null, + tab: null, + user: null, + team: null, + org: null, + customer: null, + tag: null, + agent: null, +}; + +const isUsageOption = (value: string | null): value is UsageOption => USAGE_OPTIONS.some((option) => option === value); + +const isUsageTab = (value: string | null): value is UsageTab => USAGE_TABS.some((tab) => tab === value); + +const hasEntityValue = (params: UsageUrlParams, key: EntityKey): boolean => + key === "user" ? params.user !== null : params[key].length > 0; + +export const usageViewFromParams = (params: UsageUrlParams): UsageOption => + isUsageOption(params.view) ? params.view : DEFAULT_USAGE_VIEW; + +export const usageTabFromParams = (params: UsageUrlParams, isAdmin = true): UsageTab => + isUsageTab(params.tab) && (params.tab !== "errors" || isAdmin) ? params.tab : DEFAULT_USAGE_TAB; + +export const usageViewPatch = (view: UsageOption): UsageUrlPatch => ({ + ...RESET_TO_DEFAULT_VIEW, + view: view === DEFAULT_USAGE_VIEW ? null : view, +}); + +export const usageTabPatch = (tab: string): UsageUrlPatch => ({ + tab: isUsageTab(tab) && tab !== DEFAULT_USAGE_TAB ? tab : null, +}); + +export const selectedEntitiesFromParams = (params: UsageUrlParams, entityType: EntityType): readonly string[] => { + if (entityType === "user") return params.user === null ? [] : [params.user]; + return params[MULTI_ENTITY_KEYS[entityType]]; +}; + +export const entitySelectionPatch = (entityType: EntityType, ids: readonly string[]): UsageUrlPatch => { + if (entityType === "user") return { user: ids[0] ?? null }; + return { [MULTI_ENTITY_KEYS[entityType]]: ids.length > 0 ? [...ids] : null }; +}; + +const customRangeFromParams = (from: string, to: string): DateRangePickerValue | undefined => { + const fromDate = moment(from, "YYYY-MM-DD", true); + const toDate = moment(to, "YYYY-MM-DD", true); + if (!fromDate.isValid() || !toDate.isValid() || fromDate.isAfter(toDate, "day")) return undefined; + return { from: fromDate.startOf("day").toDate(), to: toDate.endOf("day").toDate() }; +}; + +type DateParams = Pick; + +export const dateRangeFromParams = (params: DateParams): DateRangePickerValue | undefined => { + if (params.range !== null) { + return relativeTimeOptions.find((option) => option.shortLabel === params.range)?.getValue(); + } + if (params.from === null || params.to === null) return undefined; + return customRangeFromParams(params.from, params.to); +}; + +export const dateRangePatch = (value: DateRangePickerValue, presetShortLabel: string | null): UsageUrlPatch => { + if (presetShortLabel !== null && relativeTimeOptions.some((option) => option.shortLabel === presetShortLabel)) { + return { range: presetShortLabel, from: null, to: null }; + } + if (!value.from || !value.to) return { range: null, from: null, to: null }; + return { + range: null, + from: moment(value.from).format("YYYY-MM-DD"), + to: moment(value.to).format("YYYY-MM-DD"), + }; +}; + +const invalidDatePatch = (params: DateParams): UsageUrlPatch => { + const hasCustomRange = params.from !== null || params.to !== null; + if (params.range !== null) { + const rangeIsPreset = relativeTimeOptions.some((option) => option.shortLabel === params.range); + return { + ...(rangeIsPreset ? {} : { range: null }), + ...(hasCustomRange ? { from: null, to: null } : {}), + }; + } + if (!hasCustomRange || dateRangeFromParams(params)) return {}; + return { from: null, to: null }; +}; + +export interface UsageUrlAccess { + readonly allowedViews: readonly UsageOption[]; + readonly isAdmin: boolean; +} + +export interface UsageUrlCleanup { + readonly patch: UsageUrlPatch; + readonly deniedAccess: boolean; +} + +export const cleanUsageUrl = (params: UsageUrlParams, access: UsageUrlAccess): UsageUrlCleanup | null => { + const view = usageViewFromParams(params); + const deniedView = !access.allowedViews.includes(view); + const deniedUserFilter = view === "global" && params.user !== null && !access.isAdmin; + if (deniedView || deniedUserFilter) { + return { patch: { ...RESET_TO_DEFAULT_VIEW, ...invalidDatePatch(params) }, deniedAccess: true }; + } + + const viewKeys = VIEW_ENTITY_KEYS[view]; + const strayEntityKeys = (["user", "team", "org", "customer", "tag", "agent"] as const).filter( + (key) => !viewKeys.includes(key) && hasEntityValue(params, key), + ); + const invalidTab = + params.tab !== null && + (!TABBED_VIEWS.includes(view) || !isUsageTab(params.tab) || (!access.isAdmin && params.tab === "errors")); + const patch: UsageUrlPatch = { + ...(params.view !== null && !isUsageOption(params.view) ? { view: null } : {}), + ...(invalidTab ? { tab: null } : {}), + ...Object.fromEntries(strayEntityKeys.map((key) => [key, null])), + ...invalidDatePatch(params), + }; + return Object.keys(patch).length > 0 ? { patch, deniedAccess: false } : null; +}; + +export const usageHrefForUser = (userId: string): string => uiHref(`usage?${new URLSearchParams({ user: userId })}`); + +export const userRecordHref = (userId: string): string => uiHref(`users?${new URLSearchParams({ user: userId })}`); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 5b98ca82c16..1dbab3b1803 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -17,7 +17,7 @@ import { import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; -import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; +import { all_admin_roles, isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -298,6 +298,7 @@ const ViewUserDashboard: React.FC = ({ isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData} possibleUIRoles={possibleUIRoles} teams={teams} + canViewUserUsage={all_admin_roles.includes(userRole ?? "")} sorting={sorting} onSortingChange={handleSortingChange} pagination={pagination} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx index 16911946d34..b9ed37a2152 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx @@ -36,6 +36,7 @@ interface HarnessOverrides { rowCount?: number; isLoading?: boolean; selectionEnabled?: boolean; + canViewUserUsage?: boolean; onUserClick?: (userId: string, openInEditMode?: boolean) => void; onDeleteUser?: (user: UserInfo) => void; onResetPassword?: (userId: string) => void; @@ -51,6 +52,7 @@ function Harness({ rowCount = 1, isLoading = false, selectionEnabled = false, + canViewUserUsage = true, onUserClick = vi.fn(), onDeleteUser = vi.fn(), onResetPassword = vi.fn(), @@ -74,6 +76,7 @@ function Harness({ isLoading={isLoading} possibleUIRoles={possibleUIRoles} teams={[]} + canViewUserUsage={canViewUserUsage} sorting={sorting} onSortingChange={(updater) => { setSorting(updater); @@ -178,6 +181,27 @@ describe("UsersTable", () => { expect(onUserClick).toHaveBeenCalledWith("user-1", true); }); + it("links the row menu to that user's usage when allowed", async () => { + const user = userEvent.setup(); + render(); + + await openRowMenu(user, "user-1"); + + expect(await screen.findByRole("menuitem", { name: "View usage" })).toHaveAttribute( + "href", + "/ui/usage?user=user-1", + ); + }); + + it("hides the usage link when the viewer cannot view usage", async () => { + const user = userEvent.setup(); + render(); + + await openRowMenu(user, "user-1"); + + expect(screen.queryByRole("menuitem", { name: "View usage" })).not.toBeInTheDocument(); + }); + it("delegates delete and reset-password from the row menu", async () => { const user = userEvent.setup(); const onDeleteUser = vi.fn(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx index 8fa76a64af6..3e266ef482d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx @@ -33,6 +33,7 @@ interface UsersTableProps { isLoading: boolean; possibleUIRoles: Record> | null; teams: UsersTableTeamOption[] | null; + canViewUserUsage: boolean; sorting: SortingState; onSortingChange: OnChangeFn; pagination: PaginationState; @@ -74,6 +75,7 @@ export function UsersTable({ isLoading, possibleUIRoles, teams, + canViewUserUsage, sorting, onSortingChange, pagination, @@ -95,12 +97,13 @@ export function UsersTable({ const columnDeps = { possibleUIRoles, includeSelection: selectionEnabled, + canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, }; return getUsersTableColumns(columnDeps); - }, [possibleUIRoles, selectionEnabled, onUserClick, onDeleteUser, onResetPassword]); + }, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword]); const roleOptions = useMemo( () => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx index d14e2e0f4b1..ad0cdbbd9c1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx @@ -1,7 +1,8 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { ChartColumn, Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import Link from "next/link"; import { UserInfo } from "@/components/networking"; import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable"; @@ -16,6 +17,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; +import { usageHrefForUser } from "@/app/(dashboard)/usage/_components/usageUrlState"; import { copyToClipboard } from "@/utils/dataUtils"; const SSO_ID_HINT = @@ -29,12 +31,13 @@ function isScimInactive(user: UserInfo): boolean { interface UserRowActionsProps { user: UserInfo; + canViewUserUsage: boolean; onUserClick: (userId: string, openInEditMode?: boolean) => void; onDeleteUser: (user: UserInfo) => void; onResetPassword: (userId: string) => void; } -function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) { +function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) { return ( Copy user ID + {canViewUserUsage && ( + } + data-testid="user-action-view-usage" + > + + View usage + + )} onDeleteUser(user)} data-testid="user-action-delete"> @@ -73,6 +85,7 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us export interface UsersTableColumnsDeps { possibleUIRoles: Record> | null; includeSelection: boolean; + canViewUserUsage: boolean; onUserClick: (userId: string, openInEditMode?: boolean) => void; onDeleteUser: (user: UserInfo) => void; onResetPassword: (userId: string) => void; @@ -81,6 +94,7 @@ export interface UsersTableColumnsDeps { export const getUsersTableColumns = ({ possibleUIRoles, includeSelection, + canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, @@ -257,6 +271,7 @@ export const getUsersTableColumns = ({
= ({ dateRange, selectedFilters }) => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index f6940286051..58373eb08e4 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -27,7 +27,7 @@ interface UsageExportHeaderProps { showFilters?: boolean; filterLabel?: string; filterPlaceholder?: string; - selectedFilters?: string[]; + selectedFilters?: readonly string[]; onFiltersChange?: (filters: string[]) => void; filterOptions?: Array<{ label: string; value: string }>; filterSlot?: React.ReactNode; @@ -81,7 +81,7 @@ const UsageExportHeader: React.FC = ({ multiple disabled={isFilterDisabled} items={optionValues} - value={selectedFilters} + value={[...selectedFilters]} onValueChange={(next: string[]) => onFiltersChange?.(next)} > } className="w-full"> diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 189a14392ec..c2b42e58c80 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -11,7 +11,7 @@ export interface EntityUsageExportModalProps { entityType: EntityType; onExport: (exportType: ExportType, format: ExportFormat) => Promise; dateRange: DateRangePickerValue; - selectedFilters?: string[]; + selectedFilters?: readonly string[]; customTitle?: string; teams?: Team[]; } diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx index e27aab717ef..b56acfca86b 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx @@ -4,7 +4,7 @@ import type { SearchSelectOption } from "@/components/shared/SearchSelect"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; interface TeamMultiSelectProps { - value?: string[]; + value?: readonly string[]; onChange?: (value: string[]) => void; disabled?: boolean; organizationId?: string | null; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 69623d05232..0f7d76a7156 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1465,7 +1465,7 @@ export const teamSpendByUserCall = async ( accessToken: string, startTime: Date, endTime: Date, - teamIds: string[], + teamIds: readonly string[], ): Promise => apiClient.get(`/team/spend/by_user`, { accessToken, diff --git a/ui/litellm-dashboard/src/components/per_user_usage.tsx b/ui/litellm-dashboard/src/components/per_user_usage.tsx index f600cd6c45d..a7c6a5cf504 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.tsx @@ -26,7 +26,7 @@ interface PerUserAnalyticsResponse { interface PerUserUsageProps { accessToken: string | null; - selectedTags: string[]; + selectedTags: readonly string[]; formatAbbreviatedNumber: (value: number, decimalPlaces?: number) => string; } @@ -42,7 +42,7 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, }); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); - const [pagedTags, setPagedTags] = useState(selectedTags); + const [pagedTags, setPagedTags] = useState(selectedTags); if (pagedTags !== selectedTags) { setPagedTags(selectedTags); @@ -57,7 +57,7 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, accessToken, pagination.pageIndex + 1, pagination.pageSize, - pagedTags.length > 0 ? pagedTags : undefined, + pagedTags.length > 0 ? [...pagedTags] : undefined, ) .then((response) => { if (stale) return; diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx index 502078e03a9..9c96f60dee2 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx @@ -22,7 +22,7 @@ import { usePaginatedCombobox } from "./usePaginatedCombobox"; interface PaginatedMultiSelectProps { options: SearchSelectOption[]; - value?: string[]; + value?: readonly string[]; onValueChange: (value: string[]) => void; onSearchChange: (query: string) => void; onLoadMore: () => void; diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx index 78e258d1a69..104eed54b2f 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx @@ -1,5 +1,6 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import moment from "moment"; import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; import AdvancedDatePicker from "./advanced_date_picker"; @@ -166,6 +167,82 @@ describe("AdvancedDatePicker", () => { }); }); + it("passes the clicked preset label when applying", async () => { + const { container } = render(); + + openDropdown(container); + fireEvent.click(screen.getByText("Today")); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "today"); + await waitFor(() => { + expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "today"); + }); + }); + + it("passes a null preset label when typed dates match today", async () => { + const { container } = render(); + const today = moment().format("YYYY-MM-DD"); + + openDropdown(container); + fireEvent.change(screen.getByDisplayValue("2025-01-01"), { target: { value: today } }); + fireEvent.change(screen.getByDisplayValue("2025-01-31"), { target: { value: today } }); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), null); + await waitFor(() => { + expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), null); + }); + }); + + it("resets the preset label when the parent switches to a custom range", async () => { + const { container, rerender } = render( + , + ); + + openDropdown(container); + fireEvent.click(screen.getByText("Today")); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "today"); + await waitFor(() => { + expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "today"); + }); + + mockOnValueChange.mockClear(); + const customValue = { + from: new Date("2025-02-01T00:00:00.000Z"), + to: new Date("2025-02-10T23:59:59.999Z"), + }; + rerender(); + + openDropdown(container); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), null); + await waitFor(() => { + expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), null); + }); + }); + + it("preserves the parent preset label when applying an unchanged range", async () => { + const value = { + from: moment().subtract(30, "days").startOf("day").toDate(), + to: moment().endOf("day").toDate(), + }; + const { container } = render( + , + ); + + openDropdown(container); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "30d"); + await waitFor(() => { + expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "30d"); + }); + }); + it("should select relative time option", () => { const { container } = render(); diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 190e3de4a82..513db07409c 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -7,20 +7,21 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; interface AdvancedDatePickerProps { value: DateRangePickerValue; - onValueChange: (value: DateRangePickerValue) => void; + onValueChange: (value: DateRangePickerValue, presetShortLabel: string | null) => void; + presetShortLabel?: string | null; label?: string; className?: string; showTimeRange?: boolean; align?: "left" | "right"; } -interface RelativeTimeOption { +export interface RelativeTimeOption { label: string; shortLabel: string; getValue: () => { from: Date; to: Date }; } -const relativeTimeOptions: RelativeTimeOption[] = [ +export const relativeTimeOptions: readonly RelativeTimeOption[] = [ { label: "Today", shortLabel: "today", @@ -63,12 +64,22 @@ const relativeTimeOptions: RelativeTimeOption[] = [ }, ]; +export function matchRelativeTimeOption(value: DateRangePickerValue): RelativeTimeOption | undefined { + const { from, to } = value; + if (!from || !to) return undefined; + return relativeTimeOptions.find((option) => { + const optionRange = option.getValue(); + return moment(from).isSame(optionRange.from, "day") && moment(to).isSame(optionRange.to, "day"); + }); +} + /** * Advanced Date Range Picker with dropdown, relative times, and custom inputs */ const AdvancedDatePicker: React.FC = ({ value, onValueChange, + presetShortLabel = null, label = "Select Time Range", className, showTimeRange = true, @@ -77,6 +88,7 @@ const AdvancedDatePicker: React.FC = ({ const [isOpen, setIsOpen] = useState(false); const [tempValue, setTempValue] = useState(value); const [selectedOption, setSelectedOption] = useState(null); + const [pickedPreset, setPickedPreset] = useState(null); // Custom date inputs only - removed time inputs const [startDate, setStartDate] = useState(""); @@ -84,24 +96,10 @@ const AdvancedDatePicker: React.FC = ({ const dropdownRef = useRef(null); - // Function to check if current value matches a relative time option - const getMatchingOption = useCallback((currentValue: DateRangePickerValue): string | null => { - if (!currentValue.from || !currentValue.to) return null; - - for (const option of relativeTimeOptions) { - const optionRange = option.getValue(); - - // Compare dates with some tolerance (to account for time differences) - const fromMatches = moment(currentValue.from).isSame(moment(optionRange.from), "day"); - const toMatches = moment(currentValue.to).isSame(moment(optionRange.to), "day"); - - if (fromMatches && toMatches) { - return option.shortLabel; - } - } - - return null; - }, []); + const getMatchingOption = useCallback( + (currentValue: DateRangePickerValue): string | null => matchRelativeTimeOption(currentValue)?.shortLabel ?? null, + [], + ); // Update selected option when value changes useEffect(() => { @@ -140,7 +138,8 @@ const AdvancedDatePicker: React.FC = ({ setEndDate(moment(value.to).format("YYYY-MM-DD")); } setTempValue(value); - }, [value]); + setPickedPreset(presetShortLabel ?? null); + }, [value, presetShortLabel]); // Close dropdown when clicking outside useEffect(() => { @@ -206,6 +205,7 @@ const AdvancedDatePicker: React.FC = ({ // Update local state to reflect the selection (don't apply immediately) setTempValue(newValue); setSelectedOption(option.shortLabel); + setPickedPreset(option.shortLabel); // Update the form inputs to reflect the selection setStartDate(moment(from).format("YYYY-MM-DD")); @@ -243,13 +243,13 @@ const AdvancedDatePicker: React.FC = ({ const handleApply = () => { if (tempValue.from && tempValue.to && validation.isValid) { // First call with immediate value for UI responsiveness - onValueChange(tempValue); + onValueChange(tempValue, pickedPreset); // Then do the same background adjustment logic as the original component requestIdleCallback( () => { const adjustedValue = adjustDateRange(tempValue); - onValueChange(adjustedValue); + onValueChange(adjustedValue, pickedPreset); }, { timeout: 100 }, ); @@ -273,6 +273,7 @@ const AdvancedDatePicker: React.FC = ({ // Reset selected option const matchingOption = getMatchingOption(value); setSelectedOption(matchingOption); + setPickedPreset(presetShortLabel ?? null); setIsOpen(false); }; @@ -367,7 +368,10 @@ const AdvancedDatePicker: React.FC = ({ setStartDate(e.target.value)} + onChange={(e) => { + setPickedPreset(null); + setStartDate(e.target.value); + }} className={`w-65 px-3 py-2 text-sm border rounded-md cursor-pointer hover:border-ring focus:border-info focus:ring-1 focus:ring-ring ${ !validation.isValid ? "border-destructive/30 focus:border-destructive focus:ring-red-200" @@ -382,7 +386,10 @@ const AdvancedDatePicker: React.FC = ({ setEndDate(e.target.value)} + onChange={(e) => { + setPickedPreset(null); + setEndDate(e.target.value); + }} className={`w-65 px-3 py-2 text-sm border rounded-md cursor-pointer hover:border-ring focus:border-info focus:ring-1 focus:ring-ring ${ !validation.isValid ? "border-destructive/30 focus:border-destructive focus:ring-red-200" diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx index 0ea84dea970..9d5edd7e2cc 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { STACKED_USAGE_PALETTE } from "@/components/shared/charts"; import UserAgentActivity from "./user_agent_activity"; @@ -11,11 +12,7 @@ vi.mock("./networking", () => ({ tagWauCall: vi.fn(), tagMauCall: vi.fn(), tagDistinctCall: vi.fn(), -})); - -// Mock PerUserUsage component -vi.mock("./per_user_usage", () => ({ - default: () =>
Per User Usage
, + perUserAnalyticsCall: vi.fn(), })); describe("UserAgentActivity", () => { @@ -24,6 +21,7 @@ describe("UserAgentActivity", () => { const mockTagWauCall = vi.mocked(networking.tagWauCall); const mockTagMauCall = vi.mocked(networking.tagMauCall); const mockTagDistinctCall = vi.mocked(networking.tagDistinctCall); + const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall); const mockDistinctTagsData = { results: [{ tag: "User-Agent: Chrome/1.0" }, { tag: "User-Agent: Firefox/2.0" }, { tag: "User-Agent: Safari/3.0" }], @@ -102,12 +100,20 @@ describe("UserAgentActivity", () => { mockTagWauCall.mockClear(); mockTagMauCall.mockClear(); mockTagDistinctCall.mockClear(); + mockPerUserAnalyticsCall.mockClear(); mockTagDistinctCall.mockResolvedValue(mockDistinctTagsData); mockUserAgentSummaryCall.mockResolvedValue(mockSummaryData); mockTagDauCall.mockResolvedValue(mockDauData); mockTagWauCall.mockResolvedValue(mockWauData); mockTagMauCall.mockResolvedValue(mockMauData); + mockPerUserAnalyticsCall.mockResolvedValue({ + results: [], + total_count: 0, + page: 1, + page_size: 50, + total_pages: 0, + }); }); it("should render summary cards with user agent data", async () => { @@ -185,6 +191,70 @@ describe("UserAgentActivity", () => { expect(prompts).toBeGreaterThan(0); }); + it("uses and reports a controlled tag selection", async () => { + const onSelectedTagsChange = vi.fn(); + const selectedTags = ["User-Agent: Chrome/1.0"]; + + render( + , + ); + + await waitFor(() => { + expect(mockTagDauCall).toHaveBeenCalledWith("test-token", expect.any(Date), undefined, selectedTags); + }); + fireEvent.click(await screen.findByRole("button", { name: "Clear user agent filter" })); + + expect(onSelectedTagsChange).toHaveBeenCalledWith([]); + }); + + it("keeps per-user pagination when controlled tags stay unchanged on parent rerender", async () => { + const user = userEvent.setup(); + const selectedTags = ["User-Agent: Chrome/1.0"]; + const onSelectedTagsChange = vi.fn(); + const totalUsers = 120; + const pageOfUsers = (page: number, pageSize: number) => { + const start = (page - 1) * pageSize; + const count = Math.max(0, Math.min(pageSize, totalUsers - start)); + return Array.from({ length: count }, (_, index) => ({ + user_id: `user-${start + index + 1}`, + user_email: null, + user_agent: "Chrome/1.0", + successful_requests: 5, + failed_requests: 0, + total_requests: 5, + total_tokens: 100, + spend: 1, + })); + }; + mockPerUserAnalyticsCall.mockImplementation(async (_token, page = 1, pageSize = 50) => ({ + results: pageOfUsers(page, pageSize), + total_count: totalUsers, + page, + page_size: pageSize, + total_pages: Math.ceil(totalUsers / pageSize), + })); + const props = { ...defaultProps, selectedTags, onSelectedTagsChange }; + const { rerender } = render(); + + await waitFor(() => expect(mockTagDistinctCall).toHaveBeenCalled()); + await waitFor(() => expect(mockUserAgentSummaryCall).toHaveBeenCalled()); + await waitFor(() => expect(mockTagDauCall).toHaveBeenCalled()); + await waitFor(() => expect(mockTagWauCall).toHaveBeenCalled()); + await waitFor(() => expect(mockTagMauCall).toHaveBeenCalled()); + await user.click(screen.getByRole("radio", { name: "Per user" })); + expect(await screen.findByText("user-1")).toBeInTheDocument(); + await user.click(screen.getByTestId("pagination-next")); + expect(await screen.findByText("user-51")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-100 of 120"); + + const callsBeforeRerender = mockPerUserAnalyticsCall.mock.calls.length; + rerender(); + + expect(mockPerUserAnalyticsCall).toHaveBeenCalledTimes(callsBeforeRerender); + expect(screen.getByText("user-51")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-100 of 120"); + }); + // Walks up from the panel's heading to the nearest ancestor that owns a chart, so the // assertions do not depend on how many wrappers the tab library puts around a panel. const chartForTitle = (title: string): HTMLElement => { diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index ca318bfb4fe..37b320e2b5c 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -123,6 +123,8 @@ interface UserAgentActivityProps { onDateChange?: (value: DateRangePickerValue) => void; // Optional - not used anymore /** Tags to pre-select, e.g. an agent's user-agent tags when opened from the Overview's Top agents. */ initialTags?: readonly string[]; + selectedTags?: readonly string[]; + onSelectedTagsChange?: (tags: readonly string[]) => void; } const UserAgentActivity: React.FC = ({ @@ -131,6 +133,8 @@ const UserAgentActivity: React.FC = ({ dateValue, onDateChange, initialTags, + selectedTags: selectedTagsProp, + onSelectedTagsChange, }) => { const anchor = useComboboxAnchor(); // Maximum number of categories to show in charts to prevent color palette overflow @@ -146,7 +150,15 @@ const UserAgentActivity: React.FC = ({ // Tag filtering state const [availableTags, setAvailableTags] = useState([]); - const [selectedTags, setSelectedTags] = useState(() => [...(initialTags ?? [])]); + const [localSelectedTags, setLocalSelectedTags] = useState(() => [...(initialTags ?? [])]); + const selectedTags = selectedTagsProp ?? localSelectedTags; + const updateSelectedTags = (next: string[]) => { + if (onSelectedTagsChange) { + onSelectedTagsChange(next); + return; + } + setLocalSelectedTags(next); + }; const [tagsLoading, setTagsLoading] = useState(false); // Separate loading states for each endpoint @@ -184,7 +196,7 @@ const UserAgentActivity: React.FC = ({ accessToken, today, userAgentFilter || undefined, - selectedTags.length > 0 ? selectedTags : undefined, + selectedTags.length > 0 ? [...selectedTags] : undefined, ); setDauData(data); } catch (error) { @@ -203,7 +215,7 @@ const UserAgentActivity: React.FC = ({ accessToken, today, userAgentFilter || undefined, - selectedTags.length > 0 ? selectedTags : undefined, + selectedTags.length > 0 ? [...selectedTags] : undefined, ); setWauData(data); } catch (error) { @@ -222,7 +234,7 @@ const UserAgentActivity: React.FC = ({ accessToken, today, userAgentFilter || undefined, - selectedTags.length > 0 ? selectedTags : undefined, + selectedTags.length > 0 ? [...selectedTags] : undefined, ); setMauData(data); } catch (error) { @@ -241,7 +253,7 @@ const UserAgentActivity: React.FC = ({ accessToken, dateValue.from, dateValue.to, - selectedTags.length > 0 ? selectedTags : undefined, + selectedTags.length > 0 ? [...selectedTags] : undefined, ); setSummaryData(summary); } catch (error) { @@ -455,12 +467,7 @@ const UserAgentActivity: React.FC = ({ action={
- setSelectedTags(next)} - > + } className="min-w-0 flex-1" aria-busy={tagsLoading}> {(selected: string[]) => diff --git a/ui/litellm-dashboard/src/lib/toast.test.ts b/ui/litellm-dashboard/src/lib/toast.test.ts index 0afd0ec2cc4..b4a776f123a 100644 --- a/ui/litellm-dashboard/src/lib/toast.test.ts +++ b/ui/litellm-dashboard/src/lib/toast.test.ts @@ -37,6 +37,11 @@ describe("toast", () => { expect(sonner.success).toHaveBeenCalledWith("saved", { description: "Model x", duration: 1500 }); }); + it("passes an id through so repeated toasts collapse into one", () => { + toast.warning("no access", { id: "denied" }); + expect(sonner.warning).toHaveBeenCalledWith("no access", expect.objectContaining({ id: "denied" })); + }); + it("dismiss clears every toast", () => { toast.dismiss(); expect(sonner.dismiss).toHaveBeenCalledWith(); diff --git a/ui/litellm-dashboard/src/lib/toast.ts b/ui/litellm-dashboard/src/lib/toast.ts index a057d144e6f..a7a4ce45ad0 100644 --- a/ui/litellm-dashboard/src/lib/toast.ts +++ b/ui/litellm-dashboard/src/lib/toast.ts @@ -7,6 +7,7 @@ export type ToastKind = "success" | "info" | "warning" | "error"; export type ToastOptions = { readonly description?: ReactNode; readonly durationMs?: number; + readonly id?: string; }; type ErrorFacts = { @@ -127,6 +128,7 @@ const titleFor = ({ status, proxyType }: ErrorFacts): string => { const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => { sonner[kind](message, { + id: options?.id, description: options?.description, duration: options?.durationMs ?? DEFAULT_DURATION_MS[kind], }); diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx index b3cd45a15f0..31e276914b0 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -26,12 +26,18 @@ export const testQueryClient = new QueryClient({ interface ProviderOptions { searchParams?: string | Record | URLSearchParams; onUrlUpdate?: OnUrlUpdateFunction; + resetUrlUpdateQueueOnMount?: boolean; } export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions & ProviderOptions) => { - const { searchParams, onUrlUpdate, ...renderOptions } = options ?? {}; + const { searchParams, onUrlUpdate, resetUrlUpdateQueueOnMount, ...renderOptions } = options ?? {}; const Providers: React.FC = ({ children }) => ( - + {children}