feat(ui): dynamic control panel embed for remote CLIProxy

- Use React Query to fetch cliproxy_server config
- Build URLs dynamically based on remote/local mode
- Add Globe icon and Remote badge indicator
- Show context-aware error messages for remote failures
This commit is contained in:
kaitranntt
2025-12-21 00:42:54 -05:00
parent 17bb6f9836
commit bfa55e041c
@@ -3,10 +3,14 @@
*
* Embeds the CLIProxy management.html with auto-authentication.
* Uses postMessage to inject credentials into the iframe.
* Supports both local and remote CLIProxy server connections.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
/** CLIProxyAPI default port */
const CLIPROXY_DEFAULT_PORT = 8317;
@@ -25,13 +29,52 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
const [isConnected, setIsConnected] = useState(false);
const [showLoginHint, setShowLoginHint] = useState(true);
const managementUrl = `http://localhost:${port}/management.html`;
// Fetch cliproxy_server config for remote/local mode detection
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
queryKey: ['cliproxy-server-config'],
queryFn: () => api.cliproxyServer.get(),
staleTime: 30000, // 30 seconds
});
// Calculate URLs and settings based on remote or local mode
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
const remote = cliproxyConfig?.remote;
if (remote?.enabled && remote?.host) {
const protocol = remote.protocol || 'http';
// Use port from config, or default based on protocol (443 for https, 80 for http)
const remotePort = remote.port || (protocol === 'https' ? 443 : 80);
// Only include port in URL if it's non-standard
const portSuffix =
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
? ''
: `:${remotePort}`;
const baseUrl = `${protocol}://${remote.host}${portSuffix}`;
return {
managementUrl: `${baseUrl}/management.html`,
checkUrl: `${baseUrl}/`,
authToken: remote.auth_token || undefined,
isRemote: true,
displayHost: `${remote.host}${portSuffix}`,
};
}
// Local mode
return {
managementUrl: `http://localhost:${port}/management.html`,
checkUrl: `http://localhost:${port}/`,
authToken: CCS_CONTROL_PANEL_SECRET,
isRemote: false,
displayHost: `localhost:${port}`,
};
}, [cliproxyConfig, port]);
// Check if CLIProxy is running
useEffect(() => {
const checkConnection = async () => {
try {
const response = await fetch(`http://localhost:${port}/`, {
const response = await fetch(checkUrl, {
signal: AbortSignal.timeout(2000),
});
if (response.ok) {
@@ -39,16 +82,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
setError(null);
} else {
setIsConnected(false);
setError('CLIProxy returned an error');
setError(
isRemote
? `Remote CLIProxy at ${displayHost} returned an error`
: 'CLIProxy returned an error'
);
}
} catch {
setIsConnected(false);
setError('CLIProxy is not running');
setError(
isRemote
? `Remote CLIProxy at ${displayHost} is not reachable`
: 'CLIProxy is not running'
);
}
};
checkConnection();
}, [port]);
}, [checkUrl, isRemote, displayHost]);
// Handle iframe load - attempt to auto-login via postMessage
const handleIframeLoad = useCallback(() => {
@@ -57,23 +108,25 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
// Try to inject credentials via postMessage
// The management.html needs to listen for this message
// If it doesn't support it, user will see the login page
if (iframeRef.current?.contentWindow) {
if (iframeRef.current?.contentWindow && authToken) {
try {
// Derive apiBase from checkUrl (remove trailing slash)
const apiBase = checkUrl.replace(/\/$/, '');
// Send credentials to iframe
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase: `http://localhost:${port}`,
managementKey: CCS_CONTROL_PANEL_SECRET,
apiBase,
managementKey: authToken,
},
`http://localhost:${port}`
apiBase
);
} catch {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
}
}
}, [port]);
}, [checkUrl, authToken]);
const handleRefresh = () => {
setIsLoading(true);
@@ -119,15 +172,22 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
return (
<div className="flex-1 flex flex-col relative">
{/* Login hint banner */}
{/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
{isRemote && (
<>
<Globe className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-600 font-medium">Remote</span>
<span className="text-blue-300 dark:text-blue-700">|</span>
</>
)}
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
ccs
{authToken || 'ccs'}
</code>
</span>
<button
@@ -145,7 +205,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
<p className="text-sm text-muted-foreground">
{isRemote
? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'}
</p>
</div>
</div>
)}