New Logs Context: Logs context using the new Logs Panel (#108852)
* LogLineContext: initial support * LogLineContext: infinite scroll bottom * LogLineContext: clean up styles and support both scrolls * InfiniteScroll: support top scrolling * LogLineContext: support center matched line * LogLineContext: read options from storage * LogLineContext: move padding container * LogLineContext: reset on ui change * LogLineContext: improve sort order and fix open in split * LogLineContext: show log line * Translations * LogsPanel: integrate new context component * LogLineContext: add unit test * Prettier * Remove unused import * InfiniteScroll: add "scroll to load more" top * LogLineContext: rename collapsible * Update tests * LogLineContext: refactor center button * LogLineContext: handle overflow in small viewports * LogLineContext: format referenced log line * Memoize * LogLineContext: make modal bigger * LogList: improve a11y * LogLineContext: flexbox magic * LogLineContext: revert center button change * LogsPanel: hide overflow * LogLineContext: responsive min-height * LogLineContext: fix wrap log message * InfiniteScroll: improve direction handling * Add feature flag * Implement feature flag * InfiniteScroll: report direction
This commit is contained in:
@@ -1091,4 +1091,8 @@ export interface FeatureToggles {
|
||||
* Enable adhoc filter buttons in visualization tooltips
|
||||
*/
|
||||
adhocFiltersInTooltips?: boolean;
|
||||
/**
|
||||
* New Log Context component
|
||||
*/
|
||||
newLogContext?: boolean;
|
||||
}
|
||||
|
||||
@@ -1890,6 +1890,13 @@ var (
|
||||
Owner: grafanaDataProSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "newLogContext",
|
||||
Description: "New Log Context component",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -244,3 +244,4 @@ dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true
|
||||
dashboardLevelTimeMacros,experimental,@grafana/dashboards-squad,false,false,true
|
||||
alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false
|
||||
adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true
|
||||
newLogContext,experimental,@grafana/observability-logs,false,false,true
|
||||
|
||||
|
@@ -986,4 +986,8 @@ const (
|
||||
// FlagAdhocFiltersInTooltips
|
||||
// Enable adhoc filter buttons in visualization tooltips
|
||||
FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips"
|
||||
|
||||
// FlagNewLogContext
|
||||
// New Log Context component
|
||||
FlagNewLogContext = "newLogContext"
|
||||
)
|
||||
|
||||
@@ -2150,6 +2150,19 @@
|
||||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newLogContext",
|
||||
"resourceVersion": "1754044501326",
|
||||
"creationTimestamp": "2025-08-01T10:35:01Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "New Log Context component",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-logs",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newLogsPanel",
|
||||
|
||||
@@ -52,6 +52,7 @@ import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRow
|
||||
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
|
||||
import { LogRows } from 'app/features/logs/components/LogRows';
|
||||
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
|
||||
import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext';
|
||||
import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList';
|
||||
import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext';
|
||||
import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel';
|
||||
@@ -767,7 +768,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{getRowContext && contextRow && (
|
||||
{(!config.featureToggles.newLogsPanel || !config.featureToggles.newLogContext) && getRowContext && contextRow && (
|
||||
<LogRowContextModal
|
||||
open={contextOpen}
|
||||
row={contextRow}
|
||||
@@ -779,6 +780,21 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
)}
|
||||
{config.featureToggles.newLogsPanel && config.featureToggles.newLogContext && getRowContext && contextRow && (
|
||||
<LogLineContext
|
||||
open={contextOpen}
|
||||
log={contextRow}
|
||||
onClose={onCloseContext}
|
||||
getRowContext={(row, options) => getRowContext(row, contextRow, options)}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
getLogRowContextUi={getLogRowContextUi}
|
||||
logOptionsStorageKey={SETTING_KEY_ROOT}
|
||||
timeZone={timeZone}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
/>
|
||||
)}
|
||||
<PanelChrome
|
||||
title={t('explore.unthemed-logs.title-logs-volume', 'Logs volume')}
|
||||
collapsible
|
||||
|
||||
@@ -310,7 +310,7 @@ function getNextRange(visibleRange: AbsoluteTimeRange, currentRange: TimeRange,
|
||||
export const SCROLLING_THRESHOLD = 1e3;
|
||||
|
||||
// To get more logs, the difference between the visible range and the current range should be 1 second or more.
|
||||
function canScrollTop(
|
||||
export function canScrollTop(
|
||||
visibleRange: AbsoluteTimeRange,
|
||||
currentRange: TimeRange,
|
||||
timeZone: TimeZone,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Spinner, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll';
|
||||
import { canScrollBottom, canScrollTop, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll';
|
||||
|
||||
import { getStyles, LogLine } from './LogLine';
|
||||
import { LogLineMessage } from './LogLineMessage';
|
||||
@@ -25,7 +25,8 @@ interface Props {
|
||||
children: (props: ChildrenProps) => ReactNode;
|
||||
displayedFields: string[];
|
||||
handleOverflow: (index: number, id: string, height?: number) => void;
|
||||
loadMore?: (range: AbsoluteTimeRange) => void;
|
||||
infiniteScrollMode: InfiniteScrollMode;
|
||||
loadMore?: LoadMoreLogsType;
|
||||
logs: LogListModel[];
|
||||
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
|
||||
scrollElement: HTMLDivElement | null;
|
||||
@@ -38,12 +39,17 @@ interface Props {
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
type InfiniteLoaderState = 'idle' | 'out-of-bounds' | 'pre-scroll' | 'loading';
|
||||
type InfiniteLoaderState = 'idle' | 'out-of-bounds' | 'pre-scroll-top' | 'pre-scroll-bottom' | 'loading';
|
||||
export type InfiniteScrollMode = 'interval' | 'unlimited';
|
||||
export type LoadMoreLogsType =
|
||||
| ((range: AbsoluteTimeRange) => void)
|
||||
| ((range: AbsoluteTimeRange, scrollDirection: ScrollDirection) => void);
|
||||
|
||||
export const InfiniteScroll = ({
|
||||
children,
|
||||
displayedFields,
|
||||
handleOverflow,
|
||||
infiniteScrollMode,
|
||||
loadMore,
|
||||
logs,
|
||||
onClick,
|
||||
@@ -65,6 +71,7 @@ export const InfiniteScroll = ({
|
||||
const countRef = useRef(0);
|
||||
const lastLogOfPage = useRef<string[]>([]);
|
||||
const styles = useStyles2(getStyles, virtualization);
|
||||
const resetStateTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Logs have not changed, ignore effect
|
||||
@@ -74,12 +81,14 @@ export const InfiniteScroll = ({
|
||||
// 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');
|
||||
setInfiniteLoaderState(
|
||||
logs.length === prevLogs.length && infiniteScrollMode === 'interval' ? 'out-of-bounds' : 'idle'
|
||||
);
|
||||
} else {
|
||||
lastLogOfPage.current = [];
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [infiniteLoaderState, logs, prevLogs]);
|
||||
}, [infiniteLoaderState, infiniteScrollMode, logs, prevLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSortOrder && prevSortOrder !== sortOrder) {
|
||||
@@ -94,21 +103,31 @@ export const InfiniteScroll = ({
|
||||
}
|
||||
}, [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);
|
||||
const onLoadMore = useCallback(
|
||||
(scrollDirection: ScrollDirection) => {
|
||||
const newRange =
|
||||
scrollDirection === ScrollDirection.Bottom
|
||||
? canScrollBottom(getVisibleRange(logs), timeRange, timeZone, sortOrder)
|
||||
: canScrollTop(getVisibleRange(logs), timeRange, timeZone, sortOrder);
|
||||
if (!newRange && infiniteScrollMode === 'interval') {
|
||||
setInfiniteLoaderState('out-of-bounds');
|
||||
return;
|
||||
}
|
||||
if (scrollDirection === ScrollDirection.Bottom) {
|
||||
lastLogOfPage.current.push(logs[logs.length - 1].uid);
|
||||
} else {
|
||||
lastLogOfPage.current.push(logs[0].uid);
|
||||
}
|
||||
setInfiniteLoaderState('loading');
|
||||
loadMore?.(newRange ?? getVisibleRange(logs), scrollDirection);
|
||||
|
||||
reportInteraction('grafana_logs_infinite_scrolling', {
|
||||
direction: 'bottom',
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
}, [loadMore, logs, sortOrder, timeRange, timeZone]);
|
||||
reportInteraction('grafana_logs_infinite_scrolling', {
|
||||
direction: scrollDirection,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
},
|
||||
[infiniteScrollMode, loadMore, logs, sortOrder, timeRange, timeZone]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollElement || !loadMore || !config.featureToggles.logsInfiniteScrolling) {
|
||||
@@ -116,14 +135,24 @@ export const InfiniteScroll = ({
|
||||
}
|
||||
|
||||
function handleScroll(event: Event | WheelEvent) {
|
||||
if (!scrollElement || !loadMore || !logs.length || infiniteLoaderState !== 'pre-scroll') {
|
||||
if (!scrollElement || !loadMore || !logs.length) {
|
||||
return;
|
||||
}
|
||||
const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current);
|
||||
lastEvent.current = event;
|
||||
lastScroll.current = scrollElement.scrollTop;
|
||||
if (scrollDirection === ScrollDirection.Bottom) {
|
||||
onLoadMore();
|
||||
if (infiniteLoaderState !== 'pre-scroll-bottom' && infiniteLoaderState !== 'pre-scroll-top') {
|
||||
if (infiniteScrollMode === 'unlimited' && scrollDirection === ScrollDirection.Top) {
|
||||
setInfiniteLoaderState('pre-scroll-top');
|
||||
resetStateTimeout.current = setTimeout(() => {
|
||||
setInfiniteLoaderState((state) => (state === 'pre-scroll-top' ? 'idle' : state));
|
||||
}, 10000);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (scrollDirection !== ScrollDirection.NoScroll) {
|
||||
onLoadMore(scrollDirection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +163,26 @@ export const InfiniteScroll = ({
|
||||
scrollElement.removeEventListener('scroll', handleScroll);
|
||||
scrollElement.removeEventListener('wheel', handleScroll);
|
||||
};
|
||||
}, [infiniteLoaderState, loadMore, logs.length, onLoadMore, scrollElement]);
|
||||
}, [infiniteLoaderState, infiniteScrollMode, loadMore, logs.length, onLoadMore, scrollElement]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetStateTimeout.current) {
|
||||
clearTimeout(resetStateTimeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadMoreTop = useCallback(() => {
|
||||
if (resetStateTimeout.current) {
|
||||
clearTimeout(resetStateTimeout.current);
|
||||
}
|
||||
onLoadMore(ScrollDirection.Top);
|
||||
}, [onLoadMore]);
|
||||
|
||||
const loadMoreBottom = useCallback(() => {
|
||||
onLoadMore(ScrollDirection.Bottom);
|
||||
}, [onLoadMore]);
|
||||
|
||||
const Renderer = useCallback(
|
||||
({ index, style }: ListChildComponentProps) => {
|
||||
@@ -143,7 +191,7 @@ export const InfiniteScroll = ({
|
||||
<LogLineMessage
|
||||
style={style}
|
||||
styles={styles}
|
||||
onClick={infiniteLoaderState === 'pre-scroll' ? onLoadMore : undefined}
|
||||
onClick={infiniteLoaderState === 'pre-scroll-bottom' ? loadMoreBottom : undefined}
|
||||
>
|
||||
{getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)}
|
||||
</LogLineMessage>
|
||||
@@ -170,9 +218,9 @@ export const InfiniteScroll = ({
|
||||
displayedFields,
|
||||
handleOverflow,
|
||||
infiniteLoaderState,
|
||||
loadMoreBottom,
|
||||
logs,
|
||||
onClick,
|
||||
onLoadMore,
|
||||
showTime,
|
||||
sortOrder,
|
||||
styles,
|
||||
@@ -192,7 +240,7 @@ export const InfiniteScroll = ({
|
||||
const lastLogIndex = logs.length - 1;
|
||||
const preScrollIndex = logs.length - 2;
|
||||
if (props.visibleStopIndex >= lastLogIndex) {
|
||||
setInfiniteLoaderState('pre-scroll');
|
||||
setInfiniteLoaderState('pre-scroll-bottom');
|
||||
} else if (props.visibleStartIndex < preScrollIndex) {
|
||||
setInfiniteLoaderState('idle');
|
||||
}
|
||||
@@ -204,7 +252,18 @@ export const InfiniteScroll = ({
|
||||
|
||||
const itemCount = logs.length && loadMore && infiniteLoaderState !== 'idle' ? logs.length + 1 : logs.length;
|
||||
|
||||
return <>{children({ getItemKey, itemCount, onItemsRendered, Renderer })}</>;
|
||||
return (
|
||||
<>
|
||||
{infiniteLoaderState === 'pre-scroll-top' && (
|
||||
<div className={styles.loadMoreTopContainer}>
|
||||
<LogLineMessage style={{}} styles={styles} onClick={loadMoreTop}>
|
||||
{t('logs.infinite-scroll.load-more', 'Scroll to load more')}
|
||||
</LogLineMessage>
|
||||
</div>
|
||||
)}
|
||||
{children({ getItemKey, itemCount, onItemsRendered, Renderer })}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: LogsSortOrder) {
|
||||
@@ -220,7 +279,7 @@ function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: Lo
|
||||
<Spinner inline />
|
||||
</>
|
||||
);
|
||||
case 'pre-scroll':
|
||||
case 'pre-scroll-bottom':
|
||||
return t('logs.infinite-scroll.load-more', 'Scroll to load more');
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -524,6 +524,14 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
|
||||
border: 'none',
|
||||
display: 'inline',
|
||||
}),
|
||||
loadMoreTopContainer: css({
|
||||
backgroundColor: tinycolor(theme.colors.background.primary).setAlpha(0.75).toString(),
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
}),
|
||||
overflows: css({
|
||||
outline: 'solid 1px red',
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { render, screen, waitFor, userEvent } from 'test/test-utils';
|
||||
|
||||
import {
|
||||
createDataFrame,
|
||||
FieldType,
|
||||
LogRowContextQueryDirection,
|
||||
LogsSortOrder,
|
||||
SplitOpenOptions,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { dataFrameToLogsModel } from '../../logsModel';
|
||||
|
||||
import { LogLineContext } from './LogLineContext';
|
||||
|
||||
jest.mock('@grafana/assistant', () => ({
|
||||
...jest.requireActual('@grafana/assistant'),
|
||||
useAssistant: jest.fn(() => [true, jest.fn()]),
|
||||
}));
|
||||
|
||||
const dfBefore = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T07:28:11.352440161Z', '2019-04-26T09:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['foo123', 'foo123'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dfNow = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['foo123'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dfAfter = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T14:42:50.991981292Z', '2019-04-26T16:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['foo123', 'bar123'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let getRowContext = jest.fn();
|
||||
const dispatchMock = jest.fn();
|
||||
jest.mock('app/types/store', () => ({
|
||||
...jest.requireActual('app/types/store'),
|
||||
useDispatch: () => dispatchMock,
|
||||
}));
|
||||
|
||||
const splitOpenSym = Symbol('splitOpen');
|
||||
const splitOpen = jest.fn().mockReturnValue(splitOpenSym);
|
||||
jest.mock('app/features/explore/state/main', () => ({
|
||||
...jest.requireActual('app/features/explore/state/main'),
|
||||
splitOpen: (arg?: SplitOpenOptions) => {
|
||||
return splitOpen(arg);
|
||||
},
|
||||
}));
|
||||
|
||||
const logs = dataFrameToLogsModel([dfNow]);
|
||||
const row = logs.rows[0];
|
||||
|
||||
const timeZone = 'UTC';
|
||||
|
||||
describe('LogLineContext', () => {
|
||||
let uniqueRefIdCounter = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
uniqueRefIdCounter = 1;
|
||||
getRowContext = jest.fn().mockImplementation(async (_, options) => {
|
||||
uniqueRefIdCounter += 1;
|
||||
const refId = `refid_${uniqueRefIdCounter}`;
|
||||
if (options.direction === LogRowContextQueryDirection.Forward) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfBefore,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfAfter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('Should not render when it is closed', async () => {
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Log context')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('Should render when it is open', async () => {
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Log context')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('Should call not getRowContext when closed', async () => {
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getRowContext).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('Should call getRowContext on open', async () => {
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('should render 3 lines containing `foo123`', async () => {
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
// 1 in before, 1 in current, 1 in after
|
||||
await waitFor(() => expect(screen.getAllByText('foo123').length).toBe(3));
|
||||
});
|
||||
|
||||
test('should render 3 lines containing `foo123` with the same ms timestamp', async () => {
|
||||
const dfBeforeNs = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [1, 1],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['foo123', 'foo123'],
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: FieldType.string,
|
||||
values: ['1', '2'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dfNowNs = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [1],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['foo123'],
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: FieldType.string,
|
||||
values: ['2'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dfAfterNs = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [1, 1],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['foo123', 'foo123'],
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: FieldType.string,
|
||||
values: ['2', '3'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let uniqueRefIdCounter = 1;
|
||||
const logs = dataFrameToLogsModel([dfNowNs]);
|
||||
const row = logs.rows[0];
|
||||
const getRowContext = jest.fn().mockImplementation(async (_, options) => {
|
||||
uniqueRefIdCounter += 1;
|
||||
const refId = `refid_${uniqueRefIdCounter}`;
|
||||
if (uniqueRefIdCounter === 2) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfBeforeNs,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (uniqueRefIdCounter === 3) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfAfterNs,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { data: [] };
|
||||
});
|
||||
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
// 1 in before, 1 in current, 1 in after
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('foo123').length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
test('Should highlight the same `foo123` searchwords', async () => {
|
||||
const dfBeforeNs = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [1, 1],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['this contains foo123', 'this contains foo123'],
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: FieldType.string,
|
||||
values: ['1', '2'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dfNowNs = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [1],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['this contains foo123'],
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: FieldType.string,
|
||||
values: ['2'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dfAfterNs = createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [1, 1],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: ['this contains foo123', 'this contains foo123'],
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: FieldType.string,
|
||||
values: ['2', '3'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let uniqueRefIdCounter = 1;
|
||||
const logs = dataFrameToLogsModel([dfNowNs]);
|
||||
const row = logs.rows[0];
|
||||
row.searchWords = ['foo123'];
|
||||
const getRowContext = jest.fn().mockImplementation(async (_, options) => {
|
||||
uniqueRefIdCounter += 1;
|
||||
const refId = `refid_${uniqueRefIdCounter}`;
|
||||
if (uniqueRefIdCounter === 2) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfBeforeNs,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (uniqueRefIdCounter === 3) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfAfterNs,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { data: [] };
|
||||
});
|
||||
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
// there need to be 3 lines with that message, all `foo123` should be highlighted
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('foo123')).toHaveLength(3);
|
||||
expect(screen.getAllByText('this contains')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
test('Should show a split view button', async () => {
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } });
|
||||
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /open in split view/i,
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test('Should not show a split view button', async () => {
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /open in split view/i,
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Should call getRowContextQuery', async () => {
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } });
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getRowContextQuery).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('Should close modal', async () => {
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } });
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
const splitViewButton = await screen.findByRole('button', {
|
||||
name: /open in split view/i,
|
||||
});
|
||||
|
||||
await userEvent.click(splitViewButton);
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('Should create correct splitOpen', async () => {
|
||||
const queryObj = { datasource: { uid: 'test-uid' } };
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue(queryObj);
|
||||
const onClose = jest.fn();
|
||||
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
const splitViewButton = await screen.findByRole('button', {
|
||||
name: /open in split view/i,
|
||||
});
|
||||
|
||||
await userEvent.click(splitViewButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(splitOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queries: [queryObj],
|
||||
panelsState: {
|
||||
logs: {
|
||||
id: row.uid,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Should dispatch splitOpen', async () => {
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } });
|
||||
const onClose = jest.fn();
|
||||
|
||||
render(
|
||||
<LogLineContext
|
||||
log={row}
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
const splitViewButton = await screen.findByRole('button', {
|
||||
name: /open in split view/i,
|
||||
});
|
||||
|
||||
await userEvent.click(splitViewButton);
|
||||
|
||||
await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith(splitOpenSym));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { partition } from 'lodash';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DataQueryResponse,
|
||||
DataSourceWithLogsContextSupport,
|
||||
GrafanaTheme2,
|
||||
LogRowContextOptions,
|
||||
LogRowContextQueryDirection,
|
||||
LogsDedupStrategy,
|
||||
LogsSortOrder,
|
||||
dateTime,
|
||||
TimeRange,
|
||||
LoadingState,
|
||||
CoreApp,
|
||||
LogRowModel,
|
||||
AbsoluteTimeRange,
|
||||
EventBusSrv,
|
||||
store,
|
||||
} from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, TimeZone } from '@grafana/schema';
|
||||
import { Button, Collapse, Modal, useTheme2 } from '@grafana/ui';
|
||||
import { splitOpen } from 'app/features/explore/state/main';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { dataFrameToLogsModel } from '../../logsModel';
|
||||
import { sortLogRows } from '../../utils';
|
||||
import { ScrollDirection } from '../InfiniteScroll';
|
||||
import { LoadingIndicator } from '../LoadingIndicator';
|
||||
|
||||
import { LogLineDetailsLog } from './LogLineDetailsLog';
|
||||
import { LogList } from './LogList';
|
||||
import { LogListModel } from './processing';
|
||||
import { ScrollToLogsEvent } from './virtualization';
|
||||
|
||||
interface LogLineContextProps {
|
||||
log: LogRowModel | LogListModel;
|
||||
logOptionsStorageKey?: string;
|
||||
open: boolean;
|
||||
timeZone: TimeZone;
|
||||
onClose: () => void;
|
||||
getRowContext: (row: LogRowModel, options: LogRowContextOptions) => Promise<DataQueryResponse>;
|
||||
getRowContextQuery?: (
|
||||
row: LogRowModel,
|
||||
options?: LogRowContextOptions,
|
||||
cacheFilters?: boolean
|
||||
) => Promise<DataQuery | null>;
|
||||
sortOrder?: LogsSortOrder;
|
||||
runContextQuery?: () => void;
|
||||
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
|
||||
displayedFields?: string[];
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export const LogLineContext = memo(
|
||||
({
|
||||
log,
|
||||
logOptionsStorageKey,
|
||||
open,
|
||||
sortOrder = logOptionsStorageKey
|
||||
? (store.get(`${logOptionsStorageKey}.sortOrder`) ?? LogsSortOrder.Descending)
|
||||
: LogsSortOrder.Descending,
|
||||
timeZone,
|
||||
getLogRowContextUi,
|
||||
getRowContextQuery,
|
||||
onClose,
|
||||
getRowContext,
|
||||
displayedFields = [],
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
}: LogLineContextProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [contextQuery, setContextQuery] = useState<DataQuery | null>(null);
|
||||
const [aboveLogs, setAboveLogs] = useState<LogRowModel[]>([]);
|
||||
const [belowLogs, setBelowLogs] = useState<LogRowModel[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const allLogs = useMemo(() => [...aboveLogs, log, ...belowLogs], [log, belowLogs, aboveLogs]);
|
||||
const [aboveState, setAboveState] = useState(LoadingState.NotStarted);
|
||||
const [belowState, setBelowState] = useState(LoadingState.NotStarted);
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const eventBusRef = useRef(new EventBusSrv());
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const timeRange = useMemo(() => {
|
||||
const fromMs =
|
||||
sortOrder === LogsSortOrder.Ascending ? allLogs[0].timeEpochMs : allLogs[allLogs.length - 1].timeEpochMs;
|
||||
let toMs =
|
||||
sortOrder === LogsSortOrder.Ascending ? allLogs[allLogs.length - 1].timeEpochMs : allLogs[0].timeEpochMs;
|
||||
// In case we have a lot of logs and from and to have same millisecond
|
||||
// we add 1 millisecond to toMs to make sure we have a range
|
||||
if (fromMs === toMs) {
|
||||
toMs += 1;
|
||||
}
|
||||
const from = dateTime(fromMs);
|
||||
const to = dateTime(toMs);
|
||||
|
||||
const range: TimeRange = {
|
||||
from,
|
||||
to,
|
||||
raw: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
};
|
||||
return range;
|
||||
}, [allLogs, sortOrder]);
|
||||
|
||||
const updateContextQuery = useCallback(async () => {
|
||||
const contextQuery = getRowContextQuery ? await getRowContextQuery(log) : null;
|
||||
setContextQuery(contextQuery);
|
||||
}, [log, getRowContextQuery]);
|
||||
|
||||
const updateResults = useCallback(async () => {
|
||||
setAboveLogs([]);
|
||||
setBelowLogs([]);
|
||||
await updateContextQuery();
|
||||
setInitialized(false);
|
||||
}, [updateContextQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
updateContextQuery();
|
||||
}
|
||||
}, [updateContextQuery, open]);
|
||||
|
||||
const getContextLogs = useCallback(
|
||||
async (place: 'above' | 'below', refLog: LogRowModel): Promise<LogRowModel[]> => {
|
||||
const result = await getRowContext(normalizeLogRefId(refLog), {
|
||||
limit: PAGE_SIZE,
|
||||
direction: getLoadMoreDirection(place, sortOrder),
|
||||
});
|
||||
|
||||
const newLogs = dataFrameToLogsModel(result.data).rows;
|
||||
if (sortOrder === LogsSortOrder.Ascending) {
|
||||
newLogs.reverse();
|
||||
}
|
||||
return newLogs.filter((r) => !containsRow(allLogs, r));
|
||||
},
|
||||
[allLogs, getRowContext, sortOrder]
|
||||
);
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (place: 'above' | 'below', refLog: LogRowModel) => {
|
||||
const setState = place === 'above' ? setAboveState : setBelowState;
|
||||
setState(LoadingState.Loading);
|
||||
|
||||
try {
|
||||
const newLogs = (await getContextLogs(place, refLog)).map((r) =>
|
||||
// apply the original row's searchWords to all the rows for highlighting
|
||||
!r.searchWords || !r.searchWords?.length ? { ...r, searchWords: log.searchWords } : r
|
||||
);
|
||||
const [older, newer] = partition(newLogs, (newRow) => newRow.timeEpochNs > log.timeEpochNs);
|
||||
const newAbove = sortOrder === LogsSortOrder.Ascending ? newer : older;
|
||||
const newBelow = sortOrder === LogsSortOrder.Ascending ? older : newer;
|
||||
|
||||
setAboveLogs((aboveLogs: LogRowModel[]) => {
|
||||
return newAbove.length > 0 ? sortLogRows([...newAbove, ...aboveLogs], sortOrder) : aboveLogs;
|
||||
});
|
||||
setBelowLogs((belowLogs: LogRowModel[]) => {
|
||||
return newBelow.length > 0 ? sortLogRows([...belowLogs, ...newBelow], sortOrder) : belowLogs;
|
||||
});
|
||||
|
||||
setState(LoadingState.NotStarted);
|
||||
if (!newAbove.length && place === 'above') {
|
||||
setAboveState(LoadingState.Done);
|
||||
}
|
||||
if (!newBelow.length && place === 'below') {
|
||||
setBelowState(LoadingState.Done);
|
||||
}
|
||||
} catch {
|
||||
setState(LoadingState.Error);
|
||||
}
|
||||
},
|
||||
[getContextLogs, log, sortOrder]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (!initialized) {
|
||||
Promise.all([loadMore('above', log), loadMore('below', log)]).then(() => {});
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [initialized, loadMore, log, open]);
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
(_: AbsoluteTimeRange, direction: ScrollDirection) => {
|
||||
if (direction === ScrollDirection.Bottom) {
|
||||
loadMore('below', allLogs[allLogs.length - 1]);
|
||||
} else {
|
||||
loadMore('above', allLogs[0]);
|
||||
}
|
||||
},
|
||||
[allLogs, loadMore]
|
||||
);
|
||||
|
||||
const onScrollCenterClick = useCallback(() => {
|
||||
eventBusRef.current.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: log.uid,
|
||||
})
|
||||
);
|
||||
}, [log.uid]);
|
||||
|
||||
const wrapLogMessage = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.wrapLogMessage`, true) : true;
|
||||
const syntaxHighlighting = logOptionsStorageKey
|
||||
? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true)
|
||||
: true;
|
||||
// @todo: Remove when the LogRows are deprecated
|
||||
const logListModel = useMemo(
|
||||
() =>
|
||||
log instanceof LogListModel
|
||||
? log
|
||||
: new LogListModel(log, {
|
||||
escape: false,
|
||||
timeZone,
|
||||
wrapLogMessage,
|
||||
}),
|
||||
[log, timeZone, wrapLogMessage]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
title={t('logs.log-line-context.title-log-context', 'Log context')}
|
||||
contentClassName={styles.flexColumn}
|
||||
className={styles.modal}
|
||||
onDismiss={onClose}
|
||||
>
|
||||
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
|
||||
<div className={styles.datasourceUi}>{getLogRowContextUi(log, updateResults)}</div>
|
||||
)}
|
||||
<Collapse
|
||||
collapsible={true}
|
||||
isOpen={showLog}
|
||||
onToggle={() => setShowLog(!showLog)}
|
||||
className={styles.referenceLogLine}
|
||||
label={t('logs.log-line-context.title-log-line', 'Referenced log line')}
|
||||
>
|
||||
<LogLineDetailsLog log={logListModel} syntaxHighlighting={syntaxHighlighting} />
|
||||
</Collapse>
|
||||
<div className={styles.loadingIndicator}>
|
||||
{aboveState === LoadingState.Loading && (
|
||||
<LoadingIndicator
|
||||
adjective={
|
||||
sortOrder === LogsSortOrder.Descending
|
||||
? t('logs.log-line-context.newer-logs', 'newer')
|
||||
: t('logs.log-line-context.older-logs', 'older')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{aboveState === LoadingState.Done && (
|
||||
<Trans i18nKey="logs.log-line-context.no-more-logs-available">No more logs available.</Trans>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.logsContainer} ref={containerRef}>
|
||||
{containerRef.current && (
|
||||
<LogList
|
||||
app={CoreApp.Unknown}
|
||||
containerElement={containerRef.current}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
detailsMode="inline"
|
||||
displayedFields={displayedFields}
|
||||
enableLogDetails={true}
|
||||
eventBus={eventBusRef.current}
|
||||
infiniteScrollMode="unlimited"
|
||||
loadMore={handleLoadMore}
|
||||
logs={allLogs}
|
||||
loading={aboveState === LoadingState.Loading || belowState === LoadingState.Loading}
|
||||
permalinkedLogId={log.uid}
|
||||
onClickHideField={onClickHideField}
|
||||
onClickShowField={onClickShowField}
|
||||
showControls
|
||||
showTime={logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.showTime`, true) : true}
|
||||
sortOrder={sortOrder}
|
||||
syntaxHighlighting={syntaxHighlighting}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.loadingIndicator}>
|
||||
{belowState === LoadingState.Loading && (
|
||||
<LoadingIndicator
|
||||
adjective={
|
||||
sortOrder === LogsSortOrder.Descending
|
||||
? t('logs.log-line-context.older-logs', 'older')
|
||||
: t('logs.log-line-context.newer-logs', 'newer')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{belowState === LoadingState.Done && (
|
||||
<Trans i18nKey="logs.log-line-context.no-more-logs-available">No more logs available.</Trans>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" onClick={onScrollCenterClick}>
|
||||
<Trans i18nKey="logs.log-line-context.center-matched-line">Center matched line</Trans>
|
||||
</Button>
|
||||
{contextQuery?.datasource?.uid && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
let rowId = log.uid;
|
||||
if (log.dataFrame.refId) {
|
||||
// the orignal row has the refid from the base query and not the refid from the context query, so we need to replace it.
|
||||
rowId = log.uid.replace(log.dataFrame.refId, contextQuery.refId);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
splitOpen({
|
||||
queries: [contextQuery],
|
||||
range: timeRange,
|
||||
datasourceUid: contextQuery.datasource!.uid!,
|
||||
panelsState: {
|
||||
logs: {
|
||||
id: rowId,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
reportInteraction('logs_log_line_context_open_in_split_clicked', {
|
||||
datasourceType: log.datasourceType,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="logs.log-line-context.open-in-split-view">Open in split view</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
LogLineContext.displayName = 'LogLineContext';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
modal: css({
|
||||
width: '85vw',
|
||||
height: '80%',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
width: '100%',
|
||||
minHeight: '100%',
|
||||
},
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}),
|
||||
datasourceUi: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
loadingIndicator: css({
|
||||
height: theme.spacing(3),
|
||||
minHeight: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
}),
|
||||
referenceLogLine: css({
|
||||
flex: 0,
|
||||
}),
|
||||
wrapper: css({
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
padding: theme.spacing(0, 1, 1, 0),
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
}),
|
||||
logsContainer: css({
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
flexColumn: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(0, 3, 3, 3),
|
||||
height: '100%',
|
||||
}),
|
||||
link: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
':hover': {
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
}),
|
||||
logPreview: css({
|
||||
overflow: 'hidden',
|
||||
textAlign: 'left',
|
||||
textOverflow: 'ellipsis',
|
||||
width: '75vw',
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const getLoadMoreDirection = (place: 'above' | 'below', sortOrder: LogsSortOrder): LogRowContextQueryDirection => {
|
||||
if (place === 'above' && sortOrder === LogsSortOrder.Descending) {
|
||||
return LogRowContextQueryDirection.Forward;
|
||||
}
|
||||
if (place === 'below' && sortOrder === LogsSortOrder.Ascending) {
|
||||
return LogRowContextQueryDirection.Forward;
|
||||
}
|
||||
|
||||
return LogRowContextQueryDirection.Backward;
|
||||
};
|
||||
|
||||
const normalizeLogRefId = (log: LogRowModel): LogRowModel => {
|
||||
// the datasoure plugins often create the context-query based on the row's dataframe's refId,
|
||||
// by appending something to it. for example:
|
||||
// - let's say the row's dataframe's refId is "query"
|
||||
// - the datasource plugin will take "query" and append "-context" to it, so it becomes "query-context".
|
||||
// - later we want to load even more lines, so we make a context query
|
||||
// - the datasource plugin does the same transform again, but now the source is "query-context",
|
||||
// so the new refId becomes "query-context-context"
|
||||
// - next time it becomes "query-context-context-context", and so on.
|
||||
// we do not want refIds to grow unbounded.
|
||||
// to avoid this, we set the refId to a value that does not grow.
|
||||
// on the other hand, the refId is also used in generating the row's UID, so it is useful
|
||||
// when the refId is not always the exact same string, otherwise UID duplication can occur,
|
||||
// which may cause problems.
|
||||
// so we go with an approach where the refId always changes, but does not grow.
|
||||
return {
|
||||
...log,
|
||||
dataFrame: {
|
||||
...log.dataFrame,
|
||||
refId: `context_${log.uid ?? log.dataFrame.refId ?? log.timeEpochMs}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const containsRow = (rows: LogRowModel[], row: LogRowModel) => {
|
||||
return rows.some((r) => r.entry === row.entry && r.timeEpochNs === row.timeEpochNs);
|
||||
};
|
||||
@@ -25,7 +25,8 @@ interface LogLineDetailsComponentProps {
|
||||
}
|
||||
|
||||
export const LogLineDetailsComponent = memo(({ focusLogLine, log, logs }: LogLineDetailsComponentProps) => {
|
||||
const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields } = useLogListContext();
|
||||
const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields, syntaxHighlighting } =
|
||||
useLogListContext();
|
||||
const [search, setSearch] = useState('');
|
||||
const inputRef = useRef('');
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -111,7 +112,7 @@ export const LogLineDetailsComponent = memo(({ focusLogLine, log, logs }: LogLin
|
||||
isOpen={logLineOpen}
|
||||
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)}
|
||||
>
|
||||
<LogLineDetailsLog log={log} />
|
||||
<LogLineDetailsLog log={log} syntaxHighlighting={syntaxHighlighting ?? true} />
|
||||
</ControlledCollapse>
|
||||
{displayedFields.length > 0 && setDisplayedFields && (
|
||||
<ControlledCollapse
|
||||
|
||||
@@ -4,15 +4,14 @@ import { memo, useMemo } from 'react';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getStyles } from './LogLine';
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
interface Props {
|
||||
log: LogListModel;
|
||||
syntaxHighlighting: boolean;
|
||||
}
|
||||
|
||||
export const LogLineDetailsLog = memo(({ log: originalLog }: Props) => {
|
||||
const { syntaxHighlighting } = useLogListContext();
|
||||
export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: Props) => {
|
||||
const logStyles = useStyles2(getStyles);
|
||||
const log = useMemo(() => {
|
||||
const log = originalLog.clone();
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, Mou
|
||||
import { Align, VariableSizeList } from 'react-window';
|
||||
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
EventBus,
|
||||
@@ -24,7 +23,7 @@ import { ConfirmModal, Icon, PopoverContent, useStyles2, useTheme2 } from '@graf
|
||||
import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { InfiniteScroll } from './InfiniteScroll';
|
||||
import { InfiniteScrollMode, InfiniteScroll, LoadMoreLogsType } from './InfiniteScroll';
|
||||
import { getGridTemplateColumns } from './LogLine';
|
||||
import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails';
|
||||
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
||||
@@ -50,10 +49,11 @@ export interface Props {
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
getRowContextQuery?: GetRowContextQueryFn;
|
||||
grammar?: Grammar;
|
||||
infiniteScrollMode?: InfiniteScrollMode;
|
||||
initialScrollPosition?: 'top' | 'bottom';
|
||||
isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
loadMore?: (range: AbsoluteTimeRange) => void;
|
||||
loadMore?: LoadMoreLogsType;
|
||||
logLineMenuCustomItems?: LogLineMenuCustomItem[];
|
||||
logOptionsStorageKey?: string;
|
||||
logs: LogRowModel[];
|
||||
@@ -117,6 +117,7 @@ export const LogList = ({
|
||||
getFieldLinks,
|
||||
getRowContextQuery,
|
||||
grammar,
|
||||
infiniteScrollMode,
|
||||
initialScrollPosition = 'top',
|
||||
isLabelFilterActive,
|
||||
loading,
|
||||
@@ -197,6 +198,7 @@ export const LogList = ({
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
infiniteScrollMode={infiniteScrollMode}
|
||||
loading={loading}
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
@@ -215,6 +217,7 @@ const LogListComponent = ({
|
||||
getFieldLinks,
|
||||
grammar,
|
||||
initialScrollPosition = 'top',
|
||||
infiniteScrollMode = 'interval',
|
||||
loading,
|
||||
loadMore,
|
||||
logs,
|
||||
@@ -271,6 +274,20 @@ const LogListComponent = ({
|
||||
useKeyBindings();
|
||||
const { filterLogs, matchingUids, searchVisible } = useLogListSearchContext();
|
||||
|
||||
const levelFilteredLogs = useMemo(
|
||||
() =>
|
||||
filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)),
|
||||
[filterLevels, processedLogs]
|
||||
);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
matchingUids && filterLogs
|
||||
? levelFilteredLogs.filter((log) => matchingUids.includes(log.uid))
|
||||
: levelFilteredLogs,
|
||||
[filterLogs, levelFilteredLogs, matchingUids]
|
||||
);
|
||||
|
||||
const debouncedResetAfterIndex = useMemo(() => {
|
||||
return debounce((index: number) => {
|
||||
listRef.current?.resetAfterIndex(index);
|
||||
@@ -286,10 +303,10 @@ const LogListComponent = ({
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
|
||||
handleScrollToEvent(e, logs.length, listRef.current)
|
||||
handleScrollToEvent(e, filteredLogs, listRef.current)
|
||||
);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [eventBus, logs.length]);
|
||||
}, [eventBus, filteredLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
@@ -379,20 +396,6 @@ const LogListComponent = ({
|
||||
debouncedResetAfterIndex(0);
|
||||
}, [debouncedResetAfterIndex]);
|
||||
|
||||
const levelFilteredLogs = useMemo(
|
||||
() =>
|
||||
filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)),
|
||||
[filterLevels, processedLogs]
|
||||
);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
matchingUids && filterLogs
|
||||
? levelFilteredLogs.filter((log) => matchingUids.includes(log.uid))
|
||||
: levelFilteredLogs,
|
||||
[filterLogs, levelFilteredLogs, matchingUids]
|
||||
);
|
||||
|
||||
const focusLogLine = useCallback(
|
||||
(log: LogListModel) => {
|
||||
const index = filteredLogs.indexOf(log);
|
||||
@@ -405,6 +408,15 @@ const LogListComponent = ({
|
||||
|
||||
return (
|
||||
<div className={styles.logListContainer}>
|
||||
{showControls && <LogListControls eventBus={eventBus} />}
|
||||
{detailsMode === 'sidebar' && showDetails.length > 0 && (
|
||||
<LogLineDetails
|
||||
containerElement={containerElement}
|
||||
focusLogLine={focusLogLine}
|
||||
logs={filteredLogs}
|
||||
onResize={handleLogDetailsResize}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.logListWrapper} ref={wrapperRef}>
|
||||
{popoverState.selection && popoverState.selectedRow && (
|
||||
<PopoverMenu
|
||||
@@ -443,6 +455,7 @@ const LogListComponent = ({
|
||||
<InfiniteScroll
|
||||
displayedFields={displayedFields}
|
||||
handleOverflow={handleOverflow}
|
||||
infiniteScrollMode={infiniteScrollMode}
|
||||
logs={filteredLogs}
|
||||
loadMore={loadMore}
|
||||
onClick={handleLogLineClick}
|
||||
@@ -483,15 +496,6 @@ const LogListComponent = ({
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
{detailsMode === 'sidebar' && showDetails.length > 0 && (
|
||||
<LogLineDetails
|
||||
containerElement={containerElement}
|
||||
focusLogLine={focusLogLine}
|
||||
logs={filteredLogs}
|
||||
onResize={handleLogDetailsResize}
|
||||
/>
|
||||
)}
|
||||
{showControls && <LogListControls eventBus={eventBus} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -515,6 +519,7 @@ function getStyles(
|
||||
}),
|
||||
logListContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
// Minimum width to prevent rendering issues and a sausage-like logs panel.
|
||||
minWidth: theme.spacing(35),
|
||||
}),
|
||||
@@ -534,10 +539,16 @@ function getStyles(
|
||||
};
|
||||
}
|
||||
|
||||
function handleScrollToEvent(event: ScrollToLogsEvent, logsCount: number, list: VariableSizeList | null) {
|
||||
function handleScrollToEvent(event: ScrollToLogsEvent, logs: LogListModel[], list: VariableSizeList | null) {
|
||||
if (event.payload.scrollTo === 'top') {
|
||||
list?.scrollTo(0);
|
||||
} else if (event.payload.scrollTo === 'bottom') {
|
||||
list?.scrollToItem(logs.length - 1);
|
||||
} else {
|
||||
list?.scrollToItem(logsCount - 1);
|
||||
// uid
|
||||
const index = logs.findIndex((log) => log.uid === event.payload.scrollTo);
|
||||
if (index >= 0) {
|
||||
list?.scrollToItem(index, 'center');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ export function getScrollbarWidth() {
|
||||
}
|
||||
|
||||
export interface ScrollToLogsEventPayload {
|
||||
scrollTo: 'top' | 'bottom';
|
||||
scrollTo: 'top' | 'bottom' | string;
|
||||
}
|
||||
|
||||
export class ScrollToLogsEvent extends BusEventWithPayload<ScrollToLogsEventPayload> {
|
||||
|
||||
@@ -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 { LogLineContext } from 'app/features/logs/components/panel/LogLineContext';
|
||||
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';
|
||||
@@ -531,7 +532,7 @@ export const LogsPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextRow && (
|
||||
{(!config.featureToggles.newLogsPanel || !config.featureToggles.newLogContext) && contextRow && (
|
||||
<LogRowContextModal
|
||||
open={contextRow !== null}
|
||||
row={contextRow}
|
||||
@@ -542,6 +543,20 @@ export const LogsPanel = ({
|
||||
getLogRowContextUi={getLogRowContextUi}
|
||||
/>
|
||||
)}
|
||||
{config.featureToggles.newLogsPanel && config.featureToggles.newLogContext && getLogRowContext && contextRow && (
|
||||
<LogLineContext
|
||||
open={contextRow !== null}
|
||||
log={contextRow}
|
||||
onClose={onCloseContext}
|
||||
getRowContext={(row, options) => getLogRowContext(row, contextRow, options)}
|
||||
getLogRowContextUi={getLogRowContextUi}
|
||||
logOptionsStorageKey={controlsStorageKey}
|
||||
timeZone={timeZone}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
/>
|
||||
)}
|
||||
{config.featureToggles.newLogsPanel && (
|
||||
<div
|
||||
onMouseLeave={onLogContainerMouseLeave}
|
||||
@@ -721,6 +736,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
controlledLogsContainer: css({
|
||||
height: '100%',
|
||||
|
||||
@@ -9362,6 +9362,15 @@
|
||||
"show-more": "show more",
|
||||
"tooltip-error": "Error: {{errorMessage}}"
|
||||
},
|
||||
"log-line-context": {
|
||||
"center-matched-line": "Center matched line",
|
||||
"newer-logs": "newer",
|
||||
"no-more-logs-available": "No more logs available.",
|
||||
"older-logs": "older",
|
||||
"open-in-split-view": "Open in split view",
|
||||
"title-log-context": "Log context",
|
||||
"title-log-line": "Referenced log line"
|
||||
},
|
||||
"log-line-details": {
|
||||
"clear-search": "Clear",
|
||||
"close": "Close log details",
|
||||
|
||||
Reference in New Issue
Block a user