TimeSeries: Use exported time shift and fix time comparison tooltip (#109947)

* TimeSeries: Use exported time comparison function

* Add alignTimeRangeCompareData to grafana/data

* Simplify tooltip time text formatting

* Bump scenes version

* Add tests for alignTimeRangeCompareData

* Add backwards compatibility for older scenes

* Update shouldAlignTimeCompare for typical query

* Fix tooltip for older versions of scenes

* support for multiple shifts

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Drew Slobodnjak
2025-09-06 18:24:42 -07:00
committed by GitHub
co-authored by Leon Sorokin
parent 76f7836419
commit 23fa9a1484
7 changed files with 431 additions and 18 deletions
@@ -1,7 +1,8 @@
import { FieldType } from '../types/dataFrame';
import { TimeRange } from '../types/time';
import { createDataFrame, toDataFrame } from './processDataFrame';
import { anySeriesWithTimeField, addRow } from './utils';
import { anySeriesWithTimeField, addRow, alignTimeRangeCompareData, shouldAlignTimeCompare } from './utils';
describe('anySeriesWithTimeField', () => {
describe('single frame', () => {
@@ -104,3 +105,287 @@ describe('addRow', () => {
expect(frame.length).toBe(2);
});
});
describe('alignTimeRangeCompareData', () => {
const ONE_DAY_MS = 24 * 60 * 60 * 1000; // 86400000ms
const ONE_WEEK_MS = 7 * ONE_DAY_MS; // 604800000ms
it('should align time field values with positive diff (1 day)', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000] },
{ name: 'value', type: FieldType.number, values: [10, 20, 30] },
],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS);
expect(frame.fields[0].values).toEqual([ONE_DAY_MS + 1000, ONE_DAY_MS + 2000, ONE_DAY_MS + 3000]);
expect(frame.fields[1].values).toEqual([10, 20, 30]); // non-time fields unchanged
});
it('should align time field values with negative diff (1 week)', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000] },
{ name: 'value', type: FieldType.number, values: [10, 20, 30] },
],
});
alignTimeRangeCompareData(frame, -ONE_WEEK_MS);
// When diff is negative, function does v - diff, so v - (-ONE_WEEK_MS) = v + ONE_WEEK_MS
expect(frame.fields[0].values).toEqual([ONE_WEEK_MS + 1000, ONE_WEEK_MS + 2000, ONE_WEEK_MS + 3000]);
});
it('should apply default gray color and timeCompare config', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'value', type: FieldType.number, values: [10, 20] },
],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS);
frame.fields.forEach((field) => {
expect(field.config.color?.fixedColor).toBe('gray');
expect(field.config.custom?.timeCompare).toEqual({
diffMs: ONE_DAY_MS,
isTimeShiftQuery: true,
});
});
});
it('should apply custom color when provided', () => {
const frame = toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [10, 20] }],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS, 'red');
expect(frame.fields[0].config.color?.fixedColor).toBe('red');
});
it('should preserve existing config when merging', () => {
const frame = toDataFrame({
fields: [
{
name: 'value',
type: FieldType.number,
values: [10, 20],
config: {
displayName: 'My Display Name',
custom: { existingProperty: 'existingValue' },
},
},
],
});
alignTimeRangeCompareData(frame, ONE_WEEK_MS);
expect(frame.fields[0].config.displayName).toBe('My Display Name');
expect(frame.fields[0].config.custom?.existingProperty).toBe('existingValue');
expect(frame.fields[0].config.custom?.timeCompare?.diffMs).toBe(ONE_WEEK_MS);
});
});
describe('shouldAlignTimeCompare', () => {
const TIME_VALUES_A = [1000, 2000, 3000];
const TIME_VALUES_B = [5000, 6000, 7000];
const ORIGINAL_VALUES = [10, 20, 30];
const COMPARE_VALUES = [15, 25, 35];
const mockTimeRange: TimeRange = {
from: { valueOf: () => 4000 },
to: { valueOf: () => 8000 },
raw: { from: 'now-1h', to: 'now' },
} as TimeRange;
it('should return true when compare first time is before time range', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
meta: {
timeCompare: {
isTimeShiftQuery: true,
diffMs: 86400000,
},
},
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(true);
});
it('should return false when compare first time is after time range', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_B },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
meta: {
timeCompare: {
isTimeShiftQuery: true,
diffMs: 86400000,
},
},
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when compare frame refId does not end with -compare', () => {
const compareFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const allFrames = [compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when original frame is not found', () => {
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const allFrames = [compareFrame]; // No original frame with refId 'A'
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when compare frame has no time field', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [{ name: 'value', type: FieldType.number, values: COMPARE_VALUES }],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when original frame has no time field', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when time fields have empty values', () => {
const EMPTY_VALUES: number[] = [];
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: EMPTY_VALUES },
{ name: 'value', type: FieldType.number, values: EMPTY_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: EMPTY_VALUES },
{ name: 'value', type: FieldType.number, values: EMPTY_VALUES },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should handle null values and return true when first non-null time is before range', () => {
const TIME_WITH_NULLS = [null, ...TIME_VALUES_A];
const ORIGINAL_WITH_NULLS = [null, ...ORIGINAL_VALUES];
const COMPARE_WITH_NULLS = [null, ...COMPARE_VALUES];
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_WITH_NULLS },
{ name: 'value', type: FieldType.number, values: ORIGINAL_WITH_NULLS },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_WITH_NULLS },
{ name: 'value', type: FieldType.number, values: COMPARE_WITH_NULLS },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(true);
});
it('should return false when all time values are null', () => {
const ALL_NULL_TIMES = [null, null, null];
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: ALL_NULL_TIMES },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: ALL_NULL_TIMES },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
});
@@ -1,4 +1,5 @@
import { DataFrame, Field, FieldType } from '../types/dataFrame';
import { TimeRange } from '../types/time';
import { getTimeField } from './processDataFrame';
@@ -123,3 +124,79 @@ export function addRow(dataFrame: DataFrame, row: Record<string, unknown> | unkn
// does not need any external updating.
}
}
/**
* Aligns time range comparison data by adjusting timestamps and applying compare-specific styling
* @param series - The DataFrame containing the comparison data
* @param diff - The time difference in milliseconds to align the timestamps
* @param compareColor - Optional color to use for the comparison series (defaults to 'gray')
*/
export function alignTimeRangeCompareData(series: DataFrame, diff: number, compareColor = 'gray') {
series.fields.forEach((field: Field) => {
// Align compare series time stamps with reference series
if (field.type === FieldType.time) {
field.values = field.values.map((v: number) => {
return diff < 0 ? v - diff : v + diff;
});
}
field.config = {
...(field.config ?? {}),
color: {
mode: 'fixed',
fixedColor: compareColor,
},
custom: {
...(field.config?.custom ?? {}),
timeCompare: {
diffMs: diff,
isTimeShiftQuery: true,
},
},
};
});
}
/**
* Checks if a time comparison frame needs alignment based on whether its first time is before the current time range.
* Returns true if the first time in compare is before timeRange.from, indicating it needs shifting.
* @param compareFrame - The frame with time comparison data
* @param allFrames - Array of all frames to find the matching original frame
* @param timeRange - The current panel time range
* @returns true if alignment is needed
*/
export function shouldAlignTimeCompare(compareFrame: DataFrame, allFrames: DataFrame[], timeRange: TimeRange): boolean {
// Find the matching original frame by removing '-compare' from refId
const compareRefId = compareFrame.refId;
if (!compareRefId || !compareRefId.endsWith('-compare')) {
return false;
}
const originalRefId = compareRefId.replace('-compare', '');
const originalFrame = allFrames.find(
(frame) => frame.refId === originalRefId && !frame.meta?.timeCompare?.isTimeShiftQuery
);
if (!originalFrame) {
return false;
}
// Find time fields
const compareTimeField = compareFrame.fields.find((field) => field.type === FieldType.time);
const originalTimeField = originalFrame.fields.find((field) => field.type === FieldType.time);
if (!compareTimeField?.values.length || !originalTimeField?.values.length) {
return false;
}
// Find first non-null time value from each frame
const compareFirstTime = compareTimeField.values.find((value) => value != null);
const originalFirstTime = originalTimeField.values.find((value) => value != null);
if (compareFirstTime == null || originalFirstTime == null) {
return false;
}
// Check if first non-null time value is before timeRange.from
return compareFirstTime < timeRange.from.valueOf();
}