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:
Tushar Chandra 2026-06-14 04:09:20 +05:30
parent 2fad75ffda
commit 73d2645e7b
5 changed files with 206 additions and 37 deletions

View file

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

View file

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

View file

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

View file

@ -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>
) : (

View file

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