diff --git a/web-server/src/components/Service/SystemLog/FormattedLog.tsx b/web-server/src/components/Service/SystemLog/FormattedLog.tsx index d327e969..91a735b6 100644 --- a/web-server/src/components/Service/SystemLog/FormattedLog.tsx +++ b/web-server/src/components/Service/SystemLog/FormattedLog.tsx @@ -1,10 +1,71 @@ -import { useTheme } from '@mui/material'; +import { useTheme, styled } from '@mui/material'; import { useCallback } from 'react'; import { Line } from '@/components/Text'; import { ParsedLog } from '@/types/resources'; -export const FormattedLog = ({ log }: { log: ParsedLog; index: number }) => { +// Styled component for highlighted text +const HighlightSpan = styled('span', { + shouldForwardProp: (prop) => prop !== 'isCurrentMatch' +})<{ isCurrentMatch?: boolean }>(({ theme, isCurrentMatch }) => ({ + backgroundColor: isCurrentMatch ? theme.palette.warning.main : 'yellow', + color: isCurrentMatch ? 'white' : 'black', + transition: theme.transitions.create(['background-color', 'color'], { + duration: theme.transitions.duration.shortest + }) +})); + +interface FormattedLogProps { + log: ParsedLog; + index: number; + searchQuery?: string; + isCurrentMatch?: boolean; +} + +type SearchHighlightTextProps = { + text: string; + searchQuery?: string; + isCurrentMatch?: boolean; +}; + +export const SearchHighlightText = ({ + text, + searchQuery, + isCurrentMatch +}: SearchHighlightTextProps) => { + if (!searchQuery) return <>{text}>; + + const escapeRegExp = (string: string) => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const safeQuery = escapeRegExp(searchQuery); + const regex = new RegExp(`(${safeQuery})`, 'gi'); + + const parts = text.split(regex); + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === searchQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + )} + > + ); +}; + +export const FormattedLog = ({ + log, + searchQuery, + isCurrentMatch +}: FormattedLogProps) => { const theme = useTheme(); const getLevelColor = useCallback( (level: string) => { @@ -36,17 +97,35 @@ export const FormattedLog = ({ log }: { log: ParsedLog; index: number }) => { return ( - {timestamp} + {' '} {ip && ( - {ip}{' '} + {' '} )} - [{logLevel}] + [ + + ] {' '} - {message} + ); }; diff --git a/web-server/src/components/Service/SystemLog/LogSearch.tsx b/web-server/src/components/Service/SystemLog/LogSearch.tsx new file mode 100644 index 00000000..912d3171 --- /dev/null +++ b/web-server/src/components/Service/SystemLog/LogSearch.tsx @@ -0,0 +1,167 @@ +import { + Search as SearchIcon, + Clear as ClearIcon, + NavigateNext, + NavigateBefore +} from '@mui/icons-material'; +import { + Button, + InputAdornment, + TextField, + Typography, + Box +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { memo, useState, useCallback, useMemo } from 'react'; + +import { MotionBox } from '@/components/MotionComponents'; +import { debounce } from '@/utils/debounce'; + +const SearchContainer = styled('div')(() => ({ + position: 'sticky', + top: 0, + zIndex: 1, + gap: 5, + paddingBottom: 8, + alignItems: 'center', + backdropFilter: 'blur(10px)', + borderRadius: 5 +})); + +const SearchControls = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginTop: 8 +})); + +const StyledTextField = styled(TextField)(({ theme }) => ({ + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.background.paper, + transition: 'all 0.2s ease-in-out', + '&:hover': { + backgroundColor: theme.palette.background.paper, + boxShadow: `0 0 0 1px ${theme.palette.primary.main}` + }, + '&.Mui-focused': { + backgroundColor: theme.palette.background.paper, + boxShadow: `0 0 0 2px ${theme.palette.primary.main}` + } + } +})); + +interface LogSearchProps { + onSearch: (query: string) => void; + onNavigate: (direction: 'prev' | 'next') => void; + currentMatch: number; + totalMatches: number; +} + +const LogSearch = memo( + ({ onSearch, onNavigate, currentMatch, totalMatches }: LogSearchProps) => { + const [searchQuery, setSearchQuery] = useState(''); + + const debouncedSearch = useMemo( + () => + debounce((query: string) => { + onSearch(query); + }, 300), + [onSearch] + ); + + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + const query = event.target.value; + setSearchQuery(query); + debouncedSearch(query); + }, + [debouncedSearch] + ); + + const handleClear = useCallback(() => { + setSearchQuery(''); + onSearch(''); + }, [onSearch]); + + const handleNavigate = useCallback( + (direction: 'prev' | 'next') => { + if (direction === 'next') { + onNavigate('next'); + } else { + onNavigate('prev'); + } + }, + [onNavigate] + ); + + const showSearchControls = searchQuery && totalMatches > 0; + + return ( + + + + + ), + endAdornment: !!searchQuery.length && ( + + + + ) + }} + /> + {showSearchControls && ( + + + handleNavigate('prev')} + startIcon={} + sx={{ + minWidth: '20px', + padding: '4px 8px' + }} + /> + + {currentMatch} of {totalMatches} + + handleNavigate('next')} + startIcon={} + sx={{ + minWidth: '20px', + padding: '4px 8px' + }} + /> + + + )} + + ); + } +); + +LogSearch.displayName = 'LogSearch'; + +export { LogSearch }; diff --git a/web-server/src/components/Service/SystemLog/PlainLog.tsx b/web-server/src/components/Service/SystemLog/PlainLog.tsx index c777f62a..7ebe4e72 100644 --- a/web-server/src/components/Service/SystemLog/PlainLog.tsx +++ b/web-server/src/components/Service/SystemLog/PlainLog.tsx @@ -1,9 +1,26 @@ import { Line } from '@/components/Text'; -export const PlainLog = ({ log }: { log: string; index: number }) => { +import { SearchHighlightText } from './FormattedLog'; + +interface PlainLogProps { + log: string; + index: number; + searchQuery?: string; + isCurrentMatch?: boolean; +} + +export const PlainLog = ({ + log, + searchQuery, + isCurrentMatch +}: PlainLogProps) => { return ( - {log} + ); }; diff --git a/web-server/src/content/Service/SystemLogs.tsx b/web-server/src/content/Service/SystemLogs.tsx index c0dc21c9..e230bd06 100644 --- a/web-server/src/content/Service/SystemLogs.tsx +++ b/web-server/src/content/Service/SystemLogs.tsx @@ -1,13 +1,20 @@ import { ExpandCircleDown } from '@mui/icons-material'; import { Button, CircularProgress } from '@mui/material'; -import { useEffect, useRef } from 'react'; +import { + useEffect, + useRef, + useCallback, + useReducer, + memo, + useState +} from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { FlexBox } from '@/components/FlexBox'; import { FormattedLog } from '@/components/Service/SystemLog/FormattedLog'; +import { LogSearch } from '@/components/Service/SystemLog/LogSearch'; import { PlainLog } from '@/components/Service/SystemLog/PlainLog'; import { SystemLogsErrorFallback } from '@/components/Service/SystemLog/SystemLogsErrorFllback'; -import { SomethingWentWrong } from '@/components/SomethingWentWrong/SomethingWentWrong'; import { Line } from '@/components/Text'; import { ServiceNames } from '@/constants/service'; import { useBoolState } from '@/hooks/useEasyState'; @@ -16,82 +23,251 @@ import { parseLogLine } from '@/utils/logFormatter'; import { MotionBox } from '../../components/MotionComponents'; -export const SystemLogs = ({ serviceName }: { serviceName?: ServiceNames }) => { +type SearchState = { + query: string; + elements: HTMLElement[]; + selectedIndex: number | null; + isSearching: boolean; +}; + +type SearchAction = + | { type: 'SET_QUERY'; payload: string } + | { type: 'SET_MATCHES'; payload: HTMLElement[] } + | { type: 'SET_SELECTED_INDEX'; payload: number | null } + | { type: 'SET_SEARCHING'; payload: boolean } + | { type: 'RESET' }; + +const initialSearchState: SearchState = { + query: '', + elements: [], + selectedIndex: null, + isSearching: false +}; + +function searchReducer(state: SearchState, action: SearchAction): SearchState { + switch (action.type) { + case 'SET_QUERY': + return { + ...state, + query: action.payload, + isSearching: !!action.payload + }; + case 'SET_MATCHES': { + const newElements = action.payload; + return { + ...state, + elements: newElements, + selectedIndex: newElements.length > 0 ? 0 : null, + isSearching: false + }; + } + case 'SET_SELECTED_INDEX': + return { ...state, selectedIndex: action.payload }; + case 'SET_SEARCHING': + return { ...state, isSearching: action.payload }; + case 'RESET': + return initialSearchState; + default: + return state; + } +} + +const SystemLogs = memo(({ serviceName }: { serviceName?: ServiceNames }) => { const { services, loading, logs } = useSystemLogs({ serviceName }); - const showScrollDownButton = useBoolState(false); const containerRef = useRef(null); - - const scrollDown = () => { - containerRef.current.scrollIntoView({ behavior: 'smooth' }); - }; + const showScrollDownButton = useBoolState(false); + const [searchState, dispatch] = useReducer(searchReducer, initialSearchState); + const isInitialLoad = useRef(true); + const [currentMatchLineIndex, setCurrentMatchLineIndex] = useState< + number | null + >(null); useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - showScrollDownButton.false(); - } else { - showScrollDownButton.true(); - } - }); - }, - { - threshold: 0 - } - ); + if (!searchState.query || searchState.query.length < 3) { + dispatch({ type: 'SET_MATCHES', payload: [] }); + setCurrentMatchLineIndex(null); + return; + } + + dispatch({ type: 'SET_SEARCHING', payload: true }); + + const timer = setTimeout(() => { + requestAnimationFrame(() => { + const elements = Array.from( + containerRef.current?.querySelectorAll('.mhq--highlighted-log') ?? [] + ) as HTMLElement[]; + + dispatch({ type: 'SET_MATCHES', payload: elements }); + }); + }, 300); - const containerElement = containerRef.current; + return () => clearTimeout(timer); + }, [searchState.query]); + const scrollToBottom = useCallback(() => { if (containerRef.current) { - observer.observe(containerElement); + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: 'smooth' + }); } + }, []); - return () => { - if (containerElement) { - observer.unobserve(containerElement); + const handleSearch = useCallback((query: string) => { + dispatch({ type: 'SET_QUERY', payload: query }); + }, []); + + const handleNavigate = useCallback( + (direction: 'prev' | 'next') => { + const { elements, selectedIndex, isSearching } = searchState; + const total = elements.length; + + if (total === 0 || isSearching || selectedIndex === null) return; + + if (direction === 'next') { + const newIndex = (selectedIndex + 1) % total; + dispatch({ type: 'SET_SELECTED_INDEX', payload: newIndex }); + } else { + const newIndex = selectedIndex > 0 ? selectedIndex - 1 : total - 1; + dispatch({ type: 'SET_SELECTED_INDEX', payload: newIndex }); } - }; - }, [showScrollDownButton]); + }, + [searchState] + ); + + const handleLogSelect = useCallback( + (lineIndex: number) => { + setCurrentMatchLineIndex(lineIndex); + + requestAnimationFrame(() => { + const elements = searchState.elements; + if (elements[searchState.selectedIndex || 0]) { + elements[searchState.selectedIndex || 0].scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } + }); + }, + [searchState.elements, searchState.selectedIndex] + ); useEffect(() => { - if (containerRef.current && !showScrollDownButton.value) { - scrollDown(); + const { selectedIndex, elements } = searchState; + if (selectedIndex === null || !elements.length) { + setCurrentMatchLineIndex(null); + return; } - }, [logs, showScrollDownButton.value]); - if (!serviceName) - return ( - + const element = elements[selectedIndex]; + if (!element) return; + + const lineIndex = parseInt( + element.closest('[data-log-index]')?.getAttribute('data-log-index') || + '-1', + 10 ); + if (lineIndex >= 0) { + handleLogSelect(lineIndex); + } + }, [searchState.selectedIndex, searchState.elements, handleLogSelect]); + + useEffect(() => { + if ( + !loading && + logs.length && + containerRef.current && + isInitialLoad.current + ) { + isInitialLoad.current = false; + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: 'auto' + }); + } + }); + } + }, [loading, logs]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + const isScrolledUp = + container.scrollTop < + container.scrollHeight - container.clientHeight - 100; + showScrollDownButton.set(isScrolledUp); + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [showScrollDownButton]); + + const renderLogs = useCallback(() => { + return logs.map((log, index) => { + const parsedLog = parseLogLine(log); + const isCurrentMatch = index === currentMatchLineIndex; + + return ( + handleLogSelect(index)} + > + {!parsedLog ? ( + + ) : ( + + )} + + ); + }); + }, [logs, searchState.query, currentMatchLineIndex, handleLogSelect]); return ( ( + FallbackComponent={({ error }: { error: Error }) => ( )} > + {loading ? ( Loading... ) : ( - services && - logs.map((log, index) => { - const parsedLog = parseLogLine(log); - if (!parsedLog) { - return ; - } - return ; - }) + + {services && renderLogs()} + )} - {showScrollDownButton.value && ( { ); -}; +}); + +export { SystemLogs }; diff --git a/web-server/src/hooks/useSystemLogs.tsx b/web-server/src/hooks/useSystemLogs.tsx index 0c284968..64163943 100644 --- a/web-server/src/hooks/useSystemLogs.tsx +++ b/web-server/src/hooks/useSystemLogs.tsx @@ -2,10 +2,11 @@ import { useMemo } from 'react'; import { ServiceNames } from '@/constants/service'; import { useSelector } from '@/store'; + export const useSystemLogs = ({ serviceName }: { - serviceName?: ServiceNames; + serviceName: ServiceNames; }) => { const services = useSelector((state) => state.service.services); const loading = useSelector((state) => state.service.loading);