Skip to content

feat: Implemented log search functionality with highlighted results #647

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
35 changes: 30 additions & 5 deletions web-server/src/components/Service/SystemLog/FormattedLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,32 @@ import { useCallback } from 'react';
import { Line } from '@/components/Text';
import { ParsedLog } from '@/types/resources';

export const FormattedLog = ({ log }: { log: ParsedLog; index: number }) => {
interface FormattedLogProps {
log: ParsedLog;
index: number;
searchQuery?: string;
}

const HighlightedText = ({ text, searchQuery }: { text: string; searchQuery?: string }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling the component HighlightedText is misleading because this is used to render logs regardless of whether it's highlighted or not.

FormattedLog was perhaps a better name.

if (!searchQuery) return <>{text}</>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A component can directly return text. Text nodes are valid react nodes.


const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll need to check this, but I think you can directly return the parts.map and stuff without wrapping in a fragment. Confirm once.

part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={i} style={{ backgroundColor: 'yellow', color: 'black' }}>
{part}
</span>
) : (
part
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't really need to wrap in a parenthesis

)}
</>
);
};

export const FormattedLog = ({ log, searchQuery }: FormattedLogProps) => {
const theme = useTheme();
const getLevelColor = useCallback(
(level: string) => {
Expand Down Expand Up @@ -36,17 +61,17 @@ export const FormattedLog = ({ log }: { log: ParsedLog; index: number }) => {
return (
<Line mono marginBottom={1}>
<Line component="span" color="info">
{timestamp}
<HighlightedText text={timestamp} searchQuery={searchQuery} />
</Line>{' '}
{ip && (
<Line component="span" color="primary">
{ip}{' '}
<HighlightedText text={ip} searchQuery={searchQuery} />{' '}
</Line>
)}
<Line component="span" color={getLevelColor(logLevel)}>
[{logLevel}]
[<HighlightedText text={logLevel} searchQuery={searchQuery} />]
</Line>{' '}
{message}
<HighlightedText text={message} searchQuery={searchQuery} />
</Line>
);
};
141 changes: 141 additions & 0 deletions web-server/src/components/Service/SystemLog/LogSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Button, InputAdornment, TextField, Typography, Box } from '@mui/material';
import { Search as SearchIcon, Clear as ClearIcon, NavigateNext, NavigateBefore } from '@mui/icons-material';
import { useState, useCallback } from 'react';
import { styled } from '@mui/material/styles';
import { MotionBox } from '@/components/MotionComponents';

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;
Comment on lines +54 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a required change. General tip.

Unless it's important for you to specify that a function must return nothing, you can just set the return type as any, if you don't care about what the function returns.

There are good reasons for a function to return nothing, when you think the function might accidentally be incorrectly used with its return value when it's not intended to.

But unless that's the case, you're fine with any.

BUT... don't take this as an endorsement that using any without good reason is a good thing. It should be very intentional, and a last option where other options exist.

currentMatch: number;
totalMatches: number;
}

export const LogSearch = ({ onSearch, onNavigate, currentMatch, totalMatches }: LogSearchProps) => {
const [searchQuery, setSearchQuery] = useState('');

const handleSearchChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value;
setSearchQuery(query);
onSearch(query);
},
[onSearch]
);

const handleClear = useCallback(() => {
setSearchQuery('');
onSearch('');
}, [onSearch]);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter' && event.shiftKey) {
onNavigate('prev');
} else if (event.key === 'Enter') {
onNavigate('next');
}
},
[onNavigate]
);

return (
<SearchContainer>
<StyledTextField
fullWidth
variant="outlined"
placeholder="Search logs..."
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
endAdornment: searchQuery && (
<InputAdornment position="end">
<ClearIcon
style={{ cursor: 'pointer' }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer using MUI's sx prop.
It allows using MUI's css-in-js optimizations and the final result doesn't include inline-styles as a result.

Common styles are combined into the same generated classname to keep the bundle-size small.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do this for any other instances where it's an MUI component.

onClick={handleClear}
color="action"
/>
</InputAdornment>
),
}}
/>
{searchQuery && totalMatches > 0 && (
<MotionBox
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
transition={{
type: 'tween',
ease: 'easeOut',
duration: 0.3,
}}
>
<SearchControls>
<Button
size="small"
onClick={() => onNavigate('prev')}
disabled={currentMatch === 1}
startIcon={<NavigateBefore />}
sx={{
minWidth: '20px',
padding: '4px 8px',
}}
/>
<Typography variant="body2" color="text.secondary">
{currentMatch} of {totalMatches}
</Typography>
<Button
size="small"
onClick={() => onNavigate('next')}
disabled={currentMatch === totalMatches}
startIcon={<NavigateNext />}
sx={{
minWidth: '20px',
padding: '4px 8px',
}}
/>
</SearchControls>
</MotionBox>
)}
</SearchContainer>
);
};
29 changes: 27 additions & 2 deletions web-server/src/components/Service/SystemLog/PlainLog.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,34 @@
import { Line } from '@/components/Text';

export const PlainLog = ({ log }: { log: string; index: number }) => {
interface PlainLogProps {
log: string;
index: number;
searchQuery?: string;
}

const HighlightedText = ({ text, searchQuery }: { text: string; searchQuery?: string }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component is duplicated. Figure out a way to avoid duplication to avoid future code drift.

if (!searchQuery) return <>{text}</>;

const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={i} style={{ backgroundColor: 'yellow', color: 'black' }}>
{part}
</span>
) : (
part
)
)}
</>
);
};

export const PlainLog = ({ log, searchQuery }: PlainLogProps) => {
return (
<Line mono marginBottom={1}>
{log}
<HighlightedText text={log} searchQuery={searchQuery} />
</Line>
);
};
Loading