fix(gateway): add API fallbacks and connection stability improvements
- Add api-fallbacks.ts with structured fallback data for 6 missing API endpoints - QuickConfig, WorkspaceInfo, UsageStats, PluginStatus, ScheduledTasks, SecurityStatus - Graceful degradation when backend returns 404 - Add heartbeat mechanism (30s interval, 3 max missed) - Automatic connection keep-alive with ping/pong - Triggers reconnect when heartbeats fail - Improve reconnection strategy - Emit 'reconnecting' events for UI feedback - Support infinite reconnect mode - Add ConnectionStatus component - Visual indicators for 5 connection states - Manual reconnect button when disconnected - Compact and full display modes Diagnosed via Chrome DevTools: WebSocket was working fine, real issue was 404 errors from missing API endpoints being mistaken for connection problems. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
224
desktop/src/components/ConnectionStatus.tsx
Normal file
224
desktop/src/components/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* ConnectionStatus Component
|
||||
*
|
||||
* Displays the current Gateway connection status with visual indicators.
|
||||
* Supports automatic reconnect and manual reconnect button.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Wifi, WifiOff, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { getGatewayClient } from '../lib/gateway-client';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
/** Show compact version (just icon and status text) */
|
||||
compact?: boolean;
|
||||
/** Show reconnect button when disconnected */
|
||||
showReconnectButton?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ReconnectInfo {
|
||||
attempt: number;
|
||||
delay: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
type StatusType = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
|
||||
|
||||
const statusConfig: Record<StatusType, {
|
||||
color: string;
|
||||
bgColor: string;
|
||||
label: string;
|
||||
icon: typeof Wifi;
|
||||
animate?: boolean;
|
||||
}> = {
|
||||
disconnected: {
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
label: '已断开',
|
||||
icon: WifiOff,
|
||||
},
|
||||
connecting: {
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
label: '连接中...',
|
||||
icon: Loader2,
|
||||
animate: true,
|
||||
},
|
||||
handshaking: {
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
label: '认证中...',
|
||||
icon: Loader2,
|
||||
animate: true,
|
||||
},
|
||||
connected: {
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||
label: '已连接',
|
||||
icon: Wifi,
|
||||
},
|
||||
reconnecting: {
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
label: '重连中...',
|
||||
icon: RefreshCw,
|
||||
animate: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function ConnectionStatus({
|
||||
compact = false,
|
||||
showReconnectButton = true,
|
||||
className = '',
|
||||
}: ConnectionStatusProps) {
|
||||
const { connectionState, connect } = useGatewayStore();
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [reconnectInfo, setReconnectInfo] = useState<ReconnectInfo | null>(null);
|
||||
|
||||
// Listen for reconnect events
|
||||
useEffect(() => {
|
||||
const client = getGatewayClient();
|
||||
|
||||
const unsubReconnecting = client.on('reconnecting', (info) => {
|
||||
setReconnectInfo(info as ReconnectInfo);
|
||||
});
|
||||
|
||||
const unsubFailed = client.on('reconnect_failed', () => {
|
||||
setShowPrompt(true);
|
||||
setReconnectInfo(null);
|
||||
});
|
||||
|
||||
const unsubConnected = client.on('connected', () => {
|
||||
setShowPrompt(false);
|
||||
setReconnectInfo(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubReconnecting();
|
||||
unsubFailed();
|
||||
unsubConnected();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const config = statusConfig[connectionState];
|
||||
const Icon = config.icon;
|
||||
const isDisconnected = connectionState === 'disconnected';
|
||||
const isReconnecting = connectionState === 'reconnecting';
|
||||
|
||||
const handleReconnect = async () => {
|
||||
setShowPrompt(false);
|
||||
try {
|
||||
await connect();
|
||||
} catch (error) {
|
||||
console.error('Manual reconnect failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Compact version
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 ${className}`}>
|
||||
<Icon
|
||||
className={`w-3.5 h-3.5 ${config.color} ${config.animate ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<span className={`text-xs ${config.color}`}>
|
||||
{isReconnecting && reconnectInfo
|
||||
? `${config.label} (${reconnectInfo.attempt}/${reconnectInfo.maxAttempts})`
|
||||
: config.label}
|
||||
</span>
|
||||
{showPrompt && showReconnectButton && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="text-xs text-blue-500 hover:text-blue-600 ml-1"
|
||||
>
|
||||
重连
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full version
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${config.bgColor} rounded-lg px-3 py-2 ${className}`}>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: config.animate ? 360 : 0 }}
|
||||
transition={config.animate ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-medium ${config.color}`}>
|
||||
{isReconnecting && reconnectInfo
|
||||
? `${config.label} (${reconnectInfo.attempt}/${reconnectInfo.maxAttempts})`
|
||||
: config.label}
|
||||
</div>
|
||||
{reconnectInfo && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{Math.round(reconnectInfo.delay / 1000)}秒后重试
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showPrompt && isDisconnected && showReconnectButton && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重新连接
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ConnectionIndicator - Minimal connection indicator for headers
|
||||
*/
|
||||
export function ConnectionIndicator({ className = '' }: { className?: string }) {
|
||||
const { connectionState } = useGatewayStore();
|
||||
|
||||
const isConnected = connectionState === 'connected';
|
||||
const isReconnecting = connectionState === 'reconnecting';
|
||||
|
||||
return (
|
||||
<span className={`text-xs flex items-center gap-1 ${className}`}>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isConnected
|
||||
? 'bg-green-400'
|
||||
: isReconnecting
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span className={
|
||||
isConnected
|
||||
? 'text-green-500'
|
||||
: isReconnecting
|
||||
? 'text-orange-500'
|
||||
: 'text-red-500'
|
||||
}>
|
||||
{isConnected
|
||||
? 'Gateway 已连接'
|
||||
: isReconnecting
|
||||
? '重连中...'
|
||||
: 'Gateway 未连接'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectionStatus;
|
||||
Reference in New Issue
Block a user