Skip to content

Commit 005aba7

Browse files
authored
feat: Implemented log search functionality with highlighted results (#647)
* feat: Implemented log search functionality with highlighted results * Fixing coderabbitai comments * added improvements and fixed errors * Added debounce and useMemo to reduce my interaction time spent on text field and improve INP * Added memo to prevent re-render in the list and optimise FPS drops and performance * Added debounded method to improve performance and optimised code * Added circular navigation allowing users to navigate in a loop * Resolved comments and pushed a fix * Resolved comments * Resolved comments and improved code structure and robustness * minor fix * Added a fix for failing lint errors * Addressed all comments added and resolved them * Fixed Eslint check and resolved failing tests * Fixed dom travesal and implemented storing of indices * Improved log rendering and added debounce and smooth scroll implementations * eslint fix
1 parent cb2314a commit 005aba7

File tree

5 files changed

+592
-93
lines changed

5 files changed

+592
-93
lines changed

web-server/src/components/Service/SystemLog/FormattedLog.tsx

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,71 @@
1-
import { useTheme } from '@mui/material';
1+
import { useTheme, styled } from '@mui/material';
22
import { useCallback } from 'react';
33

44
import { Line } from '@/components/Text';
55
import { ParsedLog } from '@/types/resources';
66

7-
export const FormattedLog = ({ log }: { log: ParsedLog; index: number }) => {
7+
// Styled component for highlighted text
8+
const HighlightSpan = styled('span', {
9+
shouldForwardProp: (prop) => prop !== 'isCurrentMatch'
10+
})<{ isCurrentMatch?: boolean }>(({ theme, isCurrentMatch }) => ({
11+
backgroundColor: isCurrentMatch ? theme.palette.warning.main : 'yellow',
12+
color: isCurrentMatch ? 'white' : 'black',
13+
transition: theme.transitions.create(['background-color', 'color'], {
14+
duration: theme.transitions.duration.shortest
15+
})
16+
}));
17+
18+
interface FormattedLogProps {
19+
log: ParsedLog;
20+
index: number;
21+
searchQuery?: string;
22+
isCurrentMatch?: boolean;
23+
}
24+
25+
type SearchHighlightTextProps = {
26+
text: string;
27+
searchQuery?: string;
28+
isCurrentMatch?: boolean;
29+
};
30+
31+
export const SearchHighlightText = ({
32+
text,
33+
searchQuery,
34+
isCurrentMatch
35+
}: SearchHighlightTextProps) => {
36+
if (!searchQuery) return <>{text}</>;
37+
38+
const escapeRegExp = (string: string) =>
39+
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40+
41+
const safeQuery = escapeRegExp(searchQuery);
42+
const regex = new RegExp(`(${safeQuery})`, 'gi');
43+
44+
const parts = text.split(regex);
45+
return (
46+
<>
47+
{parts.map((part, i) =>
48+
part.toLowerCase() === searchQuery.toLowerCase() ? (
49+
<HighlightSpan
50+
key={i}
51+
isCurrentMatch={isCurrentMatch}
52+
data-highlighted="true"
53+
>
54+
{part}
55+
</HighlightSpan>
56+
) : (
57+
part
58+
)
59+
)}
60+
</>
61+
);
62+
};
63+
64+
export const FormattedLog = ({
65+
log,
66+
searchQuery,
67+
isCurrentMatch
68+
}: FormattedLogProps) => {
869
const theme = useTheme();
970
const getLevelColor = useCallback(
1071
(level: string) => {
@@ -36,17 +97,35 @@ export const FormattedLog = ({ log }: { log: ParsedLog; index: number }) => {
3697
return (
3798
<Line mono marginBottom={1}>
3899
<Line component="span" color="info">
39-
{timestamp}
100+
<SearchHighlightText
101+
text={timestamp}
102+
searchQuery={searchQuery}
103+
isCurrentMatch={isCurrentMatch}
104+
/>
40105
</Line>{' '}
41106
{ip && (
42107
<Line component="span" color="primary">
43-
{ip}{' '}
108+
<SearchHighlightText
109+
text={ip}
110+
searchQuery={searchQuery}
111+
isCurrentMatch={isCurrentMatch}
112+
/>{' '}
44113
</Line>
45114
)}
46115
<Line component="span" color={getLevelColor(logLevel)}>
47-
[{logLevel}]
116+
[
117+
<SearchHighlightText
118+
text={logLevel}
119+
searchQuery={searchQuery}
120+
isCurrentMatch={isCurrentMatch}
121+
/>
122+
]
48123
</Line>{' '}
49-
{message}
124+
<SearchHighlightText
125+
text={message}
126+
searchQuery={searchQuery}
127+
isCurrentMatch={isCurrentMatch}
128+
/>
50129
</Line>
51130
);
52131
};
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
Search as SearchIcon,
3+
Clear as ClearIcon,
4+
NavigateNext,
5+
NavigateBefore
6+
} from '@mui/icons-material';
7+
import {
8+
Button,
9+
InputAdornment,
10+
TextField,
11+
Typography,
12+
Box
13+
} from '@mui/material';
14+
import { styled } from '@mui/material/styles';
15+
import { memo, useState, useCallback, useMemo } from 'react';
16+
17+
import { MotionBox } from '@/components/MotionComponents';
18+
import { debounce } from '@/utils/debounce';
19+
20+
const SearchContainer = styled('div')(() => ({
21+
position: 'sticky',
22+
top: 0,
23+
zIndex: 1,
24+
gap: 5,
25+
paddingBottom: 8,
26+
alignItems: 'center',
27+
backdropFilter: 'blur(10px)',
28+
borderRadius: 5
29+
}));
30+
31+
const SearchControls = styled(Box)(({ theme }) => ({
32+
display: 'flex',
33+
alignItems: 'center',
34+
gap: theme.spacing(1),
35+
marginTop: 8
36+
}));
37+
38+
const StyledTextField = styled(TextField)(({ theme }) => ({
39+
'& .MuiOutlinedInput-root': {
40+
backgroundColor: theme.palette.background.paper,
41+
transition: 'all 0.2s ease-in-out',
42+
'&:hover': {
43+
backgroundColor: theme.palette.background.paper,
44+
boxShadow: `0 0 0 1px ${theme.palette.primary.main}`
45+
},
46+
'&.Mui-focused': {
47+
backgroundColor: theme.palette.background.paper,
48+
boxShadow: `0 0 0 2px ${theme.palette.primary.main}`
49+
}
50+
}
51+
}));
52+
53+
interface LogSearchProps {
54+
onSearch: (query: string) => void;
55+
onNavigate: (direction: 'prev' | 'next') => void;
56+
currentMatch: number;
57+
totalMatches: number;
58+
}
59+
60+
const LogSearch = memo(
61+
({ onSearch, onNavigate, currentMatch, totalMatches }: LogSearchProps) => {
62+
const [searchQuery, setSearchQuery] = useState('');
63+
64+
const debouncedSearch = useMemo(
65+
() =>
66+
debounce((query: string) => {
67+
onSearch(query);
68+
}, 300),
69+
[onSearch]
70+
);
71+
72+
const handleSearchChange = useCallback(
73+
(event: React.ChangeEvent<HTMLInputElement>) => {
74+
const query = event.target.value;
75+
setSearchQuery(query);
76+
debouncedSearch(query);
77+
},
78+
[debouncedSearch]
79+
);
80+
81+
const handleClear = useCallback(() => {
82+
setSearchQuery('');
83+
onSearch('');
84+
}, [onSearch]);
85+
86+
const handleNavigate = useCallback(
87+
(direction: 'prev' | 'next') => {
88+
if (direction === 'next') {
89+
onNavigate('next');
90+
} else {
91+
onNavigate('prev');
92+
}
93+
},
94+
[onNavigate]
95+
);
96+
97+
const showSearchControls = searchQuery && totalMatches > 0;
98+
99+
return (
100+
<SearchContainer>
101+
<StyledTextField
102+
fullWidth
103+
variant="outlined"
104+
placeholder="Search logs..."
105+
value={searchQuery}
106+
onChange={handleSearchChange}
107+
InputProps={{
108+
startAdornment: (
109+
<InputAdornment position="start">
110+
<SearchIcon color="action" />
111+
</InputAdornment>
112+
),
113+
endAdornment: !!searchQuery.length && (
114+
<InputAdornment position="end">
115+
<ClearIcon
116+
sx={{ cursor: 'pointer' }}
117+
onClick={handleClear}
118+
color="action"
119+
/>
120+
</InputAdornment>
121+
)
122+
}}
123+
/>
124+
{showSearchControls && (
125+
<MotionBox
126+
initial={{ y: 20, opacity: 0 }}
127+
animate={{ y: 0, opacity: 1 }}
128+
exit={{ y: 20, opacity: 0 }}
129+
transition={{
130+
type: 'tween',
131+
ease: 'easeOut',
132+
duration: 0.3
133+
}}
134+
>
135+
<SearchControls>
136+
<Button
137+
size="small"
138+
onClick={() => handleNavigate('prev')}
139+
startIcon={<NavigateBefore />}
140+
sx={{
141+
minWidth: '20px',
142+
padding: '4px 8px'
143+
}}
144+
/>
145+
<Typography variant="body2" color="text.secondary">
146+
{currentMatch} of {totalMatches}
147+
</Typography>
148+
<Button
149+
size="small"
150+
onClick={() => handleNavigate('next')}
151+
startIcon={<NavigateNext />}
152+
sx={{
153+
minWidth: '20px',
154+
padding: '4px 8px'
155+
}}
156+
/>
157+
</SearchControls>
158+
</MotionBox>
159+
)}
160+
</SearchContainer>
161+
);
162+
}
163+
);
164+
165+
LogSearch.displayName = 'LogSearch';
166+
167+
export { LogSearch };
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import { Line } from '@/components/Text';
22

3-
export const PlainLog = ({ log }: { log: string; index: number }) => {
3+
import { SearchHighlightText } from './FormattedLog';
4+
5+
interface PlainLogProps {
6+
log: string;
7+
index: number;
8+
searchQuery?: string;
9+
isCurrentMatch?: boolean;
10+
}
11+
12+
export const PlainLog = ({
13+
log,
14+
searchQuery,
15+
isCurrentMatch
16+
}: PlainLogProps) => {
417
return (
518
<Line mono marginBottom={1}>
6-
{log}
19+
<SearchHighlightText
20+
text={log}
21+
searchQuery={searchQuery}
22+
isCurrentMatch={isCurrentMatch}
23+
/>
724
</Line>
825
);
926
};

0 commit comments

Comments
 (0)