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:
Matias Chomicki
2025-08-01 11:30:17 +00:00
committed by GitHub
parent 9ca0750134
commit 3f90c85c4e
17 changed files with 1207 additions and 67 deletions
@@ -1091,4 +1091,8 @@ export interface FeatureToggles {
* Enable adhoc filter buttons in visualization tooltips
*/
adhocFiltersInTooltips?: boolean;
/**
* New Log Context component
*/
newLogContext?: boolean;
}
+7
View File
@@ -1890,6 +1890,13 @@ var (
Owner: grafanaDataProSquad,
FrontendOnly: true,
},
{
Name: "newLogContext",
Description: "New Log Context component",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityLogsSquad,
FrontendOnly: true,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
244 dashboardLevelTimeMacros experimental @grafana/dashboards-squad false false true
245 alertmanagerRemoteSecondaryWithRemoteState experimental @grafana/alerting-squad false false false
246 adhocFiltersInTooltips experimental @grafana/datapro false false true
247 newLogContext experimental @grafana/observability-logs false false true
+4
View File
@@ -986,4 +986,8 @@ const (
// FlagAdhocFiltersInTooltips
// Enable adhoc filter buttons in visualization tooltips
FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips"
// FlagNewLogContext
// New Log Context component
FlagNewLogContext = "newLogContext"
)
+13
View File
@@ -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",
+17 -1
View File
@@ -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> {
+17 -1
View File
@@ -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%',
+9
View File
@@ -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",