diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index 0e2f16c5d93..d5df371d7c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -5,9 +5,15 @@ import type { ToolSpendResponse } from "@/components/networking"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; const mockGetToolSpend = vi.fn(); +const mockPush = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); vi.mock("@/components/networking", () => ({ getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args), + serverRootPath: "", })); vi.mock("@/components/shared/advanced_date_picker", () => ({ @@ -22,8 +28,26 @@ vi.mock("@/components/shared/charts", () => ({ DonutChart: ({ data, label }: { data: unknown; label: string }) => (
), - BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => ( -
+ BarChart: ({ + data, + categories, + onValueChange, + }: { + data: Record[]; + categories: string[]; + onValueChange?: (item: Record & { categoryClicked: string }) => void; + }) => ( +
+ {data.map((datum, i) => + categories.map((category) => ( +
), DEFAULT_COLOR_CYCLE: ["emerald", "blue", "violet", "amber"], })); @@ -137,4 +161,21 @@ describe("UsageTab", () => { const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]"); expect(series[0]).toMatchObject({ tool_name: "search", spend: 4.0 }); }); + + it("opens the tool's logs when a bar is clicked", async () => { + const toolSpend = { + by_tool: [{ tool_name: "my tool/read", spend: 4.0, call_count: 3, total_tokens: 150 }], + daily: [{ date: "2026-07-12", tool_name: "my tool/read", spend: 4.0, call_count: 3 }], + total_spend: 4.0, + start_date: "2026-07-12", + end_date: "2026-07-12", + }; + const { findByTestId } = renderWith([day("2026-07-12", {})], toolSpend); + + (await findByTestId("bar-0-spend")).click(); + expect(mockPush).toHaveBeenCalledWith("/ui/tool-policies?tool=my+tool%2Fread"); + + (await findByTestId("bar-0-my tool/read")).click(); + expect(mockPush).toHaveBeenLastCalledWith("/ui/tool-policies?tool=my+tool%2Fread"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 9216dbfaad2..f4a6a19e8b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Collapse } from "antd"; +import { useRouter } from "next/navigation"; import { AreaChart, BarChart, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; @@ -11,6 +12,8 @@ import { SpendMetrics } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { buildDailyToolSeries, topToolsBySpend, usd } from "./costOptimizationUtils"; import { DailyActivityRange } from "./useDailyActivityRange"; +import { migratedHref } from "@/utils/migratedPages"; +import { TOOL_QUERY_PARAM } from "@/components/ToolPoliciesView"; interface UsageTabProps { accessToken: string | null; @@ -82,8 +85,17 @@ const SummaryCard = ({ label, value, hint }: { label: string; value: string; hin ); const UsageTab: React.FC = ({ accessToken, activity }) => { + const router = useRouter(); const { dateValue, onDateChange, results, loading, isFetchingMore } = activity; + const openToolLogs = useCallback( + (toolName: string) => { + const params = new URLSearchParams({ [TOOL_QUERY_PARAM]: toolName }); + router.push(`${migratedHref("tool-policies")}?${params.toString()}`); + }, + [router], + ); + const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; @@ -209,7 +221,8 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { Spend by tool

Spend on requests that called each tool (MCP and client-side tools). A request that used multiple tools - counts its full spend toward each, so this attributes rather than partitions spend. + counts its full spend toward each, so this attributes rather than partitions spend. Click a bar to see the + logs for that tool.

@@ -230,6 +243,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { yAxisWidth={140} showLegend={false} valueFormatter={usd} + onValueChange={(item) => openToolLogs(String(item.tool_name))} />
@@ -241,6 +255,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { colors={toolColors} stack valueFormatter={usd} + onValueChange={(item) => openToolLogs(item.categoryClicked)} />
diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx index 34c697a98d1..d5aff1e409f 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -1,10 +1,36 @@ import React from "react"; -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../tests/test-utils"; import ToolPoliciesView from "./ToolPoliciesView"; +let currentUrl = "/ui/tool-policies"; +const urlListeners = new Set<() => void>(); + +vi.mock("next/navigation", async () => { + const react = await import("react"); + return { + useRouter: () => ({ + push: (url: string) => { + currentUrl = url; + urlListeners.forEach((listener) => listener()); + }, + }), + usePathname: () => currentUrl.split("?")[0], + useSearchParams: () => { + const [, rerender] = react.useReducer((n: number) => n + 1, 0); + react.useEffect(() => { + urlListeners.add(rerender); + return () => { + urlListeners.delete(rerender); + }; + }, [rerender]); + return new URLSearchParams(currentUrl.split("?")[1] ?? ""); + }, + }; +}); + vi.mock("@/components/ToolDetail", () => ({ ToolDetail: ({ toolName, onBack }: { toolName: string; onBack: () => void }) => (
@@ -26,6 +52,10 @@ vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({ })); describe("ToolPoliciesView", () => { + beforeEach(() => { + currentUrl = "/ui/tool-policies"; + }); + it("should render the overview by default", () => { renderWithProviders(); @@ -40,6 +70,14 @@ describe("ToolPoliciesView", () => { expect(screen.getByText("Detail: my-tool")).toBeInTheDocument(); expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument(); + expect(currentUrl).toBe("/ui/tool-policies?tool=my-tool"); + }); + + it("should open a tool's detail directly from the ?tool= deep link", () => { + currentUrl = "/ui/tool-policies?tool=deep-linked-tool"; + renderWithProviders(); + + expect(screen.getByText("Detail: deep-linked-tool")).toBeInTheDocument(); }); it("should navigate back to overview when back is clicked", async () => { diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx index bdff40153b9..59f4e1cdcb6 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx @@ -1,30 +1,43 @@ "use client"; -import React, { useState } from "react"; +import React, { useCallback } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ToolDetail } from "@/components/ToolDetail"; import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel"; -type View = { type: "overview" } | { type: "detail"; toolName: string }; +export const TOOL_QUERY_PARAM = "tool"; interface ToolPoliciesViewProps { accessToken: string | null; } export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) { - const [view, setView] = useState({ type: "overview" }); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const selectedTool = searchParams.get(TOOL_QUERY_PARAM); - const handleSelectTool = (toolName: string) => { - setView({ type: "detail", toolName }); - }; + const navigateToTool = useCallback( + (toolName: string | null) => { + const params = new URLSearchParams(searchParams.toString()); + if (toolName) { + params.set(TOOL_QUERY_PARAM, toolName); + } else { + params.delete(TOOL_QUERY_PARAM); + } + const query = params.toString(); + router.push(query ? `${pathname}?${query}` : pathname); + }, + [pathname, router, searchParams], + ); - const handleBack = () => { - setView({ type: "overview" }); - }; + const handleSelectTool = useCallback((toolName: string) => navigateToTool(toolName), [navigateToTool]); + const handleBack = useCallback(() => navigateToTool(null), [navigateToTool]); return (
- {view.type === "detail" ? ( - + {selectedTool ? ( + ) : ( )} diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx index 6ee3319dc10..c7ae0387244 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx @@ -104,6 +104,7 @@ export function BarChart>({ fill={fills[i]} stackId={stack ? "stack" : undefined} isAnimationActive={false} + className={onValueChange ? "cursor-pointer" : undefined} onClick={ onValueChange ? (item: { payload?: TDatum }) => {