3
0
mirror of https://github.com/grafana/grafana.git synced 2025-02-25 18:55:37 -06:00

Log Rows: Added popover menu with filter options when a log line is selected ()

* LogRow: detect text selection

* LogRow: refactor menu as component

* LogRow: add actions to menu

* LogRow: hack menu position

* Remove unsused imports

* LogRowMessage: remove popover code

* PopoverMenu: refactor

* LogRows: implement PopoverMenu at log rows level

* PopoverMenu: implement copy

* PopoverMenu: receive row model

* PopoverMenu: fix onClick capture issue

* Explore: add new filter methods and props for line filters

* PopoverMenu: use new filter props

* Explore: separate toggleable and non toggleable filters

* PopoverMenu: improve copy

* ModifyQuery: extend line filter with value argument

* PopoverMenu: close with escape

* Remove unused import

* Prettier

* PopoverMenu: remove label filter options

* LogRow: rename text selection handling prop

* Update test

* Remove unused import

* Popover menu: add unit test

* LogRows: update unit test

* Log row: hide the log row menu if the user is selecting text

* Log row: dont hide row menu if popover is not in scope

* Log rows: rename state variable

* Popover menu: allow menu to scroll

* Log rows: fix classname prop

* Log rows: close popover if mouse event comes from outside the log rows

* Declare new class using object style

* Fix style declaration

* Logs Popover Menu: add string filtering functions ()

* Loki modifyQuery: add line does not contain query modification

* Elastic modifyQuery: implement line filters

* Modify query: change action name to not be loki specific

* Prettier

* Prettier

* Elastic: escape filter values

* Popover menu: create feature flag

* Log Rows: integrate logsRowsPopoverMenu flag

* Rename feature flag

* Popover menu: track interactions

* Prettier

* logRowsPopoverMenu: update stage

* Popover menu: add ds type to tracking data

* Log rows: move feature flag check

* Improve handle deselection
This commit is contained in:
Matias Chomicki 2023-11-16 10:48:10 +01:00 committed by GitHub
parent 05cf8c9253
commit 9cb303c3f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 636 additions and 79 deletions

View File

@ -162,6 +162,7 @@ Experimental features might be changed or removed without prior notice.
| `dashboardScene` | Enables dashboard rendering using scenes for all roles |
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected |
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
## Development feature toggles

View File

@ -162,5 +162,6 @@ export interface FeatureToggles {
alertingDetailsViewV2?: boolean;
datatrails?: boolean;
alertingSimplifiedRouting?: boolean;
logRowsPopoverMenu?: boolean;
pluginsSkipHostEnvVars?: boolean;
}

View File

@ -1063,6 +1063,13 @@ var (
Owner: grafanaAlertingSquad,
HideFromDocs: true,
},
{
Name: "logRowsPopoverMenu",
Description: "Enable filtering menu displayed when text of a log line is selected",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "pluginsSkipHostEnvVars",
Description: "Disables passing host environment variable to plugin processes",

View File

@ -143,4 +143,5 @@ flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profilin
alertingDetailsViewV2,experimental,@grafana/alerting-squad,false,false,false,true
datatrails,experimental,@grafana/dashboards-squad,false,false,false,true
alertingSimplifiedRouting,experimental,@grafana/alerting-squad,false,false,false,false
logRowsPopoverMenu,experimental,@grafana/observability-logs,false,false,false,true
pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
143 alertingDetailsViewV2 experimental @grafana/alerting-squad false false false true
144 datatrails experimental @grafana/dashboards-squad false false false true
145 alertingSimplifiedRouting experimental @grafana/alerting-squad false false false false
146 logRowsPopoverMenu experimental @grafana/observability-logs false false false true
147 pluginsSkipHostEnvVars experimental @grafana/plugins-platform-backend false false false false

View File

@ -583,6 +583,10 @@ const (
// Enables the simplified routing for alerting
FlagAlertingSimplifiedRouting = "alertingSimplifiedRouting"
// FlagLogRowsPopoverMenu
// Enable filtering menu displayed when text of a log line is selected
FlagLogRowsPopoverMenu = "logRowsPopoverMenu"
// FlagPluginsSkipHostEnvVars
// Disables passing host environment variable to plugin processes
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"

View File

@ -230,6 +230,20 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
this.onModifyQueries({ type: 'ADD_FILTER_OUT', options: { key, value } }, refId);
};
/**
* Used by Logs Popover Menu.
*/
onClickFilterValue = (value: string, refId?: string) => {
this.onModifyQueries({ type: 'ADD_STRING_FILTER', options: { value } }, refId);
};
/**
* Used by Logs Popover Menu.
*/
onClickFilterOutValue = (value: string, refId?: string) => {
this.onModifyQueries({ type: 'ADD_STRING_FILTER_OUT', options: { value } }, refId);
};
onClickAddQueryRowButton = () => {
const { exploreId, queryKeys } = this.props;
this.props.addQueryRow(exploreId, queryKeys.length);
@ -250,7 +264,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
return query;
}
const ds = await getDataSourceSrv().get(datasource);
if (hasToggleableQueryFiltersSupport(ds)) {
const toggleableFilters = ['ADD_FILTER', 'ADD_FILTER_OUT'];
if (hasToggleableQueryFiltersSupport(ds) && toggleableFilters.includes(modification.type)) {
return ds.toggleQueryFilter(query, {
type: modification.type === 'ADD_FILTER' ? 'FILTER_FOR' : 'FILTER_OUT',
options: modification.options ?? {},
@ -435,6 +450,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
splitOpenFn={this.onSplitOpen('logs')}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
onClickFilterValue={this.onClickFilterValue}
onClickFilterOutValue={this.onClickFilterOutValue}
/>
</ContentOutlineItem>
);

View File

@ -102,6 +102,8 @@ interface Props extends Themeable2 {
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
logsFrames?: DataFrame[];
range: TimeRange;
onClickFilterValue?: (value: string, refId?: string) => void;
onClickFilterOutValue?: (value: string, refId?: string) => void;
}
export type LogsVisualisationType = 'table' | 'logs';
@ -772,6 +774,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
scrollIntoView={this.scrollIntoView}
isFilterLabelActive={this.props.isFilterLabelActive}
containerRendered={!!this.state.logsContainer}
onClickFilterValue={this.props.onClickFilterValue}
onClickFilterOutValue={this.props.onClickFilterOutValue}
/>
</div>
)}

View File

@ -56,6 +56,8 @@ interface LogsContainerProps extends PropsFromRedux {
splitOpenFn: SplitOpen;
scrollElement?: HTMLDivElement;
isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<boolean>;
onClickFilterValue: (value: string, refId?: string) => void;
onClickFilterOutValue: (value: string, refId?: string) => void;
}
interface LogsContainerState {
@ -313,6 +315,8 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
scrollElement={scrollElement}
isFilterLabelActive={logDetailsFilterAvailable ? this.props.isFilterLabelActive : undefined}
range={range}
onClickFilterValue={this.props.onClickFilterValue}
onClickFilterOutValue={this.props.onClickFilterOutValue}
/>
</LogsCrossFadeTransition>
</>

View File

@ -30,6 +30,7 @@ const setup = (propOverrides?: Partial<ComponentProps<typeof LogRow>>, rowOverri
enableLogDetails: false,
getRows: () => [],
onOpenContext: () => {},
handleTextSelection: jest.fn(),
prettifyLogMessage: false,
app: CoreApp.Explore,
showDuplicates: false,

View File

@ -1,7 +1,7 @@
import { cx } from '@emotion/css';
import { debounce } from 'lodash';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import React, { PureComponent, MouseEvent } from 'react';
import { Field, LinkModel, LogRowModel, LogsSortOrder, dateTimeFormat, CoreApp, DataFrame } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
@ -48,6 +48,7 @@ interface Props extends Themeable2 {
onUnpinLine?: (row: LogRowModel) => void;
pinned?: boolean;
containerRendered?: boolean;
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
}
interface State {
@ -88,7 +89,12 @@ class UnThemedLogRow extends PureComponent<Props, State> {
this.props.onOpenContext(row, this.debouncedContextClose);
};
toggleDetails = () => {
onRowClick = (e: MouseEvent<HTMLTableRowElement>) => {
if (this.props.handleTextSelection?.(e, this.props.row)) {
// Event handled by the parent.
return;
}
if (!this.props.enableLogDetails) {
return;
}
@ -121,6 +127,17 @@ class UnThemedLogRow extends PureComponent<Props, State> {
}
};
onMouseMove = (e: MouseEvent) => {
// No need to worry about text selection.
if (!this.props.handleTextSelection) {
return;
}
// The user is selecting text, so hide the log row menu so it doesn't interfere.
if (document.getSelection()?.toString() && e.buttons > 0) {
this.setState({ mouseIsOver: false });
}
};
onMouseLeave = () => {
this.setState({ mouseIsOver: false });
if (this.props.onLogRowHover) {
@ -205,9 +222,10 @@ class UnThemedLogRow extends PureComponent<Props, State> {
<tr
ref={this.logLineRef}
className={logRowBackground}
onClick={this.toggleDetails}
onClick={this.onRowClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onMouseMove={this.onMouseMove}
/**
* For better accessibility support, we listen to the onFocus event here (to display the LogRowMenuCell), and
* to onBlur event in the LogRowMenuCell (to hide it). This way, the LogRowMenuCell is displayed when the user navigates

View File

@ -1,4 +1,5 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { range } from 'lodash';
import React from 'react';
@ -7,6 +8,15 @@ import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { LogRows, PREVIEW_LIMIT } from './LogRows';
import { createLogRow } from './__mocks__/logRow';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
logRowsPopoverMenu: true,
},
},
}));
describe('LogRows', () => {
it('renders rows', () => {
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
@ -195,3 +205,46 @@ describe('LogRows', () => {
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 1');
});
});
describe('Popover menu', () => {
function setup() {
const rows: LogRowModel[] = [createLogRow({ uid: '1' })];
return render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Descending}
enableLogDetails={true}
displayedFields={[]}
onClickFilterOutValue={() => {}}
onClickFilterValue={() => {}}
/>
);
}
let orgGetSelection: () => Selection | null;
beforeAll(() => {
orgGetSelection = document.getSelection;
jest.spyOn(document, 'getSelection').mockReturnValue({
toString: () => 'selected log line',
} as Selection);
});
afterAll(() => {
document.getSelection = orgGetSelection;
});
it('Does not appear in the document', () => {
setup();
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
});
it('Appears after selecting test', async () => {
setup();
await userEvent.click(screen.getByText('log message 1'));
expect(screen.getByText('Copy selection')).toBeInTheDocument();
expect(screen.getByText('Add as line contains filter')).toBeInTheDocument();
expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument();
});
});

View File

@ -1,6 +1,6 @@
import { cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import React, { PureComponent, MouseEvent, createRef } from 'react';
import {
TimeZone,
@ -12,13 +12,15 @@ import {
CoreApp,
DataFrame,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { withTheme2, Themeable2 } from '@grafana/ui';
import { UniqueKeyMaker } from '../UniqueKeyMaker';
import { sortLogRows } from '../utils';
import { sortLogRows, targetIsElement } from '../utils';
//Components
import { LogRow } from './LogRow';
import { PopoverMenu } from './PopoverMenu';
import { getLogRowStyles } from './getLogRowStyles';
export const PREVIEW_LIMIT = 100;
@ -59,14 +61,20 @@ export interface Props extends Themeable2 {
* Any overflowing content will be clipped at the table boundary.
*/
overflowingContent?: boolean;
onClickFilterValue?: (value: string, refId?: string) => void;
onClickFilterOutValue?: (value: string, refId?: string) => void;
}
interface State {
renderAll: boolean;
selection: string;
selectedRow: LogRowModel | null;
popoverMenuCoordinates: { x: number; y: number };
}
class UnThemedLogRows extends PureComponent<Props, State> {
renderAllTimer: number | null = null;
logRowsRef = createRef<HTMLDivElement>();
static defaultProps = {
previewLimit: PREVIEW_LIMIT,
@ -74,6 +82,9 @@ class UnThemedLogRows extends PureComponent<Props, State> {
state: State = {
renderAll: false,
selection: '',
selectedRow: null,
popoverMenuCoordinates: { x: 0, y: 0 },
};
/**
@ -85,6 +96,60 @@ class UnThemedLogRows extends PureComponent<Props, State> {
}
};
popoverMenuSupported() {
if (!config.featureToggles.logRowsPopoverMenu) {
return false;
}
return Boolean(this.props.onClickFilterOutValue || this.props.onClickFilterValue);
}
handleSelection = (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel): boolean => {
if (this.popoverMenuSupported() === false) {
return false;
}
const selection = document.getSelection()?.toString();
if (!selection) {
return false;
}
if (!this.logRowsRef.current) {
return false;
}
const parentBounds = this.logRowsRef.current?.getBoundingClientRect();
this.setState({
selection,
popoverMenuCoordinates: { x: e.clientX - parentBounds.left, y: e.clientY - parentBounds.top },
selectedRow: row,
});
return true;
};
handleDeselection = (e: Event) => {
if (
targetIsElement(e.target) &&
(e.target?.getAttribute('role') === 'menuitem' || e.target?.parentElement?.getAttribute('role') === 'menuitem')
) {
// Delegate closing the menu to the popover component.
return;
}
if (targetIsElement(e.target) && !this.logRowsRef.current?.contains(e.target)) {
// The mouseup event comes from outside the log rows, close the menu.
this.closePopoverMenu();
return;
}
if (document.getSelection()?.toString()) {
return;
}
this.closePopoverMenu();
};
closePopoverMenu = () => {
this.setState({
selection: '',
popoverMenuCoordinates: { x: 0, y: 0 },
selectedRow: null,
});
};
componentDidMount() {
// Staged rendering
const { logRows, previewLimit } = this.props;
@ -96,9 +161,11 @@ class UnThemedLogRows extends PureComponent<Props, State> {
} else {
this.renderAllTimer = window.setTimeout(() => this.setState({ renderAll: true }), 2000);
}
document.addEventListener('mouseup', this.handleDeselection);
}
componentWillUnmount() {
document.removeEventListener('mouseup', this.handleDeselection);
if (this.renderAllTimer) {
clearTimeout(this.renderAllTimer);
}
@ -134,56 +201,70 @@ class UnThemedLogRows extends PureComponent<Props, State> {
const keyMaker = new UniqueKeyMaker();
return (
<table className={cx(styles.logsRowsTable, this.props.overflowingContent ? '' : styles.logsRowsTableContain)}>
<tbody>
{hasData &&
firstRows.map((row) => (
<LogRow
key={keyMaker.getKey(row.uid)}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
logsSortOrder={logsSortOrder}
onOpenContext={this.openContext}
styles={styles}
onPermalinkClick={this.props.onPermalinkClick}
scrollIntoView={this.props.scrollIntoView}
permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive}
{...rest}
/>
))}
{hasData &&
renderAll &&
lastRows.map((row) => (
<LogRow
key={keyMaker.getKey(row.uid)}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
logsSortOrder={logsSortOrder}
onOpenContext={this.openContext}
styles={styles}
onPermalinkClick={this.props.onPermalinkClick}
scrollIntoView={this.props.scrollIntoView}
permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive}
{...rest}
/>
))}
{hasData && !renderAll && (
<tr>
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
</tr>
)}
</tbody>
</table>
<div className={styles.logRows} ref={this.logRowsRef}>
{this.state.selection && this.state.selectedRow && (
<PopoverMenu
close={this.closePopoverMenu}
row={this.state.selectedRow}
selection={this.state.selection}
{...this.state.popoverMenuCoordinates}
onClickFilterValue={rest.onClickFilterValue}
onClickFilterOutValue={rest.onClickFilterOutValue}
/>
)}
<table className={cx(styles.logsRowsTable, this.props.overflowingContent ? '' : styles.logsRowsTableContain)}>
<tbody>
{hasData &&
firstRows.map((row) => (
<LogRow
key={keyMaker.getKey(row.uid)}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
logsSortOrder={logsSortOrder}
onOpenContext={this.openContext}
styles={styles}
onPermalinkClick={this.props.onPermalinkClick}
scrollIntoView={this.props.scrollIntoView}
permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
{...rest}
/>
))}
{hasData &&
renderAll &&
lastRows.map((row) => (
<LogRow
key={keyMaker.getKey(row.uid)}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
logsSortOrder={logsSortOrder}
onOpenContext={this.openContext}
styles={styles}
onPermalinkClick={this.props.onPermalinkClick}
scrollIntoView={this.props.scrollIntoView}
permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
{...rest}
/>
))}
{hasData && !renderAll && (
<tr>
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
}

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PopoverMenu } from './PopoverMenu';
import { createLogRow } from './__mocks__/logRow';
const row = createLogRow();
test('Does not render if the filter functions are not defined', () => {
render(<PopoverMenu selection="test" x={0} y={0} row={row} close={() => {}} />);
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
});
test('Renders copy and line contains filter', async () => {
const onClickFilterValue = jest.fn();
render(
<PopoverMenu selection="test" x={0} y={0} row={row} close={() => {}} onClickFilterValue={onClickFilterValue} />
);
expect(screen.getByText('Copy selection')).toBeInTheDocument();
expect(screen.getByText('Add as line contains filter')).toBeInTheDocument();
await userEvent.click(screen.getByText('Add as line contains filter'));
expect(onClickFilterValue).toHaveBeenCalledTimes(1);
expect(onClickFilterValue).toHaveBeenCalledWith('test', row.dataFrame.refId);
});
test('Renders copy and line does not contain filter', async () => {
const onClickFilterOutValue = jest.fn();
render(
<PopoverMenu
selection="test"
x={0}
y={0}
row={row}
close={() => {}}
onClickFilterOutValue={onClickFilterOutValue}
/>
);
expect(screen.getByText('Copy selection')).toBeInTheDocument();
expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument();
await userEvent.click(screen.getByText('Add as line does not contain filter'));
expect(onClickFilterOutValue).toHaveBeenCalledTimes(1);
expect(onClickFilterOutValue).toHaveBeenCalledWith('test', row.dataFrame.refId);
});
test('Renders copy, line contains filter, and line does not contain filter', () => {
render(
<PopoverMenu
selection="test"
x={0}
y={0}
row={row}
close={() => {}}
onClickFilterValue={() => {}}
onClickFilterOutValue={() => {}}
/>
);
expect(screen.getByText('Copy selection')).toBeInTheDocument();
expect(screen.getByText('Add as line contains filter')).toBeInTheDocument();
expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument();
});
test('Can be dismissed with escape', async () => {
const close = jest.fn();
render(
<PopoverMenu
selection="test"
x={0}
y={0}
row={row}
close={close}
onClickFilterValue={() => {}}
onClickFilterOutValue={() => {}}
/>
);
expect(close).not.toHaveBeenCalled();
expect(screen.getByText('Copy selection')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(close).toHaveBeenCalledTimes(1);
});

View File

@ -0,0 +1,99 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef } from 'react';
import { GrafanaTheme2, LogRowModel } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Menu, useStyles2 } from '@grafana/ui';
import { copyText } from '../utils';
interface PopoverMenuProps {
selection: string;
x: number;
y: number;
onClickFilterValue?: (value: string, refId?: string) => void;
onClickFilterOutValue?: (value: string, refId?: string) => void;
row: LogRowModel;
close: () => void;
}
export const PopoverMenu = ({
x,
y,
onClickFilterValue,
onClickFilterOutValue,
selection,
row,
close,
}: PopoverMenuProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const styles = useStyles2(getStyles);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
close();
}
}
document.addEventListener('keyup', handleEscape);
return () => {
document.removeEventListener('keyup', handleEscape);
};
}, [close]);
const supported = onClickFilterValue || onClickFilterOutValue;
if (!supported) {
return null;
}
return (
<div className={styles.menu} style={{ top: y, left: x }}>
<Menu ref={containerRef}>
<Menu.Item
label="Copy selection"
onClick={() => {
copyText(selection, containerRef);
close();
track('copy_selection_clicked', selection.length, row.datasourceType);
}}
/>
{onClickFilterValue && (
<Menu.Item
label="Add as line contains filter"
onClick={() => {
onClickFilterValue(selection, row.dataFrame.refId);
close();
track('line_filter_clicked', selection.length, row.datasourceType);
}}
/>
)}
{onClickFilterOutValue && (
<Menu.Item
label="Add as line does not contain filter"
onClick={() => {
onClickFilterOutValue(selection, row.dataFrame.refId);
close();
track('line_filter_out_clicked', selection.length, row.datasourceType);
}}
/>
)}
</Menu>
</div>
);
};
function track(event: string, selectionLength: number, dataSourceType: string | undefined) {
reportInteraction(`grafana_explore_logs_${event}`, {
selection_length: selectionLength,
ds_type: dataSourceType || 'unknown',
});
}
const getStyles = (theme: GrafanaTheme2) => ({
menu: css({
position: 'absolute',
zIndex: theme.zIndex.modal,
}),
});

View File

@ -67,12 +67,16 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
color: ${theme.components.textHighlight.text}
background-color: ${theme.components.textHighlight};
`,
logRows: css({
position: 'relative',
}),
logsRowsTable: css`
label: logs-rows;
font-family: ${theme.typography.fontFamilyMonospace};
font-size: ${theme.typography.bodySmall.fontSize};
width: 100%;
${!scrollableLogsContainer && `margin-bottom: ${theme.spacing(2.25)};`}
position: relative;
`,
logsRowsTableContain: css`
contain: strict;

View File

@ -273,3 +273,27 @@ export const getLogsVolumeDataSourceInfo = (dataFrames: DataFrame[]): { name: st
export const isLogsVolumeLimited = (dataFrames: DataFrame[]) => {
return dataFrames[0]?.meta?.custom?.logsVolumeType === LogsVolumeType.Limited;
};
export const copyText = async (text: string, buttonRef: React.MutableRefObject<Element | null>) => {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
} else {
// Use a fallback method for browsers/contexts that don't support the Clipboard API.
// See https://web.dev/async-clipboard/#feature-detection.
// Use textarea so the user can copy multi-line content.
const textarea = document.createElement('textarea');
// Normally we'd append this to the body. However if we're inside a focus manager
// from react-aria, we can't focus anything outside of the managed area.
// Instead, let's append it to the button. Then we're guaranteed to be able to focus + copy.
buttonRef.current?.appendChild(textarea);
textarea.value = text;
textarea.focus();
textarea.select();
document.execCommand('copy');
textarea.remove();
}
};
export function targetIsElement(target: EventTarget | null): target is Element {
return target instanceof Element;
}

View File

@ -1134,30 +1134,75 @@ describe('enhanceDataFrame', () => {
describe('modifyQuery', () => {
let ds: ElasticDatasource;
let query: ElasticsearchQuery;
beforeEach(() => {
ds = getTestContext().ds;
});
describe('with empty query', () => {
let query: ElasticsearchQuery;
describe('ADD_FILTER and ADD_FITER_OUT', () => {
beforeEach(() => {
query = { query: '', refId: 'A' };
});
it('should add the filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER', options: { key: 'foo', value: 'bar' } }).query).toBe(
'foo:"bar"'
);
});
it('should add the negative filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'-foo:"bar"'
);
});
it('should do nothing on unknown type', () => {
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(
query.query
);
});
});
describe('with non-empty query', () => {
let query: ElasticsearchQuery;
beforeEach(() => {
query = { query: 'test:"value"', refId: 'A' };
});
it('should add the filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER', options: { key: 'foo', value: 'bar' } }).query).toBe(
'test:"value" AND foo:"bar"'
);
});
it('should add the negative filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'test:"value" AND -foo:"bar"'
);
});
it('should do nothing on unknown type', () => {
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(
query.query
);
});
});
});
describe('ADD_STRING_FILTER and ADD_STRING_FILTER_OUT', () => {
beforeEach(() => {
query = { query: '', refId: 'A' };
});
it('should add the filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER', options: { key: 'foo', value: 'bar' } }).query).toBe(
'foo:"bar"'
);
expect(ds.modifyQuery(query, { type: 'ADD_STRING_FILTER', options: { value: 'bar' } }).query).toBe('"bar"');
});
it('should add the negative filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'-foo:"bar"'
expect(ds.modifyQuery(query, { type: 'ADD_STRING_FILTER_OUT', options: { value: 'bar' } }).query).toBe(
'NOT "bar"'
);
});
it('should do nothing on unknown type', () => {
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(query.query);
});
});
describe('with non-empty query', () => {
@ -1167,20 +1212,16 @@ describe('modifyQuery', () => {
});
it('should add the filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER', options: { key: 'foo', value: 'bar' } }).query).toBe(
'test:"value" AND foo:"bar"'
expect(ds.modifyQuery(query, { type: 'ADD_STRING_FILTER', options: { value: 'bar' } }).query).toBe(
'test:"value" AND "bar"'
);
});
it('should add the negative filter', () => {
expect(ds.modifyQuery(query, { type: 'ADD_FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'test:"value" AND -foo:"bar"'
expect(ds.modifyQuery(query, { type: 'ADD_STRING_FILTER_OUT', options: { value: 'bar' } }).query).toBe(
'test:"value" NOT "bar"'
);
});
it('should do nothing on unknown type', () => {
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(query.query);
});
});
});

View File

@ -63,7 +63,13 @@ import {
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils';
import { isMetricAggregationWithMeta } from './guards';
import { addAddHocFilter, addFilterToQuery, queryHasFilter, removeFilterFromQuery } from './modifyQuery';
import {
addAddHocFilter,
addFilterToQuery,
addStringFilterToQuery,
queryHasFilter,
removeFilterFromQuery,
} from './modifyQuery';
import { trackAnnotationQuery, trackQuery } from './tracking';
import {
Logs,
@ -943,6 +949,14 @@ export class ElasticDatasource
expression = addFilterToQuery(expression, action.options.key, action.options.value, '-');
break;
}
case 'ADD_STRING_FILTER': {
expression = addStringFilterToQuery(expression, action.options.value);
break;
}
case 'ADD_STRING_FILTER_OUT': {
expression = addStringFilterToQuery(expression, action.options.value, false);
break;
}
}
return { ...query, query: expression };

View File

@ -1,4 +1,4 @@
import { queryHasFilter, removeFilterFromQuery, addFilterToQuery } from './modifyQuery';
import { queryHasFilter, removeFilterFromQuery, addFilterToQuery, addStringFilterToQuery } from './modifyQuery';
describe('queryHasFilter', () => {
it('should return true if the query contains the positive filter', () => {
@ -108,3 +108,22 @@ describe('removeFilterFromQuery', () => {
expect(removeFilterFromQuery('label\\:name:"the \\"value\\""', 'label:name', 'the "value"')).toBe('');
});
});
describe('addStringFilterToQuery', () => {
it('should add a positive filter to a query', () => {
expect(addStringFilterToQuery('label:"value"', 'filter')).toBe('label:"value" AND "filter"');
expect(addStringFilterToQuery('', 'filter')).toBe('"filter"');
expect(addStringFilterToQuery(' ', 'filter')).toBe('"filter"');
});
it('should add a negative filter to a query', () => {
expect(addStringFilterToQuery('label:"value"', 'filter', false)).toBe('label:"value" NOT "filter"');
expect(addStringFilterToQuery('', 'filter', false)).toBe('NOT "filter"');
expect(addStringFilterToQuery(' ', 'filter', false)).toBe('NOT "filter"');
});
it('should escape filter values', () => {
expect(addStringFilterToQuery('label:"value"', '"filter"')).toBe('label:"value" AND "\\"filter\\""');
expect(addStringFilterToQuery('label:"value"', '"filter"', false)).toBe('label:"value" NOT "\\"filter\\""');
});
});

View File

@ -239,3 +239,8 @@ function parseQuery(query: string) {
return null;
}
}
export function addStringFilterToQuery(query: string, filter: string, contains = true) {
const expression = `"${escapeFilterValue(filter)}"`;
return query.trim() ? `${query} ${contains ? 'AND' : 'NOT'} ${expression}` : `${contains ? '' : 'NOT '}${expression}`;
}

View File

@ -676,6 +676,50 @@ describe('LokiDatasource', () => {
});
});
});
describe('when called with ADD_LINE_FILTER', () => {
let ds: LokiDatasource;
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"}' };
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
ds.languageProvider.labelKeys = ['bar', 'job'];
});
it('adds a line filter', () => {
const action = { options: {}, type: 'ADD_LINE_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.expr).toEqual('{bar="baz"} |= ``');
});
it('adds a line filter with a value', () => {
const action = { options: { value: 'value' }, type: 'ADD_LINE_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.expr).toEqual('{bar="baz"} |= `value`');
});
});
describe('when called with ADD_LINE_FILTER_OUT', () => {
let ds: LokiDatasource;
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"}' };
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
ds.languageProvider.labelKeys = ['bar', 'job'];
});
it('adds a line filter', () => {
const action = { options: {}, type: 'ADD_LINE_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
expect(result.expr).toEqual('{bar="baz"} != ``');
});
it('adds a line filter with a value', () => {
const action = { options: { value: 'value' }, type: 'ADD_LINE_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
expect(result.expr).toEqual('{bar="baz"} != `value`');
});
});
});
describe('toggleQueryFilter', () => {

View File

@ -928,8 +928,14 @@ export class LokiDatasource
expression = addFilterAsLabelFilter(expression, [lastPosition], filter);
break;
}
case 'ADD_STRING_FILTER':
case 'ADD_LINE_FILTER': {
expression = addLineFilter(expression);
expression = addLineFilter(expression, action.options?.value);
break;
}
case 'ADD_STRING_FILTER_OUT':
case 'ADD_LINE_FILTER_OUT': {
expression = addLineFilter(expression, action.options?.value, '!=');
break;
}
default:

View File

@ -3,6 +3,7 @@ import { SyntaxNode } from '@lezer/common';
import {
addLabelFormatToQuery,
addLabelToQuery,
addLineFilter,
addNoPipelineErrorToQuery,
addParserToQuery,
NodePosition,
@ -306,3 +307,22 @@ describe('removeLabelFromQuery', () => {
expect(removeLabelFromQuery(query, 'job', '!=', value)).toBe(expected);
});
});
describe.each(['|=', '!='])('addLineFilter type %s', (op: string) => {
it('Adds a line filter to a log query', () => {
expect(addLineFilter('{place="earth"}', undefined, op)).toBe(`{place="earth"} ${op} \`\``);
});
it('Adds a line filter with a value to a log query', () => {
expect(addLineFilter('{place="earth"}', 'content', op)).toBe(`{place="earth"} ${op} \`content\``);
});
it('Adds a line filter to a metric query', () => {
expect(addLineFilter('avg_over_time({place="earth"} [1m])', undefined, op)).toBe(
`avg_over_time({place="earth"} ${op} \`\` [1m])`
);
});
it('Adds a line filter with a value to a metric query', () => {
expect(addLineFilter('avg_over_time({place="earth"} [1m])', 'content', op)).toBe(
`avg_over_time({place="earth"} ${op} \`content\` [1m])`
);
});
});

View File

@ -540,14 +540,14 @@ function addLabelFormat(
return newQuery;
}
export function addLineFilter(query: string): string {
export function addLineFilter(query: string, value = '', operator = '|='): string {
const streamSelectorPositions = getStreamSelectorPositions(query);
if (!streamSelectorPositions.length) {
return query;
}
const streamSelectorEnd = streamSelectorPositions[0].to;
const newQueryExpr = query.slice(0, streamSelectorEnd) + ' |= ``' + query.slice(streamSelectorEnd);
const newQueryExpr = query.slice(0, streamSelectorEnd) + ` ${operator} \`${value}\`` + query.slice(streamSelectorEnd);
return newQueryExpr;
}