mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-17 04:25:02 +00:00
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:
@@ -3,10 +3,14 @@
|
|||||||
*
|
*
|
||||||
* Embeds the CLIProxy management.html with auto-authentication.
|
* Embeds the CLIProxy management.html with auto-authentication.
|
||||||
* Uses postMessage to inject credentials into the iframe.
|
* Uses postMessage to inject credentials into the iframe.
|
||||||
|
* Supports both local and remote CLIProxy server connections.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-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 */
|
/** CLIProxyAPI default port */
|
||||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||||
@@ -25,13 +29,52 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
|||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [showLoginHint, setShowLoginHint] = useState(true);
|
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
|
// Check if CLIProxy is running
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkConnection = async () => {
|
const checkConnection = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:${port}/`, {
|
const response = await fetch(checkUrl, {
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -39,16 +82,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
|||||||
setError(null);
|
setError(null);
|
||||||
} else {
|
} else {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setError('CLIProxy returned an error');
|
setError(
|
||||||
|
isRemote
|
||||||
|
? `Remote CLIProxy at ${displayHost} returned an error`
|
||||||
|
: 'CLIProxy returned an error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setError('CLIProxy is not running');
|
setError(
|
||||||
|
isRemote
|
||||||
|
? `Remote CLIProxy at ${displayHost} is not reachable`
|
||||||
|
: 'CLIProxy is not running'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkConnection();
|
checkConnection();
|
||||||
}, [port]);
|
}, [checkUrl, isRemote, displayHost]);
|
||||||
|
|
||||||
// Handle iframe load - attempt to auto-login via postMessage
|
// Handle iframe load - attempt to auto-login via postMessage
|
||||||
const handleIframeLoad = useCallback(() => {
|
const handleIframeLoad = useCallback(() => {
|
||||||
@@ -57,23 +108,25 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
|||||||
// Try to inject credentials via postMessage
|
// Try to inject credentials via postMessage
|
||||||
// The management.html needs to listen for this message
|
// The management.html needs to listen for this message
|
||||||
// If it doesn't support it, user will see the login page
|
// If it doesn't support it, user will see the login page
|
||||||
if (iframeRef.current?.contentWindow) {
|
if (iframeRef.current?.contentWindow && authToken) {
|
||||||
try {
|
try {
|
||||||
|
// Derive apiBase from checkUrl (remove trailing slash)
|
||||||
|
const apiBase = checkUrl.replace(/\/$/, '');
|
||||||
// Send credentials to iframe
|
// Send credentials to iframe
|
||||||
iframeRef.current.contentWindow.postMessage(
|
iframeRef.current.contentWindow.postMessage(
|
||||||
{
|
{
|
||||||
type: 'ccs-auto-login',
|
type: 'ccs-auto-login',
|
||||||
apiBase: `http://localhost:${port}`,
|
apiBase,
|
||||||
managementKey: CCS_CONTROL_PANEL_SECRET,
|
managementKey: authToken,
|
||||||
},
|
},
|
||||||
`http://localhost:${port}`
|
apiBase
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restriction - expected if not same origin
|
// Cross-origin restriction - expected if not same origin
|
||||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
|
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [port]);
|
}, [checkUrl, authToken]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -119,15 +172,22 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col relative">
|
<div className="flex-1 flex flex-col relative">
|
||||||
{/* Login hint banner */}
|
{/* Remote indicator and login hint banner */}
|
||||||
{showLoginHint && !isLoading && (
|
{showLoginHint && !isLoading && (
|
||||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
<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">
|
<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" />
|
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||||
<span>
|
<span>
|
||||||
Key:{' '}
|
Key:{' '}
|
||||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||||
ccs
|
{authToken || 'ccs'}
|
||||||
</code>
|
</code>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<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="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user