Move "Store Prompts in Spend Logs" toggle to Admin Settings

Previously, the "Store Prompts in Spend Logs" and "Maximum Spend Logs
Retention Period" settings were surfaced via a gear-icon modal on the
Logs page. The gear was visible to every authenticated user even though
the backend endpoints (/config/update, /config/list) require PROXY_ADMIN
— so non-admins could open the modal but the request would 403 on load
and save, giving a confusing UX.

Move the controls into a new "Logging Settings" tab under Admin Settings,
which is already gated to admins at the sidebar. Remove the gear button
and the onOpenSettings prop chain (ConfigInfoMessage → LogDetailContent →
LogDetailsDrawer). ConfigInfoMessage now points users to
"Admin Settings → Logging Settings" inline.
This commit is contained in:
Ryan Crabbe
2026-04-23 21:04:13 -07:00
parent 7b47dffefd
commit 2c3c8aa4ea
10 changed files with 228 additions and 426 deletions
@@ -21,6 +21,7 @@ import { useBaseUrl } from "./constants";
import NotificationsManager from "./molecules/notifications_manager";
import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "./networking";
import SCIMConfig from "./SCIM";
import LoggingSettings from "./Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "./Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "./Settings/AdminSettings/UISettings/UISettings";
import HashicorpVault from "./Settings/AdminSettings/HashicorpVault/HashicorpVault";
@@ -362,6 +363,11 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
),
children: <UISettings />,
},
{
key: "logging-settings",
label: "Logging Settings",
children: <LoggingSettings />,
},
{
key: "hashicorp-vault",
label: "Hashicorp Vault",
@@ -5,11 +5,20 @@ import { parseErrorMessage } from "@/components/shared/errorUtils";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import SpendLogsSettingsModal from "./SpendLogsSettingsModal";
import { renderWithProviders } from "../../../../../tests/test-utils";
import LoggingSettings from "./LoggingSettings";
vi.mock("@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs");
vi.mock("@/app/(dashboard)/hooks/proxyConfig/useProxyConfig");
vi.mock("@/app/(dashboard)/hooks/proxyConfig/useProxyConfig", async () => {
const actual = await vi.importActual<typeof import("@/app/(dashboard)/hooks/proxyConfig/useProxyConfig")>(
"@/app/(dashboard)/hooks/proxyConfig/useProxyConfig",
);
return {
...actual,
useProxyConfig: vi.fn(),
useDeleteProxyConfigField: vi.fn(),
};
});
vi.mock("@/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
@@ -26,19 +35,11 @@ const mockUseDeleteProxyConfigField = vi.mocked(useDeleteProxyConfigField);
const mockNotificationsManager = vi.mocked(NotificationsManager);
const mockParseErrorMessage = vi.mocked(parseErrorMessage);
describe("SpendLogsSettingsModal", () => {
const mockOnCancel = vi.fn();
const mockOnSuccess = vi.fn();
describe("LoggingSettings", () => {
const mockMutateAsync = vi.fn();
const mockDeleteField = vi.fn();
const mockRefetch = vi.fn();
const defaultProps = {
isVisible: true,
onCancel: mockOnCancel,
onSuccess: mockOnSuccess,
};
beforeEach(() => {
vi.clearAllMocks();
mockUseStoreRequestInSpendLogs.mockReturnValue({
@@ -57,50 +58,19 @@ describe("SpendLogsSettingsModal", () => {
mockParseErrorMessage.mockImplementation((error: any) => error?.message || String(error));
});
it("should render the modal", () => {
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Spend Logs Settings")).toBeInTheDocument();
});
it("should render form fields with initial values", () => {
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
it("should render the card with title and form fields", () => {
renderWithProviders(<LoggingSettings />);
expect(screen.getByText("Logging Settings")).toBeInTheDocument();
expect(screen.getByText("Store Prompts in Spend Logs")).toBeInTheDocument();
expect(screen.getByLabelText("Maximum Spend Logs Retention Period (Optional)")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., 7d, 30d")).toBeInTheDocument();
});
it("should render cancel and save buttons", () => {
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Settings" })).toBeInTheDocument();
});
it("should call onCancel when cancel button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it("should call onCancel when modal close button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const closeButton = screen.getByRole("button", { name: /close/i });
await user.click(closeButton);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it("should toggle store prompts switch", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
@@ -114,7 +84,7 @@ describe("SpendLogsSettingsModal", () => {
it("should update retention period input", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d");
await user.type(retentionInput, "30d");
@@ -124,13 +94,13 @@ describe("SpendLogsSettingsModal", () => {
it("should submit form with store prompts enabled and retention period", async () => {
const user = userEvent.setup();
mockMutateAsync.mockImplementation(async (params, options) => {
mockMutateAsync.mockImplementation(async (_params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const switchElement = screen.getByRole("switch");
await user.click(switchElement);
@@ -148,21 +118,21 @@ describe("SpendLogsSettingsModal", () => {
store_prompts_in_spend_logs: true,
maximum_spend_logs_retention_period: "30d",
},
expect.any(Object)
expect.any(Object),
);
});
});
it("should submit form with store prompts disabled and no retention period", async () => {
it("should delete retention period field when left empty on submit", async () => {
const user = userEvent.setup();
mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" });
mockMutateAsync.mockImplementation(async (params, options) => {
mockMutateAsync.mockImplementation(async (_params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
@@ -173,207 +143,106 @@ describe("SpendLogsSettingsModal", () => {
{
store_prompts_in_spend_logs: false,
},
expect.any(Object)
expect.any(Object),
);
});
});
it("should show success notification and call onSuccess on successful submission", async () => {
it("should show success notification on successful submission", async () => {
const user = userEvent.setup();
mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" });
mockMutateAsync.mockImplementation(async (params, options) => {
mockMutateAsync.mockImplementation(async (_params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Spend logs settings updated successfully");
expect(mockRefetch).toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
});
});
it("should show error notification when submission fails", async () => {
it("should show error notification when submission throws", async () => {
const user = userEvent.setup();
const error = new Error("Network error");
mockMutateAsync.mockRejectedValue(error);
mockParseErrorMessage.mockReturnValue("Network error");
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to save spend logs settings: Network error");
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith(
"Failed to save spend logs settings: Network error",
);
});
});
it("should show error notification from onError callback", async () => {
it("should show error notification via onError callback", async () => {
const user = userEvent.setup();
const error = new Error("Backend error");
mockMutateAsync.mockImplementation((params, options) => {
mockMutateAsync.mockImplementation((_params, options) => {
options?.onError?.(error);
return Promise.reject(error);
});
mockParseErrorMessage.mockReturnValue("Backend error");
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to save spend logs settings: Backend error");
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith(
"Failed to save spend logs settings: Backend error",
);
});
});
it("should disable cancel button when pending", () => {
it("should show loading state on save button when update pending", () => {
mockUseStoreRequestInSpendLogs.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const cancelButton = screen.getByRole("button", { name: "Cancel" });
expect(cancelButton).toBeDisabled();
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeInTheDocument();
expect(saveButton.className).toContain("ant-btn-loading");
});
it("should disable cancel button when deleting field", () => {
it("should show loading state on save button when delete pending", () => {
mockUseDeleteProxyConfigField.mockReturnValue({
mutateAsync: mockDeleteField,
isPending: true,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const cancelButton = screen.getByRole("button", { name: "Cancel" });
expect(cancelButton).toBeDisabled();
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeInTheDocument();
expect(saveButton.className).toContain("ant-btn-loading");
});
it("should disable cancel button when loading config", () => {
it("should disable save button while config is loading", () => {
mockUseProxyConfig.mockReturnValue({
data: undefined,
isLoading: true,
refetch: mockRefetch,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const cancelButton = screen.getByRole("button", { name: "Cancel" });
expect(cancelButton).toBeDisabled();
});
it("should show loading state on save button when pending", () => {
mockUseStoreRequestInSpendLogs.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeInTheDocument();
expect(saveButton.className).toContain("ant-btn-loading");
});
it("should show loading state on save button when deleting field", () => {
mockUseDeleteProxyConfigField.mockReturnValue({
mutateAsync: mockDeleteField,
isPending: true,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeInTheDocument();
expect(saveButton.className).toContain("ant-btn-loading");
});
it("should call onCancel when cancel button is clicked after modifying form", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const switchElement = screen.getByRole("switch");
await user.click(switchElement);
const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d");
await user.type(retentionInput, "30d");
expect(switchElement).toBeChecked();
expect(retentionInput).toHaveValue("30d");
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it("should call refetch after successful submission", async () => {
const user = userEvent.setup();
mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" });
mockMutateAsync.mockImplementation(async (params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const switchElement = screen.getByRole("switch");
await user.click(switchElement);
const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d");
await user.type(retentionInput, "30d");
expect(switchElement).toBeChecked();
expect(retentionInput).toHaveValue("30d");
renderWithProviders(<LoggingSettings />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalled();
expect(mockRefetch).toHaveBeenCalled();
});
});
it("should not call onSuccess when it is not provided", async () => {
const user = userEvent.setup();
mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" });
mockMutateAsync.mockImplementation(async (params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal isVisible={true} onCancel={mockOnCancel} />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalled();
});
});
it("should not render modal when isVisible is false", () => {
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} isVisible={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("should call refetch when modal opens", () => {
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
expect(mockRefetch).toHaveBeenCalledTimes(1);
expect(saveButton).toBeDisabled();
});
it("should render form with initial values from config data", () => {
@@ -400,7 +269,7 @@ describe("SpendLogsSettingsModal", () => {
refetch: mockRefetch,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const switchElement = screen.getByRole("switch");
const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d");
@@ -416,13 +285,11 @@ describe("SpendLogsSettingsModal", () => {
refetch: mockRefetch,
} as any);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
// Check that switch and input are not present when loading (skeletons are shown instead)
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("e.g., 7d, 30d")).not.toBeInTheDocument();
// Check for skeleton elements (Ant Design Skeleton.Input renders with ant-skeleton class)
const skeletons = document.querySelectorAll(".ant-skeleton");
expect(skeletons.length).toBeGreaterThan(0);
});
@@ -431,13 +298,13 @@ describe("SpendLogsSettingsModal", () => {
const user = userEvent.setup();
const deleteError = new Error("Field does not exist");
mockDeleteField.mockRejectedValue(deleteError);
mockMutateAsync.mockImplementation(async (params, options) => {
mockMutateAsync.mockImplementation(async (_params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const saveButton = screen.getByRole("button", { name: "Save Settings" });
await user.click(saveButton);
@@ -448,22 +315,22 @@ describe("SpendLogsSettingsModal", () => {
{
store_prompts_in_spend_logs: false,
},
expect.any(Object)
expect.any(Object),
);
expect(mockNotificationsManager.success).toHaveBeenCalled();
});
});
it("should submit form with only store prompts enabled and no retention period", async () => {
it("should submit with only store prompts enabled when retention is empty", async () => {
const user = userEvent.setup();
mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" });
mockMutateAsync.mockImplementation(async (params, options) => {
mockMutateAsync.mockImplementation(async (_params, options) => {
await Promise.resolve();
options?.onSuccess?.();
return { message: "Success" };
});
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<LoggingSettings />);
const switchElement = screen.getByRole("switch");
await user.click(switchElement);
@@ -477,7 +344,7 @@ describe("SpendLogsSettingsModal", () => {
{
store_prompts_in_spend_logs: true,
},
expect.any(Object)
expect.any(Object),
);
});
});
@@ -0,0 +1,150 @@
"use client";
import {
ConfigType,
GeneralSettingsFieldName,
useDeleteProxyConfigField,
useProxyConfig,
} from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
import {
StoreRequestInSpendLogsParams,
useStoreRequestInSpendLogs,
} from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { ClockCircleOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, Skeleton, Space, Switch, Typography } from "antd";
import React, { useMemo } from "react";
const LoggingSettings: React.FC = () => {
const [form] = Form.useForm();
const { mutateAsync, isPending } = useStoreRequestInSpendLogs();
const { mutateAsync: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField();
const { data: proxyConfigData, isLoading: isLoadingConfig } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
const storePromptsValue = Form.useWatch("store_prompts_in_spend_logs", form);
const initialValues = useMemo(() => {
if (!proxyConfigData) {
return {
store_prompts_in_spend_logs: false,
maximum_spend_logs_retention_period: undefined,
};
}
const storePromptsField = proxyConfigData.find((field) => field.field_name === "store_prompts_in_spend_logs");
const retentionPeriodField = proxyConfigData.find(
(field) => field.field_name === "maximum_spend_logs_retention_period",
);
return {
store_prompts_in_spend_logs: storePromptsField?.field_value ?? false,
maximum_spend_logs_retention_period: retentionPeriodField?.field_value ?? undefined,
};
}, [proxyConfigData]);
const handleFormSubmit = async (formValues: StoreRequestInSpendLogsParams) => {
try {
const retentionPeriodValue = formValues.maximum_spend_logs_retention_period;
const shouldDeleteRetentionPeriod =
!retentionPeriodValue ||
(typeof retentionPeriodValue === "string" && retentionPeriodValue.trim() === "");
if (shouldDeleteRetentionPeriod) {
try {
await deleteField({
config_type: ConfigType.GENERAL_SETTINGS,
field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD,
});
} catch (deleteError) {
console.warn("Failed to delete retention period field (may not exist):", deleteError);
}
}
const updateParams: StoreRequestInSpendLogsParams = {
store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs,
...(retentionPeriodValue &&
typeof retentionPeriodValue === "string" &&
retentionPeriodValue.trim() !== "" && {
maximum_spend_logs_retention_period: retentionPeriodValue,
}),
};
await mutateAsync(updateParams, {
onSuccess: () => {
NotificationsManager.success("Spend logs settings updated successfully");
},
onError: (error) => {
NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error));
},
});
} catch (error) {
NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error));
}
};
return (
<Card title="Logging Settings">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Typography.Paragraph style={{ marginBottom: 0 }} type="secondary">
Proxy-wide settings that control how request and response data are written to spend logs.
</Typography.Paragraph>
<Form
key={proxyConfigData ? JSON.stringify(initialValues) : "loading"}
form={form}
layout="vertical"
onFinish={handleFormSubmit}
initialValues={initialValues}
>
<Form.Item
label="Store Prompts in Spend Logs"
name="store_prompts_in_spend_logs"
tooltip={
proxyConfigData?.find((f) => f.field_name === "store_prompts_in_spend_logs")?.field_description ||
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes."
}
valuePropName="checked"
>
{isLoadingConfig ? (
<Skeleton.Input active block />
) : (
<Switch
checked={storePromptsValue ?? false}
onChange={(checked) => form.setFieldValue("store_prompts_in_spend_logs", checked)}
/>
)}
</Form.Item>
<Form.Item
label="Maximum Spend Logs Retention Period (Optional)"
name="maximum_spend_logs_retention_period"
tooltip={
proxyConfigData?.find((f) => f.field_name === "maximum_spend_logs_retention_period")
?.field_description ||
"Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit."
}
>
{isLoadingConfig ? (
<Skeleton.Input active block />
) : (
<Input placeholder="e.g., 7d, 30d" prefix={<ClockCircleOutlined />} />
)}
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isPending || isDeletingField}
disabled={isLoadingConfig}
>
{isPending || isDeletingField ? "Saving..." : "Save Settings"}
</Button>
</Form.Item>
</Form>
</Space>
</Card>
);
};
export default LoggingSettings;
@@ -1,6 +1,5 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ConfigInfoMessage } from "./ConfigInfoMessage";
describe("ConfigInfoMessage", () => {
@@ -19,23 +18,8 @@ describe("ConfigInfoMessage", () => {
expect(screen.getByText(/store_prompts_in_spend_logs: true/)).toBeInTheDocument();
});
it("should render the settings button when onOpenSettings is provided", () => {
render(<ConfigInfoMessage show={true} onOpenSettings={() => {}} />);
expect(screen.getByText("open the settings")).toBeInTheDocument();
});
it("should not render the settings button when onOpenSettings is omitted", () => {
it("should reference Admin Settings \u2192 Logging Settings", () => {
render(<ConfigInfoMessage show={true} />);
expect(screen.queryByText("open the settings")).not.toBeInTheDocument();
});
it("should call onOpenSettings when the settings button is clicked", async () => {
const user = userEvent.setup();
const onOpenSettings = vi.fn();
render(<ConfigInfoMessage show={true} onOpenSettings={onOpenSettings} />);
await user.click(screen.getByText("open the settings"));
expect(onOpenSettings).toHaveBeenCalledOnce();
expect(screen.getByText(/Admin Settings → Logging Settings/)).toBeInTheDocument();
});
});
@@ -2,10 +2,9 @@ import React from "react";
interface ConfigInfoMessageProps {
show: boolean;
onOpenSettings?: () => void;
}
export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show, onOpenSettings }) => {
export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show }) => {
if (!show) return null;
return (
@@ -31,18 +30,8 @@ export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show, onOp
<h4 className="text-sm font-medium text-blue-800">Request/Response Data Not Available</h4>
<p className="text-sm text-blue-700 mt-1">
To view request and response details, enable prompt storage in your LiteLLM configuration by adding the
following to your <code className="bg-blue-100 px-1 py-0.5 rounded">proxy_config.yaml</code> file
{onOpenSettings && (
<> or{" "}
<button
onClick={onOpenSettings}
className="text-blue-600 hover:text-blue-800 underline font-medium"
>
open the settings
</button>
{" "}to configure this directly.
</>
)}
following to your <code className="bg-blue-100 px-1 py-0.5 rounded">proxy_config.yaml</code> file, or toggle
the setting in <strong>Admin Settings Logging Settings</strong>.
</p>
<pre className="mt-2 bg-white p-3 rounded border border-blue-200 text-xs font-mono overflow-auto">
{`general_settings:
@@ -171,27 +171,6 @@ describe("LogDetailContent", () => {
expect(screen.queryByText("Request/Response Data Not Available")).not.toBeInTheDocument();
});
it("should call onOpenSettings when user clicks open settings in ConfigInfoMessage", async () => {
const onOpenSettings = vi.fn();
const user = userEvent.setup();
render(
<LogDetailContent
logEntry={createLogEntry({
messages: [],
response: {},
metadata: {},
})}
onOpenSettings={onOpenSettings}
/>,
);
const settingsButton = screen.getByRole("button", { name: /open the settings/i });
await user.click(settingsButton);
expect(onOpenSettings).toHaveBeenCalledTimes(1);
});
it("should display loading state when isLoadingDetails is true", () => {
render(
<LogDetailContent
@@ -37,7 +37,6 @@ const { Text } = Typography;
export interface LogDetailContentProps {
logEntry: LogEntry;
onOpenSettings?: () => void;
/** When true, log details (messages/response) are still being lazy-loaded. */
isLoadingDetails?: boolean;
accessToken?: string | null;
@@ -51,7 +50,7 @@ export interface LogDetailContentProps {
* Designed to be placed inside LogDetailsDrawer's right panel so it can
* be reused for both single-log and session-mode views.
*/
export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = false, accessToken }: LogDetailContentProps) {
export function LogDetailContent({ logEntry, isLoadingDetails = false, accessToken }: LogDetailContentProps) {
const metadata = logEntry.metadata || {};
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
@@ -153,7 +152,7 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
{/* Configuration Info Message */}
{missingData && (
<div className="mb-6">
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
<ConfigInfoMessage show={missingData} />
</div>
)}
@@ -26,7 +26,6 @@ export interface LogDetailsDrawerProps {
logEntry: LogEntry | null;
sessionId?: string | null;
accessToken?: string | null;
onOpenSettings?: () => void;
allLogs?: LogEntry[];
onSelectLog?: (log: LogEntry) => void;
startTime?: string;
@@ -109,7 +108,6 @@ export function LogDetailsDrawer({
logEntry,
sessionId,
accessToken,
onOpenSettings,
allLogs = [],
onSelectLog,
startTime,
@@ -399,7 +397,6 @@ export function LogDetailsDrawer({
<div className="flex-1 overflow-y-auto">
<LogDetailContent
logEntry={enrichedLog}
onOpenSettings={onOpenSettings}
isLoadingDetails={isLoadingDetails}
accessToken={accessToken ?? null}
/>
@@ -1,156 +0,0 @@
"use client";
import { ConfigType, GeneralSettingsFieldName, useDeleteProxyConfigField, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
import { StoreRequestInSpendLogsParams, useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { ClockCircleOutlined } from "@ant-design/icons";
import { Button, Form, Input, Modal, Skeleton, Space, Switch, Typography } from "antd";
import React, { useEffect, useMemo } from "react";
interface SpendLogsSettingsModalProps {
isVisible: boolean;
onCancel: () => void;
onSuccess?: () => void;
}
const SpendLogsSettingsModal: React.FC<SpendLogsSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
const [form] = Form.useForm();
const { mutateAsync, isPending } = useStoreRequestInSpendLogs();
const { mutateAsync: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField();
const { data: proxyConfigData, isLoading: isLoadingConfig, refetch } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
const storePromptsValue = Form.useWatch('store_prompts_in_spend_logs', form);
// Refetch config when modal opens to ensure we have the latest values
useEffect(() => {
if (isVisible) {
refetch();
}
}, [isVisible, refetch]);
// Compute initial values from fetched config data
const initialValues = useMemo(() => {
if (!proxyConfigData) {
return {
store_prompts_in_spend_logs: false,
maximum_spend_logs_retention_period: undefined,
};
}
const storePromptsField = proxyConfigData.find(field => field.field_name === 'store_prompts_in_spend_logs');
const retentionPeriodField = proxyConfigData.find(field => field.field_name === 'maximum_spend_logs_retention_period');
return {
store_prompts_in_spend_logs: storePromptsField?.field_value ?? false,
maximum_spend_logs_retention_period: retentionPeriodField?.field_value ?? undefined,
};
}, [proxyConfigData]);
const handleFormSubmit = async (formValues: StoreRequestInSpendLogsParams) => {
try {
// If maximum_spend_logs_retention_period is empty/null, delete the field first
const retentionPeriodValue = formValues.maximum_spend_logs_retention_period;
const shouldDeleteRetentionPeriod =
!retentionPeriodValue ||
(typeof retentionPeriodValue === "string" && retentionPeriodValue.trim() === "");
if (shouldDeleteRetentionPeriod) {
try {
await deleteField({
config_type: ConfigType.GENERAL_SETTINGS,
field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD,
});
} catch (deleteError) {
// If field doesn't exist, that's okay - continue with update
console.warn("Failed to delete retention period field (may not exist):", deleteError);
}
}
// Update the settings (excluding maximum_spend_logs_retention_period if it's empty)
const updateParams: StoreRequestInSpendLogsParams = {
store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs,
...(retentionPeriodValue &&
typeof retentionPeriodValue === "string" &&
retentionPeriodValue.trim() !== "" && {
maximum_spend_logs_retention_period: retentionPeriodValue,
}),
};
await mutateAsync(updateParams, {
onSuccess: () => {
NotificationsManager.success("Spend logs settings updated successfully");
refetch(); // Refetch config to get updated values
onSuccess?.();
},
onError: (error) => {
NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error));
},
});
} catch (error) {
NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error));
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
return (
<Modal
title={<Typography.Title level={5}>Spend Logs Settings</Typography.Title>}
open={isVisible}
footer={
<Space>
<Button onClick={handleCancel} disabled={isPending || isDeletingField || isLoadingConfig}>
Cancel
</Button>
<Button type="primary" loading={isPending || isDeletingField} disabled={isLoadingConfig} onClick={() => form.submit()}>
{isPending || isDeletingField ? "Saving..." : "Save Settings"}
</Button>
</Space>
}
onCancel={handleCancel}
>
<Form
key={proxyConfigData ? JSON.stringify(initialValues) : 'loading'}
form={form}
layout="horizontal"
onFinish={handleFormSubmit}
initialValues={initialValues}
>
<Form.Item
label="Store Prompts in Spend Logs"
name="store_prompts_in_spend_logs"
tooltip={
proxyConfigData?.find(f => f.field_name === 'store_prompts_in_spend_logs')?.field_description ||
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes."
}
valuePropName="checked"
>
<div>
{isLoadingConfig ? <Skeleton.Input active block /> : <Switch checked={storePromptsValue ?? false} onChange={(checked) => form.setFieldValue('store_prompts_in_spend_logs', checked)} />}
</div>
</Form.Item>
<Form.Item
label="Maximum Spend Logs Retention Period (Optional)"
name="maximum_spend_logs_retention_period"
tooltip={
proxyConfigData?.find(f => f.field_name === 'maximum_spend_logs_retention_period')?.field_description ||
"Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit."
}
>
{isLoadingConfig ? <Skeleton.Input active block /> : <Input
placeholder="e.g., 7d, 30d"
prefix={<ClockCircleOutlined />}
/>}
</Form.Item>
</Form>
</Modal>
);
};
export default SpendLogsSettingsModal;
@@ -4,7 +4,7 @@ import { useCallback, useDeferredValue, useEffect, useRef, useState } from "reac
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { truncateString } from "@/utils/textUtils";
import { SettingOutlined, SyncOutlined } from "@ant-design/icons";
import { SyncOutlined } from "@ant-design/icons";
import { Row } from "@tanstack/react-table";
import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Button, Tag, Tooltip } from "antd";
@@ -28,7 +28,6 @@ import { useLogFilterLogic } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { getTimeRangeDisplay } from "./logs_utils";
import { RequestResponsePanel } from "./RequestResponsePanel";
import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal";
import { DataTable } from "./table";
import { VectorStoreViewer } from "./VectorStoreViewer";
@@ -85,7 +84,6 @@ export default function SpendLogsTable({
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false);
const [sortBy, setSortBy] = useState<LogsSortField>("startTime");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
@@ -490,11 +488,6 @@ export default function SpendLogsTable({
<TabPanel>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Request Logs</h1>
<Button
icon={<SettingOutlined />}
onClick={() => setIsSpendLogsSettingsModalVisible(true)}
title="Spend Logs Settings"
/>
</div>
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
<KeyInfoView
@@ -511,11 +504,6 @@ export default function SpendLogsTable({
onApplyFilters={handleFilterChange}
onResetFilters={handleFilterReset}
/>
<SpendLogsSettingsModal
isVisible={isSpendLogsSettingsModalVisible}
onCancel={() => setIsSpendLogsSettingsModalVisible(false)}
onSuccess={() => setIsSpendLogsSettingsModalVisible(false)}
/>
<div className="bg-white rounded-lg shadow w-full max-w-full box-border">
<div className="border-b px-6 py-4 w-full max-w-full box-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0 w-full max-w-full box-border">
@@ -725,7 +713,6 @@ export default function SpendLogsTable({
logEntry={selectedLog}
sessionId={selectedSessionId}
accessToken={accessToken}
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
allLogs={filteredData}
onSelectLog={handleSelectLog}
startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")}
@@ -734,7 +721,7 @@ export default function SpendLogsTable({
);
}
export function RequestViewer({ row, onOpenSettings }: { row: Row<LogEntry>; onOpenSettings?: () => void }) {
export function RequestViewer({ row }: { row: Row<LogEntry> }) {
// Helper function to clean metadata by removing specific fields
const formatData = (input: any) => {
if (typeof input === "string") {
@@ -961,7 +948,7 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row<LogEntry>; onO
/>
{/* Configuration Info Message - Show when data is missing */}
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
<ConfigInfoMessage show={missingData} />
{/* Request/Response Panel */}
<div className="w-full max-w-full overflow-hidden">