diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx new file mode 100644 index 000000000000..17d14cd7fac8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import { renderWithProviders } from "../../../../../tests/test-utils"; +import CacheDashboard from "./cache_dashboard"; + +const { adminGlobalCacheActivity, cachingHealthCheckCall } = vi.hoisted(() => ({ + adminGlobalCacheActivity: vi.fn(), + cachingHealthCheckCall: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + adminGlobalCacheActivity, + cachingHealthCheckCall, +})); + +const cacheActivity = [ + { + api_key: "sk-1", + model: "gpt-5.1", + call_type: "acompletion", + total_rows: 1500, + cache_hit_true_rows: 300, + cached_completion_tokens: 12000, + generated_completion_tokens: 48000, + }, + { + api_key: "sk-2", + model: "text-embedding-3-large", + call_type: "aembedding", + total_rows: 700, + cache_hit_true_rows: 100, + cached_completion_tokens: 2000, + generated_completion_tokens: 9000, + }, +]; + +const renderDashboard = () => + renderWithProviders( + , + ); + +const findChartCards = async () => { + await screen.findByText("Cache Hits vs API Requests"); + await waitFor(() => { + expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); + }); + const cards = Array.from(document.querySelectorAll('[data-slot="card"]')); + expect(cards).toHaveLength(2); + return { requestsCard: cards[0] as HTMLElement, tokensCard: cards[1] as HTMLElement }; +}; + +const barFills = (card: HTMLElement) => + Array.from(card.querySelectorAll(".recharts-bar")).map((bar) => + bar.querySelector("path.recharts-rectangle")?.getAttribute("fill"), + ); + +const legendFillByCategory = (card: HTMLElement) => + Object.fromEntries( + Array.from(card.querySelectorAll('.recharts-legend-wrapper [style*="background-color"]')).map((swatch) => [ + swatch.parentElement?.textContent, + swatch.getAttribute("style")?.match(/background-color:\s*([^;]+);?/)?.[1], + ]), + ); + +describe("CacheDashboard cache analytics charts", () => { + beforeEach(() => { + vi.clearAllMocks(); + adminGlobalCacheActivity.mockResolvedValue(cacheActivity); + }); + + it("renders both chart card titles", async () => { + renderDashboard(); + + expect(await screen.findByText("Cache Hits vs API Requests")).toBeInTheDocument(); + expect(screen.getByText("Cached Completion Tokens vs Generated Completion Tokens")).toBeInTheDocument(); + }); + + it("renders the requests chart with each category legend-bound to its fill and stacked in order", async () => { + renderDashboard(); + const { requestsCard } = await findChartCards(); + + expect(legendFillByCategory(requestsCard)).toEqual({ + "LLM API requests": "var(--color-sky-500, #0ea5e9)", + "Cache hit": "var(--color-teal-500, #14b8a6)", + }); + expect(barFills(requestsCard)).toEqual(["var(--color-sky-500, #0ea5e9)", "var(--color-teal-500, #14b8a6)"]); + }); + + it("renders the tokens chart with each category legend-bound to its fill and stacked in order", async () => { + renderDashboard(); + const { tokensCard } = await findChartCards(); + + expect(legendFillByCategory(tokensCard)).toEqual({ + "Generated Completion Tokens": "var(--color-sky-500, #0ea5e9)", + "Cached Completion Tokens": "var(--color-teal-500, #14b8a6)", + }); + expect(barFills(tokensCard)).toEqual(["var(--color-sky-500, #0ea5e9)", "var(--color-teal-500, #14b8a6)"]); + }); + + it("indexes bars by call_type name on the x axis", async () => { + renderDashboard(); + const { requestsCard, tokensCard } = await findChartCards(); + + for (const card of [requestsCard, tokensCard]) { + expect(within(card).getAllByText("acompletion").length).toBeGreaterThan(0); + expect(within(card).getAllByText("aembedding").length).toBeGreaterThan(0); + } + }); + + it("stacks the two categories into one column per call_type", async () => { + renderDashboard(); + const { requestsCard, tokensCard } = await findChartCards(); + + for (const card of [requestsCard, tokensCard]) { + const rects = Array.from(card.querySelectorAll("path.recharts-rectangle")); + expect(rects).toHaveLength(4); + const xPositions = rects.map((rect) => rect.getAttribute("d")?.split(",")[0]); + expect(new Set(xPositions).size).toBe(2); + } + }); + + it("formats y-axis ticks with compact notation", async () => { + renderDashboard(); + const { requestsCard, tokensCard } = await findChartCards(); + + const compactTicks = (card: HTMLElement) => + within(card) + .getAllByText(/^\d+(\.\d+)?K$/) + .map((tick) => tick.textContent); + + expect(compactTicks(requestsCard).length).toBeGreaterThan(0); + expect(compactTicks(tokensCard)).toContain("60K"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 944983da0e14..246f358a0f41 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -1,5 +1,4 @@ import { - BarChart, Card, Col, DateRangePickerValue, @@ -7,7 +6,6 @@ import { Icon, MultiSelect, MultiSelectItem, - Subtitle, Tab, TabGroup, TabList, @@ -18,6 +16,8 @@ import { import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import UsageDatePicker from "@/components/shared/usage_date_picker"; +import { BarChart } from "@/components/shared/charts"; +import { Card as ChartCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { RefreshIcon } from "@heroicons/react/outline"; import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking"; @@ -61,13 +61,13 @@ interface cacheDataItem { // Add other properties as needed } -interface uiData { +type uiData = { name: string; "LLM API requests": number; "Cache hit": number; "Cached Completion Tokens": number; "Generated Completion Tokens": number; -} +}; interface CacheHealthResponse { status?: string; @@ -348,29 +348,41 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - Cache Hits vs API Requests - - - Cached Completion Tokens vs Generated Completion Tokens - + + + Cache Hits vs API Requests + + + + + + + + + + Cached Completion Tokens vs Generated Completion Tokens + + + + + +