mirror of https://github.com/grafana/grafana.git
				
				
				
			
		
			
				
	
	
		
			507 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			507 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| import {
 | |
|   createTheme,
 | |
|   FieldType,
 | |
|   ThresholdsMode,
 | |
|   TimeRange,
 | |
|   toDataFrame,
 | |
|   dateTime,
 | |
|   DataFrame,
 | |
|   fieldMatchers,
 | |
|   FieldMatcherID,
 | |
| } from '@grafana/data';
 | |
| import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema';
 | |
| 
 | |
| import { preparePlotFrame } from '../GraphNG/utils';
 | |
| 
 | |
| import {
 | |
|   findNextStateIndex,
 | |
|   fmtDuration,
 | |
|   getThresholdItems,
 | |
|   makeFramePerSeries,
 | |
|   prepareTimelineFields,
 | |
|   prepareTimelineLegendItems,
 | |
| } from './utils';
 | |
| 
 | |
| const theme = createTheme();
 | |
| 
 | |
| describe('prepare timeline graph', () => {
 | |
|   const timeRange: TimeRange = {
 | |
|     from: dateTime(1),
 | |
|     to: dateTime(3),
 | |
|     raw: {
 | |
|       from: dateTime(1),
 | |
|       to: dateTime(3),
 | |
|     },
 | |
|   };
 | |
|   it('errors with no time fields', () => {
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'a', values: [1, 2, 3] },
 | |
|           { name: 'b', values: ['a', 'b', 'c'] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
|     const info = prepareTimelineFields(frames, true, timeRange, theme);
 | |
|     expect(info.warn).toEqual('Data does not have a time field');
 | |
|   });
 | |
| 
 | |
|   it('requires a number, string, or boolean value', () => {
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'a', type: FieldType.time, values: [1, 2, 3] },
 | |
|           { name: 'b', type: FieldType.other, values: [{}, {}, {}] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
|     const info = prepareTimelineFields(frames, true, timeRange, theme);
 | |
|     expect(info.warn).toEqual('No graphable fields');
 | |
|   });
 | |
| 
 | |
|   it('will merge duplicate values', () => {
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'a', type: FieldType.time, values: [1, 2, 3, 4, 5, 6, 7] },
 | |
|           { name: 'b', values: [1, 1, undefined, 1, 2, 2, null, 2, 3] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
|     const info = prepareTimelineFields(frames, true, timeRange, theme);
 | |
|     expect(info.warn).toBeUndefined();
 | |
| 
 | |
|     const out = info.frames![0];
 | |
| 
 | |
|     const field = out.fields.find((f) => f.name === 'b');
 | |
|     expect(field?.values).toMatchInlineSnapshot(`
 | |
|       [
 | |
|         1,
 | |
|         1,
 | |
|         undefined,
 | |
|         1,
 | |
|         2,
 | |
|         2,
 | |
|         null,
 | |
|         2,
 | |
|         3,
 | |
|       ]
 | |
|     `);
 | |
|   });
 | |
|   it('should try to sort time fields', () => {
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'a', type: FieldType.time, values: [4, 3, 1, 2] },
 | |
|           { name: 'b', values: [1, 1, 2, 2] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
|     const result = prepareTimelineFields(frames, true, timeRange, theme);
 | |
|     expect(result.frames?.[0].fields[0].values).toEqual([1, 2, 3, 4]);
 | |
|   });
 | |
| 
 | |
|   it('join multiple frames with NULL_RETAIN rather than NULL_EXPAND', () => {
 | |
|     const timeRange2: TimeRange = {
 | |
|       from: dateTime('2023-10-20T05:04:00.000Z'),
 | |
|       to: dateTime('2023-10-20T07:22:00.000Z'),
 | |
|       raw: {
 | |
|         from: dateTime('2023-10-20T05:04:00.000Z'),
 | |
|         to: dateTime('2023-10-20T07:22:00.000Z'),
 | |
|       },
 | |
|     };
 | |
| 
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         name: 'Mix',
 | |
|         fields: [
 | |
|           { name: 'time', type: FieldType.time, values: [1697778291972, 1697778393992, 1697778986994, 1697786485890] },
 | |
|           { name: 'state', type: FieldType.string, values: ['RUN', null, 'RUN', null] },
 | |
|         ],
 | |
|       }),
 | |
|       toDataFrame({
 | |
|         name: 'Cook',
 | |
|         fields: [
 | |
|           {
 | |
|             name: 'time',
 | |
|             type: FieldType.time,
 | |
|             values: [
 | |
|               1697779163986, 1697779921045, 1697780221094, 1697780521111, 1697781186192, 1697781786291, 1697783332361,
 | |
|               1697783784395, 1697783790397, 1697784146478, 1697784517471, 1697784523487, 1697784949480, 1697785369505,
 | |
|             ],
 | |
|           },
 | |
|           {
 | |
|             name: 'state',
 | |
|             type: FieldType.string,
 | |
|             values: [
 | |
|               'Heat',
 | |
|               'Stage',
 | |
|               null,
 | |
|               'Heat',
 | |
|               'Stage',
 | |
|               null,
 | |
|               'Heat',
 | |
|               'Stage',
 | |
|               null,
 | |
|               'Heat',
 | |
|               'Stage',
 | |
|               null,
 | |
|               'CCP',
 | |
|               null,
 | |
|             ],
 | |
|           },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
| 
 | |
|     const info = prepareTimelineFields(frames, true, timeRange2, theme);
 | |
| 
 | |
|     let joined = preparePlotFrame(
 | |
|       info.frames!,
 | |
|       {
 | |
|         x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
 | |
|         y: fieldMatchers.get(FieldMatcherID.byType).get('string'),
 | |
|       },
 | |
|       timeRange2
 | |
|     );
 | |
| 
 | |
|     let vals = joined!.fields.map((f) => f.values);
 | |
| 
 | |
|     expect(vals).toEqual([
 | |
|       [
 | |
|         1697778291972, 1697778393992, 1697778986994, 1697779163986, 1697779921045, 1697780221094, 1697780521111,
 | |
|         1697781186192, 1697781786291, 1697783332361, 1697783784395, 1697783790397, 1697784146478, 1697784517471,
 | |
|         1697784523487, 1697784949480, 1697785369505, 1697786485890,
 | |
|       ],
 | |
|       [
 | |
|         'RUN',
 | |
|         null,
 | |
|         'RUN',
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         null,
 | |
|       ],
 | |
|       [
 | |
|         undefined,
 | |
|         undefined,
 | |
|         undefined,
 | |
|         'Heat',
 | |
|         'Stage',
 | |
|         null,
 | |
|         'Heat',
 | |
|         'Stage',
 | |
|         null,
 | |
|         'Heat',
 | |
|         'Stage',
 | |
|         null,
 | |
|         'Heat',
 | |
|         'Stage',
 | |
|         null,
 | |
|         'CCP',
 | |
|         null,
 | |
|         undefined,
 | |
|       ],
 | |
|     ]);
 | |
|   });
 | |
| 
 | |
|   it('join multiple frames with start and end time fields', () => {
 | |
|     const timeRange2: TimeRange = {
 | |
|       from: dateTime('2024-02-28T07:47:21.428Z'),
 | |
|       to: dateTime('2024-02-28T14:12:43.391Z'),
 | |
|       raw: {
 | |
|         from: dateTime('2024-02-28T07:47:21.428Z'),
 | |
|         to: dateTime('2024-02-28T14:12:43.391Z'),
 | |
|       },
 | |
|     };
 | |
| 
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         name: 'Channel 1',
 | |
|         fields: [
 | |
|           { name: 'starttime', type: FieldType.time, values: [1709107200000, 1709118000000] },
 | |
|           { name: 'endtime', type: FieldType.time, values: [1709114400000, 1709128800000] },
 | |
|           { name: 'state', type: FieldType.string, values: ['OK', 'NO_DATA'] },
 | |
|         ],
 | |
|       }),
 | |
|       toDataFrame({
 | |
|         name: 'Channel 2',
 | |
|         fields: [
 | |
|           { name: 'starttime', type: FieldType.time, values: [1709110800000, 1709123400000] },
 | |
|           { name: 'endtime', type: FieldType.time, values: [1709116200000, 1709127000000] },
 | |
|           { name: 'state', type: FieldType.string, values: ['ERROR', 'WARNING'] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
| 
 | |
|     const info = prepareTimelineFields(frames, true, timeRange2, theme);
 | |
| 
 | |
|     let joined = preparePlotFrame(
 | |
|       info.frames!,
 | |
|       {
 | |
|         x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
 | |
|         y: fieldMatchers.get(FieldMatcherID.byType).get('string'),
 | |
|       },
 | |
|       timeRange2
 | |
|     );
 | |
| 
 | |
|     let vals = joined!.fields.map((f) => f.values);
 | |
| 
 | |
|     expect(vals).toEqual([
 | |
|       [
 | |
|         1709107200000, 1709110800000, 1709114400000, 1709116200000, 1709118000000, 1709123400000, 1709127000000,
 | |
|         1709128800000,
 | |
|       ],
 | |
|       ['OK', undefined, null, undefined, 'NO_DATA', undefined, undefined, null],
 | |
|       [undefined, 'ERROR', undefined, null, undefined, 'WARNING', null, undefined],
 | |
|     ]);
 | |
|   });
 | |
| });
 | |
| 
 | |
| describe('prepareFieldsForPagination', () => {
 | |
|   it('ignores frames without any time fields', () => {
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'a', type: FieldType.number, values: [1, 2, 3] },
 | |
|           { name: 'b', type: FieldType.string, values: ['a', 'b', 'c'] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
|     const normalizedFrames = makeFramePerSeries(frames);
 | |
|     expect(normalizedFrames.length).toEqual(0);
 | |
|   });
 | |
| 
 | |
|   it('returns normalized frames, each with one time field and one value field', () => {
 | |
|     const frames = [
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'a', type: FieldType.time, values: [1, 2, 3] },
 | |
|           { name: 'b', type: FieldType.number, values: [100, 200, 300] },
 | |
|           { name: 'c', type: FieldType.string, values: ['h', 'i', 'j'] },
 | |
|         ],
 | |
|       }),
 | |
|       toDataFrame({
 | |
|         fields: [
 | |
|           { name: 'x', type: FieldType.time, values: [10, 20, 30] },
 | |
|           { name: 'y', type: FieldType.string, values: ['e', 'f', 'g'] },
 | |
|         ],
 | |
|       }),
 | |
|     ];
 | |
|     const normalizedFrames = makeFramePerSeries(frames);
 | |
|     expect(normalizedFrames.length).toEqual(3);
 | |
|     expect(normalizedFrames).toMatchObject([
 | |
|       {
 | |
|         fields: [
 | |
|           { name: 'a', values: [1, 2, 3] },
 | |
|           { name: 'b', values: [100, 200, 300] },
 | |
|         ],
 | |
|       },
 | |
|       {
 | |
|         fields: [
 | |
|           { name: 'a', values: [1, 2, 3] },
 | |
|           { name: 'c', values: ['h', 'i', 'j'] },
 | |
|         ],
 | |
|       },
 | |
|       {
 | |
|         fields: [
 | |
|           { name: 'x', values: [10, 20, 30] },
 | |
|           { name: 'y', values: ['e', 'f', 'g'] },
 | |
|         ],
 | |
|       },
 | |
|     ]);
 | |
|   });
 | |
| });
 | |
| 
 | |
| describe('findNextStateIndex', () => {
 | |
|   it('handles leading datapoint index', () => {
 | |
|     const field = {
 | |
|       name: 'time',
 | |
|       type: FieldType.number,
 | |
|       values: [1, undefined, undefined, 2, undefined, undefined],
 | |
|       config: {},
 | |
|     };
 | |
|     const result = findNextStateIndex(field, 0);
 | |
|     expect(result).toEqual(3);
 | |
|   });
 | |
| 
 | |
|   it('handles trailing datapoint index', () => {
 | |
|     const field = {
 | |
|       name: 'time',
 | |
|       type: FieldType.number,
 | |
|       values: [1, undefined, undefined, 2, undefined, 3],
 | |
|       config: {},
 | |
|     };
 | |
|     const result = findNextStateIndex(field, 5);
 | |
|     expect(result).toEqual(null);
 | |
|   });
 | |
| 
 | |
|   it('handles trailing undefined', () => {
 | |
|     const field = {
 | |
|       name: 'time',
 | |
|       type: FieldType.number,
 | |
|       values: [1, undefined, undefined, 2, undefined, 3, undefined],
 | |
|       config: {},
 | |
|     };
 | |
|     const result = findNextStateIndex(field, 5);
 | |
|     expect(result).toEqual(null);
 | |
|   });
 | |
| 
 | |
|   it('handles datapoint index inside range', () => {
 | |
|     const field = {
 | |
|       name: 'time',
 | |
|       type: FieldType.number,
 | |
|       values: [1, undefined, undefined, 3, undefined, undefined, undefined, undefined, 2, undefined, undefined],
 | |
|       config: {},
 | |
|     };
 | |
|     const result = findNextStateIndex(field, 3);
 | |
|     expect(result).toEqual(8);
 | |
|   });
 | |
| 
 | |
|   describe('single data points', () => {
 | |
|     const field = {
 | |
|       name: 'time',
 | |
|       type: FieldType.number,
 | |
|       values: [1, 3, 2],
 | |
|       config: {},
 | |
|     };
 | |
| 
 | |
|     test('leading', () => {
 | |
|       const result = findNextStateIndex(field, 0);
 | |
|       expect(result).toEqual(1);
 | |
|     });
 | |
|     test('trailing', () => {
 | |
|       const result = findNextStateIndex(field, 2);
 | |
|       expect(result).toEqual(null);
 | |
|     });
 | |
| 
 | |
|     test('inside', () => {
 | |
|       const result = findNextStateIndex(field, 1);
 | |
|       expect(result).toEqual(2);
 | |
|     });
 | |
|   });
 | |
| });
 | |
| 
 | |
| describe('getThresholdItems', () => {
 | |
|   it('should handle only one threshold', () => {
 | |
|     const result = getThresholdItems(
 | |
|       { thresholds: { mode: ThresholdsMode.Absolute, steps: [{ color: 'black', value: 0 }] } },
 | |
|       theme
 | |
|     );
 | |
| 
 | |
|     expect(result).toHaveLength(1);
 | |
|   });
 | |
| });
 | |
| 
 | |
| describe('prepareTimelineLegendItems', () => {
 | |
|   it('should return legend items without crashing when single (base) threshold', () => {
 | |
|     const frames = [
 | |
|       {
 | |
|         refId: 'A',
 | |
|         fields: [
 | |
|           {
 | |
|             name: 'time',
 | |
|             config: {
 | |
|               color: {
 | |
|                 mode: 'thresholds',
 | |
|               },
 | |
|               thresholds: {
 | |
|                 mode: 'absolute',
 | |
|                 steps: [
 | |
|                   {
 | |
|                     color: 'green',
 | |
|                     value: null,
 | |
|                   },
 | |
|                 ],
 | |
|               },
 | |
|             },
 | |
|             values: [
 | |
|               1634092733455, 1634092763455, 1634092793455, 1634092823455, 1634092853455, 1634092883455, 1634092913455,
 | |
|               1634092943455, 1634092973455, 1634093003455,
 | |
|             ],
 | |
|             display: (value: string) => ({
 | |
|               text: value,
 | |
|               color: undefined,
 | |
|               numeric: NaN,
 | |
|             }),
 | |
|           },
 | |
|           {
 | |
|             name: 'A-series',
 | |
|             config: {
 | |
|               color: {
 | |
|                 mode: 'thresholds',
 | |
|               },
 | |
|               thresholds: {
 | |
|                 mode: 'absolute',
 | |
|                 steps: [
 | |
|                   {
 | |
|                     color: 'green',
 | |
|                     value: null,
 | |
|                   },
 | |
|                 ],
 | |
|               },
 | |
|             },
 | |
|             values: ['< -∞', null, null, null, null, null, null, null, null, null],
 | |
|             display: (value?: string) => ({
 | |
|               text: value || '',
 | |
|               color: 'green',
 | |
|               numeric: NaN,
 | |
|             }),
 | |
|           },
 | |
|         ],
 | |
|       },
 | |
|     ] as unknown as DataFrame[];
 | |
| 
 | |
|     const result = prepareTimelineLegendItems(
 | |
|       frames,
 | |
|       { displayMode: LegendDisplayMode.List } as VizLegendOptions,
 | |
|       theme
 | |
|     );
 | |
| 
 | |
|     expect(result).toHaveLength(1);
 | |
|   });
 | |
| });
 | |
| 
 | |
| describe('duration', () => {
 | |
|   it.each`
 | |
|     value               | expected
 | |
|     ${-1}               | ${''}
 | |
|     ${20}               | ${'20ms'}
 | |
|     ${1000}             | ${'1s'}
 | |
|     ${1020}             | ${'1s 20ms'}
 | |
|     ${60000}            | ${'1m'}
 | |
|     ${61020}            | ${'1m 1s'}
 | |
|     ${3600000}          | ${'1h'}
 | |
|     ${6600000}          | ${'1h 50m'}
 | |
|     ${86400000}         | ${'1d'}
 | |
|     ${96640000}         | ${'1d 2h'}
 | |
|     ${604800000}        | ${'1w'}
 | |
|     ${691200000}        | ${'1w 1d'}
 | |
|     ${2419200000}       | ${'4w'}
 | |
|     ${2678400000}       | ${'1mo 1d'}
 | |
|     ${3196800000}       | ${'1mo 1w'}
 | |
|     ${3456000000}       | ${'1mo 1w 3d'}
 | |
|     ${6739200000}       | ${'2mo 2w 4d'}
 | |
|     ${31536000000}      | ${'1y'}
 | |
|     ${31968000000}      | ${'1y 5d'}
 | |
|     ${32140800000}      | ${'1y 1w'}
 | |
|     ${67910400000}      | ${'2y 1mo 3w 5d'}
 | |
|     ${40420800000}      | ${'1y 3mo 1w 5d'}
 | |
|     ${9007199254740991} | ${'285616y 5mo 1d'}
 | |
|   `(' function should format $value ms to $expected', ({ value, expected }) => {
 | |
|     const result = fmtDuration(value);
 | |
|     expect(result).toEqual(expected);
 | |
|   });
 | |
| });
 |