mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
4710c3b30e
commit
08ef4f40b1
14 changed files with 508 additions and 66 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 : []}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
51
ui/litellm-dashboard/src/app/(dashboard)/usage/page.test.tsx
Normal file
51
ui/litellm-dashboard/src/app/(dashboard)/usage/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue