Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括: - 配置文件中的项目名称 - 代码注释和文档引用 - 环境变量和路径 - 类型定义和接口名称 - 测试用例和模拟数据 同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
287 lines
8.1 KiB
TypeScript
287 lines
8.1 KiB
TypeScript
/**
|
|
* ConnectionStatus Component
|
|
*
|
|
* Displays the current Gateway connection status with visual indicators.
|
|
* Supports automatic reconnect and manual reconnect button.
|
|
* Includes health status indicator for ZCLAW backend.
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Wifi, WifiOff, Loader2, RefreshCw, Heart, HeartPulse } from 'lucide-react';
|
|
import { useConnectionStore, getClient } from '../store/connectionStore';
|
|
import {
|
|
createHealthCheckScheduler,
|
|
getHealthStatusLabel,
|
|
formatHealthCheckTime,
|
|
type HealthCheckResult,
|
|
type HealthStatus,
|
|
} from '../lib/health-check';
|
|
|
|
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 = useConnectionStore((s) => s.connectionState);
|
|
const connect = useConnectionStore((s) => s.connect);
|
|
const [showPrompt, setShowPrompt] = useState(false);
|
|
const [reconnectInfo, setReconnectInfo] = useState<ReconnectInfo | null>(null);
|
|
|
|
// Listen for reconnect events
|
|
useEffect(() => {
|
|
const client = getClient();
|
|
|
|
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 = useConnectionStore((s) => s.connectionState);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* HealthStatusIndicator - Displays ZCLAW backend health status
|
|
*/
|
|
export function HealthStatusIndicator({
|
|
className = '',
|
|
showDetails = false,
|
|
}: {
|
|
className?: string;
|
|
showDetails?: boolean;
|
|
}) {
|
|
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Start periodic health checks
|
|
const cleanup = createHealthCheckScheduler((result) => {
|
|
setHealthResult(result);
|
|
}, 30000); // Check every 30 seconds
|
|
|
|
return cleanup;
|
|
}, []);
|
|
|
|
if (!healthResult) {
|
|
return (
|
|
<span className={`text-xs flex items-center gap-1 ${className}`}>
|
|
<Heart className="w-3.5 h-3.5 text-gray-400" />
|
|
<span className="text-gray-400">检查中...</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const statusColors: Record<HealthStatus, { dot: string; text: string; icon: typeof Heart }> = {
|
|
healthy: { dot: 'bg-green-400', text: 'text-green-500', icon: Heart },
|
|
unhealthy: { dot: 'bg-red-400', text: 'text-red-500', icon: HeartPulse },
|
|
unknown: { dot: 'bg-gray-400', text: 'text-gray-500', icon: Heart },
|
|
};
|
|
|
|
const config = statusColors[healthResult.status];
|
|
const Icon = config.icon;
|
|
|
|
return (
|
|
<span className={`text-xs flex items-center gap-1 ${className}`}>
|
|
<Icon className={`w-3.5 h-3.5 ${config.text}`} />
|
|
<span className={config.text}>
|
|
{getHealthStatusLabel(healthResult.status)}
|
|
</span>
|
|
{showDetails && healthResult.message && (
|
|
<span className="text-gray-400 ml-1" title={healthResult.message}>
|
|
({formatHealthCheckTime(healthResult.timestamp)})
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default ConnectionStatus;
|