From 24a17e83532d650411ceba4737bfef14f920eeba Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 3 Sep 2026 02:45:13 +0000 Subject: [PATCH] feat(ui): admin-configurable default Usage date range Adds the default_usage_date_range UI setting (today, last_7_days, last_30_days, month_to_date, year_to_date) so a proxy admin can pick what range the Usage page opens with for every user. Unset keeps the trailing 7 days. Presets are computed in the viewer's browser timezone and shared with the date picker. Manual range changes stay local to the page and never write the setting back. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy_setting_endpoints.py | 15 ++ .../test_proxy_setting_endpoints.py | 111 +++++++++++++ .../components/UsagePageView.test.tsx | 150 +++++++++++++++++- .../_components/components/UsagePageView.tsx | 21 +-- .../components/defaultUsageDateRange.test.ts | 65 ++++++++ .../components/defaultUsageDateRange.ts | 10 ++ .../UISettings/UISettings.test.tsx | 68 ++++++++ .../AdminSettings/UISettings/UISettings.tsx | 57 +++++++ .../shared/advanced_date_picker.tsx | 50 +----- .../components/shared/date_range_presets.ts | 68 ++++++++ 10 files changed, 556 insertions(+), 59 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.ts create mode 100644 ui/litellm-dashboard/src/components/shared/date_range_presets.ts diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index c12d071dd36..be305368d36 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -7,6 +7,7 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import ( Final, + Literal, NamedTuple, Protocol, cast, # noqa: TID251 # prisma types Json columns as fields.Json but de-serializes them to plain python on read @@ -212,6 +213,9 @@ class UIThemeSettingsResponse(SettingsResponse): """Response model for UI theme settings""" +UsageDateRangePreset = Literal["today", "last_7_days", "last_30_days", "month_to_date", "year_to_date"] + + class UISettings(BaseModel): """Configuration for UI-specific flags""" @@ -304,6 +308,16 @@ class UISettings(BaseModel): description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.", ) + default_usage_date_range: UsageDateRangePreset | None = Field( + default=None, + description=( + "Date range the Usage page opens with for every user. Boundaries are computed in the viewer's browser " + "timezone: month_to_date starts at 00:00 on the 1st of the current calendar month and ends at the end of " + "today. Unset falls back to the last 7 days. Users can still pick any other range; doing so does not " + "change this setting." + ), + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -326,6 +340,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = { "disable_custom_api_keys", "disable_key_generate_for_org_admin", "enable_chat_ui", + "default_usage_date_range", } ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: Final = "enable_ptu_cost_attribution" diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 709447d23c0..12a82d651e4 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3266,3 +3266,114 @@ class TestPtuCostAttributionUISetting: assert response.status_code == 400 assert "enable_ptu_cost_attribution" in str(response.json()["detail"]) assert not mock_prisma.db.litellm_uisettings.upsert.called + + +class TestDefaultUsageDateRangeUISetting: + """``default_usage_date_range`` is what the Usage page opens with. It is persisted + through the same allowlisted PATCH/GET pair as the other UI settings.""" + + @staticmethod + def _mock_prisma(monkeypatch, stored=None): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_record = None + if stored is not None: + mock_record = MagicMock() + mock_record.ui_settings = json.dumps(stored) + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=mock_record) + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + return mock_prisma + + @staticmethod + def _override_auth(role): + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="test-user-123", user_role=role) + + def test_get_reports_the_setting_as_unset_and_documents_the_fallback(self, mock_auth, monkeypatch): + self._mock_prisma(monkeypatch) + + response = client.get("/get/ui_settings") + + assert response.status_code == 200 + body = response.json() + assert body["values"]["default_usage_date_range"] is None + assert "Unset falls back to the last 7 days" in body["field_schema"]["properties"]["default_usage_date_range"]["description"] + + def test_proxy_admin_can_persist_a_preset_and_read_it_back(self, mock_auth, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = self._mock_prisma(monkeypatch, stored={"enable_chat_ui": True}) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + + try: + response = client.patch("/update/ui_settings", json={"default_usage_date_range": "month_to_date"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200, response.text + upsert_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"] + persisted = json.loads(upsert_data["update"]["ui_settings"]) + assert persisted == {"enable_chat_ui": True, "default_usage_date_range": "month_to_date"} + + self._mock_prisma(monkeypatch, stored=persisted) + read_back = client.get("/get/ui_settings") + assert read_back.json()["values"]["default_usage_date_range"] == "month_to_date" + + def test_proxy_admin_can_clear_the_setting_back_to_the_fallback(self, mock_auth, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = self._mock_prisma(monkeypatch, stored={"default_usage_date_range": "month_to_date"}) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + + try: + response = client.patch("/update/ui_settings", json={"default_usage_date_range": None}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200, response.text + upsert_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"] + assert json.loads(upsert_data["update"]["ui_settings"])["default_usage_date_range"] is None + + def test_patch_rejects_a_value_outside_the_presets(self, mock_auth, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = self._mock_prisma(monkeypatch) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + + try: + response = client.patch("/update/ui_settings", json={"default_usage_date_range": "last_90_days"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 422 + assert not mock_prisma.db.litellm_uisettings.upsert.called + + @pytest.mark.parametrize( + "role", + [LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY], + ) + def test_non_proxy_admins_cannot_change_the_setting(self, mock_auth, monkeypatch, role): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = self._mock_prisma(monkeypatch, stored={"default_usage_date_range": "month_to_date"}) + self._override_auth(role) + + try: + response = client.patch("/update/ui_settings", json={"default_usage_date_range": "last_30_days"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 403 + assert not mock_prisma.db.litellm_uisettings.upsert.called + + def test_read_only_user_can_read_the_setting(self, mock_auth, monkeypatch): + self._mock_prisma(monkeypatch, stored={"default_usage_date_range": "month_to_date"}) + self._override_auth(LitellmUserRoles.INTERNAL_USER_VIEW_ONLY) + + try: + response = client.get("/get/ui_settings") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["values"]["default_usage_date_range"] == "month_to_date" 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 26d595f4d74..a7b8d40cc08 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 @@ -1,5 +1,6 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; @@ -29,6 +30,7 @@ vi.mock("@/components/networking", () => ({ userDailyActivityAggregatedCall: vi.fn(), gatewayDailyActivityCall: vi.fn(), tagListCall: vi.fn(), + updateUiSettings: vi.fn(), })); // Mock child components to simplify testing @@ -48,8 +50,22 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ })); vi.mock("./EntityUsage/EntityUsage", () => ({ - default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => ( -
+ default: ({ + entityType, + entityList, + dateValue, + }: { + entityType: string; + entityList: unknown; + dateValue: { from?: Date; to?: Date }; + }) => ( +
Entity Usage
), @@ -133,6 +149,10 @@ vi.mock("@/app/(dashboard)/hooks/customers/useCustomers", () => ({ useCustomers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: vi.fn(), +})); + vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({ useAgents: vi.fn(), })); @@ -166,6 +186,16 @@ describe("UsagePage", () => { const mockUseAuthorized = vi.mocked(useAuthorized); const mockUseCurrentUser = vi.mocked(useCurrentUser); const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); + const mockUseUISettings = vi.mocked(useUISettings); + const mockUpdateUiSettings = vi.mocked(networking.updateUiSettings); + + type UISettingsResult = ReturnType; + const uiSettingsResolvedWith = (values: Record): UISettingsResult => + ({ + data: { values, field_schema: { properties: {} } }, + isLoading: false, + isError: false, + }) as unknown as UISettingsResult; const mockSpendData = { results: [ @@ -373,6 +403,8 @@ describe("UsagePage", () => { isLoading: false, error: null, } as any); + mockUseUISettings.mockReturnValue(uiSettingsResolvedWith({})); + mockUpdateUiSettings.mockClear(); mockUserDailyActivityAggregatedCall.mockClear(); mockUserDailyActivityCall.mockClear(); mockTagListCall.mockClear(); @@ -1025,6 +1057,120 @@ describe("UsagePage", () => { }); }); + describe("admin-configured default date range", () => { + const firstFetchedRange = () => { + const [, from, to] = mockUserDailyActivityAggregatedCall.mock.calls[0] as [string, Date, Date, ...unknown[]]; + return { from, to }; + }; + const startOfThisMonth = () => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + }; + const endOfToday = () => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); + }; + + it("opens on the trailing 7 days when the admin has not set a default", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); + }); + const { from, to } = firstFetchedRange(); + expect(to.getTime() - from.getTime()).toBe(7 * 24 * 60 * 60 * 1000); + expect(Date.now() - to.getTime()).toBeLessThan(60 * 1000); + }); + + it("opens a read-only user's first view on the configured month-to-date range", async () => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + mockUseUISettings.mockReturnValue(uiSettingsResolvedWith({ default_usage_date_range: "month_to_date" })); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); + }); + expect(firstFetchedRange()).toEqual({ from: startOfThisMonth(), to: endOfToday() }); + }); + + it("applies the configured range to every request an admin's first view makes", async () => { + mockUseUISettings.mockReturnValue(uiSettingsResolvedWith({ default_usage_date_range: "month_to_date" })); + + renderWithProviders(); + + await waitFor(() => { + expect(mockGatewayDailyActivityCall).toHaveBeenCalledWith("test-token", startOfThisMonth(), endOfToday()); + }); + expect(firstFetchedRange()).toEqual({ from: startOfThisMonth(), to: endOfToday() }); + }); + + it("carries the configured range into the entity usage views the user navigates to", async () => { + mockUseUISettings.mockReturnValue(uiSettingsResolvedWith({ default_usage_date_range: "month_to_date" })); + + renderWithProviders(); + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + act(() => { + fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "team" } }); + }); + + const entityUsage = await screen.findByTestId("entity-usage"); + expect(entityUsage).toHaveAttribute("data-entity-type", "team"); + expect(entityUsage).toHaveAttribute("data-date-from", startOfThisMonth().toISOString()); + expect(entityUsage).toHaveAttribute("data-date-to", endOfToday().toISOString()); + }); + + it("does not fetch with the fallback range while the setting is still loading", async () => { + mockUseUISettings.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + } as unknown as UISettingsResult); + + const { rerender } = renderWithProviders(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); + + mockUseUISettings.mockReturnValue(uiSettingsResolvedWith({ default_usage_date_range: "month_to_date" })); + rerender(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); + }); + expect(firstFetchedRange()).toEqual({ from: startOfThisMonth(), to: endOfToday() }); + }); + + it("lets the user pick another range without writing the admin default back", async () => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + mockUseUISettings.mockReturnValue(uiSettingsResolvedWith({ default_usage_date_range: "month_to_date" })); + + renderWithProviders(); + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("pick-a-different-range")); + }); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); + }); + expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith( + "test-token", + new Date("2024-01-01T00:00:00Z"), + new Date("2024-01-08T00:00:00Z"), + "user-123", + ); + expect(mockUpdateUiSettings).not.toHaveBeenCalled(); + }); + }); + describe("aggregated endpoint fallback", () => { it("should fall back to paginated calls when aggregated endpoint fails", async () => { mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available")); 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 cbdfc8f39e6..b83a8178398 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 @@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; @@ -63,6 +64,7 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import UsageAIChatPanel from "./UsageAIChatPanel"; import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import { DEFAULT_USAGE_DATE_RANGE_SETTING_KEY, resolveDefaultUsageDateRange } from "./defaultUsageDateRange"; interface UsagePageProps { teams: Team[]; @@ -86,15 +88,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Separate loading states for better UX const [isDateChanging, setIsDateChanging] = useState(false); - // Create initial dates outside of state to prevent recreation - 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 { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); + const configuredDefaultRange: unknown = uiSettings?.values?.[DEFAULT_USAGE_DATE_RANGE_SETTING_KEY]; + const defaultDateValue = useMemo( + () => (isUISettingsLoading ? null : resolveDefaultUsageDateRange(configuredDefaultRange)), + [isUISettingsLoading, configuredDefaultRange], + ); + const [pickedDateValue, setPickedDateValue] = useState(null); + const dateValue: DateRangePickerValue = pickedDateValue ?? defaultDateValue ?? {}; const [fetchedTags, setFetchedTags] = useState | null>(null); // No [] default: an unresolved query must stay undefined so the customer @@ -260,7 +261,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setIsDateChanging(true); // Update date immediately for UI responsiveness - setDateValue(newValue); + setPickedDateValue(newValue); }, []); // Derived states from userSpendData diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.test.ts new file mode 100644 index 00000000000..1d9013f537f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveDefaultUsageDateRange } from "./defaultUsageDateRange"; + +const local = (y: number, m: number, d: number, time = "00:00:00.000") => + new Date(`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T${time}`); +const END_OF_DAY = "23:59:59.999"; + +describe("resolveDefaultUsageDateRange", () => { + describe("fallback when the admin has not configured a default", () => { + it.each([undefined, null, "", "last_90_days", 7, { id: "month_to_date" }])( + "opens on the trailing 7 days for %j", + (setting) => { + const now = local(2026, 3, 15, "10:30:00.000"); + + const range = resolveDefaultUsageDateRange(setting, now); + + expect(range.from).toEqual(local(2026, 3, 8, "10:30:00.000")); + expect(range.to).toEqual(now); + }, + ); + }); + + describe("month_to_date", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("starts at local midnight on the 1st of the current month and ends at the end of today", () => { + const range = resolveDefaultUsageDateRange("month_to_date", local(2026, 3, 15, "10:30:00.000")); + + expect(range.from).toEqual(local(2026, 3, 1)); + expect(range.to).toEqual(local(2026, 3, 15, END_OF_DAY)); + }); + + it("rolls over to the new month as soon as the 1st begins", () => { + const lastOfMarch = resolveDefaultUsageDateRange("month_to_date", local(2026, 3, 31, END_OF_DAY)); + const firstOfApril = resolveDefaultUsageDateRange("month_to_date", local(2026, 4, 1, "00:00:00.001")); + + expect(lastOfMarch.from).toEqual(local(2026, 3, 1)); + expect(firstOfApril.from).toEqual(local(2026, 4, 1)); + expect(firstOfApril.to).toEqual(local(2026, 4, 1, END_OF_DAY)); + }); + + it("keeps the month boundary in the viewer's local timezone rather than UTC", () => { + vi.stubEnv("TZ", "Pacific/Kiritimati"); + + const range = resolveDefaultUsageDateRange("month_to_date", new Date("2026-01-31T10:30:00.000Z")); + + expect(range.from.toISOString()).toBe("2026-01-31T10:00:00.000Z"); + expect(range.to.toISOString()).toBe("2026-02-01T09:59:59.999Z"); + }); + }); + + it.each([ + ["today", local(2026, 3, 15), local(2026, 3, 15, END_OF_DAY)], + ["last_7_days", local(2026, 3, 8), local(2026, 3, 15, END_OF_DAY)], + ["last_30_days", local(2026, 2, 13), local(2026, 3, 15, END_OF_DAY)], + ["year_to_date", local(2026, 1, 1), local(2026, 3, 15, END_OF_DAY)], + ])("resolves %s to the same window the date picker preset uses", (preset, from, to) => { + const range = resolveDefaultUsageDateRange(preset, local(2026, 3, 15, "10:30:00.000")); + + expect(range.from).toEqual(from); + expect(range.to).toEqual(to); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.ts new file mode 100644 index 00000000000..f2451f15dad --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/defaultUsageDateRange.ts @@ -0,0 +1,10 @@ +import { isDateRangePresetId, resolveDateRangePreset } from "@/components/shared/date_range_presets"; + +export const DEFAULT_USAGE_DATE_RANGE_SETTING_KEY = "default_usage_date_range"; + +const FALLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +export const resolveDefaultUsageDateRange = (setting: unknown, now: Date = new Date()): { from: Date; to: Date } => + isDateRangePresetId(setting) + ? resolveDateRangePreset(setting, now) + : { from: new Date(now.getTime() - FALLBACK_WINDOW_MS), to: now }; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index c2834e65498..01567cc86e1 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -1,5 +1,7 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { chooseSelectOption } from "@/../tests/test-utils"; import UISettings from "./UISettings"; import { toast } from "@/lib/toast"; @@ -155,4 +157,70 @@ describe("UISettings", () => { ); expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully"); }); + + describe("default Usage date range", () => { + const selectWithValue = (value: unknown) => { + const response = buildSettingsResponse(); + mockUseUISettings.mockReturnValue({ + ...response, + data: { ...response.data, values: { ...response.data.values, default_usage_date_range: value } }, + }); + }; + + it("shows the 7-day fallback when the admin has not set a default", () => { + selectWithValue(null); + + render(); + + expect(screen.getByRole("combobox", { name: "Default Usage date range" })).toHaveTextContent( + "Last 7 days (default)", + ); + }); + + it("shows the persisted preset", () => { + selectWithValue("month_to_date"); + + render(); + + expect(screen.getByRole("combobox", { name: "Default Usage date range" })).toHaveTextContent("Month to date"); + }); + + it("persists the chosen preset under default_usage_date_range", async () => { + const mutateMock = vi.fn((_settings, options) => { + options?.onSuccess?.(); + }); + mockUseUpdateUISettings.mockReturnValue({ mutate: mutateMock, isPending: false, error: null }); + selectWithValue(null); + const user = userEvent.setup(); + + render(); + await chooseSelectOption( + user, + screen.getByRole("combobox", { name: "Default Usage date range" }), + "Month to date", + ); + + expect(mutateMock).toHaveBeenCalledWith( + { default_usage_date_range: "month_to_date" }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ); + expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully"); + }); + + it("clears the setting back to the fallback with null", async () => { + const mutateMock = vi.fn(); + mockUseUpdateUISettings.mockReturnValue({ mutate: mutateMock, isPending: false, error: null }); + selectWithValue("month_to_date"); + const user = userEvent.setup(); + + render(); + await chooseSelectOption( + user, + screen.getByRole("combobox", { name: "Default Usage date range" }), + "Last 7 days (default)", + ); + + expect(mutateMock).toHaveBeenCalledWith({ default_usage_date_range: null }, expect.anything()); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 612ca05d083..22bd98160cb 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -5,7 +5,13 @@ import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdat import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { toast } from "@/lib/toast"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { + DATE_RANGE_PRESETS, + isDateRangePresetId, + type DateRangePresetId, +} from "@/components/shared/date_range_presets"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; @@ -45,6 +51,13 @@ function SettingRow({ ); } +const FALLBACK_USAGE_DATE_RANGE_LABEL = "Last 7 days (default)"; + +const usageDateRangeItems = [ + { value: null, label: FALLBACK_USAGE_DATE_RANGE_LABEL }, + ...DATE_RANGE_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), +]; + export default function UISettings() { const { accessToken } = useAuthorized(); const { data, isLoading, isError, error } = useUISettings(); @@ -65,7 +78,11 @@ export default function UISettings() { const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys; + const defaultUsageDateRangeProperty = schema?.properties?.default_usage_date_range; const values = data?.values ?? {}; + const defaultUsageDateRange: DateRangePresetId | null = isDateRangePresetId(values.default_usage_date_range) + ? values.default_usage_date_range + : null; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); const isAgentsDisabled = Boolean(values.disable_agents_for_internal_users); @@ -252,6 +269,20 @@ export default function UISettings() { ); }; + const handleChangeDefaultUsageDateRange = (value: DateRangePresetId | null) => { + updateSettings( + { default_usage_date_range: value }, + { + onSuccess: () => { + toast.success("UI settings updated successfully"); + }, + onError: (error) => { + toast.fromError(error); + }, + }, + ); + }; + const handleToggleDisableCustomApiKeys = (checked: boolean) => { updateSettings( { disable_custom_api_keys: checked }, @@ -432,6 +463,32 @@ export default function UISettings() { } /> + +
+

Default Usage date range

+

+ {defaultUsageDateRangeProperty?.description ?? + "Date range the Usage page opens with for every user, in the viewer's browser timezone. Unset falls back to the last 7 days."} +

+ +
+ { from: Date; to: Date }; -} +type RelativeTimeOption = DateRangePreset; -const relativeTimeOptions: RelativeTimeOption[] = [ - { - label: "Today", - shortLabel: "today", - getValue: () => ({ - from: moment().startOf("day").toDate(), - to: moment().endOf("day").toDate(), - }), - }, - { - label: "Last 7 days", - shortLabel: "7d", - getValue: () => ({ - from: moment().subtract(7, "days").startOf("day").toDate(), - to: moment().endOf("day").toDate(), - }), - }, - { - label: "Last 30 days", - shortLabel: "30d", - getValue: () => ({ - from: moment().subtract(30, "days").startOf("day").toDate(), - to: moment().endOf("day").toDate(), - }), - }, - { - label: "Month to date", - shortLabel: "MTD", - getValue: () => ({ - from: moment().startOf("month").toDate(), - to: moment().endOf("day").toDate(), - }), - }, - { - label: "Year to date", - shortLabel: "YTD", - getValue: () => ({ - from: moment().startOf("year").toDate(), - to: moment().endOf("day").toDate(), - }), - }, -]; +const relativeTimeOptions: readonly RelativeTimeOption[] = DATE_RANGE_PRESETS; /** * Advanced Date Range Picker with dropdown, relative times, and custom inputs diff --git a/ui/litellm-dashboard/src/components/shared/date_range_presets.ts b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts new file mode 100644 index 00000000000..99238034e96 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts @@ -0,0 +1,68 @@ +import moment from "moment"; + +export const DATE_RANGE_PRESET_IDS = ["today", "last_7_days", "last_30_days", "month_to_date", "year_to_date"] as const; + +export type DateRangePresetId = (typeof DATE_RANGE_PRESET_IDS)[number]; + +export interface DateRangePreset { + id: DateRangePresetId; + label: string; + shortLabel: string; + getValue: (now?: Date) => { from: Date; to: Date }; +} + +export const isDateRangePresetId = (value: unknown): value is DateRangePresetId => + typeof value === "string" && (DATE_RANGE_PRESET_IDS as readonly string[]).includes(value); + +const PRESETS_BY_ID: Record = { + today: { + id: "today", + label: "Today", + shortLabel: "today", + getValue: (now = new Date()) => ({ + from: moment(now).startOf("day").toDate(), + to: moment(now).endOf("day").toDate(), + }), + }, + last_7_days: { + id: "last_7_days", + label: "Last 7 days", + shortLabel: "7d", + getValue: (now = new Date()) => ({ + from: moment(now).subtract(7, "days").startOf("day").toDate(), + to: moment(now).endOf("day").toDate(), + }), + }, + last_30_days: { + id: "last_30_days", + label: "Last 30 days", + shortLabel: "30d", + getValue: (now = new Date()) => ({ + from: moment(now).subtract(30, "days").startOf("day").toDate(), + to: moment(now).endOf("day").toDate(), + }), + }, + month_to_date: { + id: "month_to_date", + label: "Month to date", + shortLabel: "MTD", + getValue: (now = new Date()) => ({ + from: moment(now).startOf("month").toDate(), + to: moment(now).endOf("day").toDate(), + }), + }, + year_to_date: { + id: "year_to_date", + label: "Year to date", + shortLabel: "YTD", + getValue: (now = new Date()) => ({ + from: moment(now).startOf("year").toDate(), + to: moment(now).endOf("day").toDate(), + }), + }, +}; + +export const DATE_RANGE_PRESETS: readonly DateRangePreset[] = DATE_RANGE_PRESET_IDS.map((id) => PRESETS_BY_ID[id]); + +export const resolveDateRangePreset = (id: DateRangePresetId, now: Date = new Date()): { from: Date; to: Date } => + PRESETS_BY_ID[id].getValue(now);