mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(ui): click a tool in the cost dashboard to open its logs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
35dc982692
commit
b15e0e6ade
5 changed files with 124 additions and 16 deletions
|
|
@ -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 }) => (
|
||||
<div data-testid="donut-chart" data-label={label} data-slices={JSON.stringify(data)} />
|
||||
),
|
||||
BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
|
||||
<div data-testid="bar-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
|
||||
BarChart: ({
|
||||
data,
|
||||
categories,
|
||||
onValueChange,
|
||||
}: {
|
||||
data: Record<string, unknown>[];
|
||||
categories: string[];
|
||||
onValueChange?: (item: Record<string, unknown> & { categoryClicked: string }) => void;
|
||||
}) => (
|
||||
<div data-testid="bar-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)}>
|
||||
{data.map((datum, i) =>
|
||||
categories.map((category) => (
|
||||
<button
|
||||
key={`${i}-${category}`}
|
||||
data-testid={`bar-${i}-${category}`}
|
||||
onClick={() => onValueChange?.({ ...datum, categoryClicked: category })}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<UsageTabProps> = ({ 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<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
<CardTitle>Spend by tool</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -230,6 +243,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
yAxisWidth={140}
|
||||
showLegend={false}
|
||||
valueFormatter={usd}
|
||||
onValueChange={(item) => openToolLogs(String(item.tool_name))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -241,6 +255,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
colors={toolColors}
|
||||
stack
|
||||
valueFormatter={usd}
|
||||
onValueChange={(item) => openToolLogs(item.categoryClicked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div>
|
||||
|
|
@ -26,6 +52,10 @@ vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({
|
|||
}));
|
||||
|
||||
describe("ToolPoliciesView", () => {
|
||||
beforeEach(() => {
|
||||
currentUrl = "/ui/tool-policies";
|
||||
});
|
||||
|
||||
it("should render the overview by default", () => {
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
|
|
@ -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(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
expect(screen.getByText("Detail: deep-linked-tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate back to overview when back is clicked", async () => {
|
||||
|
|
|
|||
|
|
@ -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<View>({ 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 (
|
||||
<div className="p-6 w-full min-w-0 flex-1">
|
||||
{view.type === "detail" ? (
|
||||
<ToolDetail toolName={view.toolName} onBack={handleBack} accessToken={accessToken} />
|
||||
{selectedTool ? (
|
||||
<ToolDetail toolName={selectedTool} onBack={handleBack} accessToken={accessToken} />
|
||||
) : (
|
||||
<ToolPoliciesPanel accessToken={accessToken} onSelectTool={handleSelectTool} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export function BarChart<TDatum extends Record<string, unknown>>({
|
|||
fill={fills[i]}
|
||||
stackId={stack ? "stack" : undefined}
|
||||
isAnimationActive={false}
|
||||
className={onValueChange ? "cursor-pointer" : undefined}
|
||||
onClick={
|
||||
onValueChange
|
||||
? (item: { payload?: TDatum }) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue