This commit is contained in:
Oleg 2026-09-29 12:14:43 +03:00 • committed by GitHub
commit fcadf04cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 377 additions and 107 deletions

View file

@ -64,8 +64,9 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
default: () => <div>Endpoint Usage</div>,
}));
vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("./UsageViewSelect/UsageViewSelect")>();
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(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
act(() => {
fireEvent.click(screen.getByText("Model Activity"));
});
await waitFor(() => {
expect(localStorage.getItem("litellmUsageActivityTab")).toBe("models");
});
unmount();
mockUserDailyActivityAggregatedCall.mockClear();
renderWithProviders(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} organizations={mockOrganizations} />);
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(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("team");
});
});
});

View file

@ -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<UsagePageProps> = ({ 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<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 [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
// No [] default: an unresolved query must stay undefined so the customer
@ -118,14 +190,29 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
const [selectedUsageView, setUsageView] = useState<UsageOption>("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<UsageOption>(() => 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<ActivityTab>(() => 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<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
@ -273,8 +360,35 @@ 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;
@ -469,7 +583,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
</div>
)}
<Tabs defaultValue="cost">
<Tabs value={activityTab} onValueChange={setActivityTab}>
<div className="flex justify-between items-center">
<TabsList className="mt-1">
<TabsTrigger value="cost" className="flex-none px-3">

View file

@ -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<UsageViewSelectProps> = ({
value,
onChange,
@ -116,18 +147,9 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
}) => {
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) {

View file

@ -235,4 +235,43 @@ describe("AdvancedDatePicker", () => {
expect(applyButton).toBeDisabled();
});
});
it("should close dropdown when Apply is clicked", async () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
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(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
expect(screen.getByText("Today")).toBeInTheDocument();
fireEvent.click(screen.getByText("Apply"));
await waitFor(() => {
expect(screen.queryByText("Today")).not.toBeInTheDocument();
});
} finally {
window.requestIdleCallback = original;
}
});
});

View file

@ -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<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(() => {
@ -245,16 +188,21 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
// 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);
});
}
};

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