From 73d2645e7b63265d95ad1726c0173b3358b602e2 Mon Sep 17 00:00:00 2001 From: Tushar Chandra Date: Sun, 14 Jun 2026 04:09:20 +0530 Subject: [PATCH] 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. --- .../components/ChartTooltipPortal.test.tsx | 90 +++++++++++++++++++ .../components/ChartTooltipPortal.tsx | 50 +++++++++++ .../components/EntityUsage/TopKeyView.tsx | 38 ++++---- .../components/EntityUsage/TopModelView.tsx | 18 +++- .../UsagePage/components/UsagePageView.tsx | 47 ++++++---- 5 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.tsx diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.test.tsx new file mode 100644 index 00000000000..8d1dfefc865 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.test.tsx @@ -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( +
+ + Successful: 42 + +
, + ); + + 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( + + Successful: 42 + , + ); + + expect(screen.queryByText("Successful: 42")).toBeNull(); + }); + + it("pins the tooltip at the cursor with fixed positioning above other layers and ignores pointer events", () => { + render( + + tip + , + ); + + 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( + + tip + , + ); + + 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 ( +
{ + handleMouseMove(event); + force((tick) => tick + 1); + }} + > + + tip + +
+ ); + } + + render(); + 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.tsx new file mode 100644 index 00000000000..fa56c57fbe8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/ChartTooltipPortal.tsx @@ -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({ x: 0, y: 0 }); + const handleMouseMove = useCallback((event: MouseEvent): 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( +
+ {children} +
, + document.body, + ); +} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 8903f74f232..5bfca9b9df8 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -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 = ({ topKeys, teams, showTags = fals const [keyData, setKeyData] = useState(undefined); const [viewMode, setViewMode] = useState<"chart" | "table">("table"); const [expandedTags, setExpandedTags] = useState>(new Set()); + const { positionRef: keysCursor, handleMouseMove: handleKeysMouseMove } = useCursorPosition(); const toggleTagsExpansion = (apiKey: string) => { setExpandedTags((prev) => { @@ -207,7 +209,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals {viewMode === "chart" ? ( -
+
= ({ 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 ( -
-
-
- Key Alias: - {item?.key_alias} -
-
- Key ID: - {item?.api_key} -
-
- Spend: - ${formatNumberWithCommas(item?.spend, 2)} + +
+
+
+ Key Alias: + {item?.key_alias} +
+
+ Key ID: + {item?.api_key} +
+
+ Spend: + ${formatNumberWithCommas(item?.spend, 2)} +
-
+ ); }} /> diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx index c69ba42f182..53afed75c9e 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx @@ -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
{modelViewMode === "chart" ? ( -
+
{ + const item = payload?.[0]?.payload as TopModel | undefined; + return ( + +
+

{item?.key}

+

Spend: ${formatNumberWithCommas(item?.spend, 2)}

+

Successful: {item?.successful_requests.toLocaleString()}

+

Failed: {item?.failed_requests.toLocaleString()}

+

Tokens: {item?.tokens.toLocaleString()}

+
+
+ ); + }} />
) : ( diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 41dfdb21d1b..70bcc3fcd80 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -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 = ({ teams, organizations }) => { const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(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 = ({ teams, organizations }) => { {loading ? ( ) : ( -
+
{(() => { const modelData = modelViewType === "groups" ? topModelGroups : topModels; return ( @@ -785,25 +790,29 @@ const UsagePage: React.FC = ({ 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 ( -
-

{data.key}

-

- Spend: ${formatNumberWithCommas(data.spend, 2)} -

-

- Total Requests: {data.requests.toLocaleString()} -

-

- Successful: {data.successful_requests.toLocaleString()} -

-

- Failed: {data.failed_requests.toLocaleString()} -

-

Tokens: {data.tokens.toLocaleString()}

-
+ +
+

{data?.key}

+

+ Spend: ${formatNumberWithCommas(data?.spend, 2)} +

+

+ Total Requests: {data?.requests.toLocaleString()} +

+

+ Successful: {data?.successful_requests.toLocaleString()} +

+

+ Failed: {data?.failed_requests.toLocaleString()} +

+

Tokens: {data?.tokens.toLocaleString()}

+
+
); }} />