diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index d51e1167888..e04ddbc75cc 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -8,6 +8,7 @@ from types import MappingProxyType from typing import ( Annotated, Final, + Literal, NamedTuple, Protocol, cast, # noqa: TID251 # prisma types Json columns as fields.Json but de-serializes them to plain python on read @@ -256,6 +257,11 @@ class UISettings(LiteLLMBaseModel): model_config = ConfigDict(extra="allow") + usage_default_time_range: Literal["today", "last_7_days", "last_30_days", "month_to_date", "year_to_date"] = Field( + default="last_7_days", + description="Default date range when opening Usage without an explicit URL range. Calendar ranges use the browser timezone.", + ) + disable_model_add_for_internal_users: bool = Field( default=False, description=( @@ -386,6 +392,7 @@ class UISettingsResponse(SettingsResponse): # Allowlist of UI settings that can be stored ALLOWED_UI_SETTINGS_FIELDS: Final = { + "usage_default_time_range", "disable_model_add_for_internal_users", "disable_team_admin_delete_team_user", "enabled_ui_pages_internal_users", diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 512f58380ea..0d558f206d8 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -4345,3 +4345,105 @@ class TestMoyaiUrlSetting: await update_ui_settings({"moyai_url": None}, actor) assert persisted["moyai_url"] is None + + +@pytest.mark.parametrize("preset", ["today", "last_7_days", "last_30_days", "month_to_date", "year_to_date"]) +def test_usage_default_range_round_trips_without_overwriting_other_settings( + mock_auth, monkeypatch: pytest.MonkeyPatch, preset: str, +) -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.config_resolvers import SettingsStore + + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace( + ui_settings={"disable_custom_api_keys": True}, + )) + prisma.db.litellm_uisettings.upsert = AsyncMock() + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "store_model_in_db", True) + monkeypatch.setattr(proxy_server.proxy_config, "settings", SettingsStore("general_settings")) + actor: Final = UserAPIKeyAuth(user_id="test-admin", user_role=LitellmUserRoles.PROXY_ADMIN) + app.dependency_overrides[user_api_key_auth] = lambda: actor + try: + initial: Final = client.get("/get/ui_settings") + assert initial.json()["values"]["usage_default_time_range"] == "last_7_days" + response: Final = client.patch("/update/ui_settings", json={"usage_default_time_range": preset}) + assert response.status_code == 200, response.text + stored: Final = json.loads(prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["update"]["ui_settings"]) + assert stored == {"disable_custom_api_keys": True, "usage_default_time_range": preset} + prisma.db.litellm_uisettings.find_unique.return_value = SimpleNamespace(ui_settings=stored) + fetched: Final = client.get("/get/ui_settings") + assert fetched.json()["values"]["usage_default_time_range"] == preset + assert fetched.json()["source"]["usage_default_time_range"] == "db" + finally: + app.dependency_overrides.clear() + + +@pytest.mark.parametrize("submitted", ["invalid", None, 7]) +def test_usage_default_range_rejects_invalid_values_without_writing( + mock_auth, monkeypatch: pytest.MonkeyPatch, submitted: object, +) -> None: + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.upsert = AsyncMock() + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "store_model_in_db", True) + actor: Final = UserAPIKeyAuth(user_id="test-admin", user_role=LitellmUserRoles.PROXY_ADMIN) + app.dependency_overrides[user_api_key_auth] = lambda: actor + try: + response: Final = client.patch("/update/ui_settings", json={"usage_default_time_range": submitted}) + assert response.status_code == 422, response.text + prisma.db.litellm_uisettings.upsert.assert_not_called() + finally: + app.dependency_overrides.clear() + + +@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) +def test_usage_default_range_cannot_be_changed_by_non_admins( + mock_auth, monkeypatch: pytest.MonkeyPatch, role: LitellmUserRoles, +) -> None: + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.upsert = AsyncMock() + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "store_model_in_db", True) + actor: Final = UserAPIKeyAuth(user_id="test-user", user_role=role) + app.dependency_overrides[user_api_key_auth] = lambda: actor + try: + response: Final = client.patch("/update/ui_settings", json={"usage_default_time_range": "month_to_date"}) + assert response.status_code == 403, response.text + prisma.db.litellm_uisettings.upsert.assert_not_called() + finally: + app.dependency_overrides.clear() + + +def test_usage_default_range_reports_config_precedence(mock_auth, monkeypatch: pytest.MonkeyPatch) -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy.config_resolvers import SettingsStore + + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace( + ui_settings={"usage_default_time_range": "today"}, + )) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + settings: Final = SettingsStore("general_settings") + settings.load_yaml({"usage_default_time_range": "month_to_date"}) + monkeypatch.setattr(proxy_server.proxy_config, "settings", settings) + response: Final = client.get("/get/ui_settings") + assert response.status_code == 200, response.text + assert response.json()["values"]["usage_default_time_range"] == "month_to_date" + assert response.json()["source"]["usage_default_time_range"] == "config" diff --git a/ui/litellm-dashboard/AGENTS.md b/ui/litellm-dashboard/AGENTS.md index b6fe5219022..bbf2e49aad7 100644 --- a/ui/litellm-dashboard/AGENTS.md +++ b/ui/litellm-dashboard/AGENTS.md @@ -30,7 +30,7 @@ Type tests are `*.test-d.ts` files run by the `types` vitest project (`npm run t -# This is NOT the Next.js you know +## This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. 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 24e9936a1d0..adb6d8791a7 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 @@ -1914,6 +1914,61 @@ describe("UsagePage", () => { ); }); + it("queries the configured month and preserves later manual selections", async () => { + const now = new Date(2026, 9, 24, 12); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + try { + renderWithProviders(); + await waitFor(() => + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + expect.objectContaining({ + startTime: new Date(2026, 9, 1), + endTime: new Date(2026, 9, 24, 23, 59, 59, 999), + }), + ), + ); + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByTestId("pick-a-different-range")); + await waitFor(() => + expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + startTime: new Date(2024, 0, 1), + endTime: new Date(2024, 0, 8, 23, 59, 59, 999), + }), + ), + ); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["?range=today", "?from=2025-03-01&to=2025-03-05"])( + "preserves explicit dates over configured defaults for %s", + async (searchParams) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 9, 24, 12)); + try { + const view = renderWithProviders(, { + searchParams, + }); + const startTime = searchParams.includes("range=today") ? new Date(2026, 9, 24) : new Date(2025, 2, 1); + const endTime = searchParams.includes("range=today") + ? new Date(2026, 9, 24, 23, 59, 59, 999) + : new Date(2025, 2, 5, 23, 59, 59, 999); + await waitFor(() => + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + expect.objectContaining({ startTime, endTime }), + ), + ); + view.rerender(); + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }, + ); + it("fetches the custom range in the URL and writes a newly picked one", async () => { const { lastQuery } = renderAt("?from=2025-03-01&to=2025-03-05"); 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 3ff758a3c18..d7d3b502b6f 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 @@ -82,6 +82,7 @@ import { clientDateRangePatch, clientPanelPatch, dateRangeFromParams, + defaultUsageDateRange, dateRangePatch, entitySelectionPatch, selectedEntitiesFromParams, @@ -108,23 +109,24 @@ interface UsagePageProps { teams: Team[]; organizations: Organization[]; teamsLoading?: boolean; + defaultTimeRange?: string; } -const UsagePage: React.FC = ({ teams, organizations, teamsLoading = false }) => { +const UsagePage: React.FC = ({ teams, organizations, teamsLoading = false, defaultTimeRange }) => { const { accessToken, userRole, userId: userID, premiumUser, isLoading: authorizationLoading } = useAuthorized(); const [gatewayActivityData, setGatewayActivityData] = useState(null); - // 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(), []); + const defaultRange = useMemo(() => defaultUsageDateRange(defaultTimeRange), [defaultTimeRange]); const [urlParams, setUrlParams] = useQueryStates(usageUrlParsers); const { range, from, to } = urlParams; const dateValue = useMemo( - () => dateRangeFromParams({ range, from, to }) ?? { from: initialFromDate, to: initialToDate }, - [range, from, to, initialFromDate, initialToDate], + () => dateRangeFromParams({ range, from, to }) ?? defaultRange.value, + [range, from, to, defaultRange], ); + const presetShortLabel = range ?? (from === null && to === null ? defaultRange.preset : null); + const { client_range, client_from, client_to } = urlParams; const clientDateValue = useMemo( () => clientDateRangeFromParams({ client_range, client_from, client_to }) ?? dateValue, @@ -481,7 +483,11 @@ const UsagePage: React.FC = ({ teams, organizations, teamsLoadin )} - + void copyToClipboard(window.location.href, "Link copied")}> Copy Share Link @@ -815,7 +821,7 @@ const UsagePage: React.FC = ({ teams, organizations, teamsLoadin startTime={clientStartTime} endTime={clientEndTime} dateValue={clientDateValue} - presetShortLabel={client_range ?? (clientInheritsRange ? urlParams.range : null)} + presetShortLabel={client_range ?? (clientInheritsRange ? presetShortLabel : null)} onDateChange={(value, preset) => void setUrlParams(clientDateRangePatch(value, preset), { scroll: false })} agent={clientInheritsRange ? clientAgent : null} daily={clientInheritsRange ? tagDaily : []} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts index 1255ea03dd9..b84d20ac11b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts @@ -1,6 +1,6 @@ import moment from "moment"; import { createLoader, createSerializer } from "nuqs"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { UsageClient } from "./components/overview/agentCatalog"; import type { UsageOption } from "./components/UsageViewSelect/UsageViewSelect"; @@ -11,6 +11,7 @@ import { clientDateRangePatch, clientPanelPatch, dateRangeFromParams, + defaultUsageDateRange, dateRangePatch, entitySelectionPatch, selectedEntitiesFromParams, @@ -371,3 +372,79 @@ describe("client team scope", () => { expect(query).toBe("?view=team&team=engineering"); }); }); + +describe("configured Usage default", () => { + afterEach(() => vi.useRealTimers()); + + it.each([undefined, null, "unknown", "last_7_days"])("preserves the rolling seven-day fallback for %s", (setting) => { + vi.useFakeTimers(); + const now = new Date(2026, 9, 24, 12, 34, 56, 789); + vi.setSystemTime(now); + expect(defaultUsageDateRange(setting)).toEqual({ + value: { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }, + preset: null, + }); + }); + + it.each([ + ["America/Los_Angeles", 2, 9], + ["Europe/London", 2, 30], + ])("preserves untouched fallback dates after spring DST in %s", (timezone, month, day) => { + withTimezone(timezone, () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, month, day, 0, 30)); + for (const setting of [undefined, "last_7_days"]) { + const fallback = defaultUsageDateRange(setting); + const restored = dateRangeFromParams(load(applyPatch("", dateRangePatch(fallback.value, fallback.preset)))); + expect(moment(restored?.from).format("YYYY-MM-DD")).toBe(moment(fallback.value.from).format("YYYY-MM-DD")); + expect(moment(restored?.to).format("YYYY-MM-DD")).toBe(moment(fallback.value.to).format("YYYY-MM-DD")); + } + }); + }); + + it.each(["UTC", "America/Los_Angeles", "Europe/London", "Asia/Tokyo"])( + "uses local calendar boundaries in %s", + (timezone) => { + withTimezone(timezone, () => { + vi.useFakeTimers(); + for (const [year, month, day] of [ + [2024, 1, 29], + [2026, 2, 8], + [2026, 10, 1], + [2026, 11, 31], + [2027, 0, 1], + ]) { + vi.setSystemTime(new Date(year, month, day, 12)); + const end = new Date(year, month, day, 23, 59, 59, 999); + expect(defaultUsageDateRange("month_to_date")).toEqual({ + value: { from: new Date(year, month, 1), to: end }, + preset: "MTD", + }); + expect(defaultUsageDateRange("year_to_date")).toEqual({ + value: { from: new Date(year, 0, 1), to: end }, + preset: "YTD", + }); + expect(defaultUsageDateRange("today")).toEqual({ + value: { from: new Date(year, month, day), to: end }, + preset: "today", + }); + expect(defaultUsageDateRange("last_30_days")).toEqual({ + value: { from: new Date(year, month, day - 30), to: end }, + preset: "30d", + }); + } + }); + }, + ); + + it("recalculates month to date across midnight instead of storing absolute dates", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 11, 31, 23, 59)); + expect(defaultUsageDateRange("month_to_date").value.from).toEqual(new Date(2026, 11, 1)); + vi.setSystemTime(new Date(2027, 0, 1)); + expect(defaultUsageDateRange("month_to_date")).toEqual({ + value: { from: new Date(2027, 0, 1), to: new Date(2027, 0, 1, 23, 59, 59, 999) }, + preset: "MTD", + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts index 2af113412d9..19dd5c82dee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts @@ -2,7 +2,7 @@ import moment from "moment"; import { parseAsNativeArrayOf, parseAsString, parseAsStringEnum, type inferParserType } from "nuqs"; import type { EntityType } from "@/components/EntityUsageExport/types"; -import { relativeTimeOptions } from "@/components/shared/advanced_date_picker"; +import { relativeTimeOptions } from "@/components/shared/date_range_presets"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { uiHref } from "@/utils/uiHref"; import { USAGE_OPTIONS, type UsageOption } from "./components/UsageViewSelect/UsageViewSelect"; @@ -132,6 +132,15 @@ export const dateRangeFromParams = (params: DateParams): DateRangePickerValue | return customRangeFromParams(params.from, params.to); }; +export const defaultUsageDateRange = (setting: unknown): { value: DateRangePickerValue; preset: string | null } => { + const option = relativeTimeOptions.find((candidate) => candidate.settingValue === setting); + if (!option || option.settingValue === "last_7_days") { + const now = new Date(); + return { value: { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }, preset: null }; + } + return { value: option.getValue(), preset: option.shortLabel }; +}; + export const dateRangePatch = (value: DateRangePickerValue, presetShortLabel: string | null): UsageUrlPatch => { if (presetShortLabel !== null && relativeTimeOptions.some((option) => option.shortLabel === presetShortLabel)) { return { range: presetShortLabel, from: null, to: null }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.test.tsx new file mode 100644 index 00000000000..2305565f7de --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.test.tsx @@ -0,0 +1,51 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/../tests/test-utils"; +import UsagePage from "./page"; + +const settings = vi.hoisted(() => vi.fn()); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({}) })); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: settings })); +vi.mock("./_components/components/UsagePageView", () => ({ + default: ({ defaultTimeRange }: { defaultTimeRange?: string }) => ( + Usage range: {defaultTimeRange ?? "last_7_days"} + ), +})); + +describe("Usage settings readiness", () => { + beforeEach(() => settings.mockReturnValue({ isLoading: true })); + + it("waits for the configured range before mounting the usage data consumers", () => { + const { rerender } = renderWithProviders(); + expect(screen.getByRole("status", { name: "Loading usage settings" })).toBeInTheDocument(); + expect(screen.queryByText(/Usage range/)).not.toBeInTheDocument(); + settings.mockReturnValue({ isLoading: false, data: { values: { usage_default_time_range: "month_to_date" } } }); + rerender(); + expect(screen.getByText("Usage range: month_to_date")).toBeInTheDocument(); + }); + + it.each(["?range=MTD", "?from=2026-01-01&to=2026-01-15"])( + "does not delay an explicit URL range %s", + (searchParams) => { + renderWithProviders(, { searchParams }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByText(/Usage range/)).toBeInTheDocument(); + }, + ); + + it.each(["?range=unknown", "?from=2026-02-30&to=2026-03-01"])( + "waits for settings when the URL range is invalid %s", + (searchParams) => { + renderWithProviders(, { searchParams }); + expect(screen.getByRole("status", { name: "Loading usage settings" })).toBeInTheDocument(); + }, + ); + + it("uses the existing fallback if settings cannot be fetched", () => { + settings.mockReturnValue({ isLoading: false, isError: true }); + renderWithProviders(); + expect(screen.getByText("Usage range: last_7_days")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx index a719131678f..39fe65f1cb5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx @@ -5,9 +5,31 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useQueryStates } from "nuqs"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { Skeleton } from "@/components/ui/skeleton"; +import { dateRangeFromParams, usageUrlParsers } from "./_components/usageUrlState"; + export default function UsagePage() { useAuthorized(); const { data: teams, isLoading: teamsLoading } = useTeams(); const { data: organizations } = useOrganizations(); - return ; + const { data: settings, isLoading: settingsLoading } = useUISettings(); + const [params] = useQueryStates(usageUrlParsers); + if (settingsLoading && !dateRangeFromParams(params)) { + return ( + + + + ); + } + const defaultTimeRange: unknown = settings?.values?.usage_default_time_range; + return ( + + ); } 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 6a491f6160f..2aa5c738880 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 { beforeEach, describe, expect, it, vi } from "vitest"; +import { chooseSelectOption } from "@/../tests/test-utils"; +import userEvent from "@testing-library/user-event"; import UISettings from "./UISettings"; import { toast } from "@/lib/toast"; @@ -60,6 +62,34 @@ describe("UISettings", () => { }); }); + it("saves the selected deployment default through existing UI settings", async () => { + const mutate = vi.fn(); + mockUseUpdateUISettings.mockReturnValue({ mutate, isPending: false, error: null }); + render(); + expect(screen.getByRole("combobox", { name: "Default Usage time range" })).toHaveTextContent("Last 7 days"); + await chooseSelectOption( + userEvent.setup(), + screen.getByRole("combobox", { name: "Default Usage time range" }), + "Month to date", + ); + expect(mutate).toHaveBeenCalledWith({ usage_default_time_range: "month_to_date" }, expect.any(Object)); + }); + + it("shows configuration-owned defaults without offering an ineffective override", () => { + mockUseUISettings.mockReturnValue( + buildSettingsResponse({ + data: { + values: { usage_default_time_range: "year_to_date" }, + source: { usage_default_time_range: "config" }, + }, + }), + ); + render(); + expect(screen.getByRole("combobox", { name: "Default Usage time range" })).toHaveTextContent("Year to date"); + expect(screen.getByRole("combobox", { name: "Default Usage time range" })).toBeDisabled(); + expect(screen.getByText("Managed by the deployment configuration.")).toBeInTheDocument(); + }); + it("should render", () => { render(); 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 476a4f2f6b8..9d1839117d1 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -11,6 +11,9 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { uiHref } from "@/utils/uiHref"; +import { relativeTimeOptions } from "@/components/shared/date_range_presets"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; import PageVisibilitySettings from "./PageVisibilitySettings"; interface SettingRowProps { @@ -68,6 +71,10 @@ export default function UISettings() { const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys; const values = data?.values ?? {}; + const usageRange = + relativeTimeOptions.find((option) => option.settingValue === values.usage_default_time_range)?.settingValue ?? + "last_7_days"; + const usageRangeConfigOwned = data?.source?.usage_default_time_range === "config"; const moyaiUrl = (values.moyai_url as string | undefined) ?? null; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -312,6 +319,46 @@ export default function UISettings() { )} + + Default Usage time range + + The starting range for everyone opening Usage. Shared links and manual date selections take precedence. + Calendar ranges use your browser’s timezone. + + ({ value: option.settingValue, label: option.label }))} + onValueChange={(value) => { + if (value === null) return; + updateSettings( + { usage_default_time_range: value }, + { + onSuccess: () => toast.success("UI settings updated successfully"), + onError: (error) => toast.fromError(error), + }, + ); + }} + > + + + + + {relativeTimeOptions.map((option) => ( + + {option.label} + + ))} + + + {usageRangeConfigOwned && ( + Managed by the deployment configuration. + )} + { } }; + it.each([ + ["MTD", "Month to date"], + ["YTD", "Year to date"], + ])("keeps %s selected when calendar ranges coincide", (preset, label) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2027, 0, 1, 12)); + try { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "1 Jan, 00:00 - 1 Jan, 23:59" })); + expect(screen.getByRole("button", { name: new RegExp(label) })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: /Today/ })).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(screen.getByRole("button", { name: /Today/ })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + fireEvent.click(screen.getByRole("button", { name: "1 Jan, 00:00 - 1 Jan, 23:59" })); + expect(screen.getByRole("button", { name: new RegExp(label) })).toHaveAttribute("aria-pressed", "true"); + } finally { + vi.useRealTimers(); + } + }); + it("should render with default label", () => { render(); expect(screen.getByText("Select Time Range")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 27ed237720f..a16e50bfaad 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -5,6 +5,9 @@ import type { DateRangePickerValue } from "./date_picker_types"; import moment from "moment"; import React, { useCallback, useEffect, useId, useRef, useState } from "react"; +import { relativeTimeOptions, type RelativeTimeOption } from "./date_range_presets"; +export { relativeTimeOptions, type RelativeTimeOption } from "./date_range_presets"; + interface AdvancedDatePickerProps { value: DateRangePickerValue; onValueChange: (value: DateRangePickerValue, presetShortLabel: string | null) => void; @@ -16,55 +19,6 @@ interface AdvancedDatePickerProps { compact?: boolean; } -export interface RelativeTimeOption { - label: string; - shortLabel: string; - getValue: () => { from: Date; to: Date }; -} - -export const relativeTimeOptions: readonly 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(), - }), - }, -]; - export function matchRelativeTimeOption(value: DateRangePickerValue): RelativeTimeOption | undefined { const { from, to } = value; if (!from || !to) return undefined; @@ -107,8 +61,8 @@ const AdvancedDatePicker: React.FC = ({ // Update selected option when value changes useEffect(() => { const matchingOption = getMatchingOption(value); - setSelectedOption(matchingOption); - }, [value, getMatchingOption]); + setSelectedOption(presetShortLabel ?? matchingOption); + }, [value, presetShortLabel, getMatchingOption]); // Validation logic - simplified for dates only const validateDateRange = useCallback(() => { @@ -230,13 +184,13 @@ const AdvancedDatePicker: React.FC = ({ // Check if this matches any preset option const matchingOption = getMatchingOption(newValue); - setSelectedOption(matchingOption); + setSelectedOption(pickedPreset ?? matchingOption); } } } catch (error) { console.warn("Invalid date format:", error); } - }, [startDate, endDate, validation.isValid, getMatchingOption]); + }, [startDate, endDate, validation.isValid, getMatchingOption, pickedPreset]); // Update tempValue when inputs change useEffect(() => { @@ -275,7 +229,7 @@ const AdvancedDatePicker: React.FC = ({ // Reset selected option const matchingOption = getMatchingOption(value); - setSelectedOption(matchingOption); + setSelectedOption(presetShortLabel ?? matchingOption); setPickedPreset(presetShortLabel ?? null); setIsOpen(false); 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..30a32f2519b --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts @@ -0,0 +1,56 @@ +import moment from "moment"; + +export interface RelativeTimeOption { + label: string; + shortLabel: string; + settingValue: string; + getValue: () => { from: Date; to: Date }; +} + +export const relativeTimeOptions: readonly RelativeTimeOption[] = [ + { + label: "Today", + shortLabel: "today", + settingValue: "today", + getValue: () => ({ + from: moment().startOf("day").toDate(), + to: moment().endOf("day").toDate(), + }), + }, + { + label: "Last 7 days", + shortLabel: "7d", + settingValue: "last_7_days", + getValue: () => ({ + from: moment().subtract(7, "days").startOf("day").toDate(), + to: moment().endOf("day").toDate(), + }), + }, + { + label: "Last 30 days", + shortLabel: "30d", + settingValue: "last_30_days", + getValue: () => ({ + from: moment().subtract(30, "days").startOf("day").toDate(), + to: moment().endOf("day").toDate(), + }), + }, + { + label: "Month to date", + shortLabel: "MTD", + settingValue: "month_to_date", + getValue: () => ({ + from: moment().startOf("month").toDate(), + to: moment().endOf("day").toDate(), + }), + }, + { + label: "Year to date", + shortLabel: "YTD", + settingValue: "year_to_date", + getValue: () => ({ + from: moment().startOf("year").toDate(), + to: moment().endOf("day").toDate(), + }), + }, +];
+ The starting range for everyone opening Usage. Shared links and manual date selections take precedence. + Calendar ranges use your browser’s timezone. +
Managed by the deployment configuration.