Provisioning: Retry loading dashboard if ref is missing (#111811)

* Provisioning: Retry loading dashboard if ref is missing

* Fix tests

* Remove any
This commit is contained in:
Alex Khomenko 2025-10-01 08:08:05 +03:00 committed by GitHub
parent 73cf4b0895
commit bbb9c4660c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 86 additions and 32 deletions

View File

@ -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<typeof createTestStore>;
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<DashboardV2Spec> | 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();

View File

@ -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<T>
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(

View File

@ -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}

View File

@ -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,