From b3596e8c72b06fb93a50c0f22bc434f633346282 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Sun, 1 Jun 2025 14:29:49 +0200 Subject: [PATCH] New Logs Panel: Render new panel using the current visualization (#105968) * LogsPanel: integrate new panel via feature flag * Log Line: move sampled/errors/deduplication count outside of log line body * LogList: increase overscan count * Logs Panel: enable deduplication for infinite scrolling * Logs Panel: remove margin overflowing drilldown * Logs Panel: add missing dependency to effect * Logs Panel: pass missing callback * Remove console log * LogLine: show cursor pointer only when interactable * LogLineDetails: make resize handler more obvious * LogsPanel: add missing props to from panel * LogLineMenu: add support for custom items * LogsPanel: pass custom menu items to LogList * Fix imports * Chore: comments and missing argument * LogLineMenu: pass log to event listener * LogListContext: filter log details when no longer present in the new response * chore: log * LogsPanel: conditionally show options per feature flag status * LogLine: align logs when some of them are sampled or with errors * Chore: update tests * LogLineMenu: test custom options * LogsSamplePanel: show controls * LogsPanel: move return after hooks to prevent bugs --- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 1 + .../features/explore/Logs/LogsSamplePanel.tsx | 5 +- .../logs/components/panel/LogLine.test.tsx | 8 +- .../logs/components/panel/LogLine.tsx | 90 +++++++------- .../logs/components/panel/LogLineDetails.tsx | 2 +- .../components/panel/LogLineMenu.test.tsx | 29 ++++- .../logs/components/panel/LogLineMenu.tsx | 30 +++++ .../logs/components/panel/LogList.tsx | 12 +- .../logs/components/panel/LogListContext.tsx | 28 ++++- .../panel/__mocks__/LogListContext.tsx | 9 ++ .../components/panel/virtualization.test.ts | 45 +++---- .../logs/components/panel/virtualization.ts | 15 ++- public/app/plugins/panel/logs/LogsPanel.tsx | 111 ++++++++++++++++-- public/app/plugins/panel/logs/module.tsx | 44 ++++--- public/app/plugins/panel/logs/panelcfg.cue | 1 + public/app/plugins/panel/logs/panelcfg.gen.ts | 1 + public/app/plugins/panel/logs/types.ts | 5 + 17 files changed, 329 insertions(+), 107 deletions(-) diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index d8b98890e5e..6b5361f74e1 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -19,6 +19,7 @@ export interface Options { enableInfiniteScrolling?: boolean; enableLogDetails: boolean; isFilterLabelActive?: unknown; + logLineMenuCustomItems?: unknown; logRowMenuIconsAfter?: unknown; logRowMenuIconsBefore?: unknown; /** diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx index 90107d430f1..6b943db151f 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx @@ -24,7 +24,7 @@ import { LogRows } from '../../logs/components/LogRows'; import { dataFrameToLogsModel } from '../../logs/logsModel'; import { SupplementaryResultError } from '../SupplementaryResultError'; -import { SETTINGS_KEYS } from './utils/logs'; +import { SETTING_KEY_ROOT, SETTINGS_KEYS } from './utils/logs'; type Props = { queryResponse: DataQueryResponse | undefined; @@ -118,8 +118,9 @@ export function LogsSamplePanel(props: Props) { enableLogDetails dedupStrategy={LogsDedupStrategy.none} displayedFields={[]} + logOptionsStorageKey={SETTING_KEY_ROOT} logs={logs.rows} - showControls={false} + showControls showTime={store.getBool(SETTINGS_KEYS.showTime, true)} sortOrder={store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending} timeRange={props.timeRange} diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 508af198c18..baa0a16e972 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -22,7 +22,6 @@ const contextProps = { app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], - logs: [], showControls: false, showTime: false, sortOrder: LogsSortOrder.Ascending, @@ -33,6 +32,7 @@ describe('LogLine', () => { let log: LogListModel, defaultProps: Props; beforeEach(() => { log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); + contextProps.logs = [log]; defaultProps = { displayedFields: [], index: 0, @@ -106,9 +106,10 @@ describe('LogLine', () => { test('Shows log lines with errors', async () => { log.hasError = true; + log.labels.__error__ = 'error message'; jest.spyOn(log, 'errorMessage', 'get').mockReturnValue('error message'); render( - + ); @@ -118,9 +119,10 @@ describe('LogLine', () => { test('Shows sampled log lines', async () => { log.isSampled = true; + log.labels.__adaptive_logs_sampled__ = 'true'; jest.spyOn(log, 'sampledMessage', 'get').mockReturnValue('sampled message'); render( - + ); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 1feeb0ca20b..d0b69d952ad 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -45,7 +45,8 @@ export const LogLine = ({ variant, wrapLogMessage, }: Props) => { - const { detailsDisplayed, onLogLineHover } = useLogListContext(); + const { detailsDisplayed, dedupStrategy, enableLogDetails, hasLogsWithErrors, hasSampledLogs, onLogLineHover } = + useLogListContext(); const [collapsed, setCollapsed] = useState( wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined ); @@ -99,10 +100,49 @@ export const LogLine = ({ onFocus={handleMouseOver} > + {dedupStrategy !== LogsDedupStrategy.none && ( +
+ {log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} +
+ )} + {hasLogsWithErrors && ( +
+ {log.hasError && ( + + + + )} +
+ )} + {hasSampledLogs && ( +
+ {log.isSampled && ( + + + + )} +
+ )} {/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
{ - const { dedupStrategy } = useLogListContext(); - const { t } = useTranslate(); return ( <> - {dedupStrategy !== LogsDedupStrategy.none && ( - - {log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} - - )} - {log.hasError && ( - - - - - - )} - {log.isSampled && ( - - - - - - )} {showTime && {log.timestamp}} { // When logs are unwrapped, we want an empty column space to align with other log lines. @@ -334,12 +339,12 @@ export const getStyles = (theme: GrafanaTheme2) => { display: 'inline-block', }), duplicates: css({ - display: 'inline-block', + flexShrink: 0, textAlign: 'center', width: theme.spacing(4.5), }), hasError: css({ - display: 'inline-block', + flexShrink: 0, width: theme.spacing(2), '& svg': { position: 'relative', @@ -347,7 +352,7 @@ export const getStyles = (theme: GrafanaTheme2) => { }, }), isSampled: css({ - display: 'inline-block', + flexShrink: 0, width: theme.spacing(2), '& svg': { position: 'relative', @@ -389,15 +394,16 @@ export const getStyles = (theme: GrafanaTheme2) => { overflows: css({ outline: 'solid 1px red', }), - unwrappedLogLine: css({ + clickable: css({ cursor: 'pointer', + }), + unwrappedLogLine: css({ display: 'grid', gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER), whiteSpace: 'pre', paddingBottom: theme.spacing(0.75), }), wrappedLogLine: css({ - cursor: 'pointer', alignSelf: 'flex-start', paddingBottom: theme.spacing(0.75), whiteSpace: 'pre-wrap', diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index df55103225a..9e390ccafe8 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -54,7 +54,7 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize return ( { expect(onPermalinkClick).toHaveBeenCalledTimes(1); }); + test('Allows to copy a permalink', async () => { + const customOption1onClick = jest.fn(); + const logLineMenuCustomItems: LogLineMenuCustomItem[] = [ + { + label: 'Custom option 1', + onClick: customOption1onClick, + }, + { + divider: true, + }, + { + label: 'Custom option 2', + onClick: jest.fn(), + }, + ]; + render( + + + + ); + await userEvent.click(screen.getByLabelText('Log menu')); + await screen.findByText('Custom option 1'); + await screen.findByText('Custom option 2'); + await userEvent.click(screen.getByText('Custom option 1')); + expect(customOption1onClick).toHaveBeenCalledTimes(1); + }); + test('Allows to open show context', async () => { const onOpenContext = jest.fn(); const logSupportsContext = jest.fn().mockReturnValue(true); diff --git a/public/app/features/logs/components/panel/LogLineMenu.tsx b/public/app/features/logs/components/panel/LogLineMenu.tsx index e790ad8a21f..bb421cbccff 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.tsx @@ -17,6 +17,17 @@ export type GetRowContextQueryFn = ( cacheFilters?: boolean ) => Promise; +type MenuItem = { + label: string; + onClick(log: LogListModel): void; +}; + +type MenuItemDivider = { + divider: true; +}; + +export type LogLineMenuCustomItem = MenuItem | MenuItemDivider; + interface Props { log: LogListModel; styles: LogLineStyles; @@ -31,6 +42,7 @@ export const LogLineMenu = ({ log, styles }: Props) => { onPermalinkClick, onPinLine, onUnpinLine, + logLineMenuCustomItems = [], logSupportsContext, toggleDetails, } = useLogListContext(); @@ -98,6 +110,15 @@ export const LogLineMenu = ({ log, styles }: Props) => { {onPermalinkClick && log.rowId !== undefined && log.uid && ( )} + {logLineMenuCustomItems.map((item, i) => { + if (isDivider(item)) { + return ; + } + if (isItem(item)) { + return item.onClick(log)} label={item.label} key={i} />; + } + return null; + })} ), [ @@ -106,6 +127,7 @@ export const LogLineMenu = ({ log, styles }: Props) => { detailsDisplayed, enableLogDetails, log, + logLineMenuCustomItems, onPermalinkClick, onPinLine, onUnpinLine, @@ -128,3 +150,11 @@ export const LogLineMenu = ({ log, styles }: Props) => { ); }; + +function isDivider(item: LogLineMenuCustomItem) { + return 'divider' in item && item.divider; +} + +function isItem(item: LogLineMenuCustomItem) { + return 'onClick' in item && 'label' in item; +} diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1dc80967a90..b84496e7f4d 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -24,7 +24,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { InfiniteScroll } from './InfiniteScroll'; import { getGridTemplateColumns } from './LogLine'; import { LogLineDetails } from './LogLineDetails'; -import { GetRowContextQueryFn } from './LogLineMenu'; +import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; import { LogListControls } from './LogListControls'; import { preProcessLogs, LogListModel } from './processing'; @@ -53,6 +53,7 @@ export interface Props { isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise; loading?: boolean; loadMore?: (range: AbsoluteTimeRange) => void; + logLineMenuCustomItems?: LogLineMenuCustomItem[]; logOptionsStorageKey?: string; logs: LogRowModel[]; logsMeta?: LogsMetaItem[]; @@ -111,6 +112,7 @@ export const LogList = ({ isLabelFilterActive, loading, loadMore, + logLineMenuCustomItems, logOptionsStorageKey, logs, logsMeta, @@ -150,6 +152,7 @@ export const LogList = ({ isLabelFilterActive={isLabelFilterActive} logs={logs} logsMeta={logsMeta} + logLineMenuCustomItems={logLineMenuCustomItems} logOptionsStorageKey={logOptionsStorageKey} logSupportsContext={logSupportsContext} onClickFilterLabel={onClickFilterLabel} @@ -209,6 +212,8 @@ const LogListComponent = ({ dedupStrategy, filterLevels, forceEscape, + hasLogsWithErrors, + hasSampledLogs, permalinkedLogId, showDetails, showTime, @@ -265,7 +270,7 @@ const LogListComponent = ({ useEffect(() => { listRef.current?.resetAfterIndex(0); - }, [wrapLogMessage, showDetails, displayedFields]); + }, [wrapLogMessage, showDetails, displayedFields, dedupStrategy]); useEffect(() => { const handleResize = debounce(() => { @@ -362,6 +367,8 @@ const LogListComponent = ({ height={listHeight} itemCount={itemCount} itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, { + hasLogsWithErrors, + hasSampledLogs, showDuplicates: dedupStrategy !== LogsDedupStrategy.none, showTime, wrap: wrapLogMessage, @@ -370,6 +377,7 @@ const LogListComponent = ({ layout="vertical" onItemsRendered={onItemsRendered} outerRef={scrollRef} + overscanCount={5} ref={listRef} style={{ overflowY: 'scroll' }} width="100%" diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 546643fc813..6c1f8b9c57f 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -6,6 +6,7 @@ import { useCallback, useContext, useEffect, + useMemo, useState, } from 'react'; @@ -22,9 +23,9 @@ import { } from '@grafana/data'; import { PopoverContent } from '@grafana/ui'; -import { DownloadFormat, downloadLogs as download } from '../../utils'; +import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils'; -import { GetRowContextQueryFn } from './LogLineMenu'; +import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListModel } from './processing'; export interface LogListContextData extends Omit { @@ -34,7 +35,10 @@ export interface LogListContextData extends Omit void; enableLogDetails: boolean; filterLevels: LogLevel[]; + hasLogsWithErrors?: boolean; + hasSampledLogs?: boolean; hasUnescapedContent?: boolean; + logLineMenuCustomItems?: LogLineMenuCustomItem[]; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; setDetailsWidth: (width: number) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; @@ -129,6 +133,7 @@ export interface Props { getRowContextQuery?: GetRowContextQueryFn; isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise; logs: LogRowModel[]; + logLineMenuCustomItems?: LogLineMenuCustomItem[]; logsMeta?: LogsMetaItem[]; logOptionsStorageKey?: string; logSupportsContext?: (row: LogRowModel) => boolean; @@ -169,6 +174,7 @@ export const LogListContextProvider = ({ isLabelFilterActive, getRowContextQuery, logs, + logLineMenuCustomItems, logsMeta, logOptionsStorageKey, logSupportsContext, @@ -260,6 +266,18 @@ export const LogListContextProvider = ({ } }, [logListState, pinnedLogs]); + useEffect(() => { + if (!showDetails.length) { + return; + } + const newShowDetails = showDetails.filter( + (expandedLog) => logs.findIndex((log) => log.uid === expandedLog.uid) >= 0 + ); + if (newShowDetails.length !== showDetails.length) { + setShowDetails(newShowDetails); + } + }, [logs, showDetails]); + const detailsDisplayed = useCallback( (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), [showDetails] @@ -403,6 +421,9 @@ export const LogListContextProvider = ({ [logOptionsStorageKey] ); + const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]); + const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]); + const defaultWidth = (containerElement?.clientWidth ?? 0) * 0.4; const detailsWidth = logOptionsStorageKey ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10) @@ -421,10 +442,13 @@ export const LogListContextProvider = ({ enableLogDetails, filterLevels: logListState.filterLevels, forceEscape: logListState.forceEscape, + hasLogsWithErrors, + hasSampledLogs, hasUnescapedContent: logListState.hasUnescapedContent, isLabelFilterActive, getRowContextQuery, logSupportsContext, + logLineMenuCustomItems, onClickFilterLabel, onClickFilterOutLabel, onClickFilterString, diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index e9fcaabdeca..a57a80c5580 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext } from 'react'; import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils'; import { LogListContextData, Props } from '../LogListContext'; import { LogListModel } from '../processing'; @@ -114,6 +115,8 @@ export const LogListContextProvider = ({ enableLogDetails = false, filterLevels = [], getRowContextQuery = jest.fn(), + logLineMenuCustomItems = undefined, + logs = [], logSupportsContext = jest.fn(), onLogLineHover, onPermalinkClick = jest.fn(), @@ -127,6 +130,9 @@ export const LogListContextProvider = ({ syntaxHighlighting = true, wrapLogMessage = true, }: Partial) => { + const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log)); + const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log)); + return ( { let log: LogListModel, container: HTMLDivElement; @@ -28,7 +35,7 @@ describe('Virtualization', () => { describe('getLogLineSize', () => { test('Returns the a single line if the display mode is unwrapped', () => { - const size = getLogLineSize([log], container, [], { wrap: false, showTime: true, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, showTime: true }, 0); expect(size).toBe(SINGLE_LINE_HEIGHT); }); @@ -38,7 +45,7 @@ describe('Virtualization', () => { logs, container, [], - { wrap: true, showTime: true, showDuplicates: false }, + { ...defaultOptions, wrap: true, showTime: true }, logs.length + 1 ); expect(size).toBe(SINGLE_LINE_HEIGHT); @@ -48,12 +55,18 @@ describe('Virtualization', () => { // Very small container log.collapsed = true; jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); - const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0); expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT); }); test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => { - const size = getLogLineSize([log], container, [], { wrap: true, showTime, showDuplicates: false }, 0); + const size = getLogLineSize( + [log], + container, + [], + { wrap: true, showTime, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false }, + 0 + ); expect(size).toBe(SINGLE_LINE_HEIGHT); }); @@ -64,14 +77,14 @@ describe('Virtualization', () => { logLevel: undefined, }); - const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true }, 0); expect(size).toBe(TWO_LINES_HEIGHT); }); test('Measures a multi-line log line with level, controls, and displayed time', () => { log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); - const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0); // Two lines for the log and one extra for level and time expect(size).toBe(THREE_LINES_HEIGHT); }); @@ -87,7 +100,7 @@ describe('Virtualization', () => { [log], container, ['place', LOG_LINE_BODY_FIELD_NAME], - { wrap: true, showTime: false, showDuplicates: false }, + { ...defaultOptions, wrap: true }, 0 ); // Two lines for the log and one extra for the displayed fields @@ -97,13 +110,7 @@ describe('Virtualization', () => { test('Measures displayed fields in a log line with level, controls, and displayed time', () => { log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); - const size = getLogLineSize( - [log], - container, - ['place'], - { wrap: true, showTime: true, showDuplicates: false }, - 0 - ); + const size = getLogLineSize([log], container, ['place'], { ...defaultOptions, wrap: true, showTime: true }, 0); // Only renders a short displayed field, so a single line expect(size).toBe(SINGLE_LINE_HEIGHT); }); @@ -112,25 +119,23 @@ describe('Virtualization', () => { log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); log.duplicates = 1; - const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: true }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showDuplicates: true }, 0); // Two lines for the log and one extra for duplicates expect(size).toBe(THREE_LINES_HEIGHT); }); test('Measures a multi-line log line with errors', () => { log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); - log.hasError = true; - const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, hasLogsWithErrors: true }, 0); // Two lines for the log and one extra for the error icon expect(size).toBe(THREE_LINES_HEIGHT); }); test('Measures a multi-line sampled log line', () => { log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); - log.isSampled = true; - const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, hasSampledLogs: true }, 0); // Two lines for the log and one extra for the sampled icon expect(size).toBe(THREE_LINES_HEIGHT); }); @@ -138,7 +143,7 @@ describe('Virtualization', () => { test('Adds an extra line for the expand/collapse controls if present', () => { jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined); log.collapsed = false; - const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true }, 0); expect(size).toBe(TWO_LINES_HEIGHT); }); }); diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 26c7f8699d9..76221b71a33 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -147,6 +147,8 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = } interface DisplayOptions { + hasLogsWithErrors?: boolean; + hasSampledLogs?: boolean; showDuplicates: boolean; showTime: boolean; wrap: boolean; @@ -156,7 +158,7 @@ export function getLogLineSize( logs: LogListModel[], container: HTMLDivElement | null, displayedFields: string[], - { showDuplicates, showTime, wrap }: DisplayOptions, + { hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, index: number ) { if (!container) { @@ -179,15 +181,16 @@ export function getLogLineSize( let textToMeasure = ''; const gap = gridSize * FIELD_GAP_MULTIPLIER; + const iconsGap = gridSize * 0.5; let optionsWidth = 0; if (showDuplicates) { - optionsWidth += gridSize * 4.5 + gap; + optionsWidth += gridSize * 4.5 + iconsGap; } - if (logs[index].hasError) { - optionsWidth += gridSize * 2 + gap; + if (hasLogsWithErrors) { + optionsWidth += gridSize * 2 + iconsGap; } - if (logs[index].isSampled) { - optionsWidth += gridSize * 2 + gap; + if (hasSampledLogs) { + optionsWidth += gridSize * 2 + iconsGap; } if (showTime) { optionsWidth += gap; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 7052dfa12b2..6690f392ca9 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -37,6 +37,7 @@ import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; +import { LogList } from 'app/features/logs/components/panel/LogList'; import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView'; import { combineResponses } from 'app/plugins/datasource/loki/mergeResponses'; @@ -49,6 +50,7 @@ import { GetFieldLinksFn, isCoreApp, isIsFilterLabelActive, + isLogLineMenuCustomItems, isOnClickFilterLabel, isOnClickFilterOutLabel, isOnClickFilterOutString, @@ -108,6 +110,10 @@ interface LogsPanelProps extends PanelProps { * * If controls are enabled, this function is called when a change is made in one of the options from the controls. * onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; + * + * When the feature toggle newLogsPanel is enabled, you can pass extra options to the LogLineMenu component. + * These options are an array of items with { label, onClick } or { divider: true } for dividers. + * logLineMenuCustomItems?: LogLineMenuCustomItem[]; */ } interface LogsPermalinkUrlState { @@ -142,6 +148,7 @@ export const LogsPanel = ({ isFilterLabelActive, logRowMenuIconsBefore, logRowMenuIconsAfter, + logLineMenuCustomItems, enableInfiniteScrolling, onNewLogsReceived, ...options @@ -285,13 +292,19 @@ export const LogsPanel = ({ // Important to memoize stuff here, as panel rerenders a lot for example when resizing. const [logRows, deduplicatedRows, commonLabels] = useMemo(() => { const logs = panelData - ? dataFrameToLogsModel(panelData.series, panelData.request?.intervalMs, undefined, panelData.request?.targets) + ? dataFrameToLogsModel( + panelData.series, + panelData.request?.intervalMs, + undefined, + panelData.request?.targets, + Boolean(enableInfiniteScrolling) + ) : null; const logRows = logs?.rows || []; const commonLabels = logs?.meta?.find((m) => m.label === COMMON_LABELS); const deduplicatedRows = dedupLogRows(logRows, dedupStrategy); return [logRows, deduplicatedRows, commonLabels]; - }, [dedupStrategy, panelData]); + }, [dedupStrategy, enableInfiniteScrolling, panelData]); const onPermalinkClick = useCallback( async (row: LogRowModel) => { @@ -307,6 +320,9 @@ export const LogsPanel = ({ }, [data]); useLayoutEffect(() => { + if (config.featureToggles.newLogsPanel) { + return; + } if (!logsContainerRef.current || !scrollElement || keepScrollPositionRef.current) { keepScrollPositionRef.current = keepScrollPositionRef.current === 'infinite-scroll' ? null : keepScrollPositionRef.current; @@ -449,10 +465,6 @@ export const LogsPanel = ({ [data.request, dataSourcesMap, onNewLogsReceived, panelData, timeZone] ); - if (!data || logRows.length === 0) { - return ; - } - const renderCommonLabels = () => (
@@ -465,6 +477,31 @@ export const LogsPanel = ({
); + const initialScrollPosition = useMemo(() => { + /** + * In dashboards, users with newest logs at the bottom have the expectation of keeping the scroll at the bottom + * when new data is received. See https://github.com/grafana/grafana/pull/37634 + */ + if (app === CoreApp.Dashboard || app === CoreApp.PanelEditor) { + return sortOrder === LogsSortOrder.Ascending ? 'bottom' : 'top'; + } + return 'top'; + }, [app, sortOrder]); + + const storageKey = useMemo(() => { + if (controlsStorageKey) { + return controlsStorageKey; + } + if (!data.request) { + return undefined; + } + return `${data.request?.dashboardUID}.${id}`; + }, [controlsStorageKey, data.request, id]); + + if (!data || logRows.length === 0) { + return ; + } + // Passing callbacks control the display of the filtering buttons. We want to pass it only if onAddAdHocFilter is defined. const defaultOnClickFilterLabel = onAddAdHocFilter ? handleOnClickFilterLabel : undefined; const defaultOnClickFilterOutLabel = onAddAdHocFilter ? handleOnClickFilterOutLabel : undefined; @@ -485,7 +522,55 @@ export const LogsPanel = ({ getLogRowContextUi={getLogRowContextUi} /> )} - {!showControls ? ( + {config.featureToggles.newLogsPanel && ( +
setScrollElement(element)} + > + {deduplicatedRows.length > 0 && scrollElement && ( + + )} +
+ )} + {!config.featureToggles.newLogsPanel && !showControls && ( setScrollElement(scrollElement)}>
{showCommonLabels && !isAscending && renderCommonLabels()} @@ -542,7 +627,8 @@ export const LogsPanel = ({ {showCommonLabels && isAscending && renderCommonLabels()}
- ) : ( + )} + {!config.featureToggles.newLogsPanel && showControls && (
{showCommonLabels && !isAscending && renderCommonLabels()} {showCommonLabels && isAscending && renderCommonLabels()}
@@ -601,6 +687,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ marginBottom: theme.spacing(1.5), }), + logListContainer: css({ + minHeight: '100%', + maxHeight: '100%', + display: 'flex', + flex: 1, + flexDirection: 'column', + }), controlledLogsContainer: css({ height: '100%', }), diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index 45952d81f89..c07595ae0bf 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -1,4 +1,5 @@ import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { LogsPanel } from './LogsPanel'; import { Options } from './panelcfg.gen'; @@ -6,25 +7,30 @@ import { LogsPanelSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(LogsPanel) .setPanelOptions((builder) => { + builder.addBooleanSwitch({ + path: 'showTime', + name: 'Time', + description: '', + defaultValue: false, + }); + + if (!config.featureToggles.newLogsPanel) { + builder + .addBooleanSwitch({ + path: 'showLabels', + name: 'Unique labels', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'showCommonLabels', + name: 'Common labels', + description: '', + defaultValue: false, + }); + } + builder - .addBooleanSwitch({ - path: 'showTime', - name: 'Time', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'showLabels', - name: 'Unique labels', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'showCommonLabels', - name: 'Common labels', - description: '', - defaultValue: false, - }) .addBooleanSwitch({ path: 'wrapLogMessage', name: 'Wrap lines', @@ -33,7 +39,7 @@ export const plugin = new PanelPlugin(LogsPanel) }) .addBooleanSwitch({ path: 'prettifyLogMessage', - name: 'Prettify JSON', + name: config.featureToggles.newLogsPanel ? 'Enable log message highlighting' : 'Prettify JSON', description: '', defaultValue: false, }) diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index b1efe883442..c235f43abcf 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -49,6 +49,7 @@ composableKinds: PanelCfg: { onLogOptionsChange?: _ logRowMenuIconsBefore?: _ logRowMenuIconsAfter?: _ + logLineMenuCustomItems?: _ onNewLogsReceived?: _ displayedFields?: [...string] } @cuetsy(kind="interface") diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index 74775a0ee61..0866170944a 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -17,6 +17,7 @@ export interface Options { enableInfiniteScrolling?: boolean; enableLogDetails: boolean; isFilterLabelActive?: unknown; + logLineMenuCustomItems?: unknown; logRowMenuIconsAfter?: unknown; logRowMenuIconsBefore?: unknown; /** diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 9019d5750e8..506cc27c4bb 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -1,6 +1,7 @@ import React, { ReactNode } from 'react'; import { CoreApp, DataFrame, Field, LinkModel, ScopedVars } from '@grafana/data'; +import { LogLineMenuCustomItem } from 'app/features/logs/components/panel/LogLineMenu'; import { LogListControlOptions } from 'app/features/logs/components/panel/LogList'; export type { Options } from './panelcfg.gen'; @@ -66,3 +67,7 @@ export function isCoreApp(app: unknown): app is CoreApp { const apps = Object.values(CoreApp).map((coreApp) => coreApp.toString()); return typeof app === 'string' && apps.includes(app); } + +export function isLogLineMenuCustomItems(items: unknown): items is LogLineMenuCustomItem[] { + return Array.isArray(items) && items.every((item) => 'divider' in item || ('onClick' in item && 'label' in item)); +}