feat(ui): configure the default Usage time range (#48207)

* feat(ui): configure the default Usage time range

* fix(ui): retain the selected calendar preset during input sync

* fix(ui): preserve fallback dates when applying across DST
This commit is contained in:
moe-berri 2026-10-24 12:20:13 -07:00 • committed by GitHub
parent 4710c3b30e
commit 08ef4f40b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 508 additions and 66 deletions

View file

@ -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",

View file

@ -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"

View file

@ -30,7 +30,7 @@ Type tests are `*.test-d.ts` files run by the `types` vitest project (`npm run t
<!-- BEGIN:nextjs-agent-rules -->
# 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.

View file

@ -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(<UsagePage {...defaultProps} defaultTimeRange="month_to_date" />);
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(<UsagePage {...defaultProps} defaultTimeRange="month_to_date" />, {
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(<UsagePage {...defaultProps} defaultTimeRange="year_to_date" />);
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");

View file

@ -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<UsagePageProps> = ({ teams, organizations, teamsLoading = false }) => {
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoading = false, defaultTimeRange }) => {
const { accessToken, userRole, userId: userID, premiumUser, isLoading: authorizationLoading } = useAuthorized();
const [gatewayActivityData, setGatewayActivityData] = useState<FetchedGatewayActivity | null>(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<DateRangePickerValue>(
() => 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<DateRangePickerValue>(
() => clientDateRangeFromParams({ client_range, client_from, client_to }) ?? dateValue,
@ -481,7 +483,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
</div>
)}
<div className="flex items-center gap-2">
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} presetShortLabel={urlParams.range} />
<AdvancedDatePicker
value={dateValue}
onValueChange={handleDateChange}
presetShortLabel={presetShortLabel}
/>
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
<Link2 />
Copy Share Link
@ -815,7 +821,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ 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 : []}

View file

@ -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",
});
});
});

View file

@ -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 };

View file

@ -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 }) => (
<div>Usage range: {defaultTimeRange ?? "last_7_days"}</div>
),
}));
describe("Usage settings readiness", () => {
beforeEach(() => settings.mockReturnValue({ isLoading: true }));
it("waits for the configured range before mounting the usage data consumers", () => {
const { rerender } = renderWithProviders(<UsagePage />);
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(<UsagePage />);
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(<UsagePage />, { 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(<UsagePage />, { 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(<UsagePage />);
expect(screen.getByText("Usage range: last_7_days")).toBeInTheDocument();
});
});

View file

@ -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 <NewUsagePage teams={teams ?? []} organizations={organizations ?? []} teamsLoading={teamsLoading} />;
const { data: settings, isLoading: settingsLoading } = useUISettings();
const [params] = useQueryStates(usageUrlParsers);
if (settingsLoading && !dateRangeFromParams(params)) {
return (
<div role="status" aria-label="Loading usage settings" className="p-6">
<Skeleton className="h-64 w-full" />
</div>
);
}
const defaultTimeRange: unknown = settings?.values?.usage_default_time_range;
return (
<NewUsagePage
teams={teams ?? []}
organizations={organizations ?? []}
teamsLoading={teamsLoading}
defaultTimeRange={typeof defaultTimeRange === "string" ? defaultTimeRange : undefined}
/>
);
}

View file

@ -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(<UISettings />);
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(<UISettings />);
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(<UISettings />);

View file

@ -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() {
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="usage-default-time-range">Default Usage time range</Label>
<p id="usage-default-time-range-help" className="text-sm text-muted-foreground">
The starting range for everyone opening Usage. Shared links and manual date selections take precedence.
Calendar ranges use your browser’s timezone.
</p>
<Select
value={usageRange}
disabled={isUpdating || usageRangeConfigOwned}
items={relativeTimeOptions.map((option) => ({ 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),
},
);
}}
>
<SelectTrigger
id="usage-default-time-range"
aria-describedby="usage-default-time-range-help"
className="w-64"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{relativeTimeOptions.map((option) => (
<SelectItem key={option.settingValue} value={option.settingValue}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{usageRangeConfigOwned && (
<p className="text-sm text-muted-foreground">Managed by the deployment configuration.</p>
)}
</div>
<SettingRow
checked={isDisabledForInternalUsers}
disabled={isUpdating}

View file

@ -39,6 +39,32 @@ describe("AdvancedDatePicker", () => {
}
};
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(
<AdvancedDatePicker
value={{ from: new Date(2027, 0, 1), to: new Date(2027, 0, 1, 23, 59, 59, 999) }}
onValueChange={mockOnValueChange}
presetShortLabel={preset}
/>,
);
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(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
expect(screen.getByText("Select Time Range")).toBeInTheDocument();

View file

@ -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<AdvancedDatePickerProps> = ({
// 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<AdvancedDatePickerProps> = ({
// 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<AdvancedDatePickerProps> = ({
// Reset selected option
const matchingOption = getMatchingOption(value);
setSelectedOption(matchingOption);
setSelectedOption(presetShortLabel ?? matchingOption);
setPickedPreset(presetShortLabel ?? null);
setIsOpen(false);

View file

@ -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(),
}),
},
];