Compare commits

...

8 Commits

Author SHA1 Message Date
Paul Marbach
65e740fe9c Sparkline: Add warnings for invalid series, and add more test cases 2025-11-21 15:04:24 -05:00
Paul Marbach
7f100bf104 fix points mode rendering 2025-11-21 14:02:31 -05:00
Paul Marbach
76dfb8fbea Update Sparkline.test.tsx 2025-11-21 11:45:08 -05:00
Paul Marbach
19135016f9 remove unused import 2025-11-19 18:13:40 -05:00
Paul Marbach
6271b56247 add comments throughout for #112977 2025-11-19 18:03:28 -05:00
Paul Marbach
7aa58f690a refactor out utils, experiment with getting highlightIndex working 2025-11-19 17:55:40 -05:00
Paul Marbach
3e08e784c5 some tests for this case 2025-11-19 16:17:58 -05:00
Paul Marbach
f00b83f3b6 Sparkline: Prevent infinite loop when rendering a sparkline with a single value 2025-11-19 16:16:37 -05:00
7 changed files with 409 additions and 214 deletions

View File

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

View File

@@ -190,10 +190,62 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
y: dataFrame.fields[i],
x: timeField,
};
if (calc === ReducerID.last) {
sparkline.highlightIndex = sparkline.y.values.length - 1;
} else if (calc === ReducerID.first) {
sparkline.highlightIndex = 0;
let highlightIdx: number | undefined = (() => {
switch (calc) {
case ReducerID.last:
return sparkline.y.values.length - 1;
case ReducerID.first:
return 0;
// TODO: #112977 enable more reducers for highlight index
// case ReducerID.lastNotNull: {
// for (let k = sparkline.y.values.length - 1; k >= 0; k--) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v)) {
// return k;
// }
// }
// return;
// }
// case ReducerID.firstNotNull: {
// for (let k = 0; k < sparkline.y.values.length; k++) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v)) {
// return k;
// }
// }
// return;
// }
// case ReducerID.min: {
// let minIdx = -1;
// let prevMin = Infinity;
// for (let k = 0; k < sparkline.y.values.length; k++) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) {
// prevMin = v;
// minIdx = k;
// }
// }
// return minIdx >= 0 ? minIdx : undefined;
// }
// case ReducerID.max: {
// let maxIdx = -1;
// let prevMax = -Infinity;
// for (let k = 0; k < sparkline.y.values.length; k++) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) {
// prevMax = v;
// maxIdx = k;
// }
// }
// return maxIdx >= 0 ? maxIdx : undefined;
// }
default:
return;
}
})();
if (typeof highlightIdx === 'number') {
sparkline.highlightIndex = highlightIdx;
}
}

View File

@@ -2,7 +2,13 @@ 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,
getDisplayProcessor,
GrafanaTheme2,
TimeRange,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
@@ -66,6 +72,7 @@ export interface RadialGaugeProps {
showScaleLabels?: boolean;
/** For data links */
onClick?: React.MouseEventHandler<HTMLElement>;
timeRange?: TimeRange;
}
export type RadialGradientMode = 'none' | 'auto';

View File

@@ -1,30 +1,127 @@
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { createTheme, FieldSparkline, FieldType } from '@grafana/data';
import { createTheme, Field, FieldSparkline, FieldType } from '@grafana/data';
import { Sparkline } from './Sparkline';
describe('Sparkline', () => {
it('should render without throwing an error', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [1, 2, 3, 4, 5],
type: FieldType.number,
config: {},
state: {
range: { min: 1, max: 5, delta: 1 },
describe('renders without error', () => {
const numField = (name: string, numVals: number): Field => ({
name,
values: Array.from({ length: numVals }, (_, i) => i + 1),
type: FieldType.number,
config: {},
state:
numVals > 0
? {
range: { min: 1, max: numVals, delta: numVals - 1 },
}
: {},
});
const startTime = 1679839200000;
const timeField = (name: string, numVals: number): Field => ({
name,
values: Array.from({ length: numVals }, (_, i) => startTime + (i + 1) * 1000),
type: FieldType.time,
config: {},
state:
numVals > 0
? {
range: { min: 1, max: numVals, delta: numVals - 1 },
}
: {},
});
it.each<{ description: string; input: FieldSparkline; warning?: boolean }>([
{
description: 'x=time, y=number, 5 values',
input: {
x: timeField('x', 5),
y: numField('y', 5),
},
},
};
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
).not.toThrow();
{
description: 'x=time, y=number, 1 value',
input: {
x: timeField('x', 1),
y: numField('y', 1),
},
warning: true,
},
{
description: 'x=time, y=number, 0 values',
input: {
x: timeField('x', 0),
y: numField('y', 0),
},
warning: true,
},
{
description: 'x=time (unordered), y=number, 5 values',
input: {
x: {
...timeField('x', 5),
values: timeField('x', 5).values.reverse(),
},
y: timeField('y', 5),
},
warning: true,
},
{
description: 'x=number, y=number, 5 values',
input: {
x: numField('x', 5),
y: numField('y', 5),
},
},
{
description: 'x=number, y=number, 1 value',
input: {
x: numField('x', 1),
y: numField('y', 1),
},
warning: true,
},
{
description: 'x=number, y=number, 0 values',
input: {
x: numField('x', 0),
y: numField('y', 0),
},
warning: true,
},
{
description: 'y=number, 5 values',
input: {
y: numField('y', 5),
},
},
{
description: 'y=number, 1 value',
input: {
y: numField('y', 1),
},
warning: true,
},
{
description: 'y=number, 0 values',
input: {
y: numField('y', 0),
},
warning: true,
},
])('does not throw for "$description"', ({ input, warning }) => {
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={input} />)
).not.toThrow();
if (warning) {
expect(screen.getByRole('alert')).toBeInTheDocument();
} else {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByTestId('uplot-main-div')).toBeInTheDocument();
}
});
});
});

View File

@@ -1,32 +1,18 @@
import { isEqual } from 'lodash';
import { PureComponent } from 'react';
import { AlignedData, Range } from 'uplot';
import { css } from '@emotion/css';
import React, { memo } from 'react';
import {
compareDataFrameStructures,
DataFrame,
Field,
FieldConfig,
FieldSparkline,
FieldType,
getFieldColorModeForField,
nullToValue,
} from '@grafana/data';
import {
AxisPlacement,
GraphDrawStyle,
GraphFieldConfig,
VisibilityMode,
ScaleDirection,
ScaleOrientation,
} from '@grafana/schema';
import { colorManipulator, FieldConfig, FieldSparkline, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { GraphFieldConfig } from '@grafana/schema';
import { useStyles2 } from '../../themes/ThemeContext';
import { Themeable2 } from '../../types/theme';
import { Icon } from '../Icon/Icon';
import { Tooltip } from '../Tooltip/Tooltip';
import { UPlotChart } from '../uPlot/Plot';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { preparePlotData2, getStackingGroups } from '../uPlot/utils';
import { getYRange, preparePlotFrame } from './utils';
import { prepareSeries, prepareConfig } from './utils';
export interface SparklineProps extends Themeable2 {
width: number;
@@ -35,169 +21,65 @@ export interface SparklineProps extends Themeable2 {
sparkline: FieldSparkline;
}
interface State {
data: AlignedData;
alignedDataFrame: DataFrame;
configBuilder: UPlotConfigBuilder;
}
const CompactAlert = ({ children, width }: { width: number; children: string | React.ReactElement }) => {
const styles = useStyles2(getCompactAlertStyles);
const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
showPoints: VisibilityMode.Auto,
axisPlacement: AxisPlacement.Hidden,
pointSize: 2,
return (
<div role="alert" style={{ width, display: 'flex', justifyContent: 'center' }}>
{width >= 400 ? (
<div role="alert" className={styles.content}>
<Icon className={styles.icon} name="exclamation-triangle" />
{children}
</div>
) : (
<Tooltip content={children} placement="top">
<div className={styles.content}>
<Icon className={styles.icon} size="lg" name="exclamation-triangle" />
<Trans i18nKey="grafana-ui.components.sparkline.alert.title">Cannot render sparkline</Trans>
</div>
</Tooltip>
)}
</div>
);
};
/** @internal */
export class Sparkline extends PureComponent<SparklineProps, State> {
constructor(props: SparklineProps) {
super(props);
const getCompactAlertStyles = (theme: GrafanaTheme2) => ({
content: css({
margin: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
padding: theme.spacing(0.5, 1),
color: theme.colors.warning.contrastText,
background: colorManipulator.alpha(theme.colors.warning.main, 0.85),
borderRadius: theme.shape.radius.default,
display: 'flex',
justifyContent: 'center',
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
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]];
}
return [0, sparkline.y.values.length - 1];
'& a': {
color: theme.colors.warning.contrastText,
textDecoration: 'underline',
'&:hover': {
textDecoration: 'none',
},
});
},
}),
icon: css({
marginRight: theme.spacing(1),
}),
});
builder.addAxis({
scaleKey: 'x',
theme,
placement: AxisPlacement.Hidden,
});
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height } = props;
for (let i = 0; i < data.fields.length; i++) {
const field = data.fields[i];
const config: FieldConfig<GraphFieldConfig> = 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: () => 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;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
if (warning) {
return <CompactAlert width={width}>{warning}</CompactAlert>;
}
render() {
const { data, configBuilder } = this.state;
const { width, height } = this.props;
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
}
}
const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame));
const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme);
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
Sparkline.displayName = 'Sparkline';

View File

@@ -1,16 +1,29 @@
import { Range } from 'uplot';
import {
applyNullInsertThreshold,
DataFrame,
Field,
FieldConfig,
FieldSparkline,
FieldType,
getFieldColorModeForField,
GrafanaTheme2,
isLikelyAscendingVector,
nullToValue,
sortDataFrame,
applyNullInsertThreshold,
Field,
} from '@grafana/data';
import { GraphFieldConfig } from '@grafana/schema';
import { t } from '@grafana/i18n';
import {
AxisPlacement,
GraphDrawStyle,
GraphFieldConfig,
VisibilityMode,
ScaleDirection,
ScaleOrientation,
} from '@grafana/schema';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
/** @internal
* Given a sparkline config returns a DataFrame ready to be turned into Plot data set
@@ -85,3 +98,141 @@ export function getYRange(field: Field, alignedFrame: DataFrame): Range.MinMax {
return [min, max];
}
// TODO: #112977 enable highlight index
// const HIGHLIGHT_IDX_POINT_SIZE = 6;
const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
showPoints: VisibilityMode.Auto,
axisPlacement: AxisPlacement.Hidden,
pointSize: 2,
};
export const prepareSeries = (
sparkline: FieldSparkline,
fieldConfig?: FieldConfig<GraphFieldConfig>
): { frame: DataFrame; warning?: string } => {
const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig));
if (frame.fields.some((f) => f.values.length <= 1)) {
return {
warning: t(
'grafana-ui.components.sparkline.warning.too-few-values',
'Sparkline requires at least two values to render.'
),
frame,
};
}
if (sparkline.x && !isLikelyAscendingVector(sparkline.x.values)) {
return {
warning: t(
'grafana-ui.components.sparkline.warning.x-not-ascending',
"The data in your Sparkline's x series must be sorted in ascending order."
),
frame,
};
}
return { frame };
};
export const prepareConfig = (
sparkline: FieldSparkline,
dataFrame: DataFrame,
theme: GrafanaTheme2
): UPlotConfigBuilder => {
const builder = new UPlotConfigBuilder();
// const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2;
builder.setCursor({
show: false,
x: false, // no crosshairs
y: false,
});
// 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, // xField.type === FieldType.time,
range: () => {
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]];
}
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<GraphFieldConfig> = 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,
theme,
placement: AxisPlacement.Hidden,
});
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
// TODO: #112977 enable highlight index and adjust padding accordingly
// const hasHighlightIndex = typeof sparkline.highlightIndex === 'number';
// if (hasHighlightIndex) {
// builder.setPadding([rangePad, rangePad, rangePad, rangePad]);
// }
const pointsMode =
customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex
? 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,
// TODO: #112977 enable highlight index
pointSize: /* hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : */ customConfig.pointSize,
// pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined,
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
gradientMode: customConfig.gradientMode,
spanNulls: customConfig.spanNulls,
});
}
return builder;
};

View File

@@ -8693,6 +8693,17 @@
"aria-label-default": "Pick a color",
"aria-label-selected-color": "{{colorLabel}} color"
},
"components": {
"sparkline": {
"alert": {
"title": "Cannot render sparkline"
},
"warning": {
"too-few-values": "Sparkline requires at least two values to render.",
"x-not-ascending": "The data in your Sparkline's x series must be sorted in ascending order."
}
}
},
"confirm-button": {
"aria-label-delete": "Delete",
"cancel": "Cancel",