diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index a6bab169cc6..572f91f60d7 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -36,6 +36,7 @@ jest.mock('@grafana/runtime', () => { featureToggles: { ...original.config.featureToggles, dashboardNewLayouts: false, // Default value + reloadDashboardsOnParamsChange: false, // Default value }, bootData: { @@ -122,11 +123,46 @@ const mockDashboardLoader = { // Set the mock loader setDashboardLoaderSrv(mockDashboardLoader as unknown as DashboardLoaderSrv); +// Test helpers for query parameter processing tests +const mockTimeAndLocation = (mockNow: Date, queryParams: Record) => { + const originalDateNow = Date.now; + Date.now = jest.fn(() => mockNow.getTime()); + + const mockGetSearch = jest.fn().mockReturnValue(new URLSearchParams()); + const mockGetSearchObject = jest.fn().mockReturnValue(queryParams); + + locationService.getSearch = mockGetSearch; + locationService.getSearchObject = mockGetSearchObject; + + return () => { + Date.now = originalDateNow; + }; +}; + +const withFeatureToggle = (enabled: boolean, testFn: () => Promise) => async () => { + const originalValue = config.featureToggles.reloadDashboardsOnParamsChange; + config.featureToggles.reloadDashboardsOnParamsChange = enabled; + + try { + await testFn(); + } finally { + config.featureToggles.reloadDashboardsOnParamsChange = originalValue; + } +}; + +// Test constants +const MOCK_DASHBOARD = { dashboard: { uid: 'fake-dash', editable: true }, meta: {} }; +const MOCK_NOW = new Date('2023-10-01T12:00:00.000Z'); + // Reset the mock between tests beforeEach(() => { jest.clearAllMocks(); mockDashboardLoader.loadDashboard.mockReset(); mockDashboardLoader.loadSnapshot.mockReset(); + + // Reset locationService mocks + locationService.getSearch = jest.fn().mockReturnValue(new URLSearchParams()); + locationService.getSearchObject = jest.fn().mockReturnValue({}); }); describe('DashboardScenePageStateManager v1', () => { @@ -152,6 +188,109 @@ describe('DashboardScenePageStateManager v1', () => { expect(loadDashboardMock.mock.calls.length).toBe(1); }); + describe('reloadDashboardsOnParamsChange feature toggle', () => { + it( + 'should process query params when enabled', + withFeatureToggle(true, async () => { + const cleanup = mockTimeAndLocation(MOCK_NOW, { + from: 'now-1h', + to: 'now', + timezone: 'UTC', + extraParam: 'shouldBeRemoved', + }); + + const expectedFromISO = '2023-10-01T06:00:00.000Z'; // now-1h (calculated by dateMath) + const expectedToISO = '2023-10-01T12:00:00.000Z'; // now + + const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + // Verify that loadDashboard was called with processed query params + expect(loadDashboardMock).toHaveBeenCalledWith( + 'db', + '', + 'fake-dash', + expect.objectContaining({ + from: expectedFromISO, + to: expectedToISO, + }) + ); + + // Verify that extra parameters were filtered out (including timezone) + const callArgs = loadDashboardMock.mock.calls[0][3]; + expect(callArgs).not.toHaveProperty('extraParam'); + expect(callArgs).not.toHaveProperty('timezone'); // timezone is filtered out by design + + cleanup(); + }) + ); + + it( + 'should pass undefined query params when disabled', + withFeatureToggle(false, async () => { + const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + // Verify that loadDashboard was called without query params (3 parameters only) + expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); + }) + ); + + it( + 'should filter parameters correctly when enabled', + withFeatureToggle(true, async () => { + const cleanup = mockTimeAndLocation(MOCK_NOW, { + from: 'now-6h', + to: 'now', + 'var-server': 'web-01', + 'var-env': 'production', + scopes: 'scope1,scope2', + version: '2', + refresh: '5s', // should be filtered out + theme: 'dark', // should be filtered out + kiosk: 'true', // should be filtered out + }); + + const expectedFromISO = '2023-10-01T06:00:00.000Z'; // now-6h + const expectedToISO = '2023-10-01T12:00:00.000Z'; // now + + const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + const callArgs = loadDashboardMock.mock.calls[0][3]; + + // Should keep allowed parameters with specific expected values + expect(callArgs).toEqual( + expect.objectContaining({ + from: expectedFromISO, + to: expectedToISO, + 'var-server': 'web-01', + 'var-env': 'production', + scopes: 'scope1,scope2', + version: '2', + }) + ); + + // Should filter out disallowed parameters + expect(callArgs).toEqual( + expect.not.objectContaining({ + refresh: expect.anything(), + theme: expect.anything(), + kiosk: expect.anything(), + }) + ); + + cleanup(); + }) + ); + }); + it("should error when the dashboard doesn't exist", async () => { setupLoadDashboardMockReject({ status: 404, diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 19ddab65c0a..18b4098fa9c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -36,7 +36,7 @@ import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSav import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; -import { updateNavModel } from './utils'; +import { processQueryParamsForDashboardLoad, updateNavModel } from './utils'; export interface LoadError { status?: number; @@ -447,7 +447,14 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag return await dashboardLoaderSrv.loadDashboard('public', '', uid); } default: - rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid); + // If reloadDashboardsOnParamsChange is on, we need to process query params for dashboard load + // Since the scene is not yet there, we need to process whatever came through URL + if (config.featureToggles.reloadDashboardsOnParamsChange) { + const queryParamsObject = processQueryParamsForDashboardLoad(); + rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParamsObject); + } else { + rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid); + } if (route === DashboardRoutes.Embedded) { rsp.meta.isEmbedded = true; diff --git a/public/app/features/dashboard-scene/pages/utils.ts b/public/app/features/dashboard-scene/pages/utils.ts index 2e06429589f..d540e8fbdb5 100644 --- a/public/app/features/dashboard-scene/pages/utils.ts +++ b/public/app/features/dashboard-scene/pages/utils.ts @@ -1,3 +1,5 @@ +import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { updateNavIndex } from 'app/core/actions'; import { backendSrv } from 'app/core/services/backend_srv'; import { buildNavModel } from 'app/features/folders/state/navModel'; @@ -11,3 +13,49 @@ export async function updateNavModel(folderUid: string) { console.warn('Error fetching parent folder', folderUid, 'for dashboard', err); } } + +/** + * Processes query parameters for dashboard loading, normalizing time range and filtering allowed parameters + */ +export function processQueryParamsForDashboardLoad(): UrlQueryMap { + const queryParams = locationService.getSearch(); + const queryParamsObject = locationService.getSearchObject(); + + queryParamsObject.timezone = queryParams.get('timezone') ?? undefined; + const now = Date.now(); + const timeZone = getTimeZone({ + timeZone: queryParams.get('timezone') ?? undefined, + }); + const defaultTimeRange = getDefaultTimeRange(); + + const fromQP = queryParams.get('from'); + const toQP = queryParams.get('to'); + + const to = toQP + ? dateMath.toDateTime(toQP, { + roundUp: true, + timezone: timeZone, + now: now, + }) + : undefined; + + const from = fromQP + ? dateMath.toDateTime(fromQP, { + roundUp: false, + timezone: timeZone, + now: now, + }) + : undefined; + + queryParamsObject.from = from?.toISOString() ?? defaultTimeRange.from.toISOString(); + queryParamsObject.to = to?.toISOString() ?? defaultTimeRange.to.toISOString(); + + // Remove all properties that are not from, to, scopes, version or start with var- + Object.keys(queryParamsObject).forEach((key) => { + if (key !== 'from' && key !== 'to' && key !== 'scopes' && key !== 'version' && !key.startsWith('var-')) { + delete queryParamsObject[key]; + } + }); + + return queryParamsObject; +} diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index 62f069c015d..f14576d9983 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -1,4 +1,4 @@ -import { locationUtil } from '@grafana/data'; +import { locationUtil, UrlQueryMap } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Dashboard } from '@grafana/schema'; import { Status } from '@grafana/schema/src/schema/dashboard/v2'; @@ -115,9 +115,9 @@ export class K8sDashboardAPI implements DashboardAPI { })); } - async getDashboardDTO(uid: string) { + async getDashboardDTO(uid: string, params?: UrlQueryMap) { try { - const dash = await this.client.subresource>(uid, 'dto'); + const dash = await this.client.subresource>(uid, 'dto', params); // This could come as conversion error from v0 or v2 to V1. if (dash.status?.conversion?.failed && isV2StoredVersion(dash.status.conversion.storedVersion)) {