From ae700e76327ad16cad3a2f7f071f678235949e97 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 3 Apr 2025 15:24:19 +0200 Subject: [PATCH] Schema V2: Handle v2 custom home dashboard (#103332) * Schema V2: Handle v2 custom home dashboard * Clean interface * Fix old arch --- .../DashboardScenePageStateManager.test.ts | 213 ++++++++++-------- .../pages/DashboardScenePageStateManager.ts | 107 +++++---- .../dashboard/api/ResponseTransformers.ts | 75 +++--- 3 files changed, 214 insertions(+), 181 deletions(-) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 3fe399aceb3..cf806916a28 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -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', diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 1f90297e0f8..323db8450c9 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -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 return this.cache; } + protected async fetchHomeDashboard(): Promise { + const rsp = await getBackendSrv().get('/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 { + 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 private async loadScene(options: LoadDashboardOptions): Promise { 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('/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('/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); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 2727f0ca5ef..4013e5637aa 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -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 }, + }; +}