From 015219e49fb9f73bacb2d387ee061950df62d58c Mon Sep 17 00:00:00 2001 From: Oleg Zaytsev Date: Wed, 17 Dec 2025 10:43:50 +0100 Subject: [PATCH] Logs Panel: Integrate client-side search with Popover Menu (#114653) * Explore: Add custom text highlighting to logs panel Add ability to select text in log lines and highlight all occurrences with persistent colors. Highlights are stored in URL state and cycle through the theme's visualization palette. - Add CustomHighlight type to ExploreLogsPanelState - Implement LogListHighlightContext for state management - Generate custom highlight grammar using Prism.js tokens - Add "Highlight occurrences" option to popover menu - Add "Reset highlights" control when highlights exist - Fix pruneObject to preserve colorIndex: 0 in URL state * Fix CI failures: formatting and i18n extraction - Run prettier on LogLine.tsx - Run i18n-extract to update translation strings * Fix lint errors - Use theme.shape.radius.default instead of literal '2px' in LogLine.tsx - Remove unnecessary type assertion in grammar.ts * Fix TypeScript error in grammar.ts Use Record type for dynamic grammar object to allow string indexing without type assertions. * Replace hardcoded HIGHLIGHT_COLOR_COUNT with actual theme palette length Use useTheme2() hook to dynamically get the palette length instead of hardcoding it to 50. This ensures the color cycling works correctly regardless of the actual theme palette size. * Backtrack to a stable point and revert changes * Implement using search * New translations * LogListSearch: refactor search state * PopoverMenu: add divider * LogLine: remove padding and update border radius * LogListSearch: add missing tooltips * Refactor keybindings * More cleanup * LogListSearch: don't autoscroll with filterLogs --------- Co-authored-by: Matias Chomicki --- .../app/features/explore/Logs/PopoverMenu.tsx | 15 +++++- .../logs/components/panel/LogList.tsx | 10 ++++ .../logs/components/panel/LogListSearch.tsx | 51 +++++++++---------- .../components/panel/LogListSearchContext.tsx | 5 +- .../logs/components/panel/processing.ts | 2 +- .../logs/components/panel/useKeyBindings.ts | 10 ++-- public/locales/en-US/grafana.json | 3 +- 7 files changed, 62 insertions(+), 34 deletions(-) diff --git a/public/app/features/explore/Logs/PopoverMenu.tsx b/public/app/features/explore/Logs/PopoverMenu.tsx index 9c435bee1d7..5d5895ed5c4 100644 --- a/public/app/features/explore/Logs/PopoverMenu.tsx +++ b/public/app/features/explore/Logs/PopoverMenu.tsx @@ -14,6 +14,7 @@ interface PopoverMenuProps { y: number; onClickFilterString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void; + onClickSearchString?: (text: string) => void; onDisable: () => void; row: LogRowModel; close: () => void; @@ -24,6 +25,7 @@ export const PopoverMenu = ({ y, onClickFilterString, onClickFilterOutString, + onClickSearchString, selection, row, close, @@ -50,7 +52,7 @@ export const PopoverMenu = ({ props.onDisable(); }, [props, row.datasourceType, selection.length]); - const supported = onClickFilterString || onClickFilterOutString; + const supported = onClickFilterString || onClickFilterOutString || onClickSearchString; if (!supported) { return null; @@ -89,6 +91,17 @@ export const PopoverMenu = ({ /> )} + {onClickSearchString && ( + { + onClickSearchString(selection); + close(); + track('search_text', selection.length, row.datasourceType); + }} + /> + )} + diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 3c51af0342b..8cfbaee1665 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -280,6 +280,7 @@ const LogListComponent = ({ wrapLogMessage, } = useLogListContext(); const { detailsMode, showDetails, toggleDetails } = useLogDetailsContext(); + const { setSearch, showSearch } = useLogListSearchContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState(getListHeight(containerElement, app)); const theme = useTheme2(); @@ -441,6 +442,14 @@ const LogListComponent = ({ [debouncedScrollToItem, filteredLogs] ); + const onClickSearchString = useCallback( + (search: string) => { + showSearch(); + setSearch(search); + }, + [setSearch, showSearch] + ); + const logLevels = useMemo(() => getLevelsFromLogs(processedLogs), [processedLogs]); if (!containerElement || listHeight == null) { @@ -471,6 +480,7 @@ const LogListComponent = ({ {...popoverState.popoverMenuCoordinates} onClickFilterString={onClickFilterString} onClickFilterOutString={onClickFilterOutString} + onClickSearchString={onClickSearchString} onDisable={onDisablePopoverMenu} /> )} diff --git a/public/app/features/logs/components/panel/LogListSearch.tsx b/public/app/features/logs/components/panel/LogListSearch.tsx index 3cdd1cbbe3d..7cae9f36d5e 100644 --- a/public/app/features/logs/components/panel/LogListSearch.tsx +++ b/public/app/features/logs/components/panel/LogListSearch.tsx @@ -18,19 +18,11 @@ interface Props { export const LOG_LIST_SEARCH_HEIGHT = 48; export const LogListSearch = ({ listRef, logs }: Props) => { - const { - hideSearch, - filterLogs, - matchingUids, - setMatchingUids, - setSearch: setContextSearch, - searchVisible, - toggleFilterLogs, - } = useLogListSearchContext(); + const { hideSearch, filterLogs, matchingUids, search, setMatchingUids, setSearch, searchVisible, toggleFilterLogs } = + useLogListSearchContext(); const { displayedFields, noInteractions } = useLogListContext(); - const [search, setSearch] = useState(''); const [currentResult, setCurrentResult] = useState(null); - const inputRef = useRef(''); + const inputRef = useRef(null); const searchUsedRef = useRef(false); const styles = useStyles2(getStyles); @@ -43,16 +35,15 @@ export const LogListSearch = ({ listRef, logs }: Props) => { const handleChange = useCallback( (e: ChangeEvent) => { - inputRef.current = e.target.value; startTransition(() => { - setSearch(inputRef.current); + setSearch(inputRef.current?.value ?? ''); }); if (!searchUsedRef.current && !noInteractions) { reportInteraction('logs_log_list_search_used'); searchUsedRef.current = true; } }, - [noInteractions] + [noInteractions, setSearch] ); const prevResult = useCallback(() => { @@ -78,19 +69,27 @@ export const LogListSearch = ({ listRef, logs }: Props) => { setCurrentResult(null); return; } - if (!currentResult) { + if (currentResult === null) { setCurrentResult(0); - listRef?.scrollToItem(logs.indexOf(matches[0]), 'center'); + // No need to filter if we're only showing matching logs, otherwise scroll to the first result. + if (!filterLogs) { + listRef?.scrollToItem(logs.indexOf(matches[0]), 'center'); + } } - }, [currentResult, listRef, logs, matches]); + }, [currentResult, filterLogs, listRef, logs, matches]); useEffect(() => { if (!searchVisible) { - setSearch(''); - setContextSearch(undefined); setMatchingUids(null); } - }, [searchVisible, setContextSearch, setMatchingUids]); + }, [searchVisible, setMatchingUids]); + + useEffect(() => { + if (!inputRef.current || !search) { + return; + } + inputRef.current.value = search; + }, [search]); useEffect(() => { const newMatchingUids = matches.map((log) => log.uid); @@ -104,13 +103,12 @@ export const LogListSearch = ({ listRef, logs }: Props) => { .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]); + }, [logs, matches, matchingUids, search, setMatchingUids]); if (!searchVisible) { return null; @@ -126,6 +124,7 @@ export const LogListSearch = ({ listRef, logs }: Props) => { onChange={handleChange} autoFocus placeholder={t('logs.log-list-search.input-placeholder', 'Search in logs')} + ref={inputRef} suffix={suffix} /> @@ -141,22 +140,22 @@ export const LogListSearch = ({ listRef, logs }: Props) => { onClick={prevResult} disabled={!matches || !matches.length} name="angle-up" - aria-label={t('logs.log-list-search.prev', 'Previous result')} + tooltip={t('logs.log-list-search.prev', 'Previous result')} /> - + ); }; diff --git a/public/app/features/logs/components/panel/LogListSearchContext.tsx b/public/app/features/logs/components/panel/LogListSearchContext.tsx index ff1548a56cf..22be0c251a6 100644 --- a/public/app/features/logs/components/panel/LogListSearchContext.tsx +++ b/public/app/features/logs/components/panel/LogListSearchContext.tsx @@ -7,7 +7,7 @@ export interface LogListSearchContextData { search?: string; searchVisible?: boolean; setMatchingUids: (matches: string[] | null) => void; - setSearch: (search: string | undefined) => void; + setSearch: (search: string) => void; showSearch: () => void; toggleFilterLogs: () => void; } @@ -33,13 +33,14 @@ export const useLogListSearchContext = (): LogListSearchContextData => { }; export const LogListSearchContextProvider = ({ children }: { children: ReactNode }) => { - const [search, setSearch] = useState(undefined); + const [search, setSearch] = useState(''); const [searchVisible, setSearchVisible] = useState(false); const [matchingUids, setMatchingUids] = useState(null); const [filterLogs, setFilterLogs] = useState(false); const hideSearch = useCallback(() => { setSearchVisible(false); + setSearch(''); }, []); const showSearch = useCallback(() => { diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index dcb391c8e07..9132c2b7e6e 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -336,7 +336,7 @@ function countNewLines(log: string, limit = Infinity) { let count = 0; for (let i = 0; i < log.length; ++i) { // No need to iterate further - if (count > Infinity) { + if (count > limit) { return count; } if (log[i] === '\n') { diff --git a/public/app/features/logs/components/panel/useKeyBindings.ts b/public/app/features/logs/components/panel/useKeyBindings.ts index 6a75dcfcf2b..21776b15bb7 100644 --- a/public/app/features/logs/components/panel/useKeyBindings.ts +++ b/public/app/features/logs/components/panel/useKeyBindings.ts @@ -15,7 +15,7 @@ export const useKeyBindings = () => { const { showDetails, detailsMode, closeDetails } = useLogDetailsContext(); useEffect(() => { - function handleToggleSearch(event: KeyboardEvent) { + function handleOpenSearch(event: KeyboardEvent) { const isMac = navigator.userAgent.includes('Mac'); const isFKey = event.key === 'f' || event.key === 'F'; @@ -23,6 +23,8 @@ export const useKeyBindings = () => { showSearch(); return; } + } + function handleClose(event: KeyboardEvent) { if (event.key === 'Escape' && searchVisible) { hideSearch(); } @@ -30,9 +32,11 @@ export const useKeyBindings = () => { closeDetails(); } } - document.addEventListener('keydown', handleToggleSearch); + document.addEventListener('keydown', handleOpenSearch); + document.addEventListener('keyup', handleClose); return () => { - document.removeEventListener('keydown', handleToggleSearch); + document.removeEventListener('keydown', handleOpenSearch); + document.removeEventListener('keyup', handleClose); }; }, [closeDetails, detailsMode, hideSearch, searchVisible, showDetails.length, showSearch]); }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f62b26211f6..c5fc2f1d935 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -10181,7 +10181,8 @@ "copy": "Copy selection", "disable-menu": "Disable menu", "line-contains": "Add as line contains filter", - "line-contains-not": "Add as line does not contain filter" + "line-contains-not": "Add as line does not contain filter", + "search-text": "Search in results" }, "show-log-attributes": "Display log attributes for OTel logs", "timestamp-format": "Timestamp resolution",