From bbb9c4660ce2bf39a9b7a052ff735cf28ef5700c Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 1 Oct 2025 08:08:05 +0300 Subject: [PATCH] Provisioning: Retry loading dashboard if ref is missing (#111811) * Provisioning: Retry loading dashboard if ref is missing * Fix tests * Remove any --- .../DashboardScenePageStateManager.test.ts | 55 +++++++++++++++--- .../pages/DashboardScenePageStateManager.ts | 57 +++++++++++++------ .../Dashboards/SaveProvisionedDashboard.tsx | 4 +- .../SaveProvisionedDashboardForm.tsx | 2 - 4 files changed, 86 insertions(+), 32 deletions(-) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 89e83aae420..00de1c7ea13 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -1,10 +1,15 @@ +import { configureStore } from '@reduxjs/toolkit'; import { advanceBy } from 'jest-date-mock'; +import { UnknownAction } from 'redux'; +import { of } from 'rxjs'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { BackendSrv, config, locationService, setBackendSrv } from '@grafana/runtime'; import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { provisioningAPIv0alpha1 } from 'app/api/clients/provisioning/v0alpha1'; import store from 'app/core/store'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; @@ -26,18 +31,26 @@ import { UnifiedDashboardScenePageStateManager, DASHBOARD_CACHE_TTL, } from './DashboardScenePageStateManager'; +const fetchMock = jest.fn(); -// Mock the config module jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); + const originalGetBackendSrv = original.getBackendSrv; return { ...original, + getBackendSrv: () => { + const originalSrv = originalGetBackendSrv(); + return { + ...originalSrv, + fetch: fetchMock, + }; + }, config: { ...original.config, featureToggles: { ...original.config.featureToggles, - dashboardNewLayouts: false, // Default value - reloadDashboardsOnParamsChange: false, // Default value + dashboardNewLayouts: false, + reloadDashboardsOnParamsChange: false, }, datasources: { 'gdev-testdata': { @@ -81,6 +94,29 @@ jest.mock('app/features/playlist/PlaylistSrv', () => ({ }, })); +const createTestStore = () => + configureStore({ + reducer: { + [provisioningAPIv0alpha1.reducerPath]: provisioningAPIv0alpha1.reducer, + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(provisioningAPIv0alpha1.middleware), + }); + +let testStore: ReturnType; + +jest.mock('app/store/store', () => { + const actual = jest.requireActual('app/store/store'); + return { + ...actual, + dispatch: jest.fn((action: UnknownAction) => { + if (testStore) { + return testStore.dispatch(action); + } + return action; + }), + }; +}); + const setupDashboardAPI = ( d: DashboardWithAccessInfo | undefined, spy: jest.Mock, @@ -161,10 +197,13 @@ beforeEach(() => { jest.clearAllMocks(); mockDashboardLoader.loadDashboard.mockReset(); mockDashboardLoader.loadSnapshot.mockReset(); + fetchMock.mockReset(); // Reset locationService mocks locationService.getSearch = jest.fn().mockReturnValue(new URLSearchParams()); locationService.getSearchObject = jest.fn().mockReturnValue({}); + + testStore = createTestStore(); }); describe('DashboardScenePageStateManager v1', () => { @@ -1558,10 +1597,9 @@ describe('UnifiedDashboardScenePageStateManager', () => { describe('Provisioned dashboard', () => { it('should load a provisioned v1 dashboard', async () => { + fetchMock.mockImplementation(() => of(createFetchResponse(v1ProvisionedDashboardResource))); + const loader = new UnifiedDashboardScenePageStateManager({}); - setBackendSrv({ - get: () => Promise.resolve(v1ProvisionedDashboardResource), - } as unknown as BackendSrv); await loader.loadDashboard({ uid: 'blah-blah', route: DashboardRoutes.Provisioning }); expect(loader.state.dashboard).toBeDefined(); @@ -1572,10 +1610,9 @@ describe('UnifiedDashboardScenePageStateManager', () => { }); it('should load a provisioned v2 dashboard', async () => { + fetchMock.mockImplementation(() => of(createFetchResponse(v2ProvisionedDashboardResource))); + const loader = new UnifiedDashboardScenePageStateManager({}); - setBackendSrv({ - get: () => Promise.resolve(v2ProvisionedDashboardResource), - } as unknown as BackendSrv); await loader.loadDashboard({ uid: 'blah-blah', route: DashboardRoutes.Provisioning }); expect(loader.state.dashboard).toBeDefined(); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 27902c226c2..083a5e08799 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -3,7 +3,7 @@ import { t } from '@grafana/i18n'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { BASE_URL } from 'app/api/clients/provisioning/v0alpha1/baseAPI'; +import { GetRepositoryFilesWithPathApiResponse, provisioningAPIv0alpha1 } from 'app/api/clients/provisioning/v0alpha1'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; @@ -23,6 +23,7 @@ import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsPr import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { ProvisioningPreview } from 'app/features/provisioning/types'; +import { dispatch } from 'app/store/store'; import { DashboardDataDTO, DashboardDTO, @@ -178,26 +179,46 @@ abstract class DashboardScenePageStateManagerBase const params = new URLSearchParams(window.location.search); const ref = params.get('ref') ?? undefined; // commit hash or branch - const url = `${BASE_URL}/repositories/${repo}/files/${path}`; - return getBackendSrv() - .get(url, ref ? { ref } : undefined) - .then((v) => { - // Load the results from dryRun - const dryRun = v.resource.dryRun; - if (!dryRun) { - return Promise.reject('failed to read provisioned dashboard'); - } + const loadWithRef = async (refParam: string | undefined) => { + const result = await dispatch( + provisioningAPIv0alpha1.endpoints.getRepositoryFilesWithPath.initiate({ + name: repo, + path: path, + ref: refParam, + }) + ); - if (!dryRun.apiVersion.startsWith('dashboard.grafana.app')) { - return Promise.reject('unexpected resource type: ' + dryRun.apiVersion); - } + if (result && 'error' in result) { + throw result.error; + } - return this.processDashboardFromProvisioning(repo, path, dryRun, { - file: url, - ref: ref, - repo: repo, - }); + const v: GetRepositoryFilesWithPathApiResponse = structuredClone(result.data); + // Load the results from dryRun + const dryRun = v.resource.dryRun; + if (!dryRun) { + return Promise.reject('failed to read provisioned dashboard'); + } + + if (!dryRun.apiVersion.startsWith('dashboard.grafana.app')) { + return Promise.reject('unexpected resource type: ' + dryRun.apiVersion); + } + + return this.processDashboardFromProvisioning(repo, path, dryRun, { + file: v.path ?? '', + ref: refParam, + repo: repo, }); + }; + + try { + return await loadWithRef(ref); + } catch (err) { + // If ref is not found (404), retry without ref to default to the main branch + if (ref && isFetchError(err) && err.status === 404) { + return await loadWithRef(undefined); + } + throw err; + } } private processDashboardFromProvisioning( diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboard.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboard.tsx index 20077e04361..fca774ef84c 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboard.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboard.tsx @@ -13,8 +13,7 @@ export interface SaveProvisionedDashboardProps { } export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) { - const { isNew, defaultValues, loadedFromRef, workflowOptions, readOnly, repository } = - useProvisionedDashboardData(dashboard); + const { isNew, defaultValues, workflowOptions, readOnly, repository } = useProvisionedDashboardData(dashboard); if (!defaultValues) { return null; @@ -27,7 +26,6 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Save changeInfo={changeInfo} isNew={isNew} defaultValues={defaultValues} - loadedFromRef={loadedFromRef} repository={repository} workflowOptions={workflowOptions} readOnly={readOnly} diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index a3a2b79f9c9..368076cafae 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -33,7 +33,6 @@ import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard'; export interface Props extends SaveProvisionedDashboardProps { isNew: boolean; defaultValues: ProvisionedDashboardFormData; - loadedFromRef?: string; workflowOptions: Array<{ label: string; value: string }>; readOnly: boolean; repository?: RepositoryView; @@ -45,7 +44,6 @@ export function SaveProvisionedDashboardForm({ drawer, changeInfo, isNew, - loadedFromRef, workflowOptions, readOnly, repository,