Merge pull request #34465 from BerriAI/litellm_/dazzling-gagarin-3d4c69

refactor(ui): migrate budgets, skills, ui-theme to shadcn
This commit is contained in:
yuneng-jiang 2026-07-24 07:07:08 -07:00 committed by GitHub
commit 2bd7c86291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 347 additions and 119 deletions

View file

@ -144,9 +144,6 @@
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": {
@ -1830,9 +1827,6 @@
}
},
"src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1878,9 +1872,6 @@
}
},
"src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": {
"no-restricted-imports": {
"count": 1
},
"no-restricted-syntax": {
"count": 3
},

View file

@ -3,9 +3,10 @@
*
*/
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
@ -73,76 +74,82 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{canModify && (
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
<Button size="sm" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
+ Create Budget
</Button>
)}
<TabGroup>
<TabList>
<Tab>Budgets</Tab>
<Tab>Examples</Tab>
</TabList>
<TabPanels>
<TabPanel>
<div className="mt-6">
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
{selectedBudget && (
<EditBudgetModal
isModalVisible={isEditModalVisible}
setIsModalVisible={setIsEditModalVisible}
existingBudget={selectedBudget}
/>
)}
<Text className="mb-4">Create a budget to assign to customers.</Text>
<BudgetTable
budgets={budgetList}
isLoading={isLoading}
canModify={canModify}
onEditClick={handleEditCall}
onDeleteClick={handleDeleteClick}
<Tabs defaultValue="budgets">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
Budgets
</TabsTrigger>
<TabsTrigger value="examples" className="flex-none rounded-none px-4 py-2">
Examples
</TabsTrigger>
</TabsList>
<TabsContent value="budgets">
<div className="mt-6">
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
{selectedBudget && (
<EditBudgetModal
isModalVisible={isEditModalVisible}
setIsModalVisible={setIsEditModalVisible}
existingBudget={selectedBudget}
/>
<DeleteResourceModal
isOpen={isDeleteModalVisible}
title="Delete Budget?"
message="Are you sure you want to delete this budget? This action cannot be undone."
resourceInformationTitle="Budget Information"
resourceInformation={[
{ label: "Budget ID", value: selectedBudget?.budget_id, code: true },
{ label: "Max Budget", value: selectedBudget?.max_budget },
{ label: "TPM", value: selectedBudget?.tpm_limit },
{ label: "RPM", value: selectedBudget?.rpm_limit },
]}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
confirmLoading={deleteBudget.isPending}
/>
</div>
</TabPanel>
<TabPanel>
<div className="mt-6">
<Text className="text-base">How to use budget id</Text>
<TabGroup>
<TabList>
<Tab>Assign Budget to Customer</Tab>
<Tab>Test it (Curl)</Tab>
<Tab>Test it (OpenAI SDK)</Tab>
</TabList>
<TabPanels>
<TabPanel>
<SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter>
</TabPanel>
<TabPanel>
<SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter>
</TabPanel>
<TabPanel>
<SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
)}
<p className="mb-4 text-sm text-muted-foreground">Create a budget to assign to customers.</p>
<BudgetTable
budgets={budgetList}
isLoading={isLoading}
canModify={canModify}
onEditClick={handleEditCall}
onDeleteClick={handleDeleteClick}
/>
<DeleteResourceModal
isOpen={isDeleteModalVisible}
title="Delete Budget?"
message="Are you sure you want to delete this budget? This action cannot be undone."
resourceInformationTitle="Budget Information"
resourceInformation={[
{ label: "Budget ID", value: selectedBudget?.budget_id, code: true },
{ label: "Max Budget", value: selectedBudget?.max_budget },
{ label: "TPM", value: selectedBudget?.tpm_limit },
{ label: "RPM", value: selectedBudget?.rpm_limit },
]}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
confirmLoading={deleteBudget.isPending}
/>
</div>
</TabsContent>
<TabsContent value="examples">
<div className="mt-6">
<p className="text-base text-muted-foreground">How to use budget id</p>
<Tabs defaultValue="assign-budget">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="assign-budget" className="flex-none rounded-none px-4 py-2">
Assign Budget to Customer
</TabsTrigger>
<TabsTrigger value="curl" className="flex-none rounded-none px-4 py-2">
Test it (Curl)
</TabsTrigger>
<TabsTrigger value="openai-sdk" className="flex-none rounded-none px-4 py-2">
Test it (OpenAI SDK)
</TabsTrigger>
</TabsList>
<TabsContent value="assign-budget">
<SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter>
</TabsContent>
<TabsContent value="curl">
<SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter>
</TabsContent>
<TabsContent value="openai-sdk">
<SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter>
</TabsContent>
</Tabs>
</div>
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -1,7 +1,9 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getClaudeCodePluginsList } from "@/components/networking";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import type { Plugin } from "@/components/claude_code_plugins/types";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
@ -12,8 +14,27 @@ vi.mock("@/components/networking", () => ({
vi.mock("./PluginTable", () => ({
__esModule: true,
default: ({ isLoading }: { isLoading: boolean }) => (
<div data-testid="plugin-table">{isLoading ? "table-loading" : "table-loaded"}</div>
default: ({
isLoading,
pluginsList,
onDeleteClick,
}: {
isLoading: boolean;
pluginsList: Plugin[];
onDeleteClick: (pluginName: string, displayName: string) => void;
}) => (
<div data-testid="plugin-table">
{isLoading ? "table-loading" : "table-loaded"}
{pluginsList.map((plugin) => (
<button
key={plugin.id}
data-testid={`row-delete-${plugin.id}`}
onClick={() => onDeleteClick(plugin.name, plugin.name)}
>
row delete
</button>
))}
</div>
),
}));
@ -21,6 +42,14 @@ vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null }));
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null }));
const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList);
const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin);
const skill: Plugin = {
id: "plugin-1",
name: "my-skill",
source: { source: "github", repo: "acme/my-skill" },
enabled: true,
};
describe("ClaudeCodePluginsPanel loading state", () => {
beforeEach(() => {
@ -48,3 +77,48 @@ describe("ClaudeCodePluginsPanel loading state", () => {
expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false);
});
});
describe("ClaudeCodePluginsPanel delete confirmation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 });
});
it("should ask for confirmation before deleting and name the skill", async () => {
const user = userEvent.setup();
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument();
expect(screen.getByText("my-skill")).toBeInTheDocument();
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
});
it("should delete the skill and refresh the list once confirmed", async () => {
const user = userEvent.setup();
mockDeleteClaudeCodePlugin.mockResolvedValue({});
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
await screen.findByText(/are you sure you want to delete skill/i);
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill"));
await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument());
});
it("should not delete the skill when the confirmation is cancelled", async () => {
const user = userEvent.setup();
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
await screen.findByText(/are you sure you want to delete skill/i);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument());
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,14 @@
import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react";
import { Modal } from "antd";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import AddPluginForm from "./add_plugin_form";
import PluginTable from "./PluginTable";
@ -115,20 +123,28 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
/>
{pluginToDelete && (
<Modal
title="Delete Skill"
open={pluginToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={() => setPluginToDelete(null)}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
<AlertDialog
open
onOpenChange={(open) => {
if (!open) setPluginToDelete(null);
}}
>
<p>
Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>?
</p>
<p>This action cannot be undone.</p>
</Modal>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Skill</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>?
</AlertDialogDescription>
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);

View file

@ -0,0 +1,128 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import NotificationsManager from "@/components/molecules/notifications_manager";
import UIThemeSettings from "./UIThemeSettings";
const setLogoUrl = vi.fn();
const setFaviconUrl = vi.fn();
vi.mock("@/contexts/ThemeContext", () => ({
useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }),
}));
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "",
getGlobalLitellmHeaderName: () => "Authorization",
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
const LOGO_PLACEHOLDER = "https://example.com/logo.png";
const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico";
const okResponse = (values: Record<string, string | null> = {}) =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ values }) } as Response);
const fetchMock = vi.fn<typeof fetch>();
const patchCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === "PATCH");
const bodyOf = (call: Parameters<typeof fetch>) => JSON.parse(String(call[1]?.body));
describe("UIThemeSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockImplementation(() => okResponse());
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("should render nothing without an access token", () => {
const { container } = render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken={null} />);
expect(container).toBeEmptyDOMElement();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should load the saved logo and favicon urls into the inputs", async () => {
fetchMock.mockImplementation(() =>
okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }),
);
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => {
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg");
});
expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("https://cdn.example.com/fav.ico");
expect(setLogoUrl).toHaveBeenCalledWith("https://cdn.example.com/logo.svg");
expect(setFaviconUrl).toHaveBeenCalledWith("https://cdn.example.com/fav.ico");
});
it("should save the entered urls and report success", async () => {
const user = userEvent.setup();
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
await user.type(screen.getByPlaceholderText(LOGO_PLACEHOLDER), "https://a.test/logo.png");
await user.type(screen.getByPlaceholderText(FAVICON_PLACEHOLDER), "https://a.test/fav.ico");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({
logo_url: "https://a.test/logo.png",
favicon_url: "https://a.test/fav.ico",
});
await waitFor(() =>
expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings updated successfully!"),
);
});
it("should surface a backend failure when saving fails", async () => {
const user = userEvent.setup();
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
fetchMock.mockImplementation(() => Promise.resolve({ ok: false } as Response));
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() =>
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update theme settings"),
);
expect(NotificationsManager.success).not.toHaveBeenCalled();
});
it("should clear both inputs and persist nulls when resetting to default", async () => {
const user = userEvent.setup();
fetchMock.mockImplementation(() =>
okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }),
);
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => {
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg");
});
await user.click(screen.getByRole("button", { name: "Reset to Default" }));
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null });
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("");
expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("");
expect(setLogoUrl).toHaveBeenLastCalledWith(null);
expect(setFaviconUrl).toHaveBeenLastCalledWith(null);
await waitFor(() => expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings reset to default!"));
});
});

View file

@ -1,5 +1,9 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, TextInput, Button } from "@tremor/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useTheme } from "@/contexts/ThemeContext";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -113,50 +117,58 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
return (
<div className="w-full mx-auto max-w-4xl px-6 py-8">
<div className="mb-8">
<Title className="text-2xl font-bold mb-2">UI Theme Customization</Title>
<Text className="text-gray-600">Customize your LiteLLM admin dashboard with a custom logo and favicon.</Text>
<h1 className="mb-2 text-2xl font-bold">UI Theme Customization</h1>
<p className="text-sm text-muted-foreground">
Customize your LiteLLM admin dashboard with a custom logo and favicon.
</p>
</div>
<Card className="shadow-xs p-6">
<div className="space-y-6">
<Card>
<CardContent className="space-y-6">
<div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Logo URL</Text>
<TextInput
<Label htmlFor="ui-theme-logo-url" className="mb-2">
Custom Logo URL
</Label>
<Input
id="ui-theme-logo-url"
placeholder="https://example.com/logo.png"
value={logoUrlInput}
onValueChange={(v) => {
setLogoUrlInput(v);
setLogoUrl(v || null);
onChange={(event) => {
setLogoUrlInput(event.target.value);
setLogoUrl(event.target.value || null);
}}
className="w-full"
/>
<Text className="text-xs text-gray-500 mt-1">
<p className="mt-1 text-xs text-muted-foreground">
Enter a URL for your custom logo or leave empty for default
</Text>
</p>
</div>
<div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Favicon URL</Text>
<TextInput
<Label htmlFor="ui-theme-favicon-url" className="mb-2">
Custom Favicon URL
</Label>
<Input
id="ui-theme-favicon-url"
placeholder="https://example.com/favicon.ico"
value={faviconUrlInput}
onValueChange={(v) => {
setFaviconUrlInput(v);
setFaviconUrl(v || null);
onChange={(event) => {
setFaviconUrlInput(event.target.value);
setFaviconUrl(event.target.value || null);
}}
className="w-full"
/>
<Text className="text-xs text-gray-500 mt-1">
<p className="mt-1 text-xs text-muted-foreground">
Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default
</Text>
</p>
</div>
<div className="flex gap-3 pt-4">
<Button onClick={handleSave} loading={loading} disabled={loading} color="indigo">
<Button onClick={handleSave} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Save Changes
</Button>
<Button onClick={handleReset} loading={loading} disabled={loading} variant="secondary" color="gray">
<Button variant="outline" onClick={handleReset} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Reset to Default
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);