171 lines
7.2 KiB
TypeScript
171 lines
7.2 KiB
TypeScript
/**
|
|
* 멀티차트 WebSocket 연결 상태 표시
|
|
* - 클릭 시 연결 목록 및 수신 상태 팝업
|
|
* - MultiChartMarketDataProvider 내부에서 사용
|
|
*/
|
|
import React from 'react';
|
|
import { Box, Popover, LinearProgress, Typography, Tooltip, Button } from '@mui/material';
|
|
import { useMultiChartMarketData } from '../../contexts/MultiChartMarketDataContext';
|
|
import type { ConnectionStatus } from '../../hooks/useWebSocketConnectionStatus';
|
|
|
|
const WS_STATUS_CONFIG: Record<ConnectionStatus, { label: string; color: string; bgColor: string }> = {
|
|
connected: { label: '실시간 연결됨', color: 'success.main', bgColor: 'success.main' },
|
|
connecting: { label: '연결 중...', color: 'warning.main', bgColor: 'warning.main' },
|
|
disconnected: { label: '연결 끊김', color: 'text.secondary', bgColor: 'grey.400' },
|
|
error: { label: '연결 오류', color: 'error.main', bgColor: 'error.main' },
|
|
};
|
|
|
|
export const MultiChartWebSocketStatus: React.FC = () => {
|
|
const ctx = useMultiChartMarketData();
|
|
const status = ctx?.status ?? 'disconnected';
|
|
const connectionList = ctx?.connectionList ?? [];
|
|
const [popoverAnchor, setPopoverAnchor] = React.useState<HTMLElement | null>(null);
|
|
const [, setProgressTick] = React.useState(0);
|
|
const [dataReceivedBlink, setDataReceivedBlink] = React.useState(false); // ✅ 데이터 수신 깜빡임
|
|
const lastReceivedTimestampRef = React.useRef<number>(0);
|
|
|
|
React.useEffect(() => {
|
|
if (!popoverAnchor) return;
|
|
const id = setInterval(() => setProgressTick((t) => t + 1), 1000);
|
|
return () => clearInterval(id);
|
|
}, [popoverAnchor]);
|
|
|
|
// ✅ connectionList의 lastReceivedAt 변화 감지 → 깜빡임 트리거
|
|
React.useEffect(() => {
|
|
if (connectionList.length === 0) return;
|
|
|
|
const latestReceived = Math.max(...connectionList.map(c => c.lastReceivedAt));
|
|
|
|
if (latestReceived > 0 && latestReceived !== lastReceivedTimestampRef.current) {
|
|
lastReceivedTimestampRef.current = latestReceived;
|
|
setDataReceivedBlink(true);
|
|
setTimeout(() => setDataReceivedBlink(false), 300); // 300ms 후 자동 꺼짐
|
|
}
|
|
}, [connectionList]);
|
|
|
|
const config = WS_STATUS_CONFIG[status as ConnectionStatus] ?? WS_STATUS_CONFIG.disconnected;
|
|
|
|
const getReceiveProgress = (lastReceivedAt: number) => {
|
|
if (!lastReceivedAt) return 0;
|
|
const elapsed = (Date.now() - lastReceivedAt) / 1000;
|
|
if (elapsed <= 5) return 100;
|
|
if (elapsed >= 30) return 0;
|
|
return Math.round(100 - ((elapsed - 5) / 25) * 100);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Tooltip title={`WebSocket: ${config.label} (클릭하여 연결 목록 보기)`}>
|
|
<Box
|
|
onClick={(e) => setPopoverAnchor(e.currentTarget)}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 0.75,
|
|
px: 1,
|
|
py: 0.5,
|
|
borderRadius: 1,
|
|
bgcolor: 'action.hover',
|
|
fontSize: '0.75rem',
|
|
color: config.color,
|
|
cursor: 'pointer',
|
|
'&:hover': { bgcolor: 'action.selected' },
|
|
// ✅ 데이터 수신 시 연두색 형광 깜빡임
|
|
...(dataReceivedBlink && status === 'connected' ? {
|
|
boxShadow: '0 0 10px 3px rgba(204, 255, 0, 0.7), inset 0 0 8px 2px rgba(204, 255, 0, 0.3)',
|
|
transition: 'box-shadow 0.15s ease-out',
|
|
} : {}),
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: '50%',
|
|
bgcolor: config.bgColor,
|
|
...(status === 'connecting' && {
|
|
animation: 'pulse 1.5s ease-in-out infinite',
|
|
'@keyframes pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.4 } },
|
|
}),
|
|
}}
|
|
/>
|
|
{config.label}
|
|
</Box>
|
|
</Tooltip>
|
|
<Popover
|
|
open={Boolean(popoverAnchor)}
|
|
anchorEl={popoverAnchor}
|
|
onClose={() => setPopoverAnchor(null)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 280 } } }}
|
|
>
|
|
<Box sx={{ p: 1.5 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
|
WebSocket 연결 목록
|
|
</Typography>
|
|
{connectionList.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">
|
|
구독 중인 차트가 없습니다.
|
|
</Typography>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
{connectionList.map((item) => {
|
|
const progress = getReceiveProgress(item.lastReceivedAt);
|
|
const hasNoData = item.candleCount === 0;
|
|
const displayName = item.koreanName || item.market;
|
|
return (
|
|
<Box key={`${item.market}:${item.timeframe}`} sx={{ minWidth: 240 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.25 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
|
{displayName}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{item.timeframe} · 캔들 {item.candleCount}개
|
|
</Typography>
|
|
</Box>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={progress}
|
|
sx={{
|
|
height: 6,
|
|
borderRadius: 1,
|
|
bgcolor: 'action.hover',
|
|
'& .MuiLinearProgress-bar': {
|
|
bgcolor: progress >= 80 ? 'success.main' : progress >= 40 ? 'warning.main' : 'grey.400',
|
|
},
|
|
}}
|
|
/>
|
|
{hasNoData && (
|
|
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
|
|
데이터 미수신: Redis 수집 대기, 거래량 부족, 또는 해당 봉 데이터 미생성 가능
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1.5, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
|
※ 캔들 0개: market-data 서비스에서 해당 종목 수집 중이거나, 해당 타임프레임 데이터가 아직 생성되지 않았을 수 있습니다.
|
|
</Typography>
|
|
{(status === 'disconnected' || status === 'error') && ctx?.reconnect && (
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
fullWidth
|
|
sx={{ mt: 1 }}
|
|
onClick={() => {
|
|
ctx.reconnect();
|
|
setPopoverAnchor(null);
|
|
}}
|
|
>
|
|
재연결
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Popover>
|
|
</>
|
|
);
|
|
};
|