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:
Matias Chomicki
2025-06-13 14:50:03 +02:00
committed by GitHub
parent 352aac162c
commit 5f3c04f537
11 changed files with 621 additions and 46 deletions
@@ -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);
};
});
};
+10
View File
@@ -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",