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
This commit is contained in:
Matias Chomicki 2024-08-13 15:58:15 +00:00 committed by GitHub
parent 0a2db346ab
commit 699ff406c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 182 additions and 0 deletions

View File

@ -14,6 +14,7 @@ export const pluginVersion = "11.2.0-pre";
export interface Options { export interface Options {
dedupStrategy: common.LogsDedupStrategy; dedupStrategy: common.LogsDedupStrategy;
displayedFields?: Array<string>;
enableLogDetails: boolean; enableLogDetails: boolean;
isFilterLabelActive?: unknown; isFilterLabelActive?: unknown;
/** /**
@ -23,6 +24,8 @@ export interface Options {
onClickFilterOutLabel?: unknown; onClickFilterOutLabel?: unknown;
onClickFilterOutString?: unknown; onClickFilterOutString?: unknown;
onClickFilterString?: unknown; onClickFilterString?: unknown;
onClickHideField?: unknown;
onClickShowField?: unknown;
prettifyLogMessage: boolean; prettifyLogMessage: boolean;
showCommonLabels: boolean; showCommonLabels: boolean;
showLabels: boolean; showLabels: boolean;
@ -31,3 +34,7 @@ export interface Options {
sortOrder: common.LogsSortOrder; sortOrder: common.LogsSortOrder;
wrapLogMessage: boolean; wrapLogMessage: boolean;
} }
export const defaultOptions: Partial<Options> = {
displayedFields: [],
};

View File

@ -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?: {}) => { const setup = (propsOverrides?: {}) => {

View File

@ -36,6 +36,8 @@ import {
isOnClickFilterOutLabel, isOnClickFilterOutLabel,
isOnClickFilterOutString, isOnClickFilterOutString,
isOnClickFilterString, isOnClickFilterString,
isOnClickHideField,
isOnClickShowField,
Options, Options,
} from './types'; } from './types';
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
@ -56,6 +58,15 @@ interface LogsPanelProps extends PanelProps<Options> {
* *
* Determines if a given key => value filter is active in a given query. Used by Log details. * 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<boolean>; * isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
*
* 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 { interface LogsPermalinkUrlState {
@ -85,6 +96,7 @@ export const LogsPanel = ({
onClickFilterOutString, onClickFilterOutString,
onClickFilterString, onClickFilterString,
isFilterLabelActive, isFilterLabelActive,
...options
}, },
id, id,
}: LogsPanelProps) => { }: LogsPanelProps) => {
@ -96,6 +108,7 @@ export const LogsPanel = ({
const timeRange = data.timeRange; const timeRange = data.timeRange;
const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets); const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets);
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null); const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
const [displayedFields, setDisplayedFields] = useState<string[]>(options.displayedFields ?? []);
let closeCallback = useRef<() => void>(); let closeCallback = useRef<() => void>();
const { eventBus, onAddAdHocFilter } = usePanelContext(); const { eventBus, onAddAdHocFilter } = usePanelContext();
@ -272,6 +285,26 @@ export const LogsPanel = ({
[onAddAdHocFilter] [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) { if (!data || logRows.length === 0) {
return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />; return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
} }
@ -290,6 +323,9 @@ export const LogsPanel = ({
const defaultOnClickFilterLabel = onAddAdHocFilter ? handleOnClickFilterLabel : undefined; const defaultOnClickFilterLabel = onAddAdHocFilter ? handleOnClickFilterLabel : undefined;
const defaultOnClickFilterOutLabel = onAddAdHocFilter ? handleOnClickFilterOutLabel : undefined; const defaultOnClickFilterOutLabel = onAddAdHocFilter ? handleOnClickFilterOutLabel : undefined;
const onClickShowField = isOnClickShowField(options.onClickShowField) ? options.onClickShowField : showField;
const onClickHideField = isOnClickHideField(options.onClickHideField) ? options.onClickHideField : hideField;
return ( return (
<> <>
{contextRow && ( {contextRow && (
@ -342,6 +378,9 @@ export const LogsPanel = ({
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
} }
isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
displayedFields={displayedFields}
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
/> />
{showCommonLabels && isAscending && renderCommonLabels()} {showCommonLabels && isAscending && renderCommonLabels()}
</div> </div>

View File

@ -41,6 +41,9 @@ composableKinds: PanelCfg: {
isFilterLabelActive?: _ isFilterLabelActive?: _
onClickFilterString?: _ onClickFilterString?: _
onClickFilterOutString?: _ onClickFilterOutString?: _
onClickShowField?: _
onClickHideField?: _
displayedFields?: [...string]
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
} }
}] }]

View File

@ -12,6 +12,7 @@ import * as common from '@grafana/schema';
export interface Options { export interface Options {
dedupStrategy: common.LogsDedupStrategy; dedupStrategy: common.LogsDedupStrategy;
displayedFields?: Array<string>;
enableLogDetails: boolean; enableLogDetails: boolean;
isFilterLabelActive?: unknown; isFilterLabelActive?: unknown;
/** /**
@ -21,6 +22,8 @@ export interface Options {
onClickFilterOutLabel?: unknown; onClickFilterOutLabel?: unknown;
onClickFilterOutString?: unknown; onClickFilterOutString?: unknown;
onClickFilterString?: unknown; onClickFilterString?: unknown;
onClickHideField?: unknown;
onClickShowField?: unknown;
prettifyLogMessage: boolean; prettifyLogMessage: boolean;
showCommonLabels: boolean; showCommonLabels: boolean;
showLabels: boolean; showLabels: boolean;
@ -29,3 +32,7 @@ export interface Options {
sortOrder: common.LogsSortOrder; sortOrder: common.LogsSortOrder;
wrapLogMessage: boolean; wrapLogMessage: boolean;
} }
export const defaultOptions: Partial<Options> = {
displayedFields: [],
};

View File

@ -7,6 +7,8 @@ type onClickFilterOutLabelType = (key: string, value: string, frame?: DataFrame)
type onClickFilterValueType = (value: string, refId?: string) => void; type onClickFilterValueType = (value: string, refId?: string) => void;
type onClickFilterOutStringType = (value: string, refId?: string) => void; type onClickFilterOutStringType = (value: string, refId?: string) => void;
type isFilterLabelActiveType = (key: string, value: string, refId?: string) => Promise<boolean>; type isFilterLabelActiveType = (key: string, value: string, refId?: string) => Promise<boolean>;
type isOnClickShowFieldType = (value: string) => void;
type isOnClickHideFieldType = (value: string) => void;
export function isOnClickFilterLabel(callback: unknown): callback is onClickFilterLabelType { export function isOnClickFilterLabel(callback: unknown): callback is onClickFilterLabelType {
return typeof callback === 'function'; return typeof callback === 'function';
@ -27,3 +29,11 @@ export function isOnClickFilterOutString(callback: unknown): callback is onClick
export function isIsFilterLabelActive(callback: unknown): callback is isFilterLabelActiveType { export function isIsFilterLabelActive(callback: unknown): callback is isFilterLabelActiveType {
return typeof callback === 'function'; 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';
}