mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Logs Panel: Added support to pass custom options to the log rows menu (#95330)
* LogsPanel: add props to prepend or append icons to the log row menu * LogsPanel: add test and type guard * LogsPanel: add test * Fix addonBefore position * Refactor to be an array of ReactNode * Remove comment * chore: add docs * Linting issues
This commit is contained in:
parent
d68b5d222a
commit
d6efd6d606
@ -17,6 +17,8 @@ export interface Options {
|
|||||||
displayedFields?: Array<string>;
|
displayedFields?: Array<string>;
|
||||||
enableLogDetails: boolean;
|
enableLogDetails: boolean;
|
||||||
isFilterLabelActive?: unknown;
|
isFilterLabelActive?: unknown;
|
||||||
|
logRowMenuIconsAfter?: unknown;
|
||||||
|
logRowMenuIconsBefore?: unknown;
|
||||||
/**
|
/**
|
||||||
* TODO: figure out how to define callbacks
|
* TODO: figure out how to define callbacks
|
||||||
*/
|
*/
|
||||||
|
@ -2,7 +2,7 @@ import { cx } from '@emotion/css';
|
|||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { MouseEvent, PureComponent } from 'react';
|
import { MouseEvent, PureComponent, ReactNode } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CoreApp,
|
CoreApp,
|
||||||
@ -65,6 +65,8 @@ interface Props extends Themeable2 {
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
containerRendered?: boolean;
|
containerRendered?: boolean;
|
||||||
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
|
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
|
||||||
|
logRowMenuIconsBefore?: ReactNode[];
|
||||||
|
logRowMenuIconsAfter?: ReactNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -210,6 +212,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
styles,
|
styles,
|
||||||
getRowContextQuery,
|
getRowContextQuery,
|
||||||
pinned,
|
pinned,
|
||||||
|
logRowMenuIconsBefore,
|
||||||
|
logRowMenuIconsAfter,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const { showDetails, showingContext, permalinked } = this.state;
|
const { showDetails, showingContext, permalinked } = this.state;
|
||||||
@ -314,6 +318,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
mouseIsOver={this.state.mouseIsOver}
|
mouseIsOver={this.state.mouseIsOver}
|
||||||
onBlur={this.onMouseLeave}
|
onBlur={this.onMouseLeave}
|
||||||
expanded={this.state.showDetails}
|
expanded={this.state.showDetails}
|
||||||
|
logRowMenuIconsBefore={logRowMenuIconsBefore}
|
||||||
|
logRowMenuIconsAfter={logRowMenuIconsAfter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -1,4 +1,14 @@
|
|||||||
import { memo, FocusEvent, SyntheticEvent, useCallback } from 'react';
|
import {
|
||||||
|
memo,
|
||||||
|
FocusEvent,
|
||||||
|
SyntheticEvent,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
useMemo,
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
MouseEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data';
|
import { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data';
|
||||||
import { DataQuery } from '@grafana/schema';
|
import { DataQuery } from '@grafana/schema';
|
||||||
@ -26,6 +36,8 @@ interface Props {
|
|||||||
mouseIsOver: boolean;
|
mouseIsOver: boolean;
|
||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void;
|
onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void;
|
||||||
|
addonBefore?: ReactNode[];
|
||||||
|
addonAfter?: ReactNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LogRowMenuCell = memo(
|
export const LogRowMenuCell = memo(
|
||||||
@ -43,13 +55,18 @@ export const LogRowMenuCell = memo(
|
|||||||
mouseIsOver,
|
mouseIsOver,
|
||||||
onBlur,
|
onBlur,
|
||||||
getRowContextQuery,
|
getRowContextQuery,
|
||||||
|
addonBefore,
|
||||||
|
addonAfter,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false;
|
const shouldShowContextToggle = useMemo(
|
||||||
|
() => (showContextToggle ? showContextToggle(row) : false),
|
||||||
|
[row, showContextToggle]
|
||||||
|
);
|
||||||
const onLogRowClick = useCallback((e: SyntheticEvent) => {
|
const onLogRowClick = useCallback((e: SyntheticEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
const onShowContextClick = useCallback(
|
const onShowContextClick = useCallback(
|
||||||
async (event: SyntheticEvent<HTMLButtonElement, MouseEvent>) => {
|
async (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
// if ctrl or meta key is pressed, open query in new Explore tab
|
// if ctrl or meta key is pressed, open query in new Explore tab
|
||||||
if (
|
if (
|
||||||
@ -90,6 +107,21 @@ export const LogRowMenuCell = memo(
|
|||||||
[onBlur]
|
[onBlur]
|
||||||
);
|
);
|
||||||
const getLogText = useCallback(() => logText, [logText]);
|
const getLogText = useCallback(() => logText, [logText]);
|
||||||
|
|
||||||
|
const beforeContent = useMemo(() => {
|
||||||
|
if (!addonBefore) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return addClickListenersToNode(addonBefore, row);
|
||||||
|
}, [addonBefore, row]);
|
||||||
|
|
||||||
|
const afterContent = useMemo(() => {
|
||||||
|
if (!addonAfter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return addClickListenersToNode(addonAfter, row);
|
||||||
|
}, [addonAfter, row]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We keep this click listener here to prevent the row from being selected when clicking on the menu.
|
// We keep this click listener here to prevent the row from being selected when clicking on the menu.
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||||
@ -108,6 +140,7 @@ export const LogRowMenuCell = memo(
|
|||||||
)}
|
)}
|
||||||
{mouseIsOver && (
|
{mouseIsOver && (
|
||||||
<>
|
<>
|
||||||
|
{beforeContent}
|
||||||
{shouldShowContextToggle && (
|
{shouldShowContextToggle && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="md"
|
size="md"
|
||||||
@ -165,6 +198,7 @@ export const LogRowMenuCell = memo(
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{afterContent}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@ -172,4 +206,24 @@ export const LogRowMenuCell = memo(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type AddonOnClickListener = (event: MouseEvent, row: LogRowModel) => void | undefined;
|
||||||
|
function addClickListenersToNode(nodes: ReactNode[], row: LogRowModel) {
|
||||||
|
return nodes.map((node, index) => {
|
||||||
|
if (isValidElement(node)) {
|
||||||
|
const onClick: AddonOnClickListener = node.props.onClick;
|
||||||
|
if (!onClick) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return cloneElement(node, {
|
||||||
|
// @ts-expect-error
|
||||||
|
onClick: (event: MouseEvent<HTMLElement>) => {
|
||||||
|
onClick(event, row);
|
||||||
|
},
|
||||||
|
key: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
LogRowMenuCell.displayName = 'LogRowMenuCell';
|
LogRowMenuCell.displayName = 'LogRowMenuCell';
|
||||||
|
@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data';
|
import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data';
|
||||||
|
import { IconButton } from '@grafana/ui';
|
||||||
|
|
||||||
import { LogRowMessage } from './LogRowMessage';
|
import { LogRowMessage } from './LogRowMessage';
|
||||||
import { createLogRow } from './__mocks__/logRow';
|
import { createLogRow } from './__mocks__/logRow';
|
||||||
@ -197,4 +198,26 @@ line3`;
|
|||||||
expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument();
|
expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('With custom buttons', () => {
|
||||||
|
it('supports custom buttons before and after the default options', async () => {
|
||||||
|
const onBefore = jest.fn();
|
||||||
|
const logRowMenuIconsBefore = [
|
||||||
|
<IconButton name="eye-slash" onClick={onBefore} tooltip="Addon before" aria-label="Addon before" key={1} />,
|
||||||
|
];
|
||||||
|
const onAfter = jest.fn();
|
||||||
|
const logRowMenuIconsAfter = [
|
||||||
|
<IconButton name="rss" onClick={onAfter} tooltip="Addon after" aria-label="Addon after" key={1} />,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { row } = setup({ logRowMenuIconsBefore, logRowMenuIconsAfter });
|
||||||
|
|
||||||
|
await userEvent.hover(screen.getByText('test123'));
|
||||||
|
await userEvent.click(screen.getByLabelText('Addon before'));
|
||||||
|
await userEvent.click(screen.getByLabelText('Addon after'));
|
||||||
|
|
||||||
|
expect(onBefore).toHaveBeenCalledWith(expect.anything(), row);
|
||||||
|
expect(onAfter).toHaveBeenCalledWith(expect.anything(), row);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, ReactNode, useMemo } from 'react';
|
||||||
import Highlighter from 'react-highlight-words';
|
import Highlighter from 'react-highlight-words';
|
||||||
|
|
||||||
import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data';
|
import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data';
|
||||||
@ -32,6 +32,8 @@ interface Props {
|
|||||||
mouseIsOver: boolean;
|
mouseIsOver: boolean;
|
||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
logRowMenuIconsBefore?: ReactNode[];
|
||||||
|
logRowMenuIconsAfter?: ReactNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogMessageProps {
|
interface LogMessageProps {
|
||||||
@ -96,6 +98,8 @@ export const LogRowMessage = memo((props: Props) => {
|
|||||||
onBlur,
|
onBlur,
|
||||||
getRowContextQuery,
|
getRowContextQuery,
|
||||||
expanded,
|
expanded,
|
||||||
|
logRowMenuIconsBefore,
|
||||||
|
logRowMenuIconsAfter,
|
||||||
} = props;
|
} = props;
|
||||||
const { hasAnsi, raw } = row;
|
const { hasAnsi, raw } = row;
|
||||||
const restructuredEntry = useMemo(
|
const restructuredEntry = useMemo(
|
||||||
@ -132,6 +136,8 @@ export const LogRowMessage = memo((props: Props) => {
|
|||||||
styles={styles}
|
styles={styles}
|
||||||
mouseIsOver={mouseIsOver}
|
mouseIsOver={mouseIsOver}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
|
addonBefore={logRowMenuIconsBefore}
|
||||||
|
addonAfter={logRowMenuIconsAfter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { cx } from '@emotion/css';
|
import { cx } from '@emotion/css';
|
||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
import { PureComponent, MouseEvent, createRef } from 'react';
|
import { PureComponent, MouseEvent, createRef, ReactNode } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TimeZone,
|
TimeZone,
|
||||||
@ -72,6 +72,8 @@ export interface Props extends Themeable2 {
|
|||||||
overflowingContent?: boolean;
|
overflowingContent?: boolean;
|
||||||
onClickFilterString?: (value: string, refId?: string) => void;
|
onClickFilterString?: (value: string, refId?: string) => void;
|
||||||
onClickFilterOutString?: (value: string, refId?: string) => void;
|
onClickFilterOutString?: (value: string, refId?: string) => void;
|
||||||
|
logRowMenuIconsBefore?: ReactNode[];
|
||||||
|
logRowMenuIconsAfter?: ReactNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
|
@ -262,6 +262,33 @@ describe('LogsPanel', () => {
|
|||||||
expect(showContextDs.getLogRowContext).toBeCalled();
|
expect(showContextDs.getLogRowContext).toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports adding custom options to the log row menu', async () => {
|
||||||
|
const logRowMenuIconsBefore = [
|
||||||
|
<grafanaUI.IconButton name="eye-slash" tooltip="Addon before" aria-label="Addon before" key={1} />,
|
||||||
|
];
|
||||||
|
const logRowMenuIconsAfter = [
|
||||||
|
<grafanaUI.IconButton name="rss" tooltip="Addon after" aria-label="Addon after" key={1} />,
|
||||||
|
];
|
||||||
|
|
||||||
|
setup(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
series,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logRowMenuIconsBefore,
|
||||||
|
logRowMenuIconsAfter,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
await userEvent.hover(screen.getByText(/logline text/i));
|
||||||
|
expect(screen.getByLabelText('Addon before')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Addon after')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Performance regressions', () => {
|
describe('Performance regressions', () => {
|
||||||
@ -571,7 +598,7 @@ describe('LogsPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const setup = (propsOverrides?: {}) => {
|
const setup = (propsOverrides?: {}, optionOverrides?: {}) => {
|
||||||
const props: LogsPanelProps = {
|
const props: LogsPanelProps = {
|
||||||
data: {
|
data: {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
@ -604,6 +631,7 @@ const setup = (propsOverrides?: {}) => {
|
|||||||
dedupStrategy: LogsDedupStrategy.none,
|
dedupStrategy: LogsDedupStrategy.none,
|
||||||
enableLogDetails: true,
|
enableLogDetails: true,
|
||||||
showLogContextToggle: false,
|
showLogContextToggle: false,
|
||||||
|
...optionOverrides,
|
||||||
},
|
},
|
||||||
title: 'Logs panel',
|
title: 'Logs panel',
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -38,6 +38,7 @@ import {
|
|||||||
isOnClickFilterString,
|
isOnClickFilterString,
|
||||||
isOnClickHideField,
|
isOnClickHideField,
|
||||||
isOnClickShowField,
|
isOnClickShowField,
|
||||||
|
isReactNodeArray,
|
||||||
Options,
|
Options,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
|
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
|
||||||
@ -67,6 +68,12 @@ interface LogsPanelProps extends PanelProps<Options> {
|
|||||||
*
|
*
|
||||||
* Called from the "eye" icon in Log Details to request hiding the displayed field. If ommited, a default implementation is used.
|
* 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;
|
* onClickHideField?: (key: string) => void;
|
||||||
|
*
|
||||||
|
* Passed to the LogRowMenuCell component to be rendered before the default actions in the menu.
|
||||||
|
* logRowMenuIconsBefore?: ReactNode[];
|
||||||
|
*
|
||||||
|
* Passed to the LogRowMenuCell component to be rendered after the default actions in the menu.
|
||||||
|
* logRowMenuIconsAfter?: ReactNode[];
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
interface LogsPermalinkUrlState {
|
interface LogsPermalinkUrlState {
|
||||||
@ -96,6 +103,8 @@ export const LogsPanel = ({
|
|||||||
onClickFilterOutString,
|
onClickFilterOutString,
|
||||||
onClickFilterString,
|
onClickFilterString,
|
||||||
isFilterLabelActive,
|
isFilterLabelActive,
|
||||||
|
logRowMenuIconsBefore,
|
||||||
|
logRowMenuIconsAfter,
|
||||||
...options
|
...options
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
@ -389,6 +398,8 @@ export const LogsPanel = ({
|
|||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
|
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
|
||||||
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
|
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
|
||||||
|
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
|
||||||
|
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
|
||||||
/>
|
/>
|
||||||
{showCommonLabels && isAscending && renderCommonLabels()}
|
{showCommonLabels && isAscending && renderCommonLabels()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,6 +43,8 @@ composableKinds: PanelCfg: {
|
|||||||
onClickFilterOutString?: _
|
onClickFilterOutString?: _
|
||||||
onClickShowField?: _
|
onClickShowField?: _
|
||||||
onClickHideField?: _
|
onClickHideField?: _
|
||||||
|
logRowMenuIconsBefore?: _
|
||||||
|
logRowMenuIconsAfter?: _
|
||||||
displayedFields?: [...string]
|
displayedFields?: [...string]
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ export interface Options {
|
|||||||
displayedFields?: Array<string>;
|
displayedFields?: Array<string>;
|
||||||
enableLogDetails: boolean;
|
enableLogDetails: boolean;
|
||||||
isFilterLabelActive?: unknown;
|
isFilterLabelActive?: unknown;
|
||||||
|
logRowMenuIconsAfter?: unknown;
|
||||||
|
logRowMenuIconsBefore?: unknown;
|
||||||
/**
|
/**
|
||||||
* TODO: figure out how to define callbacks
|
* TODO: figure out how to define callbacks
|
||||||
*/
|
*/
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DataFrame } from '@grafana/data';
|
import { DataFrame } from '@grafana/data';
|
||||||
|
|
||||||
export { Options } from './panelcfg.gen';
|
export { Options } from './panelcfg.gen';
|
||||||
@ -37,3 +39,7 @@ export function isOnClickShowField(callback: unknown): callback is isOnClickShow
|
|||||||
export function isOnClickHideField(callback: unknown): callback is isOnClickHideFieldType {
|
export function isOnClickHideField(callback: unknown): callback is isOnClickHideFieldType {
|
||||||
return typeof callback === 'function';
|
return typeof callback === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isReactNodeArray(node: unknown): node is ReactNode[] {
|
||||||
|
return Array.isArray(node) && node.every(React.isValidElement);
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user