FieldMatchers: Add match by value (reducer) (#64477)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin 2023-03-10 17:17:29 -06:00 committed by GitHub
parent 75f89e67af
commit 18e3e0ca8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 349 additions and 49 deletions

View File

@ -1162,6 +1162,9 @@ exports[`better eslint`] = {
"packages/grafana-ui/src/components/Logs/logParser.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -11,6 +11,7 @@ import {
import { Registry } from '../utils/Registry';
import { getFieldTypeMatchers } from './matchers/fieldTypeMatcher';
import { fieldValueMatcherInfo } from './matchers/fieldValueMatcher';
import { getFieldNameMatchers, getFrameNameMatchers } from './matchers/nameMatcher';
import { getFieldPredicateMatchers, getFramePredicateMatchers } from './matchers/predicates';
import { getRefIdMatchers } from './matchers/refIdMatcher';
@ -21,6 +22,8 @@ import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatcher
import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers';
import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers';
export { type FieldValueMatcherConfig } from './matchers/fieldValueMatcher';
/**
* Registry that contains all of the built in field matchers.
* @public
@ -31,6 +34,7 @@ export const fieldMatchers = new Registry<FieldMatcherInfo>(() => {
...getFieldTypeMatchers(), // by type
...getFieldNameMatchers(), // by name
...getSimpleFieldMatchers(), // first
fieldValueMatcherInfo, // reduce field (all null/zero)
];
});

View File

@ -0,0 +1,24 @@
import { ComparisonOperation } from '@grafana/schema';
import { compareValues } from './compareValues';
describe('compare values', () => {
it('simple comparisons', () => {
expect(compareValues(null, ComparisonOperation.EQ, null)).toEqual(true);
expect(compareValues(null, ComparisonOperation.NEQ, null)).toEqual(false);
expect(compareValues(1, ComparisonOperation.GT, 2)).toEqual(false);
expect(compareValues(2, ComparisonOperation.GT, 1)).toEqual(true);
expect(compareValues(1, ComparisonOperation.GTE, 2)).toEqual(false);
expect(compareValues(2, ComparisonOperation.GTE, 1)).toEqual(true);
expect(compareValues(1, ComparisonOperation.LT, 2)).toEqual(true);
expect(compareValues(2, ComparisonOperation.LT, 1)).toEqual(false);
expect(compareValues(1, ComparisonOperation.LTE, 2)).toEqual(true);
expect(compareValues(2, ComparisonOperation.LTE, 1)).toEqual(false);
expect(compareValues(1, ComparisonOperation.EQ, 1)).toEqual(true);
expect(compareValues(1, ComparisonOperation.LTE, 1)).toEqual(true);
expect(compareValues(1, ComparisonOperation.GTE, 1)).toEqual(true);
});
});

View File

@ -0,0 +1,42 @@
import { ComparisonOperation } from '@grafana/schema';
/**
* Compare two values
*
* @internal -- not yet exported in `@grafana/data`
*/
export function compareValues(
left: string | number | boolean | null | undefined,
op: ComparisonOperation,
right: string | number | boolean | null | undefined
) {
// Normalize null|undefined values
if (left == null || right == null) {
if (left == null) {
left = 'null';
}
if (right == null) {
right = 'null';
}
if (op === ComparisonOperation.GTE || op === ComparisonOperation.LTE) {
op = ComparisonOperation.EQ; // check for equality
}
}
switch (op) {
case ComparisonOperation.EQ:
return `${left}` === `${right}`;
case ComparisonOperation.NEQ:
return `${left}` !== `${right}`;
case ComparisonOperation.GT:
return left > right;
case ComparisonOperation.GTE:
return left >= right;
case ComparisonOperation.LT:
return left < right;
case ComparisonOperation.LTE:
return left <= right;
default:
return false;
}
}

View File

@ -0,0 +1,60 @@
import { ComparisonOperation } from '@grafana/schema';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { FieldMatcher } from '../../types';
import { DataFrame, FieldType } from '../../types/dataFrame';
import { ReducerID } from '../fieldReducer';
import { fieldValueMatcherInfo } from './fieldValueMatcher';
function getMatchingFieldNames(matcher: FieldMatcher, frame: DataFrame): string[] {
return frame.fields.filter((f) => matcher(f, frame, [])).map((f) => f.name);
}
describe('Field Value Matcher', () => {
const testFrame = toDataFrame({
fields: [
{ name: '01', type: FieldType.number, values: [0, 1] },
{ name: '02', type: FieldType.number, values: [0, 2] },
{ name: '03', type: FieldType.number, values: [0, 3] },
{ name: 'null', type: FieldType.number, values: [null, null] },
],
});
it('match nulls', () => {
expect(
getMatchingFieldNames(
fieldValueMatcherInfo.get({
reducer: ReducerID.allIsNull,
}),
testFrame
)
).toEqual(['null']);
});
it('match equals', () => {
expect(
getMatchingFieldNames(
fieldValueMatcherInfo.get({
reducer: ReducerID.lastNotNull,
op: ComparisonOperation.EQ,
value: 1,
}),
testFrame
)
).toEqual(['01']);
});
it('match equals', () => {
expect(
getMatchingFieldNames(
fieldValueMatcherInfo.get({
reducer: ReducerID.lastNotNull,
op: ComparisonOperation.GTE,
value: 2,
}),
testFrame
)
).toEqual(['02', '03']);
});
});

View File

@ -0,0 +1,58 @@
import { ComparisonOperation } from '@grafana/schema';
import { Field, DataFrame } from '../../types/dataFrame';
import { FieldMatcherInfo } from '../../types/transformations';
import { reduceField, ReducerID } from '../fieldReducer';
import { compareValues } from './compareValues';
import { FieldMatcherID } from './ids';
export interface FieldValueMatcherConfig {
reducer: ReducerID;
op?: ComparisonOperation;
value?: number; // or string?
}
// This should move to a utility function on the reducer registry
function isBooleanReducer(r: ReducerID) {
return r === ReducerID.allIsNull || r === ReducerID.allIsZero;
}
export const fieldValueMatcherInfo: FieldMatcherInfo<FieldValueMatcherConfig> = {
id: FieldMatcherID.byValue,
name: 'By value (reducer)',
description: 'Reduce a field to a single value and test for inclusion',
// This is added to overrides by default
defaultOptions: {
reducer: ReducerID.allIsZero,
op: ComparisonOperation.GTE,
value: 0,
},
get: (props) => {
if (!props || !props.reducer) {
return () => false;
}
let { reducer, op, value } = props;
const isBoolean = isBooleanReducer(reducer);
if (!op) {
op = ComparisonOperation.EQ;
}
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
const left = reduceField({
field,
reducers: [reducer],
})[reducer];
if (isBoolean) {
return Boolean(left); // boolean
}
return compareValues(left, op!, value);
};
},
getOptionsDisplayText: (props) => {
return `By value (${props.reducer})`;
},
};

View File

@ -24,6 +24,7 @@ export enum FieldMatcherID {
byRegexp = 'byRegexp',
byRegexpOrNames = 'byRegexpOrNames',
byFrameRefID = 'byFrameRefID',
byValue = 'byValue',
// byIndex = 'byIndex',
// byLabel = 'byLabel',
}

View File

@ -724,6 +724,15 @@ export interface TableColoredBackgroundCellOptions {
type: TableCellDisplayMode.ColorBackground;
}
/**
* Height of a table cell
*/
export enum TableCellHeight {
Lg = 'lg',
Md = 'md',
Sm = 'sm',
}
/**
* Table cell options. Each cell has a display mode
* and other potential options for that display.
@ -790,12 +799,15 @@ export enum LogsDedupStrategy {
}
/**
* Height of a table cell
* Compare two values
*/
export enum TableCellHeight {
Lg = 'lg',
Md = 'md',
Sm = 'sm',
export enum ComparisonOperation {
EQ = 'eq',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
NEQ = 'neq',
}
/**

View File

@ -255,5 +255,5 @@ Labels: {
[string]: string
} @cuetsy(kind="interface")
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum")
// Compare two values
ComparisonOperation: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" @cuetsy(kind="enum",memberNames="EQ|NEQ|LT|LTE|GT|GTE")

View File

@ -67,6 +67,9 @@ TableColoredBackgroundCellOptions: {
mode?: TableCellBackgroundDisplayMode
} @cuetsy(kind="interface")
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum")
// Table cell options. Each cell has a display mode
// and other potential options for that display.
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type")

View File

@ -0,0 +1,110 @@
import { css } from '@emotion/css';
import React, { useMemo, useCallback } from 'react';
import {
FieldMatcherID,
fieldMatchers,
FieldValueMatcherConfig,
fieldReducers,
ReducerID,
SelectableValue,
GrafanaTheme2,
} from '@grafana/data';
import { ComparisonOperation } from '@grafana/schema';
import { useStyles2 } from '../../themes';
import { Input } from '../Input/Input';
import { Select } from '../Select/Select';
import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types';
type Props = MatcherUIProps<FieldValueMatcherConfig>;
export const comparisonOperationOptions = [
{ label: '==', value: ComparisonOperation.EQ },
{ label: '!=', value: ComparisonOperation.NEQ },
{ label: '>', value: ComparisonOperation.GT },
{ label: '>=', value: ComparisonOperation.GTE },
{ label: '<', value: ComparisonOperation.LT },
{ label: '<=', value: ComparisonOperation.LTE },
];
// This should move to a utility function on the reducer registry
function isBooleanReducer(r: ReducerID) {
return r === ReducerID.allIsNull || r === ReducerID.allIsZero;
}
export const FieldValueMatcherEditor = ({ options, onChange }: Props) => {
const styles = useStyles2(getStyles);
const reducer = useMemo(() => fieldReducers.selectOptions([options?.reducer]), [options?.reducer]);
const onSetReducer = useCallback(
(selection: SelectableValue<string>) => {
return onChange({ ...options, reducer: selection.value! as ReducerID });
},
[options, onChange]
);
const onChangeOp = useCallback(
(v: SelectableValue<ComparisonOperation>) => {
return onChange({ ...options, op: v.value! });
},
[options, onChange]
);
const onChangeValue = useCallback(
(e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget.valueAsNumber;
return onChange({ ...options, value });
},
[options, onChange]
);
const opts = options ?? {};
const isBool = isBooleanReducer(options.reducer);
return (
<div className={styles.spot}>
<Select
value={reducer.current}
options={reducer.options}
onChange={onSetReducer}
placeholder="Select field reducer"
/>
{opts.reducer && !isBool && (
<>
<Select
value={comparisonOperationOptions.find((v) => v.value === opts.op)}
options={comparisonOperationOptions}
onChange={onChangeOp}
aria-label={'Comparison operator'}
width={19}
/>
<Input type="number" value={opts.value} onChange={onChangeValue} />
</>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
spot: css`
display: flex;
flex-direction: row;
align-items: center;
align-content: flex-end;
gap: 4px;
`,
};
};
export const fieldValueMatcherItem: FieldMatcherUIRegistryItem<FieldValueMatcherConfig> = {
id: FieldMatcherID.byValue,
component: FieldValueMatcherEditor,
matcher: fieldMatchers.get(FieldMatcherID.byValue),
name: 'Fields with values',
description: 'Set properties for fields with reducer condition',
optionsToLabel: (options) => `${options?.reducer} ${options?.op} ${options?.value}`,
};

View File

@ -4,6 +4,7 @@ import { fieldNameByRegexMatcherItem } from './FieldNameByRegexMatcherEditor';
import { fieldNameMatcherItem } from './FieldNameMatcherEditor';
import { fieldNamesMatcherItem } from './FieldNamesMatcherEditor';
import { fieldTypeMatcherItem } from './FieldTypeMatcherEditor';
import { fieldValueMatcherItem } from './FieldValueMatcher';
import { fieldsByFrameRefIdItem } from './FieldsByFrameRefIdMatcher';
import { FieldMatcherUIRegistryItem } from './types';
@ -13,4 +14,5 @@ export const fieldMatchersUI = new Registry<FieldMatcherUIRegistryItem<any>>(()
fieldTypeMatcherItem,
fieldsByFrameRefIdItem,
fieldNamesMatcherItem,
fieldValueMatcherItem,
]);

View File

@ -10,6 +10,7 @@ import {
DynamicConfigValue,
ConfigOverrideRule,
GrafanaTheme2,
fieldMatchers,
} from '@grafana/data';
import { fieldMatchersUI, useStyles2, ValuePicker } from '@grafana/ui';
import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
@ -46,13 +47,19 @@ export function getFieldOverrideCategories(
};
const onOverrideAdd = (value: SelectableValue<string>) => {
const info = fieldMatchers.get(value.value!);
if (!info) {
return;
}
props.onFieldConfigsChange({
...currentFieldConfig,
overrides: [
...currentFieldConfig.overrides,
{
matcher: {
id: value.value!,
id: info.id,
options: info.defaultOptions,
},
properties: [],
},

View File

@ -5,12 +5,14 @@ import { useObservable } from 'react-use';
import { Observable } from 'rxjs';
import { GrafanaTheme2, SelectableValue, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
import { ComparisonOperation } from '@grafana/schema';
import { Button, InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
import { comparisonOperationOptions } from '@grafana/ui/src/components/MatchersUI/FieldValueMatcher';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
import { DEFAULT_STYLE_RULE } from '../layers/data/geojsonLayer';
import { defaultStyleConfig, StyleConfig } from '../style/types';
import { ComparisonOperation, FeatureStyleConfig } from '../types';
import { FeatureStyleConfig } from '../types';
import { getUniqueFeatureValues, LayerContentInfo } from '../utils/getFeatures';
import { getSelectionInfo } from '../utils/selection';
@ -21,15 +23,6 @@ export interface StyleRuleEditorSettings {
layerInfo: Observable<LayerContentInfo>;
}
const comparators = [
{ label: '==', value: ComparisonOperation.EQ },
{ label: '!=', value: ComparisonOperation.NEQ },
{ label: '>', value: ComparisonOperation.GT },
{ label: '>=', value: ComparisonOperation.GTE },
{ label: '<', value: ComparisonOperation.LT },
{ label: '<=', value: ComparisonOperation.LTE },
];
type Props = StandardEditorProps<FeatureStyleConfig, any, unknown, StyleRuleEditorSettings>;
export const StyleRuleEditor = ({ value, onChange, item, context }: Props) => {
@ -148,8 +141,8 @@ export const StyleRuleEditor = ({ value, onChange, item, context }: Props) => {
</InlineField>
<InlineField className={styles.inline}>
<Select
value={comparators.find((v) => v.value === check.operation)}
options={comparators}
value={comparisonOperationOptions.find((v) => v.value === check.operation)}
options={comparisonOperationOptions}
onChange={onChangeComparison}
aria-label={'Comparison operator'}
width={8}

View File

@ -12,7 +12,7 @@ import VectorSource from 'ol/source/Vector';
import GeoJSON from 'ol/format/GeoJSON';
import { unByKey } from 'ol/Observable';
import { checkFeatureMatchesStyleRule } from '../../utils/checkFeatureMatchesStyleRule';
import { ComparisonOperation, FeatureRuleConfig, FeatureStyleConfig } from '../../types';
import { FeatureRuleConfig, FeatureStyleConfig } from '../../types';
import { Fill, Stroke, Style } from 'ol/style';
import { FeatureLike } from 'ol/Feature';
import { defaultStyleConfig, StyleConfig, StyleConfigState } from '../../style/types';
@ -24,6 +24,7 @@ import { map as rxjsmap, first } from 'rxjs/operators';
import { getLayerPropertyInfo } from '../../utils/getFeatures';
import { findField } from 'app/features/dimensions';
import { getStyleDimension, getPublicGeoJSONFiles } from '../../utils/utils';
import { ComparisonOperation } from '@grafana/schema';
export interface DynamicGeoJSONMapperConfig {
// URL for a geojson file

View File

@ -11,7 +11,7 @@ import VectorSource from 'ol/source/Vector';
import GeoJSON from 'ol/format/GeoJSON';
import { unByKey } from 'ol/Observable';
import { checkFeatureMatchesStyleRule } from '../../utils/checkFeatureMatchesStyleRule';
import { ComparisonOperation, FeatureRuleConfig, FeatureStyleConfig } from '../../types';
import { FeatureRuleConfig, FeatureStyleConfig } from '../../types';
import { Style } from 'ol/style';
import { FeatureLike } from 'ol/Feature';
import { GeomapStyleRulesEditor } from '../../editor/GeomapStyleRulesEditor';
@ -23,6 +23,7 @@ import { ReplaySubject } from 'rxjs';
import { map as rxjsmap, first } from 'rxjs/operators';
import { getLayerPropertyInfo } from '../../utils/getFeatures';
import { getPublicGeoJSONFiles } from '../../utils/utils';
import { ComparisonOperation } from '@grafana/schema';
export interface GeoJSONMapperConfig {
// URL for a geojson file

View File

@ -5,6 +5,7 @@ import BaseLayer from 'ol/layer/Base';
import { Subject } from 'rxjs';
import { MapLayerHandler, MapLayerOptions } from '@grafana/data';
import { ComparisonOperation } from '@grafana/schema';
import { LayerElement } from 'app/core/components/Layers/types';
import { ControlsOptions as ControlsOptionsBase } from './panelcfg.gen';
@ -40,15 +41,6 @@ export interface GeomapInstanceState {
actions: GeomapLayerActions;
}
export enum ComparisonOperation {
EQ = 'eq',
NEQ = 'neq',
LT = 'lt',
LTE = 'lte',
GT = 'gt',
GTE = 'gte',
}
//-------------------
// Runtime model
//-------------------

View File

@ -1,6 +1,6 @@
import { Feature } from 'ol';
import { ComparisonOperation } from '../types';
import { ComparisonOperation } from '@grafana/schema';
import { checkFeatureMatchesStyleRule } from './checkFeatureMatchesStyleRule';

View File

@ -1,6 +1,8 @@
import { FeatureLike } from 'ol/Feature';
import { FeatureRuleConfig, ComparisonOperation } from '../types';
import { compareValues } from '@grafana/data/src/transformations/matchers/compareValues';
import { FeatureRuleConfig } from '../types';
/**
* Check whether feature has property value that matches rule
@ -10,20 +12,5 @@ import { FeatureRuleConfig, ComparisonOperation } from '../types';
*/
export const checkFeatureMatchesStyleRule = (rule: FeatureRuleConfig, feature: FeatureLike) => {
const val = feature.get(rule.property);
switch (rule.operation) {
case ComparisonOperation.EQ:
return `${val}` === `${rule.value}`;
case ComparisonOperation.NEQ:
return val !== rule.value;
case ComparisonOperation.GT:
return val > rule.value;
case ComparisonOperation.GTE:
return val >= rule.value;
case ComparisonOperation.LT:
return val < rule.value;
case ComparisonOperation.LTE:
return val <= rule.value;
default:
return false;
}
return compareValues(val, rule.operation, rule.value);
};