From 27e0d9b5b79cfaeebe50dbb995395c6b78f445fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A6=8D=E5=84=BF=20=E2=9C=A8?= Date: Fri, 18 Sep 2026 23:26:01 +0800 Subject: [PATCH] fix(dashboard): refresh per-connection proxy badges after a proxy save (#13711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): refresh per-connection proxy badges after a proxy save ProxyConfigModal persists an assignment through `PUT /api/settings/proxies/assignments`, but the provider page bound its `onSaved` callback to `fetchProxyConfig()`, which only refetches `GET /api/settings/proxy` into `proxyConfig`. The per-account proxy badges read `connProxyMap`, which is filled from a different endpoint (`GET /api/settings/proxy?resolve=`) by an effect keyed on `[loading, connections]`. A proxy save changes neither key, so the effect never re-ran and the saved (or cleared) proxy stayed invisible until a manual page reload. Account- and combo-level saves refreshed nothing visible at all; a provider-level save refreshed only the toolbar chip while the rows that inherit that proxy stayed stale. Adds `refreshProxyState()`, which re-reads both sources together, and binds the modal's `onSaved` to it. The callback reads the latest connections from a ref so it stays referentially stable and does not re-render consumers on every connections fetch. Fixes #13710 Also removes the now-unused `no-unused-vars` suppression entry for useProviderConnections.ts. That entry was already stale on the base branch (the same eslint invocation reports it on an unmodified tree), and the pre-commit ratchet refuses to pass while a touched file carries one. Only that single exact entry was pruned; no baseline was widened. * docs(changelog): add fragment for #13711 * test(providers): raise proxySaveRefresh test timeout to 30s The it() case dynamically imports ProviderModalsPanel, the first test in the repo to pull in that module's ~15 modal components, which alone consumes most of Vitest's default 5000ms testTimeout and made the test flake under CI/runner load. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: 千乘妍 (Xiaoyaner) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../13711-provider-proxy-badge-refresh.md | 1 + .../[id]/ProviderDetailPageClient.tsx | 4 +- .../[id]/__tests__/proxySaveRefresh.test.tsx | 234 ++++++++++++++++++ .../[id]/components/ProviderModalsPanel.tsx | 6 +- .../[id]/hooks/useProviderConnections.ts | 33 ++- 5 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/13711-provider-proxy-badge-refresh.md create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx diff --git a/changelog.d/fixes/13711-provider-proxy-badge-refresh.md b/changelog.d/fixes/13711-provider-proxy-badge-refresh.md new file mode 100644 index 0000000000..f786290bbb --- /dev/null +++ b/changelog.d/fixes/13711-provider-proxy-badge-refresh.md @@ -0,0 +1 @@ +- **fix(dashboard):** Saving or clearing a proxy on a provider page now refreshes the per-connection proxy badges immediately instead of leaving them stale until a manual reload ([#13711](https://github.com/diegosouzapw/OmniRoute/pull/13711)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 55779730ce..7e06b58195 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -144,7 +144,7 @@ export default function ProviderDetailPageClient() { setBatchTestResults, setProviderNode, fetchConnections, - fetchProxyConfig, + refreshProxyState, deleteConfirm, handleUpdateConnectionStatus, handleToggleRateLimit, @@ -911,7 +911,7 @@ export default function ProviderDetailPageClient() { emailsVisible={emailsVisible} proxyTarget={proxyTarget} setProxyTarget={setProxyTarget} - fetchProxyConfig={fetchProxyConfig} + refreshProxyState={refreshProxyState} importProgress={importProgress} showImportModal={showImportModal} setShowImportModal={setShowImportModal} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx new file mode 100644 index 0000000000..253188cf54 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +/** + * Provider page: saving a proxy from the provider/account proxy modal must + * refresh the per-connection proxy badges, not only the provider-level config. + * + * ProxyConfigModal writes the assignment through + * `PUT /api/settings/proxies/assignments` (registry scope assignment), but the + * account-row badges on the provider page are fed by `connProxyMap`, which is + * filled from `GET /api/settings/proxy?resolve=`. The modal's + * `onSaved` callback refreshed only `proxyConfig` + * (`GET /api/settings/proxy`), so a saved proxy stayed invisible on the page + * until a manual reload. + * + * This wires the same production pieces the real page composes — + * useProviderConnections (owns connProxyMap + the refresh callbacks) and + * ProviderModalsPanel (owns the ProxyConfigModal `onSaved` binding) — and + * asserts the refresh contract through that public path, so it holds for any + * implementation of the refresh rather than pinning one helper name. + */ +import React, { act, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "codex" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/dashboard/providers/codex", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +// Stand in for the real ProxyConfigModal: it only needs to expose the `onSaved` +// callback the page binds, which is what fires after a successful save. +vi.mock("@/shared/components", async () => { + const actual = await vi.importActual>("@/shared/components"); + return { + ...actual, + ProxyConfigModal: ({ onSaved }: { onSaved?: () => void }) => ( + + ), + }; +}); + +const CONNECTION_ID = "conn-proxy-refresh"; + +/** What `GET /api/settings/proxy?resolve=` currently reports. */ +let resolvedProxy: { name: string; host: string } | null = null; + +const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + const json = (body: unknown) => ({ + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + headers: { get: () => "application/json" }, + }); + + if (url.startsWith("/api/settings/proxy?resolve=")) { + return json(resolvedProxy ? { proxy: resolvedProxy, level: "account" } : { proxy: null }); + } + if (url.startsWith("/api/settings/proxy")) { + return json({ global: null, providers: {} }); + } + if (url.startsWith("/api/providers")) { + return json({ + connections: [{ id: CONNECTION_ID, provider: "codex", name: "Account A", priority: 1 }], + }); + } + if (url.startsWith("/api/provider-nodes")) { + return json({ nodes: [] }); + } + return json({}); +}); + +vi.stubGlobal("fetch", fetchMock); + +function resolveCallCount() { + return fetchMock.mock.calls.filter((call) => + String(call[0]).startsWith("/api/settings/proxy?resolve=") + ).length; +} + +describe("provider page — proxy save refreshes per-connection proxy badges", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + resolvedProxy = null; + fetchMock.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("re-resolves connection proxies when ProxyConfigModal reports a save", async () => { + const { useProviderConnections } = await import("../hooks/useProviderConnections"); + const ProviderModalsPanel = (await import("../components/ProviderModalsPanel")).default; + + type HookResult = ReturnType; + let hook: HookResult | null = null; + + // Mirrors the real page: the hook owns connProxyMap + refresh callbacks, + // ProviderModalsPanel owns the ProxyConfigModal binding. + function Harness() { + const hookResult = useProviderConnections("codex", false, false); + const [proxyTarget, setProxyTarget] = useState<{ + level: string; + id: string; + label: string; + } | null>({ level: "key", id: CONNECTION_ID, label: "Account A" }); + + useEffect(() => { + hook = hookResult; + }, [hookResult]); + + const panelProps = { + providerId: "codex", + providerInfo: { name: "Codex" }, + isCompatible: false, + isAnthropicProtocolCompatible: false, + isCcCompatible: false, + isCommandCode: false, + isUpstreamProxyProvider: false, + subscriptionRisk: false, + showRiskNoticeModal: false, + showKimiAuthMethodModal: false, + showOAuthModal: false, + reauthConnection: null, + showSiliconFlowEndpointModal: false, + showAddApiKeyModal: false, + siliconFlowInitialBaseUrl: undefined, + commandCodeAuthState: { phase: "idle" }, + batchDeleteConfirmOpen: false, + selectedIds: new Set(), + batchDeleting: false, + deleteConfirm: hookResult.deleteConfirm, + applyCodexModalConnectionId: null, + applyingCodexAuthId: null, + importCodexModalOpen: false, + fetchConnections: hookResult.fetchConnections, + externalLinkModalOpen: false, + externalLinkLoading: false, + externalLinkError: null, + externalLinkUrl: null, + externalLinkCopied: false, + showEditModal: false, + selectedConnection: null, + showEditNodeModal: false, + providerNode: null, + codexCliGuideOpen: false, + applyClaudeModalConnectionId: null, + applyingClaudeAuthId: null, + importClaudeModalOpen: false, + importGrokCliModalOpen: false, + batchTestResults: null, + emailsVisible: false, + proxyTarget, + setProxyTarget, + fetchProxyConfig: hookResult.fetchProxyConfig, + refreshProxyState: hookResult.refreshProxyState, + importProgress: { + current: 0, + total: 0, + phase: "idle", + status: "", + logs: [], + error: "", + importedCount: 0, + }, + showImportModal: false, + showTutorialModal: false, + t: (key: string) => key, + }; + + return ; + } + + await act(async () => { + root.render(); + }); + // Let the connections effect settle so connProxyMap is populated once. + await act(async () => { + await Promise.resolve(); + }); + + expect(hook).not.toBeNull(); + expect(hook!.connProxyMap[CONNECTION_ID]).toBeNull(); + + const resolvesBeforeSave = resolveCallCount(); + expect(resolvesBeforeSave).toBeGreaterThan(0); + + // The user picks a proxy and saves: the assignment now exists server-side. + resolvedProxy = { name: "Saved Proxy", host: "10.0.0.9" }; + + const saveButton = container.querySelector('[data-testid="proxy-saved"]'); + expect(saveButton).not.toBeNull(); + + await act(async () => { + saveButton!.click(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(resolveCallCount()).toBeGreaterThan(resolvesBeforeSave); + expect(hook!.connProxyMap[CONNECTION_ID]).toEqual({ + proxy: { name: "Saved Proxy", host: "10.0.0.9" }, + level: "account", + }); + }, 30000); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index a4d994eac5..4ae2c72f86 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -137,7 +137,7 @@ interface ProviderModalsPanelProps { // Proxy config proxyTarget: ProxyTarget | null; setProxyTarget: (t: ProxyTarget | null) => void; - fetchProxyConfig: () => Promise; + refreshProxyState: () => Promise; // Import progress importProgress: ImportProgress; showImportModal: boolean; @@ -221,7 +221,7 @@ export default function ProviderModalsPanel({ emailsVisible, proxyTarget, setProxyTarget, - fetchProxyConfig, + refreshProxyState, importProgress, showImportModal, setShowImportModal, @@ -444,7 +444,7 @@ export default function ProviderModalsPanel({ levelId={proxyTarget.id} levelLabel={proxyTarget.label} onSaved={() => { - void fetchProxyConfig(); + void refreshProxyState(); }} /> )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts index 6ae2dfe5c8..bed7707e50 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts @@ -21,7 +21,7 @@ * providers constants) — never from ProviderDetailPageClient. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { useNotificationStore } from "@/store/notificationStore"; import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; @@ -191,6 +191,7 @@ export interface UseProviderConnectionsReturn { // Connection fetch fetchConnections: () => Promise; fetchProxyConfig: () => Promise; + refreshProxyState: () => Promise; // Single-connection handlers deleteConfirm: ConnectionDeleteConfirmState; @@ -293,6 +294,13 @@ export function useProviderConnections( Record >({}); + // Latest connections, readable from a stable callback without making that + // callback (and every consumer prop depending on it) change every fetch. + const connectionsRef = useRef(connections); + useEffect(() => { + connectionsRef.current = connections; + }, [connections]); + // ── Upstream proxy routing state (native / CLIProxyAPI / Dario / fallback) ─ const [upstreamProxyMode, setUpstreamProxyModeState] = useState("native"); const [upstreamProxyFallbackBackend, setUpstreamProxyFallbackBackendState] = @@ -314,6 +322,28 @@ export function useProviderConnections( if (result) setProxyConfig(result.config); }, []); + /** + * Refresh every proxy view the page renders after a proxy assignment is + * written elsewhere (ProxyConfigModal saves/clears through + * `/api/settings/proxies/assignments`). + * + * Two independent sources back those views and BOTH must be re-read: + * - `proxyConfig` ← GET /api/settings/proxy (provider-level chip) + * - `connProxyMap` ← GET /api/settings/proxy?resolve= (per-connection badges) + * + * The `connProxyMap` effect below is keyed on [loading, connections], and a + * proxy save changes neither, so without this callback the account-row + * badges keep showing pre-save state until a manual reload. + */ + const refreshProxyState = useCallback(async () => { + const [configResult, map] = await Promise.all([ + loadProxyConfigData(), + resolveConnectionProxies(connectionsRef.current), + ]); + if (configResult) setProxyConfig(configResult.config); + if (map) setConnProxyMap(map); + }, []); + const fetchConnections = useCallback(async () => { const result = await loadProviderConnectionsData(providerId, isCompatible); if (result) { @@ -1108,6 +1138,7 @@ export function useProviderConnections( // Fetch fetchConnections, fetchProxyConfig, + refreshProxyState, // Single-connection handlers deleteConfirm,