From 53052def520230f76ab09b97863e7118d83a46a2 Mon Sep 17 00:00:00 2001 From: Kristina Date: Mon, 2 Dec 2024 13:08:45 -0600 Subject: [PATCH] Heatmap: Fix ability to define bucket size as an interval string, like 30s (#95923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../visualizations/heatmap/index.md | 2 +- .../calculateHeatmap/editor/AxisEditor.tsx | 72 +++++++++++-------- .../calculateHeatmap/editor/helper.ts | 3 + .../transformers/calculateHeatmap/heatmap.ts | 6 +- .../calculateHeatmap/utils.test.ts | 21 ++++++ .../transformers/calculateHeatmap/utils.ts | 27 ++++++- public/app/plugins/panel/heatmap/fields.ts | 57 +++++++-------- 7 files changed, 123 insertions(+), 65 deletions(-) create mode 100644 public/app/features/transformers/calculateHeatmap/utils.test.ts diff --git a/docs/sources/panels-visualizations/visualizations/heatmap/index.md b/docs/sources/panels-visualizations/visualizations/heatmap/index.md index 1194c036c0a..bd4eafef8c7 100644 --- a/docs/sources/panels-visualizations/visualizations/heatmap/index.md +++ b/docs/sources/panels-visualizations/visualizations/heatmap/index.md @@ -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 diff --git a/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx index 2c9e72dc6ba..c45de35235b 100644 --- a/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx +++ b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx @@ -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> = [ { @@ -32,12 +33,26 @@ const logModeOptions: Array> = [ export const AxisEditor = ({ value, onChange, item }: StandardEditorProps) => { const [isInvalid, setInvalid] = useState(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 { - onChange({ + modeSwitchCounter.current++; + + onValueChange({ ...value, + value: '', mode, }); }} /> - {cfg.featureToggles.transformationsVariableSupport ? ( - - ) : ( - { - onChange({ - ...value, - value: v.currentTarget.value, - }); - }} - /> - )} + { + onValueChange({ ...value, value: text }); + }} + suggestions={variables} + /> ); }; diff --git a/public/app/features/transformers/calculateHeatmap/editor/helper.ts b/public/app/features/transformers/calculateHeatmap/editor/helper.ts index 707884dbadd..51df063fddb 100644 --- a/public/app/features/transformers/calculateHeatmap/editor/helper.ts +++ b/public/app/features/transformers/calculateHeatmap/editor/helper.ts @@ -19,6 +19,9 @@ export function addHeatmapCalculationOptions( defaultValue: { mode: HeatmapCalculationMode.Size, }, + settings: { + allowInterval: true, + }, }); builder.addCustomEditor({ diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index 538b2c9a97a..068e9a1c3ec 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -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, diff --git a/public/app/features/transformers/calculateHeatmap/utils.test.ts b/public/app/features/transformers/calculateHeatmap/utils.test.ts new file mode 100644 index 00000000000..8ad808ffb1e --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/utils.test.ts @@ -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); + }); +}); diff --git a/public/app/features/transformers/calculateHeatmap/utils.ts b/public/app/features/transformers/calculateHeatmap/utils.ts index 07a457d2361..e7bc2164ee5 100644 --- a/public/app/features/transformers/calculateHeatmap/utils.ts +++ b/public/app/features/transformers/calculateHeatmap/utils.ts @@ -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; + } + } + } +}; diff --git a/public/app/plugins/panel/heatmap/fields.ts b/public/app/plugins/panel/heatmap/fields.ts index 534d963e92e..4fbf6097c77 100644 --- a/public/app/plugins/panel/heatmap/fields.ts +++ b/public/app/plugins/panel/heatmap/fields.ts @@ -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,