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:
iven
2026-03-16 09:56:25 +08:00
parent f9a3816e54
commit a312524abb
5 changed files with 1228 additions and 44 deletions

View 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;