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
docs/sources/setup-grafana/configure-grafana/feature-toggles
packages/grafana-data/src/types
pkg/services/featuremgmt
public/app
features
explore
logs
plugins/datasource
@ -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
|
||||
|
@ -162,5 +162,6 @@ export interface FeatureToggles {
|
||||
alertingDetailsViewV2?: boolean;
|
||||
datatrails?: boolean;
|
||||
alertingSimplifiedRouting?: boolean;
|
||||
logRowsPopoverMenu?: boolean;
|
||||
pluginsSkipHostEnvVars?: boolean;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
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}
|
||||
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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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 };
|
||||
|
@ -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\\""');
|
||||
});
|
||||
});
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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:
|
||||
|
@ -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])`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user