From b5a70cbc1878c26ea9443f6db9a8d4517793d553 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 12 Nov 2026 10:23:01 +0800 Subject: [PATCH] feat(ui): add Activity tabs to resource details (#50630) * feat(ui): add Activity tabs to resource details * fix(ui): preserve Activity dates and model breakdowns * fix(ui): clarify team activity visibility * fix(ui): skip unused entity breakdown in model activity --- litellm/proxy/_lazy_openapi_snapshot.json | 12 + .../daily_activity_routes.py | 3 +- .../test_daily_activity_routes.py | 20 ++ .../view_users/MemberRoleSelect.tsx | 36 +++ .../user_info_view.integration.test.tsx | 36 +++ .../_components/view_users/user_info_view.tsx | 51 ++--- .../ResourceActivity.integration.test.tsx | 207 ++++++++++++++++++ .../components/UsagePage/ResourceActivity.tsx | 117 ++++++++++ .../components/UsagePage/dailyActivityApi.ts | 1 + .../model_info/ExistingCredentialDialog.tsx | 39 ++++ .../model_info_view.integration.test.tsx | 37 ++++ .../src/components/model_info_view.tsx | 38 ++-- .../networking.dailyActivity.test.ts | 8 + .../src/components/networking.tsx | 5 +- .../src/components/team/TeamInfo.tsx | 6 + .../team/tabVisibilityUtils.test.ts | 8 +- .../src/components/team/tabVisibilityUtils.ts | 9 +- .../components/templates/key_info_view.tsx | 8 + ui/litellm-dashboard/src/i18n/locales/en.json | 6 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 14 ++ .../src/lib/wildcard_routes.ts | 2 +- 21 files changed, 605 insertions(+), 58 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/MemberRoleSelect.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.tsx create mode 100644 ui/litellm-dashboard/src/components/model_info/ExistingCredentialDialog.tsx diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 36e2cb46e90..b72beea0ba9 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -5146,6 +5146,18 @@ "type": "integer" } }, + { + "description": "Include per-entity usage and key counts.", + "in": "query", + "name": "include_entity_breakdown", + "required": false, + "schema": { + "default": true, + "description": "Include per-entity usage and key counts.", + "title": "Include Entity Breakdown", + "type": "boolean" + } + }, { "in": "query", "name": "agent_ids", diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py index b5af673585c..2b5307fc696 100644 --- a/litellm/proxy/management_endpoints/daily_activity_routes.py +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -273,6 +273,7 @@ def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver, api_key_limit: Annotated[ int, Query(ge=1, le=constants.USAGE_TOP_API_KEYS_MAX) ] = constants.USAGE_TOP_API_KEYS_DEFAULT, + include_entity_breakdown: Annotated[bool, Query(description="Include per-entity usage and key counts.")] = True, ) -> SpendAnalyticsPaginatedResponse: try: resolved: ResolvedScope = await _resolved_scope( @@ -286,7 +287,7 @@ def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver, repository, resolved.scope, entity_metadata_field=resolved.entity_metadata, - include_entity_breakdown=resolver.include_entity_breakdown, + include_entity_breakdown=resolver.include_entity_breakdown and include_entity_breakdown, include_guardrail_breakdown=resolver.include_guardrail_breakdown, api_key_limit=api_key_limit, ) diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index 23ad9efdc70..d0efca7ea55 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -655,6 +655,26 @@ def test_aggregated_routes_return_scoped_results( assert len(body["results"]) == 2, response.text +@pytest.mark.parametrize("model", (None, "popular")) +def test_aggregated_activity_can_omit_entities_without_changing_chart_data( + daily_activity_client: tuple[TestClient, _FakeRepository], model: str | None +) -> None: + client, _ = daily_activity_client + params: Final = {**_DATE_PARAMS, **({"model": model} if model else {})} + full: Final = client.get("/user/daily/activity/aggregated", params=params) + compact: Final = client.get( + "/user/daily/activity/aggregated", params={**params, "include_entity_breakdown": "false"} + ) + assert full.status_code == compact.status_code == 200 + body: Final = full.json() + assert all(day["breakdown"]["entities"] for day in body["results"]) + assert compact.json() == { + **body, + "metadata": {**body["metadata"], "entity_total_api_keys": None}, + "results": [{**day, "breakdown": {**day["breakdown"], "entities": {}}} for day in body["results"]], + } + + def _ptu_sized_router( model_group: str, shares: Mapping[str, int] = MappingProxyType({"team-a": 30, "team-b": 20}) ) -> Router: diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/MemberRoleSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/MemberRoleSelect.tsx new file mode 100644 index 00000000000..bea5df6314b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/MemberRoleSelect.tsx @@ -0,0 +1,36 @@ +import type { ParseKeys } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SimpleTooltip } from "@/components/ui/tooltip"; + +const ADD_TEAM_ROLE_FIELD_ID = "add-team-role"; + +const MEMBER_ROLE_OPTIONS: ReadonlyArray<{ value: string; hintKey: ParseKeys }> = [ + { value: "user", hintKey: "users.userInfo.memberRoles.userHint" }, + { value: "admin", hintKey: "users.userInfo.memberRoles.adminHint" }, +]; + +export function MemberRoleSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const { t } = useTranslation(); + return ( + + {t("users.userInfo.addTeamDialog.memberRole")} + + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx index d434987b873..e78e4abe42c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx @@ -10,6 +10,7 @@ import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event" import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { Profiler } from "react"; import UserInfoView from "./user_info_view"; +import { EMPTY_DAILY_ACTIVITY_RESPONSE } from "@/components/UsagePage/dailyActivityApi"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; import type { AutoRouterBenchmarksResponse } from "@/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks"; @@ -122,6 +123,41 @@ describe("UserInfoView add-to-team form", () => { mockListMCPTools.mockResolvedValue({ tools: [] }); }); + it("loads only the selected user's Activity, after opening the tab", async () => { + mockUserDailyActivityAggregatedCall.mockResolvedValue(EMPTY_DAILY_ACTIVITY_RESPONSE); + render(); + const activityTab = await screen.findByRole("tab", { name: "Activity" }); + expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); + + fireEvent.click(activityTab); + + expect(await screen.findByText("No daily details available")).toBeInTheDocument(); + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ entityIds: [defaultProps.userId], apiKey: undefined }), + ); + vi.stubGlobal("requestIdleCallback", (callback: () => void) => { + callback(); + return 0; + }); + fireEvent.click(screen.getByRole("button", { name: /\d.* - .*\d/ })); + fireEvent.click(screen.getByRole("button", { name: /Last 7 Days/i })); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2)); + const selected = vi.mocked(mockUserDailyActivityAggregatedCall).mock.calls.at(-1)![1]; + fireEvent.click(screen.getByRole("tab", { name: "Overview" })); + fireEvent.click(activityTab); + await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(3)); + expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith( + "user", + expect.objectContaining({ + startTime: selected.startTime, + endTime: selected.endTime, + }), + ); + vi.unstubAllGlobals(); + }); + const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); const openAddTeam = async (user: ReturnType, anchorTeam = "Alpha Team") => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index 89267f121bc..ca2c4264213 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -1,4 +1,5 @@ -import type { ParseKeys } from "i18next"; +import ResourceActivity from "@/components/UsagePage/ResourceActivity"; +import { MemberRoleSelect } from "./MemberRoleSelect"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -19,7 +20,6 @@ import { teamMemberDeleteCall, Member, } from "@/components/networking"; -import { SimpleTooltip } from "@/components/ui/tooltip"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Combobox, @@ -29,7 +29,6 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { hasProxyWideSpendView, rolesWithWriteAccess } from "@/utils/roles"; import { teamDetailHref } from "@/utils/entityLinks"; import { BadgeLink } from "@/components/shared/BadgeLink"; @@ -69,18 +68,12 @@ interface TeamDisplayInfo { } const ADD_TEAM_FIELD_ID = "add-team-team"; -const ADD_TEAM_ROLE_FIELD_ID = "add-team-role"; interface TeamOption { team_id: string; team_alias: string; } -const MEMBER_ROLE_OPTIONS: ReadonlyArray<{ value: string; hintKey: ParseKeys }> = [ - { value: "user", hintKey: "users.userInfo.memberRoles.userHint" }, - { value: "admin", hintKey: "users.userInfo.memberRoles.adminHint" }, -]; - export default function UserInfoView({ userId, onClose, @@ -94,7 +87,7 @@ export default function UserInfoView({ const { t } = useTranslation(); const { premiumUser, userId: signedInUserId } = useAuthorized(); const canViewAutoRouterUsage = hasProxyWideSpendView(userRole); - const canViewSavings = canViewAutoRouterUsage || (Boolean(userId.trim()) && userId === signedInUserId); + const canViewUserUsage = canViewAutoRouterUsage || (Boolean(userId.trim()) && userId === signedInUserId); const activityDateRange = useActivityDateRange(); const [userData, setUserData] = useState(null); const [teamDetails, setTeamDetails] = useState([]); @@ -107,7 +100,7 @@ export default function UserInfoView({ const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); const [activeTab, setActiveTab] = useState(initialTab === 1 ? "details" : "overview"); - const hiddenSavingsTab = activeTab === "savings" && !canViewSavings; + const hiddenUsageTab = (activeTab === "savings" || activeTab === "activity") && !canViewUserUsage; const hiddenRouterTab = activeTab === "auto-router-usage" && !canViewAutoRouterUsage; const [copiedStates, setCopiedStates] = useState>({}); const [isTeamsExpanded, setIsTeamsExpanded] = useState(false); @@ -490,7 +483,7 @@ export default function UserInfoView({ /> setActiveTab(String(v))} className="gap-0" > @@ -501,7 +494,12 @@ export default function UserInfoView({ {t("users.userInfo.tabs.details")} - {canViewSavings && ( + {canViewUserUsage && ( + + {t("shared.resourceActivity.title")} + + )} + {canViewUserUsage && ( {t("users.userInfo.tabs.savings")} @@ -513,6 +511,12 @@ export default function UserInfoView({ )} + {canViewUserUsage && ( + + + + )} + {/* Overview Panel */}
@@ -731,7 +735,7 @@ export default function UserInfoView({ )} - {canViewSavings && ( + {canViewUserUsage && ( {activeTab === "savings" && (userId.trim() ? ( @@ -829,24 +833,7 @@ export default function UserInfoView({ - - {t("users.userInfo.addTeamDialog.memberRole")} - - +
diff --git a/ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.integration.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.integration.test.tsx new file mode 100644 index 00000000000..d567445e35e --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.integration.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import * as networking from "@/components/networking"; +import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityAggregatedResponse } from "./dailyActivityApi"; +import ResourceActivity from "./ResourceActivity"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() })); + +const auth = { + accessToken: "test-session", + userId: "user-1", + userRole: "Admin", +} as ReturnType; + +const response: DailyActivityAggregatedResponse = { + results: [ + { + date: "2026-11-01", + metrics: { + spend: 12.34, + api_requests: 42, + successful_requests: 40, + failed_requests: 2, + total_tokens: 4200, + prompt_tokens: 3000, + completion_tokens: 1200, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + breakdown: {}, + }, + ], + metadata: { + ...EMPTY_DAILY_ACTIVITY_METADATA, + total_spend: 12.34, + total_api_requests: 42, + total_successful_requests: 40, + total_failed_requests: 2, + total_tokens: 4200, + }, +}; + +describe("ResourceActivity", () => { + afterEach(() => vi.useRealTimers()); + + it.each(["team", "user"] as const)("shows the returned model breakdown for a %s", async (scope) => { + vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({ + ...response, + results: [ + { + ...response.results[0], + breakdown: { + models: { + "example-model": { metrics: response.results[0].metrics }, + }, + }, + }, + ], + }); + render(); + expect(await screen.findByRole("cell", { name: "example-model" })).toBeInTheDocument(); + }); + + it("shows matching recorded models and their totals for a wildcard, in one request", async () => { + vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({ + ...response, + metadata: { ...response.metadata, total_spend: 124.68 }, + results: [ + { + ...response.results[0], + breakdown: { + models: { + "openai/model-one": { metrics: response.results[0].metrics }, + "openai/model-two": { metrics: response.results[0].metrics }, + "anthropic/other-model": { metrics: { ...response.results[0].metrics, spend: 100 } }, + }, + }, + }, + ], + }); + render(); + expect(await screen.findByText("$24.68")).toBeInTheDocument(); + expect(screen.getByText("openai/model-one")).toBeInTheDocument(); + expect(screen.getByText("openai/model-two")).toBeInTheDocument(); + expect(screen.queryByText("anthropic/other-model")).not.toBeInTheDocument(); + expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledExactlyOnceWith( + "user", + expect.objectContaining({ model: undefined, includeEntityBreakdown: false }), + ); + }); + + it("keeps wildcard usage within the viewer's scope and shows empty when no model matches", async () => { + vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" }); + render(); + expect(await screen.findByText("No daily details available")).toBeInTheDocument(); + expect(screen.queryByText("$12.34")).not.toBeInTheDocument(); + expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ model: undefined, entityIds: [auth.userId] }), + ); + }); + + it("includes today's UTC bucket for a team when the local date is still yesterday", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-09T01:00:00Z")); + vi.spyOn(networking, "formatDate").mockImplementation((date) => + new Date(date.getTime() - 8 * 60 * 60 * 1000).toISOString().slice(0, 10), + ); + render(); + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + const request = vi.mocked(networking.dailyActivityAggregatedCall).mock.calls[0][1]; + expect(request.endTime.getFullYear()).toBe(2026); + expect(request.endTime.getMonth()).toBe(10); + expect(request.endTime.getDate()).toBe(9); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(useAuthorized).mockReturnValue(auth); + vi.spyOn(networking, "dailyActivityAggregatedCall").mockResolvedValue(response); + }); + + it.each([ + { scope: "key", id: "key-1", entity: "user", filter: { apiKey: "key-1", entityIds: null } }, + { scope: "team", id: "team-1", entity: "team", filter: { entityIds: ["team-1"] } }, + { scope: "user", id: "user-2", entity: "user", filter: { entityIds: ["user-2"] } }, + { scope: "model", id: "example-model", entity: "user", filter: { model: "example-model", entityIds: null } }, + ] as const)("shows existing Activity metrics filtered to the $scope", async ({ scope, id, entity, filter }) => { + render(); + + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + const expectedRequest = { + ...filter, + includeEntityBreakdown: scope === "model" ? false : undefined, + startTime: expect.any(Date), + endTime: expect.any(Date), + }; + expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith( + entity, + expect.objectContaining(expectedRequest), + ); + }); + + it("keeps a non-admin key view within that user's spend scope", async () => { + vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" }); + render(); + + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + expect(screen.getByText("Showing your usage only.")).toBeInTheDocument(); + expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ entityIds: ["user-1"], apiKey: "key-1" }), + ); + }); + + it("explains that team Activity is limited to the keys the viewer can access", async () => { + vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" }); + render(); + + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + expect(screen.getByText("Showing usage for the team keys you have permission to view.")).toBeInTheDocument(); + expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith( + "team", + expect.objectContaining({ entityIds: ["team-1"] }), + ); + }); + + it("does not query another user's spend for a non-admin", () => { + vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" }); + render(); + + expect(networking.dailyActivityAggregatedCall).not.toHaveBeenCalled(); + expect(screen.queryByText("$12.34")).not.toBeInTheDocument(); + }); + + it("shows a retryable error instead of zero usage when the request fails", async () => { + vi.mocked(networking.dailyActivityAggregatedCall).mockRejectedValueOnce(new Error("Activity unavailable")); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Activity unavailable"); + expect(screen.queryByText("$12.34")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + }); + + it("replaces the previous resource's data when navigating to another resource", async () => { + const { rerender } = render(); + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + + vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({ + results: [], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, + }); + rerender(); + + expect(await screen.findByText("No daily details available")).toBeInTheDocument(); + expect(screen.queryByText("$12.34")).not.toBeInTheDocument(); + await waitFor(() => + expect(networking.dailyActivityAggregatedCall).toHaveBeenLastCalledWith( + "team", + expect.objectContaining({ entityIds: ["team-2"] }), + ), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.tsx b/ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.tsx new file mode 100644 index 00000000000..f0eb6cf66a3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/ResourceActivity.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { + useActivityDateRange, + type ActivityDateRange, +} from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; +import { benchmarksWindow } from "@/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks"; +import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity"; +import { getTopModels } from "@/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations"; +import { ActivityMetrics, ModelSection, processActivityData } from "@/components/activity_metrics"; +import { dailyActivityAggregatedCall } from "@/components/networking"; +import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { ErrorState } from "@/components/shared/ErrorState"; +import { hasProxyWideSpendView, spendScopeUserId } from "@/utils/roles"; +import { EMPTY_DAILY_ACTIVITY_METADATA, toDailyData, type DailyActivityRequest } from "./dailyActivityApi"; +import { keyDetailFromResponse, overallUsageMetrics, reportingKeyId } from "./keyActivityData"; +import { matchesWildcard } from "@/lib/wildcard_routes"; + +interface ResourceActivityProps { + scope: "key" | "team" | "user" | "model"; + id: string; + activity?: ActivityDateRange; +} + +export default function ResourceActivity({ scope, id, activity }: ResourceActivityProps) { + const { t } = useTranslation(); + const { accessToken, userId, userRole } = useAuthorized(); + const localActivity = useActivityDateRange(); + const { dateValue, onDateChange } = activity ?? localActivity; + const wildcard = scope === "model" && id.includes("*"); + const entity = scope === "team" ? "team" : "user"; + const entityId = scope === "team" || scope === "user" ? id : spendScopeUserId(userRole, userId); + const apiKey = scope === "key" ? reportingKeyId(id) : undefined; + const canView = scope !== "user" || hasProxyWideSpendView(userRole) || id === userId; + const hasAccess = accessToken && id && canView; + const scopedToViewer = scope !== "user" && !hasProxyWideSpendView(userRole); + const request: DailyActivityRequest | null = + hasAccess && dateValue.from && dateValue.to + ? { + accessToken, + startTime: dateValue.from, + endTime: + scope === "team" ? new Date(`${benchmarksWindow(dateValue, new Date()).end_date}T12:00:00`) : dateValue.to, + entityIds: entityId ? [entityId] : null, + apiKey, + apiKeyLimit: 1, + includeEntityBreakdown: scope === "model" ? false : undefined, + model: scope === "model" && !wildcard ? id : undefined, + includeCurrentUtcDay: true, + } + : null; + const { data, loading, failed, errorMessage, reload } = useAggregatedDailyActivity({ + fetch: () => dailyActivityAggregatedCall(entity, request as DailyActivityRequest), + enabled: request !== null, + deps: [accessToken, scope, id, entityId, dateValue.from, dateValue.to], + }); + const results = useMemo(() => toDailyData(data), [data]); + const metrics = useMemo( + () => + (apiKey ? keyDetailFromResponse(data, apiKey, []) : undefined) ?? { + ...overallUsageMetrics(results, data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA), + top_models: + scope === "team" || scope === "user" + ? getTopModels(results, "models", Infinity).map(({ key, ...model }) => ({ model: key, ...model })) + : [], + }, + [data, results, apiKey, scope], + ); + const modelMetrics = useMemo( + () => + wildcard + ? Object.fromEntries( + Object.entries(processActivityData({ results }, "models", [])).filter(([model]) => + matchesWildcard(id, model), + ), + ) + : {}, + [results, wildcard, id], + ); + + if (!request) return null; + + const content = (() => { + if (loading) return ; + if (failed) return ; + if (data.results.length === 0 || (wildcard && Object.keys(modelMetrics).length === 0)) { + return

{t("usage.keyActivity.noDailyDetails")}

; + } + return wildcard ? ( + + ) : ( + + ); + })(); + + return ( +
+
+ {t("shared.scopedSavingsTab.bucketedByUtc")} + +
+ {scopedToViewer && ( +

+ {t(scope === "team" ? "shared.resourceActivity.teamScope" : "shared.resourceActivity.ownUsage")} +

+ )} + {scope === "model" && ( +

{t("shared.resourceActivity.modelScope", { model: id })}

+ )} + {content} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index c3201b563b1..4c8ff5dc087 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -26,6 +26,7 @@ export interface DailyActivityRequest { model?: string | null; includeCurrentUtcDay?: boolean; apiKeyLimit?: number; + includeEntityBreakdown?: boolean; } export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = { diff --git a/ui/litellm-dashboard/src/components/model_info/ExistingCredentialDialog.tsx b/ui/litellm-dashboard/src/components/model_info/ExistingCredentialDialog.tsx new file mode 100644 index 00000000000..fae378afbcc --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_info/ExistingCredentialDialog.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import type { CredentialItem } from "@/components/networking"; +import { CredentialIdentity } from "@/components/shared/CredentialIdentity"; +import { credentialLabelsByName } from "@/components/shared/credentialOptions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export function ExistingCredentialDialog({ + open, + onClose, + credentialName, + credentials, +}: { + open: boolean; + onClose: () => void; + credentialName: string; + credentials: CredentialItem[]; +}) { + const { t } = useTranslation(); + return ( + !next && onClose()}> + + + {t("modelInfoView.existingCredentialDialog.title")} + + + + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx index 80ade2182a1..62043d2daed 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ModelInfoView from "./model_info_view"; +import { EMPTY_DAILY_ACTIVITY_RESPONSE } from "./UsagePage/dailyActivityApi"; import { toast } from "@/lib/toast"; import * as networking from "./networking"; import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; @@ -20,6 +21,7 @@ vi.mock("../../utils/dataUtils", () => ({ })); vi.mock("./networking", () => ({ + dailyActivityAggregatedCall: vi.fn(), credentialGetCall: vi.fn(), credentialListCall: vi.fn(), getGuardrailsList: vi.fn(), @@ -198,6 +200,41 @@ describe("ModelInfoView", () => { const wrapper = ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); + it("loads Activity for the underlying model only when its tab is opened", async () => { + vi.mocked(networking.dailyActivityAggregatedCall).mockReset().mockResolvedValue(EMPTY_DAILY_ACTIVITY_RESPONSE); + render(, { wrapper }); + const activityTab = await screen.findByRole("tab", { name: "Activity" }); + expect(networking.dailyActivityAggregatedCall).not.toHaveBeenCalled(); + + fireEvent.click(activityTab); + + expect(await screen.findByText("No daily details available")).toBeInTheDocument(); + expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ model: defaultModelData.litellm_params.model }), + ); + vi.stubGlobal("requestIdleCallback", (callback: () => void) => { + callback(); + return 0; + }); + fireEvent.click(screen.getByRole("button", { name: /\d.* - .*\d/ })); + fireEvent.click(screen.getByRole("button", { name: /Last 7 Days/i })); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledTimes(2)); + const selected = vi.mocked(networking.dailyActivityAggregatedCall).mock.calls.at(-1)![1]; + fireEvent.click(screen.getByRole("tab", { name: "Overview" })); + fireEvent.click(activityTab); + await waitFor(() => expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledTimes(3)); + expect(networking.dailyActivityAggregatedCall).toHaveBeenLastCalledWith( + "user", + expect.objectContaining({ + startTime: selected.startTime, + endTime: selected.endTime, + }), + ); + vi.unstubAllGlobals(); + }); + it("should render", async () => { render(, { wrapper }); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 226cc06e881..4f3692492b8 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -1,3 +1,5 @@ +import ResourceActivity from "@/components/UsagePage/ResourceActivity"; +import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useModelHub, useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { useQueryClient } from "@tanstack/react-query"; @@ -31,8 +33,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal"; import ReuseCredentialsModal from "./model_add/reuse_credentials"; -import { credentialLabelsByName } from "./shared/credentialOptions"; -import { CredentialIdentity } from "./shared/CredentialIdentity"; +import { ExistingCredentialDialog } from "./model_info/ExistingCredentialDialog"; import { toast } from "@/lib/toast"; import { CredentialItem, @@ -78,6 +79,7 @@ export default function ModelInfoView({ }: ModelInfoViewProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); + const activityDateRange = useActivityDateRange(); const [localModelData, setLocalModelData] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); @@ -615,6 +617,9 @@ export default function ModelInfoView({ {t("modelInfoView.tabs.overview")} + + {t("shared.resourceActivity.title")} + {t("modelInfoView.tabs.rawJson")} @@ -751,6 +756,10 @@ export default function ModelInfoView({ + + + +
@@ -803,25 +812,12 @@ export default function ModelInfoView({
           setIsCredentialModalOpen={setIsCredentialModalOpen}
         />
       ) : (
-         !open && setIsCredentialModalOpen(false)}>
-          
-            
-              {t("modelInfoView.existingCredentialDialog.title")}
-            
-            
-            
-              
-            
-          
-        
+         setIsCredentialModalOpen(false)}
+          credentialName={modelData.litellm_params.litellm_credential_name ?? ""}
+          credentials={credentialsList}
+        />
       )}
 
       {isUpdateCredentialsModalOpen && accessToken && (
diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts
index 6821d26fb33..09ac797c841 100644
--- a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts
+++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts
@@ -41,6 +41,14 @@ afterEach(() => {
 });
 
 describe("dailyActivityAggregatedCall", () => {
+  it.each([undefined, true, false])("sends the entity breakdown choice %s without changing defaults", async (value) => {
+    const mockFetch = captureFetch();
+    await dailyActivityAggregatedCall("user", req({ includeEntityBreakdown: value }));
+    expect(requestedUrl(mockFetch).searchParams.get("include_entity_breakdown")).toBe(
+      value === undefined ? null : String(value),
+    );
+  });
+
   it.each<[DailyActivityEntity, string]>([
     ["user", "user_id"],
     ["team", "team_ids"],
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 7be345de09a..32e2d0357f6 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -1395,7 +1395,10 @@ export const dailyActivityAggregatedCall = (
 ): Promise =>
   apiClient.get(`/${entity}/daily/activity/aggregated`, {
     accessToken: req.accessToken,
-    query: dailyActivityQuery(entity, req, req.apiKeyLimit === undefined ? {} : { api_key_limit: req.apiKeyLimit }),
+    query: dailyActivityQuery(entity, req, {
+      api_key_limit: req.apiKeyLimit,
+      include_entity_breakdown: req.includeEntityBreakdown,
+    }),
   });
 
 export const subscriptionUsageCall = (
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 69fac298b0f..039a9da71d0 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -1,3 +1,4 @@
+import ResourceActivity from "@/components/UsagePage/ResourceActivity";
 import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
 import type { components } from "@/lib/http/schema";
 import useCan from "@/app/(dashboard)/hooks/useCan";
@@ -1607,6 +1608,11 @@ const TeamInfoView: React.FC = ({
         
), }, + { + key: TEAM_INFO_TAB_KEYS.ACTIVITY, + label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.ACTIVITY]), + children: , + }, { key: TEAM_INFO_TAB_KEYS.MY_USER, label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER]), diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts index e115499dcb0..d59004ace74 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts @@ -23,13 +23,19 @@ describe("team_info_tabs", () => { describe("getTeamInfoVisibleTabs", () => { it("returns overview, my user, and virtual keys when user cannot edit team", () => { const tabs = getTeamInfoVisibleTabs(false); - expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.MY_USER, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]); + expect(tabs).toEqual([ + TEAM_INFO_TAB_KEYS.OVERVIEW, + TEAM_INFO_TAB_KEYS.ACTIVITY, + TEAM_INFO_TAB_KEYS.MY_USER, + TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, + ]); }); it("returns all tabs when user can edit team", () => { const tabs = getTeamInfoVisibleTabs(true); expect(tabs).toEqual([ TEAM_INFO_TAB_KEYS.OVERVIEW, + TEAM_INFO_TAB_KEYS.ACTIVITY, TEAM_INFO_TAB_KEYS.MY_USER, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, TEAM_INFO_TAB_KEYS.MEMBERS, diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts index 7e9c68a3fcf..a0616b7c174 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts @@ -7,6 +7,7 @@ import type { ParseKeys } from "i18next"; export const TEAM_INFO_TAB_KEYS = { OVERVIEW: "overview", + ACTIVITY: "activity", MY_USER: "my-user", VIRTUAL_KEYS: "virtual-keys", MEMBERS: "members", @@ -15,6 +16,7 @@ export const TEAM_INFO_TAB_KEYS = { } as const; export const TEAM_INFO_TAB_LABELS: Record = { + [TEAM_INFO_TAB_KEYS.ACTIVITY]: "shared.resourceActivity.title", [TEAM_INFO_TAB_KEYS.OVERVIEW]: "teams.teamInfo.tabs.overview", [TEAM_INFO_TAB_KEYS.MY_USER]: "teams.teamInfo.tabs.myUser", [TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "teams.teamInfo.tabs.virtualKeys", @@ -29,7 +31,12 @@ export const TEAM_INFO_TAB_LABELS: Record = { * - Members, Member Permissions, Settings: only when canEditTeam is true */ export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] { - const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.MY_USER, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]; + const baseTabs = [ + TEAM_INFO_TAB_KEYS.OVERVIEW, + TEAM_INFO_TAB_KEYS.ACTIVITY, + TEAM_INFO_TAB_KEYS.MY_USER, + TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, + ]; if (canEditTeam) { return [ ...baseTabs, diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 120770f2001..c580165b842 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -1,3 +1,4 @@ +import ResourceActivity from "@/components/UsagePage/ResourceActivity"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; @@ -746,6 +747,9 @@ export default function KeyInfoView({ {t("keyDetail.keyInfoView.tabs.overview")} + + {t("shared.resourceActivity.title")} + {t("keyDetail.keyInfoView.tabs.savings")} @@ -950,6 +954,10 @@ export default function KeyInfoView({
+ + + + {/* Savings Panel. No keepMounted: this tab sweeps the daily rollup, and staying mounted would fire that request on every key page open for people who never look at it. */} diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 8e15979afb1..b41302e1402 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -8610,6 +8610,12 @@ } }, "shared": { + "resourceActivity": { + "title": "Activity", + "ownUsage": "Showing your usage only.", + "teamScope": "Showing usage for the team keys you have permission to view.", + "modelScope": "Usage for {{model}} across deployments of this model." + }, "credentialOptions": { "none": "None" }, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 11c06527413..a446cf87857 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -54218,6 +54218,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; agent_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -59906,6 +59908,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; end_user_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -60963,6 +60967,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; end_user_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -70582,6 +70588,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; organization_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -76854,6 +76862,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; start_date?: string | null; end_date?: string | null; model?: string | null; @@ -77544,6 +77554,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; team_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -79967,6 +79979,8 @@ export interface operations { parameters: { query?: { api_key_limit?: number; + /** @description Include per-entity usage and key counts. */ + include_entity_breakdown?: boolean; /** @description Start date in YYYY-MM-DD format */ start_date?: string | null; /** @description End date in YYYY-MM-DD format */ diff --git a/ui/litellm-dashboard/src/lib/wildcard_routes.ts b/ui/litellm-dashboard/src/lib/wildcard_routes.ts index 607e5d106a3..1527e43e558 100644 --- a/ui/litellm-dashboard/src/lib/wildcard_routes.ts +++ b/ui/litellm-dashboard/src/lib/wildcard_routes.ts @@ -7,7 +7,7 @@ export type WildcardRoute = { readonly model: string; readonly sourceGroup: stri // A linear glob scan rather than a RegExp: patterns are admin-controlled model_name values, and a // backtracking regex built from one ("a*a*a*...") can freeze another admin's dashboard. -const matchesWildcard = (pattern: string, name: string): boolean => { +export const matchesWildcard = (pattern: string, name: string): boolean => { const parts = pattern.split("*"); const head = parts[0]; const tail = parts[parts.length - 1];