fix(dashboard): refresh per-connection proxy badges after a proxy save (#13711)

* 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=<connectionId>`) 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) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
小妍儿 ✨
2026-09-18 23:26:01 +08:00
committed by GitHub
parent 80ea176022
commit 27e0d9b5b7
5 changed files with 272 additions and 6 deletions

View File

@@ -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))

View File

@@ -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}

View File

@@ -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=<connectionId>`. 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<Record<string, unknown>>("@/shared/components");
return {
...actual,
ProxyConfigModal: ({ onSaved }: { onSaved?: () => void }) => (
<button type="button" data-testid="proxy-saved" onClick={() => onSaved?.()}>
save
</button>
),
};
});
const CONNECTION_ID = "conn-proxy-refresh";
/** What `GET /api/settings/proxy?resolve=<id>` 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<typeof createRoot>;
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<typeof useProviderConnections>;
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<string>(),
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 <ProviderModalsPanel {...(panelProps as never)} />;
}
await act(async () => {
root.render(<Harness />);
});
// 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<HTMLButtonElement>('[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);
});

View File

@@ -137,7 +137,7 @@ interface ProviderModalsPanelProps {
// Proxy config
proxyTarget: ProxyTarget | null;
setProxyTarget: (t: ProxyTarget | null) => void;
fetchProxyConfig: () => Promise<void>;
refreshProxyState: () => Promise<void>;
// 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();
}}
/>
)}

View File

@@ -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<void>;
fetchProxyConfig: () => Promise<void>;
refreshProxyState: () => Promise<void>;
// Single-connection handlers
deleteConfirm: ConnectionDeleteConfirmState;
@@ -293,6 +294,13 @@ export function useProviderConnections(
Record<string, { proxy: any; level: string } | null>
>({});
// Latest connections, readable from a stable callback without making that
// callback (and every consumer prop depending on it) change every fetch.
const connectionsRef = useRef<ConnectionRowConnection[]>(connections);
useEffect(() => {
connectionsRef.current = connections;
}, [connections]);
// ── Upstream proxy routing state (native / CLIProxyAPI / Dario / fallback) ─
const [upstreamProxyMode, setUpstreamProxyModeState] = useState<UpstreamProxyMode>("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,