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
This commit is contained in:
Matias Chomicki 2025-02-14 11:52:34 +00:00 committed by GitHub
parent 9e3872f8dd
commit b814f1628f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 438 additions and 87 deletions

View File

@ -16,6 +16,7 @@ export interface Options {
dedupStrategy: common.LogsDedupStrategy;
enableInfiniteScrolling?: boolean;
enableLogDetails: boolean;
onNewLogsReceived?: unknown;
showTime: boolean;
sortOrder: common.LogsSortOrder;
wrapLogMessage: boolean;

View File

@ -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;
}

View File

@ -1063,7 +1063,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
/>
</>
)}
{visualisationType === 'logs' && config.featureToggles.newLogsPanel && (
{visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && (
<>
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRows}>
{logsContainerRef.current && (
@ -1072,9 +1072,11 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
containerElement={logsContainerRef.current}
eventBus={eventBus}
forceEscape={forceEscape}
loadMore={loadMoreLogs}
logs={dedupedRows}
showTime={showTime}
sortOrder={logsSortOrder}
timeRange={props.range}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>

View File

@ -214,12 +214,12 @@ const outOfRangeMessage = (
</div>
);
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<number>,
@ -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,

View File

@ -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<InfiniteLoaderState>('idle');
const [autoScroll, setAutoScroll] = useState(false);
const prevLogs = usePrevious(logs);
const prevSortOrder = usePrevious(sortOrder);
const lastScroll = useRef<number>(scrollElement?.scrollTop || 0);
const lastEvent = useRef<Event | WheelEvent | null>(null);
const countRef = useRef(0);
const lastLogOfPage = useRef<string[]>([]);
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 (
<LogLineMessage style={style} onClick={infiniteLoaderState === 'pre-scroll' ? onLoadMore : undefined}>
{getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)}
</LogLineMessage>
);
}
return (
<LogLine
index={index}
log={logs[index]}
showTime={showTime}
style={style}
variant={getLogLineVariant(logs, index, lastLogOfPage.current)}
wrapLogMessage={wrapLogMessage}
onOverflow={handleOverflow}
/>
);
},
[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...')}{' '}
<Spinner inline />
</>
);
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;
}

View File

@ -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<HTMLDivElement | null>(null);
@ -33,7 +34,7 @@ export const LogLine = ({ index, log, style, onOverflow, showTime, wrapLogMessag
}, [index, log.uid, onOverflow, style.height]);
return (
<div style={style} className={styles.logLine} ref={onOverflow ? logLineRef : undefined}>
<div style={style} className={`${styles.logLine} ${variant}`} ref={onOverflow ? logLineRef : undefined}>
<div className={wrapLogMessage ? styles.wrappedLogLine : styles.unwrappedLogLine}>
{showTime && <span className={`${styles.timestamp} level-${log.logLevel}`}>{log.timestamp}</span>}
{log.logLevel && <span className={`${styles.level} level-${log.logLevel}`}>{log.logLevel}</span>}
@ -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),
}),
};
};

View File

@ -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 (
<div style={style} className={`${styles.logLine} ${styles.logLineMessage}`}>
{onClick ? (
<button className={styles.loadMoreButton} onClick={onClick}>
{children}
</button>
) : (
children
)}
</div>
);
};

View File

@ -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<ProcessedLogModel[]>([]);
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
const [listHeight, setListHeight] = useState(
app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
);
const theme = useTheme2();
const listRef = useRef<VariableSizeList | null>(null);
const widthRef = useRef(containerElement.clientWidth);
const scrollRef = useRef<HTMLDivElement | null>(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 (
<LogLine
index={index}
log={processedLogs[index]}
showTime={showTime}
style={style}
wrapLogMessage={wrapLogMessage}
onOverflow={handleOverflow}
/>
);
},
[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 (
<VariableSizeList
height={listHeight}
itemCount={processedLogs.length}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, { wrap: wrapLogMessage, showTime })}
itemKey={(index: number) => processedLogs[index].uid}
layout="vertical"
ref={listRef}
style={{ overflowY: 'scroll' }}
width="100%"
<InfiniteScroll
handleOverflow={handleOverflow}
logs={processedLogs}
loadMore={loadMore}
scrollElement={scrollRef.current}
showTime={showTime}
sortOrder={sortOrder}
timeRange={timeRange}
timeZone={timeZone}
setInitialScrollPosition={handleScrollPosition}
wrapLogMessage={wrapLogMessage}
>
{Renderer}
</VariableSizeList>
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
<VariableSizeList
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, { wrap: wrapLogMessage, showTime })}
itemKey={getItemKey}
layout="vertical"
onItemsRendered={onItemsRendered}
outerRef={scrollRef}
ref={listRef}
style={{ overflowY: 'scroll' }}
width="100%"
>
{Renderer}
</VariableSizeList>
)}
</InfiniteScroll>
);
};
function handleScrollToEvent(event: ScrollToLogsEvent, logsCount: number, list: VariableSizeList | null) {
if (event.payload.scrollTo === 'top') {
list?.scrollTo(0);
} else {
list?.scrollToItem(logsCount - 1);
}
}

View File

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

View File

@ -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<number, number>();
@ -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);

View File

@ -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<HTMLDivElement | null>(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 <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
@ -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}
/>

View File

@ -32,6 +32,7 @@ composableKinds: PanelCfg: {
sortOrder: common.LogsSortOrder
dedupStrategy: common.LogsDedupStrategy
enableInfiniteScrolling?: bool
onNewLogsReceived?: _
} @cuetsy(kind="interface")
}
}]

View File

@ -14,6 +14,7 @@ export interface Options {
dedupStrategy: common.LogsDedupStrategy;
enableInfiniteScrolling?: boolean;
enableLogDetails: boolean;
onNewLogsReceived?: unknown;
showTime: boolean;
sortOrder: common.LogsSortOrder;
wrapLogMessage: boolean;

View File

@ -1,6 +1,6 @@
{
"type": "panel",
"name": "Logs (new)",
"name": "Logs new",
"id": "logs-new",
"state": "alpha",

View File

@ -563,7 +563,7 @@ async function copyDashboardUrl(row: LogRowModel, rows: LogRowModel[], timeRange
return Promise.resolve();
}
async function requestMoreLogs(
export async function requestMoreLogs(
dataSourcesMap: Map<string, DataSourceApi>,
panelData: PanelData,
timeRange: AbsoluteTimeRange,

View File

@ -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<Options>(LogsPanel)
.setPanelOptions((builder) => {

View File

@ -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": {

View File

@ -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": {