diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 80ddc167e4d..591c75c48fe 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -3828,11 +3828,6 @@ "count": 1 } }, - "src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": { "react-hooks/immutability": { "count": 2 @@ -3853,11 +3848,6 @@ "count": 1 } }, - "src/components/view_logs/ToolsSection/ToolsSection.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/VectorStoreViewer.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx index 4a6773994ed..5bf97b16233 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx @@ -111,16 +111,12 @@ describe("InputCard", () => { render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); await act(async () => { - fireEvent.click(copyButton!); + fireEvent.click(copyButton); }); await waitFor(() => { @@ -209,11 +205,7 @@ describe("InputCard", () => { }, ]; render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx index 6cf3cdd1670..94616e40495 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx @@ -66,16 +66,12 @@ describe("OutputCard", () => { it("should copy message content when copy button is clicked", async () => { render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); await act(async () => { - fireEvent.click(copyButton!); + fireEvent.click(copyButton); }); await waitFor(() => { @@ -88,14 +84,10 @@ describe("OutputCard", () => { render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); - await user.click(copyButton!); + await user.click(copyButton); await waitFor(() => { expect(mockWriteText).not.toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx new file mode 100644 index 00000000000..5aee6b33ec5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SectionHeader } from "./SectionHeader"; + +describe("SectionHeader", () => { + it("renders the input label with token, cost and turn metrics", () => { + render(); + + expect(screen.getByText("Input")).toBeInTheDocument(); + expect(screen.getByText("Tokens: 1,234")).toBeInTheDocument(); + expect(screen.getByText("Cost: $0.000123")).toBeInTheDocument(); + expect(screen.getByText("Turns: 3")).toBeInTheDocument(); + }); + + it("renders the output label", () => { + render(); + + expect(screen.getByText("Output")).toBeInTheDocument(); + }); + + it("omits metrics that were not provided", () => { + render(); + + expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Cost:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument(); + }); + + it("omits the turn count when there are no turns", () => { + render(); + + expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument(); + }); + + it("copies without toggling the section", async () => { + const onCopy = vi.fn(); + const onToggleCollapse = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: /copy/i })); + + expect(onCopy).toHaveBeenCalledTimes(1); + expect(onToggleCollapse).not.toHaveBeenCalled(); + }); + + it("toggles the section when the header is clicked", async () => { + const onToggleCollapse = vi.fn(); + render(); + + await userEvent.click(screen.getByText("Input")); + + expect(onToggleCollapse).toHaveBeenCalledTimes(1); + }); + + it("stays inert when no toggle handler is given", async () => { + const onCopy = vi.fn(); + render(); + + await userEvent.click(screen.getByText("Input")); + + expect(onCopy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx index 817928f170c..93e9953b2ee 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx @@ -2,10 +2,10 @@ * SectionHeader - Datadog-style header with icon, label, metrics, and copy */ -import { Typography, Button, Tooltip } from "antd"; -import { MessageOutlined, CopyOutlined, DownOutlined, UpOutlined } from "@ant-design/icons"; - -const { Text } = Typography; +import { ChevronDown, ChevronUp, Copy, MessageSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; interface SectionHeaderProps { type: "input" | "output"; @@ -29,80 +29,57 @@ export function SectionHeader({ return (
{ - if (onToggleCollapse) { - e.currentTarget.style.background = "#f5f5f5"; - } - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = "#fafafa"; - }} + className={cn( + "flex items-center justify-between bg-muted px-4 py-2.5 transition-colors", + isCollapsed ? "border-b-0" : "border-b border-border", + onToggleCollapse ? "cursor-pointer hover:bg-accent" : "cursor-default", + )} > -
- {/* Collapse Arrow */} - {onToggleCollapse && ( -
- {isCollapsed ? ( - - ) : ( - - )} -
- )} - - {/* Icon + Label */} -
- {type === "input" ? ( - +
+ {onToggleCollapse && + (isCollapsed ? ( + ) : ( - ✨ + + ))} + +
+ {type === "input" ? ( + + ) : ( + ✨ )} - {type === "input" ? "Input" : "Output"} + {type === "input" ? "Input" : "Output"}
- {/* Tokens */} {tokens !== undefined && ( - - Tokens: {tokens.toLocaleString()} - + Tokens: {tokens.toLocaleString()} )} - {/* Cost */} - {cost !== undefined && ( - - Cost: ${cost.toFixed(6)} - - )} + {cost !== undefined && Cost: ${cost.toFixed(6)}} - {/* Turn count */} {turnCount !== undefined && turnCount > 0 && ( - - Turns: {turnCount} - + Turns: {turnCount} )}
- {/* Copy Button */} - -
); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx index 753a552b6db..e146f38b42b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx @@ -2,10 +2,46 @@ * Core tests for Tools section */ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { parseToolsFromLog } from "./utils"; +import { ToolsSection } from "./ToolsSection"; import { LogEntry } from "../columns"; +const logWithTools = (toolNames: string[], calledName?: string): LogEntry => ({ + request_id: "render-1", + api_key: "key", + team_id: "team", + model: "gpt-4", + model_id: "gpt-4", + call_type: "completion", + spend: 0.01, + total_tokens: 100, + prompt_tokens: 50, + completion_tokens: 50, + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + cache_hit: "none", + messages: JSON.stringify({ + model: "gpt-4", + messages: [{ role: "user", content: "hi" }], + tools: toolNames.map((name) => ({ + type: "function", + function: { name, description: `${name} description`, parameters: { type: "object", properties: {} } }, + })), + }), + response: JSON.stringify({ + choices: [ + { + message: calledName + ? { tool_calls: [{ id: "call_1", type: "function", function: { name: calledName, arguments: "{}" } }] } + : { content: "done" }, + }, + ], + }), +}); + describe("ToolsSection", () => { it("should parse tools from request and match with response tool calls", () => { const mockLog: LogEntry = { @@ -115,3 +151,51 @@ describe("ToolsSection", () => { expect(tools).toHaveLength(0); }); }); + +const isShown = (text: string) => screen.queryAllByText(text).some((el) => el.closest("[hidden]") === null); + +describe("ToolsSection rendering", () => { + it("summarises how many tools were provided and called", () => { + render(); + + expect(screen.getByText("Tools")).toBeInTheDocument(); + expect(screen.getByText("2 provided, 1 called")).toBeInTheDocument(); + }); + + it("previews the first two tool names", () => { + render(); + + expect(screen.getByText(/alpha, beta/)).toBeInTheDocument(); + }); + + it("renders nothing when the log has no tools", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("reveals the tool list only after the section is expanded", async () => { + render(); + + expect(isShown("called")).toBe(false); + expect(isShown("not called")).toBe(false); + + await userEvent.click(screen.getByText("Tools")); + + await waitFor(() => expect(isShown("called")).toBe(true)); + expect(isShown("not called")).toBe(true); + }); + + it("keeps a tool's expanded detail across a close and reopen", async () => { + render(); + + await userEvent.click(screen.getByText("Tools")); + await userEvent.click(await screen.findByText(/1\. get_weather/)); + expect(isShown("Description")).toBe(true); + + await userEvent.click(screen.getByText("Tools")); + await userEvent.click(screen.getByText("Tools")); + + await waitFor(() => expect(isShown("Description")).toBe(true)); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx index 2a8cdd6a1ce..e3965f182fd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx @@ -3,18 +3,19 @@ * and indicates which ones were actually called in the response */ -import { Collapse, Typography } from "antd"; +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { LogEntry } from "../columns"; import { parseToolsFromLog } from "./utils"; import { ToolItem } from "./ToolItem"; -const { Text } = Typography; - interface ToolsSectionProps { log: LogEntry; } export function ToolsSection({ log }: ToolsSectionProps) { + const [open, setOpen] = useState(false); const tools = parseToolsFromLog(log); // Don't render if no tools @@ -32,34 +33,33 @@ export function ToolsSection({ log }: ToolsSectionProps) { const hasMoreTools = tools.length > 2; return ( -
- -

Tools

- - {totalTools} provided, {calledTools} called - - - • {toolNamePreview} - {hasMoreTools && "..."} - -
- ), - children: ( -
- {tools.map((tool) => ( - - ))} -
- ), - }, - ]} - /> +
+ + + {open ? ( + + ) : ( + + )} +
+

Tools

+ + {totalTools} provided, {calledTools} called + + + • {toolNamePreview} + {hasMoreTools && "..."} + +
+
+ +
+ {tools.map((tool) => ( + + ))} +
+
+
); }