diff --git a/public/pages/AnomalyCharts/utils/__tests__/anomalyChartUtils.test.ts b/public/pages/AnomalyCharts/utils/__tests__/anomalyChartUtils.test.ts index db37f67f..be0010b1 100644 --- a/public/pages/AnomalyCharts/utils/__tests__/anomalyChartUtils.test.ts +++ b/public/pages/AnomalyCharts/utils/__tests__/anomalyChartUtils.test.ts @@ -15,6 +15,8 @@ import { getAnomalySummary, convertAlerts, generateAlertAnnotations, + buildHeatmapPlotData, + ANOMALY_HEATMAP_COLORSCALE, } from '../anomalyChartUtils'; import { httpClientMock, coreServicesMock } from '../../../../../test/mocks'; import { MonitorAlert } from '../../../../models/interfaces'; @@ -158,3 +160,91 @@ describe('anomalyChartUtils function tests', () => { ]); }); }); + + +describe('buildHeatmapPlotData', () => { + it('should build the heatmap plot data correctly', () => { + const x = ['05-13 06:58:52 2024']; + const y = ['Exception while fetching data\napp_2']; + const z = [0.1]; + const anomalyOccurrences = [1]; + const entityLists = [ + [ + { name: 'error', value: 'Exception while fetching data' }, + { name: 'service', value: 'app_2' }, + ], + ]; + const cellTimeInterval = 10; + + const expected = { + x: x, + y: y, + z: z, + colorscale: ANOMALY_HEATMAP_COLORSCALE, + zmin: 0, + zmax: 1, + type: 'heatmap', + showscale: false, + xgap: 2, + ygap: 2, + opacity: 1, + text: anomalyOccurrences, + customdata: entityLists, + hovertemplate: + 'Entities: %{y}
' + + 'Time: %{x}
' + + 'Max anomaly grade: %{z}
' + + 'Anomaly occurrences: %{text}' + + '', + cellTimeInterval: cellTimeInterval, + }; + + const result = buildHeatmapPlotData(x, y, z, anomalyOccurrences, entityLists, cellTimeInterval); + expect(result).toEqual(expected); + }); + + it('should handle multiple entries correctly', () => { + const x = ['05-13 06:58:52 2024', '05-13 07:58:52 2024']; + const y = ['Exception while fetching data\napp_2', 'Network error\napp_3']; + const z = [0.1, 0.2]; + const anomalyOccurrences = [1, 2]; + const entityLists = [ + [ + { name: 'error', value: 'Exception while fetching data' }, + { name: 'service', value: 'app_2' }, + ], + [ + { name: 'error', value: 'Network error' }, + { name: 'service', value: 'app_3' }, + ], + ]; + const cellTimeInterval = 10; + + const expected = { + x: x, + y: y, + z: z, + colorscale: ANOMALY_HEATMAP_COLORSCALE, + zmin: 0, + zmax: 1, + type: 'heatmap', + showscale: false, + xgap: 2, + ygap: 2, + opacity: 1, + text: anomalyOccurrences, + customdata: entityLists, + hovertemplate: + 'Entities: %{y}
' + + 'Time: %{x}
' + + 'Max anomaly grade: %{z}
' + + 'Anomaly occurrences: %{text}' + + '', + cellTimeInterval: cellTimeInterval, + }; + + const result = buildHeatmapPlotData(x, y, z, anomalyOccurrences, entityLists, cellTimeInterval); + expect(result).toEqual(expected); + }); +}); + diff --git a/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx b/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx index b2603ada..ed79814d 100644 --- a/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx +++ b/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx @@ -310,7 +310,19 @@ export const getSampleAnomaliesHeatmapData = ( return [resultPlotData]; }; -const buildHeatmapPlotData = ( + +/** + * Builds the data for a heatmap plot representing anomalies. + * + * @param {any[]} x - The x coordinate value for the cell representing time. + * @param {any[]} y - Array of newline-separated name-value pairs representing entities. This is used for the y-axis labels and displayed in the mouse hover tooltip. + * @param {any[]} z - Array representing the maximum anomaly grades. + * @param {any[]} anomalyOccurrences - Array representing the number of anomalies. + * @param {any[]} entityLists - JSON representation of name-value pairs. Note that the values may contain special characters such as commas and newlines. JSON is used here because it naturally handles special characters and nested structures. + * @param {number} cellTimeInterval - The interval covered by each heatmap cell. + * @returns {PlotData} - The data structure required for plotting the heatmap. + */ +export const buildHeatmapPlotData = ( x: any[], y: any[], z: any[], @@ -334,7 +346,7 @@ const buildHeatmapPlotData = ( text: anomalyOccurrences, customdata: entityLists, hovertemplate: - 'Entities: %{customdata}
' + + 'Entities: %{y}
' + 'Time: %{x}
' + 'Max anomaly grade: %{z}
' + 'Anomaly occurrences: %{text}' + diff --git a/public/pages/utils/__tests__/anomalyResultUtils.test.ts b/public/pages/utils/__tests__/anomalyResultUtils.test.ts index 7c393a7e..70ab98f1 100644 --- a/public/pages/utils/__tests__/anomalyResultUtils.test.ts +++ b/public/pages/utils/__tests__/anomalyResultUtils.test.ts @@ -14,6 +14,8 @@ import { getFeatureDataPointsForDetector, parsePureAnomalies, buildParamsForGetAnomalyResultsWithDateRange, + transformEntityListsForHeatmap, + convertHeatmapCellEntityStringToEntityList, } from '../anomalyResultUtils'; import { getRandomDetector } from '../../../redux/reducers/__tests__/utils'; import { @@ -25,6 +27,8 @@ import { import { ANOMALY_RESULT_SUMMARY, PARSED_ANOMALIES } from './constants'; import { MAX_ANOMALIES } from '../../../utils/constants'; import { SORT_DIRECTION, AD_DOC_FIELDS } from '../../../../server/utils/constants'; +import { Entity } from '../../../../server/models/interfaces'; +import { NUM_CELLS } from '../../AnomalyCharts/utils/anomalyChartUtils' describe('anomalyResultUtils', () => { let randomDetector_20_min: Detector; @@ -636,4 +640,54 @@ describe('anomalyResultUtils', () => { expect(parsedPureAnomalies).toStrictEqual(PARSED_ANOMALIES); }); }); + + describe('transformEntityListsForHeatmap', () => { + it('should transform an empty entityLists array to an empty array', () => { + const entityLists: Entity[][] = []; + const result = transformEntityListsForHeatmap(entityLists); + expect(result).toEqual([]); + const convertedBack = convertHeatmapCellEntityStringToEntityList("[]"); + expect([]).toEqual(convertedBack); + }); + + it('should transform a single entity list correctly', () => { + const entityLists: Entity[][] = [ + [ + { name: 'entity1', value: 'value1' }, + { name: 'entity2', value: 'value2' }, + ], + ]; + + const json = JSON.stringify(entityLists[0]); + + const expected = [ + new Array(NUM_CELLS).fill(json), + ]; + + const result = transformEntityListsForHeatmap(entityLists); + expect(result).toEqual(expected); + const convertedBack = convertHeatmapCellEntityStringToEntityList(json); + expect(entityLists[0]).toEqual(convertedBack); + }); + + it('should handle special characters in entity values', () => { + const entityLists: Entity[][] = [ + [ + { name: 'entity1', value: 'value1, with comma' }, + { name: 'entity2', value: 'value2\nwith newline' }, + ], + ]; + + const json = JSON.stringify(entityLists[0]); + + const expected = [ + new Array(NUM_CELLS).fill(json), + ]; + + const result = transformEntityListsForHeatmap(entityLists); + expect(result).toEqual(expected); + const convertedBack = convertHeatmapCellEntityStringToEntityList(json); + expect(entityLists[0]).toEqual(convertedBack); + }); + }); }); diff --git a/public/pages/utils/anomalyResultUtils.ts b/public/pages/utils/anomalyResultUtils.ts index 1ff35b9a..11c02878 100644 --- a/public/pages/utils/anomalyResultUtils.ts +++ b/public/pages/utils/anomalyResultUtils.ts @@ -1820,22 +1820,7 @@ export const convertToCategoryFieldAndEntityString = ( export const convertHeatmapCellEntityStringToEntityList = ( heatmapCellEntityString: string ) => { - let entityList = [] as Entity[]; - const entitiesAsStringList = heatmapCellEntityString.split( - HEATMAP_CELL_ENTITY_DELIMITER - ); - var i; - for (i = 0; i < entitiesAsStringList.length; i++) { - const entityAsString = entitiesAsStringList[i]; - const entityAsFieldValuePair = entityAsString.split( - HEATMAP_CALL_ENTITY_KEY_VALUE_DELIMITER - ); - entityList.push({ - name: entityAsFieldValuePair[0], - value: entityAsFieldValuePair[1], - }); - } - return entityList; + return JSON.parse(heatmapCellEntityString); }; export const entityListsMatch = ( @@ -1895,10 +1880,7 @@ const appendEntityFilters = (requestBody: any, entityList: Entity[]) => { export const transformEntityListsForHeatmap = (entityLists: any[]) => { let transformedEntityLists = [] as any[]; entityLists.forEach((entityList: Entity[]) => { - const listAsString = convertToCategoryFieldAndEntityString( - entityList, - ', ' - ); + const listAsString = JSON.stringify(entityList); let row = []; var i; for (i = 0; i < NUM_CELLS; i++) {