From 699ff406c33f6542bfbca82fa297fd9ebdfb8b20 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 13 Aug 2024 15:58:15 +0000 Subject: [PATCH] Logs panel: Enable displayedFields in dashboards and apps (#91810) * LogsPanelCfg: add displayedFields * LogsPanel: expose displayedFields * Chore: add docs * LogsPanel: add callbacks to external API * LogsPanel: expose field callbacks and add default implementation * chore: add unit test * chore: unfocus test * LogsPanel: add docs for new props * Enable by default --- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 7 ++ .../app/plugins/panel/logs/LogsPanel.test.tsx | 116 ++++++++++++++++++ public/app/plugins/panel/logs/LogsPanel.tsx | 39 ++++++ public/app/plugins/panel/logs/panelcfg.cue | 3 + public/app/plugins/panel/logs/panelcfg.gen.ts | 7 ++ public/app/plugins/panel/logs/types.ts | 10 ++ 6 files changed, 182 insertions(+) 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 92e3cb32e29..822648efd2d 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 @@ -14,6 +14,7 @@ export const pluginVersion = "11.2.0-pre"; export interface Options { dedupStrategy: common.LogsDedupStrategy; + displayedFields?: Array; enableLogDetails: boolean; isFilterLabelActive?: unknown; /** @@ -23,6 +24,8 @@ export interface Options { onClickFilterOutLabel?: unknown; onClickFilterOutString?: unknown; onClickFilterString?: unknown; + onClickHideField?: unknown; + onClickShowField?: unknown; prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; @@ -31,3 +34,7 @@ export interface Options { sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; } + +export const defaultOptions: Partial = { + displayedFields: [], +}; diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 88e875ae0ed..7b7a034f35e 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -426,6 +426,122 @@ describe('LogsPanel', () => { }); }); }); + + describe('Show/hide fields', () => { + const series = [ + createDataFrame({ + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['logline text'], + labels: { + app: 'common_app', + }, + }, + ], + }), + ]; + + it('displays the provided fields instead of the log line', async () => { + setup({ + data: { + series, + }, + options: { + showLabels: false, + showTime: false, + wrapLogMessage: false, + showCommonLabels: false, + prettifyLogMessage: false, + sortOrder: LogsSortOrder.Descending, + dedupStrategy: LogsDedupStrategy.none, + enableLogDetails: true, + displayedFields: ['app'], + onClickHideField: undefined, + onClickShowField: undefined, + }, + }); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + expect(screen.queryByText('logline text')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('app=common_app')); + + expect(screen.getByLabelText('Hide this field')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Hide this field')); + + expect(screen.getByText('logline text')).toBeInTheDocument(); + }); + + it('enables the behavior with a default implementation', async () => { + setup({ + data: { + series, + }, + options: { + showLabels: false, + showTime: false, + wrapLogMessage: false, + showCommonLabels: false, + prettifyLogMessage: false, + sortOrder: LogsSortOrder.Descending, + dedupStrategy: LogsDedupStrategy.none, + enableLogDetails: true, + displayedFields: [], + onClickHideField: undefined, + onClickShowField: undefined, + }, + }); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('logline text')); + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + + expect(screen.getByText('app=common_app')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Hide this field')); + + expect(screen.getByText('logline text')).toBeInTheDocument(); + }); + + it('overrides the default implementation when the callbacks are provided', async () => { + const onClickShowFieldMock = jest.fn(); + + setup({ + data: { + series, + }, + options: { + showLabels: false, + showTime: false, + wrapLogMessage: false, + showCommonLabels: false, + prettifyLogMessage: false, + sortOrder: LogsSortOrder.Descending, + dedupStrategy: LogsDedupStrategy.none, + enableLogDetails: true, + onClickHideField: jest.fn(), + onClickShowField: onClickShowFieldMock, + }, + }); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('logline text')); + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + + expect(onClickShowFieldMock).toHaveBeenCalledTimes(1); + }); + }); }); const setup = (propsOverrides?: {}) => { diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index a03c2719210..f9a78596387 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -36,6 +36,8 @@ import { isOnClickFilterOutLabel, isOnClickFilterOutString, isOnClickFilterString, + isOnClickHideField, + isOnClickShowField, Options, } from './types'; import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; @@ -56,6 +58,15 @@ interface LogsPanelProps extends PanelProps { * * Determines if a given key => value filter is active in a given query. Used by Log details. * isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; + * + * Array of field names to display instead of the log line. Pass a list of fields or an empty array to enable hide/show fields in Log Details. + * displayedFields?: string[] + * + * Called from the "eye" icon in Log Details to request showing the displayed field. If ommited, a default implementation is used. + * onClickShowField?: (key: string) => void; + * + * Called from the "eye" icon in Log Details to request hiding the displayed field. If ommited, a default implementation is used. + * onClickHideField?: (key: string) => void; */ } interface LogsPermalinkUrlState { @@ -85,6 +96,7 @@ export const LogsPanel = ({ onClickFilterOutString, onClickFilterString, isFilterLabelActive, + ...options }, id, }: LogsPanelProps) => { @@ -96,6 +108,7 @@ export const LogsPanel = ({ const timeRange = data.timeRange; const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets); const [scrollElement, setScrollElement] = useState(null); + const [displayedFields, setDisplayedFields] = useState(options.displayedFields ?? []); let closeCallback = useRef<() => void>(); const { eventBus, onAddAdHocFilter } = usePanelContext(); @@ -272,6 +285,26 @@ export const LogsPanel = ({ [onAddAdHocFilter] ); + const showField = useCallback( + (key: string) => { + const index = displayedFields?.indexOf(key); + if (index === -1) { + setDisplayedFields(displayedFields?.concat(key)); + } + }, + [displayedFields] + ); + + const hideField = useCallback( + (key: string) => { + const index = displayedFields?.indexOf(key); + if (index !== undefined && index > -1) { + setDisplayedFields(displayedFields?.filter((k) => key !== k)); + } + }, + [displayedFields] + ); + if (!data || logRows.length === 0) { return ; } @@ -290,6 +323,9 @@ export const LogsPanel = ({ const defaultOnClickFilterLabel = onAddAdHocFilter ? handleOnClickFilterLabel : undefined; const defaultOnClickFilterOutLabel = onAddAdHocFilter ? handleOnClickFilterOutLabel : undefined; + const onClickShowField = isOnClickShowField(options.onClickShowField) ? options.onClickShowField : showField; + const onClickHideField = isOnClickHideField(options.onClickHideField) ? options.onClickHideField : hideField; + return ( <> {contextRow && ( @@ -342,6 +378,9 @@ export const LogsPanel = ({ isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined } isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} + displayedFields={displayedFields} + onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} + onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} /> {showCommonLabels && isAscending && renderCommonLabels()} diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 239de796ceb..d66b2d7c07b 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -41,6 +41,9 @@ composableKinds: PanelCfg: { isFilterLabelActive?: _ onClickFilterString?: _ onClickFilterOutString?: _ + onClickShowField?: _ + onClickHideField?: _ + displayedFields?: [...string] } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index c41589a2ce0..b8139c88826 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -12,6 +12,7 @@ import * as common from '@grafana/schema'; export interface Options { dedupStrategy: common.LogsDedupStrategy; + displayedFields?: Array; enableLogDetails: boolean; isFilterLabelActive?: unknown; /** @@ -21,6 +22,8 @@ export interface Options { onClickFilterOutLabel?: unknown; onClickFilterOutString?: unknown; onClickFilterString?: unknown; + onClickHideField?: unknown; + onClickShowField?: unknown; prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; @@ -29,3 +32,7 @@ export interface Options { sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; } + +export const defaultOptions: Partial = { + displayedFields: [], +}; diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 711bd17c54f..6fc10e9970e 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -7,6 +7,8 @@ type onClickFilterOutLabelType = (key: string, value: string, frame?: DataFrame) type onClickFilterValueType = (value: string, refId?: string) => void; type onClickFilterOutStringType = (value: string, refId?: string) => void; type isFilterLabelActiveType = (key: string, value: string, refId?: string) => Promise; +type isOnClickShowFieldType = (value: string) => void; +type isOnClickHideFieldType = (value: string) => void; export function isOnClickFilterLabel(callback: unknown): callback is onClickFilterLabelType { return typeof callback === 'function'; @@ -27,3 +29,11 @@ export function isOnClickFilterOutString(callback: unknown): callback is onClick export function isIsFilterLabelActive(callback: unknown): callback is isFilterLabelActiveType { return typeof callback === 'function'; } + +export function isOnClickShowField(callback: unknown): callback is isOnClickShowFieldType { + return typeof callback === 'function'; +} + +export function isOnClickHideField(callback: unknown): callback is isOnClickHideFieldType { + return typeof callback === 'function'; +}