Fix edit logging settings not appearing (#16798)

This commit is contained in:
yuneng-jiang 2025-11-18 20:31:34 -08:00 committed by GitHub
parent 34cc532d8d
commit 5f463cef64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 355 additions and 198 deletions

View File

@ -1,9 +1,8 @@
import { PencilAltIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline";
import { Button, Icon } from "@tremor/react";
import type { TableProps } from "antd";
import { Table } from "antd";
import { Table, Tooltip } from "antd";
import Title from "antd/es/typography/Title";
import { PlusCircle } from "lucide-react";
import React from "react";
import { AlertingObject } from "./types";
@ -80,31 +79,31 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
align: "right",
render: (_: unknown, record: CallbackRow) => (
<div className="flex justify-end gap-2">
<span>
<Tooltip title="Test Callback">
<Icon
icon={PlayIcon}
size="sm"
className="cursor-pointer text-indigo-600 hover:text-indigo-700"
onClick={() => onTest(record)}
/>
</span>
</Tooltip>
<span>
<Tooltip title="Edit Callback">
<Icon
icon={PencilAltIcon}
size="sm"
className="cursor-pointer text-indigo-600 hover:text-indigo-700"
onClick={() => onEdit(record)}
/>
</span>
<span>
</Tooltip>
<Tooltip title="Delete Callback">
<Icon
icon={TrashIcon}
size="sm"
className="cursor-pointer text-indigo-600 hover:text-red-600"
onClick={() => onDelete(record)}
/>
</span>
</Tooltip>
</div>
),
width: 240,
@ -124,16 +123,9 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
<div className="flex flex-col items-center justify-center p-8 bg-gray-50 border border-gray-200 rounded-lg">
<div className="text-center">
<h3 className="text-lg font-medium text-gray-700 mb-2">No callbacks configured</h3>
<p className="text-gray-500 mb-4">Add your first callback to start logging data to external services.</p>
<button
onClick={onAdd}
className="inline-flex items-center gap-2 px-4 py-2 bg-[#6366f1] text-white rounded-md hover:bg-[#5558eb] transition-colors"
>
<PlusCircle size={18} />
Add Callback
</button>
<p className="text-gray-500">Add your first callback to start logging data to external services.</p>
</div>
</div> /* Callbacks list */
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<Table

View File

@ -1,4 +1,4 @@
import { render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking";
import Settings from "./settings";
@ -105,4 +105,86 @@ describe("Settings", () => {
expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken);
});
});
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,
},
},
};
mockGetCallbacksCall.mockResolvedValue({
callbacks: [mockCallback],
available_callbacks: {
langfuse: {
litellm_callback_name: "langfuse",
litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"],
ui_callback_name: "Langfuse",
},
},
alerts: [],
});
mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]);
const { getByText, container } = render(<Settings {...defaultProps} />);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Langfuse")).toBeInTheDocument();
});
const actionsCell = container.querySelector('[class*="flex justify-end gap-2"]');
expect(actionsCell).toBeTruthy();
const icons = actionsCell?.querySelectorAll("svg");
expect(icons?.length).toBeGreaterThanOrEqual(2);
const editIconParent = icons?.[1]?.closest('[class*="cursor-pointer"]');
expect(editIconParent).toBeTruthy();
act(() => {
fireEvent.click(editIconParent!);
});
await waitFor(() => {
expect(getByText("Edit Callback Settings")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Public Key")).toBeInTheDocument();
expect(getByText("Secret Key")).toBeInTheDocument();
expect(getByText("Host")).toBeInTheDocument();
});
});
});

View File

@ -53,6 +53,164 @@ interface genericCallbackParams {
const assetsLogoFolder = "../ui/assets/logos/";
interface DynamicParamsFieldsProps {
params: string[];
callbackConfigs: any[];
selectedCallback: string | null;
}
const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callbackConfigs, selectedCallback }) => {
if (!params || params.length === 0) {
return null;
}
return (
<div className="space-y-4 mt-6 p-4 bg-gray-50 rounded-lg border">
{params.map((param) => {
const callbackConfig = callbackConfigs.find((config) => config.id === selectedCallback);
const paramConfig = callbackConfig?.dynamic_params?.[param] || {};
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;
return (
<FormItem
label={<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>}
name={param}
key={param}
className="mb-4"
rules={
isRequired
? [
{
required: true,
message: `Please enter the ${fieldLabel.toLowerCase()}`,
},
]
: undefined
}
>
{paramType === "password" ? (
<Input.Password
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
) : paramType === "number" ? (
<Input
type="number"
size="large"
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
min={0}
max={1}
step={0.1}
/>
) : (
<Input
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
)}
</FormItem>
);
})}
</div>
);
};
// Shared component for rendering callback selector
interface CallbackSelectorProps {
callbackConfigs: any[];
selectedCallback: string | null;
onCallbackChange: (value: string) => void;
disabled?: boolean;
}
const CallbackSelector: React.FC<CallbackSelectorProps> = ({
callbackConfigs,
selectedCallback,
onCallbackChange,
disabled = false,
}) => {
return (
<FormItem
label="Callback"
name="callback"
rules={disabled ? undefined : [{ required: true, message: "Please select a callback" }]}
>
<Select
placeholder="Choose a logging callback..."
size="large"
className="w-full"
showSearch
disabled={disabled}
value={selectedCallback}
filterOption={(input, option) => {
return (option?.value?.toString() ?? "").toLowerCase().includes(input.toLowerCase());
}}
onChange={onCallbackChange}
>
{callbackConfigs.map((callbackConfig) => {
const logo = callbackConfig.logo;
const logoSrc =
logo && (logo.includes("/") || logo.startsWith("data:") || logo.startsWith("http"))
? logo
: `${assetsLogoFolder}${logo}`;
return (
<SelectItem key={callbackConfig.id} value={callbackConfig.id}>
<div className="flex items-center space-x-3 py-1">
<div className="w-6 h-6 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoSrc}
alt={`${callbackConfig.displayName} logo`}
className="w-6 h-6 rounded object-contain"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</div>
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
</div>
</SelectItem>
);
})}
</Select>
</FormItem>
);
};
// Shared helper function to get dynamic params for a callback
const getDynamicParamsForCallback = (
callbackName: string | null,
callbackConfigs: any[],
fallbackVariables?: Record<string, any>,
): string[] => {
if (!callbackName) {
return fallbackVariables ? Object.keys(fallbackVariables) : [];
}
const callbackConfig = callbackConfigs.find((config) => config.id === callbackName);
if (callbackConfig?.dynamic_params) {
return Object.keys(callbackConfig.dynamic_params);
}
return fallbackVariables ? Object.keys(fallbackVariables) : [];
};
// Shared helper function to build callback payload
const buildCallbackPayload = (formValues: Record<string, any>, callbackName: string) => {
return {
environment_variables: formValues,
litellm_settings: {
success_callback: [callbackName],
},
};
};
const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, premiumUser }) => {
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [alerts, setAlerts] = useState<any[]>([]);
@ -83,6 +241,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const [selectedEditCallback, setSelectedEditCallback] = useState<any | null>(null);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [callbackToDelete, setCallbackToDelete] = useState<string | null>(null);
const [isUpdatingCallback, setIsUpdatingCallback] = useState(false);
const [isAddingCallback, setIsAddingCallback] = useState(false);
useEffect(() => {
if (!accessToken) {
@ -102,7 +262,10 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const normalized = Object.fromEntries(
Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]),
);
editForm.setFieldsValue(normalized);
editForm.setFieldsValue({
...normalized,
callback: selectedEditCallback.name,
});
}
}, [showEditCallback, selectedEditCallback, editForm]);
@ -154,31 +317,36 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
return activeAlerts && activeAlerts.includes(alertName);
};
const updateCallbackCall = async (formValues: Record<string, any>) => {
if (!accessToken || !selectedEditCallback) {
// Shared handler for callback form submission
const handleCallbackSubmit = async (formValues: Record<string, any>, callbackName: string, isEdit: boolean) => {
if (!accessToken) {
return;
}
let env_vars: Record<string, string> = {};
// add all other variables
Object.entries(formValues).forEach(([key, value]) => {
if (key !== "callback") {
env_vars[key] = value;
}
});
let payload = {
environment_variables: formValues,
litellm_settings: {
success_callback: [selectedEditCallback.name],
},
};
if (isEdit) {
setIsUpdatingCallback(true);
} else {
setIsAddingCallback(true);
}
const payload = buildCallbackPayload(formValues, callbackName);
try {
await setCallbacksCall(accessToken, payload);
NotificationsManager.success("Callback updated successfully");
setShowEditCallback(false);
editForm.resetFields();
setSelectedEditCallback(null);
NotificationsManager.success(
isEdit ? "Callback updated successfully" : `Callback ${callbackName} added successfully`,
);
if (isEdit) {
setShowEditCallback(false);
editForm.resetFields();
setSelectedEditCallback(null);
} else {
setShowAddCallbacksModal(false);
addForm.resetFields();
setSelectedCallback(null);
setSelectedCallbackParams([]);
}
// Refresh the callbacks list
if (userID && userRole) {
@ -187,56 +355,34 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
}
} catch (error) {
NotificationsManager.fromBackend(error);
} finally {
if (isEdit) {
setIsUpdatingCallback(false);
} else {
setIsAddingCallback(false);
}
}
};
const addNewCallbackCall = async (formValues: Record<string, any>) => {
if (!accessToken) {
const updateCallbackCall = async (formValues: Record<string, any>) => {
if (!selectedEditCallback) {
return;
}
let new_callback = formValues?.callback;
await handleCallbackSubmit(formValues, selectedEditCallback.name, true);
};
let env_vars: Record<string, string> = {};
// add all other variables
Object.entries(formValues).forEach(([key, value]) => {
if (key !== "callback") {
env_vars[key] = value;
}
});
let payload = {
environment_variables: formValues,
litellm_settings: {
success_callback: [new_callback],
},
};
try {
await setCallbacksCall(accessToken, payload);
NotificationsManager.success(`Callback ${new_callback} added successfully`);
setShowAddCallbacksModal(false);
addForm.resetFields();
setSelectedCallback(null);
setSelectedCallbackParams([]);
// Refresh the callbacks list
const updatedData = await getCallbacksCall(accessToken, userID || "", userRole || "");
setCallbacks(updatedData.callbacks);
} catch (error) {
NotificationsManager.fromBackend(error);
const addNewCallbackCall = async (formValues: Record<string, any>) => {
const new_callback = formValues?.callback;
if (!new_callback) {
return;
}
await handleCallbackSubmit(formValues, new_callback, false);
};
const handleSelectedCallbackChange = (callbackName: string) => {
setSelectedCallback(callbackName);
const callbackConfig = callbackConfigs.find((config) => config.id === callbackName);
if (callbackConfig?.dynamic_params) {
setSelectedCallbackParams(Object.keys(callbackConfig.dynamic_params));
} else {
setSelectedCallbackParams([]);
}
const params = getDynamicParamsForCallback(callbackName, callbackConfigs);
setSelectedCallbackParams(params);
};
const handleSaveAlerts = async () => {
@ -564,103 +710,17 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<FormItem label="Callback" name="callback" rules={[{ required: true, message: "Please select a callback" }]}>
<Select
placeholder="Choose a logging callback..."
size="large"
className="w-full"
showSearch
filterOption={(input, option) => {
return (option?.value?.toString() ?? "").toLowerCase().includes(input.toLowerCase());
}}
onChange={(value) => {
handleSelectedCallbackChange(value);
}}
>
{callbackConfigs.map((callbackConfig) => {
const logo = callbackConfig.logo;
const logoSrc =
logo && (logo.includes("/") || logo.startsWith("data:") || logo.startsWith("http"))
? logo
: `${assetsLogoFolder}${logo}`;
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
return (
<SelectItem key={callbackConfig.id} value={callbackConfig.id}>
<div className="flex items-center space-x-3 py-1">
<div className="w-6 h-6 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoSrc}
alt={`${callbackConfig.displayName} logo`}
className="w-6 h-6 rounded object-contain"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</div>
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
</div>
</SelectItem>
);
})}
</Select>
</FormItem>
{selectedCallbackParams && selectedCallbackParams.length > 0 && (
<div className="space-y-4 mt-6 p-4 bg-gray-50 rounded-lg border">
{selectedCallbackParams.map((param) => {
const callbackConfig = callbackConfigs.find((config) => config.id === selectedCallback);
const paramConfig = callbackConfig?.dynamic_params?.[param] || {};
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;
return (
<FormItem
label={<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>}
name={param}
key={param}
className="mb-4"
rules={
isRequired
? [
{
required: true,
message: `Please enter the ${fieldLabel.toLowerCase()}`,
},
]
: undefined
}
>
{paramType === "password" ? (
<Input.Password
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
) : paramType === "number" ? (
<Input
type="number"
size="large"
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
min={0}
max={1}
step={0.1}
/>
) : (
<Input
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
)}
</FormItem>
);
})}
</div>
)}
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
@ -670,21 +730,25 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setSelectedCallbackParams([]);
addForm.resetFields();
}}
disabled={isAddingCallback}
>
Cancel
</Button2>
<Button2 htmlType="submit">Add Callback</Button2>
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
</Button2>
</div>
</Form>
</Modal>
<Modal
visible={showEditCallback}
open={showEditCallback}
width={800}
title={`Edit ${selectedEditCallback?.name} Settings`}
title={"Edit Callback Settings"}
onCancel={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.resetFields();
}}
footer={null}
>
@ -695,35 +759,54 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
{selectedEditCallback &&
selectedEditCallback.variables &&
Object.entries(selectedEditCallback.variables).map(([param]) => (
<FormItem
label={param}
name={param}
key={param}
rules={[
{
required: true,
message: `Please enter the value for ${param}`,
},
]}
>
<Input.Password />
</FormItem>
))}
</>
{selectedEditCallback && (
<>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
onCallbackChange={() => {}}
disabled={true}
/>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Save</Button2>
<DynamicParamsFields
params={getDynamicParamsForCallback(
selectedEditCallback.name,
callbackConfigs,
selectedEditCallback.variables,
)}
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
/>
</>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
onClick={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.resetFields();
}}
disabled={isUpdatingCallback}
>
Cancel
</Button2>
<Button2
onClick={() => {
editForm.submit();
}}
loading={isUpdatingCallback}
disabled={isUpdatingCallback}
>
{isUpdatingCallback ? "Saving..." : "Save Changes"}
</Button2>
</div>
</Form>
</Modal>
<Modal
title="Confirm Delete"
visible={showDeleteConfirmModal}
open={showDeleteConfirmModal}
onOk={confirmDeleteCallback}
onCancel={() => {
setShowDeleteConfirmModal(false);