From 4e474161a1c3da57e06d38e243eefbfe2ef84f55 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Fri, 12 Jan 2024 18:19:00 +0100 Subject: [PATCH] Logs: Add show context to dashboard panel (#80403) * Logs: Add show context to dashboard panel * add prop to enable show context toggle * update test * adjust tests * add query targets as a dependency * extract `useDatasourcesFromTargets` hook * add tests * remove comment --- .../logs/panelcfg/schema-reference.md | 21 +- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 1 + .../app/plugins/panel/logs/LogsPanel.test.tsx | 197 ++++++++++++++++-- public/app/plugins/panel/logs/LogsPanel.tsx | 117 +++++++++-- public/app/plugins/panel/logs/panelcfg.cue | 17 +- public/app/plugins/panel/logs/panelcfg.gen.ts | 1 + .../logs/useDatasourcesFromTargets.test.ts | 44 ++++ .../panel/logs/useDatasourcesFromTargets.ts | 31 +++ 8 files changed, 370 insertions(+), 59 deletions(-) create mode 100644 public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts create mode 100644 public/app/plugins/panel/logs/useDatasourcesFromTargets.ts diff --git a/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md index 3a883dcad8e..6a41deb49d3 100644 --- a/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md +++ b/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md @@ -24,15 +24,16 @@ title: LogsPanelCfg kind ### Options -| Property | Type | Required | Default | Description | -|----------------------|---------|----------|---------|---------------------------------------------------------------| -| `dedupStrategy` | string | **Yes** | | Possible values are: `none`, `exact`, `numbers`, `signature`. | -| `enableLogDetails` | boolean | **Yes** | | | -| `prettifyLogMessage` | boolean | **Yes** | | | -| `showCommonLabels` | boolean | **Yes** | | | -| `showLabels` | boolean | **Yes** | | | -| `showTime` | boolean | **Yes** | | | -| `sortOrder` | string | **Yes** | | Possible values are: `Descending`, `Ascending`. | -| `wrapLogMessage` | boolean | **Yes** | | | +| Property | Type | Required | Default | Description | +|------------------------|---------|----------|---------|---------------------------------------------------------------| +| `dedupStrategy` | string | **Yes** | | Possible values are: `none`, `exact`, `numbers`, `signature`. | +| `enableLogDetails` | boolean | **Yes** | | | +| `prettifyLogMessage` | boolean | **Yes** | | | +| `showCommonLabels` | boolean | **Yes** | | | +| `showLabels` | boolean | **Yes** | | | +| `showLogContextToggle` | boolean | **Yes** | | | +| `showTime` | boolean | **Yes** | | | +| `sortOrder` | string | **Yes** | | Possible values are: `Descending`, `Ascending`. | +| `wrapLogMessage` | boolean | **Yes** | | | diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 31ec9e4a86d..78e86d19bb4 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -19,6 +19,7 @@ export interface Options { prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; + showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 0581c91629e..5e6130ac227 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -1,11 +1,42 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; +import { DatasourceSrvMock, MockDataSourceApi } from 'test/mocks/datasource_srv'; -import { LoadingState, createDataFrame, FieldType, LogsSortOrder } from '@grafana/data'; +import { LoadingState, createDataFrame, FieldType, LogsSortOrder, CoreApp } from '@grafana/data'; +import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogsPanel } from './LogsPanel'; type LogsPanelProps = ComponentProps; +type LogRowContextModalProps = ComponentProps; + +const logRowContextModalMock = jest.fn().mockReturnValue(
LogRowContextModal
); +jest.mock('app/features/logs/components/log-context/LogRowContextModal', () => ({ + LogRowContextModal: (props: LogRowContextModalProps) => logRowContextModalMock(props), +})); + +const defaultDs = new MockDataSourceApi('default datasource', { data: ['default data'] }); +const noShowContextDs = new MockDataSourceApi('no-show-context'); +const showContextDs = new MockDataSourceApi('show-context') as MockDataSourceApi & { getLogRowContext: jest.Mock }; + +const datasourceSrv = new DatasourceSrvMock(defaultDs, { + 'no-show-context': noShowContextDs, + 'show-context': showContextDs, +}); +const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => getDataSourceSrvMock(), +})); + +const hasLogsContextSupport = jest.fn().mockImplementation((ds) => { + return ds.name === 'show-context'; +}); +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + hasLogsContextSupport: (ds: MockDataSourceApi) => hasLogsContextSupport(ds), +})); describe('LogsPanel', () => { describe('when returned series include common labels', () => { @@ -33,35 +64,37 @@ describe('LogsPanel', () => { }), ]; - it('shows common labels when showCommonLabels is set to true', () => { + it('shows common labels when showCommonLabels is set to true', async () => { setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true } }); - expect(screen.getByText(/common labels:/i)).toBeInTheDocument(); - expect(screen.getByText(/common_app/i)).toBeInTheDocument(); - expect(screen.getByText(/common_job/i)).toBeInTheDocument(); + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); + expect(await screen.findByText(/common_app/i)).toBeInTheDocument(); + expect(await screen.findByText(/common_job/i)).toBeInTheDocument(); }); - it('shows common labels on top when descending sort order', () => { + it('shows common labels on top when descending sort order', async () => { const { container } = setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true, sortOrder: LogsSortOrder.Descending }, }); - + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); expect(container.firstChild?.childNodes[0].textContent).toMatch(/^Common labels:common_appcommon_job/); }); - it('shows common labels on bottom when ascending sort order', () => { + it('shows common labels on bottom when ascending sort order', async () => { const { container } = setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true, sortOrder: LogsSortOrder.Ascending }, }); - + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); expect(container.firstChild?.childNodes[0].textContent).toMatch(/Common labels:common_appcommon_job$/); }); - it('does not show common labels when showCommonLabels is set to false', () => { + it('does not show common labels when showCommonLabels is set to false', async () => { setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: false } }); - expect(screen.queryByText(/common labels:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/common_app/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/common_job/i)).not.toBeInTheDocument(); + await waitFor(async () => { + expect(screen.queryByText(/common labels:/i)).toBeNull(); + expect(screen.queryByText(/common_app/i)).toBeNull(); + expect(screen.queryByText(/common_job/i)).toBeNull(); + }); }); }); describe('when returned series does not include common labels', () => { @@ -84,15 +117,139 @@ describe('LogsPanel', () => { ], }), ]; - it('shows (no common labels) when showCommonLabels is set to true', () => { + it('shows (no common labels) when showCommonLabels is set to true', async () => { setup({ data: { series: seriesWithoutCommonLabels }, options: { showCommonLabels: true } }); - expect(screen.getByText(/common labels:/i)).toBeInTheDocument(); - expect(screen.getByText(/(no common labels)/i)).toBeInTheDocument(); + + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); + expect(await screen.findByText(/(no common labels)/i)).toBeInTheDocument(); }); - it('does not show common labels when showCommonLabels is set to false', () => { + it('does not show common labels when showCommonLabels is set to false', async () => { setup({ data: { series: seriesWithoutCommonLabels }, options: { showCommonLabels: false } }); - expect(screen.queryByText(/common labels:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/(no common labels)/i)).not.toBeInTheDocument(); + await waitFor(async () => { + expect(screen.queryByText(/common labels:/i)).toBeNull(); + expect(screen.queryByText(/(no common labels)/i)).toBeNull(); + }); + }); + }); + + describe('log context', () => { + const series = [ + createDataFrame({ + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['logline text'], + labels: { + app: 'common_app', + job: 'common_job', + }, + }, + ], + }), + ]; + + beforeEach(() => { + showContextDs.getLogRowContext = jest.fn().mockImplementation(() => {}); + }); + + it('should not show the toggle if the datasource does not support show context', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'no-show-context' } }], + }, + }, + }); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.queryByLabelText(/show context/i)).toBeNull(); + }); + }); + + it('should show the toggle if the datasource does support show context', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.getByLabelText(/show context/i)).toBeInTheDocument(); + }); + }); + + it('should not show the toggle if the datasource does support show context but the app is not Dashboard', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.CloudAlerting, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.queryByLabelText(/show context/i)).toBeNull(); + }); + }); + + it('should render the mocked `LogRowContextModal` after click', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + await userEvent.click(screen.getByLabelText(/show context/i)); + expect(screen.getByText(/LogRowContextModal/i)).toBeInTheDocument(); + }); + }); + + it('should call `getLogRowContext` if the user clicks the show context toggle', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + await userEvent.click(screen.getByLabelText(/show context/i)); + + const getRowContextCb = logRowContextModalMock.mock.calls[0][0].getRowContext; + getRowContextCb(); + expect(showContextDs.getLogRowContext).toBeCalled(); + }); }); }); }); diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 31b22f07680..ab859637f1d 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -11,9 +11,13 @@ import { DataHoverClearEvent, DataHoverEvent, CoreApp, + DataQueryResponse, + LogRowContextOptions, + hasLogsContextSupport, } from '@grafana/data'; import { CustomScrollbar, useStyles2, usePanelContext } from '@grafana/ui'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView'; import { LogLabels } from '../../../features/logs/components/LogLabels'; @@ -21,6 +25,7 @@ import { LogRows } from '../../../features/logs/components/LogRows'; import { dataFrameToLogsModel, dedupLogRows, COMMON_LABELS } from '../../../features/logs/logsModel'; import { Options } from './types'; +import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; interface LogsPanelProps extends PanelProps {} @@ -37,14 +42,18 @@ export const LogsPanel = ({ sortOrder, dedupStrategy, enableLogDetails, + showLogContextToggle, }, - title, id, }: LogsPanelProps) => { const isAscending = sortOrder === LogsSortOrder.Ascending; const style = useStyles2(getStyles); const [scrollTop, setScrollTop] = useState(0); const logsContainerRef = useRef(null); + const [contextRow, setContextRow] = useState(null); + const [closeCallback, setCloseCallback] = useState<(() => void) | null>(null); + + const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets); const { eventBus } = usePanelContext(); const onLogRowHover = useCallback( @@ -64,6 +73,58 @@ export const LogsPanel = ({ [eventBus] ); + const onCloseContext = useCallback(() => { + setContextRow(null); + if (closeCallback) { + closeCallback(); + } + }, [closeCallback]); + + const onOpenContext = useCallback((row: LogRowModel, onClose: () => void) => { + setContextRow(row); + setCloseCallback(onClose); + }, []); + + const showContextToggle = useCallback( + (row: LogRowModel): boolean => { + if ( + !row.dataFrame.refId || + !dataSourcesMap || + (!showLogContextToggle && + data.request?.app !== CoreApp.Dashboard && + data.request?.app !== CoreApp.PanelEditor && + data.request?.app !== CoreApp.PanelViewer) + ) { + return false; + } + + const dataSource = dataSourcesMap.get(row.dataFrame.refId); + return hasLogsContextSupport(dataSource); + }, + [dataSourcesMap, showLogContextToggle, data.request?.app] + ); + + const getLogRowContext = useCallback( + async (row: LogRowModel, origRow: LogRowModel, options: LogRowContextOptions): Promise => { + if (!origRow.dataFrame.refId || !dataSourcesMap) { + return Promise.resolve({ data: [] }); + } + + const query = data.request?.targets[0]; + if (!query) { + return Promise.resolve({ data: [] }); + } + + const dataSource = dataSourcesMap.get(origRow.dataFrame.refId); + if (!hasLogsContextSupport(dataSource)) { + return Promise.resolve({ data: [] }); + } + + return dataSource.getLogRowContext(row, options, query); + }, + [data.request?.targets, dataSourcesMap] + ); + // Important to memoize stuff here, as panel rerenders a lot for example when resizing. const [logRows, deduplicatedRows, commonLabels] = useMemo(() => { const logs = data @@ -102,28 +163,42 @@ export const LogsPanel = ({ ); return ( - -
- {showCommonLabels && !isAscending && renderCommonLabels()} - + {contextRow && ( + getLogRowContext(row, contextRow, options)} logsSortOrder={sortOrder} - enableLogDetails={enableLogDetails} - previewLimit={isAscending ? logRows.length : undefined} - onLogRowHover={onLogRowHover} - app={CoreApp.Dashboard} + timeZone={timeZone} /> - {showCommonLabels && isAscending && renderCommonLabels()} -
-
+ )} + +
+ {showCommonLabels && !isAscending && renderCommonLabels()} + + {showCommonLabels && isAscending && renderCommonLabels()} +
+
+ ); }; diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 5cac83ca9e6..fd3719ec65c 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -26,14 +26,15 @@ composableKinds: PanelCfg: { version: [0, 0] schema: { Options: { - showLabels: bool - showCommonLabels: bool - showTime: bool - wrapLogMessage: bool - prettifyLogMessage: bool - enableLogDetails: bool - sortOrder: common.LogsSortOrder - dedupStrategy: common.LogsDedupStrategy + showLabels: bool + showCommonLabels: bool + showTime: bool + showLogContextToggle: bool + wrapLogMessage: bool + prettifyLogMessage: bool + enableLogDetails: bool + sortOrder: common.LogsSortOrder + dedupStrategy: common.LogsDedupStrategy } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index decbc6140a0..e08ad83e62c 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -16,6 +16,7 @@ export interface Options { prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; + showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; diff --git a/public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts b/public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts new file mode 100644 index 00000000000..6c8f820fcb2 --- /dev/null +++ b/public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts @@ -0,0 +1,44 @@ +// CustomHook.test.js +import { renderHook } from '@testing-library/react-hooks'; +import { MockDataSourceApi, DatasourceSrvMock } from 'test/mocks/datasource_srv'; + +import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; // Update the path accordingly + +const defaultDs = new MockDataSourceApi('default datasource', { data: ['default data'] }); +const ds1 = new MockDataSourceApi('dataSource1'); +const ds2 = new MockDataSourceApi('dataSource2') as MockDataSourceApi; + +const datasourceSrv = new DatasourceSrvMock(defaultDs, { + dataSource1: ds1, + dataSource2: ds2, +}); +const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => getDataSourceSrvMock(), +})); + +describe('useDatasourcesFromTargets', () => { + it('returns an empty map when targets are not provided', async () => { + const { result, waitForNextUpdate } = renderHook(() => useDatasourcesFromTargets(undefined)); + + await waitForNextUpdate(); + + expect(result.current.size).toBe(0); + }); + + it('fetches and returns the data sources map', async () => { + const mockTargets = [ + { refId: '1', datasource: { uid: 'dataSource1' } }, + { refId: '2', datasource: { uid: 'dataSource2' } }, + ]; + + const { result, waitForNextUpdate } = renderHook(() => useDatasourcesFromTargets(mockTargets)); + + await waitForNextUpdate(); + + expect(result.current.size).toBe(2); + expect(result.current.get('1')).toEqual(ds1); + expect(result.current.get('2')).toEqual(ds2); + }); +}); diff --git a/public/app/plugins/panel/logs/useDatasourcesFromTargets.ts b/public/app/plugins/panel/logs/useDatasourcesFromTargets.ts new file mode 100644 index 00000000000..91d72a4466f --- /dev/null +++ b/public/app/plugins/panel/logs/useDatasourcesFromTargets.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { useAsync } from 'react-use'; + +import { DataSourceApi } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; + +export const useDatasourcesFromTargets = (targets: DataQuery[] | undefined): Map => { + const [dataSourcesMap, setDataSourcesMap] = useState(new Map()); + + useAsync(async () => { + if (!targets) { + setDataSourcesMap(new Map()); + return; + } + + const raw = await Promise.all( + targets + .filter((target) => !!target.datasource?.uid) + .map((target) => + getDataSourceSrv() + .get(target.datasource?.uid) + .then((ds) => ({ key: target.refId, ds })) + ) + ); + + setDataSourcesMap(new Map(raw.map(({ key, ds }) => [key, ds]))); + }, [targets]); + + return dataSourcesMap; +};