From 3f90c85c4eea9657bac14b49c925ba32d208c54e Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 1 Aug 2025 13:30:17 +0200 Subject: [PATCH] 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 --- .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 + public/app/features/explore/Logs/Logs.tsx | 18 +- .../logs/components/InfiniteScroll.tsx | 2 +- .../logs/components/panel/InfiniteScroll.tsx | 115 +++- .../logs/components/panel/LogLine.tsx | 8 + .../components/panel/LogLineContext.test.tsx | 545 ++++++++++++++++++ .../logs/components/panel/LogLineContext.tsx | 447 ++++++++++++++ .../panel/LogLineDetailsComponent.tsx | 5 +- .../components/panel/LogLineDetailsLog.tsx | 5 +- .../logs/components/panel/LogList.tsx | 71 ++- .../logs/components/panel/virtualization.ts | 2 +- public/app/plugins/panel/logs/LogsPanel.tsx | 18 +- public/locales/en-US/grafana.json | 9 + 17 files changed, 1207 insertions(+), 67 deletions(-) create mode 100644 public/app/features/logs/components/panel/LogLineContext.test.tsx create mode 100644 public/app/features/logs/components/panel/LogLineContext.tsx diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 87a3dee8d06..68564d1ce0e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1091,4 +1091,8 @@ export interface FeatureToggles { * Enable adhoc filter buttons in visualization tooltips */ adhocFiltersInTooltips?: boolean; + /** + * New Log Context component + */ + newLogContext?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9a0c468f0f1..7b74ad685e8 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1890,6 +1890,13 @@ var ( Owner: grafanaDataProSquad, FrontendOnly: true, }, + { + Name: "newLogContext", + Description: "New Log Context component", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityLogsSquad, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 14f65065ed9..6e5c55cfd35 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c74ce4befc4..0b00cf45ac1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -986,4 +986,8 @@ const ( // FlagAdhocFiltersInTooltips // Enable adhoc filter buttons in visualization tooltips FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" + + // FlagNewLogContext + // New Log Context component + FlagNewLogContext = "newLogContext" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 2324d34c0f8..e47603d79d4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index a6d4040a446..fbac705c277 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -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) => { return ( <> - {getRowContext && contextRow && ( + {(!config.featureToggles.newLogsPanel || !config.featureToggles.newLogContext) && getRowContext && contextRow && ( = (props: Props) => { timeZone={timeZone} /> )} + {config.featureToggles.newLogsPanel && config.featureToggles.newLogContext && getRowContext && contextRow && ( + getRowContext(row, contextRow, options)} + getRowContextQuery={getRowContextQuery} + getLogRowContextUi={getLogRowContextUi} + logOptionsStorageKey={SETTING_KEY_ROOT} + timeZone={timeZone} + displayedFields={displayedFields} + onClickShowField={showField} + onClickHideField={hideField} + /> + )} ReactNode; displayedFields: string[]; handleOverflow: (index: number, id: string, height?: number) => void; - loadMore?: (range: AbsoluteTimeRange) => void; + infiniteScrollMode: InfiniteScrollMode; + loadMore?: LoadMoreLogsType; logs: LogListModel[]; onClick: (e: MouseEvent, 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([]); const styles = useStyles2(getStyles, virtualization); + const resetStateTimeout = useRef | 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 = ({ {getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)} @@ -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' && ( +
+ + {t('logs.infinite-scroll.load-more', 'Scroll to load more')} + +
+ )} + {children({ getItemKey, itemCount, onItemsRendered, Renderer })} + + ); }; function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: LogsSortOrder) { @@ -220,7 +279,7 @@ function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: Lo ); - case 'pre-scroll': + case 'pre-scroll-bottom': return t('logs.infinite-scroll.load-more', 'Scroll to load more'); default: return null; diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 4f099dac59a..f3b994250f5 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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', }), diff --git a/public/app/features/logs/components/panel/LogLineContext.test.tsx b/public/app/features/logs/components/panel/LogLineContext.test.tsx new file mode 100644 index 00000000000..31f77df4f3e --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineContext.test.tsx @@ -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( + {}} + 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( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + sortOrder={LogsSortOrder.Descending} + /> + ); + + await waitFor(() => expect(screen.queryByText('Log context')).toBeInTheDocument()); + }); + + test('Should call not getRowContext when closed', async () => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + sortOrder={LogsSortOrder.Descending} + /> + ); + + await waitFor(() => expect(getRowContext).not.toHaveBeenCalled()); + }); + + test('Should call getRowContext on open', async () => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + sortOrder={LogsSortOrder.Descending} + /> + ); + await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2)); + }); + + test('should render 3 lines containing `foo123`', async () => { + render( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + + ); + + 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( + + ); + + 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( + + ); + + const splitViewButton = await screen.findByRole('button', { + name: /open in split view/i, + }); + + await userEvent.click(splitViewButton); + + await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith(splitOpenSym)); + }); +}); diff --git a/public/app/features/logs/components/panel/LogLineContext.tsx b/public/app/features/logs/components/panel/LogLineContext.tsx new file mode 100644 index 00000000000..db28cdc0fdb --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineContext.tsx @@ -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; + getRowContextQuery?: ( + row: LogRowModel, + options?: LogRowContextOptions, + cacheFilters?: boolean + ) => Promise; + 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(null); + const [contextQuery, setContextQuery] = useState(null); + const [aboveLogs, setAboveLogs] = useState([]); + const [belowLogs, setBelowLogs] = useState([]); + 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 => { + 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 ( + + {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( +
{getLogRowContextUi(log, updateResults)}
+ )} + setShowLog(!showLog)} + className={styles.referenceLogLine} + label={t('logs.log-line-context.title-log-line', 'Referenced log line')} + > + + +
+ {aboveState === LoadingState.Loading && ( + + )} + {aboveState === LoadingState.Done && ( + No more logs available. + )} +
+
+
+ {containerRef.current && ( + + )} +
+
+
+ {belowState === LoadingState.Loading && ( + + )} + {belowState === LoadingState.Done && ( + No more logs available. + )} +
+ + + + {contextQuery?.datasource?.uid && ( + + )} + +
+ ); + } +); +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); +}; diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index 96429ce4327..fd8490f74cb 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -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)} > - + {displayedFields.length > 0 && setDisplayedFields && ( { - const { syntaxHighlighting } = useLogListContext(); +export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: Props) => { const logStyles = useStyles2(getStyles); const log = useMemo(() => { const log = originalLog.clone(); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 6350723ff89..df0c8b58fd5 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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; 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 (
+ {showControls && } + {detailsMode === 'sidebar' && showDetails.length > 0 && ( + + )}
{popoverState.selection && popoverState.selectedRow && (
- {detailsMode === 'sidebar' && showDetails.length > 0 && ( - - )} - {showControls && }
); }; @@ -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'); + } } } diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index bf84f5fe3ff..6207085d1a5 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -370,7 +370,7 @@ export function getScrollbarWidth() { } export interface ScrollToLogsEventPayload { - scrollTo: 'top' | 'bottom'; + scrollTo: 'top' | 'bottom' | string; } export class ScrollToLogsEvent extends BusEventWithPayload { diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index ca44d725502..73ee2c3604d 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -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 && ( )} + {config.featureToggles.newLogsPanel && config.featureToggles.newLogContext && getLogRowContext && contextRow && ( + getLogRowContext(row, contextRow, options)} + getLogRowContextUi={getLogRowContextUi} + logOptionsStorageKey={controlsStorageKey} + timeZone={timeZone} + displayedFields={displayedFields} + onClickShowField={showField} + onClickHideField={hideField} + /> + )} {config.featureToggles.newLogsPanel && (
({ display: 'flex', flex: 1, flexDirection: 'column', + overflow: 'hidden', }), controlledLogsContainer: css({ height: '100%', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c0061d4b664..339cb81d0d1 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",