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<string, GrammarValue> 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 <matyax@gmail.com>
This commit is contained in:
Oleg Zaytsev
2025-12-17 10:43:50 +01:00
committed by GitHub
co-authored by Matias Chomicki
parent 1b9e0fae8d
commit 015219e49f
7 changed files with 62 additions and 34 deletions
@@ -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 = ({
/>
)}
<Menu.Divider />
{onClickSearchString && (
<Menu.Item
label={t('logs.popover-menu.search-text', 'Search in results')}
onClick={() => {
onClickSearchString(selection);
close();
track('search_text', selection.length, row.datasourceType);
}}
/>
)}
<Menu.Divider />
<Menu.Item label={t('logs.popover-menu.disable-menu', 'Disable menu')} onClick={onDisable} />
</Menu>
</div>
@@ -280,6 +280,7 @@ const LogListComponent = ({
wrapLogMessage,
} = useLogListContext();
const { detailsMode, showDetails, toggleDetails } = useLogDetailsContext();
const { setSearch, showSearch } = useLogListSearchContext();
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
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}
/>
)}
@@ -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<number | null>(null);
const inputRef = useRef('');
const inputRef = useRef<HTMLInputElement>(null);
const searchUsedRef = useRef(false);
const styles = useStyles2(getStyles);
@@ -43,16 +35,15 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
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}
/>
</div>
@@ -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')}
/>
<IconButton
onClick={nextResult}
disabled={!matches || !matches.length}
name="angle-down"
aria-label={t('logs.log-list-search.next', 'Next result')}
tooltip={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')}
tooltip={t('logs.log-list-search.filter', 'Filter matching logs')}
/>
<IconButton onClick={hideSearch} name="times" aria-label={t('logs.log-list-search.close', 'Close search')} />
<IconButton onClick={hideSearch} name="times" tooltip={t('logs.log-list-search.close', 'Close search')} />
</div>
);
};
@@ -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<string | undefined>(undefined);
const [search, setSearch] = useState<string>('');
const [searchVisible, setSearchVisible] = useState(false);
const [matchingUids, setMatchingUids] = useState<string[] | null>(null);
const [filterLogs, setFilterLogs] = useState(false);
const hideSearch = useCallback(() => {
setSearchVisible(false);
setSearch('');
}, []);
const showSearch = useCallback(() => {
@@ -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') {
@@ -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]);
};
+2 -1
View File
@@ -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",