Schema V2: Handle v2 custom home dashboard (#103332)

* Schema V2: Handle v2 custom home dashboard

* Clean interface

* Fix old arch
This commit is contained in:
Dominik Prokop
2025-04-03 15:24:19 +02:00
committed by GitHub
parent 3ce4025db6
commit ae700e7632
3 changed files with 214 additions and 181 deletions
@@ -1,6 +1,6 @@
import { advanceBy } from 'jest-date-mock';
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
import { BackendSrv, locationService, setBackendSrv } from '@grafana/runtime';
import {
Spec as DashboardV2Spec,
defaultSpec as defaultDashboardV2Spec,
@@ -142,56 +142,6 @@ describe('DashboardScenePageStateManager v1', () => {
expect(loader.state.isLoading).toBe(false);
});
describe('Home dashboard', () => {
it('should handle home dashboard redirect', async () => {
setBackendSrv({
get: () => Promise.resolve({ redirectUri: '/d/asd' }),
} as unknown as BackendSrv);
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toBeUndefined();
});
it('should handle invalid home dashboard request', async () => {
setBackendSrv({
get: () =>
Promise.reject({
status: 500,
data: { message: 'Failed to load home dashboard' },
}),
} as unknown as BackendSrv);
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual({
message: 'Failed to load home dashboard',
messageId: undefined,
status: 500,
});
});
it('should throw when v2 custom home dashboard is provided', async () => {
setBackendSrv({
get: () => Promise.resolve({ dashboard: customHomeDashboardV2Spec, meta: {} }),
} as unknown as BackendSrv);
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual({
message: 'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.',
messageId: undefined,
status: undefined,
});
});
});
describe('New dashboards', () => {
it('Should have new empty model and should not be cached', async () => {
const loader = new DashboardScenePageStateManager({});
@@ -472,53 +422,6 @@ describe('DashboardScenePageStateManager v2', () => {
status: 500,
});
});
it('should not transform v2 custom home dashboard spec', async () => {
setBackendSrv({
get: () => {
return Promise.resolve({
access: {
canSave: false,
canEdit: true,
canAdmin: false,
canStar: false,
canDelete: false,
slug: '',
url: '',
expires: '0001-01-01T00:00:00Z',
created: '0001-01-01T00:00:00Z',
updated: '0001-01-01T00:00:00Z',
updatedBy: '',
createdBy: '',
version: 0,
hasAcl: false,
isFolder: false,
folderId: 0,
folderUid: '',
folderTitle: 'General',
folderUrl: '',
provisioned: false,
provisionedExternalId: '',
annotationsPermissions: null,
},
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'home',
creationTimestamp: '',
resourceVersion: '1',
},
spec: customHomeDashboardV2Spec,
});
},
} as unknown as BackendSrv);
const loader = new DashboardScenePageStateManagerV2({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard?.getInitialSaveModel()).toEqual(customHomeDashboardV2Spec);
expect(loader.state.loadError).toBeUndefined();
});
});
describe('New dashboards', () => {
@@ -794,8 +697,122 @@ describe('UnifiedDashboardScenePageStateManager', () => {
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
});
});
describe('Home dashboard', () => {
it('should handle home dashboard redirect', async () => {
setBackendSrv({
get: () => Promise.resolve({ redirectUri: '/d/asd' }),
} as unknown as BackendSrv);
const loader = new UnifiedDashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toBeUndefined();
expect(locationService.getLocation().pathname).toBe('/d/asd');
});
it('should handle invalid home dashboard request', async () => {
setBackendSrv({
get: () =>
Promise.reject({
status: 500,
data: { message: 'Failed to load home dashboard' },
}),
} as unknown as BackendSrv);
const loader = new UnifiedDashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual({
message: 'Failed to load home dashboard',
messageId: undefined,
status: 500,
});
});
it('should handle custom v1 home dashboard ', async () => {
setBackendSrv({
get: () => Promise.resolve({ dashboard: customHomeDashboardV1Spec, meta: {} }),
} as unknown as BackendSrv);
const loader = new UnifiedDashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeDefined();
expect(loader.state.dashboard!.serializer.initialSaveModel).toEqual(customHomeDashboardV1Spec);
});
it('should transform v2 custom home dashboard to v1', async () => {
setBackendSrv({
get: () => Promise.resolve({ dashboard: customHomeDashboardV2Spec, meta: {} }),
} as unknown as BackendSrv);
const loader = new UnifiedDashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeDefined();
expect(loader.state.dashboard!.serializer.initialSaveModel).toEqual(customHomeDashboardV1Spec);
});
});
});
const customHomeDashboardV1Spec = {
annotations: {
list: [
{
builtIn: 1,
datasource: {
type: 'grafana',
uid: '-- Grafana --',
},
enable: true,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotations & Alerts',
type: 'dashboard',
},
],
},
editable: true,
fiscalYearStartMonth: 0,
graphTooltip: 0,
links: [],
panels: [
{
description: 'Welcome to the home dashboard!',
fieldConfig: { defaults: {}, overrides: [] },
gridPos: { h: 6, w: 12, x: 6, y: 0 },
id: 0,
links: [],
options: {
content: '# Welcome to the home dashboard!\n\n## Example of v2 schema home dashboard',
mode: 'markdown',
},
pluginVersion: '',
targets: [{ refId: 'A' }],
title: 'Welcome',
transformations: [],
type: 'text',
},
],
preload: false,
refresh: '',
schemaVersion: 40,
tags: [],
templating: { list: [] },
time: { from: 'now-6h', to: 'now' },
timepicker: {
hidden: false,
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
},
timezone: 'browser',
title: 'Home Dashboard v2 schema',
uid: '',
version: 0,
};
const customHomeDashboardV2Spec = {
title: 'Home Dashboard v2 schema',
cursorSync: 'Off',
@@ -8,6 +8,7 @@ import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
@@ -112,6 +113,43 @@ abstract class DashboardScenePageStateManagerBase<T>
return this.cache;
}
protected async fetchHomeDashboard(): Promise<DashboardDTO | null> {
const rsp = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
if (isRedirectResponse(rsp)) {
const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
locationService.replace(newUrl);
return null;
}
// If dashboard is on v2 schema convert to v1 schema, there's curently no v2 API for home dashboard
if (isDashboardV2Spec(rsp.dashboard)) {
rsp.dashboard = transformDashboardV2SpecToV1(rsp.dashboard, {
name: '',
generation: 0,
resourceVersion: '0',
creationTimestamp: '',
});
}
if (rsp?.meta) {
rsp.meta.canSave = false;
rsp.meta.canShare = false;
rsp.meta.canStar = false;
}
return rsp;
}
private async loadHomeDashboard(): Promise<DashboardScene | null> {
const rsp = await this.fetchHomeDashboard();
if (rsp) {
return transformSaveModelToScene(rsp);
}
return null;
}
public async loadSnapshot(slug: string) {
try {
const dashboard = await this.loadSnapshotScene(slug);
@@ -177,13 +215,20 @@ abstract class DashboardScenePageStateManagerBase<T>
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene | null> {
this.setState({ dashboard: undefined, isLoading: true });
const rsp = await this.fetchDashboard(options);
if (!rsp) {
return null;
// Home dashboard is not handled through legacy API and is not versioned.
// Handling home dashboard flow separately from regular dashboard flow.
if (options.route === DashboardRoutes.Home) {
return await this.loadHomeDashboard();
} else {
const rsp = await this.fetchDashboard(options);
if (!rsp) {
return null;
}
return this.transformResponseToScene(rsp, options);
}
return this.transformResponseToScene(rsp, options);
}
public getDashboardFromCache(cacheKey: string): T | null {
@@ -283,36 +328,23 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
}
let rsp: DashboardDTO | HomeDashboardRedirectDTO;
let rsp: DashboardDTO;
try {
switch (route) {
case DashboardRoutes.New:
rsp = await buildNewDashboardSaveModel(urlFolderUid);
break;
case DashboardRoutes.Home:
// TODO: Move this fetching to APIClient.getHomeDashboard() to be able to redirect to the correct api depending on the format for the saved dashboard
rsp = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
// For legacy dashboarding we keep this logic here, as dashboard can be loaded through state manager's fetchDashboard method directly
// See DashboardPageProxy.
const homeDashboard = await this.fetchHomeDashboard();
if (isRedirectResponse(rsp)) {
const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
locationService.replace(newUrl);
if (!homeDashboard) {
return null;
}
if (isDashboardV2Spec(rsp.dashboard)) {
throw new Error(
'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.'
);
}
if (rsp?.meta) {
rsp.meta.canSave = false;
rsp.meta.canShare = false;
rsp.meta.canStar = false;
}
rsp = homeDashboard;
break;
case DashboardRoutes.New:
rsp = await buildNewDashboardSaveModel(urlFolderUid);
break;
case DashboardRoutes.Provisioning: {
return await dashboardLoaderSrv.loadDashboard('provisioning', slug, uid);
@@ -499,27 +531,6 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
switch (route) {
case DashboardRoutes.New:
rsp = await buildNewDashboardSaveModelV2(urlFolderUid);
break;
case DashboardRoutes.Home:
// TODO: Move this fetching to APIClient.getHomeDashboard() to be able to redirect to the correct api depending on the format for the saved dashboard
const dto = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
if (isRedirectResponse(dto)) {
const newUrl = locationUtil.stripBaseFromUrl(dto.redirectUri);
locationService.replace(newUrl);
return null;
}
// if custom home dashboard is v2 spec already, ignore the spec transformation
if (!isDashboardV2Resource(dto)) {
throw new Error('Custom home dashboard is not a v2 spec');
}
rsp = dto;
dto.access.canSave = false;
dto.access.canShare = false;
dto.access.canStar = false;
break;
case DashboardRoutes.Public: {
return await this.dashboardLoader.loadDashboard('public', '', uid);
@@ -52,6 +52,7 @@ import {
AnnoKeyUpdatedBy,
AnnoKeyUpdatedTimestamp,
DeprecatedInternalId,
ObjectMeta,
} from 'app/features/apiserver/types';
import { GRID_ROW_HEIGHT } from 'app/features/dashboard-scene/serialization/const';
import { TypedVariableModelV2 } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene';
@@ -210,9 +211,6 @@ export function ensureV1Response(
};
} else {
// if dashboard is on v2 schema convert to v1 schema
const annotations = getAnnotationsV1(spec.annotations);
const variables = getVariablesV1(spec.variables);
const panels = getPanelsV1(spec.elements, spec.layout);
return {
meta: {
created: dashboard.metadata.creationTimestamp,
@@ -230,38 +228,7 @@ export function ensureV1Response(
canStar: dashboard.access.canStar,
annotationsPermissions: dashboard.access.annotationsPermissions,
},
dashboard: {
uid: dashboard.metadata.name,
title: spec.title,
description: spec.description,
tags: spec.tags,
schemaVersion: 40,
graphTooltip: transformCursorSyncV2ToV1(spec.cursorSync),
preload: spec.preload,
liveNow: spec.liveNow,
editable: spec.editable,
gnetId: dashboard.metadata.annotations?.[AnnoKeyDashboardGnetId],
revision: spec.revision,
time: {
from: spec.timeSettings.from,
to: spec.timeSettings.to,
},
timezone: spec.timeSettings.timezone,
refresh: spec.timeSettings.autoRefresh,
timepicker: {
refresh_intervals: spec.timeSettings.autoRefreshIntervals,
hidden: spec.timeSettings.hideTimepicker,
quick_ranges: spec.timeSettings.quickRanges,
nowDelay: spec.timeSettings.nowDelay,
},
fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth,
weekStart: spec.timeSettings.weekStart,
version: dashboard.metadata.generation,
links: spec.links,
annotations: { list: annotations },
panels,
templating: { list: variables },
},
dashboard: transformDashboardV2SpecToV1(spec, dashboard.metadata),
};
}
}
@@ -1134,3 +1101,41 @@ function transformToV1VariableTypes(variable: TypedVariableModelV2): VariableTyp
throw new Error(`Unknown variable type: ${variable}`);
}
}
export function transformDashboardV2SpecToV1(spec: DashboardV2Spec, metadata: ObjectMeta): DashboardDataDTO {
const annotations = getAnnotationsV1(spec.annotations);
const variables = getVariablesV1(spec.variables);
const panels = getPanelsV1(spec.elements, spec.layout);
return {
uid: metadata.name,
title: spec.title,
description: spec.description,
tags: spec.tags,
schemaVersion: 40,
graphTooltip: transformCursorSyncV2ToV1(spec.cursorSync),
preload: spec.preload,
liveNow: spec.liveNow,
editable: spec.editable,
gnetId: metadata.annotations?.[AnnoKeyDashboardGnetId],
revision: spec.revision,
time: {
from: spec.timeSettings.from,
to: spec.timeSettings.to,
},
timezone: spec.timeSettings.timezone,
refresh: spec.timeSettings.autoRefresh,
timepicker: {
refresh_intervals: spec.timeSettings.autoRefreshIntervals,
hidden: spec.timeSettings.hideTimepicker,
quick_ranges: spec.timeSettings.quickRanges,
nowDelay: spec.timeSettings.nowDelay,
},
fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth,
weekStart: spec.timeSettings.weekStart,
version: metadata.generation,
links: spec.links,
annotations: { list: annotations },
panels,
templating: { list: variables },
};
}