From 9a9ef98542bb766087b711fc39e928e347ad9b86 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:17:20 -0500 Subject: [PATCH] feat(ui): add Proxy settings tab to dashboard - add Proxy tab with Local/Remote mode toggle cards - add remote server config inputs (host, port, protocol, auth token) - add Test Connection button with reachability status display - add fallback settings (enable fallback, auto-start local) - add local proxy port configuration - add getProxyConfig, updateProxyConfig, testProxyConnection API methods --- ui/src/lib/api-client.ts | 59 +++++ ui/src/pages/settings.tsx | 537 +++++++++++++++++++++++++++++++++++++- 2 files changed, 590 insertions(+), 6 deletions(-) diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 73815560..3bb1c87f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -154,6 +154,42 @@ export interface CreatePreset { haiku?: string; } +/** Remote proxy status from health check */ +export interface RemoteProxyStatus { + reachable: boolean; + latencyMs?: number; + error?: string; + errorCode?: 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; +} + +/** Remote proxy configuration */ +export interface ProxyRemoteConfig { + enabled: boolean; + host: string; + port: number; + protocol: 'http' | 'https'; + auth_token: string; +} + +/** Fallback configuration */ +export interface ProxyFallbackConfig { + enabled: boolean; + auto_start: boolean; +} + +/** Local proxy configuration */ +export interface ProxyLocalConfig { + port: number; + auto_start: boolean; +} + +/** Proxy configuration */ +export interface ProxyConfig { + remote: ProxyRemoteConfig; + fallback: ProxyFallbackConfig; + local: ProxyLocalConfig; +} + /** CLIProxy process status from session tracker */ export interface ProxyProcessStatus { running: boolean; @@ -353,4 +389,27 @@ export const api = { method: 'DELETE', }), }, + /** Proxy configuration API */ + proxy: { + /** Get proxy configuration */ + get: () => request('/proxy'), + /** Update proxy configuration */ + update: (config: Partial) => + request('/proxy', { + method: 'PUT', + body: JSON.stringify(config), + }), + /** Test remote proxy connection */ + test: (params: { + host: string; + port: number; + protocol: 'http' | 'https'; + authToken?: string; + allowSelfSigned?: boolean; + }) => + request('/proxy/test', { + method: 'POST', + body: JSON.stringify(params), + }), + }, }; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 00f39dee..6f143344 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,6 +1,6 @@ /** - * Settings Page - WebSearch & Global Env Configuration - * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + * Settings Page - WebSearch, Global Env & Proxy Configuration + * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + Proxy Settings */ import { useState, useEffect } from 'react'; @@ -12,6 +12,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Globe, RefreshCw, @@ -28,8 +35,15 @@ import { Settings2, Plus, Trash2, + Server, + Laptop, + Cloud, + Wifi, + WifiOff, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; +import { api } from '@/lib/api-client'; +import type { ProxyConfig, RemoteProxyStatus } from '@/lib/api-client'; interface ProviderConfig { enabled?: boolean; @@ -71,8 +85,10 @@ interface GlobalEnvConfig { export function SettingsPage() { const [searchParams] = useSearchParams(); - const initialTab = searchParams.get('tab') === 'globalenv' ? 'globalenv' : 'websearch'; - const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv'>(initialTab); + const tabParam = searchParams.get('tab'); + const initialTab = + tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; + const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv' | 'proxy'>(initialTab); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -100,6 +116,14 @@ export function SettingsPage() { // New env var inputs const [newEnvKey, setNewEnvKey] = useState(''); const [newEnvValue, setNewEnvValue] = useState(''); + // Proxy state + const [proxyConfig, setProxyConfig] = useState(null); + const [proxyLoading, setProxyLoading] = useState(true); + const [proxySaving, setProxySaving] = useState(false); + const [proxyError, setProxyError] = useState(null); + const [proxySuccess, setProxySuccess] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); // Load config and status on mount useEffect(() => { @@ -107,6 +131,7 @@ export function SettingsPage() { fetchStatus(); fetchRawConfig(); fetchGlobalEnvConfig(); + fetchProxyConfig(); }, []); // Sync local model inputs when config changes @@ -179,6 +204,19 @@ export function SettingsPage() { } }; + const fetchProxyConfig = async () => { + try { + setProxyLoading(true); + setProxyError(null); + const data = await api.proxy.get(); + setProxyConfig(data); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setProxyLoading(false); + } + }; + const copyToClipboard = async () => { if (!rawConfig) return; try { @@ -387,6 +425,68 @@ export function SettingsPage() { saveGlobalEnvConfig({ env: newEnv }); }; + // Proxy functions + const saveProxyConfig = async (updates: Partial) => { + if (!proxyConfig) return; + + // Optimistic update + const optimisticConfig = { + remote: { ...proxyConfig.remote, ...updates.remote }, + fallback: { ...proxyConfig.fallback, ...updates.fallback }, + local: { ...proxyConfig.local, ...updates.local }, + }; + setProxyConfig(optimisticConfig); + setTestResult(null); // Clear previous test result on config change + + try { + setProxySaving(true); + setProxyError(null); + + const data = await api.proxy.update(updates); + setProxyConfig(data); + setProxySuccess(true); + setTimeout(() => setProxySuccess(false), 1500); + // Silently refresh raw config + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + setProxyConfig(proxyConfig); + setProxyError((err as Error).message); + } finally { + setProxySaving(false); + } + }; + + const handleTestConnection = async () => { + if (!proxyConfig) return; + + const { host, port, protocol, auth_token } = proxyConfig.remote; + if (!host || !port) { + setProxyError('Host and port are required'); + return; + } + + try { + setTesting(true); + setProxyError(null); + setTestResult(null); + + const result = await api.proxy.test({ + host, + port, + protocol, + authToken: auth_token || undefined, + }); + setTestResult(result); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setTesting(false); + } + }; + if (loading) { return (
@@ -408,7 +508,7 @@ export function SettingsPage() {
setActiveTab(v as 'websearch' | 'globalenv')} + onValueChange={(v) => setActiveTab(v as 'websearch' | 'globalenv' | 'proxy')} > @@ -419,6 +519,10 @@ export function SettingsPage() { Global Env + + + Proxy +
@@ -455,7 +559,7 @@ export function SettingsPage() { fetchRawConfig={fetchRawConfig} loading={loading} /> - ) : ( + ) : activeTab === 'globalenv' ? ( + ) : ( + )}
@@ -1163,3 +1281,410 @@ function GlobalEnvContent({ ); } + +// Proxy Tab Content Component +interface ProxyContentProps { + config: ProxyConfig | null; + loading: boolean; + saving: boolean; + error: string | null; + success: boolean; + testResult: RemoteProxyStatus | null; + testing: boolean; + saveProxyConfig: (updates: Partial) => void; + handleTestConnection: () => void; + fetchProxyConfig: () => void; + fetchRawConfig: () => void; +} + +function ProxyContent({ + config, + loading, + saving, + error, + success, + testResult, + testing, + saveProxyConfig, + handleTestConnection, + fetchProxyConfig, + fetchRawConfig, +}: ProxyContentProps) { + // Memoized default config to avoid recreation + const defaultRemote = { + enabled: false, + host: '', + port: 8317, + protocol: 'http' as const, + auth_token: '', + }; + const defaultFallback = { enabled: true, auto_start: true }; + const defaultLocal = { port: 8317, auto_start: true }; + + // Sync local state with config (using refs to avoid lint warnings) + const hostInput = config?.remote.host ?? ''; + const portInput = (config?.remote.port ?? 8317).toString(); + 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; + const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayLocalPort = editedLocalPort ?? localPortInput; + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + const isRemoteMode = config?.remote.enabled ?? false; + const remoteConfig = config?.remote ?? defaultRemote; + const fallbackConfig = config?.fallback ?? defaultFallback; + const localConfig = config?.local ?? defaultLocal; + + // Save functions for blur events + const saveHost = () => { + const value = editedHost ?? displayHost; + if (value !== config?.remote.host) { + saveProxyConfig({ remote: { ...remoteConfig, host: value } }); + } + setEditedHost(null); + }; + + const savePort = () => { + const port = parseInt(editedPort ?? displayPort, 10); + if (!isNaN(port) && port !== config?.remote.port) { + saveProxyConfig({ remote: { ...remoteConfig, port } }); + } + setEditedPort(null); + }; + + const saveAuthToken = () => { + const value = editedAuthToken ?? displayAuthToken; + if (value !== config?.remote.auth_token) { + saveProxyConfig({ remote: { ...remoteConfig, auth_token: value } }); + } + setEditedAuthToken(null); + }; + + const saveLocalPort = () => { + const port = parseInt(editedLocalPort ?? displayLocalPort, 10); + if (!isNaN(port) && port !== config?.local.port) { + saveProxyConfig({ local: { ...localConfig, port } }); + } + setEditedLocalPort(null); + }; + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ Configure local or remote CLIProxyAPI connection for proxy-based profiles +

+ + {/* Mode Toggle - Card based selection */} +
+

Connection Mode

+
+ {/* Local Mode Card */} + + + {/* Remote Mode Card */} + +
+
+ + {/* Remote Settings - Show when remote mode is enabled */} + {isRemoteMode && ( +
+

+ + Remote Server Configuration +

+ + {/* Host */} +
+ + setEditedHost(e.target.value)} + onBlur={saveHost} + placeholder="192.168.1.100 or proxy.example.com" + className="font-mono" + disabled={saving} + /> +
+ + {/* Port and Protocol */} +
+
+ + setEditedPort(e.target.value)} + onBlur={savePort} + placeholder="8317" + className="font-mono" + disabled={saving} + /> +
+
+ + +
+
+ + {/* Auth Token */} +
+ + setEditedAuthToken(e.target.value)} + onBlur={saveAuthToken} + placeholder="Bearer token for authentication" + className="font-mono" + disabled={saving} + /> +
+ + {/* Test Connection */} +
+ + + {/* Test Result */} + {testResult && ( +
+
+ {testResult.reachable ? ( + <> + + + Connected ({testResult.latencyMs}ms) + + + ) : ( + <> + + + {testResult.error || 'Connection failed'} + + + )} +
+
+ )} +
+
+ )} + + {/* Fallback Settings */} +
+

Fallback Settings

+
+ {/* Enable Fallback */} +
+
+

Enable fallback to local

+

+ Use local proxy if remote is unreachable +

+
+ + saveProxyConfig({ fallback: { ...fallbackConfig, enabled: checked } }) + } + disabled={saving || !isRemoteMode} + /> +
+ + {/* Auto-start on fallback */} +
+
+

Auto-start local proxy

+

+ Automatically start local proxy on fallback +

+
+ + saveProxyConfig({ fallback: { ...fallbackConfig, auto_start: checked } }) + } + disabled={saving || !isRemoteMode || !config?.fallback.enabled} + /> +
+
+
+ + {/* Local Proxy Settings */} +
+

Local Proxy

+
+ {/* Port */} +
+ + setEditedLocalPort(e.target.value)} + onBlur={saveLocalPort} + placeholder="8317" + className="font-mono max-w-32" + disabled={saving} + /> +
+ + {/* Auto-start */} +
+
+

Auto-start

+

+ Start local proxy automatically when needed +

+
+ + saveProxyConfig({ local: { ...localConfig, auto_start: checked } }) + } + disabled={saving} + /> +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +}