FieldMatchers: Add match by value (reducer) (#64477)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
co-authored by
Ryan McKinley
parent
75f89e67af
commit
18e3e0ca8d
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user