Timeseries: support boolean values out-of-the-box (#34168)

This commit is contained in:
Ryan McKinley 2021-05-17 09:52:47 -07:00 committed by GitHub
parent 331991ca10
commit 7b32c5439b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 222 additions and 32 deletions

View File

@ -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');

View File

@ -689,7 +689,7 @@ describe('applyRawFieldOverrides', () => {
percent: expect.any(Number),
prefix: undefined,
suffix: undefined,
text: '0',
text: 'False',
});
expect(getDisplayValue(frames, frameIndex, 4)).toEqual({

View File

@ -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') },
],
},
];

View File

@ -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);

View File

@ -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<AxisProps, Axis> {
}
getConfig(): Axis {
const {
let {
scaleKey,
label,
show = true,
@ -55,6 +55,10 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
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<AxisProps, Axis> {
} 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

View File

@ -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<ScaleProps, Scale> {
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<ScaleProps, Scale> {
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,

View File

@ -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<TimeSeriesOptions> {}
@ -25,25 +27,19 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
return getFieldLinksForExplore({ field, rowIndex, range: timeRange });
};
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
const { frames, warn } = useMemo(() => prepareGraphableFields(data?.series, config.theme2), [data]);
if (!anySeriesWithTimeField(data.series)) {
if (!frames || warn) {
return (
<div className="panel-empty">
<p>Missing time field in the data</p>
<p>{warn ?? 'No data found in response'}</p>
</div>
);
}
return (
<TimeSeries
frames={data.series}
frames={frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}

View File

@ -0,0 +1,61 @@
import { createTheme, FieldType, toDataFrame } from '@grafana/data';
import { prepareGraphableFields } from './utils';
describe('prepare timeseries graph', () => {
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",
}
`);
});
});

View File

@ -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 };
}