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