diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts index 7bc3d3a3..5b0b771c 100644 --- a/src/cliproxy/remote-proxy-client.ts +++ b/src/cliproxy/remote-proxy-client.ts @@ -8,7 +8,13 @@ import * as https from 'https'; /** Error codes for remote proxy status */ -export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; +export type RemoteProxyErrorCode = + | 'CONNECTION_REFUSED' + | 'TIMEOUT' + | 'AUTH_FAILED' + | 'DNS_FAILED' + | 'NETWORK_UNREACHABLE' + | 'UNKNOWN'; /** Status returned from remote proxy health check */ export interface RemoteProxyStatus { @@ -77,8 +83,8 @@ function buildProxyUrl( * Map error to RemoteProxyErrorCode * * Handles various error types including: - * - NodeJS.ErrnoException (ECONNREFUSED, ETIMEDOUT) - * - Fetch errors (AbortError, TypeError) + * - NodeJS.ErrnoException (ECONNREFUSED, ETIMEDOUT, ENOTFOUND, ENETUNREACH) + * - Fetch errors (AbortError, TypeError, "fetch failed") * - HTTP status codes (401, 403) */ function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode { @@ -87,6 +93,27 @@ function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode const rawCode = (error as NodeJS.ErrnoException).code; const code = typeof rawCode === 'string' ? rawCode.toLowerCase() : undefined; + // DNS resolution failed + if ( + code === 'enotfound' || + code === 'eai_again' || + message.includes('getaddrinfo') || + message.includes('dns') + ) { + return 'DNS_FAILED'; + } + + // Network unreachable / host unreachable + if ( + code === 'enetunreach' || + code === 'ehostunreach' || + code === 'enetdown' || + message.includes('network') || + message.includes('unreachable') + ) { + return 'NETWORK_UNREACHABLE'; + } + // Connection refused if (code === 'econnrefused' || message.includes('connection refused')) { return 'CONNECTION_REFUSED'; @@ -107,6 +134,17 @@ function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode return 'AUTH_FAILED'; } + // Generic "fetch failed" - try to extract cause + if (message.includes('fetch failed') || message.includes('failed to fetch')) { + // Check if there's a cause property (Node.js 18+) + const cause = (error as Error & { cause?: Error }).cause; + if (cause) { + return mapErrorToCode(cause); + } + // Likely network/DNS issue if no specific cause + return 'NETWORK_UNREACHABLE'; + } + return 'UNKNOWN'; } @@ -118,11 +156,15 @@ function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): st case 'CONNECTION_REFUSED': return 'Connection refused - is the proxy running?'; case 'TIMEOUT': - return 'Connection timed out'; + return 'Connection timed out - server may be slow or unreachable'; case 'AUTH_FAILED': return 'Authentication failed - check auth token'; + case 'DNS_FAILED': + return 'DNS lookup failed - check hostname'; + case 'NETWORK_UNREACHABLE': + return 'Network unreachable - check if host is on same network'; default: - return rawError || 'Unknown error'; + return rawError || 'Connection failed'; } } diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts index 3e7b95c8..536ff1d8 100644 --- a/tests/unit/cliproxy/remote-proxy-client.test.ts +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -47,18 +47,29 @@ describe('remote-proxy-client', () => { describe('RemoteProxyErrorCode', () => { it('should define expected error codes', () => { - const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN']; + const validCodes = [ + 'CONNECTION_REFUSED', + 'TIMEOUT', + 'AUTH_FAILED', + 'DNS_FAILED', + 'NETWORK_UNREACHABLE', + 'UNKNOWN', + ]; // Type-level test - ensure error codes can be used const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' }; const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' }; const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' }; const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' }; + const status5: RemoteProxyStatus = { reachable: false, errorCode: 'DNS_FAILED' }; + const status6: RemoteProxyStatus = { reachable: false, errorCode: 'NETWORK_UNREACHABLE' }; expect(validCodes).toContain(status1.errorCode); expect(validCodes).toContain(status2.errorCode); expect(validCodes).toContain(status3.errorCode); expect(validCodes).toContain(status4.errorCode); + expect(validCodes).toContain(status5.errorCode); + expect(validCodes).toContain(status6.errorCode); }); }); diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 235614e0..32201e42 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -124,6 +124,11 @@ export function SettingsPage() { const [proxySuccess, setProxySuccess] = useState(false); const [testResult, setTestResult] = useState(null); const [testing, setTesting] = useState(false); + // Proxy input state (lifted from ProxyContent to persist across tab switches) + const [proxyEditedHost, setProxyEditedHost] = useState(null); + const [proxyEditedPort, setProxyEditedPort] = useState(null); + const [proxyEditedAuthToken, setProxyEditedAuthToken] = useState(null); + const [proxyEditedLocalPort, setProxyEditedLocalPort] = useState(null); // Load config and status on mount useEffect(() => { @@ -459,10 +464,13 @@ export function SettingsPage() { } }; - const handleTestConnection = async () => { - if (!proxyConfig) return; - - const { host, port, protocol, auth_token } = proxyConfig.remote; + const handleTestConnection = async (params: { + host: string; + port: string; + protocol: 'http' | 'https'; + authToken: string; + }) => { + const { host, port, protocol, authToken } = params; if (!host) { setProxyError('Host is required'); return; @@ -473,11 +481,13 @@ export function SettingsPage() { setProxyError(null); setTestResult(null); + // Parse port - empty string means use protocol default + const portNum = port ? parseInt(port, 10) : undefined; const result = await api.cliproxyServer.test({ host, - port: port || undefined, // Empty/0 means use protocol default + port: portNum || undefined, // Empty/0 means use protocol default protocol, - authToken: auth_token || undefined, + authToken: authToken || undefined, }); setTestResult(result); } catch (err) { @@ -590,6 +600,14 @@ export function SettingsPage() { handleTestConnection={handleTestConnection} fetchCliproxyServerConfig={fetchCliproxyServerConfig} fetchRawConfig={fetchRawConfig} + editedHost={proxyEditedHost} + setEditedHost={setProxyEditedHost} + editedPort={proxyEditedPort} + setEditedPort={setProxyEditedPort} + editedAuthToken={proxyEditedAuthToken} + setEditedAuthToken={setProxyEditedAuthToken} + editedLocalPort={proxyEditedLocalPort} + setEditedLocalPort={setProxyEditedLocalPort} /> )} @@ -1292,9 +1310,23 @@ interface ProxyContentProps { testResult: RemoteProxyStatus | null; testing: boolean; saveCliproxyServerConfig: (updates: Partial) => void; - handleTestConnection: () => void; + handleTestConnection: (params: { + host: string; + port: string; + protocol: 'http' | 'https'; + authToken: string; + }) => void; fetchCliproxyServerConfig: () => void; fetchRawConfig: () => void; + // Lifted input state for persistence across tab switches + editedHost: string | null; + setEditedHost: (value: string | null) => void; + editedPort: string | null; + setEditedPort: (value: string | null) => void; + editedAuthToken: string | null; + setEditedAuthToken: (value: string | null) => void; + editedLocalPort: string | null; + setEditedLocalPort: (value: string | null) => void; } function ProxyContent({ @@ -1309,6 +1341,14 @@ function ProxyContent({ handleTestConnection, fetchCliproxyServerConfig, fetchRawConfig, + editedHost, + setEditedHost, + editedPort, + setEditedPort, + editedAuthToken, + setEditedAuthToken, + editedLocalPort, + setEditedLocalPort, }: ProxyContentProps) { // Default configs for fallback const defaultRemote = { @@ -1332,12 +1372,6 @@ function ProxyContent({ const authTokenInput = config?.remote.auth_token ?? ''; const localPortInput = (config?.local.port ?? 8317).toString(); - // Track edited values separately - const [editedHost, setEditedHost] = useState(null); - const [editedPort, setEditedPort] = useState(null); - const [editedAuthToken, setEditedAuthToken] = useState(null); - const [editedLocalPort, setEditedLocalPort] = useState(null); - // Get display values (edited or from config) const displayHost = editedHost ?? hostInput; const displayPort = editedPort ?? portInput; @@ -1559,7 +1593,14 @@ function ProxyContent({ {/* Test Connection */}