From f00b83f3b6f351174a4d35e9e8fb6ebe34ec342f Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 19 Nov 2025 16:16:37 -0500 Subject: [PATCH] Sparkline: Prevent infinite loop when rendering a sparkline with a single value --- eslint-suppressions.json | 5 - .../components/RadialGauge/RadialGauge.tsx | 13 +- .../src/components/Sparkline/Sparkline.tsx | 240 +++++++----------- .../panel/suggestions/getAllSuggestions.ts | 26 +- 4 files changed, 115 insertions(+), 169 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 84e5d786552..aa87853a621 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -833,11 +833,6 @@ "count": 13 } }, - "packages/grafana-ui/src/components/Sparkline/Sparkline.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 3dcc25e8875..f0043c3fe09 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -2,7 +2,14 @@ import { css, cx } from '@emotion/css'; import { isNumber } from 'lodash'; import { useId } from 'react'; -import { DisplayValueAlignmentFactors, FieldDisplay, getDisplayProcessor, GrafanaTheme2 } from '@grafana/data'; +import { + DisplayValueAlignmentFactors, + FieldDisplay, + FieldType, + getDisplayProcessor, + GrafanaTheme2, + TimeRange, +} from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; @@ -66,6 +73,7 @@ export interface RadialGaugeProps { showScaleLabels?: boolean; /** For data links */ onClick?: React.MouseEventHandler; + timeRange?: TimeRange; } export type RadialGradientMode = 'none' | 'auto'; @@ -240,6 +248,9 @@ export function RadialGauge(props: RadialGaugeProps) { } if (displayValue.sparkline) { + if (props.timeRange && displayValue.sparkline.x?.type === FieldType.time) { + displayValue.sparkline.timeRange = props.timeRange; + } sparklineElement = ( { - constructor(props: SparklineProps) { - super(props); +const prepareConfig = (sparkline: FieldSparkline, dataFrame: DataFrame, theme: GrafanaTheme2): UPlotConfigBuilder => { + const builder = new UPlotConfigBuilder(); - const alignedDataFrame = preparePlotFrame(props.sparkline, props.config); + builder.setCursor({ + show: false, + x: false, + y: false, + }); - this.state = { - data: preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)), - alignedDataFrame, - configBuilder: this.prepareConfig(alignedDataFrame), - }; - } - - static getDerivedStateFromProps(props: SparklineProps, state: State) { - const _frame = preparePlotFrame(props.sparkline, props.config); - const frame = nullToValue(_frame); - if (!frame) { - return { ...state }; - } - - return { - ...state, - data: preparePlotData2(frame, getStackingGroups(frame)), - alignedDataFrame: frame, - }; - } - - componentDidUpdate(prevProps: SparklineProps, prevState: State) { - const { alignedDataFrame } = this.state; - - if (!alignedDataFrame) { - return; - } - - let rebuildConfig = false; - - if (prevProps.sparkline !== this.props.sparkline) { - const isStructureChanged = !compareDataFrameStructures(this.state.alignedDataFrame, prevState.alignedDataFrame); - const isRangeChanged = !isEqual( - alignedDataFrame.fields[1].state?.range, - prevState.alignedDataFrame.fields[1].state?.range - ); - rebuildConfig = isStructureChanged || isRangeChanged; - } else { - rebuildConfig = !isEqual(prevProps.config, this.props.config); - } - - if (rebuildConfig) { - this.setState({ configBuilder: this.prepareConfig(alignedDataFrame) }); - } - } - - getYRange(field: Field): Range.MinMax { - return getYRange(field, this.state.alignedDataFrame); - } - - prepareConfig(data: DataFrame) { - const { theme } = this.props; - const builder = new UPlotConfigBuilder(); - - builder.setCursor({ - show: false, - x: false, // no crosshairs - y: false, - }); - - // X is the first field in the alligned frame - const xField = data.fields[0]; - builder.addScale({ - scaleKey: 'x', - orientation: ScaleOrientation.Horizontal, - direction: ScaleDirection.Right, - isTime: false, //xField.type === FieldType.time, - range: () => { - const { sparkline } = this.props; - if (sparkline.x) { - if (sparkline.timeRange && sparkline.x.type === FieldType.time) { - return [sparkline.timeRange.from.valueOf(), sparkline.timeRange.to.valueOf()]; - } - const vals = sparkline.x.values; - return [vals[0], vals[vals.length - 1]]; + // X is the first field in the aligned frame + const xField = dataFrame.fields[0]; + builder.addScale({ + scaleKey: 'x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + isTime: false, + range: () => { + if (sparkline.x) { + if (sparkline.timeRange && sparkline.x.type === FieldType.time) { + return [sparkline.timeRange.from.valueOf(), sparkline.timeRange.to.valueOf()]; } - return [0, sparkline.y.values.length - 1]; - }, + const vals = sparkline.x.values; + return [vals[0], vals[vals.length - 1]]; + } + return [0, sparkline.y.values.length - 1]; + }, + }); + + builder.addAxis({ + scaleKey: 'x', + theme, + placement: AxisPlacement.Hidden, + }); + + for (let i = 0; i < dataFrame.fields.length; i++) { + const field = dataFrame.fields[i]; + const config: FieldConfig = field.config; + const customConfig: GraphFieldConfig = { + ...defaultConfig, + ...config.custom, + }; + + if (field === xField || field.type !== FieldType.number) { + continue; + } + + const scaleKey = config.unit || '__fixed'; + builder.addScale({ + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + range: () => getYRange(field, dataFrame), }); builder.addAxis({ - scaleKey: 'x', + scaleKey, theme, placement: AxisPlacement.Hidden, }); - for (let i = 0; i < data.fields.length; i++) { - const field = data.fields[i]; - const config: FieldConfig = field.config; - const customConfig: GraphFieldConfig = { - ...defaultConfig, - ...config.custom, - }; + const colorMode = getFieldColorModeForField(field); + const seriesColor = colorMode.getCalculator(field, theme)(0, 0); + const pointsMode = + customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints; - if (field === xField || field.type !== FieldType.number) { - continue; - } - - const scaleKey = config.unit || '__fixed'; - builder.addScale({ - scaleKey, - orientation: ScaleOrientation.Vertical, - direction: ScaleDirection.Up, - range: () => this.getYRange(field), - }); - - builder.addAxis({ - scaleKey, - theme, - placement: AxisPlacement.Hidden, - }); - - const colorMode = getFieldColorModeForField(field); - const seriesColor = colorMode.getCalculator(field, theme)(0, 0); - const pointsMode = - customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints; - - builder.addSeries({ - pxAlign: false, - scaleKey, - theme, - colorMode, - thresholds: config.thresholds, - drawStyle: customConfig.drawStyle!, - lineColor: customConfig.lineColor ?? seriesColor, - lineWidth: customConfig.lineWidth, - lineInterpolation: customConfig.lineInterpolation, - showPoints: pointsMode, - pointSize: customConfig.pointSize, - fillOpacity: customConfig.fillOpacity, - fillColor: customConfig.fillColor, - lineStyle: customConfig.lineStyle, - gradientMode: customConfig.gradientMode, - spanNulls: customConfig.spanNulls, - }); - } - - return builder; + builder.addSeries({ + pxAlign: false, + scaleKey, + theme, + colorMode, + thresholds: config.thresholds, + drawStyle: customConfig.drawStyle!, + lineColor: customConfig.lineColor ?? seriesColor, + lineWidth: customConfig.lineWidth, + lineInterpolation: customConfig.lineInterpolation, + showPoints: pointsMode, + pointSize: customConfig.pointSize, + fillOpacity: customConfig.fillOpacity, + fillColor: customConfig.fillColor, + lineStyle: customConfig.lineStyle, + gradientMode: customConfig.gradientMode, + spanNulls: customConfig.spanNulls, + }); } - render() { - const { data, configBuilder } = this.state; - const { width, height } = this.props; - return ; + return builder; +}; + +export const Sparkline: React.FC = memo((props) => { + const { sparkline, config: fieldConfig, theme, width, height } = props; + const alignedDataFrame = nullToValue(preparePlotFrame(sparkline, fieldConfig)); + // do not render sparklines for fields with 1 or less values - this can cause an infinite loop in uPlot + if (alignedDataFrame.fields.some((f) => f.values.length <= 1)) { + return null; } -} + + const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)); + const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme); + + return ; +}); +Sparkline.displayName = 'Sparkline'; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index 86fb0e17bde..aecbd0ff0e5 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -9,20 +9,20 @@ import { config } from '@grafana/runtime'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; export const panelsToCheckFirst = [ - 'timeseries', - 'barchart', + // 'timeseries', + // 'barchart', 'gauge', - 'stat', - 'piechart', - 'bargauge', - 'table', - 'state-timeline', - 'status-history', - 'logs', - 'candlestick', - 'flamegraph', - 'traces', - 'nodeGraph', + // 'stat', + // 'piechart', + // 'bargauge', + // 'table', + // 'state-timeline', + // 'status-history', + // 'logs', + // 'candlestick', + // 'flamegraph', + // 'traces', + // 'nodeGraph', ]; export async function getAllSuggestions(