FieldType: Add enum type and include it in testdata scenarios (#64059)

This commit is contained in:
Ryan McKinley
2023-03-03 13:37:56 -08:00
committed by GitHub
parent aed020d9b5
commit b7d8589588
9 changed files with 200 additions and 89 deletions

View File

@@ -6,7 +6,7 @@ import { getFieldTypeFromValue } from '../dataframe/processDataFrame';
import { toUtc, dateTimeParse } from '../datetime';
import { GrafanaTheme2 } from '../themes/types';
import { KeyValue, TimeZone } from '../types';
import { Field, FieldType } from '../types/dataFrame';
import { EnumFieldConfig, Field, FieldType } from '../types/dataFrame';
import { DecimalCount, DisplayProcessor, DisplayValue } from '../types/displayValue';
import { anyToNumber } from '../utils/anyToNumber';
import { getValueMappingResult } from '../utils/valueMappings';
@@ -70,6 +70,8 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
}
} else if (!unit && field.type === FieldType.string) {
unit = 'string';
} else if (field.type === FieldType.enum) {
return getEnumDisplayProcessor(options.theme, config.type?.enum);
}
const hasCurrencyUnit = unit?.startsWith('currency');
@@ -190,6 +192,41 @@ function toStringProcessor(value: unknown): DisplayValue {
return { text: toString(value), numeric: anyToNumber(value) };
}
export function getEnumDisplayProcessor(theme: GrafanaTheme2, cfg?: EnumFieldConfig): DisplayProcessor {
const config = {
text: cfg?.text ?? [],
color: cfg?.color ?? [],
};
// use the theme specific color values
config.color = config.color.map((v) => theme.visualization.getColorByName(v));
return (value: unknown) => {
if (value == null) {
return {
text: '',
numeric: NaN,
};
}
const idx = +value;
let text = config.text[idx];
if (text == null) {
text = `${value}`; // the original value
}
let color = config.color[idx];
if (color == null) {
// constant color for index
const { palette } = theme.visualization;
color = palette[idx % palette.length];
config.color[idx] = color;
}
return {
text,
numeric: idx,
color,
};
};
}
export function getRawDisplayProcessor(): DisplayProcessor {
return (value: unknown) => ({
text: getFieldTypeFromValue(value) === 'other' ? `${JSON.stringify(value, getCircularReplacer())}` : `${value}`,

View File

@@ -2,7 +2,7 @@ import { map } from 'rxjs/operators';
import { dateTimeParse } from '../../datetime';
import { SynchronousDataTransformerInfo } from '../../types';
import { DataFrame, Field, FieldType } from '../../types/dataFrame';
import { DataFrame, EnumFieldConfig, Field, FieldType } from '../../types/dataFrame';
import { ArrayVector } from '../../vector';
import { fieldMatchers } from '../matchers';
import { FieldMatcherID } from '../matchers/ids';
@@ -26,6 +26,9 @@ export interface ConvertFieldTypeOptions {
* Date format to parse a string datetime
*/
dateFormat?: string;
/** When converting to an enumeration, this is the target config */
enumConfig?: EnumFieldConfig;
}
export const convertFieldTypeTransformer: SynchronousDataTransformerInfo<ConvertFieldTypeTransformerOptions> = {
@@ -44,11 +47,7 @@ export const convertFieldTypeTransformer: SynchronousDataTransformerInfo<Convert
if (!Array.isArray(data) || data.length === 0) {
return data;
}
const timeParsed = convertFieldTypes(options, data);
if (!timeParsed) {
return [];
}
return timeParsed;
return convertFieldTypes(options, data) ?? [];
},
};
@@ -101,6 +100,8 @@ export function convertFieldType(field: Field, opts: ConvertFieldTypeOptions): F
return fieldToStringField(field, opts.dateFormat);
case FieldType.boolean:
return fieldToBooleanField(field);
case FieldType.enum:
return fieldToEnumField(field, opts.enumConfig);
case FieldType.other:
return fieldToComplexField(field);
default:
@@ -241,3 +242,37 @@ export function ensureTimeField(field: Field, dateFormat?: string): Field {
}
return fieldToTimeField(field, dateFormat);
}
function fieldToEnumField(field: Field, cfg?: EnumFieldConfig): Field {
const enumConfig = { ...cfg };
const enumValues = field.values.toArray().slice();
const lookup = new Map<unknown, number>();
if (enumConfig.text) {
for (let i = 0; i < enumConfig.text.length; i++) {
lookup.set(enumConfig.text[i], i);
}
} else {
enumConfig.text = [];
}
for (let i = 0; i < enumValues.length; i++) {
const v = enumValues[i];
if (!lookup.has(v)) {
enumConfig.text[lookup.size] = v;
lookup.set(v, lookup.size);
}
enumValues[i] = lookup.get(v);
}
return {
...field,
config: {
...field.config,
type: {
enum: enumConfig,
},
},
type: FieldType.enum,
values: new ArrayVector(enumValues),
};
}

View File

@@ -16,6 +16,7 @@ export enum FieldType {
// Used to detect that the value is some kind of trace data to help with the visualisation and processing.
trace = 'trace',
geo = 'geo',
enum = 'enum',
other = 'other', // Object, Array, etc
}
@@ -91,10 +92,24 @@ export interface FieldConfig<TOptions = any> {
// Alternative to empty string
noValue?: string;
// The field type may map to specific config
type?: FieldTypeConfig;
// Panel Specific Values
custom?: TOptions;
}
export interface FieldTypeConfig {
enum?: EnumFieldConfig;
}
export interface EnumFieldConfig {
text?: string[];
color?: string[];
icon?: string[];
description?: string[];
}
/** @public */
export interface ValueLinkConfig {
/**

View File

@@ -147,6 +147,7 @@ export const availableIconsIndex = {
link: true,
'list-ui-alt': true,
'list-ul': true,
'list-ol': true,
lock: true,
'map-marker': true,
message: true,

View File

@@ -2,6 +2,7 @@ import React, { memo, useMemo, useCallback } from 'react';
import { FieldMatcherID, fieldMatchers, SelectableValue, FieldType, DataFrame } from '@grafana/data';
import { getFieldTypeIconName } from '../../types/icon';
import { Select } from '../Select/Select';
import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types';
@@ -23,19 +24,22 @@ export const FieldTypeMatcherEditor = memo<MatcherUIProps<string>>((props) => {
});
FieldTypeMatcherEditor.displayName = 'FieldTypeMatcherEditor';
const allTypes: Array<SelectableValue<FieldType>> = [
{ value: FieldType.number, label: 'Numeric' },
{ value: FieldType.string, label: 'String' },
{ value: FieldType.time, label: 'Time' },
{ value: FieldType.boolean, label: 'Boolean' },
{ value: FieldType.trace, label: 'Traces' },
{ value: FieldType.other, label: 'Other' },
// Select options for all field types.
// This is not eported to the published package, but used internally
export const allFieldTypeIconOptions: Array<SelectableValue<FieldType>> = [
{ value: FieldType.number, label: 'Number', icon: getFieldTypeIconName(FieldType.number) },
{ value: FieldType.string, label: 'String', icon: getFieldTypeIconName(FieldType.string) },
{ value: FieldType.time, label: 'Time', icon: getFieldTypeIconName(FieldType.time) },
{ value: FieldType.boolean, label: 'Boolean', icon: getFieldTypeIconName(FieldType.boolean) },
{ value: FieldType.trace, label: 'Traces', icon: getFieldTypeIconName(FieldType.trace) },
{ value: FieldType.enum, label: 'Enum', icon: getFieldTypeIconName(FieldType.enum) },
{ value: FieldType.other, label: 'Other', icon: getFieldTypeIconName(FieldType.other) },
];
const useFieldCounts = (data: DataFrame[]): Map<FieldType, number> => {
return useMemo(() => {
const counts: Map<FieldType, number> = new Map();
for (const t of allTypes) {
for (const t of allFieldTypeIconOptions) {
counts.set(t.value!, 0);
}
for (const frame of data) {
@@ -56,7 +60,7 @@ const useSelectOptions = (counts: Map<string, number>, opt?: string): Array<Sele
return useMemo(() => {
let found = false;
const options: Array<SelectableValue<string>> = [];
for (const t of allTypes) {
for (const t of allFieldTypeIconOptions) {
const count = counts.get(t.value!);
const match = opt === t.value;
if (count || match) {

View File

@@ -13,12 +13,16 @@ export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
export const getAvailableIcons = () => Object.keys(availableIconsIndex);
/**
* Get the icon for a given field type
* @deprecated use getFieldTypeIconName
* Get the icon for a given field
*/
export function getFieldTypeIcon(field?: Field): IconName {
if (field) {
switch (field.type) {
return getFieldTypeIconName(field?.type);
}
/** Get an icon for a given field type */
export function getFieldTypeIconName(type?: FieldType): IconName {
if (type) {
switch (type) {
case FieldType.time:
return 'clock-nine';
case FieldType.string:
@@ -29,6 +33,8 @@ export function getFieldTypeIcon(field?: Field): IconName {
return 'toggle-on';
case FieldType.trace:
return 'info-circle';
case FieldType.enum:
return 'list-ol';
case FieldType.geo:
return 'map-marker';
case FieldType.other:
@@ -37,24 +43,3 @@ export function getFieldTypeIcon(field?: Field): IconName {
}
return 'question-circle';
}
/** Get the icon for a given field type */
export function getFieldTypeIconName(fieldType?: FieldType): IconName {
switch (fieldType) {
case FieldType.time:
return 'clock-nine';
case FieldType.string:
return 'font';
case FieldType.number:
return 'calculator-alt';
case FieldType.boolean:
return 'toggle-on';
case FieldType.trace:
return 'info-circle';
case FieldType.geo:
return 'map-marker';
case FieldType.other:
return 'brackets-curly';
}
return 'question-circle';
}

View File

@@ -712,15 +712,29 @@ func randomWalkTable(query backend.DataQuery, model *simplejson.Json) *data.Fram
walker := model.Get("startValue").MustFloat64(rand.Float64() * 100)
spread := 2.5
stateField := data.NewFieldFromFieldType(data.FieldTypeEnum, 0)
stateField.Name = "State"
stateField.Config = &data.FieldConfig{
TypeConfig: &data.FieldTypeConfig{
Enum: &data.EnumFieldConfig{
Text: []string{
"Unknown", "Up", "Down", // 0,1,2
},
},
},
}
frame := data.NewFrame(query.RefID,
data.NewField("Time", nil, []*time.Time{}),
data.NewField("Value", nil, []*float64{}),
data.NewField("Min", nil, []*float64{}),
data.NewField("Max", nil, []*float64{}),
data.NewField("Info", nil, []*string{}),
stateField,
)
var info strings.Builder
state := data.EnumItemIndex(0)
for i := int64(0); i < query.MaxDataPoints && timeWalkerMs < to; i++ {
delta := rand.Float64() - 0.5
@@ -729,8 +743,10 @@ func randomWalkTable(query backend.DataQuery, model *simplejson.Json) *data.Fram
info.Reset()
if delta > 0 {
info.WriteString("up")
state = 1
} else {
info.WriteString("down")
state = 2
}
if math.Abs(delta) > .4 {
info.WriteString(" fast")
@@ -748,11 +764,12 @@ func randomWalkTable(query backend.DataQuery, model *simplejson.Json) *data.Fram
for i := range vals {
if rand.Float64() > .2 {
vals[i] = nil
state = 0
}
}
}
frame.AppendRow(&t, vals[0], vals[1], vals[2], &infoString)
frame.AppendRow(&t, vals[0], vals[1], vals[2], &infoString, state)
timeWalkerMs += query.Interval.Milliseconds()
}

View File

@@ -96,7 +96,7 @@ func TestTestdataScenarios(t *testing.T) {
require.Len(t, dResp.Frames, 1)
frame := dResp.Frames[0]
require.Greater(t, frame.Rows(), 50)
require.Len(t, frame.Fields, 5)
require.Len(t, frame.Fields, 6)
require.Equal(t, "Time", frame.Fields[0].Name)
require.Equal(t, "Value", frame.Fields[1].Name)
require.Equal(t, "Min", frame.Fields[2].Name)
@@ -152,12 +152,13 @@ func TestTestdataScenarios(t *testing.T) {
require.Len(t, dResp.Frames, 1)
frame := dResp.Frames[0]
require.Greater(t, frame.Rows(), 50)
require.Len(t, frame.Fields, 5)
require.Len(t, frame.Fields, 6)
require.Equal(t, "Time", frame.Fields[0].Name)
require.Equal(t, "Value", frame.Fields[1].Name)
require.Equal(t, "Min", frame.Fields[2].Name)
require.Equal(t, "Max", frame.Fields[3].Name)
require.Equal(t, "Info", frame.Fields[4].Name)
require.Equal(t, "State", frame.Fields[5].Name)
valNil := false
minNil := false

View File

@@ -14,8 +14,10 @@ import {
ConvertFieldTypeOptions,
ConvertFieldTypeTransformerOptions,
} from '@grafana/data/src/transformations/transformers/convertFieldType';
import { Button, InlineField, InlineFieldRow, Input, Select, getFieldTypeIconName } from '@grafana/ui';
import { Button, InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
import { allFieldTypeIconOptions } from '@grafana/ui/src/components/MatchersUI/FieldTypeMatcherEditor';
import { hasAlphaPanels } from 'app/core/config';
import { findField } from 'app/features/dimensions';
const fieldNamePickerSettings: StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings> = {
@@ -27,18 +29,12 @@ export const ConvertFieldTypeTransformerEditor = ({
options,
onChange,
}: TransformerUIProps<ConvertFieldTypeTransformerOptions>) => {
const allTypes: Array<SelectableValue<FieldType>> = [
{ value: FieldType.number, label: 'Number', icon: getFieldTypeIconName(FieldType.number) },
{ value: FieldType.string, label: 'String', icon: getFieldTypeIconName(FieldType.string) },
{ value: FieldType.time, label: 'Time', icon: getFieldTypeIconName(FieldType.time) },
{ value: FieldType.boolean, label: 'Boolean', icon: getFieldTypeIconName(FieldType.boolean) },
{ value: FieldType.other, label: 'JSON', icon: getFieldTypeIconName(FieldType.other) },
];
const allTypes = allFieldTypeIconOptions.filter((v) => v.value !== FieldType.trace);
const onSelectField = useCallback(
(idx: number) => (value: string | undefined) => {
const conversions = options.conversions;
conversions[idx] = { ...conversions[idx], targetField: value ?? '' };
conversions[idx] = { ...conversions[idx], targetField: value ?? '', dateFormat: undefined };
onChange({
...options,
conversions: conversions,
@@ -97,45 +93,65 @@ export const ConvertFieldTypeTransformerEditor = ({
<>
{options.conversions.map((c: ConvertFieldTypeOptions, idx: number) => {
return (
<InlineFieldRow key={`${c.targetField}-${idx}`}>
<InlineField label={'Field'}>
<FieldNamePicker
context={{ data: input }}
value={c.targetField ?? ''}
onChange={onSelectField(idx)}
item={fieldNamePickerSettings}
/>
</InlineField>
<InlineField label={'as'}>
<Select
options={allTypes}
value={c.destinationType}
placeholder={'Type'}
onChange={onSelectDestinationType(idx)}
width={18}
/>
</InlineField>
{c.destinationType === FieldType.time && (
<InlineField
label="Input format"
tooltip="Specify the format of the input field so Grafana can parse the date string correctly."
>
<Input value={c.dateFormat} placeholder={'e.g. YYYY-MM-DD'} onChange={onInputFormat(idx)} width={24} />
<div key={`${c.targetField}-${idx}`}>
<InlineFieldRow>
<InlineField label={'Field'}>
<FieldNamePicker
context={{ data: input }}
value={c.targetField ?? ''}
onChange={onSelectField(idx)}
item={fieldNamePickerSettings}
/>
</InlineField>
)}
{c.destinationType === FieldType.string && (c.dateFormat || findField(input?.[0], c.targetField)) && (
<InlineField label="Date format" tooltip="Specify the output format.">
<Input value={c.dateFormat} placeholder={'e.g. YYYY-MM-DD'} onChange={onInputFormat(idx)} width={24} />
<InlineField label={'as'}>
<Select
options={allTypes}
value={c.destinationType}
placeholder={'Type'}
onChange={onSelectDestinationType(idx)}
width={18}
/>
</InlineField>
{c.destinationType === FieldType.time && (
<InlineField
label="Input format"
tooltip="Specify the format of the input field so Grafana can parse the date string correctly."
>
<Input
value={c.dateFormat}
placeholder={'e.g. YYYY-MM-DD'}
onChange={onInputFormat(idx)}
width={24}
/>
</InlineField>
)}
{c.destinationType === FieldType.string &&
(c.dateFormat || findField(input?.[0], c.targetField)?.type === FieldType.time) && (
<InlineField label="Date format" tooltip="Specify the output format.">
<Input
value={c.dateFormat}
placeholder={'e.g. YYYY-MM-DD'}
onChange={onInputFormat(idx)}
width={24}
/>
</InlineField>
)}
<Button
size="md"
icon="trash-alt"
variant="secondary"
onClick={() => onRemoveConvertFieldType(idx)}
aria-label={'Remove convert field type transformer'}
/>
</InlineFieldRow>
{c.destinationType === FieldType.enum && hasAlphaPanels && (
<InlineFieldRow>
<InlineField label={''} labelWidth={6}>
<div>TODO... show options here (alpha panels enabled)</div>
</InlineField>
</InlineFieldRow>
)}
<Button
size="md"
icon="trash-alt"
variant="secondary"
onClick={() => onRemoveConvertFieldType(idx)}
aria-label={'Remove convert field type transformer'}
/>
</InlineFieldRow>
</div>
);
})}
<Button