From c5496fd61c1fc83626da2a7edad91aebe1394192 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 12 Nov 2025 13:27:02 -0800 Subject: [PATCH] [Feature] UI - Move Budgets out of Experimental (#16544) * Move budget out of experimental and delete budget with friction * Fixed tests * Revert non test change --- .../EntityUsageExportModal.test.tsx | 18 ++- .../EntityUsageExportModal.tsx | 1 - .../components/budgets/budget_panel.test.tsx | 65 +++++++++++ .../src/components/budgets/budget_panel.tsx | 109 ++++++++++++------ .../src/components/leftnav.test.tsx | 68 +++++++++++ .../src/components/leftnav.tsx | 57 ++++----- ui/litellm-dashboard/tests/setupTests.ts | 13 +++ 7 files changed, 257 insertions(+), 74 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/budgets/budget_panel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/leftnav.test.tsx diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index a482b1007df..dae758e7e71 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -1,6 +1,6 @@ /** * Tests for EntityUsageExportModal component - * + * * Validates core export functionality: * - Renders modal with correct default state (CSV format, daily scope) * - User can select export type (daily vs daily_with_models) @@ -10,7 +10,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import EntityUsageExportModal from "./EntityUsageExportModal"; @@ -72,13 +72,13 @@ describe("EntityUsageExportModal", () => { const user = userEvent.setup(); const { generateExportData } = await import("./utils"); - render(); + const { getByRole } = render(); // Default primary action reflects CSV export - expect(screen.getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); + expect(getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); // Click export - await user.click(screen.getByRole("button", { name: /Export CSV/i })); + await user.click(getByRole("button", { name: /Export CSV/i })); // Verifies export pipeline was invoked with default scope 'daily' expect(generateExportData).toHaveBeenCalled(); @@ -98,14 +98,14 @@ describe("EntityUsageExportModal", () => { const user = userEvent.setup(); const { generateExportData } = await import("./utils"); - render(); + const { getByText, getByRole } = render(); // Choose the alternate export type - click the label to trigger radio - const dailyModelLabel = screen.getByText(/Day-by-day by tag and model/i); + const dailyModelLabel = getByText(/Day-by-day by tag and model/i); await user.click(dailyModelLabel); // Export with default CSV format - const exportBtn = screen.getByRole("button", { name: /Export CSV/i }); + const exportBtn = getByRole("button", { name: /Export CSV/i }); await user.click(exportBtn); // Ensure the selected scope flowed through @@ -117,5 +117,3 @@ describe("EntityUsageExportModal", () => { expect(baseProps.onClose).toHaveBeenCalled(); }); }); - - diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index bd9adb6d889..104e446cb38 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -110,4 +110,3 @@ const EntityUsageExportModal: React.FC = ({ }; export default EntityUsageExportModal; - diff --git a/ui/litellm-dashboard/src/components/budgets/budget_panel.test.tsx b/ui/litellm-dashboard/src/components/budgets/budget_panel.test.tsx new file mode 100644 index 00000000000..f8485f986d6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/budgets/budget_panel.test.tsx @@ -0,0 +1,65 @@ +import * as networking from "../networking"; +import { fireEvent, render, waitFor, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import BudgetPanel from "./budget_panel"; + +vi.mock("../networking", () => ({ + getBudgetList: vi.fn(), + budgetDeleteCall: vi.fn(), +})); + +describe("Budget Panel", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the budget panel and load budgets", async () => { + vi.mocked(networking.getBudgetList).mockResolvedValue([ + { + budget_id: "budget-1", + max_budget: "100", + rpm_limit: 10, + tpm_limit: 1000, + updated_at: "2024-01-01T00:00:00Z", + }, + ]); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText("Create a budget to assign to customers.")).toBeInTheDocument(); + expect(getByText("budget-1")).toBeInTheDocument(); + }); + }); + + it("should open delete modal when clicking delete icon", async () => { + vi.mocked(networking.getBudgetList).mockResolvedValue([ + { + budget_id: "budget-to-delete", + max_budget: "200", + rpm_limit: 20, + tpm_limit: 2000, + updated_at: "2024-01-02T00:00:00Z", + }, + ]); + + const { getByText, container } = render(); + + await waitFor(() => { + expect(getByText("budget-to-delete")).toBeInTheDocument(); + }); + + // Find the first table row in tbody and click the second icon (trash/delete) + const bodyRows = container.querySelectorAll("tbody tr"); + expect(bodyRows.length).toBeGreaterThan(0); + const firstRow = bodyRows[0]; + const rowClickableIcons = firstRow.querySelectorAll(".cursor-pointer"); + expect(rowClickableIcons.length).toBeGreaterThan(1); + + fireEvent.click(rowClickableIcons[1]); + + await waitFor(() => { + expect(screen.getByText("Delete Budget")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx b/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx index aa96b32554f..51c8713776c 100644 --- a/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx +++ b/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx @@ -3,33 +3,31 @@ * */ -import React, { useState, useEffect } from "react"; -import BudgetModal from "./budget_modal"; -import EditBudgetModal from "./edit_budget_modal"; +import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; import { + Button, + Card, + Icon, + Tab, + TabGroup, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, - Card, - Button, - Icon, - Text, - Tab, - TabGroup, TabList, TabPanel, TabPanels, + Text, } from "@tremor/react"; -import { - PencilAltIcon, - TrashIcon, -} from "@heroicons/react/outline"; +import { Modal } from "antd"; +import React, { useEffect, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { getBudgetList, budgetDeleteCall } from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; +import { budgetDeleteCall, getBudgetList } from "../networking"; +import BudgetModal from "./budget_modal"; +import EditBudgetModal from "./edit_budget_modal"; interface BudgetSettingsPageProps { accessToken: string | null; @@ -44,10 +42,12 @@ export interface budgetItem { } const BudgetPanel: React.FC = ({ accessToken }) => { - const [isModalVisible, setIsModalVisible] = useState(false); + const [isCreateModelVisible, setIsCreateModelVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [selectedBudget, setSelectedBudget] = useState(null); const [budgetList, setBudgetList] = useState([]); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); useEffect(() => { if (!accessToken) { return; @@ -57,33 +57,44 @@ const BudgetPanel: React.FC = ({ accessToken }) => { }); }, [accessToken]); - const handleEditCall = async (budget_id: string, index: number) => { - console.log("budget_id", budget_id); + const handleEditCall = async (budget: budgetItem) => { if (accessToken == null) { return; } - // Find the budget first - const budget = budgetList.find((budget) => budget.budget_id === budget_id) || null; - - // Update state and show modal after state is updated setSelectedBudget(budget); setIsEditModalVisible(true); }; - const handleDeleteCall = async (budget_id: string, index: number) => { - if (accessToken == null) { + const handleDeleteClick = (budget: budgetItem) => { + setSelectedBudget(budget); + setIsDeleteModalVisible(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedBudget || accessToken == null) { return; } + setIsDeleting(true); + try { + await budgetDeleteCall(accessToken, selectedBudget.budget_id); + NotificationsManager.success("Budget deleted."); + await handleUpdateCall(); + } catch (error) { + console.error("Error deleting budget:", error); + if (typeof NotificationsManager.fromBackend === "function") { + NotificationsManager.fromBackend("Failed to delete budget"); + } else { + NotificationsManager.info("Failed to delete budget"); + } + } finally { + setIsDeleting(false); + setIsDeleteModalVisible(false); + setSelectedBudget(null); + } + }; - NotificationsManager.info("Request made"); - - await budgetDeleteCall(accessToken, budget_id); - - const newBudgetList = [...budgetList]; - newBudgetList.splice(index, 1); - setBudgetList(newBudgetList); - - NotificationsManager.success("Budget Deleted."); + const handleDeleteCancel = () => { + setIsDeleteModalVisible(false); }; const handleUpdateCall = async () => { @@ -97,13 +108,13 @@ const BudgetPanel: React.FC = ({ accessToken }) => { return (
- {selectedBudget && ( @@ -138,13 +149,37 @@ const BudgetPanel: React.FC = ({ accessToken }) => { {value.max_budget ? value.max_budget : "n/a"} {value.tpm_limit ? value.tpm_limit : "n/a"} {value.rpm_limit ? value.rpm_limit : "n/a"} - handleEditCall(value.budget_id, index)} /> - handleDeleteCall(value.budget_id, index)} /> + handleEditCall(value)} + /> + handleDeleteClick(value)} + /> ))} + {isDeleteModalVisible && ( + +

Are you sure you want to delete budget: {selectedBudget?.budget_id} ?

+

This action cannot be undone.

+
+ )}
How to use budget id diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx new file mode 100644 index 00000000000..43f20880add --- /dev/null +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -0,0 +1,68 @@ +import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import Sidebar from "./leftnav"; + +// Stub ResizeObserver used by antd in jsdom +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +(global as any).ResizeObserver = ResizeObserver; + +vi.mock("../utils/roles", () => { + return { + all_admin_roles: ["admin"], + internalUserRoles: ["internal"], + rolesWithWriteAccess: ["admin", "internal"], + isAdminRole: (role: string) => role === "admin", + }; +}); + +describe("Sidebar (leftnav)", () => { + const defaultProps = { + accessToken: null as string | null, + setPage: vi.fn(), + userRole: "admin", + defaultSelectedKey: "api-keys", + collapsed: false, + }; + + it("renders all top-level (non-nested) tabs for admin", () => { + const { getByText } = render(); + + const topLevelLabels = [ + "Virtual Keys", + "Test Key", + "Models + Endpoints", + "Usage", + "Teams", + "Organizations", + "Internal Users", + "Budgets", + "API Reference", + "Model Hub", + "Logs", + "Guardrails", + "Tools", + "Experimental", + "Settings", + ]; + + topLevelLabels.forEach((label) => { + expect(getByText(label)).toBeInTheDocument(); + }); + }); + + it("expands a nested tab to reveal its children (Tools > MCP Servers)", async () => { + const { getByText, queryByText } = render(); + + expect(queryByText("MCP Servers")).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText("Tools")); + }); + await waitFor(() => { + expect(getByText("MCP Servers")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 7a94c069a81..87a6279347d 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,28 +1,28 @@ -import { Layout, Menu } from "antd"; import { - KeyOutlined, - PlayCircleOutlined, - BlockOutlined, - BarChartOutlined, - TeamOutlined, - BankOutlined, - UserOutlined, - SettingOutlined, ApiOutlined, AppstoreOutlined, - DatabaseOutlined, - FileTextOutlined, - LineChartOutlined, - SafetyOutlined, - ExperimentOutlined, - ToolOutlined, - TagsOutlined, + BankOutlined, + BarChartOutlined, BgColorsOutlined, + BlockOutlined, + CreditCardOutlined, + DatabaseOutlined, + ExperimentOutlined, + FileTextOutlined, + KeyOutlined, + LineChartOutlined, + PlayCircleOutlined, + SafetyOutlined, SearchOutlined, + SettingOutlined, + TagsOutlined, + TeamOutlined, + ToolOutlined, + UserOutlined, } from "@ant-design/icons"; -import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles"; +import { ConfigProvider, Layout, Menu } from "antd"; +import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "../utils/roles"; import UsageIndicator from "./usage_indicator"; -import { ConfigProvider } from "antd"; const { Sider } = Layout; // Define the props type @@ -89,6 +89,13 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , roles: all_admin_roles, }, + { + key: "10", + page: "budgets", + label: "Budgets", + icon: , + roles: all_admin_roles, + }, { key: "14", page: "api_ref", label: "API Reference", icon: }, { key: "16", @@ -111,7 +118,12 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , children: [ { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, - { key: "28", page: "search-tools", label: "Search Tools", icon: }, + { + key: "28", + page: "search-tools", + label: "Search Tools", + icon: , + }, { key: "21", page: "vector-stores", @@ -141,13 +153,6 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , roles: all_admin_roles, }, - { - key: "10", - page: "budgets", - label: "Budgets", - icon: , - roles: all_admin_roles, - }, { key: "20", page: "transform-request", diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index 0232a28ac5f..a7a3316636d 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -28,3 +28,16 @@ Object.defineProperty(window, "matchMedia", { dispatchEvent: vi.fn(), }), }); + +// Silence jsdom "getComputedStyle with pseudo-elements" not implemented warnings +// by ignoring the second argument and delegating to the native implementation. +const realGetComputedStyle = window.getComputedStyle.bind(window); +window.getComputedStyle = ((elt: Element) => realGetComputedStyle(elt)) as any; + +// Avoid "navigation to another Document" warnings when clicking with blob: URLs +// used by download flows in tests. +Object.defineProperty(HTMLAnchorElement.prototype, "click", { + configurable: true, + writable: true, + value: vi.fn(), +});