diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts new file mode 100644 index 00000000000..9c6211c3086 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts @@ -0,0 +1,63 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking"; +import useAuthorized from "../useAuthorized"; + +export interface StoreRequestInSpendLogsParams { + store_prompts_in_spend_logs: boolean; + maximum_spend_logs_retention_period?: string; +} + +export interface StoreRequestInSpendLogsResponse { + message: string; +} + +const performStoreRequestInSpendLogs = async ( + accessToken: string, + params: StoreRequestInSpendLogsParams +): Promise => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/config/update` : `/config/update`; + + const response = await fetch(url, { + method: "POST", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + general_settings: { + store_prompts_in_spend_logs: params.store_prompts_in_spend_logs, + ...(params.maximum_spend_logs_retention_period && { + maximum_spend_logs_retention_period: params.maximum_spend_logs_retention_period, + }), + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to update spend logs settings"; + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; +}; + +export const useStoreRequestInSpendLogs = (): UseMutationResult< + StoreRequestInSpendLogsResponse, + Error, + StoreRequestInSpendLogsParams +> => { + const { accessToken } = useAuthorized(); + + return useMutation({ + mutationFn: async (params: StoreRequestInSpendLogsParams) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return await performStoreRequestInSpendLogs(accessToken, params); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/page_utils.test.ts b/ui/litellm-dashboard/src/components/page_utils.test.ts index fed6c381498..12085735d33 100644 --- a/ui/litellm-dashboard/src/components/page_utils.test.ts +++ b/ui/litellm-dashboard/src/components/page_utils.test.ts @@ -7,6 +7,22 @@ import { describe, it, expect } from "vitest"; import { getAvailablePages } from "./page_utils"; import { menuGroups } from "./leftnav"; import { pageDescriptions } from "./page_metadata"; +import { internalUserRoles } from "@/utils/roles"; + +/** + * Check if a page is accessible to internal users + * A page is accessible if: + * 1. It has no role restrictions, OR + * 2. Its roles include at least one internal user role + */ +const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => { + if (!pageRoles || pageRoles.length === 0) { + return true; // No role restrictions + } + + // Check if any of the page's roles match internal user roles + return pageRoles.some(role => internalUserRoles.includes(role)); +}; describe("Page Utils - LeftNav Sync", () => { it("should return all pages from leftnav configuration", () => { @@ -32,26 +48,32 @@ describe("Page Utils - LeftNav Sync", () => { const availablePages = getAvailablePages(); const availablePageKeys = availablePages.map((p) => p.page); - // Collect all page keys from menuGroups (excluding parent containers) + // Collect all page keys from menuGroups (excluding parent containers and pages not accessible to internal users) const menuPageKeys: string[] = []; const excludedParents = ["tools", "experimental", "settings"]; menuGroups.forEach((group) => { group.items.forEach((item) => { - if (item.page && !excludedParents.includes(item.page)) { + if ( + item.page && + !excludedParents.includes(item.page) && + isPageAccessibleToInternalUsers(item.roles) + ) { menuPageKeys.push(item.page); } - // Add children + // Add children (only if accessible to internal users) if (item.children) { item.children.forEach((child) => { - menuPageKeys.push(child.page); + if (isPageAccessibleToInternalUsers(child.roles)) { + menuPageKeys.push(child.page); + } }); } }); }); - // Every menu page should be in available pages + // Every menu page accessible to internal users should be in available pages menuPageKeys.forEach((pageKey) => { expect( availablePageKeys, diff --git a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx new file mode 100644 index 00000000000..e955f42872b --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx @@ -0,0 +1,348 @@ +import { useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +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"; + +vi.mock("@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + fromBackend: vi.fn(), + }, +})); +vi.mock("@/components/shared/errorUtils", () => ({ + parseErrorMessage: vi.fn(), +})); + +const mockUseStoreRequestInSpendLogs = vi.mocked(useStoreRequestInSpendLogs); +const mockNotificationsManager = vi.mocked(NotificationsManager); +const mockParseErrorMessage = vi.mocked(parseErrorMessage); + +describe("SpendLogsSettingsModal", () => { + const mockOnCancel = vi.fn(); + const mockOnSuccess = vi.fn(); + const mockMutateAsync = vi.fn(); + + const defaultProps = { + isVisible: true, + onCancel: mockOnCancel, + onSuccess: mockOnSuccess, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseStoreRequestInSpendLogs.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + } as any); + mockParseErrorMessage.mockImplementation((error: any) => error?.message || String(error)); + }); + + it("should render the modal", () => { + renderWithProviders(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Spend Logs Settings")).toBeInTheDocument(); + }); + + it("should render form fields with initial values", () => { + renderWithProviders(); + + 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(); + + 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(); + + 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(); + + 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(); + + const switchElement = screen.getByRole("switch"); + expect(switchElement).not.toBeChecked(); + + await user.click(switchElement); + + await waitFor(() => { + expect(switchElement).toBeChecked(); + }); + }); + + it("should update retention period input", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d"); + await user.type(retentionInput, "30d"); + + expect(retentionInput).toHaveValue("30d"); + }); + + it("should submit form with store prompts enabled and retention period", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + renderWithProviders(); + + const switchElement = screen.getByRole("switch"); + await user.click(switchElement); + + const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d"); + await user.type(retentionInput, "30d"); + + const saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + { + store_prompts_in_spend_logs: true, + maximum_spend_logs_retention_period: "30d", + }, + expect.any(Object) + ); + }); + }); + + it("should submit form with store prompts disabled and no retention period", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + renderWithProviders(); + + const saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + { + store_prompts_in_spend_logs: false, + maximum_spend_logs_retention_period: undefined, + }, + expect.any(Object) + ); + }); + }); + + it("should show success notification and call onSuccess on successful submission", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + renderWithProviders(); + + const saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalledWith("Spend logs settings updated successfully"); + expect(mockOnSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it("should show error notification when submission fails", async () => { + const user = userEvent.setup(); + const error = new Error("Network error"); + mockMutateAsync.mockRejectedValue(error); + mockParseErrorMessage.mockReturnValue("Network error"); + + renderWithProviders(); + + 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"); + }); + }); + + it("should show error notification from onError callback", async () => { + const user = userEvent.setup(); + const error = new Error("Backend error"); + mockMutateAsync.mockImplementation((params, options) => { + options?.onError?.(error); + return Promise.reject(error); + }); + mockParseErrorMessage.mockReturnValue("Backend error"); + + renderWithProviders(); + + 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"); + }); + }); + + it("should disable cancel button when pending", () => { + mockUseStoreRequestInSpendLogs.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: true, + } as any); + + renderWithProviders(); + + 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(); + + 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(); + + 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 reset form fields after successful submission", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + const { rerender } = renderWithProviders(); + + 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 saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalled(); + }); + + rerender(); + + await waitFor(() => { + const updatedSwitchElement = screen.getByRole("switch"); + const updatedRetentionInput = screen.getByPlaceholderText("e.g., 7d, 30d"); + expect(updatedSwitchElement).not.toBeChecked(); + expect(updatedRetentionInput).toHaveValue(""); + }); + }); + + it("should not call onSuccess when it is not provided", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + renderWithProviders(); + + 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(); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("should submit form with only store prompts enabled and no retention period", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + renderWithProviders(); + + const switchElement = screen.getByRole("switch"); + await user.click(switchElement); + + const saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + { + store_prompts_in_spend_logs: true, + maximum_spend_logs_retention_period: undefined, + }, + expect.any(Object) + ); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx new file mode 100644 index 00000000000..cf8e51be94b --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx @@ -0,0 +1,100 @@ +"use client"; + +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, Space, Switch } from "antd"; +import React from "react"; + +interface SpendLogsSettingsModalProps { + isVisible: boolean; + onCancel: () => void; + onSuccess?: () => void; +} + +const SpendLogsSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => { + const [form] = Form.useForm(); + const { mutateAsync, isPending } = useStoreRequestInSpendLogs(); + const storePromptsValue = Form.useWatch('store_prompts_in_spend_logs', form); + + const handleFormSubmit = async (formValues: StoreRequestInSpendLogsParams) => { + try { + await mutateAsync(formValues, { + onSuccess: () => { + NotificationsManager.success("Spend logs settings updated successfully"); + form.resetFields(); + 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 ( + + + + + } + onCancel={handleCancel} + > +
+ +
+ Store Prompts in Spend Logs + form.setFieldValue('store_prompts_in_spend_logs', checked)} /> +
+
+ + + + } + /> + +
+
+ ); +}; + +export default SpendLogsSettingsModal; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 904d052073a..e25925f13b8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -12,7 +12,7 @@ import { RequestResponsePanel } from "./RequestResponsePanel"; import { ErrorViewer } from "./ErrorViewer"; import { internalUserRoles } from "../../utils/roles"; import { ConfigInfoMessage } from "./ConfigInfoMessage"; -import { Tooltip } from "antd"; +import { Button, Tooltip } from "antd"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import KeyInfoView from "../templates/key_info_view"; import { SessionView } from "./SessionView"; @@ -31,6 +31,8 @@ import { truncateString } from "@/utils/textUtils"; import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; import NewBadge from "../common_components/NewBadge"; +import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal"; +import { SettingOutlined } from "@ant-design/icons"; interface SpendLogsTableProps { accessToken: string | null; @@ -91,6 +93,7 @@ export default function SpendLogsTable({ const [expandedRequestId, setExpandedRequestId] = useState(null); const [selectedSessionId, setSelectedSessionId] = useState(null); + const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false); const queryClient = useQueryClient(); @@ -526,6 +529,13 @@ export default function SpendLogsTable({ "Request Logs" )} + {!selectedSessionId && ( +