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}