From 809b63b9f37567b35704c4b52ec9ba0318db4950 Mon Sep 17 00:00:00 2001 From: spions Date: Fri, 7 Aug 2026 15:01:56 +0300 Subject: [PATCH] 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; +}