New Logs Panel: Implement client-side text search (#106448)
* LogList: add key bindings hook * LogListContext: add search support * LogList: create and integrate LogListSearch * useKeyBindings: close search with escape * LogListSearch: implement go to results * LogLine: support highlight in logs with ansi colors * LogListSearchContext: extract from LogListContext * LogListSearch: highlight matches in ansi logs * LogListSearch: fix count * LogListSearch: implement optional results filtering * Translations * LogListSearch: display within the panel and add tooltip * Translations * LogList: highlight search words and search matches * LogListSearch: remove ufuzzy Unfortunately we can't highlight ufuzzy matches * LogListSearch: clean up removed ufuzzy implementation * Prettier * LogListSearch: search in displayed fields * useKeyBindings: switch to native event listeners * LogListSearch: fix effect loop * LogListSearch: remove character so people don't think this text comes from AI * LogLine: add text search test cases * LogList: add integration test case * LogListSearch: use uncontrolled input and react transitions * LogListSearch: import t from i18n * LogListControls: add search control * LogListSearch: escape regexes
This commit is contained in:
@@ -9,6 +9,7 @@ import { createLogLine } from '../__mocks__/logRow';
|
||||
import { getStyles, LogLine, Props } from './LogLine';
|
||||
import { LogListFontSize } from './LogList';
|
||||
import { LogListContextProvider } from './LogListContext';
|
||||
import { LogListSearchContext } from './LogListSearchContext';
|
||||
import { defaultProps } from './__mocks__/LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
import { getTruncationLength } from './virtualization';
|
||||
@@ -309,4 +310,111 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
|
||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Text search support', () => {
|
||||
test('Highlights search text in a highlighted log line', () => {
|
||||
log.setCurrentSearch('message');
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={true}>
|
||||
<LogListSearchContext.Provider
|
||||
value={{
|
||||
hideSearch: jest.fn(),
|
||||
filterLogs: false,
|
||||
matchingUids: [log.uid],
|
||||
search: 'message',
|
||||
searchVisible: true,
|
||||
setMatchingUids: jest.fn(),
|
||||
setSearch: jest.fn(),
|
||||
showSearch: jest.fn(),
|
||||
toggleFilterLogs: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListSearchContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||
expect(screen.queryByText('log message 1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Highlights search text in a non-highlighted log line', () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false}>
|
||||
<LogListSearchContext.Provider
|
||||
value={{
|
||||
hideSearch: jest.fn(),
|
||||
filterLogs: false,
|
||||
matchingUids: [log.uid],
|
||||
search: 'message',
|
||||
searchVisible: true,
|
||||
setMatchingUids: jest.fn(),
|
||||
setSearch: jest.fn(),
|
||||
showSearch: jest.fn(),
|
||||
toggleFilterLogs: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListSearchContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||
expect(screen.queryByText('log message 1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Highlights search text in a ANSI log lines', () => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: 'Lorem \u001B[31mipsum\u001B[0m et dolor' });
|
||||
log.hasAnsi = true;
|
||||
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false}>
|
||||
<LogListSearchContext.Provider
|
||||
value={{
|
||||
hideSearch: jest.fn(),
|
||||
filterLogs: false,
|
||||
matchingUids: [log.uid],
|
||||
search: 'olo',
|
||||
searchVisible: true,
|
||||
setMatchingUids: jest.fn(),
|
||||
setSearch: jest.fn(),
|
||||
showSearch: jest.fn(),
|
||||
toggleFilterLogs: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<LogLine {...defaultProps} log={log} />
|
||||
</LogListSearchContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument();
|
||||
expect(screen.queryByText(log.entry)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('olo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Highlights search text in displayed fields', () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} displayedFields={['place']}>
|
||||
<LogListSearchContext.Provider
|
||||
value={{
|
||||
hideSearch: jest.fn(),
|
||||
filterLogs: false,
|
||||
matchingUids: [log.uid],
|
||||
search: 'un',
|
||||
searchVisible: true,
|
||||
setMatchingUids: jest.fn(),
|
||||
setSearch: jest.fn(),
|
||||
showSearch: jest.fn(),
|
||||
toggleFilterLogs: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<LogLine {...defaultProps} displayedFields={['place']} />
|
||||
</LogListSearchContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||
expect(screen.queryByText('log message 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('luna')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('un')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { CSSProperties, memo, useCallback, useEffect, useRef, useState, MouseEvent } from 'react';
|
||||
import { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, MouseEvent } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data';
|
||||
import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Button, Icon, Tooltip } from '@grafana/ui';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { LogMessageAnsi } from '../LogMessageAnsi';
|
||||
|
||||
import { LogLineMenu } from './LogLineMenu';
|
||||
import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext';
|
||||
import { useLogListSearchContext } from './LogListSearchContext';
|
||||
import { LogListModel } from './processing';
|
||||
import {
|
||||
FIELD_GAP_MULTIPLIER,
|
||||
@@ -213,31 +215,73 @@ const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: Lo
|
||||
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
|
||||
)}
|
||||
{displayedFields.length > 0 ? (
|
||||
displayedFields.map((field) =>
|
||||
field === LOG_LINE_BODY_FIELD_NAME ? (
|
||||
<LogLineBody log={log} key={field} />
|
||||
) : (
|
||||
<span className="field" title={field} key={field}>
|
||||
{log.getDisplayedFieldValue(field)}
|
||||
</span>
|
||||
)
|
||||
)
|
||||
<DisplayedFields displayedFields={displayedFields} log={log} styles={styles} />
|
||||
) : (
|
||||
<LogLineBody log={log} />
|
||||
<LogLineBody log={log} styles={styles} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Log.displayName = 'Log';
|
||||
|
||||
const LogLineBody = ({ log }: { log: LogListModel }) => {
|
||||
const DisplayedFields = ({
|
||||
displayedFields,
|
||||
log,
|
||||
styles,
|
||||
}: {
|
||||
displayedFields: string[];
|
||||
log: LogListModel;
|
||||
styles: LogLineStyles;
|
||||
}) => {
|
||||
const { matchingUids, search } = useLogListSearchContext();
|
||||
|
||||
const searchWords = useMemo(() => {
|
||||
const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords : [];
|
||||
if (search && matchingUids?.includes(log.uid)) {
|
||||
searchWords.push(search);
|
||||
}
|
||||
if (!searchWords.length) {
|
||||
return undefined;
|
||||
}
|
||||
return searchWords;
|
||||
}, [log.searchWords, log.uid, matchingUids, search]);
|
||||
|
||||
return displayedFields.map((field) =>
|
||||
field === LOG_LINE_BODY_FIELD_NAME ? (
|
||||
<LogLineBody log={log} key={field} styles={styles} />
|
||||
) : (
|
||||
<span className="field" title={field} key={field}>
|
||||
{searchWords ? (
|
||||
<Highlighter
|
||||
textToHighlight={log.getDisplayedFieldValue(field)}
|
||||
searchWords={searchWords}
|
||||
findChunks={findHighlightChunksInText}
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
) : (
|
||||
log.getDisplayedFieldValue(field)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => {
|
||||
const { syntaxHighlighting } = useLogListContext();
|
||||
const { matchingUids, search } = useLogListSearchContext();
|
||||
|
||||
const highlight = useMemo(() => {
|
||||
const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords : [];
|
||||
if (search && matchingUids?.includes(log.uid)) {
|
||||
searchWords.push(search);
|
||||
}
|
||||
if (!searchWords.length) {
|
||||
return undefined;
|
||||
}
|
||||
return { searchWords, highlightClassName: styles.matchHighLight };
|
||||
}, [log.searchWords, log.uid, matchingUids, search, styles.matchHighLight]);
|
||||
|
||||
if (log.hasAnsi) {
|
||||
const needsHighlighter =
|
||||
log.searchWords && log.searchWords.length > 0 && log.searchWords[0] && log.searchWords[0].length > 0;
|
||||
const highlight = needsHighlighter ? { searchWords: log.searchWords ?? [], highlightClassName: '' } : undefined;
|
||||
return (
|
||||
<span className="field no-highlighting">
|
||||
<LogMessageAnsi value={log.body} highlight={highlight} />
|
||||
@@ -246,7 +290,16 @@ const LogLineBody = ({ log }: { log: LogListModel }) => {
|
||||
}
|
||||
|
||||
if (!syntaxHighlighting) {
|
||||
return <span className="field no-highlighting">{log.body}</span>;
|
||||
return highlight ? (
|
||||
<Highlighter
|
||||
textToHighlight={log.body}
|
||||
searchWords={highlight.searchWords}
|
||||
findChunks={findHighlightChunksInText}
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
) : (
|
||||
<span className="field no-highlighting">{log.body}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
|
||||
@@ -324,11 +377,19 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
'.log-token-method': {
|
||||
color: theme.colors.info.shade,
|
||||
},
|
||||
'.log-search-match': {
|
||||
color: theme.components.textHighlight.text,
|
||||
backgroundColor: theme.components.textHighlight.background,
|
||||
},
|
||||
},
|
||||
'& .no-highlighting': {
|
||||
color: theme.colors.text.primary,
|
||||
},
|
||||
}),
|
||||
matchHighLight: css({
|
||||
color: theme.components.textHighlight.text,
|
||||
backgroundColor: theme.components.textHighlight.background,
|
||||
}),
|
||||
fontSizeSmall: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
lineHeight: theme.typography.bodySmall.lineHeight,
|
||||
|
||||
@@ -232,4 +232,32 @@ describe('LogList', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Text search', () => {
|
||||
test('Supports text search', async () => {
|
||||
render(<LogList {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search in logs')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('log message 2')).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard('{Control>}{f}{/Control}');
|
||||
|
||||
expect(screen.getByPlaceholderText('Search in logs')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Search in logs'), 'message 2');
|
||||
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('log message 2')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('message 2')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter matching logs'));
|
||||
|
||||
expect(screen.queryByText('log message 1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('message 2')).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search in logs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,10 @@ import { LogLineDetails } from './LogLineDetails';
|
||||
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
||||
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
|
||||
import { LogListControls } from './LogListControls';
|
||||
import { LOG_LIST_SEARCH_HEIGHT, LogListSearch } from './LogListSearch';
|
||||
import { LogListSearchContextProvider, useLogListSearchContext } from './LogListSearchContext';
|
||||
import { preProcessLogs, LogListModel } from './processing';
|
||||
import { useKeyBindings } from './useKeyBindings';
|
||||
import { usePopoverMenu } from './usePopoverMenu';
|
||||
import {
|
||||
calculateFieldDimensions,
|
||||
@@ -185,19 +188,21 @@ export const LogList = ({
|
||||
syntaxHighlighting={syntaxHighlighting}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
>
|
||||
<LogListComponent
|
||||
containerElement={containerElement}
|
||||
eventBus={eventBus}
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
loading={loading}
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<LogListSearchContextProvider>
|
||||
<LogListComponent
|
||||
containerElement={containerElement}
|
||||
eventBus={eventBus}
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
loading={loading}
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</LogListSearchContextProvider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
};
|
||||
@@ -259,6 +264,8 @@ const LogListComponent = ({
|
||||
popoverState,
|
||||
showDisablePopoverOptions,
|
||||
} = usePopoverMenu(wrapperRef.current);
|
||||
useKeyBindings();
|
||||
const { filterLogs, matchingUids, searchVisible } = useLogListSearchContext();
|
||||
|
||||
const debouncedResetAfterIndex = useMemo(() => {
|
||||
return debounce((index: number) => {
|
||||
@@ -296,9 +303,9 @@ const LogListComponent = ({
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(() => {
|
||||
setListHeight(
|
||||
app === CoreApp.Explore
|
||||
(app === CoreApp.Explore
|
||||
? Math.max(window.innerHeight * 0.8, containerElement.clientHeight)
|
||||
: containerElement.clientHeight
|
||||
: containerElement.clientHeight) - (searchVisible ? LOG_LIST_SEARCH_HEIGHT : 0)
|
||||
);
|
||||
}, 50);
|
||||
window.addEventListener('resize', handleResize);
|
||||
@@ -306,7 +313,7 @@ const LogListComponent = ({
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [app, containerElement.clientHeight]);
|
||||
}, [app, containerElement.clientHeight, searchVisible]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (widthRef.current === widthContainer.clientWidth) {
|
||||
@@ -362,12 +369,20 @@ const LogListComponent = ({
|
||||
debouncedResetAfterIndex(0);
|
||||
}, [debouncedResetAfterIndex]);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
const levelFilteredLogs = useMemo(
|
||||
() =>
|
||||
filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)),
|
||||
[filterLevels, processedLogs]
|
||||
);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
matchingUids && filterLogs
|
||||
? levelFilteredLogs.filter((log) => matchingUids.includes(log.uid))
|
||||
: levelFilteredLogs,
|
||||
[filterLogs, levelFilteredLogs, matchingUids]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.logListContainer}>
|
||||
<div className={styles.logListWrapper} ref={wrapperRef}>
|
||||
@@ -404,6 +419,7 @@ const LogListComponent = ({
|
||||
onDismiss={onDisableCancel}
|
||||
/>
|
||||
)}
|
||||
<LogListSearch logs={levelFilteredLogs} listRef={listRef.current} />
|
||||
<InfiniteScroll
|
||||
displayedFields={displayedFields}
|
||||
handleOverflow={handleOverflow}
|
||||
@@ -473,8 +489,8 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo
|
||||
minWidth: theme.spacing(35),
|
||||
}),
|
||||
logListWrapper: css({
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}),
|
||||
shortcut: css({
|
||||
display: 'inline-flex',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LogsVisualisationType } from '../../../explore/Logs/Logs';
|
||||
import { DownloadFormat } from '../../utils';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { useLogListSearchContext } from './LogListSearchContext';
|
||||
import { ScrollToLogsEvent } from './virtualization';
|
||||
|
||||
type Props = {
|
||||
@@ -63,6 +64,7 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
const { hideSearch, searchVisible, showSearch } = useLogListSearchContext();
|
||||
|
||||
const onScrollToTopClick = useCallback(() => {
|
||||
reportInteraction('logs_log_list_controls_scroll_top_clicked');
|
||||
@@ -244,6 +246,19 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
||||
{visualisationType === 'logs' && (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
{config.featureToggles.newLogsPanel && (
|
||||
<IconButton
|
||||
name={'search'}
|
||||
className={searchVisible ? styles.controlButtonActive : styles.controlButton}
|
||||
onClick={searchVisible ? hideSearch : showSearch}
|
||||
tooltip={
|
||||
searchVisible
|
||||
? t('logs.logs-controls.hide-search', 'Close search')
|
||||
: t('logs.logs-controls.show-search', 'Search in logs result')
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
<Dropdown overlay={deduplicationMenu} placement="auto-end">
|
||||
<IconButton
|
||||
name={'filter'}
|
||||
@@ -380,14 +395,29 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
||||
<IconButton
|
||||
name={'gf-logs'}
|
||||
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
|
||||
tooltip={t('logs.logs-controls.display-level', 'Display levels')}
|
||||
size="lg"
|
||||
/>
|
||||
</Dropdown>
|
||||
<>
|
||||
{config.featureToggles.newLogsPanel && (
|
||||
<IconButton
|
||||
name={'search'}
|
||||
className={searchVisible ? styles.controlButtonActive : styles.controlButton}
|
||||
onClick={searchVisible ? hideSearch : showSearch}
|
||||
tooltip={
|
||||
searchVisible
|
||||
? t('logs.logs-controls.hide-search', 'Close search')
|
||||
: t('logs.logs-controls.show-search', 'Search in logs result')
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
||||
<IconButton
|
||||
name={'gf-logs'}
|
||||
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
|
||||
tooltip={t('logs.logs-controls.display-level', 'Display levels')}
|
||||
size="lg"
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
{visualisationType === 'logs' && (
|
||||
<IconButton
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ChangeEvent, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
|
||||
import { escapeRegex, GrafanaTheme2, shallowCompare } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { IconButton, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { useLogListSearchContext } from './LogListSearchContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
interface Props {
|
||||
listRef: VariableSizeList | null;
|
||||
logs: LogListModel[];
|
||||
}
|
||||
|
||||
export const LOG_LIST_SEARCH_HEIGHT = 48;
|
||||
export const LogListSearch = ({ listRef, logs }: Props) => {
|
||||
const {
|
||||
hideSearch,
|
||||
filterLogs,
|
||||
matchingUids,
|
||||
setMatchingUids,
|
||||
setSearch: setContextSearch,
|
||||
searchVisible,
|
||||
toggleFilterLogs,
|
||||
} = useLogListSearchContext();
|
||||
const { displayedFields } = useLogListContext();
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentResult, setCurrentResult] = useState<number | null>(null);
|
||||
const inputRef = useRef('');
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!search || !searchVisible) {
|
||||
return [];
|
||||
}
|
||||
return findMatchingLogs(logs, search, displayedFields);
|
||||
}, [displayedFields, logs, search, searchVisible]);
|
||||
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
inputRef.current = e.target.value;
|
||||
startTransition(() => {
|
||||
setSearch(inputRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prevResult = useCallback(() => {
|
||||
if (currentResult === null) {
|
||||
return;
|
||||
}
|
||||
const prev = currentResult > 0 ? currentResult - 1 : matches.length - 1;
|
||||
setCurrentResult(prev);
|
||||
listRef?.scrollToItem(logs.indexOf(matches[prev]), 'center');
|
||||
}, [currentResult, listRef, logs, matches]);
|
||||
|
||||
const nextResult = useCallback(() => {
|
||||
if (currentResult === null) {
|
||||
return;
|
||||
}
|
||||
const next = currentResult < matches.length - 1 ? currentResult + 1 : 0;
|
||||
setCurrentResult(next);
|
||||
listRef?.scrollToItem(logs.indexOf(matches[next]), 'center');
|
||||
}, [currentResult, listRef, logs, matches]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matches.length) {
|
||||
setCurrentResult(null);
|
||||
return;
|
||||
}
|
||||
if (!currentResult) {
|
||||
setCurrentResult(0);
|
||||
listRef?.scrollToItem(logs.indexOf(matches[0]), 'center');
|
||||
}
|
||||
}, [currentResult, listRef, logs, matches]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchVisible) {
|
||||
setSearch('');
|
||||
setContextSearch(undefined);
|
||||
setMatchingUids(null);
|
||||
}
|
||||
}, [searchVisible, setContextSearch, setMatchingUids]);
|
||||
|
||||
useEffect(() => {
|
||||
const newMatchingUids = matches.map((log) => log.uid);
|
||||
const sameLogs = matchingUids ? shallowCompare(matchingUids, newMatchingUids) : false;
|
||||
|
||||
if (matchingUids && !sameLogs) {
|
||||
// Cleanup previous matches
|
||||
logs
|
||||
.filter((log) => matchingUids.includes(log.uid))
|
||||
.filter((prevMatchingLog) => matches.findIndex((matchingLog) => matchingLog.uid === prevMatchingLog.uid) < 0)
|
||||
.forEach((log) => log.setCurrentSearch(undefined));
|
||||
}
|
||||
|
||||
setContextSearch(search ? search : undefined);
|
||||
if (!sameLogs) {
|
||||
setMatchingUids(newMatchingUids.length ? newMatchingUids : null);
|
||||
} else if (!matches.length) {
|
||||
setMatchingUids(null);
|
||||
}
|
||||
}, [logs, matches, matchingUids, search, setContextSearch, setMatchingUids]);
|
||||
|
||||
if (!searchVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix =
|
||||
search !== '' ? <>{`${currentResult !== null ? currentResult + 1 : 0}/${matches?.length ?? 0}`}</> : undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.wrapper}>
|
||||
<Input
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
placeholder={t('logs.log-list-search.input-placeholder', 'Search in logs')}
|
||||
suffix={suffix}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
name="info-circle"
|
||||
variant="secondary"
|
||||
tooltip={t(
|
||||
'logs.log-list-search.info',
|
||||
'Client-side search for strings within the displayed logs. Not to be confused with query filters. Use this component to search for specific strings in your log results.'
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={prevResult}
|
||||
disabled={!matches || !matches.length}
|
||||
name="angle-up"
|
||||
aria-label={t('logs.log-list-search.prev', 'Previous result')}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={nextResult}
|
||||
disabled={!matches || !matches.length}
|
||||
name="angle-down"
|
||||
aria-label={t('logs.log-list-search.next', 'Next result')}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={toggleFilterLogs}
|
||||
disabled={!matches || !matches.length}
|
||||
className={filterLogs ? styles.controlButtonActive : undefined}
|
||||
name="filter"
|
||||
aria-label={t('logs.log-list-search.filter', 'Filter matching logs')}
|
||||
/>
|
||||
<IconButton onClick={hideSearch} name="times" aria-label={t('logs.log-list-search.close', 'Close search')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
background: theme.colors.background.elevated,
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1),
|
||||
zIndex: theme.zIndex.modal,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
}),
|
||||
wrapper: css({
|
||||
width: '50%',
|
||||
}),
|
||||
controlButtonActive: css({
|
||||
'&:after': {
|
||||
display: 'block',
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
height: 2,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
bottom: 2,
|
||||
backgroundImage: theme.colors.gradients.brandHorizontal,
|
||||
width: '95%',
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
function findMatchingLogs(logs: LogListModel[], search: string, displayedFields: string[]) {
|
||||
const regex = new RegExp(escapeRegex(search), 'i');
|
||||
const newMatches = logs.filter((log) => {
|
||||
if (log.entry.match(regex)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return displayedFields.some((field) => log.getDisplayedFieldValue(field).match(regex));
|
||||
});
|
||||
newMatches.forEach((log) => log.setCurrentSearch(search));
|
||||
return newMatches;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createContext, ReactNode, useCallback, useContext, useState } from 'react';
|
||||
|
||||
export interface LogListSearchContextData {
|
||||
hideSearch: () => void;
|
||||
filterLogs: boolean;
|
||||
matchingUids: string[] | null;
|
||||
search?: string;
|
||||
searchVisible?: boolean;
|
||||
setMatchingUids: (matches: string[] | null) => void;
|
||||
setSearch: (search: string | undefined) => void;
|
||||
showSearch: () => void;
|
||||
toggleFilterLogs: () => void;
|
||||
}
|
||||
|
||||
export const LogListSearchContext = createContext<LogListSearchContextData>({
|
||||
hideSearch: () => {},
|
||||
filterLogs: false,
|
||||
matchingUids: null,
|
||||
searchVisible: false,
|
||||
setMatchingUids: () => {},
|
||||
setSearch: () => {},
|
||||
showSearch: () => {},
|
||||
toggleFilterLogs: () => {},
|
||||
});
|
||||
|
||||
export const useLogListSearchContextData = (key: keyof LogListSearchContextData) => {
|
||||
const data: LogListSearchContextData = useContext(LogListSearchContext);
|
||||
return data[key];
|
||||
};
|
||||
|
||||
export const useLogListSearchContext = (): LogListSearchContextData => {
|
||||
return useContext(LogListSearchContext);
|
||||
};
|
||||
|
||||
export const LogListSearchContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [search, setSearch] = useState<string | undefined>(undefined);
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [matchingUids, setMatchingUids] = useState<string[] | null>(null);
|
||||
const [filterLogs, setFilterLogs] = useState(false);
|
||||
|
||||
const hideSearch = useCallback(() => {
|
||||
setSearchVisible(false);
|
||||
}, []);
|
||||
|
||||
const showSearch = useCallback(() => {
|
||||
setSearchVisible(true);
|
||||
}, []);
|
||||
|
||||
const toggleFilterLogs = useCallback(() => {
|
||||
setFilterLogs((filterLogs) => !filterLogs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LogListSearchContext.Provider
|
||||
value={{
|
||||
hideSearch,
|
||||
filterLogs,
|
||||
matchingUids,
|
||||
search,
|
||||
searchVisible,
|
||||
setMatchingUids,
|
||||
setSearch,
|
||||
showSearch,
|
||||
toggleFilterLogs,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LogListSearchContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Grammar } from 'prismjs';
|
||||
|
||||
import { escapeRegex } from '@grafana/data';
|
||||
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
// The Logs grammar is used for highlight in the logs panel
|
||||
@@ -23,3 +25,19 @@ export const generateLogGrammar = (log: LogListModel) => {
|
||||
...logsGrammar,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateTextMatchGrammar = (
|
||||
highlightWords: string[] | undefined = [],
|
||||
search: string | undefined
|
||||
): Grammar => {
|
||||
const textMatches = [...highlightWords];
|
||||
if (search) {
|
||||
textMatches.push(escapeRegex(search));
|
||||
}
|
||||
if (!textMatches.length) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'log-search-match': new RegExp(textMatches.join('|'), 'g'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows }
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { FieldDef, getAllFields } from '../logParser';
|
||||
|
||||
import { generateLogGrammar } from './grammar';
|
||||
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
import { getTruncationLength } from './virtualization';
|
||||
|
||||
export class LogListModel implements LogRowModel {
|
||||
@@ -39,6 +39,7 @@ export class LogListModel implements LogRowModel {
|
||||
uniqueLabels: Labels | undefined;
|
||||
|
||||
private _body: string | undefined = undefined;
|
||||
private _currentSearch: string | undefined = undefined;
|
||||
private _grammar?: Grammar;
|
||||
private _highlightedBody: string | undefined = undefined;
|
||||
private _fields: FieldDef[] | undefined = undefined;
|
||||
@@ -108,7 +109,8 @@ export class LogListModel implements LogRowModel {
|
||||
get highlightedBody() {
|
||||
if (this._highlightedBody === undefined) {
|
||||
this._grammar = this._grammar ?? generateLogGrammar(this);
|
||||
this._highlightedBody = Prism.highlight(this.body, this._grammar, 'lokiql');
|
||||
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
|
||||
this._highlightedBody = Prism.highlight(this.body, { ...extraGrammar, ...this._grammar }, 'lokiql');
|
||||
}
|
||||
return this._highlightedBody;
|
||||
}
|
||||
@@ -150,6 +152,11 @@ export class LogListModel implements LogRowModel {
|
||||
}
|
||||
this.collapsed = collapsed;
|
||||
}
|
||||
|
||||
setCurrentSearch(search: string | undefined) {
|
||||
this._currentSearch = search;
|
||||
this._highlightedBody = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreProcessOptions {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useLogListSearchContext } from './LogListSearchContext';
|
||||
|
||||
/**
|
||||
* Handles toggling of the search box of virtualized Logs Panel.
|
||||
* Mousetrap cannot be used because of the following issues:
|
||||
* - https://github.com/ccampbell/mousetrap/issues/442
|
||||
* - https://github.com/ccampbell/mousetrap/issues/162
|
||||
*/
|
||||
|
||||
export const useKeyBindings = () => {
|
||||
const { hideSearch, searchVisible, showSearch } = useLogListSearchContext();
|
||||
|
||||
useEffect(() => {
|
||||
function handleToggleSearch(event: KeyboardEvent) {
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const isFKey = event.key === 'f' || event.key === 'F';
|
||||
|
||||
if ((isMac && event.metaKey && isFKey) || (!isMac && event.ctrlKey && isFKey)) {
|
||||
showSearch();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && searchVisible) {
|
||||
hideSearch();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleToggleSearch);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleToggleSearch);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -7688,6 +7688,14 @@
|
||||
"show-details": "Hide log details",
|
||||
"unpin-from-outline": "Unpin log"
|
||||
},
|
||||
"log-list-search": {
|
||||
"close": "Close search",
|
||||
"filter": "Filter matching logs",
|
||||
"info": "Client-side search for strings within the displayed logs. Not to be confused with query filters. Use this component to search for specific strings in your log results.",
|
||||
"input-placeholder": "Search in logs",
|
||||
"next": "Next result",
|
||||
"prev": "Previous result"
|
||||
},
|
||||
"log-row-context-modal": {
|
||||
"error-loading-log-more-logs": "Error loading more logs.",
|
||||
"no-more-logs-available": "No more logs available.",
|
||||
@@ -7741,6 +7749,7 @@
|
||||
"escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
|
||||
"font-size-default": "Use small font size",
|
||||
"font-size-small": "Use default font size",
|
||||
"hide-search": "Close search",
|
||||
"hide-timestamps": "Hide timestamps",
|
||||
"hide-unique-labels": "Hide unique labels",
|
||||
"newest-first": "Sorted by newest logs first - Click to show oldest first",
|
||||
@@ -7749,6 +7758,7 @@
|
||||
"remove-escaping": "Remove escaping",
|
||||
"scroll-bottom": "Scroll to bottom",
|
||||
"scroll-top": "Scroll to top",
|
||||
"show-search": "Search in logs result",
|
||||
"show-timestamps": "Show timestamps",
|
||||
"show-unique-labels": "Show unique labels",
|
||||
"unwrap-lines": "Unwrap lines",
|
||||
|
||||
Reference in New Issue
Block a user