TimeSeries: Add Time Comparison (#104672)

* TimeComparison: Add time comparison option

* Display control conditionally based on option

* Provide timeCompare option to panels that opt in

* Only render time compare if panel supports it

* Create a custom time range compare component

* Move custom component

* Remove button checked

* Move time compare options out of field config

* Fix types and remove from fieldConfigProperty enum

* Remove from field config registry

* Add headerActions to V2 schema

* Remove dependency on timeCompare support registry

* Add feature flag

* Subscribe to options change in wrapper

* Remove custom rendering to simplify wrapper

* Bump scenes version

* Hide checkbox

* Remove unneeded query refresh during disable

* Fix import order in timeseries module

* Add util tests

* Support time comparison toggle for new viz panels

* Revert grafana-app-sdk and manifest changes

* Revert yarn lock
This commit is contained in:
Drew Slobodnjak
2025-07-24 13:07:28 -07:00
committed by GitHub
parent d3b6d7c45e
commit 2196727226
19 changed files with 531 additions and 570 deletions
@@ -401,6 +401,10 @@ export interface FeatureToggles {
*/
canvasPanelPanZoom?: boolean;
/**
* Enables time comparison option in supported panels
*/
timeComparison?: boolean;
/**
* Enables infinite scrolling for the Logs panel in Explore and Dashboards
* @default true
*/
@@ -882,6 +882,16 @@ export type TimeZoneUtc = 'utc';
*/
export type TimeZoneBrowser = 'browser';
/**
* Options for time comparison
*/
export interface TimeCompareOptions {
/**
* Enable time comparison control
*/
timeCompare?: boolean;
}
/**
* Optional formats for the template variable replace functions
* See also https://grafana.com/docs/grafana/latest/dashboards/variables/variable-syntax/#advanced-variable-format-options
@@ -0,0 +1,7 @@
package common
// Options for time comparison
TimeCompareOptions: {
// Enable time comparison control
timeCompare?: bool
} @cuetsy(kind="interface")
@@ -15,6 +15,7 @@ export const pluginVersion = "12.1.0-pre";
export interface Options extends common.OptionsWithTimezones {
legend: common.VizLegendOptions;
orientation?: common.VizOrientation;
timeCompare?: common.TimeCompareOptions;
tooltip: common.VizTooltipOptions;
}
@@ -4,3 +4,4 @@ export * from './legend';
export { addTooltipOptions } from './tooltip';
export * from './text';
export * from './stacking';
export { addTimeCompareOption } from './timeCompare';
@@ -0,0 +1,19 @@
import { PanelOptionsEditorBuilder } from '@grafana/data';
import { TimeCompareOptions } from '@grafana/schema';
/**
* Adds a generic time comparison option to the panel options editor.
* Can be used by any panel that supports time comparison.
*/
export function addTimeCompareOption<T extends { timeCompare?: TimeCompareOptions }>(
builder: PanelOptionsEditorBuilder<T>,
defaultValue = false
) {
builder.addBooleanSwitch({
path: 'timeCompare',
name: 'Enable',
category: ['Time Comparison'],
description: '',
defaultValue,
});
}
+7
View File
@@ -667,6 +667,13 @@ var (
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
Name: "timeComparison",
Description: "Enables time comparison option in supported panels",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
Name: "logsInfiniteScrolling",
Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards",
+1
View File
@@ -88,6 +88,7 @@ dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,true
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true
logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true
pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
88 panelFilterVariable experimental @grafana/dashboards-squad false false true
89 pdfTables preview @grafana/grafana-operator-experience-squad false false false
90 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
91 timeComparison experimental @grafana/dataviz-squad false false true
92 logsInfiniteScrolling GA @grafana/observability-logs false false true
93 logRowsPopoverMenu GA @grafana/observability-logs false false true
94 pluginsSkipHostEnvVars experimental @grafana/plugins-platform-backend false false false
+4
View File
@@ -363,6 +363,10 @@ const (
// Allow pan and zoom in canvas panel
FlagCanvasPanelPanZoom = "canvasPanelPanZoom"
// FlagTimeComparison
// Enables time comparison option in supported panels
FlagTimeComparison = "timeComparison"
// FlagLogsInfiniteScrolling
// Enables infinite scrolling for the Logs panel in Explore and Dashboards
FlagLogsInfiniteScrolling = "logsInfiniteScrolling"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import { SceneTimeRangeCompare, SceneComponentProps, VizPanel, sceneGraph } from '@grafana/scenes';
import { TimeCompareOptions } from '@grafana/schema';
function hasTimeCompare(options: unknown): options is TimeCompareOptions {
return options != null && typeof options === 'object' && 'timeCompare' in options;
}
export class CustomTimeRangeCompare extends SceneTimeRangeCompare {
constructor(state: Partial<SceneTimeRangeCompare['state']> = {}) {
super({
...state,
compareWith: undefined,
compareOptions: [],
hideCheckbox: true,
});
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
// Subscribe to parent panel's options changes
const vizPanel = sceneGraph.getAncestor(this, VizPanel);
this._subs.add(
vizPanel.subscribeToState((newState, prevState) => {
const newTimeCompareEnabled = hasTimeCompare(newState.options) && newState.options.timeCompare;
const prevTimeCompareEnabled = hasTimeCompare(prevState.options) && prevState.options.timeCompare;
// Only act when transitioning from enabled to disabled
if (prevTimeCompareEnabled && !newTimeCompareEnabled) {
this._handleDisable();
}
})
);
}
private _handleDisable() {
// Only clear state if there's actually a comparison active
if (this.state.compareWith) {
this.setState({
compareWith: undefined,
});
}
}
static Component = function CustomTimeRangeCompareRenderer({ model }: SceneComponentProps<SceneTimeRangeCompare>) {
// Get the parent VizPanel to check timeCompare option
const vizPanel = sceneGraph.getAncestor(model, VizPanel);
const { options } = vizPanel.useState();
// Check if timeCompare is enabled
const isTimeCompareEnabled = hasTimeCompare(options) && options.timeCompare;
if (!isTimeCompareEnabled) {
return <></>;
}
return <SceneTimeRangeCompare.Component model={model} />;
};
}
@@ -1,6 +1,8 @@
import { PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { PanelQueryKind, PanelKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { getRuntimePanelDataSource } from './utils';
import { CustomTimeRangeCompare } from '../../scene/CustomTimeRangeCompare';
import { buildVizPanel, getRuntimePanelDataSource } from './utils';
// Mock the config needed for the function
jest.mock('@grafana/runtime', () => ({
@@ -32,9 +34,81 @@ jest.mock('@grafana/runtime', () => ({
},
},
},
featureToggles: {
timeComparison: false,
},
},
}));
// Mock only what's essential for header actions tests
jest.mock('../../scene/CustomTimeRangeCompare', () => ({
CustomTimeRangeCompare: jest.fn(),
}));
// Helper function to create a minimal panel for testing
const createTestPanel = (): PanelKind => ({
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'timeseries',
spec: {
options: {},
fieldConfig: { defaults: {}, overrides: [] },
pluginVersion: '1.0.0',
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [],
queryOptions: {},
transformations: [],
},
},
links: [],
},
});
describe('buildVizPanel', () => {
describe('header actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should include CustomTimeRangeCompare in headerActions when timeComparison feature toggle is enabled', () => {
// Mock config with timeComparison enabled
const mockConfig = require('@grafana/runtime').config;
mockConfig.featureToggles.timeComparison = true;
const panel = createTestPanel();
const vizPanel = buildVizPanel(panel);
expect(vizPanel.state.headerActions).toBeDefined();
expect(vizPanel.state.headerActions).toHaveLength(1);
expect(CustomTimeRangeCompare).toHaveBeenCalledWith({
key: 'time-compare',
compareWith: undefined,
compareOptions: [],
});
});
it('should not include headerActions when timeComparison feature toggle is disabled', () => {
// Mock config with timeComparison disabled
const mockConfig = require('@grafana/runtime').config;
mockConfig.featureToggles.timeComparison = false;
const panel = createTestPanel();
const vizPanel = buildVizPanel(panel);
expect(vizPanel.state.headerActions).toBeUndefined();
expect(CustomTimeRangeCompare).not.toHaveBeenCalled();
});
});
});
describe('getRuntimePanelDataSource', () => {
it('should return the datasource when it is specified in the query', () => {
const query: PanelQueryKind = {
@@ -25,6 +25,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
import { ConditionalRenderingGroup } from '../../conditional-rendering/ConditionalRenderingGroup';
import { conditionalRenderingSerializerRegistry } from '../../conditional-rendering/serializers';
import { CustomTimeRangeCompare } from '../../scene/CustomTimeRangeCompare';
import { DashboardDatasourceBehaviour } from '../../scene/DashboardDatasourceBehaviour';
import { DashboardScene } from '../../scene/DashboardScene';
import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior';
@@ -73,6 +74,9 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel {
$behaviors: [],
extendPanelContext: setDashboardPanelContext,
// _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), //FIXME: Angular Migration
headerActions: config.featureToggles.timeComparison
? [new CustomTimeRangeCompare({ key: 'time-compare', compareWith: undefined, compareOptions: [] })]
: undefined,
};
if (!config.publicDashboardAccessToken) {
@@ -810,6 +810,41 @@ describe('transformSaveModelToScene', () => {
expect((libPanelBehavior as LibraryPanelBehavior).state.name).toEqual(panel.libraryPanel.name);
expect(gridItem.state.body.state.title).toEqual(panel.title);
});
describe('header actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should include headerActions when timeComparison feature toggle is enabled', () => {
config.featureToggles.timeComparison = true;
const panel = {
title: 'Test Panel',
type: 'timeseries',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
};
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.headerActions).toBeDefined();
expect(vizPanel.state.headerActions).toHaveLength(1);
});
it('should not include headerActions when timeComparison feature toggle is disabled', () => {
config.featureToggles.timeComparison = false;
const panel = {
title: 'Test Panel',
type: 'timeseries',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
};
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.headerActions).toBeUndefined();
});
});
});
describe('Convert to new rows', () => {
@@ -29,6 +29,7 @@ import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { CustomTimeRangeCompare } from '../scene/CustomTimeRangeCompare';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
@@ -407,6 +408,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
$behaviors: [],
extendPanelContext: setDashboardPanelContext,
_UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel),
headerActions: config.featureToggles.timeComparison
? [new CustomTimeRangeCompare({ key: 'time-compare', compareWith: undefined, compareOptions: [] })]
: undefined,
};
if (panel.libraryPanel) {
@@ -17,6 +17,7 @@ import { Dashboard, Panel, RowPanel } from '@grafana/schema';
import { createLogger } from '@grafana/ui';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { CustomTimeRangeCompare } from '../scene/CustomTimeRangeCompare';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
import { DashboardLayoutOrchestrator } from '../scene/DashboardLayoutOrchestrator';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
@@ -343,6 +344,9 @@ export function getDefaultVizPanel(): VizPanel {
menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior],
}),
headerActions: config.featureToggles.timeComparison
? [new CustomTimeRangeCompare({ key: 'time-compare', compareWith: undefined, compareOptions: [] })]
: undefined,
$data: new SceneDataTransformer({
$data: new SceneQueryRunner({
queries: [{ refId: 'A' }],
@@ -1,5 +1,6 @@
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { commonOptionsBuilder } from '@grafana/ui';
import { optsWithHideZeros } from '@grafana/ui/internal';
@@ -17,6 +18,10 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros);
commonOptionsBuilder.addLegendOptions(builder);
if (config.featureToggles.timeComparison) {
commonOptionsBuilder.addTimeCompareOption(builder);
}
builder.addCustomEditor({
id: 'timezone',
name: t('timeseries.name-time-zone', 'Time zone'),
@@ -25,6 +25,7 @@ composableKinds: PanelCfg: lineage: {
Options: common.OptionsWithTimezones & {
legend: common.VizLegendOptions
tooltip: common.VizTooltipOptions
timeCompare?: common.TimeCompareOptions
orientation?: common.VizOrientation
} @cuetsy(kind="interface")
@@ -13,6 +13,7 @@ import * as common from '@grafana/schema';
export interface Options extends common.OptionsWithTimezones {
legend: common.VizLegendOptions;
orientation?: common.VizOrientation;
timeCompare?: common.TimeCompareOptions;
tooltip: common.VizTooltipOptions;
}