From 5f3c04f5372f24af0041c75912b641a6eddc7451 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 13 Jun 2025 14:50:03 +0200 Subject: [PATCH] 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 --- .../logs/components/panel/LogLine.test.tsx | 108 ++++++++++ .../logs/components/panel/LogLine.tsx | 97 +++++++-- .../logs/components/panel/LogList.test.tsx | 28 +++ .../logs/components/panel/LogList.tsx | 52 +++-- .../logs/components/panel/LogListControls.tsx | 46 ++++- .../logs/components/panel/LogListSearch.tsx | 194 ++++++++++++++++++ .../components/panel/LogListSearchContext.tsx | 70 +++++++ .../features/logs/components/panel/grammar.ts | 18 ++ .../logs/components/panel/processing.ts | 11 +- .../logs/components/panel/useKeyBindings.ts | 33 +++ public/locales/en-US/grafana.json | 10 + 11 files changed, 621 insertions(+), 46 deletions(-) create mode 100644 public/app/features/logs/components/panel/LogListSearch.tsx create mode 100644 public/app/features/logs/components/panel/LogListSearchContext.tsx create mode 100644 public/app/features/logs/components/panel/useKeyBindings.ts diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index a7d22497d13..42c05bb9bdc 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -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( + + + + + + ); + 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( + + + + + + ); + 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( + + + + + + ); + 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( + + + + + + ); + 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(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 4b37ce51eee..9999b946a0e 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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 {log.displayLevel} )} {displayedFields.length > 0 ? ( - displayedFields.map((field) => - field === LOG_LINE_BODY_FIELD_NAME ? ( - - ) : ( - - {log.getDisplayedFieldValue(field)} - - ) - ) + ) : ( - + )} ); }); - 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 ? ( + + ) : ( + + {searchWords ? ( + + ) : ( + log.getDisplayedFieldValue(field) + )} + + ) + ); +}; + +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 ( @@ -246,7 +290,16 @@ const LogLineBody = ({ log }: { log: LogListModel }) => { } if (!syntaxHighlighting) { - return {log.body}; + return highlight ? ( + + ) : ( + {log.body} + ); } return ; @@ -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, diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 973cbf95ee2..dbe8dfe78a2 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -232,4 +232,32 @@ describe('LogList', () => { }); }); }); + describe('Text search', () => { + test('Supports text search', async () => { + render(); + + 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(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index ae078696283..5f4cb59b70d 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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} > - + + + ); }; @@ -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 (
@@ -404,6 +419,7 @@ const LogListComponent = ({ onDismiss={onDisableCancel} /> )} + { reportInteraction('logs_log_list_controls_scroll_top_clicked'); @@ -244,6 +246,19 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) {visualisationType === 'logs' && ( <>
+ {config.featureToggles.newLogsPanel && ( + + )} ) : ( - - 0 ? styles.controlButtonActive : styles.controlButton} - tooltip={t('logs.logs-controls.display-level', 'Display levels')} - size="lg" - /> - + <> + {config.featureToggles.newLogsPanel && ( + + )} + + 0 ? styles.controlButtonActive : styles.controlButton} + tooltip={t('logs.logs-controls.display-level', 'Display levels')} + size="lg" + /> + + )} {visualisationType === 'logs' && ( { + const { + hideSearch, + filterLogs, + matchingUids, + setMatchingUids, + setSearch: setContextSearch, + searchVisible, + toggleFilterLogs, + } = useLogListSearchContext(); + const { displayedFields } = useLogListContext(); + const [search, setSearch] = useState(''); + const [currentResult, setCurrentResult] = useState(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) => { + 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 ( +
+
+ +
+ + + + + +
+ ); +}; + +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; +} diff --git a/public/app/features/logs/components/panel/LogListSearchContext.tsx b/public/app/features/logs/components/panel/LogListSearchContext.tsx new file mode 100644 index 00000000000..ff1548a56cf --- /dev/null +++ b/public/app/features/logs/components/panel/LogListSearchContext.tsx @@ -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({ + 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(undefined); + const [searchVisible, setSearchVisible] = useState(false); + const [matchingUids, setMatchingUids] = useState(null); + const [filterLogs, setFilterLogs] = useState(false); + + const hideSearch = useCallback(() => { + setSearchVisible(false); + }, []); + + const showSearch = useCallback(() => { + setSearchVisible(true); + }, []); + + const toggleFilterLogs = useCallback(() => { + setFilterLogs((filterLogs) => !filterLogs); + }, []); + + return ( + + {children} + + ); +}; diff --git a/public/app/features/logs/components/panel/grammar.ts b/public/app/features/logs/components/panel/grammar.ts index 05686f81ddd..edf58e921c9 100644 --- a/public/app/features/logs/components/panel/grammar.ts +++ b/public/app/features/logs/components/panel/grammar.ts @@ -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'), + }; +}; diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index e2a9dc992f1..5c5b95abbc3 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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 { diff --git a/public/app/features/logs/components/panel/useKeyBindings.ts b/public/app/features/logs/components/panel/useKeyBindings.ts new file mode 100644 index 00000000000..bf166aede4e --- /dev/null +++ b/public/app/features/logs/components/panel/useKeyBindings.ts @@ -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); + }; + }); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 87df7fb5c54..01f00828c1d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",