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.
This commit is contained in:
spions 2026-08-07 15:01:56 +03:00
parent e1717c5e9c
commit 809b63b9f3
3 changed files with 197 additions and 77 deletions

View file

@ -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<UsagePageProps> = ({ 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<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,
});
// 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<DateRangePickerValue>(() => loadPersistedDateRange());
const [allTags, setAllTags] = useState<EntityList[]>([]);
const { data: customers = [] } = useCustomers();
@ -165,7 +234,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
const [usageView, setUsageView] = useState<UsageOption>("global");
const [usageView, setUsageViewState] = useState<UsageOption>(() => loadPersistedUsageView());
const setUsageView = useCallback((value: UsageOption) => {
setUsageViewState(value);
setLocalStorageItem(USAGE_VIEW_KEY, value);
}, []);
const [activityTabIndex, setActivityTabIndexState] = useState<number>(() => loadPersistedActivityTabIndex());
const setActivityTabIndex = useCallback((index: number) => {
setActivityTabIndexState(index);
setLocalStorageItem(USAGE_ACTIVITY_TAB_KEY, String(index));
}, []);
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
@ -297,8 +375,32 @@ const UsagePage: React.FC<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
/>
</div>
)}
<TabGroup>
<TabGroup defaultIndex={activityTabIndex} onIndexChange={setActivityTabIndex}>
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>

View file

@ -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<AdvancedDatePickerProps> = ({
const dropdownRef = useRef<HTMLDivElement>(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(() => {

View file

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