From 51275e7014fd873def891afe4c553d8452eb8785 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 8 Dec 2025 21:12:06 -0800 Subject: [PATCH] UI Settings Frontend --- .../hooks/uiSettings/useUISettings.ts | 15 ++++ .../hooks/uiSettings/useUpdateUISettings.ts | 21 +++++ .../UISettings/UISettings.test.tsx | 90 +++++++++++++++++++ .../AdminSettings/UISettings/UISettings.tsx | 74 +++++++++++++++ .../src/components/admins.tsx | 5 ++ .../src/components/networking.tsx | 38 ++++++++ 6 files changed, 243 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUpdateUISettings.ts create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts new file mode 100644 index 00000000000..823c0067b5c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -0,0 +1,15 @@ +import { getUiSettings } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const uiSettingsKeys = createQueryKeys("uiSettings"); + +export const useUISettings = (accessToken: string) => { + return useQuery>({ + queryKey: uiSettingsKeys.list({}), + queryFn: async () => await getUiSettings(accessToken), + enabled: !!accessToken, + staleTime: 60 * 60 * 1000, // 1 hour - data rarely changes + gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUpdateUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUpdateUISettings.ts new file mode 100644 index 00000000000..b048ba0ef9d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUpdateUISettings.ts @@ -0,0 +1,21 @@ +import { updateUiSettings } from "@/components/networking"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const uiSettingsKeys = createQueryKeys("uiSettings"); + +export const useUpdateUISettings = (accessToken: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settings: Record) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return updateUiSettings(accessToken, settings); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: uiSettingsKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx new file mode 100644 index 00000000000..31dcfc102ec --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import UISettings from "./UISettings"; +import NotificationManager from "@/components/molecules/notifications_manager"; + +const mockUseAuthorized = vi.hoisted(() => vi.fn()); +const mockUseUISettings = vi.hoisted(() => vi.fn()); +const mockUseUpdateUISettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: mockUseAuthorized, +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: mockUseUISettings, +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({ + useUpdateUISettings: mockUseUpdateUISettings, +})); + +const buildSettingsResponse = (overrides?: Partial>) => ({ + data: { + field_schema: { + description: "UI settings description", + properties: { + disable_model_add_for_internal_users: { + description: "Disable model add for internal users", + }, + }, + }, + values: { + disable_model_add_for_internal_users: false, + }, + }, + isLoading: false, + isError: false, + error: null, + ...overrides, +}); + +describe("UISettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" }); + mockUseUISettings.mockReturnValue(buildSettingsResponse()); + mockUseUpdateUISettings.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + error: null, + }); + }); + + it("should render", () => { + render(); + + expect(screen.getByText("UI Settings")).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Disable model add for internal users" })).toBeInTheDocument(); + }); + + it("should toggle setting and call update", () => { + const mutateMock = vi.fn((_settings, options) => { + options?.onSuccess?.(); + }); + + mockUseUpdateUISettings.mockReturnValue({ + mutate: mutateMock, + isPending: false, + error: null, + }); + + render(); + + const toggle = screen.getByRole("switch", { name: "Disable model add for internal users" }); + + act(() => { + fireEvent.click(toggle); + }); + + expect(mutateMock).toHaveBeenCalledWith( + { disable_model_add_for_internal_users: true }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx new file mode 100644 index 00000000000..7680232ef0b --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import NotificationManager from "@/components/molecules/notifications_manager"; +import { Alert, Card, Skeleton, Space, Switch, Typography } from "antd"; + +export default function UISettings() { + const { accessToken } = useAuthorized(); + const { data, isLoading, isError, error } = useUISettings(accessToken); + const { mutate: updateSettings, isPending: isUpdating, error: updateError } = useUpdateUISettings(accessToken); + + const schema = data?.field_schema; + const property = schema?.properties?.disable_model_add_for_internal_users; + const values = data?.values ?? {}; + const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); + + const handleToggle = (checked: boolean) => { + updateSettings( + { disable_model_add_for_internal_users: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + + return ( + + {isLoading ? ( + + ) : isError ? ( + + ) : ( + + {schema?.description && ( + {schema.description} + )} + + {updateError && ( + + )} + + + + + Disable model add for internal users + {property?.description && {property.description}} + + + + )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx index 9c89adc55e6..4ddd5cd5d1f 100644 --- a/ui/litellm-dashboard/src/components/admins.tsx +++ b/ui/litellm-dashboard/src/components/admins.tsx @@ -54,6 +54,7 @@ import { deleteAllowedIP, getSSOSettings, } from "./networking"; +import UISettings from "./Settings/AdminSettings/UISettings/UISettings"; const AdminPanel: React.FC = ({ searchParams, @@ -497,6 +498,7 @@ const AdminPanel: React.FC = ({ Security Settings SCIM + UI Settings @@ -648,6 +650,9 @@ const AdminPanel: React.FC = ({ + + + diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ba63579dee1..cd1289f0a88 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8030,3 +8030,41 @@ export const loginCall = async (username: string, password: string): Promise { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + }); + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + throw new Error(errorMessage); + } + const data = await response.json(); + return data; +}; + +export const updateUiSettings = async (accessToken: string, settings: Record) => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/update/ui_settings` : `/update/ui_settings`; + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(settings), + }); + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + throw new Error(errorMessage); + } + const data = await response.json(); + return data; +};