diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index e701cb0c2f6..285b8f2cff3 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -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 diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 7fbe94d26c1..8f75b471e95 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -162,5 +162,6 @@ export interface FeatureToggles { alertingDetailsViewV2?: boolean; datatrails?: boolean; alertingSimplifiedRouting?: boolean; + logRowsPopoverMenu?: boolean; pluginsSkipHostEnvVars?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ecddb76c76b..779d7fe14e9 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 1fffede8694..826dd69f5e3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 943cc371f8d..e6234c6d826 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 81aad3edc5b..aa37d9fe1a6 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -230,6 +230,20 @@ export class Explore extends React.PureComponent { 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 { 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 { splitOpenFn={this.onSplitOpen('logs')} scrollElement={this.scrollElement} isFilterLabelActive={this.isFilterLabelActive} + onClickFilterValue={this.onClickFilterValue} + onClickFilterOutValue={this.onClickFilterOutValue} /> ); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 6990b7e34d3..692e0976eba 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -102,6 +102,8 @@ interface Props extends Themeable2 { isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; 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 { scrollIntoView={this.scrollIntoView} isFilterLabelActive={this.props.isFilterLabelActive} containerRendered={!!this.state.logsContainer} + onClickFilterValue={this.props.onClickFilterValue} + onClickFilterOutValue={this.props.onClickFilterOutValue} /> )} diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index b60a620a4f8..96d46b37c0d 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -56,6 +56,8 @@ interface LogsContainerProps extends PropsFromRedux { splitOpenFn: SplitOpen; scrollElement?: HTMLDivElement; isFilterLabelActive: (key: string, value: string, refId?: string) => Promise; + onClickFilterValue: (value: string, refId?: string) => void; + onClickFilterOutValue: (value: string, refId?: string) => void; } interface LogsContainerState { @@ -313,6 +315,8 @@ class LogsContainer extends PureComponent diff --git a/public/app/features/logs/components/LogRow.test.tsx b/public/app/features/logs/components/LogRow.test.tsx index c8fc50d4d9b..13e0290d086 100644 --- a/public/app/features/logs/components/LogRow.test.tsx +++ b/public/app/features/logs/components/LogRow.test.tsx @@ -30,6 +30,7 @@ const setup = (propOverrides?: Partial>, rowOverri enableLogDetails: false, getRows: () => [], onOpenContext: () => {}, + handleTextSelection: jest.fn(), prettifyLogMessage: false, app: CoreApp.Explore, showDuplicates: false, diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index f4b8697ae56..923eb1feaa0 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -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, row: LogRowModel) => boolean; } interface State { @@ -88,7 +89,12 @@ class UnThemedLogRow extends PureComponent { this.props.onOpenContext(row, this.debouncedContextClose); }; - toggleDetails = () => { + onRowClick = (e: MouseEvent) => { + 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 { } }; + 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 { ({ + ...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( + {}} + 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(); + }); +}); diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 2b9c8d9e49e..4d951114839 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -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 { renderAllTimer: number | null = null; + logRowsRef = createRef(); static defaultProps = { previewLimit: PREVIEW_LIMIT, @@ -74,6 +82,9 @@ class UnThemedLogRows extends PureComponent { state: State = { renderAll: false, + selection: '', + selectedRow: null, + popoverMenuCoordinates: { x: 0, y: 0 }, }; /** @@ -85,6 +96,60 @@ class UnThemedLogRows extends PureComponent { } }; + popoverMenuSupported() { + if (!config.featureToggles.logRowsPopoverMenu) { + return false; + } + return Boolean(this.props.onClickFilterOutValue || this.props.onClickFilterValue); + } + + handleSelection = (e: MouseEvent, 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 { } 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 { const keyMaker = new UniqueKeyMaker(); return ( - - - {hasData && - firstRows.map((row) => ( - - ))} - {hasData && - renderAll && - lastRows.map((row) => ( - - ))} - {hasData && !renderAll && ( - - - - )} - -
Rendering {orderedRows.length - previewLimit!} rows...
+
+ {this.state.selection && this.state.selectedRow && ( + + )} + + + {hasData && + firstRows.map((row) => ( + + ))} + {hasData && + renderAll && + lastRows.map((row) => ( + + ))} + {hasData && !renderAll && ( + + + + )} + +
Rendering {orderedRows.length - previewLimit!} rows...
+
); } } diff --git a/public/app/features/logs/components/PopoverMenu.test.tsx b/public/app/features/logs/components/PopoverMenu.test.tsx new file mode 100644 index 00000000000..1c52e84cb68 --- /dev/null +++ b/public/app/features/logs/components/PopoverMenu.test.tsx @@ -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( {}} />); + + expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); +}); + +test('Renders copy and line contains filter', async () => { + const onClickFilterValue = jest.fn(); + render( + {}} 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( + {}} + 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( + {}} + 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( + {}} + onClickFilterOutValue={() => {}} + /> + ); + + expect(close).not.toHaveBeenCalled(); + expect(screen.getByText('Copy selection')).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + expect(close).toHaveBeenCalledTimes(1); +}); diff --git a/public/app/features/logs/components/PopoverMenu.tsx b/public/app/features/logs/components/PopoverMenu.tsx new file mode 100644 index 00000000000..22c26782194 --- /dev/null +++ b/public/app/features/logs/components/PopoverMenu.tsx @@ -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(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 ( +
+ + { + copyText(selection, containerRef); + close(); + track('copy_selection_clicked', selection.length, row.datasourceType); + }} + /> + {onClickFilterValue && ( + { + onClickFilterValue(selection, row.dataFrame.refId); + close(); + track('line_filter_clicked', selection.length, row.datasourceType); + }} + /> + )} + {onClickFilterOutValue && ( + { + onClickFilterOutValue(selection, row.dataFrame.refId); + close(); + track('line_filter_out_clicked', selection.length, row.datasourceType); + }} + /> + )} + +
+ ); +}; + +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, + }), +}); diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index d059f418350..4583138ece8 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -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; diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 9ac25e2cf6e..9f7cce6fab3 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -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) => { + 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; +} diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index ad7b29dd6ec..d59640549da 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -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); - }); }); }); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 0eb102cbcf3..22911072163 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -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 }; diff --git a/public/app/plugins/datasource/elasticsearch/modifyQuery.test.ts b/public/app/plugins/datasource/elasticsearch/modifyQuery.test.ts index 7e211da673d..a4c7898c58a 100644 --- a/public/app/plugins/datasource/elasticsearch/modifyQuery.test.ts +++ b/public/app/plugins/datasource/elasticsearch/modifyQuery.test.ts @@ -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\\""'); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/modifyQuery.ts b/public/app/plugins/datasource/elasticsearch/modifyQuery.ts index 529ffac457e..f565d2e5cbe 100644 --- a/public/app/plugins/datasource/elasticsearch/modifyQuery.ts +++ b/public/app/plugins/datasource/elasticsearch/modifyQuery.ts @@ -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}`; +} diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 6afcf983a8f..6c6ba6657f6 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -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', () => { diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 615bd7e360e..6ca38333b26 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -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: diff --git a/public/app/plugins/datasource/loki/modifyQuery.test.ts b/public/app/plugins/datasource/loki/modifyQuery.test.ts index 9b22d518965..8965e27d2cc 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.test.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.test.ts @@ -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])` + ); + }); +}); diff --git a/public/app/plugins/datasource/loki/modifyQuery.ts b/public/app/plugins/datasource/loki/modifyQuery.ts index 7335fe2b930..5ef8aa70cb6 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.ts @@ -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; }