Logs: Show active state of "filter for value" buttons in Logs Details (#70328)

* Datasource test: fix describe nesting

* Parsing: export handleQuotes function

* Modify query: add functions to detect the presence of a label and remove it

* Loki: add support to toggle filters if already present

* Datasource test: fix describe nesting

* Loki: add support to toggle filter out if present

* Remove label: handle escaped values

* Datasource: add test case for escaped label values

* Loki: remove = filter when applying !=

* Remove selector: add support for Selector node being far from Matcher

* Modify query: add unit tests

* Elasticsearch: create modifyQuery for elastic

* Elastic modify query: implement functions

* Elasticsearch: implement modifyQuery functions in datasource

* Elasticsearch: update datasource test

* Loki modify query: check for streamSelectorPositions length

* Elasticsearch query has filter: escape filter value in regex

* Remove unused type

* Modify query: add functions to detect the presence of a label and remove it

* Remove label: handle escaped values

* Logs: create props to check for label filters in the query

* Log Details Row: use label state props to show visual feedback

* Make isCallbacks async

* Explore: add placeholder for checking for filter in query

* Datasource: define new API method

* Inspect query: add base implementation

* Remove isFilterOutLabelActive as it will not be needed

* Check for "isActive" on every render

Otherwise the active state will be out of sync

* Elasticsearch: implement inspectQuery in the datasource

* Logs: update test

* Log details: update test

* Datasources: update tests

* Inspect query: rename to analize query to prevent confusion

* Datasource types: mark method as alpha

* Explore: add comment to log-specific functions

* Remove duplicated code from bad rebase

* Remove label filter: check node type

* getMatchersWithFilter: rename argument

* Fix bad rebase

* Create DataSourceWithQueryManipulationSupport interface

* Implement type guard for DataSourceWithQueryManipulationSupport

* DataSourceWithQueryManipulationSupport: move to logs module

* hasQueryManipulationSupport: change implementation

`modifyQuery` comes from the prototype.

* DataSourceWithQueryManipulationSupport: expand code comments

* AnalyzeQueryOptions: move to logs module

* DataSourceWithQueryManipulationSupport: add support for more return types

* Fix merge error

* Update packages/grafana-data/src/types/logs.ts

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>

* DatasourceAPI: deprecate modifyQuery

* Explore: refactor isFilterLabelActive

* DataSourceWithQueryModificationSupport: rename interface

* Split interfaces into Analyze and Modify

* Query analysis: better name for interface

* Fix guard

* Create feature flag for active state

* Use new feature flag in Explore

* DataSourceToggleableQueryFiltersSupport: create a specific interface for this feature

* Rename feature flag

* De-deprecate modifyQuery

* DataSourceToggleableQueryFiltersSupport: Rethink types and methods

* Explore: adjust modifyQuery and isFilterLabelActive to new methods

* Loki: implement new interface and revert modifyQuery

* DataSourceToggleableQueryFiltersSupport: better name for arguments

* Elasticsearch: implement new interface and revert modifyQuery

* Loki: better name for arguments

* Explore: document current limitation on isFilterLabelActive

* Explore: place toggleable filters under feature flag

* Loki: add tests for the new methods

* Loki: add legacy modifyQuery tests

* Elasticsearch: add tests for the new methods

* Elasticsearch: add legacy modifyQuery tests

* Toggle filter action: improve type values

* Logs types: update interface description

* DataSourceWithToggleableQueryFiltersSupport: update interface name

* Update feature flag description

* Explore: add todo comment for isFilterLabelActive

---------

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>
This commit is contained in:
Matias Chomicki 2023-07-24 10:22:47 +02:00 committed by GitHub
parent 719f6bc520
commit 84f94cdc24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 493 additions and 129 deletions

View File

@ -124,6 +124,7 @@ Experimental features might be changed or removed without prior notice.
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore |
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
| `transformationsRedesign` | Enables the transformations redesign |
| `toggleLabelsInLogsUI` | Enable toggleable filters in log details view |
| `mlExpressions` | Enable support for Machine Learning in server-side expressions |
| `disableTraceQLStreaming` | Disables the option to stream the response of TraceQL queries of the Tempo data source |
| `grafanaAPIServer` | Enable Kubernetes API Server for Grafana resources |

View File

@ -110,6 +110,7 @@ export interface FeatureToggles {
logsExploreTableVisualisation?: boolean;
awsDatasourcesTempCredentials?: boolean;
transformationsRedesign?: boolean;
toggleLabelsInLogsUI?: boolean;
mlExpressions?: boolean;
disableTraceQLStreaming?: boolean;
grafanaAPIServer?: boolean;

View File

@ -2,7 +2,7 @@ import { Observable } from 'rxjs';
import { DataQuery } from '@grafana/schema';
import { Labels } from './data';
import { KeyValue, Labels } from './data';
import { DataFrame } from './dataFrame';
import { DataQueryRequest, DataQueryResponse } from './datasource';
import { AbsoluteTimeRange } from './time';
@ -263,3 +263,42 @@ export const hasLogsContextUiSupport = (datasource: unknown): datasource is Data
return withLogsSupport.getLogRowContextUi !== undefined;
};
export interface QueryFilterOptions extends KeyValue<string> {}
export interface ToggleFilterAction {
type: 'FILTER_FOR' | 'FILTER_OUT';
options: QueryFilterOptions;
}
/**
* Data sources that support toggleable filters through `toggleQueryFilter`, and displaying the active
* state of filters through `queryHasFilter`, in the Log Details component in Explore.
* @internal
* @alpha
*/
export interface DataSourceWithToggleableQueryFiltersSupport<TQuery extends DataQuery> {
/**
* Toggle filters on and off from query.
* If the filter is already present, it should be removed.
* If the opposite filter is present, it should be replaced.
*/
toggleQueryFilter(query: TQuery, filter: ToggleFilterAction): TQuery;
/**
* Given a query, determine if it has a filter that matches the options.
*/
queryHasFilter(query: TQuery, filter: QueryFilterOptions): boolean;
}
/**
* @internal
*/
export const hasToggleableQueryFiltersSupport = <TQuery extends DataQuery>(
datasource: unknown
): datasource is DataSourceWithToggleableQueryFiltersSupport<TQuery> => {
return (
datasource !== null &&
typeof datasource === 'object' &&
'toggleQueryFilter' in datasource &&
'queryHasFilter' in datasource
);
};

View File

@ -628,6 +628,13 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "toggleLabelsInLogsUI",
Description: "Enable toggleable filters in log details view",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "mlExpressions",
Description: "Enable support for Machine Learning in server-side expressions",

View File

@ -91,6 +91,7 @@ prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-me
logsExploreTableVisualisation,experimental,@grafana/observability-logs,false,false,false,true
awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false,false
transformationsRedesign,experimental,@grafana/observability-metrics,false,false,false,true
toggleLabelsInLogsUI,experimental,@grafana/observability-logs,false,false,false,true
mlExpressions,experimental,@grafana/alerting-squad,false,false,false,false
disableTraceQLStreaming,experimental,@grafana/observability-traces-and-profiling,false,false,false,true
grafanaAPIServer,experimental,@grafana/grafana-app-platform-squad,false,false,false,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
91 logsExploreTableVisualisation experimental @grafana/observability-logs false false false true
92 awsDatasourcesTempCredentials experimental @grafana/aws-datasources false false false false
93 transformationsRedesign experimental @grafana/observability-metrics false false false true
94 toggleLabelsInLogsUI experimental @grafana/observability-logs false false false true
95 mlExpressions experimental @grafana/alerting-squad false false false false
96 disableTraceQLStreaming experimental @grafana/observability-traces-and-profiling false false false true
97 grafanaAPIServer experimental @grafana/grafana-app-platform-squad false false false false

View File

@ -375,6 +375,10 @@ const (
// Enables the transformations redesign
FlagTransformationsRedesign = "transformationsRedesign"
// FlagToggleLabelsInLogsUI
// Enable toggleable filters in log details view
FlagToggleLabelsInLogsUI = "toggleLabelsInLogsUI"
// FlagMlExpressions
// Enable support for Machine Learning in server-side expressions
FlagMlExpressions = "mlExpressions"

View File

@ -15,6 +15,7 @@ import {
EventBus,
SplitOpenOptions,
SupplementaryQueryType,
hasToggleableQueryFiltersSupport,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
@ -183,10 +184,41 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
};
/**
* Used by Logs details.
* Returns true if all queries have the filter, otherwise false.
* TODO: In the future, we would like to return active filters based the query that produced the log line.
* @alpha
*/
isFilterLabelActive = async (key: string, value: string) => {
if (!config.featureToggles.toggleLabelsInLogsUI) {
return false;
}
if (this.props.queries.length === 0) {
return false;
}
for (const query of this.props.queries) {
const ds = await getDataSourceSrv().get(query.datasource);
if (!hasToggleableQueryFiltersSupport(ds)) {
return false;
}
if (!ds.queryHasFilter(query, { key, value })) {
return false;
}
}
return true;
};
/**
* Used by Logs details.
*/
onClickFilterLabel = (key: string, value: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', options: { key, value } });
};
/**
* Used by Logs details.
*/
onClickFilterOutLabel = (key: string, value: string) => {
this.onModifyQueries({ type: 'ADD_FILTER_OUT', options: { key, value } });
};
@ -201,6 +233,9 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
makeAbsoluteTime();
};
/**
* Used by Logs details.
*/
onModifyQueries = (action: QueryFixAction) => {
const modifier = async (query: DataQuery, modification: QueryFixAction) => {
const { datasource } = query;
@ -208,6 +243,12 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
return query;
}
const ds = await getDataSourceSrv().get(datasource);
if (hasToggleableQueryFiltersSupport(ds) && config.featureToggles.toggleLabelsInLogsUI) {
return ds.toggleQueryFilter(query, {
type: modification.type === 'ADD_FILTER' ? 'FILTER_FOR' : 'FILTER_OUT',
options: modification.options ?? {},
});
}
if (ds.modifyQuery) {
return ds.modifyQuery(query, modification);
} else {
@ -369,6 +410,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
eventBus={this.logsEventBus}
splitOpenFn={this.onSplitOpen('logs')}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
/>
);
}

View File

@ -159,6 +159,7 @@ describe('Logs', () => {
return [];
}}
eventBus={new EventBusSrv()}
isFilterLabelActive={jest.fn()}
logsFrames={[testDataFrame]}
{...partialProps}
/>
@ -222,6 +223,7 @@ describe('Logs', () => {
return [];
}}
eventBus={new EventBusSrv()}
isFilterLabelActive={jest.fn()}
/>
);
const button = screen.getByRole('button', {
@ -264,6 +266,7 @@ describe('Logs', () => {
return [];
}}
eventBus={new EventBusSrv()}
isFilterLabelActive={jest.fn()}
/>
);
@ -310,6 +313,7 @@ describe('Logs', () => {
return [];
}}
eventBus={new EventBusSrv()}
isFilterLabelActive={jest.fn()}
/>
);

View File

@ -95,6 +95,7 @@ interface Props extends Themeable2 {
eventBus: EventBus;
panelState?: ExplorePanelsState;
scrollElement?: HTMLDivElement;
isFilterLabelActive: (key: string, value: string) => Promise<boolean>;
logsFrames?: DataFrame[];
range: TimeRange;
}
@ -703,6 +704,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
onPermalinkClick={this.onPermalinkClick}
permalinkedRowId={this.props.panelState?.logs?.id}
scrollIntoView={this.scrollIntoView}
isFilterLabelActive={this.props.isFilterLabelActive}
/>
</div>
)}

View File

@ -52,6 +52,7 @@ interface LogsContainerProps extends PropsFromRedux {
eventBus: EventBus;
splitOpenFn: SplitOpen;
scrollElement?: HTMLDivElement;
isFilterLabelActive: (key: string, value: string) => Promise<boolean>;
}
class LogsContainer extends PureComponent<LogsContainerProps> {
@ -216,6 +217,7 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
panelState={this.props.panelState}
logsFrames={this.props.logsFrames}
scrollElement={scrollElement}
isFilterLabelActive={this.props.isFilterLabelActive}
range={range}
/>
</LogsCrossFadeTransition>

View File

@ -26,6 +26,7 @@ export interface Props extends Themeable2 {
displayedFields?: string[];
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
isFilterLabelActive?: (key: string, value: string) => Promise<boolean>;
}
class UnThemedLogDetails extends PureComponent<Props> {
@ -103,6 +104,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage}
displayedFields={displayedFields}
disableActions={false}
isFilterLabelActive={this.props.isFilterLabelActive}
/>
);
})}
@ -123,6 +125,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
row={row}
app={app}
disableActions={false}
isFilterLabelActive={this.props.isFilterLabelActive}
/>
);
})}

View File

@ -58,11 +58,18 @@ describe('LogDetailsRow', () => {
it('should render filter label button', () => {
setup();
expect(screen.getAllByRole('button', { name: 'Filter for value' })).toHaveLength(1);
expect(screen.queryByRole('button', { name: 'Remove filter' })).not.toBeInTheDocument();
});
it('should render filter out label button', () => {
setup();
expect(screen.getAllByRole('button', { name: 'Filter out value' })).toHaveLength(1);
});
it('should render remove filter button', async () => {
setup({
isFilterLabelActive: jest.fn().mockResolvedValue(true),
});
expect(await screen.findByRole('button', { name: 'Remove filter' })).toBeInTheDocument();
});
});
describe('if props is not a label', () => {

View File

@ -1,17 +1,15 @@
import { css, cx } from '@emotion/css';
import { isEqual } from 'lodash';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import React, { PureComponent, useState } from 'react';
import { CoreApp, Field, GrafanaTheme2, LinkModel, LogLabelStatsModel, LogRowModel } from '@grafana/data';
import { CoreApp, Field, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel, LogRowModel } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { ClipboardButton, DataLinkButton, IconButton, Themeable2, withTheme2 } from '@grafana/ui';
import { LogLabelStats } from './LogLabelStats';
import { getLogRowStyles } from './getLogRowStyles';
//Components
export interface Props extends Themeable2 {
parsedValues: string[];
parsedKeys: string[];
@ -27,6 +25,7 @@ export interface Props extends Themeable2 {
onClickHideField?: (key: string) => void;
row: LogRowModel;
app?: CoreApp;
isFilterLabelActive?: (key: string, value: string) => Promise<boolean>;
}
interface State {
@ -134,6 +133,14 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
});
};
isFilterLabelActive = async () => {
const { isFilterLabelActive, parsedKeys, parsedValues } = this.props;
if (isFilterLabelActive) {
return await isFilterLabelActive(parsedKeys[0], parsedValues[0]);
}
return false;
};
filterLabel = () => {
const { onClickFilterLabel, parsedKeys, parsedValues, row } = this.props;
if (onClickFilterLabel) {
@ -267,7 +274,7 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
<td className={style.logsDetailsIcon}>
<div className={styles.buttonRow}>
{hasFilteringFunctionality && (
<IconButton name="search-plus" tooltip="Filter for value" onClick={this.filterLabel} />
<AsyncIconButton name="search-plus" onClick={this.filterLabel} isActive={this.isFilterLabelActive} />
)}
{hasFilteringFunctionality && (
<IconButton name="search-minus" tooltip="Filter out value" onClick={this.filterOutLabel} />
@ -330,5 +337,28 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}
}
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
name: IconName;
isActive(): Promise<boolean>;
}
const AsyncIconButton = ({ isActive, ...rest }: AsyncIconButtonProps) => {
const [active, setActive] = useState(false);
/**
* We purposely want to run this on every render to allow the active state to be updated
* when log details remains open between updates.
*/
isActive().then(setActive);
return (
<IconButton
{...rest}
variant={active ? 'primary' : undefined}
tooltip={active ? 'Remove filter' : 'Filter for value'}
/>
);
};
export const LogDetailsRow = withTheme2(UnThemedLogDetailsRow);
LogDetailsRow.displayName = 'LogDetailsRow';

View File

@ -42,6 +42,7 @@ interface Props extends Themeable2 {
styles: LogRowStyles;
permalinkedRowId?: string;
scrollIntoView?: (element: HTMLElement) => void;
isFilterLabelActive?: (key: string, value: string) => Promise<boolean>;
onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinned?: boolean;
@ -274,6 +275,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
displayedFields={displayedFields}
app={app}
styles={styles}
isFilterLabelActive={this.props.isFilterLabelActive}
/>
)}
</>

View File

@ -50,6 +50,7 @@ export interface Props extends Themeable2 {
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
permalinkedRowId?: string;
scrollIntoView?: (element: HTMLElement) => void;
isFilterLabelActive?: (key: string, value: string) => Promise<boolean>;
pinnedRowId?: string;
}
@ -144,6 +145,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive}
{...rest}
/>
))}
@ -164,6 +166,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive}
{...rest}
/>
))}

View File

@ -1185,7 +1185,6 @@ describe('modifyQuery', () => {
let ds: ElasticDatasource;
beforeEach(() => {
ds = getTestContext().ds;
config.featureToggles.elasticToggleableFilters = true;
});
describe('with empty query', () => {
let query: ElasticsearchQuery;
@ -1199,24 +1198,12 @@ describe('modifyQuery', () => {
);
});
it('should toggle the filter', () => {
query.query = 'foo:"bar"';
expect(ds.modifyQuery(query, { type: 'ADD_FILTER', options: { key: 'foo', value: 'bar' } }).query).toBe('');
});
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 remove a positive filter to add a negative filter', () => {
query.query = 'foo:"bar"';
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);
});
@ -1244,29 +1231,82 @@ describe('modifyQuery', () => {
expect(ds.modifyQuery(query, { type: 'unknown', options: { key: 'foo', value: 'bar' } }).query).toBe(query.query);
});
});
});
describe('legacy behavior', () => {
describe('toggleQueryFilter', () => {
let ds: ElasticDatasource;
beforeEach(() => {
ds = getTestContext().ds;
config.featureToggles.elasticToggleableFilters = true;
});
describe('with empty query', () => {
let query: ElasticsearchQuery;
beforeEach(() => {
config.featureToggles.elasticToggleableFilters = false;
query = { query: '', refId: 'A' };
});
it('should not modify other filters in the query', () => {
expect(
ds.modifyQuery(
{ query: 'test:"value"', refId: 'A' },
{ type: 'ADD_FILTER', options: { key: 'test', value: 'value' } }
).query
).toBe('test:"value"');
expect(
ds.modifyQuery(
{ query: 'test:"value"', refId: 'A' },
{ type: 'ADD_FILTER_OUT', options: { key: 'test', value: 'value' } }
).query
).toBe('test:"value" AND -test:"value"');
it('should add the filter', () => {
expect(ds.toggleQueryFilter(query, { type: 'FILTER_FOR', options: { key: 'foo', value: 'bar' } }).query).toBe(
'foo:"bar"'
);
});
it('should toggle the filter', () => {
query.query = 'foo:"bar"';
expect(ds.toggleQueryFilter(query, { type: 'FILTER_FOR', options: { key: 'foo', value: 'bar' } }).query).toBe('');
});
it('should add the negative filter', () => {
expect(ds.toggleQueryFilter(query, { type: 'FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'-foo:"bar"'
);
});
it('should remove a positive filter to add a negative filter', () => {
query.query = 'foo:"bar"';
expect(ds.toggleQueryFilter(query, { type: 'FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'-foo:"bar"'
);
});
});
describe('with non-empty query', () => {
let query: ElasticsearchQuery;
beforeEach(() => {
query = { query: 'test:"value"', refId: 'A' };
});
it('should add the filter', () => {
expect(ds.toggleQueryFilter(query, { type: 'FILTER_FOR', options: { key: 'foo', value: 'bar' } }).query).toBe(
'test:"value" AND foo:"bar"'
);
});
it('should add the negative filter', () => {
expect(ds.toggleQueryFilter(query, { type: 'FILTER_OUT', options: { key: 'foo', value: 'bar' } }).query).toBe(
'test:"value" AND -foo:"bar"'
);
});
});
});
describe('addAdHocFilters', () => {
describe('queryHasFilter()', () => {
let ds: ElasticDatasource;
beforeEach(() => {
ds = getTestContext().ds;
});
it('inspects queries for filter presence', () => {
const query = { refId: 'A', query: 'grafana:"awesome"' };
expect(
ds.queryHasFilter(query, {
key: 'grafana',
value: 'awesome',
})
).toBe(true);
});
});
describe('addAdhocFilters', () => {
describe('with invalid filters', () => {
it('should filter out ad hoc filter without key', () => {
const { ds, templateSrv } = getTestContext();

View File

@ -32,6 +32,9 @@ import {
toUtc,
AnnotationEvent,
FieldType,
DataSourceWithToggleableQueryFiltersSupport,
QueryFilterOptions,
ToggleFilterAction,
} from '@grafana/data';
import { DataSourceWithBackend, getDataSourceSrv, config, BackendSrvRequest } from '@grafana/runtime';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
@ -90,7 +93,8 @@ export class ElasticDatasource
implements
DataSourceWithLogsContextSupport,
DataSourceWithQueryImportSupport<ElasticsearchQuery>,
DataSourceWithSupplementaryQueriesSupport<ElasticsearchQuery>
DataSourceWithSupplementaryQueriesSupport<ElasticsearchQuery>,
DataSourceWithToggleableQueryFiltersSupport<ElasticsearchQuery>
{
basicAuth?: string;
withCredentials?: boolean;
@ -892,41 +896,48 @@ export class ElasticDatasource
return false;
}
toggleQueryFilter(query: ElasticsearchQuery, filter: ToggleFilterAction): ElasticsearchQuery {
let expression = query.query ?? '';
switch (filter.type) {
case 'FILTER_FOR': {
// This gives the user the ability to toggle a filter on and off.
expression = queryHasFilter(expression, filter.options.key, filter.options.value)
? removeFilterFromQuery(expression, filter.options.key, filter.options.value)
: addFilterToQuery(expression, filter.options.key, filter.options.value);
break;
}
case 'FILTER_OUT': {
// If the opposite filter is present, remove it before adding the new one.
if (queryHasFilter(expression, filter.options.key, filter.options.value)) {
expression = removeFilterFromQuery(expression, filter.options.key, filter.options.value);
}
expression = addFilterToQuery(expression, filter.options.key, filter.options.value, '-');
break;
}
}
return { ...query, query: expression };
}
queryHasFilter(query: ElasticsearchQuery, options: QueryFilterOptions): boolean {
let expression = query.query ?? '';
return queryHasFilter(expression, options.key, options.value);
}
modifyQuery(query: ElasticsearchQuery, action: QueryFixAction): ElasticsearchQuery {
if (!action.options) {
return query;
}
let expression = query.query ?? '';
if (config.featureToggles.elasticToggleableFilters) {
switch (action.type) {
case 'ADD_FILTER': {
// This gives the user the ability to toggle a filter on and off.
expression = queryHasFilter(expression, action.options.key, action.options.value)
? removeFilterFromQuery(expression, action.options.key, action.options.value)
: addFilterToQuery(expression, action.options.key, action.options.value);
break;
}
case 'ADD_FILTER_OUT': {
// If the opposite filter is present, remove it before adding the new one.
if (queryHasFilter(expression, action.options.key, action.options.value)) {
expression = removeFilterFromQuery(expression, action.options.key, action.options.value);
}
expression = addFilterToQuery(expression, action.options.key, action.options.value, '-');
break;
}
switch (action.type) {
case 'ADD_FILTER': {
expression = addFilterToQuery(expression, action.options.key, action.options.value);
break;
}
} else {
// Legacy behavior
switch (action.type) {
case 'ADD_FILTER': {
expression = addFilterToQuery(expression, action.options.key, action.options.value);
break;
}
case 'ADD_FILTER_OUT': {
expression = addFilterToQuery(expression, action.options.key, action.options.value, '-');
break;
}
case 'ADD_FILTER_OUT': {
expression = addFilterToQuery(expression, action.options.key, action.options.value, '-');
break;
}
}

View File

@ -13,6 +13,7 @@ import {
dateTime,
FieldType,
SupplementaryQueryType,
ToggleFilterAction,
} from '@grafana/data';
import {
BackendSrv,
@ -637,55 +638,22 @@ describe('LokiDatasource', () => {
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz", job="grafana"}[5m])');
});
describe('and the filter is already present', () => {
it('then it should remove the filter', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz", job="grafana"}' };
describe('and query has parser', () => {
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"}');
expect(result.expr).toEqual('{bar="baz"} | logfmt | job=`grafana`');
});
it('then it should remove the filter with escaped value', () => {
const query: LokiQuery = { refId: 'A', expr: '{place="luna", job="\\"grafana/data\\""}' };
const action = { options: { key: 'job', value: '"grafana/data"' }, type: 'ADD_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{place="luna"}');
});
});
});
describe('and query has parser', () => {
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt | job=`grafana`');
});
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"} | logfmt [5m])' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz"} | logfmt | job=`grafana` [5m])');
});
describe('and the filter is already present', () => {
it('then it should remove the filter', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt | job="grafana"' };
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"} | logfmt [5m])' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt');
expect(result.expr).toEqual('rate({bar="baz"} | logfmt | job=`grafana` [5m])');
});
});
});
@ -724,12 +692,160 @@ describe('LokiDatasource', () => {
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz", job!="grafana"}[5m])');
});
describe('and query has parser', () => {
let ds: LokiDatasource;
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
});
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt | job!=`grafana`');
});
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"} | logfmt [5m])' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz"} | logfmt | job!=`grafana` [5m])');
});
});
});
});
});
describe('toggleQueryFilter', () => {
describe('when called with FILTER', () => {
let ds: LokiDatasource;
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
});
describe('and query has no parser', () => {
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"}' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz", job="grafana"}');
});
it('then the correctly escaped label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"}' };
const action: ToggleFilterAction = { options: { key: 'job', value: '\\test' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz", job="\\\\test"}');
});
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"}[5m])' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz", job="grafana"}[5m])');
});
describe('and the filter is already present', () => {
it('then it should remove the filter', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz", job="grafana"}' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"}');
});
it('then it should remove the filter with escaped value', () => {
const query: LokiQuery = { refId: 'A', expr: '{place="luna", job="\\"grafana/data\\""}' };
const action: ToggleFilterAction = { options: { key: 'job', value: '"grafana/data"' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{place="luna"}');
});
});
});
describe('and query has parser', () => {
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt | job=`grafana`');
});
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"} | logfmt [5m])' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz"} | logfmt | job=`grafana` [5m])');
});
describe('and the filter is already present', () => {
it('then it should remove the filter', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt | job="grafana"' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_FOR' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt');
});
});
});
});
describe('when called with FILTER_OUT', () => {
describe('and query has no parser', () => {
let ds: LokiDatasource;
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
});
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"}' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz", job!="grafana"}');
});
it('then the correctly escaped label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"}' };
const action: ToggleFilterAction = { options: { key: 'job', value: '"test' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz", job!="\\"test"}');
});
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"}[5m])' };
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz", job!="grafana"}[5m])');
});
describe('and the opposite filter is present', () => {
it('then it should remove the filter', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz", job="grafana"}' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz", job!="grafana"}');
@ -745,8 +861,8 @@ describe('LokiDatasource', () => {
it('then the correct label should be added for logs query', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt | job!=`grafana`');
@ -754,8 +870,8 @@ describe('LokiDatasource', () => {
it('then the correct label should be added for metrics query', () => {
const query: LokiQuery = { refId: 'A', expr: 'rate({bar="baz"} | logfmt [5m])' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('rate({bar="baz"} | logfmt | job!=`grafana` [5m])');
@ -764,8 +880,8 @@ describe('LokiDatasource', () => {
describe('and the filter is already present', () => {
it('then it should remove the filter', () => {
const query: LokiQuery = { refId: 'A', expr: '{bar="baz"} | logfmt | job="grafana"' };
const action = { options: { key: 'job', value: 'grafana' }, type: 'ADD_FILTER_OUT' };
const result = ds.modifyQuery(query, action);
const action: ToggleFilterAction = { options: { key: 'job', value: 'grafana' }, type: 'FILTER_OUT' };
const result = ds.toggleQueryFilter(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('{bar="baz"} | logfmt | job!=`grafana`');
@ -1371,6 +1487,22 @@ describe('showContextToggle()', () => {
});
});
describe('queryHasFilter()', () => {
let ds: LokiDatasource;
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
});
it('inspects queries for filter presence', () => {
const query = { refId: 'A', expr: '{grafana="awesome"}' };
expect(
ds.queryHasFilter(query, {
key: 'grafana',
value: 'awesome',
})
).toBe(true);
});
});
function assertAdHocFilters(query: string, expectedResults: string, ds: LokiDatasource) {
const lokiQuery: LokiQuery = { refId: 'A', expr: query };
const result = ds.addAdHocFilters(lokiQuery.expr);

View File

@ -33,6 +33,9 @@ import {
SupplementaryQueryOptions,
TimeRange,
LogRowContextOptions,
DataSourceWithToggleableQueryFiltersSupport,
ToggleFilterAction,
QueryFilterOptions,
} from '@grafana/data';
import { BackendSrvRequest, config, DataSourceWithBackend, FetchError } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
@ -131,7 +134,8 @@ export class LokiDatasource
DataSourceWithLogsContextSupport,
DataSourceWithSupplementaryQueriesSupport<LokiQuery>,
DataSourceWithQueryImportSupport<LokiQuery>,
DataSourceWithQueryExportSupport<LokiQuery>
DataSourceWithQueryExportSupport<LokiQuery>,
DataSourceWithToggleableQueryFiltersSupport<LokiQuery>
{
private streams = new LiveStreams();
languageProvider: LanguageProvider;
@ -611,33 +615,61 @@ export class LokiDatasource
return escapedValues.join('|');
}
modifyQuery(query: LokiQuery, action: QueryFixAction): LokiQuery {
toggleQueryFilter(query: LokiQuery, filter: ToggleFilterAction): LokiQuery {
let expression = query.expr ?? '';
switch (action.type) {
case 'ADD_FILTER': {
if (action.options?.key && action.options?.value) {
const value = escapeLabelValueInSelector(action.options.value);
switch (filter.type) {
case 'FILTER_FOR': {
if (filter.options?.key && filter.options?.value) {
const value = escapeLabelValueInSelector(filter.options.value);
// This gives the user the ability to toggle a filter on and off.
expression = queryHasFilter(expression, action.options.key, '=', value)
? removeLabelFromQuery(expression, action.options.key, '=', value)
: addLabelToQuery(expression, action.options.key, '=', value);
expression = queryHasFilter(expression, filter.options.key, '=', value)
? removeLabelFromQuery(expression, filter.options.key, '=', value)
: addLabelToQuery(expression, filter.options.key, '=', value);
}
break;
}
case 'ADD_FILTER_OUT': {
if (action.options?.key && action.options?.value) {
const value = escapeLabelValueInSelector(action.options.value);
case 'FILTER_OUT': {
if (filter.options?.key && filter.options?.value) {
const value = escapeLabelValueInSelector(filter.options.value);
/**
* If there is a filter with the same key and value, remove it.
* This prevents the user from seeing no changes in the query when they apply
* this filter.
*/
if (queryHasFilter(expression, action.options.key, '=', value)) {
expression = removeLabelFromQuery(expression, action.options.key, '=', value);
if (queryHasFilter(expression, filter.options.key, '=', value)) {
expression = removeLabelFromQuery(expression, filter.options.key, '=', value);
}
expression = addLabelToQuery(expression, filter.options.key, '!=', value);
}
break;
}
default:
break;
}
return { ...query, expr: expression };
}
queryHasFilter(query: LokiQuery, filter: QueryFilterOptions): boolean {
let expression = query.expr ?? '';
return queryHasFilter(expression, filter.key, '=', filter.value);
}
modifyQuery(query: LokiQuery, action: QueryFixAction): LokiQuery {
let expression = query.expr ?? '';
switch (action.type) {
case 'ADD_FILTER': {
if (action.options?.key && action.options?.value) {
const value = escapeLabelValueInSelector(action.options.value);
expression = addLabelToQuery(expression, action.options.key, '=', value);
}
break;
}
case 'ADD_FILTER_OUT': {
if (action.options?.key && action.options?.value) {
const value = escapeLabelValueInSelector(action.options.value);
expression = addLabelToQuery(expression, action.options.key, '!=', value);
}
break;

View File

@ -16,6 +16,7 @@ import {
Selector,
UnwrapExpr,
String,
PipelineStage,
} from '@grafana/lezer-logql';
import { QueryBuilderLabelFilter } from '../prometheus/querybuilder/shared/types';
@ -70,7 +71,7 @@ export function removeLabelFromQuery(query: string, key: string, operator: strin
function removeLabelFilter(query: string, matcher: SyntaxNode): string {
const pipelineStage = matcher.parent?.parent;
if (!pipelineStage) {
if (!pipelineStage || pipelineStage.type.id !== PipelineStage) {
return query;
}
return (query.substring(0, pipelineStage.from) + query.substring(pipelineStage.to)).trim();
@ -96,7 +97,7 @@ function removeSelector(query: string, matcher: SyntaxNode): string {
return prefix + modeller.renderQuery(matchVisQuery.query) + suffix;
}
function getMatchersWithFilter(query: string, key: string, operator: string, value: string): SyntaxNode[] {
function getMatchersWithFilter(query: string, label: string, operator: string, value: string): SyntaxNode[] {
const tree = parser.parse(query);
const matchers: SyntaxNode[] = [];
tree.iterate({
@ -114,7 +115,7 @@ function getMatchersWithFilter(query: string, key: string, operator: string, val
return false;
}
const labelName = query.substring(labelNode.from, labelNode.to);
if (labelName !== key) {
if (labelName !== label) {
return false;
}
const labelValue = query.substring(valueNode.from, valueNode.to);