From 809b63b9f37567b35704c4b52ec9ba0318db4950 Mon Sep 17 00:00:00 2001 From: spions Date: Fri, 7 Aug 2026 15:01:56 +0300 Subject: [PATCH 1/4] feat(ui): persist usage page time range and activity tab across reload /ui/usage previously reset its "Select Time Range" and activity tab (Model Activity, Key Activity, etc.) to defaults on every page reload, and relative presets like "Today"/"7d" were frozen at initial mount instead of tracking the actual current day. Extracted the relative-time-preset logic from AdvancedDatePicker into a shared module, and used it plus localStorage to: persist the selected preset or custom range and the selected usage view/tab, and recompute relative presets fresh on load and on tab focus/visibility change so "now" always resolves to today. --- .../_components/components/UsagePageView.tsx | 124 ++++++++++++++++-- .../shared/advanced_date_picker.tsx | 71 +--------- .../components/shared/date_range_presets.ts | 79 +++++++++++ 3 files changed, 197 insertions(+), 77 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/date_range_presets.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index c3645d6371e..1e40e374c3e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -48,6 +48,8 @@ import { } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { ChartLoader } from "@/components/shared/chart_loader"; +import { getMatchingRelativeOption, getRelativeRangeByShortLabel } from "@/components/shared/date_range_presets"; +import { getLocalStorageItem, removeLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils"; import { Tag } from "@/components/tag_management/types"; import UserAgentActivity from "@/components/user_agent_activity"; import ViewUserSpend from "@/components/view_user_spend"; @@ -76,6 +78,77 @@ interface UsagePageProps { organizations: Organization[]; } +const USAGE_DATE_RANGE_PRESET_KEY = "litellmUsageDateRangePreset"; +const USAGE_DATE_RANGE_CUSTOM_KEY = "litellmUsageDateRangeCustom"; +const USAGE_VIEW_KEY = "litellmUsageView"; +const USAGE_ACTIVITY_TAB_KEY = "litellmUsageActivityTabIndex"; + +const VALID_USAGE_OPTIONS: UsageOption[] = [ + "global", + "my-usage", + "organization", + "team", + "customer", + "tag", + "agent", + "user", + "user-agent-activity", +]; + +const DEFAULT_DATE_RANGE = (): DateRangePickerValue => ({ + from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + to: new Date(), +}); + +/** Resolves the persisted time range. Relative presets ("7d", "today", ...) are + * recomputed fresh from `now` on every load, so a saved preset never goes stale. */ +function loadPersistedDateRange(): DateRangePickerValue { + const presetLabel = getLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY); + if (presetLabel) { + const range = getRelativeRangeByShortLabel(presetLabel); + if (range) return range; + } + + const customRaw = getLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY); + if (customRaw) { + try { + const { from, to } = JSON.parse(customRaw) as { from: string; to: string }; + const fromDate = new Date(from); + const toDate = new Date(to); + if (!isNaN(fromDate.getTime()) && !isNaN(toDate.getTime())) { + return { from: fromDate, to: toDate }; + } + } catch { + // fall through to default + } + } + + return DEFAULT_DATE_RANGE(); +} + +function persistDateRange(value: DateRangePickerValue): void { + const presetLabel = getMatchingRelativeOption(value); + if (presetLabel) { + setLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY, presetLabel); + removeLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY); + return; + } + removeLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY); + if (value.from && value.to) { + setLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY, JSON.stringify({ from: value.from, to: value.to })); + } +} + +function loadPersistedUsageView(): UsageOption { + const stored = getLocalStorageItem(USAGE_VIEW_KEY); + return VALID_USAGE_OPTIONS.includes(stored as UsageOption) ? (stored as UsageOption) : "global"; +} + +function loadPersistedActivityTabIndex(): number { + const stored = Number(getLocalStorageItem(USAGE_ACTIVITY_TAB_KEY)); + return Number.isInteger(stored) && stored >= 0 && stored <= 4 ? stored : 0; +} + const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); // Aggregated endpoint: try first, fall back to paginated if unavailable @@ -93,15 +166,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Separate loading states for better UX const [isDateChanging, setIsDateChanging] = useState(false); - // Create initial dates outside of state to prevent recreation - const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []); - const initialToDate = useMemo(() => new Date(), []); - - // Single date state that directly triggers data fetching - const [dateValue, setDateValue] = useState({ - from: initialFromDate, - to: initialToDate, - }); + // Single date state that directly triggers data fetching. Initialized from + // localStorage so a saved selection survives a page reload; relative presets + // ("7d", "today", ...) are recomputed fresh here rather than replayed as a + // frozen timestamp, so "now" always tracks the actual current day. + const [dateValue, setDateValue] = useState(() => loadPersistedDateRange()); const [allTags, setAllTags] = useState([]); const { data: customers = [] } = useCustomers(); @@ -165,7 +234,16 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); - const [usageView, setUsageView] = useState("global"); + const [usageView, setUsageViewState] = useState(() => loadPersistedUsageView()); + const setUsageView = useCallback((value: UsageOption) => { + setUsageViewState(value); + setLocalStorageItem(USAGE_VIEW_KEY, value); + }, []); + const [activityTabIndex, setActivityTabIndexState] = useState(() => loadPersistedActivityTabIndex()); + const setActivityTabIndex = useCallback((index: number) => { + setActivityTabIndexState(index); + setLocalStorageItem(USAGE_ACTIVITY_TAB_KEY, String(index)); + }, []); const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); @@ -297,8 +375,32 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Update date immediately for UI responsiveness setDateValue(newValue); + persistDateRange(newValue); }, []); + // If the user leaves this tab open across midnight (or backgrounded on + // mobile), a relative preset like "Today"/"7d" should track the real + // current day again once they come back, not stay pinned to load time. + useEffect(() => { + const refreshPresetIfStale = () => { + const presetLabel = getMatchingRelativeOption(dateValue); + if (!presetLabel) return; + const freshRange = getRelativeRangeByShortLabel(presetLabel); + if (!freshRange) return; + if (freshRange.from.getTime() !== dateValue.from?.getTime() || freshRange.to.getTime() !== dateValue.to?.getTime()) { + setDateValue(freshRange); + persistDateRange(freshRange); + } + }; + + document.addEventListener("visibilitychange", refreshPresetIfStale); + window.addEventListener("focus", refreshPresetIfStale); + return () => { + document.removeEventListener("visibilitychange", refreshPresetIfStale); + window.removeEventListener("focus", refreshPresetIfStale); + }; + }, [dateValue]); + // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -588,7 +690,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> )} - +
Cost diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 013ccd118e9..30969a4cef0 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -2,6 +2,7 @@ import { CalendarOutlined, ClockCircleOutlined } from "@ant-design/icons"; import { Button, DateRangePickerValue, Text } from "@tremor/react"; import moment from "moment"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { getMatchingRelativeOption, RelativeTimeOption, relativeTimeOptions } from "@/components/shared/date_range_presets"; interface AdvancedDatePickerProps { value: DateRangePickerValue; @@ -11,55 +12,6 @@ interface AdvancedDatePickerProps { showTimeRange?: boolean; } -interface RelativeTimeOption { - label: string; - shortLabel: string; - getValue: () => { from: Date; to: Date }; -} - -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(), - }), - }, -]; - /** * Advanced Date Range Picker with dropdown, relative times, and custom inputs */ @@ -80,23 +32,10 @@ const AdvancedDatePicker: React.FC = ({ const dropdownRef = useRef(null); // Function to check if current value matches a relative time option - const getMatchingOption = useCallback((currentValue: DateRangePickerValue): string | null => { - if (!currentValue.from || !currentValue.to) return null; - - for (const option of relativeTimeOptions) { - const optionRange = option.getValue(); - - // Compare dates with some tolerance (to account for time differences) - const fromMatches = moment(currentValue.from).isSame(moment(optionRange.from), "day"); - const toMatches = moment(currentValue.to).isSame(moment(optionRange.to), "day"); - - if (fromMatches && toMatches) { - return option.shortLabel; - } - } - - return null; - }, []); + const getMatchingOption = useCallback( + (currentValue: DateRangePickerValue): string | null => getMatchingRelativeOption(currentValue), + [], + ); // Update selected option when value changes useEffect(() => { diff --git a/ui/litellm-dashboard/src/components/shared/date_range_presets.ts b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts new file mode 100644 index 00000000000..a19b27aa44a --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts @@ -0,0 +1,79 @@ +import moment from "moment"; + +// Structurally compatible with @tremor/react's DateRangePickerValue, defined +// locally so this module doesn't need a (now-discouraged) tremor import. +interface DateRangeLike { + from?: Date; + to?: Date; +} + +export interface RelativeTimeOption { + label: string; + shortLabel: string; + getValue: () => { from: Date; to: Date }; +} + +export 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(), + }), + }, +]; + +/** Returns the shortLabel of the relative preset that matches `value`, or null if it's a custom range. */ +export function getMatchingRelativeOption(value: DateRangeLike): string | null { + if (!value.from || !value.to) return null; + + for (const option of relativeTimeOptions) { + const optionRange = option.getValue(); + const fromMatches = moment(value.from).isSame(moment(optionRange.from), "day"); + const toMatches = moment(value.to).isSame(moment(optionRange.to), "day"); + if (fromMatches && toMatches) { + return option.shortLabel; + } + } + + return null; +} + +/** Recomputes a fresh {from, to} for a stored preset shortLabel (e.g. "today" always resolves to the current day). */ +export function getRelativeRangeByShortLabel(shortLabel: string): { from: Date; to: Date } | null { + const option = relativeTimeOptions.find((o) => o.shortLabel === shortLabel); + return option ? option.getValue() : null; +} From d1c3e7bcc5f465e704c7013b83ea7b7a32fd8da2 Mon Sep 17 00:00:00 2001 From: spions Date: Fri, 7 Aug 2026 17:15:22 +0300 Subject: [PATCH 2/4] fix(ui): close date-range Apply dropdown in Safari handleApply() called setIsOpen(false) after requestIdleCallback(), which Safari has never implemented. There the call threw a ReferenceError that silently skipped the close, leaving the dropdown stuck open on every Apply click. Moved the close before the scheduling call and added a setTimeout fallback so the background date adjustment still runs there too. --- .../shared/advanced_date_picker.test.tsx | 39 +++++++++++++++++++ .../shared/advanced_date_picker.tsx | 23 ++++++----- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx index 90d75df75e4..b2824e02390 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx @@ -180,4 +180,43 @@ describe("AdvancedDatePicker", () => { expect(applyButton).toBeDisabled(); }); }); + + it("should close dropdown when Apply is clicked", async () => { + const { container } = render(); + + openDropdown(container); + expect(screen.getByText("Today")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Apply")); + + await waitFor(() => { + expect(screen.queryByText("Today")).not.toBeInTheDocument(); + }); + }); + + it("should close dropdown on Apply even without requestIdleCallback (Safari)", async () => { + // Safari has never implemented requestIdleCallback. Simulate that by + // removing the polyfill installed in the beforeAll above for this one + // test, so a regression that puts setIsOpen(false) after the + // requestIdleCallback call (and lets its ReferenceError swallow it) + // fails here instead of only in real Safari. + const original = window.requestIdleCallback; + // @ts-expect-error - deleting a browser API to emulate Safari's absence of it + delete window.requestIdleCallback; + + try { + const { container } = render(); + + openDropdown(container); + expect(screen.getByText("Today")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Apply")); + + await waitFor(() => { + expect(screen.queryByText("Today")).not.toBeInTheDocument(); + }); + } finally { + window.requestIdleCallback = original; + } + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 30969a4cef0..4e83eeddbfe 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -179,16 +179,21 @@ const AdvancedDatePicker: React.FC = ({ // First call with immediate value for UI responsiveness onValueChange(tempValue); - // Then do the same background adjustment logic as the original component - requestIdleCallback( - () => { - const adjustedValue = adjustDateRange(tempValue); - onValueChange(adjustedValue); - }, - { timeout: 100 }, - ); - + // Close immediately - Safari has no requestIdleCallback, and scheduling + // the close after it (as before) meant the thrown ReferenceError there + // silently skipped this line, leaving the dropdown stuck open. setIsOpen(false); + + // Then do the same background adjustment logic as the original component. + // Safari doesn't implement requestIdleCallback, so fall back to setTimeout. + const scheduleIdle: (callback: () => void) => void = + typeof requestIdleCallback === "function" + ? (callback) => requestIdleCallback(callback, { timeout: 100 }) + : (callback) => setTimeout(callback, 0); + scheduleIdle(() => { + const adjustedValue = adjustDateRange(tempValue); + onValueChange(adjustedValue); + }); } }; From 547c5397caa7a385de3288359a77b0580c94b48a Mon Sep 17 00:00:00 2001 From: spions Date: Fri, 7 Aug 2026 17:28:14 +0300 Subject: [PATCH 3/4] test(ui): clear localStorage between UsagePageView test cases UsagePageView now persists the selected usage view, activity tab, and date range to localStorage. Without resetting it in beforeEach, state written by one test (switching to "tag" view, a different tab, etc.) leaked into every test that ran after it in the same file, since vitest doesn't reset localStorage between tests automatically. --- .../usage/_components/components/UsagePageView.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 0ded7d195d0..fe6af2a2e71 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -539,6 +539,10 @@ describe("UsagePage", () => { }; beforeEach(() => { + // UsagePageView now persists the selected view/tab/date-range to + // localStorage; without clearing it here, whichever test runs first + // pollutes every test after it in this file. + localStorage.clear(); mockUseAuthorized.mockReturnValue({ isLoading: false, isAuthorized: true, From 506b068527776f2379aa654ba1e64c88549cf3d1 Mon Sep 17 00:00:00 2001 From: spions Date: Fri, 7 Aug 2026 17:52:54 +0300 Subject: [PATCH 4/4] style: run prettier on the usage persistence + date picker changes --- .../usage/_components/components/UsagePageView.tsx | 5 ++++- .../src/components/shared/advanced_date_picker.tsx | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 1e40e374c3e..62daf9143d6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -387,7 +387,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { if (!presetLabel) return; const freshRange = getRelativeRangeByShortLabel(presetLabel); if (!freshRange) return; - if (freshRange.from.getTime() !== dateValue.from?.getTime() || freshRange.to.getTime() !== dateValue.to?.getTime()) { + if ( + freshRange.from.getTime() !== dateValue.from?.getTime() || + freshRange.to.getTime() !== dateValue.to?.getTime() + ) { setDateValue(freshRange); persistDateRange(freshRange); } diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 4e83eeddbfe..9025c261c6f 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -2,7 +2,11 @@ import { CalendarOutlined, ClockCircleOutlined } from "@ant-design/icons"; import { Button, DateRangePickerValue, Text } from "@tremor/react"; import moment from "moment"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { getMatchingRelativeOption, RelativeTimeOption, relativeTimeOptions } from "@/components/shared/date_range_presets"; +import { + getMatchingRelativeOption, + RelativeTimeOption, + relativeTimeOptions, +} from "@/components/shared/date_range_presets"; interface AdvancedDatePickerProps { value: DateRangePickerValue;