diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index e45d19454a7..f8d6a827c20 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1074,6 +1074,10 @@ export interface FeatureToggles { */ dashboardDsAdHocFiltering?: boolean; /** + * Supports __from and __to macros that always use the dashboard level time range + */ + dashboardLevelTimeMacros?: boolean; + /** * Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. * @default false */ diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 9185887aec9..74259652fbf 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -90,7 +90,6 @@ func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64, isFol return r0, r1 } - // DeleteDashboard provides a mock function with given fields: ctx, cmd func (_m *FakeDashboardStore) DeleteDashboard(ctx context.Context, cmd *DeleteDashboardCommand) error { ret := _m.Called(ctx, cmd) @@ -127,7 +126,6 @@ func (_m *FakeDashboardStore) DeleteDashboardsInFolders(ctx context.Context, req return r0 } - // FindDashboards provides a mock function with given fields: ctx, query func (_m *FakeDashboardStore) FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { ret := _m.Called(ctx, query) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 07bc3cdcc6e..661313c4d30 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1857,6 +1857,13 @@ var ( Owner: grafanaDataProSquad, FrontendOnly: true, }, + { + Name: "dashboardLevelTimeMacros", + Description: "Supports __from and __to macros that always use the dashboard level time range", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + FrontendOnly: true, + }, { Name: "alertmanagerRemoteSecondaryWithRemoteState", Description: "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 67f899018d8..0b7c3e381db 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -240,5 +240,6 @@ alertingNotificationHistory,experimental,@grafana/alerting-squad,false,false,fal pluginAssetProvider,experimental,@grafana/plugins-platform-backend,false,true,false unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true +dashboardLevelTimeMacros,experimental,@grafana/dashboards-squad,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 236516152f5..9aee8de5873 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -971,6 +971,10 @@ const ( // Enables adhoc filtering support for the dashboard datasource FlagDashboardDsAdHocFiltering = "dashboardDsAdHocFiltering" + // FlagDashboardLevelTimeMacros + // Supports __from and __to macros that always use the dashboard level time range + FlagDashboardLevelTimeMacros = "dashboardLevelTimeMacros" + // FlagAlertmanagerRemoteSecondaryWithRemoteState // Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 2ee75811760..e085dda6279 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -836,6 +836,19 @@ "frontend": true } }, + { + "metadata": { + "name": "dashboardLevelTimeMacros", + "resourceVersion": "1753435849295", + "creationTimestamp": "2025-07-25T09:30:49Z" + }, + "spec": { + "description": "Supports __from and __to macros that always use the dashboard level time range", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "dashboardNewLayouts", diff --git a/public/app/app.ts b/public/app/app.ts index 0e3ce53e238..5c48558c30c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -50,7 +50,7 @@ import { setPanelRenderer, setPluginPage, } from '@grafana/runtime/internal'; -import { loadResources as loadScenesResources } from '@grafana/scenes'; +import { loadResources as loadScenesResources, sceneUtils } from '@grafana/scenes'; import config, { updateConfig } from 'app/core/config'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -82,6 +82,7 @@ import { initAlerting } from './features/alerting/unified/initAlerting'; import { initAuthConfig } from './features/auth-config'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy'; +import { DashboardLevelTimeMacro } from './features/dashboard-scene/scene/DashboardLevelTimeMacro'; import { initGrafanaLive } from './features/live'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; @@ -284,6 +285,11 @@ export class GrafanaApp { initializeCrashDetection(); } + if (config.featureToggles.dashboardLevelTimeMacros) { + sceneUtils.registerVariableMacro('__from', DashboardLevelTimeMacro, true); + sceneUtils.registerVariableMacro('__to', DashboardLevelTimeMacro, true); + } + const root = createRoot(document.getElementById('reactRoot')!); root.render( createElement(AppWrapper, { diff --git a/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.test.ts b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.test.ts new file mode 100644 index 00000000000..d55c4940f52 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.test.ts @@ -0,0 +1,74 @@ +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneTimeRange, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes'; + +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardLevelTimeMacro } from './DashboardLevelTimeMacro'; +import { DashboardScene } from './DashboardScene'; +import { PanelTimeRange } from './PanelTimeRange'; +import { AutoGridItem } from './layout-auto-grid/AutoGridItem'; +import { AutoGridLayout } from './layout-auto-grid/AutoGridLayout'; +import { + AutoGridLayoutManager, + getAutoRowsTemplate, + getTemplateColumnsTemplate, +} from './layout-auto-grid/AutoGridLayoutManager'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), +})); + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +describe('dashboardLevelTimeMacros', () => { + it('Can use use $__from and $__to', async () => { + const panel = new VizPanel({ + $timeRange: new PanelTimeRange({ timeShift: '1h' }), + title: 'Test Panel', + key: 'panel-1', + pluginId: 'timeseries', + }); + + const scene = new DashboardScene({ + $timeRange: new SceneTimeRange({ from: '2023-05-23T06:09:57.073Z', to: '2023-05-23T07:09:57.073Z' }), + body: new AutoGridLayoutManager({ + maxColumnCount: 12, + columnWidth: 100, + rowHeight: 100, + fillScreen: true, + layout: new AutoGridLayout({ + isDraggable: true, + templateColumns: getTemplateColumnsTemplate(12, 100), + autoRows: getAutoRowsTemplate(100, true), + children: [ + new AutoGridItem({ + body: panel, + }), + ], + }), + }), + }); + + activateFullSceneTree(scene); + + // Wait for the scene to be activated + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(sceneGraph.interpolate(scene, '$__from')).toBe('1684822197073'); // Dashboard level time range + expect(sceneGraph.interpolate(scene, '$__to')).toBe('1684825797073'); // Dashboard level time range + + expect(sceneGraph.interpolate(panel, '$__from')).toBe('1684818597073'); // Time shifted by 1h + expect(sceneGraph.interpolate(panel, '$__to')).toBe('1684822197073'); // Time shifted by 1h + + sceneUtils.registerVariableMacro('__from', DashboardLevelTimeMacro, true); + sceneUtils.registerVariableMacro('__to', DashboardLevelTimeMacro, true); + + expect(sceneGraph.interpolate(panel, '$__from')).toBe('1684822197073'); // Dashboard level time range even when panel is time shifted + expect(sceneGraph.interpolate(panel, '$__to')).toBe('1684825797073'); // Dashboard level time range even when panel is time shifted + }); +}); diff --git a/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.ts b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.ts new file mode 100644 index 00000000000..e6d0fc032fb --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.ts @@ -0,0 +1,33 @@ +import { dateTimeFormat } from '@grafana/data'; +import { FormatVariable, sceneGraph, SceneObject } from '@grafana/scenes'; + +/** + * This macro is used to support the old __to and __from macros that always used the dashboard level time range. + **/ +export class DashboardLevelTimeMacro implements FormatVariable { + public state: { name: string; type: string }; + private _sceneObject: SceneObject; + + public constructor(name: string, sceneObject: SceneObject) { + this.state = { name: name, type: 'time_macro' }; + this._sceneObject = sceneObject.getRoot(); + } + + public getValue() { + const timeRange = sceneGraph.getTimeRange(this._sceneObject); + if (this.state.name === '__from') { + return timeRange.state.value.from.valueOf(); + } else { + return timeRange.state.value.to.valueOf(); + } + } + + public getValueText?(): string { + const timeRange = sceneGraph.getTimeRange(this._sceneObject); + if (this.state.name === '__from') { + return dateTimeFormat(timeRange.state.value.from, { timeZone: timeRange.getTimeZone() }); + } else { + return dateTimeFormat(timeRange.state.value.to, { timeZone: timeRange.getTimeZone() }); + } + } +}