From b814f1628fd987f471af5a745bcf00bbcefe5836 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 14 Feb 2025 11:52:34 +0000 Subject: [PATCH] New Logs Panel: Add infinite scrolling support (#99773) * Create Infinite Scroll wrapper component * Logs list: refactor event subscriber * Infinitely load logs * Move renderer to Infinite Scroll component * Implement infinite scroll state * Switch internal implementation to use the existing infinite scrolling component logic * Integrate with logs panel * Move scrolling management to infinite scrolling component * LogList: change subscription dependency to prevent unnecessary runs * Infinite scroll: remove autoscrolling * Logs Panel: fix dependencies to prevent re-renders on refresh * Infinite scroll: introduce pre-scroll state * LogList: expose initial log position prop * Infinite scroll: less work on scroll and autoscroll behavior * Remove console * Fix imports * Add infinite scroll translations * Fix imports * Add visual delimiter for new pages and increase gap * Remove log * Chore: rename interface to LogListModel * Hover: decrease opacity * Fix no-logs state * Prettier * Infinite scroll: move scroll delimiter * Load more message: make it clickable --- .../panelcfg/x/LogsNewPanelCfg_types.gen.ts | 1 + .../panelcfg/x/LogsNewPanelCfg_types.gen.ts | 23 ++ public/app/features/explore/Logs/Logs.tsx | 4 +- .../logs/components/InfiniteScroll.tsx | 8 +- .../logs/components/panel/InfiniteScroll.tsx | 213 ++++++++++++++++++ .../logs/components/panel/LogLine.tsx | 40 +++- .../logs/components/panel/LogLineMessage.tsx | 27 +++ .../logs/components/panel/LogList.tsx | 97 ++++---- .../logs/components/panel/processing.ts | 9 +- .../logs/components/panel/virtualization.ts | 11 +- .../app/plugins/panel/logs-new/LogsPanel.tsx | 76 +++++-- .../app/plugins/panel/logs-new/panelcfg.cue | 1 + .../plugins/panel/logs-new/panelcfg.gen.ts | 1 + public/app/plugins/panel/logs-new/plugin.json | 2 +- public/app/plugins/panel/logs/LogsPanel.tsx | 2 +- public/app/plugins/panel/logs/module.tsx | 2 +- public/locales/en-US/grafana.json | 4 + public/locales/pseudo-LOCALE/grafana.json | 4 + 18 files changed, 438 insertions(+), 87 deletions(-) create mode 100644 packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts create mode 100644 public/app/features/logs/components/panel/InfiniteScroll.tsx create mode 100644 public/app/features/logs/components/panel/LogLineMessage.tsx 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 index 91589b9cea1..b758575dba2 100644 --- 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 @@ -16,6 +16,7 @@ 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 new file mode 100644 index 00000000000..b758575dba2 --- /dev/null +++ b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts @@ -0,0 +1,23 @@ +// 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/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index fb05d6bee91..ae6c8e1f7d0 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1063,7 +1063,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { /> )} - {visualisationType === 'logs' && config.featureToggles.newLogsPanel && ( + {visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && ( <>
{logsContainerRef.current && ( @@ -1072,9 +1072,11 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { containerElement={logsContainerRef.current} eventBus={eventBus} forceEscape={forceEscape} + loadMore={loadMoreLogs} logs={dedupedRows} showTime={showTime} sortOrder={logsSortOrder} + timeRange={props.range} timeZone={timeZone} wrapLogMessage={wrapLogMessage} /> diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index 4fffcf4c62e..36d748d2db1 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -214,12 +214,12 @@ const outOfRangeMessage = (
); -enum ScrollDirection { +export enum ScrollDirection { Top = -1, Bottom = 1, NoScroll = 0, } -function shouldLoadMore( +export function shouldLoadMore( event: Event | WheelEvent, lastEvent: Event | WheelEvent | null, countRef: MutableRefObject, @@ -284,7 +284,7 @@ function shouldIgnoreChainOfEvents( return true; } -function getVisibleRange(rows: LogRowModel[]) { +export function getVisibleRange(rows: LogRowModel[]) { const firstTimeStamp = rows[0].timeEpochMs; const lastTimeStamp = rows[rows.length - 1].timeEpochMs; @@ -326,7 +326,7 @@ function canScrollTop( return canScroll ? getPrevRange(visibleRange, currentRange) : undefined; } -function canScrollBottom( +export function canScrollBottom( visibleRange: AbsoluteTimeRange, currentRange: TimeRange, timeZone: TimeZone, diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx new file mode 100644 index 00000000000..4f0e7a9584b --- /dev/null +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -0,0 +1,213 @@ +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { usePrevious } from 'react-use'; +import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'; + +import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; +import { Spinner } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll'; + +import { LogLine } from './LogLine'; +import { LogLineMessage } from './LogLineMessage'; +import { LogListModel } from './processing'; + +interface ChildrenProps { + itemCount: number; + getItemKey: (index: number) => string; + onItemsRendered: (props: ListOnItemsRenderedProps) => void; + Renderer: (props: ListChildComponentProps) => ReactNode; +} + +interface Props { + children: (props: ChildrenProps) => ReactNode; + handleOverflow: (index: number, id: string, height: number) => void; + loadMore?: (range: AbsoluteTimeRange) => void; + logs: LogListModel[]; + scrollElement: HTMLDivElement | null; + setInitialScrollPosition: () => void; + showTime: boolean; + sortOrder: LogsSortOrder; + timeRange: TimeRange; + timeZone: string; + wrapLogMessage: boolean; +} + +type InfiniteLoaderState = 'idle' | 'out-of-bounds' | 'pre-scroll' | 'loading'; + +export const InfiniteScroll = ({ + children, + handleOverflow, + loadMore, + logs, + scrollElement, + setInitialScrollPosition, + showTime, + sortOrder, + timeRange, + timeZone, + wrapLogMessage, +}: Props) => { + const [infiniteLoaderState, setInfiniteLoaderState] = useState('idle'); + const [autoScroll, setAutoScroll] = useState(false); + const prevLogs = usePrevious(logs); + const prevSortOrder = usePrevious(sortOrder); + const lastScroll = useRef(scrollElement?.scrollTop || 0); + const lastEvent = useRef(null); + const countRef = useRef(0); + const lastLogOfPage = useRef([]); + + useEffect(() => { + // Logs have not changed, ignore effect + if (!prevLogs || prevLogs === logs) { + return; + } + // New logs are from infinite scrolling + if (infiniteLoaderState === 'loading') { + // out-of-bounds if no new logs returned + setInfiniteLoaderState(logs.length === prevLogs.length ? 'out-of-bounds' : 'idle'); + } else { + lastLogOfPage.current = []; + setAutoScroll(true); + } + }, [infiniteLoaderState, logs, prevLogs]); + + useEffect(() => { + if (prevSortOrder && prevSortOrder !== sortOrder) { + setInfiniteLoaderState('idle'); + } + }, [prevSortOrder, sortOrder]); + + useEffect(() => { + if (autoScroll) { + setInitialScrollPosition(); + setAutoScroll(false); + } + }, [autoScroll, setInitialScrollPosition]); + + const onLoadMore = useCallback(() => { + const newRange = canScrollBottom(getVisibleRange(logs), timeRange, timeZone, sortOrder); + if (!newRange) { + setInfiniteLoaderState('out-of-bounds'); + return; + } + lastLogOfPage.current.push(logs[logs.length - 1].uid); + setInfiniteLoaderState('loading'); + loadMore?.(newRange); + + reportInteraction('grafana_logs_infinite_scrolling', { + direction: 'bottom', + sort_order: sortOrder, + }); + }, [loadMore, logs, sortOrder, timeRange, timeZone]); + + useEffect(() => { + if (!scrollElement || !loadMore || !config.featureToggles.logsInfiniteScrolling) { + return; + } + + function handleScroll(event: Event | WheelEvent) { + if (!scrollElement || !loadMore || !logs.length || infiniteLoaderState !== 'pre-scroll') { + return; + } + const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current); + lastEvent.current = event; + lastScroll.current = scrollElement.scrollTop; + if (scrollDirection === ScrollDirection.Bottom) { + onLoadMore(); + } + } + + scrollElement.addEventListener('scroll', handleScroll); + scrollElement.addEventListener('wheel', handleScroll); + + return () => { + scrollElement.removeEventListener('scroll', handleScroll); + scrollElement.removeEventListener('wheel', handleScroll); + }; + }, [infiniteLoaderState, loadMore, logs.length, onLoadMore, scrollElement]); + + const Renderer = useCallback( + ({ index, style }: ListChildComponentProps) => { + if (!logs[index] && infiniteLoaderState !== 'idle') { + return ( + + {getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)} + + ); + } + return ( + + ); + }, + [handleOverflow, infiniteLoaderState, logs, onLoadMore, showTime, sortOrder, wrapLogMessage] + ); + + const onItemsRendered = useCallback( + (props: ListOnItemsRenderedProps) => { + if (!scrollElement || infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') { + return; + } + if (scrollElement.scrollHeight <= scrollElement.clientHeight) { + return; + } + const lastLogIndex = logs.length - 1; + const preScrollIndex = logs.length - 2; + if (props.visibleStopIndex >= lastLogIndex) { + setInfiniteLoaderState('pre-scroll'); + } else if (props.visibleStartIndex < preScrollIndex) { + setInfiniteLoaderState('idle'); + } + }, + [infiniteLoaderState, logs.length, scrollElement] + ); + + const getItemKey = useCallback((index: number) => (logs[index] ? logs[index].uid : index.toString()), [logs]); + + const itemCount = logs.length && loadMore && infiniteLoaderState !== 'idle' ? logs.length + 1 : logs.length; + + return <>{children({ getItemKey, itemCount, onItemsRendered, Renderer })}; +}; + +function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: LogsSortOrder) { + switch (state) { + case 'out-of-bounds': + return t('logs.infinite-scroll.end-of-range', 'End of the selected time range.'); + case 'loading': + return ( + <> + {order === LogsSortOrder.Ascending + ? t('logs.infinite-scroll.load-newer', 'Loading newer logs...') + : t('logs.infinite-scroll.load-older', 'Loading older logs...')}{' '} + + + ); + case 'pre-scroll': + return t('logs.infinite-scroll.load-more', 'Scroll to load more'); + default: + return null; + } +} + +function getLogLineVariant(logs: LogListModel[], index: number, lastLogOfPage: string[]) { + if (!lastLogOfPage.length || !logs[index - 1]) { + return undefined; + } + const prevLog = logs[index - 1]; + for (const uid of lastLogOfPage) { + if (prevLog.uid === uid) { + // First log of an infinite scrolling page + return 'infinite-scroll'; + } + } + return undefined; +} diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 02b2b464dff..bba06ff1ab0 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -4,19 +4,20 @@ import { CSSProperties, useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; -import { ProcessedLogModel } from './processing'; +import { LogListModel } from './processing'; import { hasUnderOrOverflow } from './virtualization'; interface Props { index: number; - log: ProcessedLogModel; + log: LogListModel; showTime: boolean; style: CSSProperties; onOverflow?: (index: number, id: string, height: number) => void; + variant?: 'infinite-scroll'; wrapLogMessage: boolean; } -export const LogLine = ({ index, log, style, onOverflow, showTime, wrapLogMessage }: Props) => { +export const LogLine = ({ index, log, style, onOverflow, showTime, variant, wrapLogMessage }: Props) => { const theme = useTheme2(); const styles = getStyles(theme); const logLineRef = useRef(null); @@ -33,7 +34,7 @@ export const LogLine = ({ index, log, style, onOverflow, showTime, wrapLogMessag }, [index, log.uid, onOverflow, style.height]); return ( -
+
{showTime && {log.timestamp}} {log.logLevel && {log.logLevel}} @@ -43,7 +44,7 @@ export const LogLine = ({ index, log, style, onOverflow, showTime, wrapLogMessag ); }; -const getStyles = (theme: GrafanaTheme2) => { +export const getStyles = (theme: GrafanaTheme2) => { const colors = { critical: '#B877D9', error: '#FF5286', @@ -60,8 +61,23 @@ const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.fontSize, wordBreak: 'break-all', '&:hover': { - opacity: 0.9, + opacity: 0.7, }, + '&.infinite-scroll': { + '&::before': { + borderTop: `solid 1px ${theme.colors.border.strong}`, + content: '""', + height: 0, + left: 0, + position: 'absolute', + top: -3, + width: '100%', + }, + }, + }), + logLineMessage: css({ + fontFamily: theme.typography.fontFamily, + textAlign: 'center', }), timestamp: css({ color: theme.colors.text.secondary, @@ -73,6 +89,9 @@ const getStyles = (theme: GrafanaTheme2) => { '&.level-error': { color: colors.error, }, + '&.level-info': { + color: colors.info, + }, '&.level-warning': { color: colors.warning, }, @@ -101,16 +120,21 @@ const getStyles = (theme: GrafanaTheme2) => { color: colors.debug, }, }), + loadMoreButton: css({ + background: 'transparent', + border: 'none', + display: 'inline', + }), overflows: css({ outline: 'solid 1px red', }), unwrappedLogLine: css({ whiteSpace: 'pre', - paddingBottom: theme.spacing(0.5), + paddingBottom: theme.spacing(0.75), }), wrappedLogLine: css({ whiteSpace: 'pre-wrap', - paddingBottom: theme.spacing(0.5), + paddingBottom: theme.spacing(0.75), }), }; }; diff --git a/public/app/features/logs/components/panel/LogLineMessage.tsx b/public/app/features/logs/components/panel/LogLineMessage.tsx new file mode 100644 index 00000000000..2bdff1c03f5 --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineMessage.tsx @@ -0,0 +1,27 @@ +import { CSSProperties, ReactNode } from 'react'; + +import { useTheme2 } from '@grafana/ui'; + +import { getStyles } from './LogLine'; + +interface Props { + children: ReactNode; + onClick?: () => void; + style: CSSProperties; +} + +export const LogLineMessage = ({ children, onClick, style }: Props) => { + const theme = useTheme2(); + const styles = getStyles(theme); + return ( +
+ {onClick ? ( + + ) : ( + children + )} +
+ ); +}; diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 8c08e96bfef..9e42cb73214 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -1,12 +1,12 @@ import { debounce } from 'lodash'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { ListChildComponentProps, VariableSizeList } from 'react-window'; +import { VariableSizeList } from 'react-window'; -import { CoreApp, EventBus, LogRowModel, LogsSortOrder } from '@grafana/data'; +import { AbsoluteTimeRange, CoreApp, EventBus, LogRowModel, LogsSortOrder, TimeRange } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; -import { LogLine } from './LogLine'; -import { preProcessLogs, ProcessedLogModel } from './processing'; +import { InfiniteScroll } from './InfiniteScroll'; +import { preProcessLogs, LogListModel } from './processing'; import { getLogLineSize, init as initVirtualization, @@ -21,8 +21,11 @@ interface Props { containerElement: HTMLDivElement; eventBus: EventBus; forceEscape?: boolean; + initialScrollPosition?: 'top' | 'bottom'; + loadMore?: (range: AbsoluteTimeRange) => void; showTime: boolean; sortOrder: LogsSortOrder; + timeRange: TimeRange; timeZone: string; wrapLogMessage: boolean; } @@ -30,41 +33,40 @@ interface Props { export const LogList = ({ app, containerElement, - logs, eventBus, forceEscape = false, + initialScrollPosition = 'top', + loadMore, + logs, showTime, sortOrder, + timeRange, timeZone, wrapLogMessage, }: Props) => { - const [processedLogs, setProcessedLogs] = useState([]); + const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState( app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight ); const theme = useTheme2(); const listRef = useRef(null); const widthRef = useRef(containerElement.clientWidth); + const scrollRef = useRef(null); useEffect(() => { initVirtualization(theme); }, [theme]); useEffect(() => { - const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => { - if (e.payload.scrollTo === 'top') { - listRef.current?.scrollTo(0); - } else { - listRef.current?.scrollToItem(processedLogs.length - 1); - } - }); + const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => + handleScrollToEvent(e, logs.length, listRef.current) + ); return () => subscription.unsubscribe(); - }, [eventBus, processedLogs.length]); + }, [eventBus, logs.length]); useEffect(() => { setProcessedLogs(preProcessLogs(logs, { wrap: wrapLogMessage, escape: forceEscape, order: sortOrder, timeZone })); listRef.current?.resetAfterIndex(0); - listRef.current?.scrollTo(0); }, [forceEscape, logs, sortOrder, timeZone, wrapLogMessage]); useEffect(() => { @@ -97,21 +99,9 @@ export const LogList = ({ [containerElement] ); - const Renderer = useCallback( - ({ index, style }: ListChildComponentProps) => { - return ( - - ); - }, - [handleOverflow, processedLogs, showTime, wrapLogMessage] - ); + const handleScrollPosition = useCallback(() => { + listRef.current?.scrollToItem(initialScrollPosition === 'top' ? 0 : logs.length - 1); + }, [initialScrollPosition, logs.length]); if (!containerElement || listHeight == null) { // Wait for container to be rendered @@ -119,17 +109,42 @@ export const LogList = ({ } return ( - processedLogs[index].uid} - layout="vertical" - ref={listRef} - style={{ overflowY: 'scroll' }} - width="100%" + - {Renderer} - + {({ getItemKey, itemCount, onItemsRendered, Renderer }) => ( + + {Renderer} + + )} + ); }; + +function handleScrollToEvent(event: ScrollToLogsEvent, logsCount: number, list: VariableSizeList | null) { + if (event.payload.scrollTo === 'top') { + list?.scrollTo(0); + } else { + list?.scrollToItem(logsCount - 1); + } +} diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 68986505ccc..77f525614b5 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -4,7 +4,7 @@ import { escapeUnescapedString, sortLogRows } from '../../utils'; import { measureTextWidth } from './virtualization'; -export interface ProcessedLogModel extends LogRowModel { +export interface LogListModel extends LogRowModel { body: string; timestamp: string; dimensions: LogDimensions; @@ -25,7 +25,7 @@ interface PreProcessOptions { export const preProcessLogs = ( logs: LogRowModel[], { escape, order, timeZone, wrap }: PreProcessOptions -): ProcessedLogModel[] => { +): LogListModel[] => { const orderedLogs = sortLogRows(logs, order); return orderedLogs.map((log) => preProcessLog(log, { wrap, escape, timeZone, expanded: false })); }; @@ -36,10 +36,7 @@ interface PreProcessLogOptions { timeZone: string; wrap: boolean; } -const preProcessLog = ( - log: LogRowModel, - { escape, expanded, timeZone, wrap }: PreProcessLogOptions -): ProcessedLogModel => { +const preProcessLog = (log: LogRowModel, { escape, expanded, timeZone, wrap }: PreProcessLogOptions): LogListModel => { let body = log.entry; const timestamp = dateTimeFormat(log.timeEpochMs, { timeZone, diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 6c9bfbfbb30..201cc4c82ed 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -1,10 +1,10 @@ import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; -import { ProcessedLogModel } from './processing'; +import { LogListModel } from './processing'; let ctx: CanvasRenderingContext2D | null = null; let gridSize = 8; -let paddingBottom = gridSize * 0.5; +let paddingBottom = gridSize * 0.75; let lineHeight = 22; let measurementMode: 'canvas' | 'dom' = 'canvas'; @@ -16,7 +16,7 @@ export function init(theme: GrafanaTheme2) { initCanvasMeasurement(font, letterSpacing); gridSize = theme.spacing.gridSize; - paddingBottom = gridSize * 0.5; + paddingBottom = gridSize * 0.75; lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight; widthMap = new Map(); @@ -144,7 +144,7 @@ interface DisplayOptions { } export function getLogLineSize( - logs: ProcessedLogModel[], + logs: LogListModel[], container: HTMLDivElement | null, { wrap, showTime }: DisplayOptions, index: number @@ -152,7 +152,8 @@ export function getLogLineSize( if (!container) { return 0; } - if (!wrap) { + // !logs[index] means the line is not yet loaded by infinite scrolling + if (!wrap || !logs[index]) { return lineHeight + paddingBottom; } const storedSize = retrieveLogLineSize(logs[index].uid, container); diff --git a/public/app/plugins/panel/logs-new/LogsPanel.tsx b/public/app/plugins/panel/logs-new/LogsPanel.tsx index c6a00fd2322..44dea0e5f54 100644 --- a/public/app/plugins/panel/logs-new/LogsPanel.tsx +++ b/public/app/plugins/panel/logs-new/LogsPanel.tsx @@ -1,13 +1,24 @@ import { css } from '@emotion/css'; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { CoreApp, GrafanaTheme2, LogsSortOrder, PanelProps } from '@grafana/data'; +import { + AbsoluteTimeRange, + CoreApp, + DataFrame, + GrafanaTheme2, + LoadingState, + LogsSortOrder, + PanelProps, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; import { usePanelContext, useStyles2 } from '@grafana/ui'; import { LogList } from 'app/features/logs/components/panel/LogList'; -import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization'; 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'; @@ -17,45 +28,69 @@ export const LogsPanel = ({ data, timeZone, fieldConfig, - options: { showTime, wrapLogMessage, sortOrder, dedupStrategy }, + options: { dedupStrategy, enableInfiniteScrolling, onNewLogsReceived, showTime, sortOrder, wrapLogMessage }, id, }: LogsPanelProps) => { - const isAscending = sortOrder === LogsSortOrder.Ascending; const style = useStyles2(getStyles); const [logsContainer, setLogsContainer] = useState(null); const [panelData, setPanelData] = useState(data); + const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets); // Prevents the scroll position to change when new data from infinite scrolling is received const keepScrollPositionRef = useRef(false); + // Loading ref to prevent firing multiple requests + const loadingRef = useRef(false); const { eventBus } = usePanelContext(); const logs = useMemo(() => { const logsModel = panelData - ? dataFrameToLogsModel(panelData.series, data.request?.intervalMs, undefined, data.request?.targets) + ? dataFrameToLogsModel(panelData.series, panelData.request?.intervalMs, undefined, panelData.request?.targets) : null; return logsModel ? dedupLogRows(logsModel.rows, dedupStrategy) : []; - }, [data.request?.intervalMs, data.request?.targets, dedupStrategy, panelData]); + }, [dedupStrategy, panelData]); useEffect(() => { - setPanelData(data); + if (data.state !== LoadingState.Loading) { + setPanelData(data); + } }, [data]); - useLayoutEffect(() => { - if (keepScrollPositionRef.current) { - keepScrollPositionRef.current = false; - return; - } + const loadMoreLogs = useCallback( + async (scrollRange: AbsoluteTimeRange) => { + if (!data.request || !config.featureToggles.logsInfiniteScrolling || loadingRef.current) { + return; + } + loadingRef.current = true; + + const onNewLogsReceivedCallback = isOnNewLogsReceivedType(onNewLogsReceived) ? onNewLogsReceived : undefined; + + let newSeries: DataFrame[] = []; + try { + newSeries = await requestMoreLogs(dataSourcesMap, panelData, scrollRange, timeZone, onNewLogsReceivedCallback); + } catch (e) { + console.error(e); + } finally { + loadingRef.current = false; + } + + keepScrollPositionRef.current = true; + setPanelData({ + ...panelData, + series: newSeries, + }); + }, + [data.request, dataSourcesMap, onNewLogsReceived, panelData, timeZone] + ); + + 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 (data.request?.app === CoreApp.Dashboard || data.request?.app === CoreApp.PanelEditor) { - eventBus.publish( - new ScrollToLogsEvent({ - scrollTo: isAscending ? 'top' : 'bottom', - }) - ); + return sortOrder === LogsSortOrder.Ascending ? 'bottom' : 'top'; } - }, [data.request?.app, eventBus, isAscending, logs]); + return 'top'; + }, [data.request?.app, sortOrder]); if (!logs.length) { return ; @@ -68,9 +103,12 @@ export const LogsPanel = ({ app={CoreApp.Dashboard} containerElement={logsContainer} eventBus={eventBus} + initialScrollPosition={initialScrollPosition} logs={logs} + loadMore={enableInfiniteScrolling ? loadMoreLogs : undefined} showTime={showTime} sortOrder={sortOrder} + timeRange={data.timeRange} timeZone={timeZone} wrapLogMessage={wrapLogMessage} /> diff --git a/public/app/plugins/panel/logs-new/panelcfg.cue b/public/app/plugins/panel/logs-new/panelcfg.cue index ff5d3351ef3..e7b28449105 100644 --- a/public/app/plugins/panel/logs-new/panelcfg.cue +++ b/public/app/plugins/panel/logs-new/panelcfg.cue @@ -32,6 +32,7 @@ composableKinds: PanelCfg: { sortOrder: common.LogsSortOrder dedupStrategy: common.LogsDedupStrategy enableInfiniteScrolling?: bool + 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 e8d58be30ce..62523679a72 100644 --- a/public/app/plugins/panel/logs-new/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs-new/panelcfg.gen.ts @@ -14,6 +14,7 @@ export interface Options { dedupStrategy: common.LogsDedupStrategy; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; + onNewLogsReceived?: unknown; showTime: boolean; sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; diff --git a/public/app/plugins/panel/logs-new/plugin.json b/public/app/plugins/panel/logs-new/plugin.json index 54357e1cd97..4d02ef9582d 100644 --- a/public/app/plugins/panel/logs-new/plugin.json +++ b/public/app/plugins/panel/logs-new/plugin.json @@ -1,6 +1,6 @@ { "type": "panel", - "name": "Logs (new)", + "name": "Logs new", "id": "logs-new", "state": "alpha", diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 51ae908d9b5..a335e830aca 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -563,7 +563,7 @@ async function copyDashboardUrl(row: LogRowModel, rows: LogRowModel[], timeRange return Promise.resolve(); } -async function requestMoreLogs( +export async function requestMoreLogs( dataSourcesMap: Map, panelData: PanelData, timeRange: AbsoluteTimeRange, diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index f6d3aa142fa..45952d81f89 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -1,8 +1,8 @@ import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data'; import { LogsPanel } from './LogsPanel'; +import { Options } from './panelcfg.gen'; import { LogsPanelSuggestionsSupplier } from './suggestions'; -import { Options } from './types'; export const plugin = new PanelPlugin(LogsPanel) .setPanelOptions((builder) => { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 27301c8765b..1872daf2358 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2012,6 +2012,10 @@ }, "logs": { "infinite-scroll": { + "end-of-range": "End of the selected time range.", + "load-more": "Scroll to load more", + "load-newer": "Loading newer logs...", + "load-older": "Loading older logs...", "older-logs": "Older logs" }, "log-details": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 59371299812..0df58885897 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2012,6 +2012,10 @@ }, "logs": { "infinite-scroll": { + "end-of-range": "Ēʼnđ őƒ ŧĥę şęľęčŧęđ ŧįmę řäʼnģę.", + "load-more": "Ŝčřőľľ ŧő ľőäđ mőřę", + "load-newer": "Ŀőäđįʼnģ ʼnęŵęř ľőģş...", + "load-older": "Ŀőäđįʼnģ őľđęř ľőģş...", "older-logs": "Øľđęř ľőģş" }, "log-details": {