TimeSeries / StateTimeline: Add support for rendering enum fields (#64179)

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin 2023-07-21 11:38:11 -05:00 committed by GitHub
parent 4e50115d95
commit 1de35bf3c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 182 additions and 61 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 { EnumFieldConfig, Field, FieldType } from '../types/dataFrame';
import { Field, FieldType } from '../types/dataFrame';
import { DecimalCount, DisplayProcessor, DisplayValue } from '../types/displayValue';
import { anyToNumber } from '../utils/anyToNumber';
import { getValueMappingResult } from '../utils/valueMappings';
@ -44,6 +44,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
const field = options.field as Field;
const config = field.config ?? {};
const { palette } = options.theme.visualization;
let unit = config.unit;
let hasDateUnit = unit && (timeFormats[unit] || unit.startsWith('time:'));
@ -70,8 +71,6 @@ 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');
@ -116,6 +115,28 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
icon = mappingResult.icon;
}
}
} else if (field.type === FieldType.enum) {
// Apply enum display handling if field is enum type and no mappings are specified
if (value == null) {
return {
text: '',
numeric: NaN,
};
}
const enumIndex = +value;
if (config && config.type && config.type.enum) {
const { text: enumText, color: enumColor } = config.type.enum;
text = enumText ? enumText[enumIndex] : `${value}`;
// If no color specified in enum field config we will fallback to iterating through the theme palette
color = enumColor ? enumColor[enumIndex] : undefined;
if (color == null) {
const namedColor = palette[enumIndex % palette.length];
color = options.theme.visualization.getColorByName(namedColor);
}
}
}
if (!Number.isNaN(numeric)) {
@ -192,41 +213,6 @@ 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

@ -21,6 +21,24 @@ const fieldTypeMatcher: FieldMatcherInfo<FieldType> = {
},
};
// General Field matcher (multiple types)
const fieldTypesMatcher: FieldMatcherInfo<Set<FieldType>> = {
id: FieldMatcherID.byTypes,
name: 'Field Type',
description: 'match based on the field types',
defaultOptions: new Set(),
get: (types) => {
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return types.has(field.type);
};
},
getOptionsDisplayText: (types) => {
return `Field types: ${[...types].join(' | ')}`;
},
};
// Numeric Field matcher
// This gets its own entry so it shows up in the dropdown
const numericMatcher: FieldMatcherInfo = {
@ -56,5 +74,5 @@ const timeMatcher: FieldMatcherInfo = {
* Registry Initialization
*/
export function getFieldTypeMatchers(): FieldMatcherInfo[] {
return [fieldTypeMatcher, numericMatcher, timeMatcher];
return [fieldTypeMatcher, fieldTypesMatcher, numericMatcher, timeMatcher];
}

View File

@ -19,6 +19,7 @@ export enum FieldMatcherID {
// With arguments
byType = 'byType',
byTypes = 'byTypes',
byName = 'byName',
byNames = 'byNames',
byRegexp = 'byRegexp',

View File

@ -10,6 +10,7 @@ import {
Field,
FieldMatcherID,
fieldMatchers,
FieldType,
LegacyGraphHoverEvent,
TimeRange,
TimeZone,
@ -120,7 +121,7 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
frames,
fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])),
},
props.timeRange
);

View File

@ -43,7 +43,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"incrs": undefined,
"labelGap": 0,
"rotate": undefined,
"scale": "__fixed/na-na/na-na/auto/linear/na",
"scale": "__fixed/na-na/na-na/auto/linear/na/number",
"show": true,
"side": 3,
"size": [Function],
@ -81,7 +81,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"key": "__global_",
"scales": [
"x",
"__fixed/na-na/na-na/auto/linear/na",
"__fixed/na-na/na-na/auto/linear/na/number",
],
},
},
@ -101,7 +101,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
[Function],
],
"scales": {
"__fixed/na-na/na-na/auto/linear/na": {
"__fixed/na-na/na-na/auto/linear/na/number": {
"asinh": undefined,
"auto": true,
"dir": 1,
@ -140,7 +140,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": "#ff0000",
},
"pxAlign": undefined,
"scale": "__fixed/na-na/na-na/auto/linear/na",
"scale": "__fixed/na-na/na-na/auto/linear/na/number",
"show": true,
"spanGaps": false,
"stroke": "#ff0000",
@ -163,7 +163,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": "#ff0000",
},
"pxAlign": undefined,
"scale": "__fixed/na-na/na-na/auto/linear/na",
"scale": "__fixed/na-na/na-na/auto/linear/na/number",
"show": true,
"spanGaps": false,
"stroke": "#ff0000",
@ -186,7 +186,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": "#ff0000",
},
"pxAlign": undefined,
"scale": "__fixed/na-na/na-na/auto/linear/na",
"scale": "__fixed/na-na/na-na/auto/linear/na/number",
"show": true,
"spanGaps": false,
"stroke": "#ff0000",
@ -209,7 +209,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": "#ff0000",
},
"pxAlign": undefined,
"scale": "__fixed/na-na/na-na/auto/linear/na",
"scale": "__fixed/na-na/na-na/auto/linear/na/number",
"show": true,
"spanGaps": false,
"stroke": "#ff0000",
@ -232,7 +232,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": "#ff0000",
},
"pxAlign": undefined,
"scale": "__fixed/na-na/na-na/auto/linear/na",
"scale": "__fixed/na-na/na-na/auto/linear/na/number",
"show": true,
"spanGaps": false,
"stroke": "#ff0000",

View File

@ -146,7 +146,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
return null;
}
export function buildScaleKey(config: FieldConfig<GraphFieldConfig>) {
export function buildScaleKey(config: FieldConfig<GraphFieldConfig>, fieldType: FieldType) {
const defaultPart = 'na';
const scaleRange = `${config.min !== undefined ? config.min : defaultPart}-${
@ -169,7 +169,7 @@ export function buildScaleKey(config: FieldConfig<GraphFieldConfig>) {
const scaleLabel = Boolean(config.custom?.axisLabel) ? config.custom!.axisLabel : defaultPart;
return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}`;
return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}/${fieldType}`;
}
function getScaleDistributionPart(config: ScaleDistributionConfig) {

View File

@ -214,7 +214,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
const customConfig: GraphFieldConfig = config.custom!;
if (field === xField || field.type !== FieldType.number) {
if (field === xField || (field.type !== FieldType.number && field.type !== FieldType.enum)) {
continue;
}
@ -231,7 +231,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
theme,
});
}
const scaleKey = buildScaleKey(config);
const scaleKey = buildScaleKey(config, field.type);
const colorMode = getFieldColorModeForField(field);
const scaleColor = getFieldSeriesColor(field, theme);
const seriesColor = scaleColor.color;
@ -258,6 +258,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
dataMax = dataMax > 0 ? 1 : 0;
return [dataMin, dataMax];
}
: field.type === FieldType.enum
? (u: uPlot, dataMin: number, dataMax: number) => {
// this is the exhaustive enum (stable)
let len = field.config.type!.enum!.text!.length;
return [-1, len];
// these are only values that are present
// return [dataMin - 1, dataMax + 1]
}
: undefined,
decimals: field.config.decimals,
},
@ -302,8 +312,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
let incrs: uPlot.Axis.Incrs | undefined;
// TODO: these will be dynamic with frame updates, so need to accept getYTickLabels()
let values: uPlot.Axis.Values | undefined;
let splits: uPlot.Axis.Splits | undefined;
if (IEC_UNITS.has(config.unit!)) {
incrs = BIN_INCRS;
} else if (field.type === FieldType.enum) {
let text = field.config.type!.enum!.text!;
splits = text.map((v: string, i: number) => i);
values = text;
}
builder.addAxis(
@ -318,6 +336,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
grid: { show: customConfig.axisGridShow },
decimals: field.config.decimals,
distr: customConfig.scaleDistribution?.type,
splits,
values,
incrs,
...axisColorOpts,
},

View File

@ -82,7 +82,7 @@ export function getStackingBands(group: StackingGroup) {
export function getStackingGroups(frame: DataFrame) {
let groups: Map<string, StackingGroup> = new Map();
frame.fields.forEach(({ config, values }, i) => {
frame.fields.forEach(({ config, values, type }, i) => {
// skip x or time field
if (i === 0) {
return;
@ -125,7 +125,10 @@ export function getStackingGroups(frame: DataFrame) {
? (custom.lineInterpolation as LineInterpolation)
: null;
let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey(config)}|${drawStyle}|${drawStyle2}`;
let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey(
config,
type
)}|${drawStyle}|${drawStyle2}`;
let group = groups.get(stackKey);

View File

@ -88,7 +88,11 @@ export class TimelineChart extends React.Component<TimelineProps> {
{...this.props}
fields={{
x: (f) => f.type === FieldType.time,
y: (f) => f.type === FieldType.number || f.type === FieldType.boolean || f.type === FieldType.string,
y: (f) =>
f.type === FieldType.number ||
f.type === FieldType.boolean ||
f.type === FieldType.string ||
f.type === FieldType.enum,
}}
prepConfig={this.prepConfig}
propsToDiff={propsToDiff}

View File

@ -466,6 +466,7 @@ export function prepareTimelineFields(
hasTimeseries = true;
fields.push(field);
break;
case FieldType.enum:
case FieldType.number:
if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) {
const f = mergeThresholdValues(field, theme);

View File

@ -3,8 +3,8 @@ import {
DataFrame,
DisplayProcessor,
Field,
FieldType,
getDisplayProcessor,
getEnumDisplayProcessor,
GrafanaTheme2,
} from '@grafana/data';
@ -107,7 +107,9 @@ export class FlameGraphDataContainer {
// both a backward compatibility but also to allow using a simple dataFrame without enum config. This would allow
// users to use this panel with correct query from data sources that do not return profiles natively.
if (enumConfig) {
this.labelDisplayProcessor = getEnumDisplayProcessor(theme, enumConfig);
// TODO: Fix this from backend to set field type to enum correctly
this.labelField.type = FieldType.enum;
this.labelDisplayProcessor = getDisplayProcessor({ field: this.labelField, theme });
this.uniqueLabels = enumConfig.text || [];
} else {
this.labelDisplayProcessor = (value) => ({

View File

@ -1,7 +1,7 @@
import React, { useState, useLayoutEffect, useMemo, useRef } from 'react';
import uPlot from 'uplot';
import { FieldConfigSource, ThresholdsConfig, getValueFormat } from '@grafana/data';
import { FieldConfigSource, ThresholdsConfig, getValueFormat, FieldType } from '@grafana/data';
import { UPlotConfigBuilder, buildScaleKey } from '@grafana/ui';
import { ThresholdDragHandle } from './ThresholdDragHandle';
@ -40,7 +40,7 @@ export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChang
if (!thresholds) {
return null;
}
const scale = buildScaleKey(fieldConfig.defaults);
const scale = buildScaleKey(fieldConfig.defaults, FieldType.number);
const decimals = fieldConfig.defaults.decimals;
const handles = [];

View File

@ -1,4 +1,5 @@
import {
ArrayVector,
DataFrame,
Field,
FieldType,
@ -14,6 +15,58 @@ import { convertFieldType } from '@grafana/data/src/transformations/transformers
import { GraphFieldConfig, LineInterpolation } from '@grafana/schema';
import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold';
import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue';
import { buildScaleKey } from '@grafana/ui/src/components/GraphNG/utils';
type ScaleKey = string;
// this will re-enumerate all enum fields on the same scale to create one ordinal progression
// e.g. ['a','b'][0,1,0] + ['c','d'][1,0,1] -> ['a','b'][0,1,0] + ['c','d'][3,2,3]
function reEnumFields(frames: DataFrame[]) {
let allTextsByKey: Map<ScaleKey, string[]> = new Map();
let frames2: DataFrame[] = frames.map((frame) => {
return {
...frame,
fields: frame.fields.map((field) => {
if (field.type === FieldType.enum) {
let scaleKey = buildScaleKey(field.config, field.type);
let allTexts = allTextsByKey.get(scaleKey);
if (!allTexts) {
allTexts = [];
allTextsByKey.set(scaleKey, allTexts);
}
let idxs: number[] = field.values.toArray().slice();
let txts = field.config.type!.enum!.text!;
// by-reference incrementing
if (allTexts.length > 0) {
for (let i = 0; i < idxs.length; i++) {
idxs[i] += allTexts.length;
}
}
allTexts.push(...txts);
// shared among all enum fields on same scale
field.config.type!.enum!.text! = allTexts;
return {
...field,
values: new ArrayVector(idxs),
};
// TODO: update displayProcessor?
}
return field;
}),
};
});
return frames2;
}
/**
* Returns null if there are no graphable fields
@ -52,6 +105,17 @@ export function prepareGraphableFields(
}
}
let enumFieldsCount = 0;
loopy: for (let frame of series) {
for (let field of frame.fields) {
if (field.type === FieldType.enum && ++enumFieldsCount > 1) {
series = reEnumFields(series);
break loopy;
}
}
}
let copy: Field;
const frames: DataFrame[] = [];
@ -94,6 +158,8 @@ export function prepareGraphableFields(
fields.push(copy);
break; // ok
case FieldType.enum:
hasValueField = true;
case FieldType.string:
copy = {
...field,
@ -150,18 +216,37 @@ export function prepareGraphableFields(
if (frames.length) {
setClassicPaletteIdxs(frames, theme, 0);
matchEnumColorToSeriesColor(frames, theme);
return frames;
}
return null;
}
const matchEnumColorToSeriesColor = (frames: DataFrame[], theme: GrafanaTheme2) => {
const { palette } = theme.visualization;
for (const frame of frames) {
for (const field of frame.fields) {
if (field.type === FieldType.enum) {
const namedColor = palette[field.state?.seriesIndex! % palette.length];
const hexColor = theme.visualization.getColorByName(namedColor);
const enumConfig = field.config.type!.enum!;
enumConfig.color = Array(enumConfig.text!.length).fill(hexColor);
field.display = getDisplayProcessor({ field, theme });
}
}
}
};
const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => {
let seriesIndex = 0;
frames.forEach((frame) => {
frame.fields.forEach((field, fieldIdx) => {
// TODO: also add FieldType.enum type here after https://github.com/grafana/grafana/pull/60491
if (fieldIdx !== skipFieldIdx && (field.type === FieldType.number || field.type === FieldType.boolean)) {
if (
fieldIdx !== skipFieldIdx &&
(field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum)
) {
field.state = {
...field.state,
seriesIndex: seriesIndex++, // TODO: skip this for fields with custom renderers (e.g. Candlestick)?