diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index cb9f6518464..71ebcd998ed 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -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}`, diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts index 975d8ad55c9..13d6db0d7c2 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts @@ -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 = { @@ -44,11 +47,7 @@ export const convertFieldTypeTransformer: SynchronousDataTransformerInfo(); + 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), + }; +} diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 1dba22854dc..5038d375051 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -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 { // 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 { /** diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 62bb143eeeb..16ef1ddc7a1 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -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, diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx index 87c901bef3b..c196290adef 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx @@ -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>((props) => { }); FieldTypeMatcherEditor.displayName = 'FieldTypeMatcherEditor'; -const allTypes: Array> = [ - { 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> = [ + { 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 => { return useMemo(() => { const counts: Map = 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, opt?: string): Array { let found = false; const options: Array> = []; - for (const t of allTypes) { + for (const t of allFieldTypeIconOptions) { const count = counts.get(t.value!); const match = opt === t.value; if (count || match) { diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index bf34ad0f357..f383e120002 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -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'; -} diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index bfffb798e72..20d91acace9 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -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() } diff --git a/pkg/tsdb/testdatasource/scenarios_test.go b/pkg/tsdb/testdatasource/scenarios_test.go index ec3838c03ab..64da99d41b1 100644 --- a/pkg/tsdb/testdatasource/scenarios_test.go +++ b/pkg/tsdb/testdatasource/scenarios_test.go @@ -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 diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx index 0e6a10b1d57..f9c1e6476a3 100644 --- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx @@ -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 = { @@ -27,18 +29,12 @@ export const ConvertFieldTypeTransformerEditor = ({ options, onChange, }: TransformerUIProps) => { - const allTypes: Array> = [ - { 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 ( - - - - - - +
+ + + - )} - {c.destinationType === FieldType.string && (c.dateFormat || findField(input?.[0], c.targetField)) && ( - - + + + + )} + {c.destinationType === FieldType.string && + (c.dateFormat || findField(input?.[0], c.targetField)?.type === FieldType.time) && ( + + + + )} +
); })}