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
This commit is contained in:
Sven Grossmann 2024-01-12 18:19:00 +01:00 committed by GitHub
parent e3745b5fb8
commit 4e474161a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 370 additions and 59 deletions

View File

@ -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** | | |

View File

@ -19,6 +19,7 @@ export interface Options {
prettifyLogMessage: boolean;
showCommonLabels: boolean;
showLabels: boolean;
showLogContextToggle: boolean;
showTime: boolean;
sortOrder: common.LogsSortOrder;
wrapLogMessage: boolean;

View File

@ -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<typeof LogsPanel>;
type LogRowContextModalProps = ComponentProps<typeof LogRowContextModal>;
const logRowContextModalMock = jest.fn().mockReturnValue(<div>LogRowContextModal</div>);
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();
});
});
});
});

View File

@ -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<Options> {}
@ -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<HTMLDivElement>(null);
const [contextRow, setContextRow] = useState<LogRowModel | null>(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<DataQueryResponse> => {
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 (
<CustomScrollbar autoHide scrollTop={scrollTop}>
<div className={style.container} ref={logsContainerRef}>
{showCommonLabels && !isAscending && renderCommonLabels()}
<LogRows
logRows={logRows}
deduplicatedRows={deduplicatedRows}
dedupStrategy={dedupStrategy}
showLabels={showLabels}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
<>
{contextRow && (
<LogRowContextModal
open={contextRow !== null}
row={contextRow}
onClose={onCloseContext}
getRowContext={(row, options) => getLogRowContext(row, contextRow, options)}
logsSortOrder={sortOrder}
enableLogDetails={enableLogDetails}
previewLimit={isAscending ? logRows.length : undefined}
onLogRowHover={onLogRowHover}
app={CoreApp.Dashboard}
timeZone={timeZone}
/>
{showCommonLabels && isAscending && renderCommonLabels()}
</div>
</CustomScrollbar>
)}
<CustomScrollbar autoHide scrollTop={scrollTop}>
<div className={style.container} ref={logsContainerRef}>
{showCommonLabels && !isAscending && renderCommonLabels()}
<LogRows
logRows={logRows}
showContextToggle={showContextToggle}
deduplicatedRows={deduplicatedRows}
dedupStrategy={dedupStrategy}
showLabels={showLabels}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={sortOrder}
enableLogDetails={enableLogDetails}
previewLimit={isAscending ? logRows.length : undefined}
onLogRowHover={onLogRowHover}
app={CoreApp.Dashboard}
onOpenContext={onOpenContext}
/>
{showCommonLabels && isAscending && renderCommonLabels()}
</div>
</CustomScrollbar>
</>
);
};

View File

@ -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")
}
}]

View File

@ -16,6 +16,7 @@ export interface Options {
prettifyLogMessage: boolean;
showCommonLabels: boolean;
showLabels: boolean;
showLogContextToggle: boolean;
showTime: boolean;
sortOrder: common.LogsSortOrder;
wrapLogMessage: boolean;

View File

@ -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);
});
});

View File

@ -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<string, DataSourceApi> => {
const [dataSourcesMap, setDataSourcesMap] = useState(new Map<string, DataSourceApi>());
useAsync(async () => {
if (!targets) {
setDataSourcesMap(new Map<string, DataSourceApi>());
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<string, DataSourceApi>(raw.map(({ key, ds }) => [key, ds])));
}, [targets]);
return dataSourcesMap;
};