From 8d80f8cba95b30a6ad07f5ea164c16e6b9a0151f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 11:44:32 -0700 Subject: [PATCH] refactor(ui): give each Caching tab its own route Split the Caching page's four tabs (Cache Analytics, Cache Health, Cache Settings, Coordination Redis) into their own prerendered paths under /caching, mirroring the Models + Endpoints per-tab routing. A shared layout renders the tab bar and derives the active tab from the pathname; each tab is its own page.tsx that owns only its state, so deep links and hard-loads to /caching/health, /caching/settings and /caching/coordination-redis resolve to real static HTML with no nginx change. The former 430-line cache_dashboard god-component is gone. Its analytics panel moved into CacheAnalyticsTab, which now derives the chart data and summary stats with useMemo during render instead of writing them back to state inside an effect, and the health-check state moved into the health route. The tab bar is rebuilt on the shadcn Tabs primitive rather than adding new antd/tremor usage. The old tab-bar refresh control only reset a "Last Refreshed" timestamp without refetching, so it is dropped. --- ui/litellm-dashboard/eslint-suppressions.json | 16 +- ...rd.test.tsx => CacheAnalyticsTab.test.tsx} | 27 +- .../caching/_components/CacheAnalyticsTab.tsx | 258 +++++++++++ .../caching/_components/cache_dashboard.tsx | 430 ------------------ .../caching/coordination-redis/page.tsx | 7 + .../app/(dashboard)/caching/health/page.tsx | 62 +++ .../app/(dashboard)/caching/layout.test.tsx | 76 ++++ .../src/app/(dashboard)/caching/layout.tsx | 49 ++ .../src/app/(dashboard)/caching/page.tsx | 16 +- .../app/(dashboard)/caching/settings/page.tsx | 9 + .../app/(dashboard)/caching/tabRoutes.test.ts | 38 ++ .../src/app/(dashboard)/caching/tabRoutes.ts | 21 + 12 files changed, 537 insertions(+), 472 deletions(-) rename ui/litellm-dashboard/src/app/(dashboard)/caching/_components/{cache_dashboard.test.tsx => CacheAnalyticsTab.test.tsx} (89%) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/_components/CacheAnalyticsTab.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/coordination-redis/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/health/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/layout.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/settings/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/tabRoutes.ts 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] ?? ""; +}