@@ -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",