Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
cec8b9c9a7 fix(uplot-chart-data): ensure anomaly response does not crash chart 2026-04-20 18:31:50 -03:00
3 changed files with 493 additions and 16 deletions

View File

@@ -76,9 +76,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
if (widget.query.queryType) {
updatedQuery.queryType = widget.query.queryType;
}
// If widget has a y-axis unit, set it to the updated query if it is not already set
if (widget.yAxisUnit && !isEmpty(widget.yAxisUnit)) {
updatedQuery.unit = widget.yAxisUnit;

View File

@@ -112,14 +112,15 @@ export const getUPlotChartData = (
const processAnomalyDetectionData = (
anomalyDetectionData: any,
isDarkMode: boolean,
): Record<string, { data: number[][]; color: string }> => {
// eslint-disable-next-line sonarjs/cognitive-complexity
): Record<string, { data: (number | null)[][]; color: string }> => {
if (!anomalyDetectionData) {
return {};
}
const processedData: Record<
string,
{ data: number[][]; color: string; legendLabel: string }
{ data: (number | null)[][]; color: string; legendLabel: string }
> = {};
for (
@@ -148,24 +149,30 @@ const processAnomalyDetectionData = (
anomalyDetectionData.length > 1 ? `${queryName}-${label}` : label;
// Single iteration instead of 5 separate map operations
const { values: seriesValues } = series[index];
const { values: predictedValues } = predictedSeries[index];
const { values: upperBoundValues } = upperBoundSeries[index];
const { values: lowerBoundValues } = lowerBoundSeries[index];
const { values: seriesValues } = series?.[index] || { values: [] };
const { values: predictedValues } = predictedSeries?.[index] || {
values: [],
};
const { values: upperBoundValues } = upperBoundSeries?.[index] || {
values: [],
};
const { values: lowerBoundValues } = lowerBoundSeries?.[index] || {
values: [],
};
const length = seriesValues.length;
const timestamps: number[] = new Array(length);
const values: number[] = new Array(length);
const predicted: number[] = new Array(length);
const upperBound: number[] = new Array(length);
const lowerBound: number[] = new Array(length);
const predicted: (number | null)[] = new Array(length);
const upperBound: (number | null)[] = new Array(length);
const lowerBound: (number | null)[] = new Array(length);
for (let i = 0; i < length; i++) {
timestamps[i] = seriesValues[i].timestamp / 1000;
values[i] = seriesValues[i].value;
predicted[i] = predictedValues[i].value;
upperBound[i] = upperBoundValues[i].value;
lowerBound[i] = lowerBoundValues[i].value;
predicted[i] = predictedValues[i]?.value ?? null;
upperBound[i] = upperBoundValues[i]?.value ?? null;
lowerBound[i] = lowerBoundValues[i]?.value ?? null;
}
processedData[objKey] = {
@@ -185,7 +192,10 @@ const processAnomalyDetectionData = (
export const getUplotChartDataForAnomalyDetection = (
apiResponse: MetricRangePayloadProps,
isDarkMode: boolean,
): Record<string, { [x: string]: any; data: number[][]; color: string }> => {
): Record<
string,
{ [x: string]: any; data: (number | null)[][]; color: string }
> => {
const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result;
return processAnomalyDetectionData(anomalyDetectionData, isDarkMode);
};

View File

@@ -0,0 +1,470 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
getUPlotChartData,
getUplotChartDataForAnomalyDetection,
} from '../getUplotChartData';
describe('getUPlotChartData', () => {
test('should return empty array with timestamps when no series data', () => {
const result = getUPlotChartData(undefined);
expect(result).toEqual([[]]);
});
test('should return timestamps and values for single series', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '20'],
[3000, '30'],
],
queryName: 'A',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
expect(result[0]).toEqual([1000, 2000, 3000]); // timestamps
expect(result[1]).toEqual([10, 20, 30]); // values
});
test('should handle multiple series with different timestamps', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '20'],
],
queryName: 'A',
legend: '',
},
{
metric: {},
values: [
[1000, '100'],
[3000, '300'],
],
queryName: 'B',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
// All unique timestamps sorted
expect(result[0]).toEqual([1000, 2000, 3000]);
// First series: has value at 1000, 2000, missing at 3000
expect(result[1]).toEqual([10, 20, null]);
// Second series: has value at 1000, missing at 2000, has at 3000
expect(result[2]).toEqual([100, null, 300]);
});
test('should handle stacked bar chart', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '20'],
],
queryName: 'A',
legend: '',
},
{
metric: {},
values: [
[1000, '5'],
[2000, '10'],
],
queryName: 'B',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse, false, true);
expect(result[0]).toEqual([1000, 2000]); // timestamps
// Stacked: first series = its value + second series value
expect(result[1]).toEqual([15, 30]); // 10+5, 20+10
expect(result[2]).toEqual([5, 10]); // second series unchanged
});
test('should handle invalid values like +Inf', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '+Inf'],
[3000, 'NaN'],
],
queryName: 'A',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
expect(result[0]).toEqual([1000, 2000, 3000]);
expect(result[1]).toEqual([10, null, null]); // Invalid values become null
});
test('should handle series with empty values array', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [],
queryName: 'A',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
expect(result[0]).toEqual([]); // No timestamps
expect(result[1]).toEqual([]); // Empty values
});
});
describe('getUplotChartDataForAnomalyDetection', () => {
const createSeriesItem = (
labels: Record<string, string>,
values: Array<{ timestamp: number; value: string }>,
) => ({
labels,
labelsArray: Object.entries(labels).map(([k, v]) => ({ [k]: v })),
values,
});
const createAnomalyResponse = (
overrides: Partial<{
series: ReturnType<typeof createSeriesItem>[];
predictedSeries: ReturnType<typeof createSeriesItem>[];
upperBoundSeries: ReturnType<typeof createSeriesItem>[];
lowerBoundSeries: ReturnType<typeof createSeriesItem>[];
queryName: string;
legend: string;
}> = {},
): MetricRangePayloadProps =>
(({
data: {
newResult: {
data: {
result: [
{
series: overrides.series ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '10' },
{ timestamp: 2000000, value: '20' },
]),
],
predictedSeries: overrides.predictedSeries ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '12' },
{ timestamp: 2000000, value: '22' },
]),
],
upperBoundSeries: overrides.upperBoundSeries ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '15' },
{ timestamp: 2000000, value: '25' },
]),
],
lowerBoundSeries: overrides.lowerBoundSeries ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '8' },
{ timestamp: 2000000, value: '18' },
]),
],
queryName: overrides.queryName ?? 'A',
legend: overrides.legend ?? '',
},
],
},
},
resultType: 'matrix',
},
} as unknown) as MetricRangePayloadProps);
test('should return empty object when anomalyDetectionData is undefined', () => {
const result = getUplotChartDataForAnomalyDetection(
{ data: { resultType: 'matrix' } } as MetricRangePayloadProps,
true,
);
expect(result).toEqual({});
});
test('should process anomaly detection data correctly', () => {
const apiResponse = createAnomalyResponse({});
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
expect(Object.keys(result)).toHaveLength(1);
const seriesKey = Object.keys(result)[0];
const seriesData = result[seriesKey];
// Data should have 5 arrays: timestamps, values, predicted, upperBound, lowerBound
expect(seriesData.data).toHaveLength(5);
// Timestamps (converted from ms to seconds)
expect(seriesData.data[0]).toEqual([1000, 2000]);
// Values (stored as strings from API)
expect(seriesData.data[1]).toEqual(['10', '20']);
// Predicted
expect(seriesData.data[2]).toEqual(['12', '22']);
// Upper bound
expect(seriesData.data[3]).toEqual(['15', '25']);
// Lower bound
expect(seriesData.data[4]).toEqual(['8', '18']);
expect(seriesData.color).toBeDefined();
expect(seriesData.legendLabel).toBeDefined();
});
test('should handle multiple queries with unique keys', () => {
const apiResponse = ({
data: {
newResult: {
data: {
result: [
{
series: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '10' },
]),
],
predictedSeries: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '12' },
]),
],
upperBoundSeries: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '15' },
]),
],
lowerBoundSeries: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '8' },
]),
],
queryName: 'QueryA',
legend: '',
},
{
series: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '100' },
]),
],
predictedSeries: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '120' },
]),
],
upperBoundSeries: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '150' },
]),
],
lowerBoundSeries: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '80' },
]),
],
queryName: 'QueryB',
legend: '',
},
],
},
},
resultType: 'matrix',
},
} as unknown) as MetricRangePayloadProps;
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
// Should have 2 series with queryName prefix since multiple queries
expect(Object.keys(result)).toHaveLength(2);
expect(Object.keys(result).some((key) => key.startsWith('QueryA-'))).toBe(
true,
);
expect(Object.keys(result).some((key) => key.startsWith('QueryB-'))).toBe(
true,
);
});
// Issue #11022: Anomaly alert preview bug with empty prediction series
describe('Issue #11022 - Empty prediction series handling', () => {
test('should not crash when predictedSeries is empty array', () => {
const apiResponse = createAnomalyResponse({
predictedSeries: [],
});
// This should not throw
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Should still have valid structure with null predicted values
expect(result[seriesKey].data).toHaveLength(5);
// Predicted values should be null when predictedSeries is empty
expect(result[seriesKey].data[2]).toEqual([null, null]);
});
test('should not crash when upperBoundSeries is empty array', () => {
const apiResponse = createAnomalyResponse({
upperBoundSeries: [],
});
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Upper bound values should be null when upperBoundSeries is empty
expect(result[seriesKey].data[3]).toEqual([null, null]);
});
test('should not crash when lowerBoundSeries is empty array', () => {
const apiResponse = createAnomalyResponse({
lowerBoundSeries: [],
});
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Lower bound values should be null when lowerBoundSeries is empty
expect(result[seriesKey].data[4]).toEqual([null, null]);
});
test('should not crash when all bound series are empty arrays', () => {
const apiResponse = createAnomalyResponse({
predictedSeries: [],
upperBoundSeries: [],
lowerBoundSeries: [],
});
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Should have structure with values from series but null predictions/bounds
expect(result[seriesKey].data).toHaveLength(5);
expect(result[seriesKey].data[0]).toEqual([1000, 2000]); // timestamps from series
expect(result[seriesKey].data[1]).toEqual(['10', '20']); // values from series (strings from API)
expect(result[seriesKey].data[2]).toEqual([null, null]); // predicted null
expect(result[seriesKey].data[3]).toEqual([null, null]); // upper bound null
expect(result[seriesKey].data[4]).toEqual([null, null]); // lower bound null
});
test('should not crash when series exists but prediction arrays have fewer elements', () => {
const apiResponse = ({
data: {
newResult: {
data: {
result: [
{
series: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '10' },
{ timestamp: 2000000, value: '20' },
]),
createSeriesItem({ service: 'test2' }, [
{ timestamp: 1000000, value: '30' },
]),
],
// Only one element in prediction arrays, but series has two
predictedSeries: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '12' },
]),
],
upperBoundSeries: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '15' },
]),
],
lowerBoundSeries: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '8' },
]),
],
queryName: 'A',
legend: '',
},
],
},
},
resultType: 'matrix',
},
} as unknown) as MetricRangePayloadProps;
// This tests the case where series[1] exists but predictedSeries[1] is undefined
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
// Should have 2 series processed
expect(Object.keys(result)).toHaveLength(2);
// The second series should have null values for predictions/bounds
const secondKey = Object.keys(result).find((k) =>
k.includes('test2'),
) as string;
expect(result[secondKey].data[2]).toEqual([null]); // predicted
expect(result[secondKey].data[3]).toEqual([null]); // upper bound
expect(result[secondKey].data[4]).toEqual([null]); // lower bound
});
});
});