diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index daf12d11743..b2eec2b02d3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1140,7 +1140,7 @@ "count": 1 }, "react-hooks/set-state-in-effect": { - "count": 3 + "count": 2 } }, "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 8f5bb9455d0..56537e36ef4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReactNode } from "react"; +import { useState, type ComponentProps, type ReactNode } from "react"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import * as networking from "@/components/networking"; @@ -9,6 +9,13 @@ import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from import EntityUsage from "./EntityUsage"; import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations"; +type EntityUsageProps = ComponentProps; + +function StatefulEntityUsage(props: Omit) { + const [selectedEntities, setSelectedEntities] = useState([]); + return ; +} + const emptySpendMetrics: SpendMetrics = { spend: 0, prompt_tokens: 0, @@ -94,7 +101,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", () => ({ @@ -513,7 +520,7 @@ describe("EntityUsage", () => { }); it("should render with tag entity type and display spend metrics", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -531,7 +538,7 @@ describe("EntityUsage", () => { }); it("should render with team entity type and call team API", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -554,12 +561,12 @@ describe("EntityUsage", () => { teamsResult([{ team_id: "team-alpha" }, { team_id: "litellm-dashboard" }, { team_id: "team-beta" }]), ); - render(); + render(); expect(await screen.findByText("team-user-spend:team-alpha|team-beta")).toBeInTheDocument(); cleanup(); mockUseTeams.mockReturnValue(teamsResult([])); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); }); @@ -567,7 +574,7 @@ describe("EntityUsage", () => { }); it("should render with organization entity type and call organization API", async () => { - render(); + render(); await waitFor(() => { expect(mockOrganizationDailyActivityCall).toHaveBeenCalled(); @@ -582,7 +589,7 @@ describe("EntityUsage", () => { }); it("should render with customer entity type and call customer API", async () => { - render(); + render(); await waitFor(() => { expect(mockCustomerDailyActivityCall).toHaveBeenCalled(); @@ -597,7 +604,7 @@ describe("EntityUsage", () => { }); it("should render with agent entity type and call agent API", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -612,7 +619,7 @@ describe("EntityUsage", () => { }); it("should render with user entity type and call user API", async () => { - render(); + render(); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalled(); @@ -627,7 +634,7 @@ describe("EntityUsage", () => { }); it("should switch between tabs", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -676,7 +683,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(); @@ -702,7 +709,7 @@ describe("EntityUsage", () => { ]; it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -733,7 +740,7 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockResolvedValue(emptyData); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -746,7 +753,7 @@ describe("EntityUsage", () => { }); it("should display Model Activity tab for non-agent entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -756,7 +763,7 @@ describe("EntityUsage", () => { }); it("should display Request / Token Consumption tab for agent entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -766,7 +773,7 @@ describe("EntityUsage", () => { }); it("should display Top Public Model Names title for non-agent entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -776,7 +783,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(); @@ -802,7 +809,7 @@ describe("EntityUsage", () => { }); it("should display Top Agents title for agent entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalled(); @@ -817,7 +824,7 @@ describe("EntityUsage", () => { { label: "Tag 2", value: "tag-2" }, ]; - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -831,7 +838,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(); @@ -843,7 +850,7 @@ describe("EntityUsage", () => { }); it("should fallback to team_alias when entityList is null", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -855,7 +862,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(); @@ -865,7 +872,7 @@ describe("EntityUsage", () => { }); it("should not request the filter while the entity list is still unresolved", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -875,7 +882,7 @@ describe("EntityUsage", () => { }); it("should display Agent Activity tab for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -885,7 +892,7 @@ describe("EntityUsage", () => { }); it("should not display Agent Activity tab for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -895,7 +902,7 @@ describe("EntityUsage", () => { }); it("should display Top Agents Driving Spend card for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -905,7 +912,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(); @@ -915,7 +922,7 @@ describe("EntityUsage", () => { }); it("should fetch agent activity data when entity type is team", async () => { - render(); + render(); await waitFor(() => { expect(mockAgentDailyActivityCall).toHaveBeenCalledWith( @@ -929,7 +936,7 @@ describe("EntityUsage", () => { }); it("should not fetch agent activity data for non-team entity types", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -939,7 +946,7 @@ describe("EntityUsage", () => { }); it("should switch to Agent Activity tab for team entity type", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -976,7 +983,7 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias); - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -988,7 +995,7 @@ describe("EntityUsage", () => { }); it("renders daily spend bars, per-entity bars, and the provider donut with cyan fills and a $ center total", async () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1037,7 +1044,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(); @@ -1050,7 +1057,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")); @@ -1061,7 +1068,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(); }); @@ -1069,7 +1076,7 @@ describe("EntityUsage", () => { cleanup(); call().mockClear(); - render(); + render(); expect(await screen.findByText(heading)).toBeInTheDocument(); expect(call()).not.toHaveBeenCalled(); }); @@ -1082,7 +1089,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(() => { @@ -1095,7 +1104,7 @@ describe("EntityUsage", () => { }); it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -1108,7 +1117,7 @@ describe("EntityUsage", () => { }); it("keeps the tag breakdown for an internal user", async () => { - render(); + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -1134,7 +1143,7 @@ describe("EntityUsage", () => { }; mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider); - render(); + render(); await waitFor(() => { expect(screen.getAllByText("zzz-internal").length).toBeGreaterThan(0); @@ -1174,7 +1183,7 @@ describe("EntityUsage", () => { ], }); - render(); + render(); await waitFor(() => { expect(screen.getByText("top-keys:sk-abc=30.75=alice@example.com")).toBeInTheDocument(); @@ -1184,7 +1193,7 @@ describe("EntityUsage", () => { }); it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => { - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); @@ -1199,7 +1208,7 @@ describe("EntityUsage", () => { it("falls back to the paginated team endpoint when the aggregated call fails", async () => { mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - render(); + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -1215,12 +1224,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 6687bd4df03..4d87b13f285 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -23,6 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import React, { type ReactNode, 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 { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; @@ -89,6 +90,8 @@ interface EntityUsageProps { premiumUser: boolean; dateValue: DateRangePickerValue; isOrgAdmin?: boolean; + selectedEntities: readonly string[]; + onSelectedEntitiesChange: (ids: readonly string[]) => void; } const ENTITY_FETCH_FNS: Record Promise> = { @@ -119,9 +122,10 @@ const EntityUsage: React.FC = ({ userRole, dateValue, isOrgAdmin = false, + selectedEntities: selectedTags, + onSelectedEntitiesChange: setSelectedTags, }) => { const { teams } = useTeams(); - const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); @@ -274,7 +278,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 ed90e144efb..69f545a727c 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 @@ -24,7 +24,7 @@ interface TeamUserSpendCardProps { accessToken: string | null; startTime: Date | null; endTime: Date | null; - teamIds: string[]; + teamIds: readonly string[]; } const columns: ColumnDef[] = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 7702488f5bf..90d2dddabdc 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,11 +7,15 @@ import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UrlUpdateEvent } from "nuqs/adapters/testing"; import { renderWithProviders } from "@/../tests/test-utils"; +import { toast } from "@/lib/toast"; import type { Organization } from "@/components/networking"; import * as networking from "@/components/networking"; import UsagePage from "./UsagePageView"; +const mockEntityUsageMounted = vi.hoisted(() => vi.fn()); + // Polyfill ResizeObserver for test environment beforeAll(() => { if (typeof window !== "undefined" && !window.ResizeObserver) { @@ -47,14 +51,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
, @@ -64,7 +92,8 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({ default: () =>
Endpoint Usage
, })); -vi.mock("./UsageViewSelect/UsageViewSelect", async () => { +vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => { + const actual = await importOriginal(); const React = await import("react"); const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => { const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null; @@ -87,14 +116,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" }, @@ -104,13 +138,13 @@ 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-01-01T00:00:00Z"), to: new Date("2024-01-08T00:00:00Z") }, null), }, "pick", ), ); AdvancedDatePicker.displayName = "AdvancedDatePicker"; - return { default: AdvancedDatePicker }; + return { ...actual, default: AdvancedDatePicker }; }); vi.mock("@/components/user_agent_activity", () => ({ @@ -147,6 +181,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(), })); @@ -316,8 +354,10 @@ describe("UsagePage", () => { userRole: "Internal User", userRoleLabel: "Internal User", isViewOnly: false, + loginMethod: null, premiumUser: true, disabledPersonalKeyCreation: false, + passwordResetRequired: false, showSSOBanner: false, }; @@ -375,6 +415,7 @@ describe("UsagePage", () => { } as any); mockUserDailyActivityAggregatedCall.mockClear(); mockUserDailyActivityCall.mockClear(); + mockEntityUsageMounted.mockClear(); mockTagListCall.mockClear(); mockGatewayDailyActivityCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); @@ -809,6 +850,7 @@ describe("UsagePage", () => { act(() => { fireEvent.change(usageSelect, { target: { value: usageView } }); }); + expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument(); }); @@ -1365,4 +1407,164 @@ describe("UsagePage", () => { expect(screen.getByText("Endpoint Activity")).toBeInTheDocument(); }); }); + describe("URL state", () => { + const renderAt = (searchParams: string) => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const view = renderWithProviders(, { + searchParams, + onUrlUpdate, + resetUrlUpdateQueueOnMount: false, + }); + const lastQuery = (): string | undefined => onUrlUpdate.mock.lastCall?.[0].queryString; + return { ...view, onUrlUpdate, lastQuery }; + }; + + it("preselects the user filter from ?user= and links back to that user's record", async () => { + renderAt("?user=user-001"); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + "user-001", + ); + }); + expect(screen.getByRole("link", { name: "View user record" })).toHaveAttribute("href", "/ui/users?user=user-001"); + }); + + it("shows no user record link while the global view is unfiltered", async () => { + renderAt(""); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + null, + ); + }); + expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument(); + }); + + it("opens the linked entity view with its selection and writes new picks back", async () => { + const { lastQuery } = renderAt("?view=team&team=team-1"); + + const entityUsage = await screen.findByTestId("entity-usage"); + expect(entityUsage).toHaveAttribute("data-entity-type", "team"); + expect(entityUsage).toHaveAttribute("data-selected", JSON.stringify(["team-1"])); + + fireEvent.click(screen.getByRole("button", { name: "pick entities" })); + + await waitFor(() => { + expect(lastQuery()).toBe("?view=team&team=picked-1&team=picked-2"); + }); + }); + + it("writes the chosen view and drops the previous view's filters", async () => { + const { lastQuery } = renderAt("?user=user-001&tab=keys&range=30d"); + + fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "customer" } }); + + await waitFor(() => { + expect(lastQuery()).toBe("?range=30d&view=customer"); + }); + }); + + it("restores the inner tab from the URL and writes tab changes", async () => { + const { lastQuery } = renderAt("?tab=models"); + + expect(await screen.findByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true"); + + fireEvent.click(screen.getByRole("tab", { name: "Key Activity" })); + + await waitFor(() => { + expect(lastQuery()).toBe("?tab=keys"); + }); + }); + + it("fetches the custom range in the URL and writes a newly picked one", async () => { + const { lastQuery } = renderAt("?from=2025-03-01T00:00:00.000Z&to=2025-03-05T00:00:00.000Z"); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + new Date("2025-03-01T00:00:00.000Z"), + new Date("2025-03-05T00:00:00.000Z"), + null, + ); + }); + + fireEvent.click(screen.getByTestId("pick-a-different-range")); + + await waitFor(() => { + expect(lastQuery()).toBe("?from=2024-01-01T00:00:00.000Z&to=2024-01-08T00:00:00.000Z"); + }); + }); + + it("tells a viewer they lack access and sends them to the default view", async () => { + const warning = vi.spyOn(toast, "warning"); + mockUseAuthorized.mockReturnValue(nonAdminSession); + + const { lastQuery } = renderAt("?view=organization&org=org-1&range=30d"); + + await waitFor(() => { + expect(lastQuery()).toBe("?range=30d"); + }); + expect(warning).toHaveBeenCalledWith("You don't have access to that usage view", { + 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("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 018dfc84740..4c8c7349f25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -6,7 +6,8 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react"; +import { ChevronDown, ChevronRight, Download, Info, Link2, Sparkles, X } from "lucide-react"; +import { useQueryStates } from "nuqs"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -23,14 +24,17 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { toast } from "@/lib/toast"; import { hasCapability } from "@/utils/capabilities"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; +import type { EntityType } from "@/components/EntityUsageExport/types"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; import { @@ -65,7 +69,21 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations"; import UsageAIChatPanel from "./UsageAIChatPanel"; -import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import UserRecordLink from "./UserRecordLink"; +import { + cleanUsageUrl, + dateRangeFromParams, + dateRangePatch, + entitySelectionPatch, + selectedEntitiesFromParams, + DEFAULT_USAGE_VIEW, + usageTabFromParams, + usageTabPatch, + usageUrlParsers, + usageViewFromParams, + usageViewPatch, +} from "../usageUrlState"; interface UsagePageProps { teams: Team[]; @@ -93,11 +111,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []); const initialToDate = useMemo(() => new Date(), []); - // Single date state that directly triggers data fetching - const [dateValue, setDateValue] = useState({ - from: initialFromDate, - to: initialToDate, - }); + const [urlParams, setUrlParams] = useQueryStates(usageUrlParsers); + const { range, from, to } = urlParams; + const dateValue = useMemo( + () => dateRangeFromParams({ range, from, to }) ?? { from: initialFromDate, to: initialToDate }, + [range, from, to, initialFromDate, initialToDate], + ); const [fetchedTags, setFetchedTags] = useState | null>(null); // No [] default: an unresolved query must stay undefined so the customer @@ -108,37 +127,42 @@ 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 [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); + const usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); const [showTokenBreakdown, setShowTokenBreakdown] = useState(false); - // Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render) - useEffect(() => { - if (!isAdmin && userID) { - setSelectedUserId(userID); - } - }, [isAdmin, userID]); - // For non-admins or "my-usage" view, always pass their own user_id - const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId; + const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user; + + const { 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]); @@ -267,13 +291,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]); // Super responsive date change handler - const handleDateChange = useCallback((newValue: DateRangePickerValue) => { - // Instant visual feedback - setIsDateChanging(true); + const handleDateChange = useCallback( + (newValue: DateRangePickerValue, presetShortLabel: string | null) => { + // Instant visual feedback + setIsDateChanging(true); - // Update date immediately for UI responsiveness - setDateValue(newValue); - }, []); + void setUrlParams(dateRangePatch(newValue, presetShortLabel)); + }, + [setUrlParams], + ); // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -446,12 +472,22 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
setUsageView(value)} + onChange={(value) => void setUrlParams(usageViewPatch(value))} userRole={userRole} canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} /> - +
+ + +
= ({ teams, organizations }) => { {isAdmin && usageView === "global" && (

Filter by user

- +
+ void setUrlParams({ user })} /> + {urlParams.user && } +
)} - + void setUrlParams(usageTabPatch(tab))}>
@@ -874,10 +913,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 }) => { { + 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..ad5b036eb05 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts @@ -0,0 +1,192 @@ +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 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("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("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 = { from: moment().startOf("day").toDate(), to: moment().endOf("day").toDate() }; + + expect(dateRangePatch(today, null)).toEqual({ + range: null, + from: today.from.toISOString(), + to: today.to.toISOString(), + }); + }); + + it("stores an unknown preset label as explicit dates", () => { + const today = { from: moment().startOf("day").toDate(), to: moment().endOf("day").toDate() }; + + expect(dateRangePatch(today, "not-a-preset")).toEqual({ + range: null, + from: today.from.toISOString(), + to: today.to.toISOString(), + }); + }); + + 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 the same UTC instants", () => { + const custom = { from: new Date("2026-01-05T08:00:00.000Z"), to: new Date("2026-01-09T20:30:00.000Z") }; + const query = applyPatch("?range=7d", dateRangePatch(custom, null)); + + expect(query).toBe("?from=2026-01-05T08:00:00.000Z&to=2026-01-09T20:30:00.000Z"); + expect(dateRangeFromParams(load(query))).toEqual(custom); + }); + + it.each([ + ["an unknown preset", "?range=90d"], + ["a malformed date", "?from=yesterday&to=2026-01-09T00:00:00.000Z"], + ["a range that ends before it starts", "?from=2026-01-09T00:00:00.000Z&to=2026-01-05T00:00:00.000Z"], + ["only one end of a custom range", "?from=2026-01-05T00:00:00.000Z"], + ])("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-05T00:00:00.000Z&to=2026-01-09T00:00:00.000Z")).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-05T00:00:00.000Z&to=2026-01-09T00:00:00.000Z")).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..2caee80f6dc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts @@ -0,0 +1,178 @@ +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", "mcp", "endpoints"] 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": [], +}; + +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): UsageTab => + isUsageTab(params.tab) ? 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 parseIsoDate = (value: string): Date | undefined => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +}; + +const customRangeFromParams = (from: string, to: string): DateRangePickerValue | undefined => { + const fromDate = parseIsoDate(from); + const toDate = parseIsoDate(to); + if (!fromDate || !toDate || fromDate > toDate) return undefined; + return { from: fromDate, to: 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: value.from.toISOString(), to: value.to.toISOString() }; +}; + +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)); + 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 20ce22b6444..de95f48a969 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 c3c228f911b..7b50398cf76 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, @@ -253,6 +267,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 388a211d9bb..24d0c026adb 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; @@ -83,7 +83,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 15f193ecc3f..5832fdca740 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -23,7 +23,7 @@ export interface EntityUsageExportModalProps { entityType: EntityType; spendData: EntitySpendData; dateRange: DateRangePickerValue; - selectedFilters: string[]; + selectedFilters: readonly string[]; customTitle?: string; teams?: Team[]; } diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 95ce584cc89..46c4ba8061c 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -420,7 +420,7 @@ export const generateExportData = ( export const generateMetadata = ( entityType: EntityType, dateRange: DateRangePickerValue, - selectedFilters: string[], + selectedFilters: readonly string[], exportScope: ExportScope, spendData: EntitySpendData, ): ExportMetadata => { @@ -443,7 +443,7 @@ export const generateMetadata = ( from: dateRange.from?.toISOString(), to: dateRange.to?.toISOString(), }, - filters_applied: selectedFilters.length > 0 ? selectedFilters : "None", + filters_applied: selectedFilters.length > 0 ? [...selectedFilters] : "None", export_scope: exportScope, summary, }; @@ -476,7 +476,7 @@ export const handleExportJSON = ( entityLabel: string, entityType: EntityType, dateRange: DateRangePickerValue, - selectedFilters: string[], + selectedFilters: readonly string[], teamAliasMap: Record = {}, ): void => { const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); 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 d0a5349364f..b40f00d2529 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1474,7 +1474,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/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/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 553726faff0..411fb954a12 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -25,12 +25,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} );