From d943d556c4c60ddccbc12ee62bef66910b5e37c4 Mon Sep 17 00:00:00 2001 From: yuneng Date: Tue, 22 Sep 2026 00:42:52 +0000 Subject: [PATCH] fix(ui): hold router and fallback write controls until the source map loads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Fallbacks/Fallbacks.test.tsx | 19 +++++++++++++++ .../RouterSettings/Fallbacks/Fallbacks.tsx | 4 ++-- .../components/router_settings/index.test.tsx | 23 +++++++++++++++++-- .../src/components/router_settings/index.tsx | 4 ++-- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx index dd5b727bef7..4a3ed2ed5c6 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx @@ -417,6 +417,25 @@ describe("Fallbacks", () => { expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument(); }); + it("hides write actions until the source map has loaded", async () => { + let resolveSources: (value: { fields: never[]; source: Record }) => void = () => {}; + vi.mocked(networkingModule.getRouterSettingsCall).mockReturnValue( + new Promise((resolve) => { + resolveSources = resolve; + }), + ); + renderWithQueryClient(); + + await waitFor(() => { + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + }); + expect(screen.queryByTestId("add-fallbacks-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("edit-fallback-button")).not.toBeInTheDocument(); + + resolveSources({ fields: [], source: { fallbacks: "config" } }); + expect(await screen.findByTestId("add-fallbacks-button")).toBeDisabled(); + }); + it.each(["env", "default", "db"])("keeps fallbacks editable when source is %s", async (source) => { const user = userEvent.setup(); vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({ diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index d94f07d90b9..e2bc180d69c 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -122,7 +122,7 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str const Fallbacks: React.FC = ({ accessToken, userRole, userID }) => { const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({}); - const [routerSources, setRouterSources] = useState({}); + const [routerSources, setRouterSources] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [fallbackToDelete, setFallbackToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -254,7 +254,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0; // Admin Viewer follows the read-parity rule: see fallbacks, no writes. - const canModify = isProxyAdminRole(userRole ?? ""); + const canModify = isProxyAdminRole(userRole ?? "") && routerSources !== null; const fallbacksFrozen = isConfigOwned(routerSources, "fallbacks"); const frozenActionClass = `${iconWrapperClass} cursor-not-allowed opacity-50`; diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx index e1c864a0247..d601fa00fca 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx @@ -67,9 +67,9 @@ describe("RouterSettings", () => { expect(container).toBeEmptyDOMElement(); }); - it("should render the Save Changes and Reset buttons when authenticated", () => { + it("should render the Save Changes and Reset buttons when authenticated", async () => { renderWithProviders(); - expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); }); @@ -203,6 +203,25 @@ describe("RouterSettings", () => { expect(await screen.findByText("Set in config.yaml and cannot be changed here")).toBeInTheDocument(); }); + it("holds the form until the source map has loaded so config owned fields never render editable", async () => { + let resolveSources: ( + value: typeof mockRouterSettingsResponse & { source: Record }, + ) => void = () => {}; + vi.mocked(getRouterSettingsCall).mockReturnValue( + new Promise((resolve) => { + resolveSources = resolve; + }), + ); + renderWithProviders(); + + await waitFor(() => expect(getCallbacksCall).toHaveBeenCalled()); + expect(screen.queryByRole("textbox", { name: /num_retries/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument(); + + resolveSources({ ...mockRouterSettingsResponse, source: { num_retries: "config" } }); + expect(await screen.findByRole("textbox", { name: /num_retries/i })).toBeDisabled(); + }); + it.each(["env", "default", "db"])("keeps fields editable when source is %s", async (source) => { const user = userEvent.setup(); vi.mocked(getRouterSettingsCall).mockResolvedValue({ diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx index 4bd09bade9e..483e2726471 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx @@ -25,7 +25,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole, const [availableRoutingStrategies, setAvailableRoutingStrategies] = useState([]); const [routerFieldsMetadata, setRouterFieldsMetadata] = useState<{ [key: string]: any }>({}); const [routingStrategyDescriptions, setRoutingStrategyDescriptions] = useState<{ [key: string]: string }>({}); - const [routerSources, setRouterSources] = useState({}); + const [routerSources, setRouterSources] = useState(null); useEffect(() => { if (!accessToken || !userRole || !userID) { @@ -177,7 +177,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole, } }; - if (!accessToken) { + if (!accessToken || routerSources === null) { return null; }