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:
co-authored by
Matias Chomicki
parent
1b9e0fae8d
commit
015219e49f
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user