Heatmap: Fix ability to define bucket size as an interval string, like 30s (#95923)

* validate with durations

* update docs

* Add default values to calculation, show error if too many bins

* move default generation to separate function

* Update docs/sources/panels-visualizations/visualizations/heatmap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* dont try to parse ‘’ as a duration, move max to variable

* Add new function to support duration and ms, only calculate if valid

* Add radix

* Remove validation and precalc to determine bucket quantity

* simplify

* simplify more

* less

* cleanup transformationsVariableSupport. reset value to auto on mode changes

* maybe...

* by hook or by crook

* Change function name back

---------

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
pull/97367/head
Kristina 6 months ago committed by GitHub
parent 88621d6fa0
commit 53052def52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      docs/sources/panels-visualizations/visualizations/heatmap/index.md
  2. 72
      public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx
  3. 3
      public/app/features/transformers/calculateHeatmap/editor/helper.ts
  4. 6
      public/app/features/transformers/calculateHeatmap/heatmap.ts
  5. 21
      public/app/features/transformers/calculateHeatmap/utils.test.ts
  6. 27
      public/app/features/transformers/calculateHeatmap/utils.ts
  7. 57
      public/app/plugins/panel/heatmap/fields.ts

@ -80,7 +80,7 @@ This setting determines if the data is already a calculated heatmap (from the da
### X Bucket
This setting determines how the X-axis is split into buckets. You can specify a time interval in the **Size** input. For example, a time range of `1h` makes the cells 1-hour wide on the X-axis.
This setting determines how the X-axis is split into buckets. You can specify a time interval in the **Size** input. For example, a time range of `1h` makes the cells 1-hour wide on the X-axis. If the value is a number only, the duration is in milliseconds.
### Y Bucket

@ -1,12 +1,13 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { SelectableValue, StandardEditorProps, VariableOrigin } from '@grafana/data';
import { getTemplateSrv, config as cfg } from '@grafana/runtime';
import { getTemplateSrv } from '@grafana/runtime';
import { HeatmapCalculationBucketConfig, HeatmapCalculationMode } from '@grafana/schema';
import { HorizontalGroup, Input, RadioButtonGroup, ScaleDistribution } from '@grafana/ui';
import { HorizontalGroup, RadioButtonGroup, ScaleDistribution } from '@grafana/ui';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../../utils';
import { convertDurationToMilliseconds } from '../utils';
const modeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
{
@ -32,12 +33,26 @@ const logModeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
export const AxisEditor = ({ value, onChange, item }: StandardEditorProps<HeatmapCalculationBucketConfig>) => {
const [isInvalid, setInvalid] = useState<boolean>(false);
const onValueChange = (bucketValue: string) => {
setInvalid(!numberOrVariableValidator(bucketValue));
onChange({
...value,
value: bucketValue,
});
const modeSwitchCounter = useRef(0);
const allowInterval = item.settings?.allowInterval ?? false;
const onValueChange = ({ mode, scale, value = '' }: HeatmapCalculationBucketConfig) => {
let isValid = true;
if (mode !== HeatmapCalculationMode.Count) {
if (!allowInterval) {
isValid = numberOrVariableValidator(value);
} else if (value !== '') {
let durationMS = convertDurationToMilliseconds(value);
if (durationMS === undefined) {
isValid = false;
}
}
}
setInvalid(!isValid);
onChange({ mode, scale, value });
};
const templateSrv = getTemplateSrv();
@ -51,33 +66,28 @@ export const AxisEditor = ({ value, onChange, item }: StandardEditorProps<Heatma
value={value?.mode || HeatmapCalculationMode.Size}
options={value?.scale?.type === ScaleDistribution.Log ? logModeOptions : modeOptions}
onChange={(mode) => {
onChange({
modeSwitchCounter.current++;
onValueChange({
...value,
value: '',
mode,
});
}}
/>
{cfg.featureToggles.transformationsVariableSupport ? (
<SuggestionsInput
invalid={isInvalid}
error={'Value needs to be an integer or a variable'}
value={value?.value ?? ''}
placeholder="Auto"
onChange={onValueChange}
suggestions={variables}
/>
) : (
<Input
value={value?.value ?? ''}
placeholder="Auto"
onChange={(v) => {
onChange({
...value,
value: v.currentTarget.value,
});
}}
/>
)}
<SuggestionsInput
// we need this cause the value prop is not changeable after init
// so we have to re-create the component during mode switches to reset the value to auto
key={modeSwitchCounter.current}
invalid={isInvalid}
error={'Value needs to be an integer or a variable'}
value={value?.value ?? ''}
placeholder="Auto"
onChange={(text) => {
onValueChange({ ...value, value: text });
}}
suggestions={variables}
/>
</HorizontalGroup>
);
};

@ -19,6 +19,9 @@ export function addHeatmapCalculationOptions(
defaultValue: {
mode: HeatmapCalculationMode.Size,
},
settings: {
allowInterval: true,
},
});
builder.addCustomEditor({

@ -12,8 +12,6 @@ import {
Field,
getValueFormat,
formattedValueToString,
durationToMilliseconds,
parseDuration,
TransformationApplicabilityLevels,
TimeRange,
} from '@grafana/data';
@ -26,7 +24,7 @@ import {
HeatmapCalculationOptions,
} from '@grafana/schema';
import { niceLinearIncrs, niceTimeIncrs } from './utils';
import { convertDurationToMilliseconds, niceLinearIncrs, niceTimeIncrs } from './utils';
export interface HeatmapTransformerOptions extends HeatmapCalculationOptions {
/** the raw values will still exist in results after transformation */
@ -329,7 +327,7 @@ export function calculateHeatmapFromData(
xMode: xBucketsCfg.mode,
xSize:
xBucketsCfg.mode === HeatmapCalculationMode.Size
? durationToMilliseconds(parseDuration(xBucketsCfg.value ?? ''))
? convertDurationToMilliseconds(xBucketsCfg.value ?? '')
: xBucketsCfg.value
? +xBucketsCfg.value
: undefined,

@ -0,0 +1,21 @@
import { convertDurationToMilliseconds } from './utils';
describe('Heatmap utils', () => {
const cases: Array<[string, number | undefined]> = [
['1', 1],
['6', 6],
['2.3', 2],
['1ms', 1],
['5MS', 5],
['1s', 1000],
['1.5s', undefined],
['1.2345s', undefined],
['one', undefined],
['20sec', undefined],
['', undefined],
];
test.each(cases)('convertToMilliseconds can correctly convert "%s"', (input, output) => {
expect(convertDurationToMilliseconds(input)).toEqual(output);
});
});

@ -1,4 +1,6 @@
import { guessDecimals, roundDecimals } from '@grafana/data';
import { durationToMilliseconds, guessDecimals, isValidDuration, parseDuration, roundDecimals } from '@grafana/data';
import { numberOrVariableValidator } from '../utils';
const { abs, pow } = Math;
@ -117,3 +119,26 @@ export const niceTimeIncrs = [
9 * year,
10 * year,
];
// convert a string to the number of milliseconds. valid inputs are a number, variable, or duration. duration in ms is supported.
// value out will always be an integer, as ms is the lowest granularity for heatmaps
export const convertDurationToMilliseconds = (duration: string): number | undefined => {
const isValidNumberOrVariable = numberOrVariableValidator(duration); // check if number only. if so, equals number of ms
if (isValidNumberOrVariable) {
const durationMs = Number.parseInt(duration, 10);
return Number.isNaN(durationMs) ? undefined : durationMs;
} else {
const validDuration = isValidDuration(duration); // check if non-ms duration. If so, convert value to number of ms
if (validDuration) {
return durationToMilliseconds(parseDuration(duration));
} else {
const match = duration.match(/(\d+)ms$/i);
if (match) {
const durationMs = Number.parseInt(match[1], 10);
return Number.isNaN(durationMs) ? undefined : durationMs;
} else {
return undefined;
}
}
}
};

@ -14,8 +14,12 @@ import {
ValueFormatter,
} from '@grafana/data';
import { parseSampleValue, sortSeriesByLabel } from '@grafana/prometheus';
import { config } from '@grafana/runtime';
import { HeatmapCellLayout } from '@grafana/schema';
import {
HeatmapCalculationMode,
HeatmapCalculationOptions,
HeatmapCellLayout,
ScaleDistribution,
} from '@grafana/schema';
import {
calculateHeatmapFromData,
isHeatmapCellsDense,
@ -98,36 +102,16 @@ export function prepareHeatmapData({
});
if (options.calculate) {
if (config.featureToggles.transformationsVariableSupport) {
const optionsCopy = {
...options,
calculation: {
xBuckets: { ...options.calculation?.xBuckets } ?? undefined,
yBuckets: { ...options.calculation?.yBuckets } ?? undefined,
},
};
if (optionsCopy.calculation?.xBuckets?.value && replaceVariables !== undefined) {
optionsCopy.calculation.xBuckets.value = replaceVariables(optionsCopy.calculation.xBuckets.value);
}
// if calculate is true, we need to have the default values for the calculation if they don't exist
let calculation = getCalculationObjectWithDefaults(options.calculation);
if (optionsCopy.calculation?.yBuckets?.value && replaceVariables !== undefined) {
optionsCopy.calculation.yBuckets.value = replaceVariables(optionsCopy.calculation.yBuckets.value);
}
return getDenseHeatmapData(
calculateHeatmapFromData(frames, { ...options.calculation, timeRange }),
exemplars,
optionsCopy,
palette,
theme
);
}
calculation.xBuckets.value = replaceVariables(calculation.xBuckets.value ?? '');
calculation.yBuckets.value = replaceVariables(calculation.yBuckets.value ?? '');
return getDenseHeatmapData(
calculateHeatmapFromData(frames, { ...options.calculation, timeRange }),
calculateHeatmapFromData(frames, { ...calculation, timeRange }),
exemplars,
options,
{ ...options, calculation },
palette,
theme
);
@ -207,6 +191,23 @@ export function prepareHeatmapData({
};
}
const getCalculationObjectWithDefaults = (calculation?: HeatmapCalculationOptions) => {
return {
xBuckets: {
...calculation?.xBuckets,
mode: calculation?.xBuckets?.mode ?? HeatmapCalculationMode.Size,
},
yBuckets: {
...calculation?.yBuckets,
mode: calculation?.yBuckets?.mode ?? HeatmapCalculationMode.Size,
scale: {
...calculation?.yBuckets?.scale,
type: calculation?.yBuckets?.scale?.type ?? ScaleDistribution.Linear,
},
},
};
};
const getSparseHeatmapData = (
frame: DataFrame,
exemplars: DataFrame | undefined,

Loading…
Cancel
Save