diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts index bf6ccdcb17b..b4da1eac77b 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts @@ -4,6 +4,7 @@ import { createRangeOperation, createRangeOperationWithGrouping, getLineFilterRenderer, + isConflictingFilter, labelFilterRenderer, } from './operationUtils'; import { LokiVisualQueryOperationCategory } from './types'; @@ -184,3 +185,23 @@ describe('labelFilterRenderer', () => { ); }); }); + +describe('isConflictingFilter', () => { + it('should return true if the operation conflict with another label filter', () => { + const operation = { id: '__label_filter', params: ['abc', '!=', '123'] }; + const queryOperations = [ + { id: '__label_filter', params: ['abc', '=', '123'] }, + { id: '__label_filter', params: ['abc', '!=', '123'] }, + ]; + expect(isConflictingFilter(operation, queryOperations)).toBe(true); + }); + + it("should return false if the operation doesn't conflict with another label filter", () => { + const operation = { id: '__label_filter', params: ['abc', '=', '123'] }; + const queryOperations = [ + { id: '__label_filter', params: ['abc', '=', '123'] }, + { id: '__label_filter', params: ['abc', '=', '123'] }, + ]; + expect(isConflictingFilter(operation, queryOperations)).toBe(false); + }); +}); diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts index 3a5c486f2b7..0a26a20e4b7 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts @@ -157,6 +157,32 @@ export function labelFilterRenderer(model: QueryBuilderOperation, def: QueryBuil return `${innerExpr} | ${model.params[0]} ${model.params[1]} \`${model.params[2]}\``; } +export function isConflictingFilter( + operation: QueryBuilderOperation, + queryOperations: QueryBuilderOperation[] +): boolean { + const operationIsNegative = operation.params[1].toString().startsWith('!'); + + const candidates = queryOperations.filter( + (queryOperation) => + queryOperation.id === LokiOperationId.LabelFilter && + queryOperation.params[0] === operation.params[0] && + queryOperation.params[2] === operation.params[2] + ); + + const conflict = candidates.some((candidate) => { + if (operationIsNegative && candidate.params[1].toString().startsWith('!') === false) { + return true; + } + if (operationIsNegative === false && candidate.params[1].toString().startsWith('!')) { + return true; + } + return false; + }); + + return conflict; +} + export function pipelineRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { return `${innerExpr} | ${model.id}`; } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx index 82413e2ffb4..c7c400273ec 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx @@ -4,13 +4,15 @@ import React, { useState } from 'react'; import { SelectableValue, toOption } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { AccessoryButton, InputGroup } from '@grafana/experimental'; -import { Select } from '@grafana/ui'; +import { InlineField, Select } from '@grafana/ui'; +import { isConflictingSelector } from './operationUtils'; import { QueryBuilderLabelFilter } from './types'; export interface Props { defaultOp: string; item: Partial; + items: Array>; onChange: (value: QueryBuilderLabelFilter) => void; onGetLabelNames: (forLabel: Partial) => Promise; onGetLabelValues: (forLabel: Partial) => Promise; @@ -21,6 +23,7 @@ export interface Props { export function LabelFilterItem({ item, + items, defaultOp, onChange, onDelete, @@ -35,6 +38,7 @@ export function LabelFilterItem({ isLoadingLabelNames?: boolean; isLoadingLabelValues?: boolean; }>({}); + const CONFLICTING_LABEL_FILTER_ERROR_MESSAGE = 'You have conflicting label filters'; const isMultiSelect = (operator = item.op) => { return operators.find((op) => op.label === operator)?.isMultiValue; @@ -58,94 +62,99 @@ export function LabelFilterItem({ return uniqBy([...selectedOptions, ...labelValues], 'value'); }; + const isConflicting = isConflictingSelector(item, items); + return (
- - { + setState({ isLoadingLabelNames: true }); + const labelNames = await onGetLabelNames(item); + setState({ labelNames, isLoadingLabelNames: undefined }); + }} + isLoading={state.isLoadingLabelNames} + options={state.labelNames} + onChange={(change) => { + if (change.label) { + onChange({ + ...item, + op: item.op ?? defaultOp, + label: change.label, + } as unknown as QueryBuilderLabelFilter); + } + }} + invalid={isConflicting || invalidLabel} + /> - { + if (change.value != null) { + onChange({ + ...item, + op: change.value, + value: isMultiSelect(change.value) ? item.value : getSelectOptionsFromString(item?.value)[0], + } as unknown as QueryBuilderLabelFilter); + } + }} + invalid={isConflicting} + /> - - - + allowCustomValue + onOpenMenu={async () => { + setState({ isLoadingLabelValues: true }); + const labelValues = await onGetLabelValues(item); + setState({ + ...state, + labelValues, + isLoadingLabelValues: undefined, + }); + }} + isMulti={isMultiSelect()} + isLoading={state.isLoadingLabelValues} + options={getOptions()} + onChange={(change) => { + if (change.value) { + onChange({ + ...item, + value: change.value, + op: item.op ?? defaultOp, + } as unknown as QueryBuilderLabelFilter); + } else { + const changes = change + .map((change: any) => { + return change.label; + }) + .join('|'); + onChange({ ...item, value: changes, op: item.op ?? defaultOp } as unknown as QueryBuilderLabelFilter); + } + }} + invalid={isConflicting || invalidValue} + /> + + +
); } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx index cab849567b4..5d21651cd70 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx @@ -62,6 +62,7 @@ export function LabelFilters({ renderItem={(item: Partial, onChangeItem, onDelete) => ( { + if (isDragging) { + return undefined; + } + + return isConflicting ? true : undefined; + }; + return ( - {(provided) => ( -
( + - -
{operationElements}
- {restParam} - {index < query.operations.length - 1 && ( -
-
-
-
- )} -
+
+ +
{operationElements}
+ {restParam} + {index < query.operations.length - 1 && ( +
+
+
+
+ )} +
+ )} ); @@ -220,6 +245,9 @@ function callParamChangedThenOnChange( const getStyles = (theme: GrafanaTheme2) => { return { + error: css({ + marginBottom: theme.spacing(1), + }), card: css({ background: theme.colors.background.primary, border: `1px solid ${theme.colors.border.medium}`, @@ -231,6 +259,10 @@ const getStyles = (theme: GrafanaTheme2) => { position: 'relative', transition: 'all 0.5s ease-in 0s', }), + cardError: css({ + boxShadow: `0px 0px 4px 0px ${theme.colors.warning.main}`, + border: `1px solid ${theme.colors.warning.main}`, + }), cardHighlight: css({ boxShadow: `0px 0px 4px 0px ${theme.colors.primary.border}`, border: `1px solid ${theme.colors.primary.border}`, diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts index 72a9c178545..03d45ca8e52 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts @@ -1,4 +1,8 @@ -import { createAggregationOperation, createAggregationOperationWithParam } from './operationUtils'; +import { + createAggregationOperation, + createAggregationOperationWithParam, + isConflictingSelector, +} from './operationUtils'; describe('createAggregationOperation', () => { it('returns correct aggregation definitions with overrides', () => { @@ -164,3 +168,32 @@ describe('createAggregationOperationWithParams', () => { ).toBe('test_aggregation by(source, place) ("5", rate({place="luna"} |= `` [5m]))'); }); }); + +describe('isConflictingSelector', () => { + it('returns true if selector is conflicting', () => { + const newLabel = { label: 'job', op: '!=', value: 'tns/app' }; + const labels = [ + { label: 'job', op: '=', value: 'tns/app' }, + { label: 'job', op: '!=', value: 'tns/app' }, + ]; + expect(isConflictingSelector(newLabel, labels)).toBe(true); + }); + + it('returns false if selector is not complete', () => { + const newLabel = { label: 'job', op: '', value: 'tns/app' }; + const labels = [ + { label: 'job', op: '=', value: 'tns/app' }, + { label: 'job', op: '', value: 'tns/app' }, + ]; + expect(isConflictingSelector(newLabel, labels)).toBe(false); + }); + + it('returns false if selector is not conflicting', () => { + const newLabel = { label: 'host', op: '=', value: 'docker-desktop' }; + const labels = [ + { label: 'job', op: '=', value: 'tns/app' }, + { label: 'host', op: '=', value: 'docker-desktop' }, + ]; + expect(isConflictingSelector(newLabel, labels)).toBe(false); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts index cc4dbcebd9a..cdd7988b2ea 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts @@ -7,6 +7,7 @@ import { LabelParamEditor } from '../components/LabelParamEditor'; import { PromVisualQueryOperationCategory } from '../types'; import { + QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, QueryBuilderOperationParamDef, @@ -321,3 +322,34 @@ export function getOnLabelAddedHandler(changeToOperationId: string) { return op; }; } + +export function isConflictingSelector( + newLabel: Partial, + labels: Array> +): boolean { + if (!newLabel.label || !newLabel.op || !newLabel.value) { + return false; + } + + if (labels.length < 2) { + return false; + } + + const operationIsNegative = newLabel.op.toString().startsWith('!'); + + const candidates = labels.filter( + (label) => label.label === newLabel.label && label.value === newLabel.value && label.op !== newLabel.op + ); + + const conflict = candidates.some((candidate) => { + if (operationIsNegative && candidate?.op?.toString().startsWith('!') === false) { + return true; + } + if (operationIsNegative === false && candidate?.op?.toString().startsWith('!')) { + return true; + } + return false; + }); + + return conflict; +}