diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index eefb0302a8..44cfdb6aa5 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -30,8 +30,7 @@ import { Text, TextInput, } from "@tremor/react"; -import { Button as Button2, Form, Input, Modal, Select as Select2, Tooltip, Typography } from "antd"; -import { AlertTriangleIcon, XIcon } from "lucide-react"; +import { Button as Button2, Form, Input, Modal, Select as Select2, Switch, Tooltip, Typography } from "antd"; import React, { useEffect, useState } from "react"; import { formatNumberWithCommas } from "../utils/dataUtils"; import { fetchTeams } from "./common_components/fetch_teams"; @@ -77,6 +76,7 @@ interface EditTeamModalProps { } import { updateExistingKeys } from "@/utils/dataUtils"; +import DeleteResourceModal from "./common_components/DeleteResourceModal"; import { Member, teamCreateCall, v2TeamListCall } from "./networking"; interface TeamInfo { diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx index 403548e611..93de859dd7 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx @@ -57,10 +57,6 @@ export default function DeleteResourceModal({ >
{alertMessage && } -
- {message} -
-
{resourceInformationTitle} @@ -74,18 +70,23 @@ export default function DeleteResourceModal({ ))} </Descriptions> </div> + <div> + <Text>{message}</Text> + </div> {requiredConfirmation && ( - <div className="mb-5"> + <div className="mb-6 mt-4 pt-4 border-t border-gray-200"> <Text className="block text-base font-medium text-gray-700 mb-2"> - {`Type `} - <span className="underline">{requiredConfirmation}</span> - {` to confirm deletion:`} + <Text>Type </Text> + <Text strong type="danger"> + {requiredConfirmation} + </Text> + <Text> to confirm deletion:</Text> </Text> <Input value={requiredConfirmationInput} onChange={(e) => setRequiredConfirmationInput(e.target.value)} placeholder={requiredConfirmation} - className="rounded-md" + className="rounded-md text-base border-gray-200" autoFocus /> </div> diff --git a/ui/litellm-dashboard/src/components/guardrails.test.tsx b/ui/litellm-dashboard/src/components/guardrails.test.tsx new file mode 100644 index 0000000000..8cafc18eb9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import GuardrailsPanel from "./guardrails"; +import { getGuardrailsList } from "./networking"; + +vi.mock("./networking", () => ({ + getGuardrailsList: vi.fn(), + deleteGuardrailCall: vi.fn(), +})); + +vi.mock("./guardrails/add_guardrail_form", () => ({ + __esModule: true, + default: () => <div>Mock Add Guardrail Form</div>, +})); + +vi.mock("./guardrails/guardrail_table", () => ({ + __esModule: true, + default: ({ guardrailsList, onDeleteClick }: any) => ( + <div> + <div>Mock Guardrail Table</div> + {guardrailsList.length > 0 && ( + <button + data-testid="delete-button" + onClick={() => onDeleteClick(guardrailsList[0].guardrail_id, guardrailsList[0].guardrail_name)} + > + Delete + </button> + )} + </div> + ), +})); + +vi.mock("./guardrails/guardrail_info", () => ({ + __esModule: true, + default: () => <div>Mock Guardrail Info View</div>, +})); + +vi.mock("./guardrails/GuardrailTestPlayground", () => ({ + __esModule: true, + default: () => <div>Mock Guardrail Test Playground</div>, +})); + +vi.mock("@/utils/roles", () => ({ + isAdminRole: vi.fn((role: string) => role === "admin"), +})); + +vi.mock("./guardrails/guardrail_info_helpers", () => ({ + getGuardrailLogoAndName: vi.fn(() => ({ + logo: null, + displayName: "Test Provider", + })), +})); + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +describe("GuardrailsPanel", () => { + const defaultProps = { + accessToken: "test-token", + userRole: "admin", + }; + + const mockGetGuardrailsList = vi.mocked(getGuardrailsList); + + beforeEach(() => { + vi.clearAllMocks(); + mockGetGuardrailsList.mockResolvedValue({ + guardrails: [ + { + guardrail_id: "test-guardrail-1", + guardrail_name: "Test Guardrail", + litellm_params: { + guardrail: "test-provider", + mode: "async", + default_on: true, + }, + guardrail_info: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + guardrail_definition_location: "database" as any, + }, + ], + }); + }); + + it("should render the component", async () => { + render(<GuardrailsPanel {...defaultProps} />); + expect(screen.getByText("Guardrails")).toBeInTheDocument(); + expect(screen.getByText("+ Add New Guardrail")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index 3861545f8c..26b9acb6a2 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from "react"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; -import { Modal } from "antd"; import { getGuardrailsList, deleteGuardrailCall } from "./networking"; import AddGuardrailForm from "./guardrails/add_guardrail_form"; import GuardrailTable from "./guardrails/guardrail_table"; @@ -9,6 +8,8 @@ import GuardrailInfoView from "./guardrails/guardrail_info"; import GuardrailTestPlayground from "./guardrails/GuardrailTestPlayground"; import NotificationsManager from "./molecules/notifications_manager"; import { Guardrail, GuardrailDefinitionLocation } from "./guardrails/types"; +import DeleteResourceModal from "./common_components/DeleteResourceModal"; +import { getGuardrailLogoAndName } from "./guardrails/guardrail_info_helpers"; interface GuardrailsPanelProps { accessToken: string | null; @@ -38,7 +39,8 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [guardrailToDelete, setGuardrailToDelete] = useState<{ id: string; name: string } | null>(null); + const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null); const [activeTab, setActiveTab] = useState<number>(0); @@ -81,7 +83,9 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole }; const handleDeleteClick = (guardrailId: string, guardrailName: string) => { - setGuardrailToDelete({ id: guardrailId, name: guardrailName }); + const guardrail = guardrailsList.find((g) => g.guardrail_id === guardrailId) || null; + setGuardrailToDelete(guardrail); + setIsDeleteModalOpen(true); }; const handleDeleteConfirm = async () => { @@ -90,22 +94,29 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole // Log removed to maintain clean production code setIsDeleting(true); try { - await deleteGuardrailCall(accessToken, guardrailToDelete.id); - NotificationsManager.success(`Guardrail "${guardrailToDelete.name}" deleted successfully`); - fetchGuardrails(); // Refresh the list + await deleteGuardrailCall(accessToken, guardrailToDelete.guardrail_id); + NotificationsManager.success(`Guardrail "${guardrailToDelete.guardrail_name}" deleted successfully`); + await fetchGuardrails(); // Refresh the list } catch (error) { console.error("Error deleting guardrail:", error); NotificationsManager.fromBackend("Failed to delete guardrail"); } finally { setIsDeleting(false); + setIsDeleteModalOpen(false); setGuardrailToDelete(null); } }; const handleDeleteCancel = () => { + setIsDeleteModalOpen(false); setGuardrailToDelete(null); }; + const providerDisplayName = + guardrailToDelete && guardrailToDelete.litellm_params + ? getGuardrailLogoAndName(guardrailToDelete.litellm_params.guardrail).displayName + : undefined; + return ( <div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2"> <TabGroup index={activeTab} onIndexChange={setActiveTab}> @@ -148,20 +159,25 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole onSuccess={handleSuccess} /> - {guardrailToDelete && ( - <Modal - title="Delete Guardrail" - open={guardrailToDelete !== null} - onOk={handleDeleteConfirm} - onCancel={handleDeleteCancel} - confirmLoading={isDeleting} - okText="Delete" - okButtonProps={{ danger: true }} - > - <p>Are you sure you want to delete guardrail: {guardrailToDelete.name} ?</p> - <p>This action cannot be undone.</p> - </Modal> - )} + <DeleteResourceModal + isOpen={isDeleteModalOpen} + title="Delete Guardrail" + message={`Are you sure you want to delete guardrail: ${guardrailToDelete?.guardrail_name}? This action cannot be undone.`} + resourceInformationTitle="Guardrail Information" + resourceInformation={[ + { label: "Name", value: guardrailToDelete?.guardrail_name }, + { label: "ID", value: guardrailToDelete?.guardrail_id, code: true }, + { label: "Provider", value: providerDisplayName }, + { label: "Mode", value: guardrailToDelete?.litellm_params.mode }, + { + label: "Default On", + value: guardrailToDelete?.litellm_params.default_on ? "Yes" : "No", + }, + ]} + onCancel={handleDeleteCancel} + onOk={handleDeleteConfirm} + confirmLoading={isDeleting} + /> </TabPanel> <TabPanel> diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/components/organizations.tsx index 71e9030a24..5f7275091e 100644 --- a/ui/litellm-dashboard/src/components/organizations.tsx +++ b/ui/litellm-dashboard/src/components/organizations.tsx @@ -32,6 +32,7 @@ import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; import { formatNumberWithCommas } from "../utils/dataUtils"; import NotificationsManager from "./molecules/notifications_manager"; +import DeleteResourceModal from "./common_components/DeleteResourceModal"; interface OrganizationsTableProps { organizations: Organization[]; @@ -70,6 +71,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({ const [editOrg, setEditOrg] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [orgToDelete, setOrgToDelete] = useState<string | null>(null); + const [isDeleting, setIsDeleting] = useState(false); const [isOrgModalVisible, setIsOrgModalVisible] = useState(false); const [form] = Form.useForm(); const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({}); @@ -91,15 +93,18 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({ if (!orgToDelete || !accessToken) return; try { + setIsDeleting(true); await organizationDeleteCall(accessToken, orgToDelete); NotificationsManager.success("Organization deleted successfully"); setIsDeleteModalOpen(false); setOrgToDelete(null); // Refresh organizations list - fetchOrganizations(accessToken, setOrganizations); + await fetchOrganizations(accessToken, setOrganizations); } catch (error) { console.error("Error deleting organization:", error); + } finally { + setIsDeleting(false); } }; @@ -506,40 +511,16 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({ </Form> </Modal> - {isDeleteModalOpen ? ( - <div className="fixed z-10 inset-0 overflow-y-auto"> - <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> - <div className="fixed inset-0 transition-opacity" aria-hidden="true"> - <div className="absolute inset-0 bg-gray-500 opacity-75"></div> - </div> - - <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> - ​ - </span> - - <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> - <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> - <div className="sm:flex sm:items-start"> - <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> - <h3 className="text-lg leading-6 font-medium text-gray-900">Delete Organization</h3> - <div className="mt-2"> - <p className="text-sm text-gray-500">Are you sure you want to delete this organization?</p> - </div> - </div> - </div> - </div> - <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> - <Button onClick={confirmDelete} color="red" className="ml-2"> - Delete - </Button> - <Button onClick={cancelDelete}>Cancel</Button> - </div> - </div> - </div> - </div> - ) : ( - <></> - )} + <DeleteResourceModal + isOpen={isDeleteModalOpen} + title="Delete Organization?" + message="Are you sure you want to delete this organization? This action cannot be undone." + resourceInformationTitle="Organization Information" + resourceInformation={[{ label: "Organization ID", value: orgToDelete, code: true }]} + onCancel={cancelDelete} + onOk={confirmDelete} + confirmLoading={isDeleting} + /> </div> ); }; diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index f8a7bafce5..80b5653e75 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -38,6 +38,7 @@ import { import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable"; import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types"; import { parseErrorMessage } from "./shared/errorUtils"; +import DeleteResourceModal from "./common_components/DeleteResourceModal"; interface SettingsPageProps { accessToken: string | null; userRole: string | null; @@ -240,9 +241,10 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, const [showEditCallback, setShowEditCallback] = useState(false); const [selectedEditCallback, setSelectedEditCallback] = useState<any | null>(null); const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); - const [callbackToDelete, setCallbackToDelete] = useState<string | null>(null); + const [callbackToDelete, setCallbackToDelete] = useState<any | null>(null); const [isUpdatingCallback, setIsUpdatingCallback] = useState(false); const [isAddingCallback, setIsAddingCallback] = useState(false); + const [isDeletingCallback, setIsDeletingCallback] = useState(false); useEffect(() => { if (!accessToken) { @@ -525,8 +527,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, }); }; - const handleDeleteCallback = (callbackName: string) => { - setCallbackToDelete(callbackName); + const handleDeleteCallback = (callback: any) => { + setCallbackToDelete(callback); setShowDeleteConfirmModal(true); }; @@ -536,8 +538,9 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, } try { - await deleteCallback(accessToken, callbackToDelete); - NotificationsManager.success(`Callback ${callbackToDelete} deleted successfully`); + setIsDeletingCallback(true); + await deleteCallback(accessToken, callbackToDelete.name); + NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`); // Refresh the callbacks list if (userID && userRole) { @@ -550,6 +553,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, } catch (error) { console.error("Failed to delete callback:", error); NotificationsManager.fromBackend(error); + } finally { + setIsDeletingCallback(false); } }; @@ -577,7 +582,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, setSelectedEditCallback(cb); setShowEditCallback(true); }} - onDelete={(cb) => handleDeleteCallback(cb.name)} + onDelete={(cb) => handleDeleteCallback(cb)} onTest={async (cb) => { try { await serviceHealthCheck(accessToken, cb.name); @@ -804,20 +809,22 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, </Form> </Modal> - <Modal - title="Confirm Delete" - open={showDeleteConfirmModal} - onOk={confirmDeleteCallback} + <DeleteResourceModal + isOpen={showDeleteConfirmModal} + title="Delete Callback" + message="Are you sure you want to delete this callback? This action cannot be undone." + resourceInformationTitle="Callback Information" + resourceInformation={[ + { label: "Callback Name", value: callbackToDelete?.name }, + { label: "Mode", value: callbackToDelete?.mode || "success" }, + ]} onCancel={() => { setShowDeleteConfirmModal(false); setCallbackToDelete(null); }} - okText="Delete" - cancelText="Cancel" - okButtonProps={{ danger: true }} - > - <p>Are you sure you want to delete the callback - {callbackToDelete}? This action cannot be undone.</p> - </Modal> + onOk={confirmDeleteCallback} + confirmLoading={isDeletingCallback} + /> </div> ); }; diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index 0dada8bb79..6558889d28 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -25,7 +25,7 @@ import { teamUpdateCall, getGuardrailsList, } from "@/components/networking"; -import { Button, Form, Input, Select, Switch, message, Modal, Tooltip } from "antd"; +import { Button, Form, Input, Select, Switch, message, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import MemberModal from "./edit_membership"; @@ -44,6 +44,7 @@ import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; import NotificationsManager from "../molecules/notifications_manager"; import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; export interface TeamMembership { user_id: string; @@ -139,6 +140,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({ const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({}); const [guardrailsList, setGuardrailsList] = useState<string[]>([]); const [memberToDelete, setMemberToDelete] = useState<Member | null>(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); console.log("userModels in team info", userModels); @@ -272,6 +274,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({ const handleMemberDelete = (member: Member) => { setMemberToDelete(member); + setIsDeleteModalOpen(true); }; const handleDeleteConfirm = async () => { @@ -294,11 +297,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({ console.error("Error removing team member:", error); } finally { setIsDeleting(false); + setIsDeleteModalOpen(false); setMemberToDelete(null); } }; const handleDeleteCancel = () => { + setIsDeleteModalOpen(false); setMemberToDelete(null); }; @@ -924,28 +929,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({ /> {/* Delete Member Confirmation Modal */} - {memberToDelete && ( - <Modal - title="Delete Team Member" - open={memberToDelete !== null} - onOk={handleDeleteConfirm} - onCancel={handleDeleteCancel} - confirmLoading={isDeleting} - okText={isDeleting ? "Deleting..." : "Delete"} - okButtonProps={{ danger: true }} - > - <p>Are you sure you want to remove this member from the team?</p> - <p className="mt-2"> - <strong>User ID:</strong> {memberToDelete.user_id} - </p> - {memberToDelete.user_email && ( - <p> - <strong>Email:</strong> {memberToDelete.user_email} - </p> - )} - <p className="mt-2 text-red-600">This action cannot be undone.</p> - </Modal> - )} + <DeleteResourceModal + isOpen={isDeleteModalOpen} + title="Delete Team Member" + alertMessage="Removing team members will also delete any keys created by or created for this member." + message="Are you sure you want to remove this member from the team? This action cannot be undone." + resourceInformationTitle="Team Member Information" + resourceInformation={[ + { label: "User ID", value: memberToDelete?.user_id, code: true }, + { label: "Email", value: memberToDelete?.user_email }, + { label: "Role", value: memberToDelete?.role }, + ]} + onCancel={handleDeleteCancel} + onOk={handleDeleteConfirm} + confirmLoading={isDeleting} + /> </div> ); };