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 0193f8a0c20..65c8acfc676 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 @@ -101,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", () => ({ @@ -800,7 +800,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(); 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 345e66079e1..9e36539434b 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 @@ -29,6 +29,7 @@ beforeAll(() => { vi.mock("@/components/networking", () => ({ userDailyActivityCall: vi.fn(), userDailyActivityAggregatedCall: vi.fn(), + teamDailyActivityAggregatedCall: vi.fn(), gatewayDailyActivityCall: vi.fn(), tagListCall: vi.fn(), })); @@ -49,8 +50,10 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ default: () =>
Top Keys
, })); -vi.mock("./EntityUsage/EntityUsage", () => ({ - default: ({ +vi.mock("./EntityUsage/EntityUsage", async () => { + const React = await import("react"); + const { teamDailyActivityAggregatedCall } = await import("@/components/networking"); + const EntityUsage = ({ entityType, entityList, selectedEntities, @@ -60,21 +63,28 @@ vi.mock("./EntityUsage/EntityUsage", () => ({ entityList: unknown; selectedEntities: readonly string[]; onSelectedEntitiesChange: (ids: readonly string[]) => void; - }) => ( -
- Entity Usage - -
- ), - EntityList: [], -})); + }) => { + React.useEffect(() => { + if (entityType === "team") { + void teamDailyActivityAggregatedCall("test-token", new Date(), new Date(), ["team-1"]); + } + }, [entityType]); + return ( +
+ Entity Usage + +
+ ); + }; + return { default: EntityUsage, EntityList: [] }; +}); vi.mock("./EntityUsage/SpendByProvider", () => ({ default: () =>
Spend By Provider
, @@ -116,7 +126,11 @@ vi.mock("@/components/shared/advanced_date_picker", async (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" }, @@ -126,7 +140,7 @@ vi.mock("@/components/shared/advanced_date_picker", async (importOriginal) => { { "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", ), @@ -185,6 +199,7 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ describe("UsagePage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); + const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall); const mockTagListCall = vi.mocked(networking.tagListCall); const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall); const mockUseCustomers = vi.mocked(useCustomers); @@ -401,6 +416,7 @@ describe("UsagePage", () => { } as any); mockUserDailyActivityAggregatedCall.mockClear(); mockUserDailyActivityCall.mockClear(); + mockTeamDailyActivityAggregatedCall.mockClear(); mockTagListCall.mockClear(); mockGatewayDailyActivityCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); @@ -826,15 +842,10 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); - const usageSelect = screen.getByTestId("usage-view-select"); act(() => { - fireEvent.change(usageSelect, { target: { value: "team" } }); + fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: usageView } }); }); - expect(screen.getAllByText("Entity Usage").length).toBeGreaterThan(0); - act(() => { - fireEvent.change(usageSelect, { target: { value: usageView } }); - }); expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument(); }); @@ -1501,6 +1512,18 @@ describe("UsagePage", () => { expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument(); }); + it("does not render or fetch a disallowed team view for an internal user", async () => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + + renderAt("?view=team&team=team-1"); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument(); + expect(mockTeamDailyActivityAggregatedCall).not.toHaveBeenCalled(); + }); + 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); @@ -1532,5 +1555,11 @@ describe("UsagePage", () => { 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 6d361f05fa5..2388225f047 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 @@ -70,7 +70,7 @@ 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 { allowedUsageOptions, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; import UserRecordLink from "./UserRecordLink"; import { cleanUsageUrl, @@ -78,6 +78,7 @@ import { dateRangePatch, entitySelectionPatch, selectedEntitiesFromParams, + DEFAULT_USAGE_VIEW, usageTabFromParams, usageTabPatch, usageUrlParsers, @@ -127,8 +128,11 @@ 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], + ); const [modelViewType, setModelViewType] = useState("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); @@ -136,12 +140,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isAiChatOpen, setIsAiChatOpen] = useState(false); const selectedUsageView = usageViewFromParams(urlParams); const selectedTab = usageTabFromParams(urlParams); - // Org-admin membership is read from the server, so unlike the other usage - // views this one can be revoked while the page is open. Derive the view in - // render rather than storing it, so the fallback lands on the same paint and - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); @@ -151,10 +150,6 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user; const { isLoading: organizationsLoading } = useOrganizations(); - const allowedViews = useMemo( - () => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin), - [userRole, canViewTagUsage, isOrgAdmin], - ); const accessSettled = userRole !== null && !organizationsLoading; useEffect(() => { if (!accessSettled) return; @@ -303,11 +298,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Super responsive date change handler const handleDateChange = useCallback( - (newValue: DateRangePickerValue) => { + (newValue: DateRangePickerValue, presetShortLabel: string | null) => { // Instant visual feedback setIsDateChanging(true); - void setUrlParams(dateRangePatch(newValue)); + void setUrlParams(dateRangePatch(newValue, presetShortLabel)); }, [setUrlParams], ); @@ -497,7 +492,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} /> - +
+ + +
= ({ teams, organizations }) => {
-