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 (#75306)
* 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 (#76757) * 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:
parent
05cf8c9253
commit
9cb303c3f7
@ -162,6 +162,7 @@ Experimental features might be changed or removed without prior notice.
|
|||||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles |
|
| `dashboardScene` | Enables dashboard rendering using scenes for all roles |
|
||||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
|
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
|
||||||
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
|
| `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 |
|
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
|
||||||
|
|
||||||
## Development feature toggles
|
## Development feature toggles
|
||||||
|
@ -162,5 +162,6 @@ export interface FeatureToggles {
|
|||||||
alertingDetailsViewV2?: boolean;
|
alertingDetailsViewV2?: boolean;
|
||||||
datatrails?: boolean;
|
datatrails?: boolean;
|
||||||
alertingSimplifiedRouting?: boolean;
|
alertingSimplifiedRouting?: boolean;
|
||||||
|
logRowsPopoverMenu?: boolean;
|
||||||
pluginsSkipHostEnvVars?: boolean;
|
pluginsSkipHostEnvVars?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1063,6 +1063,13 @@ var (
|
|||||||
Owner: grafanaAlertingSquad,
|
Owner: grafanaAlertingSquad,
|
||||||
HideFromDocs: true,
|
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",
|
Name: "pluginsSkipHostEnvVars",
|
||||||
Description: "Disables passing host environment variable to plugin processes",
|
Description: "Disables passing host environment variable to plugin processes",
|
||||||
|
@ -143,4 +143,5 @@ flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profilin
|
|||||||
alertingDetailsViewV2,experimental,@grafana/alerting-squad,false,false,false,true
|
alertingDetailsViewV2,experimental,@grafana/alerting-squad,false,false,false,true
|
||||||
datatrails,experimental,@grafana/dashboards-squad,false,false,false,true
|
datatrails,experimental,@grafana/dashboards-squad,false,false,false,true
|
||||||
alertingSimplifiedRouting,experimental,@grafana/alerting-squad,false,false,false,false
|
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
|
pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false,false
|
||||||
|
|
@ -583,6 +583,10 @@ const (
|
|||||||
// Enables the simplified routing for alerting
|
// Enables the simplified routing for alerting
|
||||||
FlagAlertingSimplifiedRouting = "alertingSimplifiedRouting"
|
FlagAlertingSimplifiedRouting = "alertingSimplifiedRouting"
|
||||||
|
|
||||||
|
// FlagLogRowsPopoverMenu
|
||||||
|
// Enable filtering menu displayed when text of a log line is selected
|
||||||
|
FlagLogRowsPopoverMenu = "logRowsPopoverMenu"
|
||||||
|
|
||||||
// FlagPluginsSkipHostEnvVars
|
// FlagPluginsSkipHostEnvVars
|
||||||
// Disables passing host environment variable to plugin processes
|
// Disables passing host environment variable to plugin processes
|
||||||
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"
|
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"
|
||||||
|
@ -230,6 +230,20 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
this.onModifyQueries({ type: 'ADD_FILTER_OUT', options: { key, value } }, refId);
|
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 = () => {
|
onClickAddQueryRowButton = () => {
|
||||||
const { exploreId, queryKeys } = this.props;
|
const { exploreId, queryKeys } = this.props;
|
||||||
this.props.addQueryRow(exploreId, queryKeys.length);
|
this.props.addQueryRow(exploreId, queryKeys.length);
|
||||||
@ -250,7 +264,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
const ds = await getDataSourceSrv().get(datasource);
|
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, {
|
return ds.toggleQueryFilter(query, {
|
||||||
type: modification.type === 'ADD_FILTER' ? 'FILTER_FOR' : 'FILTER_OUT',
|
type: modification.type === 'ADD_FILTER' ? 'FILTER_FOR' : 'FILTER_OUT',
|
||||||
options: modification.options ?? {},
|
options: modification.options ?? {},
|
||||||
@ -435,6 +450,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
splitOpenFn={this.onSplitOpen('logs')}
|
splitOpenFn={this.onSplitOpen('logs')}
|
||||||
scrollElement={this.scrollElement}
|
scrollElement={this.scrollElement}
|
||||||
isFilterLabelActive={this.isFilterLabelActive}
|
isFilterLabelActive={this.isFilterLabelActive}
|
||||||
|
onClickFilterValue={this.onClickFilterValue}
|
||||||
|
onClickFilterOutValue={this.onClickFilterOutValue}
|
||||||
/>
|
/>
|
||||||
</ContentOutlineItem>
|
</ContentOutlineItem>
|
||||||
);
|
);
|
||||||
|
@ -102,6 +102,8 @@ interface Props extends Themeable2 {
|
|||||||
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
|
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||||
logsFrames?: DataFrame[];
|
logsFrames?: DataFrame[];
|
||||||
range: TimeRange;
|
range: TimeRange;
|
||||||
|
onClickFilterValue?: (value: string, refId?: string) => void;
|
||||||
|
onClickFilterOutValue?: (value: string, refId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogsVisualisationType = 'table' | 'logs';
|
export type LogsVisualisationType = 'table' | 'logs';
|
||||||
@ -772,6 +774,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
scrollIntoView={this.scrollIntoView}
|
scrollIntoView={this.scrollIntoView}
|
||||||
isFilterLabelActive={this.props.isFilterLabelActive}
|
isFilterLabelActive={this.props.isFilterLabelActive}
|
||||||
containerRendered={!!this.state.logsContainer}
|
containerRendered={!!this.state.logsContainer}
|
||||||
|
onClickFilterValue={this.props.onClickFilterValue}
|
||||||
|
onClickFilterOutValue={this.props.onClickFilterOutValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -56,6 +56,8 @@ interface LogsContainerProps extends PropsFromRedux {
|
|||||||
splitOpenFn: SplitOpen;
|
splitOpenFn: SplitOpen;
|
||||||
scrollElement?: HTMLDivElement;
|
scrollElement?: HTMLDivElement;
|
||||||
isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<boolean>;
|
isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||||
|
onClickFilterValue: (value: string, refId?: string) => void;
|
||||||
|
onClickFilterOutValue: (value: string, refId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogsContainerState {
|
interface LogsContainerState {
|
||||||
@ -313,6 +315,8 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
|
|||||||
scrollElement={scrollElement}
|
scrollElement={scrollElement}
|
||||||
isFilterLabelActive={logDetailsFilterAvailable ? this.props.isFilterLabelActive : undefined}
|
isFilterLabelActive={logDetailsFilterAvailable ? this.props.isFilterLabelActive : undefined}
|
||||||
range={range}
|
range={range}
|
||||||
|
onClickFilterValue={this.props.onClickFilterValue}
|
||||||
|
onClickFilterOutValue={this.props.onClickFilterOutValue}
|
||||||
/>
|
/>
|
||||||
</LogsCrossFadeTransition>
|
</LogsCrossFadeTransition>
|
||||||
</>
|
</>
|
||||||
|
@ -30,6 +30,7 @@ const setup = (propOverrides?: Partial<ComponentProps<typeof LogRow>>, rowOverri
|
|||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
getRows: () => [],
|
getRows: () => [],
|
||||||
onOpenContext: () => {},
|
onOpenContext: () => {},
|
||||||
|
handleTextSelection: jest.fn(),
|
||||||
prettifyLogMessage: false,
|
prettifyLogMessage: false,
|
||||||
app: CoreApp.Explore,
|
app: CoreApp.Explore,
|
||||||
showDuplicates: false,
|
showDuplicates: false,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { cx } from '@emotion/css';
|
import { cx } from '@emotion/css';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import memoizeOne from 'memoize-one';
|
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 { Field, LinkModel, LogRowModel, LogsSortOrder, dateTimeFormat, CoreApp, DataFrame } from '@grafana/data';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
@ -48,6 +48,7 @@ interface Props extends Themeable2 {
|
|||||||
onUnpinLine?: (row: LogRowModel) => void;
|
onUnpinLine?: (row: LogRowModel) => void;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
containerRendered?: boolean;
|
containerRendered?: boolean;
|
||||||
|
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -88,7 +89,12 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
this.props.onOpenContext(row, this.debouncedContextClose);
|
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) {
|
if (!this.props.enableLogDetails) {
|
||||||
return;
|
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 = () => {
|
onMouseLeave = () => {
|
||||||
this.setState({ mouseIsOver: false });
|
this.setState({ mouseIsOver: false });
|
||||||
if (this.props.onLogRowHover) {
|
if (this.props.onLogRowHover) {
|
||||||
@ -205,9 +222,10 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
<tr
|
<tr
|
||||||
ref={this.logLineRef}
|
ref={this.logLineRef}
|
||||||
className={logRowBackground}
|
className={logRowBackground}
|
||||||
onClick={this.toggleDetails}
|
onClick={this.onRowClick}
|
||||||
onMouseEnter={this.onMouseEnter}
|
onMouseEnter={this.onMouseEnter}
|
||||||
onMouseLeave={this.onMouseLeave}
|
onMouseLeave={this.onMouseLeave}
|
||||||
|
onMouseMove={this.onMouseMove}
|
||||||
/**
|
/**
|
||||||
* For better accessibility support, we listen to the onFocus event here (to display the LogRowMenuCell), and
|
* 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
|
* to onBlur event in the LogRowMenuCell (to hide it). This way, the LogRowMenuCell is displayed when the user navigates
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { act, render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { range } from 'lodash';
|
import { range } from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@ -7,6 +8,15 @@ import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
|||||||
import { LogRows, PREVIEW_LIMIT } from './LogRows';
|
import { LogRows, PREVIEW_LIMIT } from './LogRows';
|
||||||
import { createLogRow } from './__mocks__/logRow';
|
import { createLogRow } from './__mocks__/logRow';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
config: {
|
||||||
|
featureToggles: {
|
||||||
|
logRowsPopoverMenu: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('LogRows', () => {
|
describe('LogRows', () => {
|
||||||
it('renders rows', () => {
|
it('renders rows', () => {
|
||||||
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
|
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');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -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 React, { PureComponent } from 'react';
|
import React, { PureComponent, MouseEvent, createRef } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TimeZone,
|
TimeZone,
|
||||||
@ -12,13 +12,15 @@ import {
|
|||||||
CoreApp,
|
CoreApp,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { withTheme2, Themeable2 } from '@grafana/ui';
|
import { withTheme2, Themeable2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { UniqueKeyMaker } from '../UniqueKeyMaker';
|
import { UniqueKeyMaker } from '../UniqueKeyMaker';
|
||||||
import { sortLogRows } from '../utils';
|
import { sortLogRows, targetIsElement } from '../utils';
|
||||||
|
|
||||||
//Components
|
//Components
|
||||||
import { LogRow } from './LogRow';
|
import { LogRow } from './LogRow';
|
||||||
|
import { PopoverMenu } from './PopoverMenu';
|
||||||
import { getLogRowStyles } from './getLogRowStyles';
|
import { getLogRowStyles } from './getLogRowStyles';
|
||||||
|
|
||||||
export const PREVIEW_LIMIT = 100;
|
export const PREVIEW_LIMIT = 100;
|
||||||
@ -59,14 +61,20 @@ export interface Props extends Themeable2 {
|
|||||||
* Any overflowing content will be clipped at the table boundary.
|
* Any overflowing content will be clipped at the table boundary.
|
||||||
*/
|
*/
|
||||||
overflowingContent?: boolean;
|
overflowingContent?: boolean;
|
||||||
|
onClickFilterValue?: (value: string, refId?: string) => void;
|
||||||
|
onClickFilterOutValue?: (value: string, refId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
renderAll: boolean;
|
renderAll: boolean;
|
||||||
|
selection: string;
|
||||||
|
selectedRow: LogRowModel | null;
|
||||||
|
popoverMenuCoordinates: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnThemedLogRows extends PureComponent<Props, State> {
|
class UnThemedLogRows extends PureComponent<Props, State> {
|
||||||
renderAllTimer: number | null = null;
|
renderAllTimer: number | null = null;
|
||||||
|
logRowsRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
previewLimit: PREVIEW_LIMIT,
|
previewLimit: PREVIEW_LIMIT,
|
||||||
@ -74,6 +82,9 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
|
|
||||||
state: State = {
|
state: State = {
|
||||||
renderAll: false,
|
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() {
|
componentDidMount() {
|
||||||
// Staged rendering
|
// Staged rendering
|
||||||
const { logRows, previewLimit } = this.props;
|
const { logRows, previewLimit } = this.props;
|
||||||
@ -96,9 +161,11 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
} else {
|
} else {
|
||||||
this.renderAllTimer = window.setTimeout(() => this.setState({ renderAll: true }), 2000);
|
this.renderAllTimer = window.setTimeout(() => this.setState({ renderAll: true }), 2000);
|
||||||
}
|
}
|
||||||
|
document.addEventListener('mouseup', this.handleDeselection);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
document.removeEventListener('mouseup', this.handleDeselection);
|
||||||
if (this.renderAllTimer) {
|
if (this.renderAllTimer) {
|
||||||
clearTimeout(this.renderAllTimer);
|
clearTimeout(this.renderAllTimer);
|
||||||
}
|
}
|
||||||
@ -134,6 +201,17 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
const keyMaker = new UniqueKeyMaker();
|
const keyMaker = new UniqueKeyMaker();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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)}>
|
<table className={cx(styles.logsRowsTable, this.props.overflowingContent ? '' : styles.logsRowsTableContain)}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{hasData &&
|
{hasData &&
|
||||||
@ -153,6 +231,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
onUnpinLine={this.props.onUnpinLine}
|
onUnpinLine={this.props.onUnpinLine}
|
||||||
pinned={this.props.pinnedRowId === row.uid}
|
pinned={this.props.pinnedRowId === row.uid}
|
||||||
isFilterLabelActive={this.props.isFilterLabelActive}
|
isFilterLabelActive={this.props.isFilterLabelActive}
|
||||||
|
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -174,6 +253,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
onUnpinLine={this.props.onUnpinLine}
|
onUnpinLine={this.props.onUnpinLine}
|
||||||
pinned={this.props.pinnedRowId === row.uid}
|
pinned={this.props.pinnedRowId === row.uid}
|
||||||
isFilterLabelActive={this.props.isFilterLabelActive}
|
isFilterLabelActive={this.props.isFilterLabelActive}
|
||||||
|
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -184,6 +264,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
89
public/app/features/logs/components/PopoverMenu.test.tsx
Normal file
89
public/app/features/logs/components/PopoverMenu.test.tsx
Normal 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);
|
||||||
|
});
|
99
public/app/features/logs/components/PopoverMenu.tsx
Normal file
99
public/app/features/logs/components/PopoverMenu.tsx
Normal 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,
|
||||||
|
}),
|
||||||
|
});
|
@ -67,12 +67,16 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
|||||||
color: ${theme.components.textHighlight.text}
|
color: ${theme.components.textHighlight.text}
|
||||||
background-color: ${theme.components.textHighlight};
|
background-color: ${theme.components.textHighlight};
|
||||||
`,
|
`,
|
||||||
|
logRows: css({
|
||||||
|
position: 'relative',
|
||||||
|
}),
|
||||||
logsRowsTable: css`
|
logsRowsTable: css`
|
||||||
label: logs-rows;
|
label: logs-rows;
|
||||||
font-family: ${theme.typography.fontFamilyMonospace};
|
font-family: ${theme.typography.fontFamilyMonospace};
|
||||||
font-size: ${theme.typography.bodySmall.fontSize};
|
font-size: ${theme.typography.bodySmall.fontSize};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
${!scrollableLogsContainer && `margin-bottom: ${theme.spacing(2.25)};`}
|
${!scrollableLogsContainer && `margin-bottom: ${theme.spacing(2.25)};`}
|
||||||
|
position: relative;
|
||||||
`,
|
`,
|
||||||
logsRowsTableContain: css`
|
logsRowsTableContain: css`
|
||||||
contain: strict;
|
contain: strict;
|
||||||
|
@ -273,3 +273,27 @@ export const getLogsVolumeDataSourceInfo = (dataFrames: DataFrame[]): { name: st
|
|||||||
export const isLogsVolumeLimited = (dataFrames: DataFrame[]) => {
|
export const isLogsVolumeLimited = (dataFrames: DataFrame[]) => {
|
||||||
return dataFrames[0]?.meta?.custom?.logsVolumeType === LogsVolumeType.Limited;
|
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;
|
||||||
|
}
|
||||||
|
@ -1134,11 +1134,12 @@ describe('enhanceDataFrame', () => {
|
|||||||
|
|
||||||
describe('modifyQuery', () => {
|
describe('modifyQuery', () => {
|
||||||
let ds: ElasticDatasource;
|
let ds: ElasticDatasource;
|
||||||
|
let query: ElasticsearchQuery;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ds = getTestContext().ds;
|
ds = getTestContext().ds;
|
||||||
});
|
});
|
||||||
describe('with empty query', () => {
|
describe('with empty query', () => {
|
||||||
let query: ElasticsearchQuery;
|
describe('ADD_FILTER and ADD_FITER_OUT', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
query = { query: '', refId: 'A' };
|
query = { query: '', refId: 'A' };
|
||||||
});
|
});
|
||||||
@ -1156,7 +1157,9 @@ describe('modifyQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing on unknown type', () => {
|
it('should do nothing on unknown type', () => {
|
||||||
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(query.query);
|
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(
|
||||||
|
query.query
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1179,7 +1182,45 @@ describe('modifyQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing on unknown type', () => {
|
it('should do nothing on unknown type', () => {
|
||||||
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(query.query);
|
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_STRING_FILTER', options: { value: 'bar' } }).query).toBe('"bar"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add the negative filter', () => {
|
||||||
|
expect(ds.modifyQuery(query, { type: 'ADD_STRING_FILTER_OUT', options: { value: 'bar' } }).query).toBe(
|
||||||
|
'NOT "bar"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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_STRING_FILTER', options: { value: 'bar' } }).query).toBe(
|
||||||
|
'test:"value" AND "bar"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add the negative filter', () => {
|
||||||
|
expect(ds.modifyQuery(query, { type: 'ADD_STRING_FILTER_OUT', options: { value: 'bar' } }).query).toBe(
|
||||||
|
'test:"value" NOT "bar"'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -63,7 +63,13 @@ import {
|
|||||||
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
|
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
|
||||||
import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils';
|
import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils';
|
||||||
import { isMetricAggregationWithMeta } from './guards';
|
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 { trackAnnotationQuery, trackQuery } from './tracking';
|
||||||
import {
|
import {
|
||||||
Logs,
|
Logs,
|
||||||
@ -943,6 +949,14 @@ export class ElasticDatasource
|
|||||||
expression = addFilterToQuery(expression, action.options.key, action.options.value, '-');
|
expression = addFilterToQuery(expression, action.options.key, action.options.value, '-');
|
||||||
break;
|
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 };
|
return { ...query, query: expression };
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { queryHasFilter, removeFilterFromQuery, addFilterToQuery } from './modifyQuery';
|
import { queryHasFilter, removeFilterFromQuery, addFilterToQuery, addStringFilterToQuery } from './modifyQuery';
|
||||||
|
|
||||||
describe('queryHasFilter', () => {
|
describe('queryHasFilter', () => {
|
||||||
it('should return true if the query contains the positive filter', () => {
|
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('');
|
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\\""');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -239,3 +239,8 @@ function parseQuery(query: string) {
|
|||||||
return null;
|
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}`;
|
||||||
|
}
|
||||||
|
@ -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', () => {
|
describe('toggleQueryFilter', () => {
|
||||||
|
@ -928,8 +928,14 @@ export class LokiDatasource
|
|||||||
expression = addFilterAsLabelFilter(expression, [lastPosition], filter);
|
expression = addFilterAsLabelFilter(expression, [lastPosition], filter);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'ADD_STRING_FILTER':
|
||||||
case 'ADD_LINE_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;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -3,6 +3,7 @@ import { SyntaxNode } from '@lezer/common';
|
|||||||
import {
|
import {
|
||||||
addLabelFormatToQuery,
|
addLabelFormatToQuery,
|
||||||
addLabelToQuery,
|
addLabelToQuery,
|
||||||
|
addLineFilter,
|
||||||
addNoPipelineErrorToQuery,
|
addNoPipelineErrorToQuery,
|
||||||
addParserToQuery,
|
addParserToQuery,
|
||||||
NodePosition,
|
NodePosition,
|
||||||
@ -306,3 +307,22 @@ describe('removeLabelFromQuery', () => {
|
|||||||
expect(removeLabelFromQuery(query, 'job', '!=', value)).toBe(expected);
|
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])`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -540,14 +540,14 @@ function addLabelFormat(
|
|||||||
return newQuery;
|
return newQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addLineFilter(query: string): string {
|
export function addLineFilter(query: string, value = '', operator = '|='): string {
|
||||||
const streamSelectorPositions = getStreamSelectorPositions(query);
|
const streamSelectorPositions = getStreamSelectorPositions(query);
|
||||||
if (!streamSelectorPositions.length) {
|
if (!streamSelectorPositions.length) {
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
const streamSelectorEnd = streamSelectorPositions[0].to;
|
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;
|
return newQueryExpr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user