mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Timeseries: support boolean values out-of-the-box (#34168)
This commit is contained in:
parent
331991ca10
commit
7b32c5439b
@ -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');
|
||||
|
@ -689,7 +689,7 @@ describe('applyRawFieldOverrides', () => {
|
||||
percent: expect.any(Number),
|
||||
prefix: undefined,
|
||||
suffix: undefined,
|
||||
text: '0',
|
||||
text: 'False',
|
||||
});
|
||||
|
||||
expect(getDisplayValue(frames, frameIndex, 4)).toEqual({
|
||||
|
@ -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') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
61
public/app/plugins/panel/timeseries/utils.test.ts
Normal file
61
public/app/plugins/panel/timeseries/utils.test.ts
Normal 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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
92
public/app/plugins/panel/timeseries/utils.ts
Normal file
92
public/app/plugins/panel/timeseries/utils.ts
Normal 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 };
|
||||
}
|
Loading…
Reference in New Issue
Block a user