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 7702488f5bf..2db209b444f 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 @@ -64,8 +64,9 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({ default: () =>
Endpoint Usage
, })); -vi.mock("./UsageViewSelect/UsageViewSelect", async () => { +vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => { const React = await import("react"); + const actual = await importOriginal(); const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => { const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null; return React.createElement( @@ -87,7 +88,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { ); }; UsageViewSelect.displayName = "UsageViewSelect"; - return { UsageViewSelect }; + return { ...actual, UsageViewSelect }; }); vi.mock("@/components/shared/advanced_date_picker", async () => { @@ -353,6 +354,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, @@ -1364,5 +1369,68 @@ describe("UsagePage", () => { expect(screen.getByText("MCP Server Activity")).toBeInTheDocument(); expect(screen.getByText("Endpoint Activity")).toBeInTheDocument(); }); + + it("should persist the selected activity tab across a reload", async () => { + const { unmount } = renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + act(() => { + fireEvent.click(screen.getByText("Model Activity")); + }); + + await waitFor(() => { + expect(localStorage.getItem("litellmUsageActivityTab")).toBe("models"); + }); + + unmount(); + mockUserDailyActivityAggregatedCall.mockClear(); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect(screen.getByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Cost" })).toHaveAttribute("aria-selected", "false"); + }); + }); + + describe("persisted usage view", () => { + it.each(["agent", "organization", "customer", "user"])( + "should not restore a persisted %s usage view for a session without access to it", + async (persistedView) => { + // The persisted view can outlive the session that set it (role change, + // org-admin membership revoked, a different account signing in on the + // same browser). Restoring it verbatim would land a non-admin on a + // selector value with no matching panel and no way to get back except + // manually reselecting a different option. + localStorage.setItem("litellmUsageView", persistedView); + mockUseAuthorized.mockReturnValue(nonAdminSession); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("global"); + }, + ); + + it("should restore a persisted usage view that the session still has access to", async () => { + localStorage.setItem("litellmUsageView", "team"); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("team"); + }); }); }); 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 018dfc84740..e584235e093 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 @@ -42,6 +42,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"; @@ -65,13 +67,87 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations"; import UsageAIChatPanel from "./UsageAIChatPanel"; -import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import { isUsageOptionAvailable, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; interface UsagePageProps { teams: Team[]; 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 = "litellmUsageActivityTab"; + +const VALID_ACTIVITY_TABS = ["cost", "models", "keys", "mcp", "endpoints"] as const; +type ActivityTab = (typeof VALID_ACTIVITY_TABS)[number]; + +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 loadPersistedActivityTab(): ActivityTab { + const stored = getLocalStorageItem(USAGE_ACTIVITY_TAB_KEY); + return (VALID_ACTIVITY_TABS as readonly string[]).includes(stored ?? "") ? (stored as ActivityTab) : "cost"; +} + const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); // Aggregated endpoint: try first, fall back to paginated if unavailable @@ -89,15 +165,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 [fetchedTags, setFetchedTags] = useState | null>(null); // No [] default: an unresolved query must stay undefined so the customer @@ -118,14 +190,29 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); - const [selectedUsageView, setUsageView] = useState("global"); - // Org-admin membership is read from the server, so unlike the other usage - // views this one can be revoked while the page is open. Derive the view in - // render rather than storing it, so the fallback lands on the same paint and - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const [selectedUsageView, setUsageViewState] = useState(() => loadPersistedUsageView()); + const setUsageView = useCallback((value: UsageOption) => { + setUsageViewState(value); + setLocalStorageItem(USAGE_VIEW_KEY, value); + }, []); + // Capabilities (org-admin membership, tag usage) are read from the server and + // a persisted view is restored from localStorage, so either can grant a view + // the current session no longer has. Derive the view in render rather than + // storing it, so the fallback lands on the same paint and the selector never + // holds a value it no longer offers. + const usageView: UsageOption = isUsageOptionAvailable(selectedUsageView, { + userRole, + canViewTagUsage, + isOrgAdmin, + }) + ? selectedUsageView + : "global"; + const [activityTab, setActivityTabState] = useState(() => loadPersistedActivityTab()); + const setActivityTab = useCallback((value: string) => { + setActivityTabState(value as ActivityTab); + setLocalStorageItem(USAGE_ACTIVITY_TAB_KEY, value); + }, []); const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); @@ -273,8 +360,35 @@ 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; @@ -469,7 +583,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { )} - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx index ea21a2155c1..7ac7cbe5830 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx @@ -104,6 +104,37 @@ const OPTIONS: OptionConfig[] = [ adminOnly: true, }, ]; +export interface UsageOptionAvailability { + userRole: string | null; + canViewTagUsage?: boolean; + isOrgAdmin?: boolean; +} + +/** + * Single source of truth for whether a given usage view is available to the + * current user, mirroring the capability matrix in OPTIONS below. Used both + * to filter the select's menu and to validate a usage view restored from + * persisted state (e.g. localStorage), whose role may have changed since. + */ +export const isUsageOptionAvailable = ( + value: UsageOption, + { userRole, canViewTagUsage = false, isOrgAdmin = false }: UsageOptionAvailability, +): boolean => { + const option = OPTIONS.find((o) => o.value === value); + if (!option) return false; + const isAdmin = all_admin_roles.includes(userRole ?? ""); + if (option.capability) { + return hasCapability(userRole, option.capability, isOrgAdmin); + } + if (option.value === "tag" && canViewTagUsage) { + return true; + } + if (option.adminOnly && !isAdmin) { + return false; + } + return true; +}; + export const UsageViewSelect: React.FC = ({ value, onChange, @@ -116,18 +147,9 @@ export const UsageViewSelect: React.FC = ({ }) => { const isAdmin = all_admin_roles.includes(userRole ?? ""); const getFilteredOptions = () => { - return OPTIONS.filter((option) => { - if (option.capability) { - return hasCapability(userRole, option.capability, isOrgAdmin); - } - if (option.value === "tag" && canViewTagUsage) { - return true; - } - if (option.adminOnly && !isAdmin) { - return false; - } - return true; - }).map((option) => { + return OPTIONS.filter((option) => + isUsageOptionAvailable(option.value, { userRole, canViewTagUsage, isOrgAdmin }), + ).map((option) => { let label = option.label; let desc = option.description; if (option.showForAdmin && option.showForNonAdmin) { 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 78e258d1a69..e157e25b713 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 @@ -235,4 +235,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 190e3de4a82..cd1d7a8b3b5 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -4,6 +4,11 @@ import { cn } from "@/lib/cva.config"; import type { DateRangePickerValue } from "./date_picker_types"; 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; @@ -14,55 +19,6 @@ interface AdvancedDatePickerProps { align?: "left" | "right"; } -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 */ @@ -85,23 +41,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(() => { @@ -245,16 +188,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); + }); } }; 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; +}