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
co-authored by Ryan McKinley
parent 75f89e67af
commit 18e3e0ca8d
19 changed files with 349 additions and 49 deletions
@@ -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)
];
});
@@ -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);
});
});
@@ -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;
}
}
@@ -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']);
});
});
@@ -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})`;
},
};
@@ -24,6 +24,7 @@ export enum FieldMatcherID {
byRegexp = 'byRegexp',
byRegexpOrNames = 'byRegexpOrNames',
byFrameRefID = 'byFrameRefID',
byValue = 'byValue',
// byIndex = 'byIndex',
// byLabel = 'byLabel',
}
@@ -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',
}
/**
@@ -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")
@@ -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")
@@ -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}`,
};
@@ -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,
]);