import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
Box,
|
Button,
|
Chip,
|
Divider,
|
FormControlLabel,
|
IconButton,
|
Paper,
|
Stack,
|
Switch,
|
Typography,
|
useTheme,
|
} from '@mui/material';
|
import CloseIcon from '@mui/icons-material/Close';
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
import { useTranslate } from 'react-admin';
|
import * as Http from './http';
|
|
const LOG_LEVEL_META = {
|
1: { label: 'INFO', color: 'info' },
|
2: { label: 'WARN', color: 'warning' },
|
3: { label: 'ERROR', color: 'error' },
|
};
|
|
const POLLING_INTERVAL = 1000;
|
const PANEL_WIDTH = 600;
|
const PANEL_HEIGHT = 420;
|
const MIN_WIDTH = 320;
|
const MIN_HEIGHT = 240;
|
const EDGE_MARGIN = 16;
|
|
const MAX_VISIBLE_LOGS = 200;
|
const MAX_BUFFER_LOGS = 600;
|
|
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
const NewsLogDialog = ({ open, onClose }) => {
|
const translate = useTranslate();
|
const theme = useTheme();
|
const [logs, setLogs] = useState([]);
|
const [autoScroll, setAutoScroll] = useState(true);
|
const [lastUpdated, setLastUpdated] = useState(null);
|
const [position, setPosition] = useState(null);
|
const [size, setSize] = useState(null);
|
const scrollRef = useRef(null);
|
const panelRef = useRef(null);
|
const pollingRef = useRef(null);
|
const dragRef = useRef({ active: false, offsetX: 0, offsetY: 0 });
|
const programmaticScrollRef = useRef(false);
|
const initialScrollDoneRef = useRef(false);
|
const userScrollRef = useRef(false);
|
const autoScrollRef = useRef(true);
|
const nearBottomRef = useRef(true);
|
const pendingLogsRef = useRef(null);
|
const resizeRef = useRef({
|
active: false,
|
startX: 0,
|
startY: 0,
|
initialWidth: PANEL_WIDTH,
|
initialHeight: PANEL_HEIGHT,
|
initialX: EDGE_MARGIN,
|
initialY: EDGE_MARGIN,
|
});
|
const getSizeLimits = useCallback(() => {
|
const viewportWidth = typeof window === 'undefined' ? PANEL_WIDTH + 64 : window.innerWidth;
|
const viewportHeight = typeof window === 'undefined' ? PANEL_HEIGHT + 160 : window.innerHeight;
|
return {
|
maxWidth: Math.max(MIN_WIDTH, viewportWidth - 64),
|
maxHeight: Math.max(MIN_HEIGHT, viewportHeight - 160),
|
};
|
}, []);
|
|
const buildInitialSize = useCallback(() => {
|
const { maxWidth, maxHeight } = getSizeLimits();
|
return {
|
width: clamp(PANEL_WIDTH, MIN_WIDTH, maxWidth),
|
height: clamp(PANEL_HEIGHT, MIN_HEIGHT, maxHeight),
|
};
|
}, [getSizeLimits]);
|
|
const applyLogs = useCallback((payload, { programmatic = false } = {}) => {
|
if (!payload) {
|
return;
|
}
|
programmaticScrollRef.current = programmatic;
|
pendingLogsRef.current = null;
|
setLogs(payload.logs);
|
setLastUpdated(payload.timestamp);
|
}, []);
|
|
const flushPendingLogs = useCallback((options = {}) => {
|
if (!pendingLogsRef.current) {
|
return false;
|
}
|
applyLogs(pendingLogsRef.current, options);
|
return true;
|
}, [applyLogs]);
|
|
const computeNearBottom = useCallback(() => {
|
const container = scrollRef.current;
|
if (!container) {
|
return true;
|
}
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
return scrollHeight - (scrollTop + clientHeight) < 32;
|
}, []);
|
|
const fetchLogs = useCallback(async () => {
|
const data = await Http.fetchNewsLogs();
|
if (Array.isArray(data)) {
|
let nextLogs = data;
|
if (data.length > MAX_VISIBLE_LOGS) {
|
const shouldStickToBottom = autoScrollRef.current && nearBottomRef.current;
|
const limit = shouldStickToBottom
|
? MAX_VISIBLE_LOGS
|
: Math.min(MAX_BUFFER_LOGS, data.length);
|
nextLogs = data.slice(data.length - limit);
|
}
|
const payload = { logs: nextLogs, timestamp: new Date() };
|
if (autoScrollRef.current && nearBottomRef.current) {
|
applyLogs(payload, { programmatic: true });
|
} else {
|
pendingLogsRef.current = payload;
|
}
|
}
|
}, [applyLogs]);
|
|
useEffect(() => {
|
if (!open) {
|
clearInterval(pollingRef.current);
|
pollingRef.current = null;
|
setLogs([]);
|
setLastUpdated(null);
|
setPosition(null);
|
setSize(null);
|
initialScrollDoneRef.current = false;
|
nearBottomRef.current = true;
|
pendingLogsRef.current = null;
|
return;
|
}
|
setSize(buildInitialSize());
|
setAutoScroll(true);
|
userScrollRef.current = false;
|
programmaticScrollRef.current = true;
|
initialScrollDoneRef.current = false;
|
nearBottomRef.current = true;
|
pendingLogsRef.current = null;
|
fetchLogs();
|
pollingRef.current = setInterval(fetchLogs, POLLING_INTERVAL);
|
return () => {
|
clearInterval(pollingRef.current);
|
pollingRef.current = null;
|
};
|
}, [open, fetchLogs, buildInitialSize]);
|
|
const scrollToBottom = useCallback((behavior = 'smooth', attempts = 3) => {
|
if (!scrollRef.current) {
|
return;
|
}
|
programmaticScrollRef.current = true;
|
nearBottomRef.current = true;
|
const step = (remaining) => {
|
if (!scrollRef.current) {
|
return;
|
}
|
scrollRef.current.scrollTo({
|
top: scrollRef.current.scrollHeight,
|
behavior,
|
});
|
if (remaining > 0) {
|
requestAnimationFrame(() => step(remaining - 1));
|
}
|
};
|
requestAnimationFrame(() => step(attempts));
|
}, []);
|
|
useEffect(() => {
|
if (!autoScroll || userScrollRef.current) {
|
return;
|
}
|
if (!logs.length) {
|
return;
|
}
|
const behavior = initialScrollDoneRef.current ? 'smooth' : 'auto';
|
const attempts = initialScrollDoneRef.current ? 1 : 5;
|
scrollToBottom(behavior, attempts);
|
initialScrollDoneRef.current = true;
|
}, [logs, autoScroll, scrollToBottom]);
|
|
useEffect(() => {
|
autoScrollRef.current = autoScroll;
|
}, [autoScroll]);
|
|
const handleScroll = useCallback(() => {
|
if (!scrollRef.current) {
|
return;
|
}
|
if (programmaticScrollRef.current) {
|
programmaticScrollRef.current = false;
|
nearBottomRef.current = computeNearBottom();
|
return;
|
}
|
const isNearBottom = computeNearBottom();
|
nearBottomRef.current = isNearBottom;
|
if (!autoScroll) {
|
userScrollRef.current = !isNearBottom;
|
return;
|
}
|
if (isNearBottom) {
|
if (userScrollRef.current) {
|
userScrollRef.current = false;
|
}
|
} else {
|
userScrollRef.current = true;
|
}
|
}, [autoScroll, computeNearBottom]);
|
|
const handleJumpLatest = () => {
|
flushPendingLogs({ programmatic: true });
|
userScrollRef.current = false;
|
setAutoScroll(true);
|
scrollToBottom('smooth', 2);
|
initialScrollDoneRef.current = true;
|
};
|
|
const handleClose = () => {
|
onClose?.();
|
};
|
|
const handleAutoScrollChange = (event) => {
|
const { checked } = event.target;
|
setAutoScroll(checked);
|
if (checked) {
|
flushPendingLogs({ programmatic: true });
|
userScrollRef.current = false;
|
initialScrollDoneRef.current = false;
|
nearBottomRef.current = true;
|
scrollToBottom('auto', 5);
|
} else {
|
userScrollRef.current = true;
|
}
|
};
|
|
const handleDragging = useCallback((event) => {
|
if (!dragRef.current.active) {
|
return;
|
}
|
event.preventDefault();
|
const width = panelRef.current?.offsetWidth || PANEL_WIDTH;
|
const height = panelRef.current?.offsetHeight || PANEL_HEIGHT;
|
const viewportWidth = typeof window === 'undefined' ? width + 2 * EDGE_MARGIN : window.innerWidth;
|
const viewportHeight = typeof window === 'undefined' ? height + 10 * EDGE_MARGIN : window.innerHeight;
|
const maxX = viewportWidth - width - EDGE_MARGIN;
|
const maxY = viewportHeight - height - EDGE_MARGIN;
|
const x = event.clientX - dragRef.current.offsetX;
|
const y = event.clientY - dragRef.current.offsetY;
|
setPosition({
|
x: clamp(x, EDGE_MARGIN, Math.max(maxX, EDGE_MARGIN)),
|
y: clamp(y, EDGE_MARGIN, Math.max(maxY, EDGE_MARGIN)),
|
});
|
}, []);
|
|
const handleDragEnd = useCallback(() => {
|
if (!dragRef.current.active) {
|
return;
|
}
|
dragRef.current.active = false;
|
document.removeEventListener('mousemove', handleDragging);
|
document.removeEventListener('mouseup', handleDragEnd);
|
}, [handleDragging]);
|
|
const handleDragStart = useCallback((event) => {
|
if (!panelRef.current) {
|
return;
|
}
|
const rect = panelRef.current.getBoundingClientRect();
|
dragRef.current = {
|
active: true,
|
offsetX: event.clientX - rect.left,
|
offsetY: event.clientY - rect.top,
|
};
|
document.addEventListener('mousemove', handleDragging);
|
document.addEventListener('mouseup', handleDragEnd);
|
}, [handleDragging, handleDragEnd]);
|
|
const handleResizing = useCallback((event) => {
|
if (!resizeRef.current.active) {
|
return;
|
}
|
event.preventDefault();
|
const deltaX = event.clientX - resizeRef.current.startX;
|
const deltaY = event.clientY - resizeRef.current.startY;
|
const { maxWidth, maxHeight } = getSizeLimits();
|
const initialWidth = resizeRef.current.initialWidth;
|
const initialHeight = resizeRef.current.initialHeight;
|
const initialX = resizeRef.current.initialX;
|
const initialY = resizeRef.current.initialY;
|
const initialRight = initialX + initialWidth;
|
const maxWidthAllowed = Math.min(maxWidth, initialRight - EDGE_MARGIN);
|
const widthRaw = initialWidth - deltaX;
|
const newWidth = clamp(widthRaw, MIN_WIDTH, Math.max(MIN_WIDTH, maxWidthAllowed));
|
const newX = clamp(initialRight - newWidth, EDGE_MARGIN, Math.max(initialRight - MIN_WIDTH, EDGE_MARGIN));
|
const newHeight = clamp(initialHeight + deltaY, MIN_HEIGHT, maxHeight);
|
setSize({
|
width: newWidth,
|
height: newHeight,
|
});
|
setPosition({
|
x: newX,
|
y: initialY,
|
});
|
}, [getSizeLimits]);
|
|
const handleResizeEnd = useCallback(() => {
|
if (!resizeRef.current.active) {
|
return;
|
}
|
resizeRef.current.active = false;
|
document.removeEventListener('mousemove', handleResizing);
|
document.removeEventListener('mouseup', handleResizeEnd);
|
}, [handleResizing]);
|
|
const handleResizeStart = useCallback((event) => {
|
event.preventDefault();
|
resizeRef.current.active = true;
|
resizeRef.current.startX = event.clientX;
|
resizeRef.current.startY = event.clientY;
|
const rect = panelRef.current?.getBoundingClientRect();
|
const resolvedX = position?.x ?? rect?.left ?? EDGE_MARGIN;
|
const resolvedY = position?.y ?? rect?.top ?? EDGE_MARGIN;
|
resizeRef.current.initialWidth = panelRef.current?.offsetWidth || size?.width || PANEL_WIDTH;
|
resizeRef.current.initialHeight = panelRef.current?.offsetHeight || size?.height || PANEL_HEIGHT;
|
resizeRef.current.initialX = resolvedX;
|
resizeRef.current.initialY = resolvedY;
|
setPosition({ x: resolvedX, y: resolvedY });
|
document.addEventListener('mousemove', handleResizing);
|
document.addEventListener('mouseup', handleResizeEnd);
|
}, [handleResizing, handleResizeEnd, size, position]);
|
|
useEffect(() => {
|
return () => {
|
document.removeEventListener('mousemove', handleDragging);
|
document.removeEventListener('mouseup', handleDragEnd);
|
document.removeEventListener('mousemove', handleResizing);
|
document.removeEventListener('mouseup', handleResizeEnd);
|
};
|
}, [handleDragging, handleDragEnd, handleResizing, handleResizeEnd]);
|
|
const timeLabel = useMemo(() => {
|
if (!lastUpdated) {
|
return translate('page.map.monitor.log.lastUpdate.empty', { _: 'No Updates' });
|
}
|
return lastUpdated.toLocaleTimeString();
|
}, [lastUpdated, translate]);
|
|
const panelPositionStyle = position
|
? { top: position.y, left: position.x }
|
: { top: 140, right: 24 };
|
|
if (!open) {
|
return null;
|
}
|
|
const panelWidth = size?.width ?? PANEL_WIDTH;
|
const panelHeight = size?.height ?? PANEL_HEIGHT;
|
|
return (
|
<Box
|
sx={{
|
position: 'fixed',
|
zIndex: theme.zIndex.drawer + 10,
|
pointerEvents: 'none',
|
...panelPositionStyle,
|
}}
|
>
|
<Paper
|
ref={panelRef}
|
elevation={16}
|
sx={{
|
width: panelWidth,
|
height: panelHeight,
|
minWidth: MIN_WIDTH,
|
minHeight: MIN_HEIGHT,
|
display: 'flex',
|
flexDirection: 'column',
|
borderRadius: 2,
|
overflow: 'hidden',
|
pointerEvents: 'auto',
|
backdropFilter: 'blur(4px)',
|
position: 'relative',
|
}}
|
>
|
<Box
|
sx={{
|
display: 'flex',
|
alignItems: 'center',
|
padding: '12px 16px',
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
cursor: 'move',
|
background: theme.palette.mode === 'light'
|
? theme.palette.grey[50]
|
: theme.palette.grey[900],
|
}}
|
onMouseDown={handleDragStart}
|
>
|
<Stack direction="row" spacing={1} alignItems="center">
|
<PushPinIcon fontSize="small" color="primary" />
|
<Typography variant="subtitle1" fontWeight={600}>
|
{translate('page.map.monitor.log.title', { _: 'Real-time Logs' })}
|
</Typography>
|
</Stack>
|
<Box sx={{ flexGrow: 1 }} />
|
<FormControlLabel
|
sx={{ mr: 2, userSelect: 'none' }}
|
control={(
|
<Switch
|
size="small"
|
checked={autoScroll}
|
onChange={handleAutoScrollChange}
|
/>
|
)}
|
label={translate('page.map.monitor.log.autoScroll', { _: 'Auto Scroll' })}
|
/>
|
<Chip
|
label={timeLabel}
|
size="small"
|
color="primary"
|
variant="outlined"
|
sx={{ mr: 1, fontWeight: 500 }}
|
/>
|
<IconButton size="small" onClick={handleClose}>
|
<CloseIcon fontSize="small" />
|
</IconButton>
|
</Box>
|
<Box
|
ref={scrollRef}
|
onScroll={handleScroll}
|
sx={{
|
flex: 1,
|
minHeight: 0,
|
overflowY: 'auto',
|
overflowX: 'auto',
|
px: 0,
|
py: 0,
|
backgroundColor: theme.palette.background.default,
|
}}
|
>
|
{logs.length === 0 ? (
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 6 }}>
|
{translate('page.map.monitor.log.empty', { _: 'No Logs' })}
|
</Typography>
|
) : (
|
<Stack
|
spacing={0}
|
divider={<Divider sx={{ borderColor: theme.palette.divider, opacity: 0.8 }} />}
|
sx={{ minWidth: '100%', width: 'fit-content' }}
|
>
|
{logs.map((item, index) => {
|
const level = item?.l;
|
const levelMeta = LOG_LEVEL_META[level] || LOG_LEVEL_META[1];
|
return (
|
<Box
|
key={`${item?.t}-${index}`}
|
sx={{
|
px: 2,
|
py: 1.25,
|
width: '100%',
|
backgroundColor: index % 2 === 0
|
? theme.palette.background.paper
|
: theme.palette.background.default,
|
}}
|
>
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
<Typography
|
variant="caption"
|
sx={{
|
fontWeight: 700,
|
letterSpacing: 0.6,
|
textTransform: 'uppercase',
|
color: theme.palette[levelMeta.color]?.main || theme.palette.text.secondary,
|
}}
|
>
|
{levelMeta.label}
|
</Typography>
|
<Typography
|
variant="caption"
|
color="text.secondary"
|
sx={{ fontFamily: 'SFMono-Regular, Menlo, monospace', letterSpacing: 0.3 }}
|
>
|
{item?.t || '--'}
|
</Typography>
|
</Stack>
|
<Typography
|
variant="body2"
|
sx={{
|
mt: 0.5,
|
whiteSpace: 'pre-wrap',
|
fontSize: 13,
|
lineHeight: 1.5,
|
color: theme.palette.text.primary,
|
}}
|
>
|
{item?.v || ''}
|
</Typography>
|
</Box>
|
);
|
})}
|
</Stack>
|
)}
|
</Box>
|
<Divider />
|
<Box
|
sx={{
|
display: 'flex',
|
alignItems: 'center',
|
justifyContent: 'space-between',
|
py: 1,
|
px: 2,
|
backgroundColor: theme.palette.background.paper,
|
}}
|
>
|
{!autoScroll && logs.length > 0 ? (
|
<Button onClick={handleJumpLatest} size="small">
|
{translate('page.map.monitor.log.jumpLatest', { _: 'Jump to latest' })}
|
</Button>
|
) : (
|
<span />
|
)}
|
<Button onClick={handleClose} size="small">
|
{translate('ra.action.close', { _: 'ra.action.close' })}
|
</Button>
|
</Box>
|
<Box
|
onMouseDown={handleResizeStart}
|
sx={{
|
position: 'absolute',
|
width: 18,
|
height: 18,
|
left: 6,
|
bottom: 6,
|
cursor: 'nesw-resize',
|
borderLeft: `2px solid ${theme.palette.divider}`,
|
borderBottom: `2px solid ${theme.palette.divider}`,
|
borderBottomLeftRadius: 2,
|
}}
|
/>
|
</Paper>
|
</Box>
|
);
|
};
|
|
export default NewsLogDialog;
|