From 7b32c5439b35add23e34fa92b5e1764c566f7592 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 17 May 2021 09:52:47 -0700 Subject: [PATCH] Timeseries: support boolean values out-of-the-box (#34168) --- .../src/field/displayProcessor.ts | 6 +- .../src/field/fieldOverrides.test.ts | 2 +- .../src/valueFormats/categories.ts | 18 +++- .../src/valueFormats/valueFormats.ts | 22 ++++- .../uPlot/config/UPlotAxisBuilder.ts | 10 +- .../uPlot/config/UPlotScaleBuilder.ts | 23 ++--- .../panel/timeseries/TimeSeriesPanel.tsx | 20 ++-- .../plugins/panel/timeseries/utils.test.ts | 61 ++++++++++++ public/app/plugins/panel/timeseries/utils.ts | 92 +++++++++++++++++++ 9 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 public/app/plugins/panel/timeseries/utils.test.ts create mode 100644 public/app/plugins/panel/timeseries/utils.ts diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 55622f1f2fe..617639f445d 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -4,7 +4,7 @@ import { toString, toNumber as _toNumber, isEmpty, isBoolean } from 'lodash'; // Types import { Field, FieldType } from '../types/dataFrame'; import { DisplayProcessor, DisplayValue } from '../types/displayValue'; -import { getValueFormat } from '../valueFormats/valueFormats'; +import { getValueFormat, isBooleanUnit } from '../valueFormats/valueFormats'; import { getValueMappingResult } from '../utils/valueMappings'; import { dateTime } from '../datetime'; import { KeyValue, TimeZone } from '../types'; @@ -49,6 +49,10 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP if (field.type === FieldType.time && !hasDateUnit) { unit = `dateTimeAsSystem`; hasDateUnit = true; + } else if (field.type === FieldType.boolean) { + if (!isBooleanUnit(unit)) { + unit = 'bool'; + } } const formatFunc = getValueFormat(unit || 'none'); diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index dd504f29f4f..aba0fa97624 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -689,7 +689,7 @@ describe('applyRawFieldOverrides', () => { percent: expect.any(Number), prefix: undefined, suffix: undefined, - text: '0', + text: 'False', }); expect(getDisplayValue(frames, frameIndex, 4)).toEqual({ diff --git a/packages/grafana-data/src/valueFormats/categories.ts b/packages/grafana-data/src/valueFormats/categories.ts index 79dfb956082..34cf8e711af 100644 --- a/packages/grafana-data/src/valueFormats/categories.ts +++ b/packages/grafana-data/src/valueFormats/categories.ts @@ -1,4 +1,12 @@ -import { locale, scaledUnits, simpleCountUnit, toFixedUnit, ValueFormatCategory, stringFormater } from './valueFormats'; +import { + locale, + scaledUnits, + simpleCountUnit, + toFixedUnit, + ValueFormatCategory, + stringFormater, + booleanValueFormatter, +} from './valueFormats'; import { dateTimeAsIso, dateTimeAsIsoNoDateIfToday, @@ -399,4 +407,12 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'gallons', id: 'gallons', fn: toFixedUnit('gal') }, ], }, + { + name: 'Boolean', + formats: [ + { name: 'True / False', id: 'bool', fn: booleanValueFormatter('True', 'False') }, + { name: 'Yes / No', id: 'bool_yes_no', fn: booleanValueFormatter('Yes', 'No') }, + { name: 'On / Off', id: 'bool_on_off', fn: booleanValueFormatter('On', 'Off') }, + ], + }, ]; diff --git a/packages/grafana-data/src/valueFormats/valueFormats.ts b/packages/grafana-data/src/valueFormats/valueFormats.ts index 3d2174fdb73..2753426170a 100644 --- a/packages/grafana-data/src/valueFormats/valueFormats.ts +++ b/packages/grafana-data/src/valueFormats/valueFormats.ts @@ -113,6 +113,16 @@ export function toFixedUnit(unit: string, asPrefix?: boolean): ValueFormatter { }; } +export function isBooleanUnit(unit?: string) { + return unit && unit.startsWith('bool'); +} + +export function booleanValueFormatter(t: string, f: string): ValueFormatter { + return (value: any) => { + return { text: value ? t : f }; + }; +} + // Formatter which scales the unit string geometrically according to the given // numeric factor. Repeatedly scales the value down by the factor until it is // less than the factor in magnitude, or the end of the array is reached. @@ -199,7 +209,7 @@ export function getValueFormat(id?: string | null): ValueFormatter { const fmt = index[id]; if (!fmt && id) { - const idx = id.indexOf(':'); + let idx = id.indexOf(':'); if (idx > 0) { const key = id.substring(0, idx); @@ -230,6 +240,16 @@ export function getValueFormat(id?: string | null): ValueFormatter { if (key === 'currency') { return currency(sub); } + + if (key === 'bool') { + idx = sub.indexOf('/'); + if (idx >= 0) { + const t = sub.substring(0, idx); + const f = sub.substring(idx + 1); + return booleanValueFormatter(t, f); + } + return booleanValueFormatter(sub, '-'); + } } return toFixedUnit(id); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 5842cdb87f7..58ba89d20e0 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -1,4 +1,4 @@ -import { dateTimeFormat, GrafanaTheme2, systemDateFormats, TimeZone } from '@grafana/data'; +import { dateTimeFormat, GrafanaTheme2, isBooleanUnit, systemDateFormats, TimeZone } from '@grafana/data'; import uPlot, { Axis } from 'uplot'; import { PlotConfigBuilder } from '../types'; import { measureText } from '../../../utils/measureText'; @@ -35,7 +35,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { } getConfig(): Axis { - const { + let { scaleKey, label, show = true, @@ -55,6 +55,10 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { const gridColor = theme.isDark ? 'rgba(240, 250, 255, 0.09)' : 'rgba(0, 10, 23, 0.09)'; + if (isBooleanUnit(scaleKey)) { + splits = [0, 1]; + } + let config: Axis = { scale: scaleKey, show, @@ -89,7 +93,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { } else if (isTime) { config.values = formatTime; } else if (formatValue) { - config.values = (u: uPlot, vals: any[]) => vals.map((v) => formatValue(v)); + config.values = (u: uPlot, vals: any[]) => vals.map((v) => formatValue!(v)); } // store timezone diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts index 8d8ec320f6b..98bc6301da3 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts @@ -2,6 +2,7 @@ import uPlot, { Scale, Range } from 'uplot'; import { PlotConfigBuilder } from '../types'; import { ScaleOrientation, ScaleDirection } from '../config'; import { ScaleDistribution } from '../models.gen'; +import { isBooleanUnit } from '@grafana/data'; export interface ScaleProps { scaleKey: string; @@ -23,18 +24,8 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { this.props.max = optMinMax('max', this.props.max, props.max); } - getConfig() { - const { - isTime, - scaleKey, - min: hardMin, - max: hardMax, - softMin, - softMax, - range, - direction, - orientation, - } = this.props; + getConfig(): Scale { + let { isTime, scaleKey, min: hardMin, max: hardMax, softMin, softMax, range, direction, orientation } = this.props; const distribution = !isTime ? { distr: @@ -94,10 +85,16 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { return minMax; }; + let auto = !isTime && !(hardMinOnly && hardMaxOnly); + if (isBooleanUnit(scaleKey)) { + auto = false; + range = [0, 1]; + } + return { [scaleKey]: { time: isTime, - auto: !isTime && !(hardMinOnly && hardMaxOnly), + auto, range: range ?? rangeFn, dir: direction, ori: orientation, diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index c66a37bbbc4..54b9317ca2e 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -1,11 +1,13 @@ -import { anySeriesWithTimeField, DashboardCursorSync, Field, PanelProps } from '@grafana/data'; +import { DashboardCursorSync, Field, PanelProps } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { TooltipDisplayMode, usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin } from '@grafana/ui'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; -import React from 'react'; +import React, { useMemo } from 'react'; import { AnnotationsPlugin } from './plugins/AnnotationsPlugin'; import { ContextMenuPlugin } from './plugins/ContextMenuPlugin'; import { ExemplarsPlugin } from './plugins/ExemplarsPlugin'; import { TimeSeriesOptions } from './types'; +import { prepareGraphableFields } from './utils'; interface TimeSeriesPanelProps extends PanelProps {} @@ -25,25 +27,19 @@ export const TimeSeriesPanel: React.FC = ({ return getFieldLinksForExplore({ field, rowIndex, range: timeRange }); }; - if (!data || !data.series?.length) { - return ( -
-

No data found in response

-
- ); - } + const { frames, warn } = useMemo(() => prepareGraphableFields(data?.series, config.theme2), [data]); - if (!anySeriesWithTimeField(data.series)) { + if (!frames || warn) { return (
-

Missing time field in the data

+

{warn ?? 'No data found in response'}

); } return ( { + it('errors with no time fields', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'a', values: [1, 2, 3] }, + { name: 'b', values: ['a', 'b', 'c'] }, + ], + }), + ]; + const info = prepareGraphableFields(frames, createTheme()); + expect(info.warn).toEqual('Data does not have a time field'); + }); + + it('requires a number or boolean value', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'a', type: FieldType.time, values: [1, 2, 3] }, + { name: 'b', values: ['a', 'b', 'c'] }, + ], + }), + ]; + const info = prepareGraphableFields(frames, createTheme()); + expect(info.warn).toEqual('No graphable fields'); + }); + + it('will graph numbers and boolean values', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'a', type: FieldType.time, values: [1, 2, 3] }, + { name: 'b', values: ['a', 'b', 'c'] }, + { name: 'c', values: [true, false, true] }, + { name: 'd', values: [100, 200, 300] }, + ], + }), + ]; + const info = prepareGraphableFields(frames, createTheme()); + expect(info.warn).toBeUndefined(); + + const out = info.frames![0]; + expect(out.fields.map((f) => f.name)).toEqual(['a', 'c', 'd']); + + const field = out.fields.find((f) => f.name === 'c'); + expect(field?.display).toBeDefined(); + expect(field!.display!(1)).toMatchInlineSnapshot(` + Object { + "color": "#808080", + "numeric": 1, + "percent": 1, + "prefix": undefined, + "suffix": undefined, + "text": "True", + } + `); + }); +}); diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts new file mode 100644 index 00000000000..71795ebb8ec --- /dev/null +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -0,0 +1,92 @@ +import { + ArrayVector, + DataFrame, + Field, + FieldType, + getDisplayProcessor, + GrafanaTheme2, + isBooleanUnit, +} from '@grafana/data'; +import { GraphFieldConfig, LineInterpolation } from '@grafana/ui'; + +// This will return a set of frames with only graphable values included +export function prepareGraphableFields( + series: DataFrame[] | undefined, + theme: GrafanaTheme2 +): { frames?: DataFrame[]; warn?: string } { + if (!series?.length) { + return { warn: 'No data in response' }; + } + let hasTimeseries = false; + const frames: DataFrame[] = []; + for (let frame of series) { + let isTimeseries = false; + let changed = false; + const fields: Field[] = []; + for (const field of frame.fields) { + switch (field.type) { + case FieldType.time: + isTimeseries = true; + hasTimeseries = true; + fields.push(field); + break; + case FieldType.number: + fields.push(field); + break; // ok + case FieldType.boolean: + changed = true; + const custom: GraphFieldConfig = field.config?.custom ?? {}; + const config = { + ...field.config, + max: 1, + min: 0, + custom, + }; + // smooth and linear do not make sense + if (custom.lineInterpolation !== LineInterpolation.StepBefore) { + custom.lineInterpolation = LineInterpolation.StepAfter; + } + const copy = { + ...field, + config, + type: FieldType.number, + values: new ArrayVector( + field.values.toArray().map((v) => { + if (v == null) { + return v; + } + return Boolean(v) ? 1 : 0; + }) + ), + }; + if (!isBooleanUnit(config.unit)) { + config.unit = 'bool'; + copy.display = getDisplayProcessor({ field: copy, theme }); + } + fields.push(copy); + break; + default: + changed = true; + } + } + if (isTimeseries && fields.length > 1) { + hasTimeseries = true; + if (changed) { + frames.push({ + ...frame, + fields, + }); + } else { + frames.push(frame); + } + } + } + + if (!hasTimeseries) { + return { warn: 'Data does not have a time field' }; + } + if (!frames.length) { + return { warn: 'No graphable fields' }; + } + return { frames }; +}