| New file |
| | |
| | | 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 = 2000; |
| | | const PANEL_WIDTH = 480; |
| | | const PANEL_HEIGHT = 360; |
| | | const MIN_WIDTH = 320; |
| | | const MIN_HEIGHT = 240; |
| | | const EDGE_MARGIN = 16; |
| | | |
| | | 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 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 fetchLogs = useCallback(async () => { |
| | | const data = await Http.fetchNewsLogs(); |
| | | if (Array.isArray(data)) { |
| | | setLogs(data); |
| | | setLastUpdated(new Date()); |
| | | } |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | if (!open) { |
| | | clearInterval(pollingRef.current); |
| | | pollingRef.current = null; |
| | | setLogs([]); |
| | | setLastUpdated(null); |
| | | setPosition(null); |
| | | setSize(null); |
| | | return; |
| | | } |
| | | setSize(buildInitialSize()); |
| | | setAutoScroll(true); |
| | | fetchLogs(); |
| | | pollingRef.current = setInterval(fetchLogs, POLLING_INTERVAL); |
| | | return () => { |
| | | clearInterval(pollingRef.current); |
| | | pollingRef.current = null; |
| | | }; |
| | | }, [open, fetchLogs, buildInitialSize]); |
| | | |
| | | useEffect(() => { |
| | | if (!autoScroll || !scrollRef.current) { |
| | | return; |
| | | } |
| | | scrollRef.current.scrollTo({ |
| | | top: scrollRef.current.scrollHeight, |
| | | behavior: 'smooth', |
| | | }); |
| | | }, [logs, autoScroll]); |
| | | |
| | | const handleScroll = useCallback(() => { |
| | | if (!scrollRef.current || !autoScroll) { |
| | | return; |
| | | } |
| | | const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; |
| | | if (scrollHeight - (scrollTop + clientHeight) > 32) { |
| | | setAutoScroll(false); |
| | | } |
| | | }, [autoScroll]); |
| | | |
| | | const handleJumpLatest = () => { |
| | | setAutoScroll(true); |
| | | if (scrollRef.current) { |
| | | scrollRef.current.scrollTo({ |
| | | top: scrollRef.current.scrollHeight, |
| | | behavior: 'smooth', |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | const handleClose = () => { |
| | | onClose?.(); |
| | | }; |
| | | |
| | | 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={(event) => setAutoScroll(event.target.checked)} |
| | | /> |
| | | )} |
| | | 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', |
| | | px: 3, |
| | | py: 2, |
| | | 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={1.25}> |
| | | {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={{ |
| | | borderRadius: 1.5, |
| | | border: `1px solid ${theme.palette.divider}`, |
| | | backgroundColor: theme.palette.background.paper, |
| | | px: 2, |
| | | py: 1.25, |
| | | }} |
| | | > |
| | | <Stack direction="row" spacing={1} alignItems="center"> |
| | | <Chip |
| | | label={levelMeta.label} |
| | | size="small" |
| | | color={levelMeta.color} |
| | | variant="outlined" |
| | | /> |
| | | <Divider orientation="vertical" flexItem /> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {item?.t || '--'} |
| | | </Typography> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}> |
| | | {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; |