diff --git a/packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts deleted file mode 100644 index b758575dba2..00000000000 --- a/packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// public/app/plugins/gen.go -// Using jennies: -// TSTypesJenny -// PluginTsTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -import * as common from '@grafana/schema'; - -export const pluginVersion = "11.6.0-pre"; - -export interface Options { - dedupStrategy: common.LogsDedupStrategy; - enableInfiniteScrolling?: boolean; - enableLogDetails: boolean; - onNewLogsReceived?: unknown; - showTime: boolean; - sortOrder: common.LogsSortOrder; - wrapLogMessage: boolean; -} diff --git a/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts index 5ff339342be..06c1a2c7735 100644 --- a/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts @@ -16,8 +16,12 @@ export interface Options { dedupStrategy: common.LogsDedupStrategy; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; + grammar?: unknown; + onLogOptionsChange?: unknown; onNewLogsReceived?: unknown; + showControls: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; + syntaxHighlighting: boolean; wrapLogMessage: boolean; } diff --git a/public/app/features/explore/ContentOutline/ContentOutlineAnalyticEvents.ts b/public/app/features/explore/ContentOutline/ContentOutlineAnalyticEvents.ts index 308203694da..d2fb7e2228d 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineAnalyticEvents.ts +++ b/public/app/features/explore/ContentOutline/ContentOutlineAnalyticEvents.ts @@ -36,9 +36,9 @@ export function contentOutlineTrackUnpinClicked() { }); } -export function contentOutlineTrackLevelFilter(level: { levelStr: string; logLevel: LogLevel }) { +export function contentOutlineTrackLevelFilter(level: LogLevel) { reportInteraction('explore_toolbar_contentoutline_clicked', { item: 'section', - type: `Logs:filter:${level.levelStr}`, + type: `Logs:filter:${level}`, }); } diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 1934a768a6e..42922f57cb8 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -55,10 +55,9 @@ import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/sh import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { LogRows } from 'app/features/logs/components/LogRows'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; -import { LogList } from 'app/features/logs/components/panel/LogList'; -import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization'; +import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList'; import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; -import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; +import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; import { isLokiQuery } from 'app/plugins/datasource/loki/queryUtils'; import { getState } from 'app/store/store'; @@ -82,7 +81,7 @@ import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; -import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; +import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; interface Props extends Themeable2 { width: number; @@ -225,6 +224,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const cancelFlippingTimer = useRef(undefined); const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {}); const topLogsRef = useRef(null); + const logLevelsRef = useRef(null); const tableHeight = getLogsTableHeight(); const styles = getStyles(theme, wrapLogMessage, tableHeight); @@ -265,38 +265,37 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { } // check if we have dataFrames that return the same level - const logLevelsArray: Array<{ levelStr: string; logLevel: LogLevel }> = []; + const logLevelsArray: LogLevel[] = []; logVolumeDataFrames.forEach((dataFrame) => { const { level } = getLogLevelInfo(dataFrame, logsVolumeData?.data ?? []); - logLevelsArray.push({ levelStr: level, logLevel: getLogLevel(level) }); + logLevelsArray.push(getLogLevelFromKey(level)); }); - const sortedLLArray = logLevelsArray.sort( - (a: { levelStr: string; logLevel: LogLevel }, b: { levelStr: string; logLevel: LogLevel }) => { - return levelsArr.indexOf(a.logLevel.toString()) > levelsArr.indexOf(b.logLevel.toString()) ? 1 : -1; - } + const sortedLLArray = logLevelsArray.sort((a: string, b: string) => + levelsArr.indexOf(a) > levelsArr.indexOf(b) ? 1 : -1 ); const logLevels = new Set(sortedLLArray); + logLevelsRef.current = Array.from(logLevels); if (logLevels.size > 1 && logsVolumeEnabled && numberOfLogVolumes === 1) { logLevels.forEach((level) => { const allLevelsSelected = hiddenLogLevels.length === 0; - const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr); + const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level); if (register) { register({ - title: level.levelStr, + title: level, icon: 'gf-logs', panelId: PINNED_LOGS_PANELID, level: 'child', type: 'filter', highlight: currentLevelSelected && !allLevelsSelected, onClick: (e: React.MouseEvent) => { - toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); + toggleLegendRef.current?.(level, mapMouseEventToMode(e)); contentOutlineTrackLevelFilter(level); }, ref: null, - color: LogLevelColor[level.logLevel], + color: LogLevelColor[level], }); } }); @@ -450,42 +449,50 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { [scrollElement] ); - const onChangeLogsSortOrder = () => { - setIsFlipping(true); - // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs - flipOrderTimer.current = window.setTimeout(() => { - const newSortOrder = - logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; - store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); - if (logsQueries) { - let hasLokiQueries = false; - const newQueries = logsQueries.map((query) => { - if (query.datasource?.type !== 'loki' || !isLokiQuery(query)) { - return query; - } - if (query.direction === LokiQueryDirection.Scan) { - // Don't override Scan. When the direction is Scan it means that the user specifically assigned this direction to the query. - return query; - } - hasLokiQueries = true; - const newDirection = - newSortOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward; - if (newDirection !== query.direction) { - query.direction = newDirection; - } - return query; - }); - - if (hasLokiQueries) { - dispatch(changeQueries({ exploreId, queries: newQueries })); - dispatch(runQueries({ exploreId })); - } + const sortOrderChanged = useCallback( + (newSortOrder: LogsSortOrder) => { + if (!logsQueries) { + return; } + let hasLokiQueries = false; + const newQueries = logsQueries.map((query) => { + if (query.datasource?.type !== 'loki' || !isLokiQuery(query)) { + return query; + } + if (query.direction === LokiQueryDirection.Scan) { + // Don't override Scan. When the direction is Scan it means that the user specifically assigned this direction to the query. + return query; + } + hasLokiQueries = true; + const newDirection = + newSortOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward; + if (newDirection !== query.direction) { + query.direction = newDirection; + } + return query; + }); - setLogsSortOrder(newSortOrder); - }, 0); - cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000); - }; + if (hasLokiQueries) { + dispatch(changeQueries({ exploreId, queries: newQueries })); + dispatch(runQueries({ exploreId })); + } + }, + [dispatch, exploreId, logsQueries] + ); + + const onChangeLogsSortOrder = useCallback( + (newSortOrder: LogsSortOrder) => { + setIsFlipping(true); + // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs + flipOrderTimer.current = window.setTimeout(() => { + store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); + sortOrderChanged(newSortOrder); + setLogsSortOrder(newSortOrder); + }, 0); + cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000); + }, + [sortOrderChanged] + ); const onEscapeNewlines = useCallback(() => { setForceEscape(!forceEscape); @@ -561,7 +568,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { }, []); const onToggleLogLevel = useCallback((hiddenRawLevels: string[]) => { - const hiddenLogLevels = hiddenRawLevels.map((level) => getLogLevelFromKey(level)); + const hiddenLogLevels = hiddenRawLevels.map(getLogLevelFromKey); setHiddenLogLevels(hiddenLogLevels); }, []); @@ -696,13 +703,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const scrollToTopLogs = useCallback(() => { - if (config.featureToggles.newLogsPanel) { - eventBus.publish( - new ScrollToLogsEvent({ - scrollTo: 'top', - }) - ); - } else if (config.featureToggles.logsInfiniteScrolling) { + if (config.featureToggles.logsInfiniteScrolling) { if (logsContainerRef.current) { logsContainerRef.current.scroll({ behavior: 'auto', @@ -711,25 +712,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { } } topLogsRef.current?.scrollIntoView(); - }, [eventBus]); - - const scrollToBottomLogs = useCallback(() => { - if (config.featureToggles.newLogsPanel) { - eventBus.publish( - new ScrollToLogsEvent({ - scrollTo: 'bottom', - }) - ); - } else if (config.featureToggles.logsInfiniteScrolling) { - if (logsContainerRef.current) { - logsContainerRef.current.scroll({ - behavior: 'auto', - top: logsContainerRef.current.scrollHeight, - }); - } - } - topLogsRef.current?.scrollTo(0, topLogsRef.current.scrollHeight); - }, [eventBus]); + }, []); const onPinToContentOutlineClick = useCallback( (row: LogRowModel, allowUnPin = true) => { @@ -789,6 +772,44 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { [logsQueries] ); + const onLogOptionsChange = useCallback( + (option: keyof LogListControlOptions, value: string | string[] | boolean) => { + if (option === 'sortOrder' && (value === LogsSortOrder.Ascending || value === LogsSortOrder.Descending)) { + sortOrderChanged(value); + } else if (option === 'filterLevels' && Array.isArray(value)) { + if (value.length === 0) { + setHiddenLogLevels([]); + return; + } + const allLevels = logLevelsRef.current ?? Object.keys(LogLevelColor).map(getLogLevelFromKey); + if (hiddenLogLevels.length === 0) { + toggleLegendRef.current?.(value[0], SeriesVisibilityChangeMode.ToggleSelection); + setHiddenLogLevels(allLevels.filter((level) => level !== value[0])); + return; + } + const appendsLevel = value.find((level) => hiddenLogLevels.includes(getLogLevelFromKey(level))); + const removesLevel = allLevels.find((level) => !value.includes(level) && !hiddenLogLevels.includes(level)); + if (appendsLevel) { + toggleLegendRef.current?.(appendsLevel, SeriesVisibilityChangeMode.AppendToSelection); + setHiddenLogLevels(hiddenLogLevels.filter((hiddenLevel) => hiddenLevel === appendsLevel)); + return; + } else if (removesLevel) { + toggleLegendRef.current?.(removesLevel, SeriesVisibilityChangeMode.AppendToSelection); + setHiddenLogLevels([...hiddenLogLevels, removesLevel]); + } + } + }, + [hiddenLogLevels, sortOrderChanged] + ); + + const filterLevels: LogLevel[] | undefined = useMemo( + () => + !logLevelsRef.current + ? undefined + : logLevelsRef.current.filter((level) => hiddenLogLevels.length > 0 && !hiddenLogLevels.includes(level)), + [hiddenLogLevels] + ); + return ( <> {getRowContext && contextRow && ( @@ -865,7 +886,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { loadingState={loading ? LoadingState.Loading : LoadingState.Done} >
- {visualisationType !== 'table' && ( + {visualisationType !== 'table' && !config.featureToggles.newLogsPanel && (
= (props: Props) => { )} {visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && ( - <> -
- {logsContainerRef.current && ( - - )} -
- - +
+ {logsContainerRef.current && ( + + )} +
)} {!loading && !hasData && !scanning && (
@@ -1226,7 +1237,7 @@ const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => return { dedupedRows, dedupCount }; }; -const filterRows = (logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { +const filterRows = (logRows: LogRowModel[], hiddenLogLevels: string[]) => { return filterLogLevels(logRows, new Set(hiddenLogLevels)); }; diff --git a/public/app/features/explore/Logs/utils/logs.ts b/public/app/features/explore/Logs/utils/logs.ts index 2aec91da980..1e8cd037436 100644 --- a/public/app/features/explore/Logs/utils/logs.ts +++ b/public/app/features/explore/Logs/utils/logs.ts @@ -7,4 +7,6 @@ export const SETTINGS_KEYS = { logContextWrapLogMessage: 'grafana.explore.logs.logContext.wrapLogMessage', }; +export const SETTING_KEY_ROOT = 'grafana.explore.logs'; + export const visualisationTypeKey = 'grafana.explore.logs.visualisationType'; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 774bd22612d..952f2f2a3cf 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -1,16 +1,28 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createTheme } from '@grafana/data'; +import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../__mocks__/logRow'; import { getStyles, LogLine } from './LogLine'; +import { LogListContextProvider } from './LogListContext'; import { LogListModel } from './processing'; +jest.mock('./LogListContext'); + const theme = createTheme(); const styles = getStyles(theme); +const contextProps = { + app: CoreApp.Unknown, + dedupStrategy: LogsDedupStrategy.exact, + displayedFields: [], + showControls: false, + showTime: false, + sortOrder: LogsSortOrder.Ascending, + wrapLogMessage: false, +}; describe('LogLine', () => { let log: LogListModel; @@ -102,4 +114,69 @@ describe('LogLine', () => { expect(screen.getByText('Copy log line')).toBeInTheDocument(); }); }); + + describe('Syntax highlighting', () => { + beforeEach(() => { + log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` }); + }); + + test('Highlights relevant tokens in the log line', () => { + render( + + ); + expect(screen.getByText('place')).toBeInTheDocument(); + expect(screen.getByText('1ms')).toBeInTheDocument(); + expect(screen.getByText('3 KB')).toBeInTheDocument(); + expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument(); + }); + + test('Can be disabled', () => { + render( + + + + ); + expect(screen.getByText(`place="luna" 1ms 3 KB`)).toBeInTheDocument(); + expect(screen.queryByText('place')).not.toBeInTheDocument(); + expect(screen.queryByText('1ms')).not.toBeInTheDocument(); + expect(screen.queryByText('3 KB')).not.toBeInTheDocument(); + }); + + test('Does not alter 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(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 9c26236b9b4..db563f050bf 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { CSSProperties, useEffect, useRef } from 'react'; +import { CSSProperties, useCallback, useEffect, useRef } from 'react'; import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; @@ -8,7 +8,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LogMessageAnsi } from '../LogMessageAnsi'; import { LogLineMenu } from './LogLineMenu'; -import { useLogIsPinned } from './LogListContext'; +import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight, LogFieldDimension } from './virtualization'; @@ -35,9 +35,14 @@ export const LogLine = ({ variant, wrapLogMessage, }: Props) => { + const { onLogLineHover } = useLogListContext(); const logLineRef = useRef(null); const pinned = useLogIsPinned(log); + const handleMouseOver = useCallback(() => { + onLogLineHover?.(log); + }, [log, onLogLineHover]); + useEffect(() => { if (!onOverflow || !logLineRef.current) { return; @@ -54,6 +59,7 @@ export const LogLine = ({ style={style} className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`} ref={onOverflow ? logLineRef : undefined} + onMouseOver={handleMouseOver} >
@@ -105,17 +111,23 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp }; const LogLineBody = ({ log }: { log: LogListModel }) => { + const { syntaxHighlighting } = useLogListContext(); + 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 ( - + ); } + if (!syntaxHighlighting) { + return {log.body}; + } + return ; }; @@ -142,11 +154,11 @@ export type LogLineStyles = ReturnType; export const getStyles = (theme: GrafanaTheme2) => { const colors = { critical: '#B877D9', - error: '#f22f44', + error: theme.colors.error.text, warning: '#FBAD37', - debug: '#6CCF8E', + debug: '#6E9FFF', trace: '#6ed0e0', - info: '#6E9FFF', + info: '#6CCF8E', metadata: theme.colors.text.primary, parsedField: theme.colors.text.primary, }; @@ -161,7 +173,7 @@ export const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.fontSize, wordBreak: 'break-all', '&:hover': { - background: `hsla(0, 0%, 0%, 0.1)`, + background: `hsla(0, 0%, 0%, 0.2)`, }, '&.infinite-scroll': { '&::before': { @@ -205,6 +217,9 @@ export const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.info.shade, }, }, + '& .no-highlighting': { + color: theme.colors.text.primary, + }, }), pinnedLogLine: css({ backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(), diff --git a/public/app/features/logs/components/panel/LogLineMenu.test.tsx b/public/app/features/logs/components/panel/LogLineMenu.test.tsx index 27c8b78f9b9..1eebfd1a3a9 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.test.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.test.tsx @@ -1,17 +1,28 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createTheme } from '@grafana/data'; +import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import { createLogLine } from '../__mocks__/logRow'; import { getStyles } from './LogLine'; import { LogLineMenu } from './LogLineMenu'; -import { LogListContext } from './LogListContext'; +import { LogListContextProvider } from './LogListContext'; import { LogListModel } from './processing'; +jest.mock('./LogListContext'); + const theme = createTheme(); const styles = getStyles(theme); +const contextProps = { + app: CoreApp.Unknown, + dedupStrategy: LogsDedupStrategy.exact, + displayedFields: [], + showControls: false, + showTime: false, + sortOrder: LogsSortOrder.Ascending, + wrapLogMessage: false, +}; describe('LogLineMenu', () => { let log: LogListModel; @@ -30,9 +41,9 @@ describe('LogLineMenu', () => { test('Allows to copy a permalink', async () => { const onPermalinkClick = jest.fn(); render( - + - + ); await userEvent.click(screen.getByLabelText('Log menu')); await userEvent.click(screen.getByText('Copy link to log line')); @@ -44,9 +55,14 @@ describe('LogLineMenu', () => { const logSupportsContext = jest.fn().mockReturnValue(true); const getRowContextQuery = jest.fn(); render( - + - + ); await userEvent.click(screen.getByLabelText('Log menu')); await userEvent.click(screen.getByText('Show context')); @@ -58,9 +74,14 @@ describe('LogLineMenu', () => { const logSupportsContext = jest.fn().mockReturnValue(false); const getRowContextQuery = jest.fn(); render( - + - + ); await userEvent.click(screen.getByLabelText('Log menu')); expect(screen.queryByText('Show context')).not.toBeInTheDocument(); @@ -69,9 +90,9 @@ describe('LogLineMenu', () => { test('Allows to pin log line', async () => { const onPinLine = jest.fn(); render( - + - + ); await userEvent.click(screen.getByLabelText('Log menu')); await userEvent.click(screen.getByText('Pin log')); @@ -81,9 +102,9 @@ describe('LogLineMenu', () => { test('Allows to unpin log line', async () => { const onUnpinLine = jest.fn(); render( - + - + ); await userEvent.click(screen.getByLabelText('Log menu')); expect(screen.queryByText('Pin log')).not.toBeInTheDocument(); diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx new file mode 100644 index 00000000000..be99c5bb1cd --- /dev/null +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; + +import { createLogRow } from '../__mocks__/logRow'; + +import { LogList } from './LogList'; + +const logs: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' })]; + +describe('LogList', () => { + test('Renders a list of logs without controls ', async () => { + const containerElement = document.createElement('div'); + render( + + ); + expect(screen.getByText('log message 1')).toBeInTheDocument(); + expect(screen.getByText('log message 2')).toBeInTheDocument(); + expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument(); + }); + + test('Renders a list of logs with controls', async () => { + const containerElement = document.createElement('div'); + render( + + ); + expect(screen.getByText('log message 1')).toBeInTheDocument(); + expect(screen.getByText('log message 2')).toBeInTheDocument(); + expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); + }); + + test('Reports mouse over events', async () => { + const containerElement = document.createElement('div'); + const onLogRowHover = jest.fn(); + render( + + ); + await userEvent.hover(screen.getByText('log message 1')); + expect(onLogRowHover).toHaveBeenCalledTimes(1); + expect(onLogRowHover).toHaveBeenCalledWith(expect.objectContaining(logs[0])); + }); +}); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 3d645e917ff..4c3a81cf1d4 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import { debounce } from 'lodash'; +import { Grammar } from 'prismjs'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { VariableSizeList } from 'react-window'; @@ -8,9 +9,12 @@ import { CoreApp, DataFrame, EventBus, + EventBusSrv, Field, LinkModel, + LogLevel, LogRowModel, + LogsDedupStrategy, LogsSortOrder, TimeRange, } from '@grafana/data'; @@ -19,7 +23,8 @@ import { PopoverContent, useTheme2 } from '@grafana/ui'; import { InfiniteScroll } from './InfiniteScroll'; import { getGridTemplateColumns } from './LogLine'; import { GetRowContextQueryFn } from './LogLineMenu'; -import { LogListContext } from './LogListContext'; +import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; +import { LogListControls } from './LogListControls'; import { preProcessLogs, LogListModel } from './processing'; import { calculateFieldDimensions, @@ -36,45 +41,130 @@ export type GetFieldLinksFn = (field: Field, rowIndex: number, dataFrame: DataFr interface Props { app: CoreApp; containerElement: HTMLDivElement; + dedupStrategy: LogsDedupStrategy; displayedFields: string[]; - eventBus: EventBus; + eventBus?: EventBus; + filterLevels?: LogLevel[]; forceEscape?: boolean; getFieldLinks?: GetFieldLinksFn; getRowContextQuery?: GetRowContextQueryFn; + grammar?: Grammar; initialScrollPosition?: 'top' | 'bottom'; loadMore?: (range: AbsoluteTimeRange) => void; + logOptionsStorageKey?: string; logs: LogRowModel[]; logSupportsContext?: (row: LogRowModel) => boolean; + onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; + onLogLineHover?: (row?: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; onPinLine?: (row: LogRowModel) => void; onOpenContext?: (row: LogRowModel, onClose: () => void) => void; onUnpinLine?: (row: LogRowModel) => void; pinLineButtonTooltipTitle?: PopoverContent; pinnedLogs?: string[]; + showControls: boolean; showTime: boolean; sortOrder: LogsSortOrder; + storageKey?: string; timeRange: TimeRange; timeZone: string; + syntaxHighlighting?: boolean; wrapLogMessage: boolean; } +export type LogListControlOptions = LogListState; + +type LogListComponentProps = Omit< + Props, + 'app' | 'dedupStrategy' | 'displayedFields' | 'showTime' | 'sortOrder' | 'syntaxHighlighting' | 'wrapLogMessage' +>; + export const LogList = ({ app, + displayedFields, containerElement, - displayedFields = [], + dedupStrategy, eventBus, + filterLevels, forceEscape = false, getFieldLinks, + getRowContextQuery, + grammar, initialScrollPosition = 'top', loadMore, + logOptionsStorageKey, logs, + logSupportsContext, + onLogOptionsChange, + onLogLineHover, + onPermalinkClick, + onPinLine, + onOpenContext, + onUnpinLine, + pinLineButtonTooltipTitle, + pinnedLogs, + showControls, showTime, sortOrder, + syntaxHighlighting, timeRange, timeZone, wrapLogMessage, - ...logListContext }: Props) => { + return ( + + + + ); +}; + +const LogListComponent = ({ + containerElement, + eventBus = new EventBusSrv(), + forceEscape = false, + getFieldLinks, + grammar, + initialScrollPosition = 'top', + loadMore, + logs, + showControls, + timeRange, + timeZone, +}: LogListComponentProps) => { + const { app, displayedFields, filterLevels, showTime, sortOrder, wrapLogMessage } = useLogListContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState( app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight @@ -101,8 +191,8 @@ export const LogList = ({ }, [eventBus, logs.length]); useEffect(() => { - setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone })); - }, [forceEscape, getFieldLinks, logs, sortOrder, timeZone]); + setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone }, grammar)); + }, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]); useEffect(() => { resetLogLineSizes(); @@ -148,12 +238,18 @@ export const LogList = ({ return null; } + const filteredLogs = useMemo( + () => + filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)), + [filterLevels, processedLogs] + ); + return ( - +
)} - + {showControls && } +
); }; @@ -197,6 +295,9 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo gridTemplateColumns: getGridTemplateColumns(columns), }, }), + logListContainer: css({ + display: 'flex', + }), }; } diff --git a/public/app/features/logs/components/panel/LogListContext.test.tsx b/public/app/features/logs/components/panel/LogListContext.test.tsx index 3505c554797..6244b7a66f8 100644 --- a/public/app/features/logs/components/panel/LogListContext.test.tsx +++ b/public/app/features/logs/components/panel/LogListContext.test.tsx @@ -4,9 +4,11 @@ import { ReactNode } from 'react'; import { createLogLine } from '../__mocks__/logRow'; import { useLogListContextData, useLogListContext, useLogIsPinned, LogListContext } from './LogListContext'; +import { defaultProps } from './__mocks__/LogListContext'; const log = createLogLine({ rowId: 'yep' }); const value = { + ...defaultProps, getRowContextQuery: jest.fn(), logSupportsContext: jest.fn(), onPermalinkClick: jest.fn(), diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 9f654cf4dce..d90559d782a 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -1,22 +1,51 @@ -import { createContext, useContext } from 'react'; +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; -import { LogRowModel } from '@grafana/data'; +import { CoreApp, LogLevel, LogRowModel, LogsDedupStrategy, LogsSortOrder, shallowCompare, store } from '@grafana/data'; import { PopoverContent } from '@grafana/ui'; import { GetRowContextQueryFn } from './LogLineMenu'; -export interface LogListContextData { - getRowContextQuery?: GetRowContextQueryFn; - logSupportsContext?: (row: LogRowModel) => boolean; - onPermalinkClick?: (row: LogRowModel) => Promise; - onPinLine?: (row: LogRowModel) => void; - onOpenContext?: (row: LogRowModel, onClose: () => void) => void; - onUnpinLine?: (row: LogRowModel) => void; - pinLineButtonTooltipTitle?: PopoverContent; - pinnedLogs?: string[]; +export interface LogListContextData extends Omit { + filterLevels: LogLevel[]; + setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; + setDisplayedFields: (displayedFields: string[]) => void; + setFilterLevels: (filterLevels: LogLevel[]) => void; + setLogListState: Dispatch>; + setPinnedLogs: (pinnedlogs: string[]) => void; + setSyntaxHighlighting: (syntaxHighlighting: boolean) => void; + setShowTime: (showTime: boolean) => void; + setSortOrder: (sortOrder: LogsSortOrder) => void; + setWrapLogMessage: (showTime: boolean) => void; } -export const LogListContext = createContext({}); +export const LogListContext = createContext({ + app: CoreApp.Unknown, + dedupStrategy: LogsDedupStrategy.none, + displayedFields: [], + filterLevels: [], + setDedupStrategy: () => {}, + setDisplayedFields: () => {}, + setFilterLevels: () => {}, + setLogListState: () => {}, + setPinnedLogs: () => {}, + setShowTime: () => {}, + setSortOrder: () => {}, + setSyntaxHighlighting: () => {}, + setWrapLogMessage: () => {}, + showTime: true, + sortOrder: LogsSortOrder.Ascending, + syntaxHighlighting: true, + wrapLogMessage: false, +}); export const useLogListContextData = (key: keyof LogListContextData) => { const data: LogListContextData = useContext(LogListContext); @@ -31,3 +60,230 @@ export const useLogIsPinned = (log: LogRowModel) => { const { pinnedLogs } = useContext(LogListContext); return pinnedLogs?.some((logId) => logId === log.rowId); }; + +export type LogListState = Pick< + LogListContextData, + | 'dedupStrategy' + | 'displayedFields' + | 'filterLevels' + | 'pinnedLogs' + | 'showTime' + | 'sortOrder' + | 'syntaxHighlighting' + | 'wrapLogMessage' +>; + +export interface Props { + app: CoreApp; + children?: ReactNode; + dedupStrategy: LogsDedupStrategy; + displayedFields: string[]; + filterLevels?: LogLevel[]; + getRowContextQuery?: GetRowContextQueryFn; + logOptionsStorageKey?: string; + logSupportsContext?: (row: LogRowModel) => boolean; + onLogOptionsChange?: (option: keyof LogListState, value: string | boolean | string[]) => void; + onLogLineHover?: (row?: LogRowModel) => void; + onPermalinkClick?: (row: LogRowModel) => Promise; + onPinLine?: (row: LogRowModel) => void; + onOpenContext?: (row: LogRowModel, onClose: () => void) => void; + onUnpinLine?: (row: LogRowModel) => void; + pinLineButtonTooltipTitle?: PopoverContent; + pinnedLogs?: string[]; + showControls: boolean; + showTime: boolean; + sortOrder: LogsSortOrder; + syntaxHighlighting?: boolean; + wrapLogMessage: boolean; +} + +export const LogListContextProvider = ({ + app, + children, + dedupStrategy, + displayedFields, + getRowContextQuery, + logOptionsStorageKey, + filterLevels, + logSupportsContext, + onLogOptionsChange, + onLogLineHover, + onPermalinkClick, + onPinLine, + onOpenContext, + onUnpinLine, + pinLineButtonTooltipTitle, + pinnedLogs, + showControls, + showTime, + sortOrder, + syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true, + wrapLogMessage, +}: Props) => { + const [logListState, setLogListState] = useState({ + dedupStrategy, + displayedFields, + filterLevels: + filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), + pinnedLogs, + showTime, + sortOrder, + syntaxHighlighting, + wrapLogMessage, + }); + + useEffect(() => { + // Props are updated in the context only of the panel is being externally controlled. + if (showControls && app !== CoreApp.PanelEditor) { + return; + } + const newState = { + ...logListState, + dedupStrategy, + showTime, + sortOrder, + syntaxHighlighting, + wrapLogMessage, + }; + if (!shallowCompare(logListState.displayedFields, displayedFields)) { + newState.displayedFields = displayedFields; + } + if (!shallowCompare(logListState.pinnedLogs ?? [], pinnedLogs ?? [])) { + newState.pinnedLogs = pinnedLogs; + } + if (!shallowCompare(logListState, newState)) { + setLogListState(newState); + } + }, [ + app, + dedupStrategy, + displayedFields, + logListState, + pinnedLogs, + showControls, + showTime, + sortOrder, + syntaxHighlighting, + wrapLogMessage, + ]); + + useEffect(() => { + if (filterLevels === undefined) { + return; + } + if (!shallowCompare(logListState.filterLevels, filterLevels)) { + setLogListState({ ...logListState, filterLevels }); + } + }, [filterLevels, logListState]); + + const setDedupStrategy = useCallback( + (dedupStrategy: LogsDedupStrategy) => { + setLogListState({ ...logListState, dedupStrategy }); + onLogOptionsChange?.('dedupStrategy', dedupStrategy); + }, + [logListState, onLogOptionsChange] + ); + + const setDisplayedFields = useCallback( + (displayedFields: string[]) => { + setLogListState({ ...logListState, displayedFields }); + onLogOptionsChange?.('displayedFields', displayedFields); + }, + [logListState, onLogOptionsChange] + ); + + const setFilterLevels = useCallback( + (filterLevels: LogLevel[]) => { + setLogListState({ ...logListState, filterLevels }); + onLogOptionsChange?.('filterLevels', filterLevels); + }, + [logListState, onLogOptionsChange] + ); + + const setPinnedLogs = useCallback( + (pinnedLogs: string[]) => { + setLogListState({ ...logListState, pinnedLogs }); + onLogOptionsChange?.('pinnedLogs', pinnedLogs); + }, + [logListState, onLogOptionsChange] + ); + + const setShowTime = useCallback( + (showTime: boolean) => { + setLogListState({ ...logListState, showTime }); + onLogOptionsChange?.('showTime', showTime); + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.showTime`, showTime); + } + }, + [logListState, logOptionsStorageKey, onLogOptionsChange] + ); + + const setSyntaxHighlighting = useCallback( + (syntaxHighlighting: boolean) => { + setLogListState({ ...logListState, syntaxHighlighting }); + onLogOptionsChange?.('syntaxHighlighting', syntaxHighlighting); + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.syntaxHighlighting`, syntaxHighlighting); + } + }, + [logListState, logOptionsStorageKey, onLogOptionsChange] + ); + + const setSortOrder = useCallback( + (sortOrder: LogsSortOrder) => { + setLogListState({ ...logListState, sortOrder }); + onLogOptionsChange?.('sortOrder', sortOrder); + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.sortOrder`, sortOrder); + } + }, + [logListState, logOptionsStorageKey, onLogOptionsChange] + ); + + const setWrapLogMessage = useCallback( + (wrapLogMessage: boolean) => { + setLogListState({ ...logListState, wrapLogMessage }); + onLogOptionsChange?.('wrapLogMessage', wrapLogMessage); + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.wrapLogMessage`, wrapLogMessage); + } + }, + [logListState, logOptionsStorageKey, onLogOptionsChange] + ); + + return ( + + {children} + + ); +}; diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx new file mode 100644 index 00000000000..dce294ce772 --- /dev/null +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CoreApp, EventBusSrv, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; + +import { LogListContextProvider } from './LogListContext'; +import { LogListControls } from './LogListControls'; +import { ScrollToLogsEvent } from './virtualization'; + +const contextProps = { + app: CoreApp.Unknown, + dedupStrategy: LogsDedupStrategy.exact, + displayedFields: [], + showControls: true, + showTime: false, + sortOrder: LogsSortOrder.Ascending, + syntaxHighlighting: false, + wrapLogMessage: false, +}; + +describe('LogListControls', () => { + test('Renders without errors', () => { + render( + + + + ); + expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); + expect(screen.getByLabelText('Oldest logs first')).toBeInTheDocument(); + expect(screen.getByLabelText('Deduplication')).toBeInTheDocument(); + expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); + expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument(); + expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument(); + expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument(); + expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); + }); + + test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])( + 'Renders a subset of options for dashboards', + (app: CoreApp) => { + render( + + + + ); + expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); + expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); + expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); + expect(screen.queryByLabelText('Oldest logs first')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Deduplication')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Show timestamps')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Wrap lines')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Enable highlighting')).not.toBeInTheDocument(); + } + ); + + test('Allows to scroll', async () => { + const eventBus = new EventBusSrv(); + jest.spyOn(eventBus, 'publish'); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Scroll to bottom')); + await userEvent.click(screen.getByLabelText('Scroll to top')); + expect(eventBus.publish).toHaveBeenCalledTimes(2); + expect(eventBus.publish).toHaveBeenCalledWith( + new ScrollToLogsEvent({ + scrollTo: 'bottom', + }) + ); + expect(eventBus.publish).toHaveBeenCalledWith( + new ScrollToLogsEvent({ + scrollTo: 'top', + }) + ); + }); + + test('Controls sort order', async () => { + const onLogOptionsChange = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Oldest logs first')); + expect(onLogOptionsChange).toHaveBeenCalledTimes(1); + expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending); + }); + + test('Controls deduplication', async () => { + const onLogOptionsChange = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Deduplication')); + await userEvent.click(screen.getByText('Numbers')); + expect(onLogOptionsChange).toHaveBeenCalledTimes(1); + expect(onLogOptionsChange).toHaveBeenCalledWith('dedupStrategy', LogsDedupStrategy.numbers); + }); + + test('Sets level filters', async () => { + const onLogOptionsChange = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Display levels')); + expect(await screen.findByText('All levels')).toBeVisible(); + expect(screen.getByText('Info')).toBeVisible(); + expect(screen.getByText('Debug')).toBeVisible(); + expect(screen.getByText('Trace')).toBeVisible(); + expect(screen.getByText('Warning')).toBeVisible(); + expect(screen.getByText('Error')).toBeVisible(); + expect(screen.getByText('Critical')).toBeVisible(); + await userEvent.click(screen.getByText('Error')); + expect(onLogOptionsChange).toHaveBeenCalledWith('filterLevels', ['error']); + }); + + test('Controls timestamp visibility', async () => { + const onLogOptionsChange = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Show timestamps')); + expect(onLogOptionsChange).toHaveBeenCalledTimes(1); + expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true); + }); + + test('Controls line wrapping', async () => { + const onLogOptionsChange = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Wrap lines')); + expect(onLogOptionsChange).toHaveBeenCalledTimes(1); + expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true); + }); + + test('Controls syntax highlighting', async () => { + const onLogOptionsChange = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Enable highlighting')); + expect(onLogOptionsChange).toHaveBeenCalledTimes(1); + expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true); + }); +}); diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx new file mode 100644 index 00000000000..4c69aaca522 --- /dev/null +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -0,0 +1,313 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import { MouseEvent, useCallback, useMemo } from 'react'; + +import { + CoreApp, + EventBus, + GrafanaTheme2, + LogLevel, + LogsDedupDescription, + LogsDedupStrategy, + LogsSortOrder, +} from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Dropdown, IconButton, Menu, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { useLogListContext } from './LogListContext'; +import { ScrollToLogsEvent } from './virtualization'; + +type Props = { + eventBus: EventBus; +}; + +const DEDUP_OPTIONS = [ + LogsDedupStrategy.none, + LogsDedupStrategy.exact, + LogsDedupStrategy.numbers, + LogsDedupStrategy.signature, +]; + +const FILTER_LEVELS: LogLevel[] = [ + LogLevel.info, + LogLevel.debug, + LogLevel.trace, + LogLevel.warning, + LogLevel.error, + LogLevel.critical, +]; + +export const LogListControls = ({ eventBus }: Props) => { + const styles = useStyles2(getStyles); + const { + app, + dedupStrategy, + filterLevels, + setDedupStrategy, + setFilterLevels, + setShowTime, + setSortOrder, + setSyntaxHighlighting, + setWrapLogMessage, + showTime, + sortOrder, + syntaxHighlighting, + wrapLogMessage, + } = useLogListContext(); + + const onScrollToTopClick = useCallback(() => { + reportInteraction('logs_log_list_controls_scroll_top_clicked'); + eventBus.publish( + new ScrollToLogsEvent({ + scrollTo: 'top', + }) + ); + }, [eventBus]); + + const onScrollToBottomClick = useCallback(() => { + reportInteraction('logs_log_list_controls_scroll_bottom_clicked'); + eventBus.publish( + new ScrollToLogsEvent({ + scrollTo: 'bottom', + }) + ); + }, [eventBus]); + + const onFilterLevelClick = useCallback( + (level?: LogLevel) => { + reportInteraction('logs_log_list_controls_level_clicked'); + if (level === undefined) { + setFilterLevels([]); + } else if (!filterLevels.includes(level)) { + setFilterLevels([...filterLevels, level]); + } else { + setFilterLevels(filterLevels.filter((filterLevel) => filterLevel !== level)); + } + }, + [filterLevels, setFilterLevels] + ); + + const onShowTimestampsClick = useCallback(() => { + reportInteraction('logs_log_list_controls_show_time_clicked', { + show_time: showTime, + }); + setShowTime(!showTime); + }, [setShowTime, showTime]); + + const onSortOrderClick = useCallback(() => { + reportInteraction('logs_log_list_controls_sort_order_clicked', { + order: sortOrder === LogsSortOrder.Ascending ? LogsSortOrder.Descending : LogsSortOrder.Ascending, + }); + setSortOrder(sortOrder === LogsSortOrder.Ascending ? LogsSortOrder.Descending : LogsSortOrder.Ascending); + }, [setSortOrder, sortOrder]); + + const onSyntaxHightlightingClick = useCallback(() => { + reportInteraction('logs_log_list_controls_syntax_clicked', { + state: !syntaxHighlighting, + }); + setSyntaxHighlighting(!syntaxHighlighting); + }, [setSyntaxHighlighting, syntaxHighlighting]); + + const onWrapLogMessageClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + reportInteraction('logs_log_list_controls_wrap_clicked', { + state: !wrapLogMessage, + }); + setWrapLogMessage(!wrapLogMessage); + }, + [setWrapLogMessage, wrapLogMessage] + ); + + const deduplicationMenu = useMemo( + () => ( + + {DEDUP_OPTIONS.map((option) => ( + setDedupStrategy(option)} + /> + ))} + + ), + [dedupStrategy, setDedupStrategy, styles.menuItemActive] + ); + + const filterLevelsMenu = useMemo( + () => ( + + onFilterLevelClick()} + /> + {FILTER_LEVELS.map((level) => ( + onFilterLevelClick(level)} + /> + ))} + + ), + [filterLevels, onFilterLevelClick, styles.menuItemActive] + ); + + const inDashboard = app === CoreApp.Dashboard || app === CoreApp.PanelEditor || app === CoreApp.PanelViewer; + + return ( +
+ + {!inDashboard ? ( + <> + + + + + + 0 ? styles.controlButtonActive : styles.controlButton} + tooltip={t('logs.logs-controls.display-level', 'Display levels')} + size="lg" + /> + + + + + + ) : ( + + 0 ? styles.controlButtonActive : styles.controlButton} + tooltip={t('logs.logs-controls.display-level', 'Display levels')} + size="lg" + /> + + )} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + navContainer: css({ + maxHeight: '100%', + display: 'flex', + gap: theme.spacing(3), + flexDirection: 'column', + justifyContent: 'flex-start', + width: theme.spacing(4), + paddingTop: theme.spacing(0.75), + paddingLeft: theme.spacing(1), + borderLeft: `solid 1px ${theme.colors.border.medium}`, + }), + scrollToTopButton: css({ + margin: 0, + marginTop: 'auto', + }), + controlButton: css({ + margin: 0, + color: theme.colors.text.secondary, + height: theme.spacing(2), + }), + controlButtonActive: css({ + margin: 0, + color: theme.colors.text.secondary, + height: theme.spacing(2), + '&:after': { + display: 'block', + content: '" "', + position: 'absolute', + height: 2, + borderRadius: theme.shape.radius.default, + bottom: theme.spacing(-1), + backgroundImage: theme.colors.gradients.brandHorizontal, + width: '95%', + opacity: 1, + }, + }), + menuItemActive: css({ + '&:before': { + content: '""', + position: 'absolute', + left: 0, + top: theme.spacing(0.5), + height: `calc(100% - ${theme.spacing(1)})`, + width: '2px', + backgroundColor: theme.colors.warning.main, + }, + }), + }; +}; diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx new file mode 100644 index 00000000000..62508f2c357 --- /dev/null +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -0,0 +1,119 @@ +import { createContext, useContext } from 'react'; + +import { CoreApp, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; + +import { LogListContextData, Props } from '../LogListContext'; + +export const LogListContext = createContext({ + app: CoreApp.Unknown, + dedupStrategy: LogsDedupStrategy.none, + displayedFields: [], + filterLevels: [], + setDedupStrategy: () => {}, + setDisplayedFields: () => {}, + setFilterLevels: () => {}, + setLogListState: () => {}, + setPinnedLogs: () => {}, + setShowTime: () => {}, + setSortOrder: () => {}, + setSyntaxHighlighting: () => {}, + setWrapLogMessage: () => {}, + showTime: true, + sortOrder: LogsSortOrder.Ascending, + syntaxHighlighting: true, + wrapLogMessage: false, +}); + +export const useLogListContextData = (key: keyof LogListContextData) => { + const data: LogListContextData = useContext(LogListContext); + return data[key]; +}; + +export const useLogListContext = (): LogListContextData => { + return useContext(LogListContext); +}; + +export const useLogIsPinned = (log: LogRowModel) => { + const { pinnedLogs } = useContext(LogListContext); + return pinnedLogs?.some((logId) => logId === log.rowId); +}; + +export const defaultProps = { + app: CoreApp.Explore, + dedupStrategy: LogsDedupStrategy.none, + displayedFields: [], + filterLevels: [], + getRowContextQuery: jest.fn(), + logSupportsContext: jest.fn(), + onPermalinkClick: jest.fn(), + onPinLine: jest.fn(), + onOpenContext: jest.fn(), + onUnpinLine: jest.fn(), + pinnedLogs: [], + setDedupStrategy: jest.fn(), + setDisplayedFields: jest.fn(), + setFilterLevels: jest.fn(), + setLogListState: jest.fn(), + setPinnedLogs: jest.fn(), + setShowTime: jest.fn(), + setSortOrder: jest.fn(), + setSyntaxHighlighting: jest.fn(), + setWrapLogMessage: jest.fn(), + showControls: true, + showTime: true, + sortOrder: LogsSortOrder.Descending, + syntaxHighlighting: true, + wrapLogMessage: true, +}; + +export const LogListContextProvider = ({ + app = CoreApp.Explore, + children, + dedupStrategy = LogsDedupStrategy.none, + displayedFields = [], + filterLevels = [], + getRowContextQuery = jest.fn(), + logSupportsContext = jest.fn(), + onPermalinkClick = jest.fn(), + onPinLine = jest.fn(), + onOpenContext = jest.fn(), + onUnpinLine = jest.fn(), + pinnedLogs = [], + showTime = true, + sortOrder = LogsSortOrder.Descending, + syntaxHighlighting = true, + wrapLogMessage = true, +}: Partial) => { + return ( + + {children} + + ); +}; diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index f536264831d..2f451ff9983 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -1,4 +1,4 @@ -import Prism from 'prismjs'; +import Prism, { Grammar } from 'prismjs'; import { dateTimeFormat, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; @@ -26,10 +26,11 @@ export interface PreProcessOptions { export const preProcessLogs = ( logs: LogRowModel[], - { escape, getFieldLinks, order, timeZone }: PreProcessOptions + { escape, getFieldLinks, order, timeZone }: PreProcessOptions, + grammar?: Grammar ): LogListModel[] => { const orderedLogs = sortLogRows(logs, order); - return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone })); + return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone }, grammar)); }; interface PreProcessLogOptions { @@ -37,7 +38,11 @@ interface PreProcessLogOptions { getFieldLinks?: GetFieldLinksFn; timeZone: string; } -const preProcessLog = (log: LogRowModel, { escape, getFieldLinks, timeZone }: PreProcessLogOptions): LogListModel => { +const preProcessLog = ( + log: LogRowModel, + { escape, getFieldLinks, timeZone }: PreProcessLogOptions, + grammar?: Grammar +): LogListModel => { let body = log.raw; const timestamp = dateTimeFormat(log.timeEpochMs, { timeZone, @@ -56,7 +61,7 @@ const preProcessLog = (log: LogRowModel, { escape, getFieldLinks, timeZone }: Pr _highlightedBody: '', get highlightedBody() { if (!this._highlightedBody) { - this._highlightedBody = Prism.highlight(body, generateLogGrammar(this), 'lokiql'); + this._highlightedBody = Prism.highlight(body, grammar ? grammar : generateLogGrammar(this), 'lokiql'); } return this._highlightedBody; }, diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index b9aee82273d..d7e79638bb4 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -14,6 +14,7 @@ const iconWidth = 24; // Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body export const FIELD_GAP_MULTIPLIER = 1.5; +const LOG_LIST_NAVIGATION_WIDTH = 28; export const getLineHeight = () => lineHeight; @@ -149,6 +150,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = interface DisplayOptions { wrap: boolean; + showControls: boolean; showTime: boolean; } @@ -156,7 +158,7 @@ export function getLogLineSize( logs: LogListModel[], container: HTMLDivElement | null, displayedFields: string[], - { wrap, showTime }: DisplayOptions, + { wrap, showControls, showTime }: DisplayOptions, index: number ) { if (!container) { @@ -174,6 +176,9 @@ export function getLogLineSize( let textToMeasure = ''; const gap = gridSize * FIELD_GAP_MULTIPLIER; let optionsWidth = 0; + if (showControls) { + optionsWidth += LOG_LIST_NAVIGATION_WIDTH; + } if (showTime) { optionsWidth += gap; textToMeasure += logs[index].timestamp; diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index a63e464c62f..12a0b727c1a 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -102,7 +102,7 @@ export function dedupLogRows(rows: LogRowModel[], strategy?: LogsDedupStrategy): }, []); } -export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set): LogRowModel[] { +export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set): LogRowModel[] { if (hiddenLogLevels.size === 0) { return logRows; } diff --git a/public/app/plugins/panel/logs-new/LogsPanel.test.tsx b/public/app/plugins/panel/logs-new/LogsPanel.test.tsx new file mode 100644 index 00000000000..d9d5d35587e --- /dev/null +++ b/public/app/plugins/panel/logs-new/LogsPanel.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; + +import { + LoadingState, + createDataFrame, + FieldType, + LogsSortOrder, + getDefaultTimeRange, + LogsDedupStrategy, + EventBusSrv, + DataFrameType, + LogSortOrderChangeEvent, +} from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; + +import { LogsPanel } from './LogsPanel'; + +type LogsPanelProps = ComponentProps; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getAppEvents: jest.fn(), +})); + +const defaultProps = { + data: { + error: undefined, + request: { + panelId: 4, + app: 'dashboard', + requestId: 'A', + timezone: 'browser', + interval: '30s', + intervalMs: 30000, + maxDataPoints: 823, + targets: [], + range: getDefaultTimeRange(), + scopedVars: {}, + startTime: 1, + }, + series: [ + createDataFrame({ + refId: 'A', + fields: [ + { + name: 'timestamp', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z'], + }, + { + name: 'body', + type: FieldType.string, + values: ['logline text'], + }, + { + name: 'labels', + type: FieldType.other, + values: [ + { + app: 'common_app', + }, + ], + }, + ], + meta: { + type: DataFrameType.LogLines, + }, + }), + ], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }, + timeZone: 'utc', + timeRange: getDefaultTimeRange(), + options: { + showLabels: false, + showTime: false, + wrapLogMessage: false, + sortOrder: LogsSortOrder.Descending, + dedupStrategy: LogsDedupStrategy.none, + enableLogDetails: false, + enableInfiniteScrolling: false, + showControls: false, + syntaxHighlighting: false, + }, + title: 'Logs panel', + id: 1, + transparent: false, + width: 400, + height: 100, + renderCounter: 0, + fieldConfig: { + defaults: {}, + overrides: [], + }, + eventBus: new EventBusSrv(), + onOptionsChange: jest.fn(), + onFieldConfigChange: jest.fn(), + replaceVariables: jest.fn(), + onChangeTimeRange: jest.fn(), +}; + +const publishMock = jest.fn(); +beforeAll(() => { + jest.mocked(getAppEvents).mockReturnValue({ + publish: publishMock, + getStream: jest.fn(), + subscribe: jest.fn(), + removeAllListeners: jest.fn(), + newScopedBus: jest.fn(), + }); +}); + +describe('LogsPanel', () => { + test('Renders a list of logs without controls ', async () => { + setup(); + expect(await screen.findByText('logline text')).toBeInTheDocument(); + expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Display levels')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument(); + }); + + test('Renders a list of logs with controls', async () => { + setup({ options: { ...defaultProps.options, showControls: true } }); + expect(await screen.findByText('logline text')).toBeInTheDocument(); + expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); + expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); + expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); + }); + + test('Publishes an event with the current sort order', async () => { + publishMock.mockClear(); + setup(); + + await screen.findByText('logline text'); + + expect(publishMock).toHaveBeenCalledTimes(1); + expect(publishMock).toHaveBeenCalledWith( + new LogSortOrderChangeEvent({ + order: LogsSortOrder.Descending, + }) + ); + }); +}); + +const setup = (propsOverrides?: Partial) => { + const props: LogsPanelProps = { + ...defaultProps, + data: { + ...(propsOverrides?.data || defaultProps.data), + }, + options: { + ...(propsOverrides?.options || defaultProps.options), + }, + }; + + return { ...render(), props }; +}; diff --git a/public/app/plugins/panel/logs-new/LogsPanel.tsx b/public/app/plugins/panel/logs-new/LogsPanel.tsx index 9b0b26b4c23..135c72e28c0 100644 --- a/public/app/plugins/panel/logs-new/LogsPanel.tsx +++ b/public/app/plugins/panel/logs-new/LogsPanel.tsx @@ -5,22 +5,25 @@ import { AbsoluteTimeRange, CoreApp, DataFrame, + DataHoverEvent, GrafanaTheme2, LoadingState, + LogRowModel, + LogSortOrderChangeEvent, LogsSortOrder, PanelProps, } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, getAppEvents } from '@grafana/runtime'; import { usePanelContext, useStyles2 } from '@grafana/ui'; import { LogList } from 'app/features/logs/components/panel/LogList'; import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView'; import { dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel'; import { requestMoreLogs } from '../logs/LogsPanel'; -import { isOnNewLogsReceivedType } from '../logs/types'; import { useDatasourcesFromTargets } from '../logs/useDatasourcesFromTargets'; import { Options } from './panelcfg.gen'; +import { isCoreApp, isLogsGrammar, isOnLogOptionsChange, isOnNewLogsReceivedType } from './types'; interface LogsPanelProps extends PanelProps {} @@ -28,7 +31,18 @@ export const LogsPanel = ({ data, timeZone, fieldConfig, - options: { dedupStrategy, enableInfiniteScrolling, onNewLogsReceived, showTime, sortOrder, wrapLogMessage }, + options: { + dedupStrategy, + enableInfiniteScrolling, + grammar, + onLogOptionsChange, + onNewLogsReceived, + showControls, + showTime, + sortOrder, + syntaxHighlighting, + wrapLogMessage, + }, id, }: LogsPanelProps) => { const style = useStyles2(getStyles); @@ -39,7 +53,7 @@ export const LogsPanel = ({ const keepScrollPositionRef = useRef(false); // Loading ref to prevent firing multiple requests const loadingRef = useRef(false); - const { eventBus } = usePanelContext(); + const { app, eventBus } = usePanelContext(); const logs = useMemo(() => { const logsModel = panelData @@ -48,6 +62,14 @@ export const LogsPanel = ({ return logsModel ? dedupLogRows(logsModel.rows, dedupStrategy) : []; }, [dedupStrategy, panelData]); + useEffect(() => { + getAppEvents().publish( + new LogSortOrderChangeEvent({ + order: sortOrder, + }) + ); + }, [sortOrder]); + useEffect(() => { if (data.state !== LoadingState.Loading) { setPanelData(data); @@ -81,6 +103,21 @@ export const LogsPanel = ({ [data.request, dataSourcesMap, onNewLogsReceived, panelData, timeZone] ); + const onLogRowHover = useCallback( + (row?: LogRowModel) => { + if (row) { + eventBus.publish( + new DataHoverEvent({ + point: { + time: row.timeEpochMs, + }, + }) + ); + } + }, + [eventBus] + ); + const initialScrollPosition = useMemo(() => { /** * In dashboards, users with newest logs at the bottom have the expectation of keeping the scroll at the bottom @@ -100,15 +137,20 @@ export const LogsPanel = ({
setLogsContainer(element)}> {logs.length > 0 && logsContainer && ( (LogsPanel) description: '', defaultValue: false, }) + .addBooleanSwitch({ + path: 'syntaxHighlighting', + name: 'Enable syntax highlighting', + description: 'Use a predefined syntax coloring grammar to highlight relevant parts of the log lines', + defaultValue: true, + }) .addBooleanSwitch({ path: 'enableLogDetails', name: 'Enable log details', description: '', defaultValue: true, }) + .addBooleanSwitch({ + path: 'showControls', + name: 'Show controls', + description: 'Display controls to jump to the last or first log line, and filters by log level', + defaultValue: false, + }) .addBooleanSwitch({ path: 'enableInfiniteScrolling', name: 'Enable infinite scrolling', diff --git a/public/app/plugins/panel/logs-new/panelcfg.cue b/public/app/plugins/panel/logs-new/panelcfg.cue index e7b28449105..1b736468f15 100644 --- a/public/app/plugins/panel/logs-new/panelcfg.cue +++ b/public/app/plugins/panel/logs-new/panelcfg.cue @@ -26,12 +26,16 @@ composableKinds: PanelCfg: { version: [0, 0] schema: { Options: { + showControls: bool showTime: bool wrapLogMessage: bool enableLogDetails: bool + syntaxHighlighting: bool sortOrder: common.LogsSortOrder dedupStrategy: common.LogsDedupStrategy + grammar?: _ enableInfiniteScrolling?: bool + onLogOptionsChange?: _ onNewLogsReceived?: _ } @cuetsy(kind="interface") } diff --git a/public/app/plugins/panel/logs-new/panelcfg.gen.ts b/public/app/plugins/panel/logs-new/panelcfg.gen.ts index 62523679a72..017984570be 100644 --- a/public/app/plugins/panel/logs-new/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs-new/panelcfg.gen.ts @@ -14,8 +14,12 @@ export interface Options { dedupStrategy: common.LogsDedupStrategy; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; + grammar?: unknown; + onLogOptionsChange?: unknown; onNewLogsReceived?: unknown; + showControls: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; + syntaxHighlighting: boolean; wrapLogMessage: boolean; } diff --git a/public/app/plugins/panel/logs-new/types.ts b/public/app/plugins/panel/logs-new/types.ts new file mode 100644 index 00000000000..582cc94a1b8 --- /dev/null +++ b/public/app/plugins/panel/logs-new/types.ts @@ -0,0 +1,32 @@ +import { Grammar } from 'prismjs'; + +import { CoreApp, DataFrame } from '@grafana/data'; +import { LogListControlOptions } from 'app/features/logs/components/panel/LogList'; + +type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void; +type onLogOptionsChangeType = (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; + +export function isOnNewLogsReceivedType(callback: unknown): callback is onNewLogsReceivedType { + return typeof callback === 'function'; +} + +export function isOnLogOptionsChange(callback: unknown): callback is onLogOptionsChangeType { + return typeof callback === 'function'; +} + +export function isLogsGrammar(grammar: unknown): grammar is Grammar { + return grammar !== null && typeof grammar === 'object' && Object.getPrototypeOf(grammar) === Object.prototype; +} + +export function isCoreApp(app: unknown): app is CoreApp { + return ( + app === CoreApp.CloudAlerting || + app === CoreApp.Correlations || + app === CoreApp.Dashboard || + app === CoreApp.Explore || + app === CoreApp.PanelEditor || + app === CoreApp.PanelViewer || + app === CoreApp.UnifiedAlerting || + app === CoreApp.Unknown + ); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d10c187f2b8..6b500c77488 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4052,6 +4052,21 @@ "shortcut": "alt+select to enable again" } }, + "logs-controls": { + "deduplication": "Deduplication", + "disable-highlighting": "Disable highlighting", + "display-level": "Display levels", + "display-level-all": "All levels", + "enable-highlighting": "Enable highlighting", + "hide-timestamps": "Hide timestamps", + "newest-first": "Newest logs first", + "oldest-first": "Oldest logs first", + "scroll-bottom": "Scroll to bottom", + "scroll-top": "Scroll to top", + "show-timestamps": "Show timestamps", + "unwrap-lines": "Unwrap lines", + "wrap-lines": "Wrap lines" + }, "logs-navigation": { "newer-logs": "Newer logs", "older-logs": "Older logs",