mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(ui): render usage chart tooltips in a portal to prevent clipping
The Top Public Model Names, Top Virtual Keys, and Top Models bar charts wrap their Tremor BarChart in an overflow-y-auto scroll container so large lists scroll instead of stretching the card. Recharts renders its tooltip as a DOM node inside that container, so the container clips it; with only a few bars the chart is shorter than the tooltip and its bottom is always cut off. Render the tooltip through a portal to document.body with fixed positioning at the cursor so it escapes the overflow ancestor, and flip it toward the cursor near the viewport edges so it stays on screen.
This commit is contained in:
parent
2fad75ffda
commit
73d2645e7b
5 changed files with 206 additions and 37 deletions
|
|
@ -0,0 +1,90 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ChartTooltipPortal, useCursorPosition } from "./ChartTooltipPortal";
|
||||
|
||||
describe("ChartTooltipPortal", () => {
|
||||
beforeEach(() => {
|
||||
window.innerWidth = 1024;
|
||||
window.innerHeight = 768;
|
||||
});
|
||||
|
||||
it("renders the tooltip on document.body so an overflow ancestor cannot clip it", () => {
|
||||
render(
|
||||
<div data-testid="clipping-container" style={{ overflow: "auto", maxHeight: 80 }}>
|
||||
<ChartTooltipPortal active position={{ x: 100, y: 100 }}>
|
||||
<span>Successful: 42</span>
|
||||
</ChartTooltipPortal>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const tooltip = screen.getByText("Successful: 42");
|
||||
expect(screen.getByTestId("clipping-container")).not.toContainElement(tooltip);
|
||||
expect(document.body).toContainElement(tooltip);
|
||||
});
|
||||
|
||||
it("renders nothing when inactive", () => {
|
||||
render(
|
||||
<ChartTooltipPortal active={false} position={{ x: 0, y: 0 }}>
|
||||
<span>Successful: 42</span>
|
||||
</ChartTooltipPortal>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Successful: 42")).toBeNull();
|
||||
});
|
||||
|
||||
it("pins the tooltip at the cursor with fixed positioning above other layers and ignores pointer events", () => {
|
||||
render(
|
||||
<ChartTooltipPortal active position={{ x: 120, y: 140 }}>
|
||||
<span>tip</span>
|
||||
</ChartTooltipPortal>,
|
||||
);
|
||||
|
||||
const portal = screen.getByTestId("chart-tooltip-portal");
|
||||
expect(portal).toHaveStyle({ position: "fixed", pointerEvents: "none" });
|
||||
expect(portal.style.zIndex).toBe("9999");
|
||||
expect(portal.style.left).toBe("134px");
|
||||
expect(portal.style.top).toBe("154px");
|
||||
expect(portal.style.transform).toBe("translate(0, 0)");
|
||||
});
|
||||
|
||||
it("flips toward the cursor near the right and bottom edges to stay on screen", () => {
|
||||
render(
|
||||
<ChartTooltipPortal active position={{ x: 1000, y: 700 }}>
|
||||
<span>tip</span>
|
||||
</ChartTooltipPortal>,
|
||||
);
|
||||
|
||||
const portal = screen.getByTestId("chart-tooltip-portal");
|
||||
expect(portal.style.transform).toBe("translate(-100%, -100%)");
|
||||
expect(portal.style.left).toBe("986px");
|
||||
expect(portal.style.top).toBe("686px");
|
||||
});
|
||||
|
||||
it("useCursorPosition records the latest cursor coordinates from mouse move", () => {
|
||||
function Harness() {
|
||||
const { positionRef, handleMouseMove } = useCursorPosition();
|
||||
const [, force] = useState(0);
|
||||
return (
|
||||
<div
|
||||
data-testid="area"
|
||||
onMouseMove={(event) => {
|
||||
handleMouseMove(event);
|
||||
force((tick) => tick + 1);
|
||||
}}
|
||||
>
|
||||
<ChartTooltipPortal active position={positionRef.current}>
|
||||
<span>tip</span>
|
||||
</ChartTooltipPortal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
fireEvent.mouseMove(screen.getByTestId("area"), { clientX: 220, clientY: 330 });
|
||||
|
||||
const portal = screen.getByTestId("chart-tooltip-portal");
|
||||
expect(portal.style.left).toBe("234px");
|
||||
expect(portal.style.top).toBe("344px");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { useCallback, useRef, type MouseEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const CURSOR_OFFSET_PX = 14;
|
||||
const EDGE_FLIP_RATIO = 0.6;
|
||||
|
||||
export interface CursorPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function useCursorPosition() {
|
||||
const positionRef = useRef<CursorPosition>({ x: 0, y: 0 });
|
||||
const handleMouseMove = useCallback((event: MouseEvent<HTMLElement>): void => {
|
||||
positionRef.current = { x: event.clientX, y: event.clientY };
|
||||
}, []);
|
||||
return { positionRef, handleMouseMove };
|
||||
}
|
||||
|
||||
interface ChartTooltipPortalProps {
|
||||
active: boolean;
|
||||
position: CursorPosition;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ChartTooltipPortal({ active, position, children }: ChartTooltipPortalProps): ReactNode {
|
||||
if (!active || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flipX = position.x > window.innerWidth * EDGE_FLIP_RATIO;
|
||||
const flipY = position.y > window.innerHeight * EDGE_FLIP_RATIO;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-testid="chart-tooltip-portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: position.x + (flipX ? -CURSOR_OFFSET_PX : CURSOR_OFFSET_PX),
|
||||
top: position.y + (flipY ? -CURSOR_OFFSET_PX : CURSOR_OFFSET_PX),
|
||||
transform: `translate(${flipX ? "-100%" : "0"}, ${flipY ? "-100%" : "0"})`,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { keyInfoV1Call } from "../../../networking";
|
|||
import KeyInfoView from "../../../templates/key_info_view";
|
||||
import { DataTable } from "../../../view_logs/table";
|
||||
import { TagUsage } from "../../types";
|
||||
import { ChartTooltipPortal, useCursorPosition } from "../ChartTooltipPortal";
|
||||
|
||||
interface TopKeyViewProps {
|
||||
topKeys: any[];
|
||||
|
|
@ -25,6 +26,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
const [keyData, setKeyData] = useState<any | undefined>(undefined);
|
||||
const [viewMode, setViewMode] = useState<"chart" | "table">("table");
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const { positionRef: keysCursor, handleMouseMove: handleKeysMouseMove } = useCursorPosition();
|
||||
|
||||
const toggleTagsExpansion = (apiKey: string) => {
|
||||
setExpandedTags((prev) => {
|
||||
|
|
@ -207,7 +209,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
</div>
|
||||
|
||||
{viewMode === "chart" ? (
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
<div className="relative max-h-[600px] overflow-y-auto" onMouseMove={handleKeysMouseMove}>
|
||||
<BarChart
|
||||
className="mt-4 cursor-pointer hover:opacity-90"
|
||||
style={{ height: Math.min(processedTopKeys.length, topKeysLimit) * 52 }}
|
||||
|
|
@ -222,25 +224,27 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
onValueChange={(item) => handleKeyClick(item)}
|
||||
showTooltip={true}
|
||||
customTooltip={(props) => {
|
||||
const item = props.payload?.[0]?.payload;
|
||||
customTooltip={({ payload, active }) => {
|
||||
const item = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="relative z-50 p-3 bg-black/90 shadow-lg rounded-lg text-white max-w-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">Key Alias: </span>
|
||||
<span className="font-mono text-gray-100 break-all">{item?.key_alias}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">Key ID: </span>
|
||||
<span className="font-mono text-gray-100 break-all">{item?.api_key}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">Spend: </span>
|
||||
<span className="text-white font-medium">${formatNumberWithCommas(item?.spend, 2)}</span>
|
||||
<ChartTooltipPortal active={!!active && !!item} position={keysCursor.current}>
|
||||
<div className="p-3 bg-black/90 shadow-lg rounded-lg text-white max-w-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">Key Alias: </span>
|
||||
<span className="font-mono text-gray-100 break-all">{item?.key_alias}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">Key ID: </span>
|
||||
<span className="font-mono text-gray-100 break-all">{item?.api_key}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">Spend: </span>
|
||||
<span className="text-white font-medium">${formatNumberWithCommas(item?.spend, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChartTooltipPortal>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Segmented } from "antd";
|
|||
import { useState } from "react";
|
||||
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
|
||||
import { DataTable } from "../../../view_logs/table";
|
||||
import { ChartTooltipPortal, useCursorPosition } from "../ChartTooltipPortal";
|
||||
|
||||
interface TopModel {
|
||||
key: string;
|
||||
|
|
@ -20,6 +21,7 @@ interface TopModelViewProps {
|
|||
|
||||
export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) {
|
||||
const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table");
|
||||
const { positionRef: modelsCursor, handleMouseMove: handleModelsMouseMove } = useCursorPosition();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -82,7 +84,7 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
|
|||
</div>
|
||||
</div>
|
||||
{modelViewMode === "chart" ? (
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
<div className="relative max-h-[600px] overflow-y-auto" onMouseMove={handleModelsMouseMove}>
|
||||
<BarChart
|
||||
className="mt-4 cursor-pointer hover:opacity-90"
|
||||
style={{ height: Math.min(processedTopModels.length, topModelsLimit) * 52 }}
|
||||
|
|
@ -95,6 +97,20 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
|
|||
yAxisWidth={200}
|
||||
tickGap={5}
|
||||
showLegend={false}
|
||||
customTooltip={({ payload, active }) => {
|
||||
const item = payload?.[0]?.payload as TopModel | undefined;
|
||||
return (
|
||||
<ChartTooltipPortal active={!!active && !!item} position={modelsCursor.current}>
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{item?.key}</p>
|
||||
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(item?.spend, 2)}</p>
|
||||
<p className="text-green-600">Successful: {item?.successful_requests.toLocaleString()}</p>
|
||||
<p className="text-red-600">Failed: {item?.failed_requests.toLocaleString()}</p>
|
||||
<p className="text-gray-600">Tokens: {item?.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
</ChartTooltipPortal>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import ViewUserSpend from "../../view_user_spend";
|
|||
import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity";
|
||||
import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types";
|
||||
import { valueFormatterSpend } from "../utils/value_formatters";
|
||||
import { ChartTooltipPortal, useCursorPosition } from "./ChartTooltipPortal";
|
||||
import EndpointUsage from "./EndpointUsage/EndpointUsage";
|
||||
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
||||
import SpendByProvider from "./EntityUsage/SpendByProvider";
|
||||
|
|
@ -146,6 +147,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
const [showTokenBreakdown, setShowTokenBreakdown] = useState(false);
|
||||
const { positionRef: topModelsCursor, handleMouseMove: handleTopModelsMouseMove } = useCursorPosition();
|
||||
// Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render)
|
||||
useEffect(() => {
|
||||
if (!isAdmin && userID) {
|
||||
|
|
@ -769,7 +771,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
<div
|
||||
className="relative max-h-[600px] overflow-y-auto"
|
||||
onMouseMove={handleTopModelsMouseMove}
|
||||
>
|
||||
{(() => {
|
||||
const modelData = modelViewType === "groups" ? topModelGroups : topModels;
|
||||
return (
|
||||
|
|
@ -785,25 +790,29 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
yAxisWidth={200}
|
||||
showLegend={false}
|
||||
customTooltip={({ payload, active }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.key}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(data.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Total Requests: {data.requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {data.successful_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-red-600">
|
||||
Failed: {data.failed_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
<ChartTooltipPortal
|
||||
active={!!active && !!data}
|
||||
position={topModelsCursor.current}
|
||||
>
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data?.key}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(data?.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Total Requests: {data?.requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {data?.successful_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-red-600">
|
||||
Failed: {data?.failed_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-gray-600">Tokens: {data?.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
</ChartTooltipPortal>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue