mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
[Feature] UI - Move Budgets out of Experimental (#16544)
* Move budget out of experimental and delete budget with friction * Fixed tests * Revert non test change
This commit is contained in:
parent
20350fa094
commit
c5496fd61c
7 changed files with 257 additions and 74 deletions
|
|
@ -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(<EntityUsageExportModal {...baseProps} />);
|
||||
const { getByRole } = render(<EntityUsageExportModal {...baseProps} />);
|
||||
|
||||
// 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(<EntityUsageExportModal {...baseProps} />);
|
||||
const { getByText, getByRole } = render(<EntityUsageExportModal {...baseProps} />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,4 +110,3 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
|
|||
};
|
||||
|
||||
export default EntityUsageExportModal;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
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(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<BudgetSettingsPageProps> = ({ accessToken }) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isCreateModelVisible, setIsCreateModelVisible] = useState(false);
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [selectedBudget, setSelectedBudget] = useState<budgetItem | null>(null);
|
||||
const [budgetList, setBudgetList] = useState<budgetItem[]>([]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
|
|
@ -57,33 +57,44 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ 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<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsModalVisible(true)}>
|
||||
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
|
||||
+ Create Budget
|
||||
</Button>
|
||||
<BudgetModal
|
||||
accessToken={accessToken}
|
||||
isModalVisible={isModalVisible}
|
||||
setIsModalVisible={setIsModalVisible}
|
||||
isModalVisible={isCreateModelVisible}
|
||||
setIsModalVisible={setIsCreateModelVisible}
|
||||
setBudgetList={setBudgetList}
|
||||
/>
|
||||
{selectedBudget && (
|
||||
|
|
@ -138,13 +149,37 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
<TableCell>{value.max_budget ? value.max_budget : "n/a"}</TableCell>
|
||||
<TableCell>{value.tpm_limit ? value.tpm_limit : "n/a"}</TableCell>
|
||||
<TableCell>{value.rpm_limit ? value.rpm_limit : "n/a"}</TableCell>
|
||||
<Icon icon={PencilAltIcon} size="sm" onClick={() => handleEditCall(value.budget_id, index)} />
|
||||
<Icon icon={TrashIcon} size="sm" onClick={() => handleDeleteCall(value.budget_id, index)} />
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleEditCall(value)}
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
className="cursor-pointer hover:text-red-500"
|
||||
onClick={() => handleDeleteClick(value)}
|
||||
/>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
{isDeleteModalVisible && (
|
||||
<Modal
|
||||
title="Delete Budget"
|
||||
open={isDeleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
confirmLoading={isDeleting}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<p>Are you sure you want to delete budget: {selectedBudget?.budget_id} ?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</Modal>
|
||||
)}
|
||||
<div className="mt-5">
|
||||
<Text className="text-base">How to use budget id</Text>
|
||||
<TabGroup>
|
||||
|
|
|
|||
68
ui/litellm-dashboard/src/components/leftnav.test.tsx
Normal file
68
ui/litellm-dashboard/src/components/leftnav.test.tsx
Normal file
|
|
@ -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(<Sidebar {...defaultProps} />);
|
||||
|
||||
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(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(queryByText("MCP Servers")).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.click(getByText("Tools"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getByText("MCP Servers")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SidebarProps> = ({ accessToken, setPage, userRole, defau
|
|||
icon: <UserOutlined style={{ fontSize: "18px" }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
icon: <CreditCardOutlined style={{ fontSize: "18px" }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined style={{ fontSize: "18px" }} /> },
|
||||
{
|
||||
key: "16",
|
||||
|
|
@ -111,7 +118,12 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
|
|||
icon: <ToolOutlined style={{ fontSize: "18px" }} />,
|
||||
children: [
|
||||
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: "18px" }} /> },
|
||||
{ key: "28", page: "search-tools", label: "Search Tools", icon: <SearchOutlined style={{ fontSize: "18px" }} /> },
|
||||
{
|
||||
key: "28",
|
||||
page: "search-tools",
|
||||
label: "Search Tools",
|
||||
icon: <SearchOutlined style={{ fontSize: "18px" }} />,
|
||||
},
|
||||
{
|
||||
key: "21",
|
||||
page: "vector-stores",
|
||||
|
|
@ -141,13 +153,6 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
|
|||
icon: <FileTextOutlined style={{ fontSize: "18px" }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
icon: <BankOutlined style={{ fontSize: "18px" }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
page: "transform-request",
|
||||
|
|
|
|||
|
|
@ -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 <a> with blob: URLs
|
||||
// used by download flows in tests.
|
||||
Object.defineProperty(HTMLAnchorElement.prototype, "click", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue