From 3170fff768e0c196dc472b80ffb4483541bd7091 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 07:49:33 -0700 Subject: [PATCH] refactor(ui): move the settings page and bulk user invite onto shadcn primitives Rebuilds settings.tsx and bulk_create_users_button.tsx on the in-repo shadcn layer. The settings callback form moves from antd Form to react-hook-form with the shared Field primitives, and the CSV drop zone replaces antd Upload with a native file input plus drag handlers. Both public prop signatures are unchanged, so no caller moves. --- ui/litellm-dashboard/eslint-suppressions.json | 9 - .../bulk_create_users_button.test.tsx | 49 +- .../components/bulk_create_users_button.tsx | 789 +++++++++--------- .../src/components/settings.test.tsx | 179 ++-- .../src/components/settings.tsx | 639 +++++++------- 5 files changed, 900 insertions(+), 765 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..aad9b63f8b3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2295,9 +2295,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3061,15 +3058,9 @@ "local/filename-pascal-case": { "count": 1 }, - "local/no-complex-jsx-arrow": { - "count": 4 - }, "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 3 - }, "prefer-const": { "count": 4 } diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx index 7397eaa6b24..fff03ed8e82 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx @@ -1,4 +1,5 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import BulkCreateUsersButton from "./bulk_create_users_button"; @@ -20,9 +21,55 @@ vi.mock("./molecules/notifications_manager", () => ({ }, })); +const csvFile = () => + new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" }); + +const openUploadStep = async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("+ Bulk Invite Users")); + return user; +}; + describe("BulkCreateUsersButton", () => { it("should render", () => { const { getByText } = render(); expect(getByText("+ Bulk Invite Users")).toBeInTheDocument(); }); + + it("parses a CSV chosen through the file input", async () => { + await openUploadStep(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [csvFile()] } }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("parses a CSV dropped onto the drop zone", async () => { + await openUploadStep(); + + const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label"); + fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("exposes the drop zone as a label for a keyboard-reachable file input", async () => { + await openUploadStep(); + + const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement; + expect(fileInput).toHaveAttribute("type", "file"); + expect(fileInput).toHaveAttribute("accept", ".csv"); + expect(fileInput).toBeVisible(); + + const dropZone = fileInput.closest("label") as HTMLLabelElement; + expect(fileInput.id).not.toBe(""); + expect(dropZone.htmlFor).toBe(fileInput.id); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); }); diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index 8faff9ca72f..faaf2cbf7a5 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -1,14 +1,8 @@ import React, { useState, useEffect } from "react"; -import { Text } from "@tremor/react"; -import { Button, Modal, Table, Upload, Typography } from "antd"; -import { - UploadOutlined, - DownloadOutlined, - WarningOutlined, - FileTextOutlined, - DeleteOutlined, - FileExclamationOutlined, -} from "@ant-design/icons"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react"; import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking"; import Papa from "papaparse"; import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline"; @@ -38,6 +32,8 @@ interface UserData { invitation_link?: string; } +const PREVIEW_PAGE_SIZE = 5; + // Define an interface for the UI settings interface UISettings { PROXY_BASE_URL: string | null; @@ -61,6 +57,9 @@ const BulkCreateUsersButton: React.FC = ({ const [selectedFile, setSelectedFile] = useState(null); const [uiSettings, setUISettings] = useState(null); const [baseUrl, setBaseUrl] = useState("http://localhost:4000"); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const csvInputId = React.useId(); useEffect(() => { // Get UI settings @@ -93,7 +92,7 @@ const BulkCreateUsersButton: React.FC = ({ if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`); NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file."); - return false; + return; } // Check file size (limit to 5MB) @@ -101,7 +100,7 @@ const BulkCreateUsersButton: React.FC = ({ setFileError( `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`, ); - return false; + return; } Papa.parse(file, { @@ -262,7 +261,27 @@ const BulkCreateUsersButton: React.FC = ({ }, header: false, }); - return false; + }; + + const handleFileInputChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleFileUpload(file); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(false); + const file = event.dataTransfer.files?.[0]; + if (file) { + handleFileUpload(file); + } }; const removeSelectedFile = () => { @@ -273,6 +292,12 @@ const BulkCreateUsersButton: React.FC = ({ setFileError(null); }; + const resetParsedData = () => { + setParsedData([]); + setParseError(null); + setPageIndex(0); + }; + const handleBulkCreate = async () => { setIsProcessing(true); const updatedData = parsedData.map((user) => ({ ...user, status: "pending" })); @@ -434,340 +459,395 @@ const BulkCreateUsersButton: React.FC = ({ window.URL.revokeObjectURL(url); }; - const columns = [ - { - title: "Row", - dataIndex: "rowNumber", - key: "rowNumber", - width: 80, - }, - { - title: "Email", - dataIndex: "user_email", - key: "user_email", - }, - { - title: "Role", - dataIndex: "user_role", - key: "user_role", - }, - { - title: "Teams", - dataIndex: "teams", - key: "teams", - }, - { - title: "Budget", - dataIndex: "max_budget", - key: "max_budget", - }, - { - title: "Status", - key: "status", - render: (_: any, record: UserData) => { - if (!record.isValid) { - return ( -
-
- - Invalid -
- {record.error && {record.error}} -
- ); - } - if (!record.status || record.status === "pending") { - return Pending; - } - if (record.status === "success") { - return ( -
-
- - Success -
- {record.invitation_link && ( -
-
- {record.invitation_link} - NotificationsManager.success("Invitation link copied!")} - > - - -
-
- )} -
- ); - } - return ( -
-
- - Failed -
- {record.error && {JSON.stringify(record.error)}} + const renderStatusCell = (record: UserData) => { + if (!record.isValid) { + return ( +
+
+ + Invalid
- ); - }, - }, - ]; + {record.error && {record.error}} +
+ ); + } + if (!record.status || record.status === "pending") { + return Pending; + } + if (record.status === "success") { + return ( +
+
+ + Success +
+ {record.invitation_link && ( +
+
+ {record.invitation_link} + NotificationsManager.success("Invitation link copied!")} + > + + +
+
+ )} +
+ ); + } + return ( +
+
+ + Failed +
+ {record.error && {JSON.stringify(record.error)}} +
+ ); + }; + + const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE)); + const currentPage = Math.min(pageIndex, pageCount - 1); + const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE); return ( <> - - setIsModalVisible(false)} - bodyStyle={{ maxHeight: "70vh", overflow: "auto" }} - footer={null} - > -
- {/* Step indicator */} - {parsedData.length === 0 ? ( -
-
-
- 1 -
-

Download and fill the template

-
- -
-

Add multiple users at once by following these steps:

-
    -
  1. Download our CSV template
  2. -
  3. Add your users' information to the spreadsheet
  4. -
  5. Save the file and upload it here
  6. -
  7. After creation, download the results file containing the Virtual Keys for each user
  8. -
- -
-

Template Column Names

-
-
-
-
-

user_email

-

User's email address (required)

-
-
-
-
-
-

user_role

-

- User's role (one of: "proxy_admin", "proxy_admin_viewer", - "internal_user", "internal_user_viewer") -

-
-
-
-
-
-

teams

-

- Comma-separated team IDs (e.g., "team-1,team-2") -

-
-
-
-
-
-

max_budget

-

Maximum budget as a number (e.g., "100")

-
-
-
-
-
-

budget_duration

-

- Budget reset period (e.g., "30d", "1mo") -

-
-
-
-
-
-

models

-

- Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") -

-
-
+ !open && setIsModalVisible(false)}> + + + Bulk Invite Users + +
+ {/* Step indicator */} + {parsedData.length === 0 ? ( +
+
+
+ 1
+

Download and fill the template

- -
+
+

Add multiple users at once by following these steps:

+
    +
  1. Download our CSV template
  2. +
  3. Add your users' information to the spreadsheet
  4. +
  5. Save the file and upload it here
  6. +
  7. After creation, download the results file containing the Virtual Keys for each user
  8. +
-
-
- 2 -
-

Upload your completed CSV

-
- -
- {selectedFile ? ( -
-
-
- {fileError ? ( - - ) : ( - - )} +
+

Template Column Names

+
+
+
- - {selectedFile.name} - - - {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} - +

user_email

+

User's email address (required)

- -
- - {fileError ? ( -
- - {fileError} -
- ) : ( - !csvStructureError && ( -
-
-
-
- Processing... +
+
+
+

user_role

+

+ User's role (one of: "proxy_admin", "proxy_admin_viewer", + "internal_user", "internal_user_viewer") +

+
+
+
+
+
+

teams

+

+ Comma-separated team IDs (e.g., "team-1,team-2") +

+
+
+
+
+
+

max_budget

+

Maximum budget as a number (e.g., "100")

+
+
+
+
+
+

budget_duration

+

+ Budget reset period (e.g., "30d", "1mo") +

+
+
+
+
+
+

models

+

+ Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") +

- ) - )} -
- ) : ( - -
- -

Drag and drop your CSV file here

-

or

- -

Only CSV files (.csv) are supported

-
-
- )} - - {csvStructureError && ( -
-
- -
- - CSV Structure Error - - - {csvStructureError} - - - Please download our template and ensure your CSV follows the required format. -
- )} -
-
- ) : ( -
-
-
- 3 + +
-

- {parsedData.some((user) => user.status === "success" || user.status === "failed") - ? "User Creation Results" - : "Review and create users"} -

-
- {parseError && ( -
-
- -
- {parseError} - {parsedData.some((user) => !user.isValid) && ( -
    -
  • Check the table below for specific errors in each row
  • -
  • - Common issues include invalid email formats, missing required fields, or incorrect role - values -
  • -
  • Fix these issues in your CSV file and upload again
  • -
+
+
+ 2 +
+

Upload your completed CSV

+
+ +
+ {selectedFile ? ( +
+
+
+ {fileError ? ( + + ) : ( + + )} +
+ + {selectedFile.name} + + + {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} + +
+
+ +
+ + {fileError ? ( +
+ + {fileError} +
+ ) : ( + !csvStructureError && ( +
+
+
+
+ Processing... +
+ ) )}
-
-
- )} + ) : ( + + )} -
-
-
- {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( -
- Creation Summary - - {parsedData.filter((d) => d.status === "success").length} Successful - - {parsedData.some((d) => d.status === "failed") && ( - - {parsedData.filter((d) => d.status === "failed").length} Failed - + {csvStructureError && ( +
+
+ +
+ CSV Structure Error +

{csvStructureError}

+

+ Please download our template and ensure your CSV follows the required format. +

+
+
+
+ )} +
+
+ ) : ( +
+
+
+ 3 +
+

+ {parsedData.some((user) => user.status === "success" || user.status === "failed") + ? "User Creation Results" + : "Review and create users"} +

+
+ + {parseError && ( +
+
+ +
+

{parseError}

+ {parsedData.some((user) => !user.isValid) && ( +
    +
  • Check the table below for specific errors in each row
  • +
  • + Common issues include invalid email formats, missing required fields, or incorrect role + values +
  • +
  • Fix these issues in your CSV file and upload again
  • +
)}
- ) : ( -
- User Preview - - {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid - +
+
+ )} + +
+
+
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( +
+

Creation Summary

+

+ {parsedData.filter((d) => d.status === "success").length} Successful +

+ {parsedData.some((d) => d.status === "failed") && ( +

+ {parsedData.filter((d) => d.status === "failed").length} Failed +

+ )} +
+ ) : ( +
+

User Preview

+

+ {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid +

+
+ )} +
+ + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
)}
- {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
+ {parsedData.some((user) => user.status === "success") && ( +
+
+
+ +
+
+

User creation complete

+

+ Next step: Download the credentials file containing + Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests + through LiteLLM. +

+
+
+
+ )} + +
+ + + + Row + Email + Role + Teams + Budget + Status + + + + {visibleRows.map((record) => ( + + {record.rowNumber} + {record.user_email} + {record.user_role} + {record.teams} + {record.max_budget} + {renderStatusCell(record)} + + ))} + +
+
+ + {pageCount > 1 && ( +
+ + Page {currentPage + 1} of {pageCount} + + +
+ )} + + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+
)} -
- {parsedData.some((user) => user.status === "success") && ( -
-
-
- -
-
- User creation complete - - Next step: Download the credentials file containing - Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests - through LiteLLM. - -
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
-
- )} - - (!record.isValid ? "bg-red-50" : "")} - /> - - {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} - - {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} + )} + - - )} - - + )} + + + ); }; diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index c9bcc1eb5b9..62efa1dc372 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -1,8 +1,8 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking"; +import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking"; import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings"; vi.mock("./networking", () => ({ @@ -114,42 +114,20 @@ describe("Settings", () => { }); }); - it("should display edit modal with fields when edit is clicked", async () => { - const mockCallback = { - name: "langfuse", - variables: { - LANGFUSE_PUBLIC_KEY: "test-public-key", - LANGFUSE_SECRET_KEY: "test-secret-key", - LANGFUSE_HOST: "https://test.langfuse.com", - SLACK_WEBHOOK_URL: null, - OPENMETER_API_KEY: null, - }, - }; - - const mockCallbackConfig = { - id: "langfuse", - displayName: "Langfuse", - dynamic_params: { - LANGFUSE_PUBLIC_KEY: { - type: "text", - ui_name: "Public Key", - required: true, - }, - LANGFUSE_SECRET_KEY: { - type: "password", - ui_name: "Secret Key", - required: true, - }, - LANGFUSE_HOST: { - type: "text", - ui_name: "Host", - required: false, - }, - }, - }; - + const openLangfuseEditModal = async () => { mockGetCallbacksCall.mockResolvedValue({ - callbacks: [mockCallback], + callbacks: [ + { + name: "langfuse", + variables: { + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://test.langfuse.com", + SLACK_WEBHOOK_URL: null, + OPENMETER_API_KEY: null, + }, + }, + ], available_callbacks: { langfuse: { litellm_callback_name: "langfuse", @@ -160,30 +138,118 @@ describe("Settings", () => { alerts: [], }); - mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); + mockGetCallbackConfigsCall.mockResolvedValue([ + { + id: "langfuse", + displayName: "Langfuse", + dynamic_params: { + LANGFUSE_PUBLIC_KEY: { type: "text", ui_name: "Public Key", required: true }, + LANGFUSE_SECRET_KEY: { type: "password", ui_name: "Secret Key", required: true }, + LANGFUSE_HOST: { type: "text", ui_name: "Host", required: false }, + }, + }, + ]); const user = userEvent.setup(); - const { getByText } = render(); + render(); await waitFor(() => { - expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Langfuse")).toBeInTheDocument(); + expect(screen.getByText("Langfuse")).toBeInTheDocument(); }); await user.click(screen.getByTestId("callback-actions-langfuse-success")); await user.click(await screen.findByTestId("callback-action-edit")); await waitFor(() => { - expect(getByText("Edit Callback Settings")).toBeInTheDocument(); + expect(screen.getByText("Edit Callback Settings")).toBeInTheDocument(); + }); + + return user; + }; + + it("should display edit modal with fields when edit is clicked", async () => { + await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByText("Public Key")).toBeInTheDocument(); + expect(screen.getByText("Secret Key")).toBeInTheDocument(); + expect(screen.getByText("Host")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Public Key")).toBeInTheDocument(); - expect(getByText("Secret Key")).toBeInTheDocument(); - expect(getByText("Host")).toBeInTheDocument(); + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + expect(screen.getByLabelText("Secret Key")).toHaveValue("test-secret-key"); + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); + + it("should post the edited callback variables when the edit modal is saved", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + }); + + await user.clear(screen.getByLabelText("Host")); + await user.type(screen.getByLabelText("Host"), "https://edited.langfuse.com"); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + environment_variables: { + callback: "langfuse", + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://edited.langfuse.com", + }, + litellm_settings: { success_callback: ["langfuse"] }, + }); + }); + }); + + it("should block the edit submit when a required field is emptied", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + + await user.clear(screen.getByLabelText("Public Key")); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + expect(await screen.findByText("Please enter the public key")).toBeInTheDocument(); + expect(vi.mocked(setCallbacksCall)).not.toHaveBeenCalled(); + }); + + it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: "Alerting Types" })); + + const webhookInput = document.querySelector('input[name="llm_exceptions"]') as HTMLInputElement; + expect(webhookInput).not.toBeNull(); + await user.type(webhookInput, "https://hooks.example.com/llm-exceptions"); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + general_settings: expect.objectContaining({ + alert_to_webhook_url: expect.objectContaining({ + llm_exceptions: "https://hooks.example.com/llm-exceptions", + }), + }), + }); }); }); @@ -252,6 +318,19 @@ describe("backendCallbackLogoSrc", () => { }); }); +const CallbackSelectorHarness = ({ + callbackConfigs, +}: { + callbackConfigs: { id: string; displayName: string; logo?: string }[]; +}) => { + const form = useForm>(); + return ( + + + + ); +}; + describe("CallbackSelector logos", () => { it("resolves backend logos per entry: bare filename, external url, and missing logo", async () => { const callbackConfigs = [ @@ -260,13 +339,9 @@ describe("CallbackSelector logos", () => { { id: "nologo", displayName: "NoLogo" }, ]; - render( -
- - , - ); + render(); - fireEvent.mouseDown(screen.getByRole("combobox")); + await userEvent.click(screen.getByRole("combobox")); expect(await screen.findByAltText("Langfuse logo")).toHaveAttribute("src", "/ui/assets/logos/langfuse.png"); expect(screen.getByAltText("Hosted logo")).toHaveAttribute("src", "https://logos.example.com/hosted.png"); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 904fd4d611e..34fd9af06db 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -1,31 +1,26 @@ -import { - Button, - Card, - Grid, - SelectItem, - Switch, - Tab, - TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, -} from "@tremor/react"; import React, { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; -import { Button as Button2, Form, Input, Modal, Select } from "antd"; +import { Field, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import EmailSettings from "./email_settings"; import { Logo } from "@/components/molecules/logo/Logo"; import NotificationsManager from "./molecules/notifications_manager"; -import FormItem from "antd/es/form/FormItem"; import AlertingSettings from "./alerting/alerting_settings"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; @@ -46,6 +41,8 @@ interface SettingsPageProps { premiumUser: boolean; } +type CallbackFormValues = Record; + const assetsLogoFolder = "/ui/assets/logos/"; export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => { @@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps { } const DynamicParamsFields: React.FC = ({ params, callbackConfigs, selectedCallback }) => { + const { register, formState } = useFormContext(); + const fieldIdPrefix = React.useId(); + if (!params || params.length === 0) { return null; } @@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC = ({ params, callb const paramType = paramConfig.type || "text"; const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); const isRequired = paramConfig.required || false; + const fieldId = `${fieldIdPrefix}-${param}`; + const registration = register( + param, + isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined, + ); return ( - {fieldLabel} } - name={param} - key={param} - className="mb-4" - rules={ - isRequired - ? [ - { - required: true, - message: `Please enter the ${fieldLabel.toLowerCase()}`, - }, - ] - : undefined - } - > + + + {fieldLabel} + {paramType === "password" ? ( - ) : paramType === "number" ? ( ) : ( - + )} - + + ); })} ); }; +interface CallbackConfigOption { + id: string; + displayName: string; + logo?: string | null; +} + // Shared component for rendering callback selector interface CallbackSelectorProps { callbackConfigs: any[]; @@ -135,42 +132,64 @@ export const CallbackSelector: React.FC = ({ onCallbackChange, disabled = false, }) => { + const { control } = useFormContext(); + const inputId = React.useId(); + const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null; + return ( - - - + rules={disabled ? undefined : { required: "Please select a callback" }} + render={({ field, fieldState }) => ( + + Callback + { + field.onChange(config?.id ?? ""); + onCallbackChange(config?.id ?? ""); + }} + isItemEqualToValue={(a: CallbackConfigOption, b: CallbackConfigOption) => a.id === b.id} + itemToStringLabel={(config: CallbackConfigOption) => config.displayName} + filter={(config: CallbackConfigOption, query: string) => + config.id.toLowerCase().includes(query.trim().toLowerCase()) + } + disabled={disabled} + > + + + No results + + {(callbackConfig: CallbackConfigOption) => ( + +
+
+ +
+ {callbackConfig.displayName} +
+
+ )} +
+
+
+ +
+ )} + /> ); }; @@ -206,8 +225,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const [callbacks, setCallbacks] = useState([]); const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); const [alerts, setAlerts] = useState([]); - const [addForm] = Form.useForm(); - const [editForm] = Form.useForm(); + const addForm = useForm({ shouldUnregister: true }); + const editForm = useForm({ shouldUnregister: true }); const [selectedCallback, setSelectedCallback] = useState(null); const [catchAllWebhookURL, setCatchAllWebhookURL] = useState(""); const [alertToWebhooks, setAlertToWebhooks] = useState>({}); @@ -254,7 +273,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const normalized = Object.fromEntries( Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]), ); - editForm.setFieldsValue({ + editForm.reset({ ...normalized, callback: selectedEditCallback.name, }); @@ -337,11 +356,11 @@ const Settings: React.FC = ({ accessToken, userRole, userID, if (isEdit) { setShowEditCallback(false); - editForm.resetFields(); + editForm.reset(); setSelectedEditCallback(null); } else { setShowAddCallbacksModal(false); - addForm.resetFields(); + addForm.reset(); setSelectedCallback(null); setSelectedCallbackParams([]); } @@ -383,6 +402,23 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setSelectedCallbackParams(params); }; + const closeAddCallbackModal = () => { + setShowAddCallbacksModal(false); + setSelectedCallback(null); + setSelectedCallbackParams([]); + }; + + const cancelAddCallback = () => { + closeAddCallbackModal(); + addForm.reset(); + }; + + const closeEditCallbackModal = () => { + setShowEditCallback(false); + setSelectedEditCallback(null); + editForm.reset(); + }; + const handleSaveAlerts = async () => { if (!accessToken) { return; @@ -447,257 +483,216 @@ const Settings: React.FC = ({ accessToken, userRole, userID, return (
- - - - Logging Callbacks - CloudZero Cost Tracking - Alerting Types - Alerting Settings - Email Alerts - - - - setShowAddCallbacksModal(true)} - onEdit={(cb) => { - setSelectedEditCallback(cb); - setShowEditCallback(true); - }} - onDelete={(cb) => handleDeleteCallback(cb)} - onTest={async (cb) => { - try { - await serviceHealthCheck(accessToken, cb.name); - NotificationsManager.success("Health check triggered"); - } catch (error) { - NotificationsManager.fromBackend(parseErrorMessage(error)); - } - }} - /> - - -
- -
-
- - - - Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} - - here - - -
- - - - - Slack Webhook URL - - +
+ + + Logging Callbacks + CloudZero Cost Tracking + Alerting Types + Alerting Settings + Email Alerts + + + setShowAddCallbacksModal(true)} + onEdit={(cb) => { + setSelectedEditCallback(cb); + setShowEditCallback(true); + }} + onDelete={(cb) => handleDeleteCallback(cb)} + onTest={async (cb) => { + try { + await serviceHealthCheck(accessToken, cb.name); + NotificationsManager.success("Health check triggered"); + } catch (error) { + NotificationsManager.fromBackend(parseErrorMessage(error)); + } + }} + /> + + +
+ +
+
+ + +

+ Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} + + here + +

+
+ + + + + Slack Webhook URL + + - - {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( - - - {key == "region_outage_alerts" ? ( - premiumUser ? ( - handleSwitchChange(key)} - /> - ) : ( - - ) - ) : ( + + {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( + + + {key == "region_outage_alerts" ? ( + premiumUser ? ( handleSwitchChange(key)} + onCheckedChange={() => handleSwitchChange(key)} /> - )} - - - {value} - - - - - - ))} - -
- + ) : ( + + ) + ) : ( + handleSwitchChange(key)} + /> + )} + + +

{value}

+
+ + + + + ))} + + + - - - - - - - - - - - - + + + + + + + + + + +
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - }} - footer={null} - > - - {" "} - LiteLLM Docs: Logging - + !open && closeAddCallbackModal()}> + + + Add Logging Callback + + + {" "} + LiteLLM Docs: Logging + -
- - - - -
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - addForm.resetFields(); - }} - disabled={isAddingCallback} - > - Cancel - - - {isAddingCallback ? "Adding..." : "Add Callback"} - -
- -
- - { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - footer={null} - > -
- {selectedEditCallback && ( - <> + + {}} - disabled={true} + selectedCallback={selectedCallback} + onCallbackChange={handleSelectedCallbackChange} /> - - )} -
- { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - disabled={isUpdatingCallback} - > - Cancel - - { - editForm.submit(); - }} - loading={isUpdatingCallback} - disabled={isUpdatingCallback} - > - {isUpdatingCallback ? "Saving..." : "Save Changes"} - -
- -
+
+ + +
+ + + +
+ + !open && closeEditCallbackModal()}> + + + Edit Callback Settings + + +
+ {selectedEditCallback && ( + <> + {}} + disabled={true} + /> + + + + )} + +
+ + +
+ +
+
+