mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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:
parent
e1717c5e9c
commit
809b63b9f3
3 changed files with 197 additions and 77 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue