diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..cd3d6dbd782 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -157,21 +157,9 @@ "count": 2 } }, - "src/app/(dashboard)/caching/_components/cache_dashboard.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, + "src/app/(dashboard)/caching/_components/CacheAnalyticsTab.tsx": { "no-restricted-imports": { "count": 1 - }, - "prefer-const": { - "count": 3 - }, - "react-hooks/purity": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 2 } }, "src/app/(dashboard)/caching/_components/cache_health.tsx": { @@ -4529,4 +4517,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.test.tsx similarity index 89% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.test.tsx index 13472a3d1df..a68d1ed417d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.test.tsx @@ -2,16 +2,14 @@ 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"; +import CacheAnalyticsTab from "./CacheAnalyticsTab"; -const { adminGlobalCacheActivity, cachingHealthCheckCall } = vi.hoisted(() => ({ +const { adminGlobalCacheActivity } = vi.hoisted(() => ({ adminGlobalCacheActivity: vi.fn(), - cachingHealthCheckCall: vi.fn(), })); vi.mock("@/components/networking", () => ({ adminGlobalCacheActivity, - cachingHealthCheckCall, })); const cacheActivity = [ @@ -35,10 +33,7 @@ const cacheActivity = [ }, ]; -const renderDashboard = () => - renderWithProviders( - , - ); +const renderAnalytics = () => renderWithProviders(); const findChartCards = async () => { await screen.findByText("Cache Hits vs API Requests"); @@ -63,21 +58,21 @@ const legendFillByCategory = (card: HTMLElement) => ]), ); -describe("CacheDashboard cache analytics charts", () => { +describe("CacheAnalyticsTab charts", () => { beforeEach(() => { vi.clearAllMocks(); adminGlobalCacheActivity.mockResolvedValue(cacheActivity); }); it("renders both chart card titles", async () => { - renderDashboard(); + renderAnalytics(); expect(await screen.findByText("Cache Hits vs API Requests")).toBeInTheDocument(); expect(screen.getByText("Cached Completion Tokens vs Generated Completion Tokens")).toBeInTheDocument(); }); it("scopes the analytics tab to the response cache, not provider prompt caching", async () => { - renderDashboard(); + renderAnalytics(); expect(await screen.findByText(/is not shown here/)).toBeInTheDocument(); expect(screen.getByRole("link", { name: "response cache" })).toHaveAttribute( @@ -93,7 +88,7 @@ describe("CacheDashboard cache analytics charts", () => { }); it("renders the requests chart with each category legend-bound to its fill and stacked in order", async () => { - renderDashboard(); + renderAnalytics(); const { requestsCard } = await findChartCards(); expect(legendFillByCategory(requestsCard)).toEqual({ @@ -104,7 +99,7 @@ describe("CacheDashboard cache analytics charts", () => { }); it("renders the tokens chart with each category legend-bound to its fill and stacked in order", async () => { - renderDashboard(); + renderAnalytics(); const { tokensCard } = await findChartCards(); expect(legendFillByCategory(tokensCard)).toEqual({ @@ -115,7 +110,7 @@ describe("CacheDashboard cache analytics charts", () => { }); it("indexes bars by call_type name on the x axis", async () => { - renderDashboard(); + renderAnalytics(); const { requestsCard, tokensCard } = await findChartCards(); for (const card of [requestsCard, tokensCard]) { @@ -125,7 +120,7 @@ describe("CacheDashboard cache analytics charts", () => { }); it("stacks the two categories into one column per call_type", async () => { - renderDashboard(); + renderAnalytics(); const { requestsCard, tokensCard } = await findChartCards(); for (const card of [requestsCard, tokensCard]) { @@ -137,7 +132,7 @@ describe("CacheDashboard cache analytics charts", () => { }); it("formats y-axis ticks with compact notation", async () => { - renderDashboard(); + renderAnalytics(); const { requestsCard, tokensCard } = await findChartCards(); const compactTicks = (card: HTMLElement) => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.tsx new file mode 100644 index 00000000000..26bd5370ab0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.tsx @@ -0,0 +1,258 @@ +import { Card, Col, DateRangePickerValue, Grid, MultiSelect, MultiSelectItem, Text } from "@tremor/react"; +import React, { useEffect, useMemo, useState } from "react"; +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 { adminGlobalCacheActivity } from "@/components/networking"; + +const formatDateWithoutTZ = (date: Date | undefined) => { + if (!date) return undefined; + return date.toISOString().split("T")[0]; +}; + +function valueFormatterNumbers(number: number) { + const formatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + notation: "compact", + compactDisplay: "short", + }); + + return formatter.format(number); +} + +interface cacheDataItem { + api_key: string; + model: string; + cache_hit_true_rows: number; + cached_completion_tokens: number; + total_rows: number; + generated_completion_tokens: number; + call_type: string; +} + +type uiData = { + name: string; + "LLM API requests": number; + "Cache hit": number; + "Cached Completion Tokens": number; + "Generated Completion Tokens": number; +}; + +const aggregateCacheData = (rows: cacheDataItem[]) => { + const chartData = rows.reduce((acc, item) => { + const name = item.call_type || "Unknown"; + const apiRequests = (item.total_rows || 0) - (item.cache_hit_true_rows || 0); + const existing = acc.find((row) => row.name === name); + if (existing) { + existing["LLM API requests"] += apiRequests; + existing["Cache hit"] += item.cache_hit_true_rows || 0; + existing["Cached Completion Tokens"] += item.cached_completion_tokens || 0; + existing["Generated Completion Tokens"] += item.generated_completion_tokens || 0; + return acc; + } + return [ + ...acc, + { + name, + "LLM API requests": apiRequests, + "Cache hit": item.cache_hit_true_rows || 0, + "Cached Completion Tokens": item.cached_completion_tokens || 0, + "Generated Completion Tokens": item.generated_completion_tokens || 0, + }, + ]; + }, []); + + const totals = rows.reduce( + (acc, item) => ({ + cacheHits: acc.cacheHits + (item.cache_hit_true_rows || 0), + cachedTokens: acc.cachedTokens + (item.cached_completion_tokens || 0), + apiRequests: acc.apiRequests + ((item.total_rows || 0) - (item.cache_hit_true_rows || 0)), + }), + { cacheHits: 0, cachedTokens: 0, apiRequests: 0 }, + ); + + const allRequests = totals.cacheHits + totals.apiRequests; + const cacheHitRatio = allRequests > 0 ? ((totals.cacheHits / allRequests) * 100).toFixed(2) : "0"; + + return { + chartData, + cacheHitRatio, + cachedResponses: valueFormatterNumbers(totals.cacheHits), + cachedTokens: valueFormatterNumbers(totals.cachedTokens), + }; +}; + +const CacheAnalyticsTab: React.FC<{ accessToken: string | null }> = ({ accessToken }) => { + const [selectedApiKeys, setSelectedApiKeys] = useState([]); + const [selectedModels, setSelectedModels] = useState([]); + const [data, setData] = useState([]); + + const [dateValue, setDateValue] = useState(() => { + const now = new Date(); + return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }; + }); + + useEffect(() => { + if (!accessToken || !dateValue) { + return; + } + const fetchData = async () => { + const response = await adminGlobalCacheActivity( + accessToken, + formatDateWithoutTZ(dateValue.from), + formatDateWithoutTZ(dateValue.to), + ); + setData(response); + }; + fetchData(); + }, [accessToken]); + + const uniqueApiKeys = Array.from(new Set(data.map((item) => item?.api_key ?? ""))); + const uniqueModels = Array.from(new Set(data.map((item) => item?.model ?? ""))); + + const updateCachingData = async (startTime: Date | undefined, endTime: Date | undefined) => { + if (!startTime || !endTime || !accessToken) { + return; + } + + const newCacheData = await adminGlobalCacheActivity( + accessToken, + formatDateWithoutTZ(startTime), + formatDateWithoutTZ(endTime), + ); + + setData(newCacheData); + }; + + const { chartData, cacheHitRatio, cachedResponses, cachedTokens } = useMemo(() => { + const matchesFilters = (item: cacheDataItem) => { + const keyMatch = selectedApiKeys.length === 0 || selectedApiKeys.includes(item.api_key); + const modelMatch = selectedModels.length === 0 || selectedModels.includes(item.model); + return keyMatch && modelMatch; + }; + return aggregateCacheData(data.filter(matchesFilters)); + }, [data, selectedApiKeys, selectedModels]); + + return ( + + + Analytics for LiteLLM's{" "} + + response cache + {" "} + (e.g. Redis / in-memory): requests answered from cache without calling the LLM provider. Provider-side{" "} + + prompt caching + {" "} + (cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching Metrics" on + the Usage page or individual requests in the Logs page. + + + + + {uniqueApiKeys.map((key) => ( + + {key} + + ))} + + + + + {uniqueModels.map((model) => ( + + {model} + + ))} + + + + { + setDateValue(value); + updateCachingData(value.from, value.to); + }} + /> + + + +
+ +

+ Cache Hit Ratio +

+
+

+ {cacheHitRatio}% +

+
+
+ +

+ Cache Hits +

+
+

+ {cachedResponses} +

+
+
+ + +

+ Cached Completion Tokens +

+
+

+ {cachedTokens} +

+
+
+
+ + + + Cache Hits vs API Requests + + + + + + + + + + Cached Completion Tokens vs Generated Completion Tokens + + + + + + +
+ ); +}; + +export default CacheAnalyticsTab; 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 deleted file mode 100644 index 51c0b85cedb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ /dev/null @@ -1,430 +0,0 @@ -import { - Card, - Col, - DateRangePickerValue, - Grid, - Icon, - MultiSelect, - MultiSelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, -} from "@tremor/react"; -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"; - -// Import the new component -import { CacheHealthTab } from "./cache_health"; -import CacheSettings from "./cache_settings"; -import CoordinationRedisSettings from "./coordination_redis_settings"; - -const formatDateWithoutTZ = (date: Date | undefined) => { - if (!date) return undefined; - return date.toISOString().split("T")[0]; -}; - -function valueFormatterNumbers(number: number) { - const formatter = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - notation: "compact", - compactDisplay: "short", - }); - - return formatter.format(number); -} - -interface CachePageProps { - accessToken: string | null; - token: string | null; - userRole: string | null; - userID: string | null; - premiumUser: boolean; -} - -interface cacheDataItem { - api_key: string; - model: string; - cache_hit_true_rows: number; - cached_completion_tokens: number; - total_rows: number; - generated_completion_tokens: number; - call_type: string; - - // Add other properties as needed -} - -type uiData = { - name: string; - "LLM API requests": number; - "Cache hit": number; - "Cached Completion Tokens": number; - "Generated Completion Tokens": number; -}; - -interface CacheHealthResponse { - status?: string; - cache_type?: string; - ping_response?: boolean; - set_cache_response?: string; - litellm_cache_params?: string; - error?: { - message: string; - type: string; - param: string; - code: string; - }; -} - -// Helper function to deep-parse a JSON string if possible -const deepParse = (input: any) => { - let parsed = input; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch { - return parsed; - } - } - return parsed; -}; - -const CacheDashboard: React.FC = ({ accessToken, token, userRole, userID, premiumUser }) => { - const [filteredData, setFilteredData] = useState([]); - const [selectedApiKeys, setSelectedApiKeys] = useState([]); - const [selectedModels, setSelectedModels] = useState([]); - const [data, setData] = useState([]); - const [cachedResponses, setCachedResponses] = useState("0"); - const [cachedTokens, setCachedTokens] = useState("0"); - const [cacheHitRatio, setCacheHitRatio] = useState("0"); - - const [dateValue, setDateValue] = useState({ - from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - to: new Date(), - }); - - const [lastRefreshed, setLastRefreshed] = useState(""); - const [healthCheckResponse, setHealthCheckResponse] = useState(""); - - useEffect(() => { - if (!accessToken || !dateValue) { - return; - } - const fetchData = async () => { - const response = await adminGlobalCacheActivity( - accessToken, - formatDateWithoutTZ(dateValue.from), - formatDateWithoutTZ(dateValue.to), - ); - setData(response); - }; - fetchData(); - - const currentDate = new Date(); - setLastRefreshed(currentDate.toLocaleString()); - }, [accessToken]); - - const uniqueApiKeys = Array.from(new Set(data.map((item) => item?.api_key ?? ""))); - const uniqueModels = Array.from(new Set(data.map((item) => item?.model ?? ""))); - const uniqueCallTypes = Array.from(new Set(data.map((item) => item?.call_type ?? ""))); - - const updateCachingData = async (startTime: Date | undefined, endTime: Date | undefined) => { - if (!startTime || !endTime || !accessToken) { - return; - } - - let new_cache_data = await adminGlobalCacheActivity( - accessToken, - formatDateWithoutTZ(startTime), - formatDateWithoutTZ(endTime), - ); - - setData(new_cache_data); - }; - - useEffect(() => { - let newData: cacheDataItem[] = data; - if (selectedApiKeys.length > 0) { - newData = newData.filter((item) => selectedApiKeys.includes(item.api_key)); - } - - if (selectedModels.length > 0) { - newData = newData.filter((item) => selectedModels.includes(item.model)); - } - - /* - Data looks like this - [{"api_key":"sk-test-mock-key-001","call_type":"acompletion","model":"llama3-8b-8192","total_rows":13,"cache_hit_true_rows":0}, - {"api_key":"sk-test-mock-key-002","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - {"api_key":"sk-test-mock-key-123","call_type":"acompletion","model":"gpt-3.5-turbo","total_rows":19,"cache_hit_true_rows":0}, - {"api_key":"sk-test-mock-key-123","call_type":"aimage_generation","model":"","total_rows":3,"cache_hit_true_rows":0}, - {"api_key":"sk-test-mock-key-003","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - {"api_key":"sk-test-mock-key-004","call_type":"","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - {"api_key":"sk-test-mock-key-005","call_type":"","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - */ - - // What data we need for bar chat - // ui_data = [ - // { - // name: "Call Type", - // Cache hit: 20, - // LLM API requests: 10, - // } - // ] - - let llm_api_requests = 0; - let cache_hits = 0; - let cached_tokens = 0; - const processedData = newData.reduce((acc: uiData[], item) => { - if (!item.call_type) { - item.call_type = "Unknown"; - } - - llm_api_requests += (item.total_rows || 0) - (item.cache_hit_true_rows || 0); - cache_hits += item.cache_hit_true_rows || 0; - cached_tokens += item.cached_completion_tokens || 0; - - const existingItem = acc.find((i) => i.name === item.call_type); - if (existingItem) { - existingItem["LLM API requests"] += (item.total_rows || 0) - (item.cache_hit_true_rows || 0); - existingItem["Cache hit"] += item.cache_hit_true_rows || 0; - existingItem["Cached Completion Tokens"] += item.cached_completion_tokens || 0; - existingItem["Generated Completion Tokens"] += item.generated_completion_tokens || 0; - } else { - acc.push({ - name: item.call_type, - "LLM API requests": (item.total_rows || 0) - (item.cache_hit_true_rows || 0), - "Cache hit": item.cache_hit_true_rows || 0, - "Cached Completion Tokens": item.cached_completion_tokens || 0, - "Generated Completion Tokens": item.generated_completion_tokens || 0, - }); - } - return acc; - }, []); - - // set header cache statistics - setCachedResponses(valueFormatterNumbers(cache_hits)); - setCachedTokens(valueFormatterNumbers(cached_tokens)); - let allRequests = cache_hits + llm_api_requests; - if (allRequests > 0) { - let cache_hit_ratio = ((cache_hits / allRequests) * 100).toFixed(2); - setCacheHitRatio(cache_hit_ratio); - } else { - setCacheHitRatio("0"); - } - - setFilteredData(processedData); - }, [selectedApiKeys, selectedModels, dateValue, data]); - - const handleRefreshClick = () => { - // Update the 'lastRefreshed' state to the current date and time - const currentDate = new Date(); - setLastRefreshed(currentDate.toLocaleString()); - }; - - const runCachingHealthCheck = async () => { - try { - NotificationsManager.info("Running cache health check..."); - setHealthCheckResponse(""); - const response = await cachingHealthCheckCall(accessToken !== null ? accessToken : ""); - setHealthCheckResponse(response); - } catch (error: any) { - console.error("Error running health check:", error); - let errorData; - if (error && error.message) { - try { - // Parse the error message which may contain a nested error layer. - let parsedData = JSON.parse(error.message); - // If the parsed object is wrapped (e.g. { error: { ... } }), unwrap it. - if (parsedData.error) { - parsedData = parsedData.error; - } - errorData = parsedData; - } catch (e) { - errorData = { message: error.message }; - } - } else { - errorData = { message: "Unknown error occurred" }; - } - setHealthCheckResponse({ error: errorData }); - } - }; - - return ( - - -
- Cache Analytics - Cache Health - Cache Settings - Coordination Redis -
- -
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- - - - - Analytics for LiteLLM's{" "} - - response cache - {" "} - (e.g. Redis / in-memory): requests answered from cache without calling the LLM provider. Provider-side{" "} - - prompt caching - {" "} - (cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching - Metrics" on the Usage page or individual requests in the Logs page. - - - - - {uniqueApiKeys.map((key) => ( - - {key} - - ))} - - - - - {uniqueModels.map((model) => ( - - {model} - - ))} - - - - { - setDateValue(value); - updateCachingData(value.from, value.to); - }} - /> - - - -
- -

- Cache Hit Ratio -

-
-

- {cacheHitRatio}% -

-
-
- -

- Cache Hits -

-
-

- {cachedResponses} -

-
-
- - -

- Cached Completion Tokens -

-
-

- {cachedTokens} -

-
-
-
- - - - Cache Hits vs API Requests - - - - - - - - - - Cached Completion Tokens vs Generated Completion Tokens - - - - - - -
-
- - - - - - - - - -
-
- ); -}; - -export default CacheDashboard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/coordination-redis/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/coordination-redis/page.tsx new file mode 100644 index 00000000000..73965f44137 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/coordination-redis/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import CoordinationRedisSettings from "@/app/(dashboard)/caching/_components/coordination_redis_settings"; + +export default function CoordinationRedisPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/health/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/health/page.tsx new file mode 100644 index 00000000000..9c7ce3b96e1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/health/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { CacheHealthTab } from "@/app/(dashboard)/caching/_components/cache_health"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { cachingHealthCheckCall } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +interface CacheHealthResponse { + status?: string; + cache_type?: string; + ping_response?: boolean; + set_cache_response?: string; + litellm_cache_params?: string; + error?: { message?: string; type?: string; param?: string; code?: string }; +} + +const parseHealthCheckError = (message: string): CacheHealthResponse["error"] => { + try { + const parsed: unknown = JSON.parse(message); + if (parsed && typeof parsed === "object") { + const outer = parsed as Record; + const inner = outer.error; + const source = inner && typeof inner === "object" ? (inner as Record) : outer; + const read = (field: string) => (typeof source[field] === "string" ? (source[field] as string) : undefined); + return { + message: read("message") ?? message, + type: read("type"), + param: read("param"), + code: read("code"), + }; + } + } catch { + // message is not JSON; fall through to the raw string + } + return { message }; +}; + +export default function CacheHealthPage() { + const { accessToken } = useAuthorized(); + const [healthCheckResponse, setHealthCheckResponse] = useState(""); + + const runCachingHealthCheck = async () => { + try { + NotificationsManager.info("Running cache health check..."); + setHealthCheckResponse(""); + const response = await cachingHealthCheckCall(accessToken ?? ""); + setHealthCheckResponse(response); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error occurred"; + setHealthCheckResponse({ error: parseHealthCheckError(message) }); + } + }; + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/layout.test.tsx new file mode 100644 index 00000000000..d4436ae2793 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/layout.test.tsx @@ -0,0 +1,76 @@ +/* @vitest-environment jsdom */ +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CachingLayout from "./layout"; + +const { mockPush, navState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + navState: { pathname: "/caching" }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +const renderLayout = () => + render( + +
CHILD
+
, + ); + +describe("CachingLayout", () => { + beforeEach(() => { + navState.pathname = "/caching"; + mockPush.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it("renders the tab bar and the active tab's page content", () => { + const { getByRole, getByTestId } = renderLayout(); + expect(getByRole("tab", { name: "Cache Analytics" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Cache Health" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Cache Settings" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Coordination Redis" })).toBeInTheDocument(); + expect(getByTestId("tab-content")).toHaveTextContent("CHILD"); + }); + + it("navigates to a tab's path when its tab is clicked", async () => { + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Cache Health" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/caching\/health\/$/)); + }); + + it("routes the base tab back to the caching root (no slug)", async () => { + navState.pathname = "/caching/health"; + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Cache Analytics" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/caching\/$/)); + }); + + it("redirects to the base caching path when the tab slug is unknown", async () => { + const replaceMock = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" }, + }); + navState.pathname = "/caching/bogus"; + await act(async () => { + renderLayout(); + }); + expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/caching\/$/)); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/layout.tsx new file mode 100644 index 00000000000..f0c75a7bb28 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/layout.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cacheTabHref, slugFromPathname, type CacheTabSlug } from "@/app/(dashboard)/caching/tabRoutes"; + +const BASE_TAB_KEY = "cache-analytics"; + +const ORDERED_KEYS: Array<"" | CacheTabSlug> = ["", "health", "settings", "coordination-redis"]; + +const TAB_LABELS: Record<"" | CacheTabSlug, string> = { + "": "Cache Analytics", + health: "Cache Health", + settings: "Cache Settings", + "coordination-redis": "Coordination Redis", +}; + +export default function CachingLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + + const activeSlug = slugFromPathname(pathname); + const isKnownSlug = ORDERED_KEYS.some((slug) => slug === activeSlug); + const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY; + + useEffect(() => { + if (activeSlug !== "" && !isKnownSlug) { + window.location.replace(cacheTabHref("")); + } + }, [activeSlug, isKnownSlug]); + + return ( +
+ router.push(cacheTabHref(key === BASE_TAB_KEY ? "" : key))}> + + {ORDERED_KEYS.map((slug) => ( + + {TAB_LABELS[slug]} + + ))} + + + +
{children}
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx index 33f3e81c689..b0f9c9dc5ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx @@ -1,17 +1,9 @@ "use client"; -import CacheDashboard from "./_components/cache_dashboard"; +import CacheAnalyticsTab from "./_components/CacheAnalyticsTab"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -export default function Caching() { - const { accessToken, userRole, userId, token, premiumUser } = useAuthorized(); - return ( - - ); +export default function CachingPage() { + const { accessToken } = useAuthorized(); + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/settings/page.tsx new file mode 100644 index 00000000000..7323ac91ed5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/settings/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import CacheSettings from "@/app/(dashboard)/caching/_components/cache_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function CacheSettingsPage() { + const { accessToken, userRole, userId } = useAuthorized(); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.test.ts new file mode 100644 index 00000000000..842f5c5680c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.test.ts @@ -0,0 +1,38 @@ +/* @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { CACHE_TAB_SLUGS, cacheTabHref, slugFromPathname } from "./tabRoutes"; + +describe("slugFromPathname", () => { + it("returns empty string for the base path with or without a trailing slash", () => { + expect(slugFromPathname("/caching")).toBe(""); + expect(slugFromPathname("/caching/")).toBe(""); + }); + + it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { + expect(slugFromPathname("/caching/health")).toBe("health"); + expect(slugFromPathname("/ui/caching/coordination-redis/")).toBe("coordination-redis"); + }); + + it("returns the raw segment for an unknown tab so the layout can redirect to base", () => { + expect(slugFromPathname("/ui/caching/bogus")).toBe("bogus"); + }); + + it("returns empty string when the caching base segment is not in the path", () => { + expect(slugFromPathname("/teams")).toBe(""); + }); +}); + +describe("cacheTabHref", () => { + it("builds the trailing-slash base href for the empty slug", () => { + expect(cacheTabHref("")).toBe("/ui/caching/"); + }); + + it("builds a trailing-slash href for every tab slug (required by static export)", () => { + for (const slug of CACHE_TAB_SLUGS) { + expect(cacheTabHref(slug)).toBe(`/ui/caching/${slug}/`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.ts new file mode 100644 index 00000000000..15806cbe813 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.ts @@ -0,0 +1,21 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export const CACHING_BASE_SEGMENT = "caching"; + +export const CACHE_TAB_SLUGS = ["health", "settings", "coordination-redis"] as const; + +export type CacheTabSlug = (typeof CACHE_TAB_SLUGS)[number]; + +export function cacheTabHref(slug: string): string { + const base = migratedHref(CACHING_BASE_SEGMENT); + return slug ? `${base}/${slug}/` : `${base}/`; +} + +export function slugFromPathname(pathname: string): string { + const parts = pathname.split("/").filter(Boolean); + const idx = parts.indexOf(CACHING_BASE_SEGMENT); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; +}