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 }) => {
-