mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
feat(dashboard): show exact token counts on hover in usage analytics cards and tables (#12553)
* feat(dashboard): show exact token counts on hover in usage analytics cards and tables * fix(dashboard): lock tooltip position to prevent top-left slide animation --------- Co-authored-by: ZaimMarzuki <ZaimMarzuki@users.noreply.github.com>
This commit is contained in:
@@ -24,9 +24,10 @@ import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content?: string;
|
||||
content?: ReactNode;
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
className?: string;
|
||||
tooltipClassName?: string;
|
||||
delayMs?: number;
|
||||
/**
|
||||
* Issue #2352: Render the tooltip in a React portal so it escapes the
|
||||
@@ -52,6 +53,7 @@ export default function Tooltip({
|
||||
content,
|
||||
position = "top",
|
||||
className = "",
|
||||
tooltipClassName = "",
|
||||
delayMs = 200,
|
||||
usePortal = true,
|
||||
multiline = false,
|
||||
@@ -60,7 +62,7 @@ export default function Tooltip({
|
||||
const tooltipId = useId();
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wrapperRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -90,39 +92,41 @@ export default function Tooltip({
|
||||
if (!wrap || !tt) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const tRect = tt.getBoundingClientRect();
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
top = rect.bottom + scrollY + 8;
|
||||
left = rect.left + scrollX + rect.width / 2 - tRect.width / 2;
|
||||
top = rect.bottom + 8;
|
||||
left = rect.left + rect.width / 2 - tRect.width / 2;
|
||||
break;
|
||||
case "left":
|
||||
top = rect.top + scrollY + rect.height / 2 - tRect.height / 2;
|
||||
left = rect.left + scrollX - tRect.width - 8;
|
||||
top = rect.top + rect.height / 2 - tRect.height / 2;
|
||||
left = rect.left - tRect.width - 8;
|
||||
break;
|
||||
case "right":
|
||||
top = rect.top + scrollY + rect.height / 2 - tRect.height / 2;
|
||||
left = rect.right + scrollX + 8;
|
||||
top = rect.top + rect.height / 2 - tRect.height / 2;
|
||||
left = rect.right + 8;
|
||||
break;
|
||||
case "top":
|
||||
default:
|
||||
top = rect.top + scrollY - tRect.height - 8;
|
||||
left = rect.left + scrollX + rect.width / 2 - tRect.width / 2;
|
||||
top = rect.top - tRect.height - 8;
|
||||
left = rect.left + rect.width / 2 - tRect.width / 2;
|
||||
break;
|
||||
}
|
||||
// Clamp horizontally inside the viewport so a trigger near the right
|
||||
// edge does not produce a tooltip that bleeds off the screen.
|
||||
const margin = 8;
|
||||
const maxLeft = window.innerWidth + scrollX - tRect.width - margin;
|
||||
const minLeft = scrollX + margin;
|
||||
const maxLeft = window.innerWidth - tRect.width - margin;
|
||||
const minLeft = margin;
|
||||
if (left > maxLeft) left = maxLeft;
|
||||
if (left < minLeft) left = minLeft;
|
||||
|
||||
// Lock position immediately without any slide/flying animation from (0,0).
|
||||
tt.style.transition = "none";
|
||||
tt.style.top = `${top}px`;
|
||||
tt.style.left = `${left}px`;
|
||||
tt.style.visibility = "visible";
|
||||
tt.style.opacity = "1";
|
||||
}, [visible, usePortal, position, content]);
|
||||
|
||||
const positionClasses = {
|
||||
@@ -149,14 +153,13 @@ export default function Tooltip({
|
||||
);
|
||||
|
||||
const widthClass = multiline ? "max-w-xs whitespace-normal break-words" : "whitespace-nowrap";
|
||||
const baseTooltipClass =
|
||||
"z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg pointer-events-none animate-in fade-in duration-150 motion-reduce:transition-none motion-reduce:animate-none border border-white/10";
|
||||
const baseTooltipClass = `z-50 px-3 py-2 text-xs font-medium text-white bg-[#10141e]/95 rounded-lg shadow-xl pointer-events-none transition-opacity duration-150 motion-reduce:transition-none border border-white/10 backdrop-blur-sm ${tooltipClassName}`;
|
||||
|
||||
const portalEnabled = usePortal && typeof window !== "undefined";
|
||||
|
||||
const tooltipEl =
|
||||
visible && content ? (
|
||||
<span
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
@@ -165,13 +168,15 @@ export default function Tooltip({
|
||||
? `fixed ${baseTooltipClass} ${widthClass}`
|
||||
: `absolute ${baseTooltipClass} ${widthClass} ${positionClasses[position] || positionClasses.top}`
|
||||
}
|
||||
// For portal-rendered tooltips, mount off-screen + hidden so the
|
||||
// layout effect can measure dimensions before the user sees a flash.
|
||||
// The useLayoutEffect above promotes visibility once coords are set.
|
||||
style={portalEnabled ? { top: -9999, left: -9999, visibility: "hidden" } : undefined}
|
||||
// For portal-rendered tooltips, mount hidden with opacity 0 at origin so
|
||||
// layout effect sets the exact viewport coordinates before revealing it.
|
||||
// This prevents the tooltip from flying or sliding in from top-left.
|
||||
style={
|
||||
portalEnabled ? { top: -9999, left: -9999, visibility: "hidden", opacity: 0 } : undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -275,24 +275,32 @@ export default function UsageAnalytics() {
|
||||
icon="generating_tokens"
|
||||
label={t("totalTokens")}
|
||||
value={fmt(s.totalTokens)}
|
||||
tooltip={fmtFull(s.totalTokens)}
|
||||
subValue={`${fmtFull(s.totalRequests)} ${t("chartRequests")}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="input"
|
||||
label={t("inputTokens")}
|
||||
value={fmt(s.promptTokens)}
|
||||
tooltip={fmtFull(s.promptTokens)}
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon="output"
|
||||
label={t("outputTokens")}
|
||||
value={fmt(s.completionTokens)}
|
||||
tooltip={fmtFull(s.completionTokens)}
|
||||
color="text-emerald-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon="payments"
|
||||
label={t("estCost")}
|
||||
value={fmtCost(s.totalCost)}
|
||||
tooltip={
|
||||
s.totalCost !== undefined && s.totalCost !== null
|
||||
? `$${Number(s.totalCost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })}`
|
||||
: undefined
|
||||
}
|
||||
color="text-amber-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -321,12 +329,16 @@ export default function UsageAnalytics() {
|
||||
icon: "speed",
|
||||
label: t("perfAvgTokens"),
|
||||
value: fmt(avgTokensPerReq),
|
||||
tooltip: `tokens : ${fmtFull(avgTokensPerReq)} tokens`,
|
||||
color: "text-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: "request_quote",
|
||||
label: t("perfCostReq"),
|
||||
value: fmtCost(costPerReq),
|
||||
tooltip: costPerReq
|
||||
? `cost : $${Number(costPerReq).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`
|
||||
: undefined,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
{
|
||||
@@ -339,6 +351,7 @@ export default function UsageAnalytics() {
|
||||
icon: "bolt",
|
||||
label: t("perfFastReq"),
|
||||
value: fmt(s.fastRequests || 0),
|
||||
tooltip: `requests : ${fmtFull(s.fastRequests || 0)} requests`,
|
||||
color: "text-sky-500",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -128,13 +128,22 @@ export function ModelTable({ byModel, summary }: ModelTableProps) {
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
|
||||
{fmtFull(m.requests)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-primary">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-primary"
|
||||
title={fmtFull(m.promptTokens)}
|
||||
>
|
||||
{fmt(m.promptTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-emerald-500"
|
||||
title={fmtFull(m.completionTokens)}
|
||||
>
|
||||
{fmt(m.completionTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-semibold">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono font-semibold"
|
||||
title={fmtFull(m.totalTokens)}
|
||||
>
|
||||
{fmt(m.totalTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
|
||||
|
||||
@@ -92,7 +92,10 @@ export default function RequestCountTable({
|
||||
<td className="px-4 py-2.5 text-right font-mono font-semibold">
|
||||
{fmtFull(row.requests)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-text-muted"
|
||||
title={fmtFull(row.totalTokens)}
|
||||
>
|
||||
{fmt(row.totalTokens)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import Card from "../Card";
|
||||
import { getModelColor } from "@/shared/constants/colors";
|
||||
import Tooltip from "../Tooltip";
|
||||
import { PROVIDER_COLORS } from "./chartColors";
|
||||
import {
|
||||
fmtCompact as fmt,
|
||||
@@ -49,23 +49,63 @@ export function StatCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
tooltip,
|
||||
color = "text-text-main",
|
||||
}: {
|
||||
icon: any;
|
||||
label: any;
|
||||
value: any;
|
||||
subValue?: any;
|
||||
tooltip?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const isCost = String(label).toLowerCase().includes("cost");
|
||||
const tooltipText = tooltip
|
||||
? String(tooltip).includes(":")
|
||||
? tooltip
|
||||
: isCost
|
||||
? `cost : ${String(tooltip).startsWith("$") ? tooltip : `$${tooltip}`}`
|
||||
: `tokens : ${tooltip} tokens`
|
||||
: null;
|
||||
|
||||
const valueElement = (
|
||||
<span
|
||||
className={`text-2xl font-bold ${color} truncate cursor-default`}
|
||||
data-tooltip={tooltip}
|
||||
title={tooltip ? undefined : String(value)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="px-4 py-3 flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-[11px] uppercase font-semibold tracking-wide min-w-0">
|
||||
<span className="material-symbols-outlined text-[14px] shrink-0">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<span className={`text-2xl font-bold ${color} truncate`} title={String(value)}>
|
||||
{value}
|
||||
</span>
|
||||
{tooltipText ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex flex-col gap-0.5 text-left py-0.5 min-w-[140px]">
|
||||
<div className="font-semibold text-white/95 text-xs">{label}</div>
|
||||
<div
|
||||
className={`font-mono text-xs ${
|
||||
color && color !== "text-text-main" ? color : "text-violet-400"
|
||||
} tracking-wide`}
|
||||
>
|
||||
{tooltipText}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="w-fit max-w-full"
|
||||
delayMs={150}
|
||||
>
|
||||
{valueElement}
|
||||
</Tooltip>
|
||||
) : (
|
||||
valueElement
|
||||
)}
|
||||
{subValue && <span className="text-xs text-text-muted truncate">{subValue}</span>}
|
||||
</Card>
|
||||
);
|
||||
@@ -75,7 +115,7 @@ export function StatCard({
|
||||
|
||||
export type CompactStatSection = {
|
||||
title: string;
|
||||
items: Array<{ icon: string; label: string; value: any; color?: string }>;
|
||||
items: Array<{ icon: string; label: string; value: any; tooltip?: string; color?: string }>;
|
||||
/** On mobile use 1 column instead of 2 — useful when values can be long (model names, etc.) */
|
||||
wideValues?: boolean;
|
||||
};
|
||||
@@ -99,28 +139,56 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }
|
||||
: "grid grid-cols-2 md:grid-cols-4 gap-x-5 gap-y-2"
|
||||
}
|
||||
>
|
||||
{section.items.map((stat, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2 min-w-0 py-0.5">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${section.wideValues ? "shrink-0" : "min-w-0"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted shrink-0">
|
||||
{stat.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] uppercase font-semibold tracking-wide text-text-muted ${section.wideValues ? "whitespace-nowrap" : "truncate"}`}
|
||||
>
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
{section.items.map((stat, i) => {
|
||||
const statValueEl = (
|
||||
<span
|
||||
className={`text-sm font-bold text-right ${section.wideValues ? "truncate min-w-0" : "shrink-0"} ${stat.color || "text-text-main"}`}
|
||||
title={String(stat.value)}
|
||||
className={`text-sm font-bold text-right cursor-default ${section.wideValues ? "truncate min-w-0" : "shrink-0"} ${stat.color || "text-text-main"}`}
|
||||
data-tooltip={stat.tooltip}
|
||||
title={stat.tooltip ? undefined : String(stat.value)}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between gap-2 min-w-0 py-0.5">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${section.wideValues ? "shrink-0" : "min-w-0"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted shrink-0">
|
||||
{stat.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] uppercase font-semibold tracking-wide text-text-muted ${section.wideValues ? "whitespace-nowrap" : "truncate"}`}
|
||||
>
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
{stat.tooltip ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex flex-col gap-0.5 text-left py-0.5 min-w-[120px]">
|
||||
<div className="font-semibold text-white/95 text-xs">{stat.label}</div>
|
||||
<div
|
||||
className={`font-mono text-xs ${stat.color || "text-violet-400"} tracking-wide`}
|
||||
>
|
||||
{String(stat.tooltip).includes(":")
|
||||
? stat.tooltip
|
||||
: `${stat.label.toLowerCase().includes("cost") ? "cost" : "tokens"} : ${stat.tooltip}`}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className={section.wideValues ? "truncate min-w-0" : "shrink-0"}
|
||||
delayMs={150}
|
||||
>
|
||||
{statValueEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
statValueEl
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -421,13 +489,22 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
|
||||
{fmtFull(row.requests)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-primary">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-primary"
|
||||
title={fmtFull(row.promptTokens)}
|
||||
>
|
||||
{fmt(row.promptTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-emerald-500"
|
||||
title={fmtFull(row.completionTokens)}
|
||||
>
|
||||
{fmt(row.completionTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-semibold">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono font-semibold"
|
||||
title={fmtFull(row.totalTokens)}
|
||||
>
|
||||
{fmt(row.totalTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
|
||||
@@ -499,9 +576,26 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
<span className="text-xl font-bold capitalize" style={{ lineHeight: 1.2 }}>
|
||||
{data.weekday}
|
||||
</span>
|
||||
<span className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })}
|
||||
</span>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex flex-col gap-0.5 text-left py-0.5 min-w-[140px]">
|
||||
<div className="font-semibold text-white/95 text-xs">{data.weekday}</div>
|
||||
<div className="font-mono text-xs text-violet-400 tracking-wide">
|
||||
tokens : {fmtFull(data.tokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="w-fit max-w-full"
|
||||
delayMs={150}
|
||||
>
|
||||
<span
|
||||
className="text-xs mt-1 cursor-default"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
data-tooltip={fmtFull(data.tokens)}
|
||||
>
|
||||
{t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
@@ -562,7 +656,7 @@ export function WeeklySquares7d({ activityMap }) {
|
||||
{t("chartWeekly")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
|
||||
{days.map((d, i) => (
|
||||
{days.map((d) => (
|
||||
<div
|
||||
key={d.key}
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
|
||||
@@ -861,13 +955,22 @@ export function ProviderTable({ byProvider }) {
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
|
||||
{fmtFull(p.requests)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-primary">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-primary"
|
||||
title={fmtFull(p.promptTokens)}
|
||||
>
|
||||
{fmt(p.promptTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono text-emerald-500"
|
||||
title={fmtFull(p.completionTokens)}
|
||||
>
|
||||
{fmt(p.completionTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-semibold">
|
||||
<td
|
||||
className="px-4 py-2.5 text-right font-mono font-semibold"
|
||||
title={fmtFull(p.totalTokens)}
|
||||
>
|
||||
{fmt(p.totalTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
|
||||
|
||||
296
tests/unit/ui/analytics-token-hover-tooltip.test.tsx
Normal file
296
tests/unit/ui/analytics-token-hover-tooltip.test.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
StatCard,
|
||||
CompactStatGrid,
|
||||
ApiKeyTable,
|
||||
ProviderTable,
|
||||
MostActiveDay7d,
|
||||
} from "@/shared/components/analytics/charts";
|
||||
import { ModelTable } from "@/shared/components/analytics/ModelTable";
|
||||
import RequestCountTable from "@/shared/components/analytics/RequestCountTable";
|
||||
import { fmtFull } from "@/shared/utils/formatting";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useLocale: () => "en",
|
||||
useTranslations: () => {
|
||||
const t = (key: string, values?: Record<string, any>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
};
|
||||
t.has = () => false;
|
||||
return t;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Analytics Token Hover Tooltips", () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("StatCard renders rich custom tooltip when provided, else falls back to value title", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<div>
|
||||
<StatCard
|
||||
icon="generating_tokens"
|
||||
label="Total Tokens"
|
||||
value="1.5B"
|
||||
tooltip={fmtFull(1532481200)}
|
||||
/>
|
||||
<StatCard icon="payments" label="Est. Cost" value="$1180.27" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const values = container.querySelectorAll(".text-2xl.font-bold");
|
||||
expect(values).toHaveLength(2);
|
||||
expect(values[0]?.getAttribute("data-tooltip")).toBe(fmtFull(1532481200));
|
||||
expect(values[0]?.textContent).toBe("1.5B");
|
||||
expect(values[1]?.getAttribute("title")).toBe("$1180.27");
|
||||
expect(values[1]?.textContent).toBe("$1180.27");
|
||||
|
||||
// Trigger hover on the first StatCard value
|
||||
const trigger = container.querySelector(".w-fit.max-w-full");
|
||||
expect(trigger).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// Check portal tooltip content in document.body
|
||||
const tooltipPortal = document.body.querySelector('[role="tooltip"]');
|
||||
expect(tooltipPortal).not.toBeNull();
|
||||
expect(tooltipPortal?.textContent).toContain("Total Tokens");
|
||||
expect(tooltipPortal?.textContent).toContain(`tokens : ${fmtFull(1532481200)} tokens`);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("CompactStatGrid renders rich custom tooltip when provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CompactStatGrid
|
||||
sections={[
|
||||
{
|
||||
title: "Performance",
|
||||
items: [
|
||||
{
|
||||
icon: "speed",
|
||||
label: "Avg Tokens/Req",
|
||||
value: "131.5K",
|
||||
tooltip: `tokens : ${fmtFull(131482)} tokens`,
|
||||
},
|
||||
{
|
||||
icon: "bolt",
|
||||
label: "Fast Requests",
|
||||
value: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const statElements = container.querySelectorAll(".text-sm.font-bold.text-right");
|
||||
expect(statElements).toHaveLength(2);
|
||||
expect(statElements[0]?.getAttribute("data-tooltip")).toBe(
|
||||
`tokens : ${fmtFull(131482)} tokens`
|
||||
);
|
||||
expect(statElements[1]?.getAttribute("title")).toBe("0");
|
||||
|
||||
// Trigger hover on the first stat item
|
||||
const trigger = statElements[0]?.closest(".relative.inline-flex");
|
||||
expect(trigger).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
const tooltipPortal = document.body.querySelector('[role="tooltip"]');
|
||||
expect(tooltipPortal).not.toBeNull();
|
||||
expect(tooltipPortal?.textContent).toContain("Avg Tokens/Req");
|
||||
expect(tooltipPortal?.textContent).toContain(`tokens : ${fmtFull(131482)} tokens`);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ApiKeyTable renders full token counts as title hover tooltips", async () => {
|
||||
const root = createRoot(container);
|
||||
const mockData = [
|
||||
{
|
||||
apiKeyId: "key-12345678",
|
||||
apiKeyName: "Default Key",
|
||||
requests: 10,
|
||||
promptTokens: 1532000000,
|
||||
completionTokens: 6900000,
|
||||
totalTokens: 1538900000,
|
||||
cost: 12.34,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ApiKeyTable byApiKey={mockData} />);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const cells = rows[0]?.querySelectorAll("td");
|
||||
// promptTokens
|
||||
expect(cells?.[2]?.getAttribute("title")).toBe(fmtFull(1532000000));
|
||||
expect(cells?.[2]?.textContent?.trim()).toBe("1.5B");
|
||||
// completionTokens
|
||||
expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(6900000));
|
||||
expect(cells?.[3]?.textContent?.trim()).toBe("6.9M");
|
||||
// totalTokens
|
||||
expect(cells?.[4]?.getAttribute("title")).toBe(fmtFull(1538900000));
|
||||
expect(cells?.[4]?.textContent?.trim()).toBe("1.5B");
|
||||
});
|
||||
|
||||
it("ProviderTable renders full token counts as title hover tooltips", async () => {
|
||||
const root = createRoot(container);
|
||||
const mockData = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
requests: 15,
|
||||
promptTokens: 2000000,
|
||||
completionTokens: 500000,
|
||||
totalTokens: 2500000,
|
||||
cost: 5.5,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ProviderTable byProvider={mockData} />);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const cells = rows[0]?.querySelectorAll("td");
|
||||
// promptTokens
|
||||
expect(cells?.[2]?.getAttribute("title")).toBe(fmtFull(2000000));
|
||||
expect(cells?.[2]?.textContent?.trim()).toBe("2.0M");
|
||||
// completionTokens
|
||||
expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(500000));
|
||||
expect(cells?.[3]?.textContent?.trim()).toBe("500.0K");
|
||||
// totalTokens
|
||||
expect(cells?.[4]?.getAttribute("title")).toBe(fmtFull(2500000));
|
||||
expect(cells?.[4]?.textContent?.trim()).toBe("2.5M");
|
||||
});
|
||||
|
||||
it("ModelTable renders full token counts as title hover tooltips", async () => {
|
||||
const root = createRoot(container);
|
||||
const mockData = [
|
||||
{
|
||||
model: "claude-3-7-sonnet",
|
||||
requests: 20,
|
||||
promptTokens: 10000000,
|
||||
completionTokens: 2000000,
|
||||
totalTokens: 12000000,
|
||||
cost: 10.0,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ModelTable byModel={mockData} summary={{ totalTokens: 12000000 }} />);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const cells = rows[0]?.querySelectorAll("td");
|
||||
// promptTokens
|
||||
expect(cells?.[2]?.getAttribute("title")).toBe(fmtFull(10000000));
|
||||
expect(cells?.[2]?.textContent?.trim()).toBe("10.0M");
|
||||
// completionTokens
|
||||
expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(2000000));
|
||||
expect(cells?.[3]?.textContent?.trim()).toBe("2.0M");
|
||||
// totalTokens
|
||||
expect(cells?.[4]?.getAttribute("title")).toBe(fmtFull(12000000));
|
||||
expect(cells?.[4]?.textContent?.trim()).toBe("12.0M");
|
||||
});
|
||||
|
||||
it("RequestCountTable renders full totalTokens count as title hover tooltip", async () => {
|
||||
const root = createRoot(container);
|
||||
const mockData = [
|
||||
{
|
||||
date: "2026-09-03",
|
||||
provider: "openai",
|
||||
requests: 5,
|
||||
promptTokens: 1000,
|
||||
completionTokens: 500,
|
||||
totalTokens: 1500000,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RequestCountTable
|
||||
rows={mockData}
|
||||
sortBy="date"
|
||||
sortOrder="desc"
|
||||
onToggleSort={() => {}}
|
||||
dateLabel="Date"
|
||||
providerLabel="Provider"
|
||||
requestsLabel="Requests"
|
||||
totalLabel="Total"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const cells = rows[0]?.querySelectorAll("td");
|
||||
// totalTokens cell (index 3)
|
||||
expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(1500000));
|
||||
expect(cells?.[3]?.textContent?.trim()).toBe("1.5M");
|
||||
});
|
||||
|
||||
it("MostActiveDay7d renders rich custom tooltip on hover", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(container);
|
||||
// Use today's date formatted as YYYY-MM-DD
|
||||
const today = new Date();
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const mockActivityMap: Record<string, number> = {
|
||||
[todayKey]: 309080259,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(<MostActiveDay7d activityMap={mockActivityMap} />);
|
||||
});
|
||||
|
||||
const trigger = container.querySelector(".relative.inline-flex.w-fit");
|
||||
expect(trigger).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
const tooltipPortal = document.body.querySelector('[role="tooltip"]');
|
||||
expect(tooltipPortal).not.toBeNull();
|
||||
expect(tooltipPortal?.textContent).toContain(`tokens : ${fmtFull(309080259)} tokens`);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user