mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
9e3872f8dd
commit
b814f1628f
@ -16,6 +16,7 @@ export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
onNewLogsReceived?: unknown;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
wrapLogMessage: boolean;
|
||||
|
@ -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;
|
||||
}
|
@ -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}
|
||||
/>
|
||||
|
@ -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,
|
||||
|
213
public/app/features/logs/components/panel/InfiniteScroll.tsx
Normal file
213
public/app/features/logs/components/panel/InfiniteScroll.tsx
Normal 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;
|
||||
}
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
27
public/app/features/logs/components/panel/LogLineMessage.tsx
Normal file
27
public/app/features/logs/components/panel/LogLineMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -32,6 +32,7 @@ composableKinds: PanelCfg: {
|
||||
sortOrder: common.LogsSortOrder
|
||||
dedupStrategy: common.LogsDedupStrategy
|
||||
enableInfiniteScrolling?: bool
|
||||
onNewLogsReceived?: _
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
}]
|
||||
|
@ -14,6 +14,7 @@ export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
onNewLogsReceived?: unknown;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
wrapLogMessage: boolean;
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Logs (new)",
|
||||
"name": "Logs new",
|
||||
"id": "logs-new",
|
||||
"state": "alpha",
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) => {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user