mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
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:
1
changelog.d/fixes/13711-provider-proxy-badge-refresh.md
Normal file
1
changelog.d/fixes/13711-provider-proxy-badge-refresh.md
Normal 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))
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user