[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:
yuneng-jiang 2025-11-12 13:27:02 -08:00 • committed by GitHub
parent 20350fa094
commit c5496fd61c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 257 additions and 74 deletions

View file

@ -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();
});
});

View file

@ -110,4 +110,3 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
};
export default EntityUsageExportModal;

View file

@ -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();
});
});
});

View file

@ -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>

View 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();
});
});
});

View file

@ -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",

View file

@ -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(),
});