Dashboard: Redirect between `v1alpha1` and `v2alpha1` depending on stored version (#101292)

* wip: Create a proxy state manager to avoid complexity

* Read path redirecting

* add tests for unified dashboard API

* add tests

* Contemplate both formats in DashboardProxy

* Fix force old

* Fix tests for proxy

* catch errors

* Save as V2 when dynamic dashboard is enabled

* Improve tests

* Remove feature toggle

* Use kubernetesDashboards for e2e suite

* Fix issue when loading snapshots

* Fix typescript errors

* Integrate with backend conversion error

* Remove legacy annotation

* fix snapshot loading; lint

* Add missing hideTimeControls

* fix test

* make setupDashboardAPI to all suites

* refactor getDashboardAPI

* Add tests

* fix DashboardScenePage tests

* fix tests

* fix go tests

* Refactor to understand better the need of transforming to v2 to compare

* Fix detect changes logic

* yes status from schema gen

---------

Co-authored-by: alexandra vargas <alexa1866@gmail.com>
Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
pull/102058/head
Ivan Ortega Alba 4 months ago committed by GitHub
parent 0233c39a7f
commit bfedf0b512
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      .betterer.results
  2. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  3. 6
      e2e/cypress/support/e2e.js
  4. 6
      e2e/run-suite
  5. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  6. 2
      pkg/apis/dashboard_manifest.go
  7. 2
      pkg/registry/apis/dashboard/register.go
  8. 2
      pkg/registry/apis/dashboard/register_test.go
  9. 7
      pkg/services/featuremgmt/registry.go
  10. 1
      pkg/services/featuremgmt/toggles_gen.csv
  11. 4
      pkg/services/featuremgmt/toggles_gen.go
  12. 3
      pkg/services/featuremgmt/toggles_gen.json
  13. 2
      public/app/core/components/Select/DashboardPicker.test.tsx
  14. 34
      public/app/core/components/Select/DashboardPicker.tsx
  15. 2
      public/app/core/services/backend_srv.ts
  16. 31
      public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
  17. 5
      public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
  18. 195
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
  19. 159
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
  20. 3
      public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx
  21. 11
      public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx
  22. 26
      public/app/features/dashboard-scene/saving/getDashboardChanges.ts
  23. 1
      public/app/features/dashboard-scene/saving/shared.tsx
  24. 7
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  25. 4
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  26. 8
      public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts
  27. 49
      public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
  28. 7
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
  29. 8
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  30. 112
      public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts
  31. 50
      public/app/features/dashboard/api/UnifiedDashboardAPI.ts
  32. 38
      public/app/features/dashboard/api/dashboard_api.test.ts
  33. 26
      public/app/features/dashboard/api/dashboard_api.ts
  34. 25
      public/app/features/dashboard/api/types.ts
  35. 22
      public/app/features/dashboard/api/utils.test.ts
  36. 31
      public/app/features/dashboard/api/utils.ts
  37. 7
      public/app/features/dashboard/api/v1.ts
  38. 10
      public/app/features/dashboard/api/v2.test.ts
  39. 39
      public/app/features/dashboard/api/v2.ts
  40. 16
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx
  41. 28
      public/app/features/dashboard/containers/DashboardPageProxy.tsx
  42. 16
      public/app/features/dashboard/services/DashboardLoaderSrv.ts
  43. 2
      public/app/features/dashboard/state/initDashboard.ts
  44. 9
      public/app/features/manage-dashboards/utils/validation.ts

@ -3143,12 +3143,14 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "9"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "10"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "12"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "16"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "17"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "17"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "18"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "19"]
],
"public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@ -3166,7 +3168,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/features/dashboard-scene/saving/shared.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@ -3714,8 +3717,9 @@ exports[`better eslint`] = {
],
"public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],

@ -214,7 +214,6 @@ Experimental features might be changed or removed without prior notice.
| `enableSCIM` | Enables SCIM support for user and group management |
| `crashDetection` | Enables browser crash detection reporting to Faro. |
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
| `investigationsBackend` | Enable the investigations backend API |
| `k8SFolderCounts` | Enable folder's api server counts |

@ -51,8 +51,8 @@ beforeEach(() => {
cy.setLocalStorage('grafana.featureToggles', 'dashboardScene=false');
}
if (Cypress.env('useV2DashboardsAPI')) {
cy.logToConsole('enabling v2 dashboards API in localstorage');
cy.setLocalStorage('grafana.featureToggles', 'useV2DashboardsAPI=true');
if (Cypress.env('kubernetesDashboards')) {
cy.logToConsole('enabling kubernetes dashboards API in localstorage');
cy.setLocalStorage('grafana.featureToggles', 'kubernetesDashboards=true');
}
});

@ -28,7 +28,7 @@ declare -A env=(
testFilesForSingleSuite="*.spec.ts"
rootForEnterpriseSuite="./e2e/extensions-suite"
rootForOldArch="./e2e/old-arch"
rootForDashboardsSchemaV2="./e2e/dashboards-suite"
rootForKubernetesDashboards="./e2e/dashboards-suite"
declare -A cypressConfig=(
[screenshotsFolder]=./e2e/"${args[0]}"/screenshots
@ -113,8 +113,8 @@ case "$1" in
env[DISABLE_SCENES]=true
;;
"dashboards-schema-v2")
env[useV2DashboardsAPI]=true
cypressConfig[specPattern]=$rootForDashboardsSchemaV2/$testFilesForSingleSuite
env[kubernetesDashboards]=true
cypressConfig[specPattern]=$rootForKubernetesDashboards/$testFilesForSingleSuite
cypressConfig[video]=false
case "$2" in
"debug")

@ -225,7 +225,6 @@ export interface FeatureToggles {
alertingUIOptimizeReducer?: boolean;
azureMonitorEnableUserAuth?: boolean;
alertingNotificationsStepMode?: boolean;
useV2DashboardsAPI?: boolean;
feedbackButton?: boolean;
unifiedStorageSearchUI?: boolean;
elasticsearchCrossClusterSearch?: boolean;

@ -11,8 +11,6 @@ import (
"github.com/grafana/grafana-app-sdk/app"
)
var ()
var appManifestData = app.ManifestData{
AppName: "dashboard",
Group: "dashboard.grafana.app",

@ -109,7 +109,7 @@ func RegisterAPIService(
}
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagUseV2DashboardsAPI) {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
// If dashboards v2 is enabled, we want to use v2alpha1 as the default API version.
return []schema.GroupVersion{
dashboardv2alpha1.DashboardResourceInfo.GroupVersion(),

@ -192,7 +192,7 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
{
name: "should return v2alpha1 as the default if dashboards v2 is enabled",
enabledFeatures: []string{
featuremgmt.FlagUseV2DashboardsAPI,
featuremgmt.FlagDashboardNewLayouts,
},
expected: []schema.GroupVersion{
v2alpha1.DashboardResourceInfo.GroupVersion(),

@ -1559,13 +1559,6 @@ var (
FrontendOnly: true,
Expression: "true",
},
{
Name: "useV2DashboardsAPI",
Description: "Use the v2 kubernetes API in the frontend for dashboards",
Stage: FeatureStageExperimental,
Owner: grafanaDashboardsSquad,
RequiresRestart: true, // changes the API routing
},
{
Name: "feedbackButton",
Description: "Enables a button to send feedback from the Grafana UI",

@ -206,7 +206,6 @@ reportingUseRawTimeRange,GA,@grafana/sharing-squad,false,false,false
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
206 alertingUIOptimizeReducer GA @grafana/alerting-squad false false true
207 azureMonitorEnableUserAuth GA @grafana/partner-datasources false false false
208 alertingNotificationsStepMode GA @grafana/alerting-squad false false true
useV2DashboardsAPI experimental @grafana/dashboards-squad false true false
209 feedbackButton experimental @grafana/grafana-operator-experience-squad false false false
210 unifiedStorageSearchUI experimental @grafana/search-and-storage false false false
211 elasticsearchCrossClusterSearch preview @grafana/aws-datasources false false false

@ -835,10 +835,6 @@ const (
// Enables simplified step mode in the notifications section
FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode"
// FlagUseV2DashboardsAPI
// Use the v2 kubernetes API in the frontend for dashboards
FlagUseV2DashboardsAPI = "useV2DashboardsAPI"
// FlagFeedbackButton
// Enables a button to send feedback from the Grafana UI
FlagFeedbackButton = "feedbackButton"

@ -4259,7 +4259,8 @@
"metadata": {
"name": "useV2DashboardsAPI",
"resourceVersion": "1732535420861",
"creationTimestamp": "2024-12-17T21:17:09Z"
"creationTimestamp": "2024-12-17T21:17:09Z",
"deletionTimestamp": "2025-03-04T10:50:39Z"
},
"spec": {
"description": "Use the v2 kubernetes API in the frontend for dashboards",

@ -2,7 +2,6 @@ import { noop } from 'lodash';
import { Props } from 'react-virtualized-auto-sizer';
import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { defaultDashboard as defaultDashboardData } from '@grafana/schema';
import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { backendSrv } from 'app/core/services/backend_srv';
@ -104,7 +103,6 @@ describe('DashboardPicker', () => {
['v2', mockDashboardV2],
])('Dashboard %s', (format, dashboard) => {
beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = format === 'v2';
getDashboardDTO.mockResolvedValue(dashboard);
});

@ -2,11 +2,11 @@ import debounce from 'debounce-promise';
import { useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv';
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { DashboardSearchItem } from 'app/features/search/types';
import { DashboardDTO } from 'app/types';
@ -57,34 +57,28 @@ export const DashboardPicker = ({
(async () => {
// value was manually changed from outside or we are rendering for the first time.
// We need to fetch dashboard information.
const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI;
if (isUIReadyForV2) {
// When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop
// That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape
const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined);
const dto = await getDashboardAPI().getDashboardDTO(value, undefined);
if (isDashboardV2Resource(dto)) {
setCurrent({
value: {
uid: resWithSchemaV2.metadata.name,
title: resWithSchemaV2.spec.title,
folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle],
folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder],
uid: dto.metadata.name,
title: dto.spec.title,
folderTitle: dto.metadata.annotations?.[AnnoKeyFolderTitle],
folderUid: dto.metadata.annotations?.[AnnoKeyFolder],
},
label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title),
label: formatLabel(dto.metadata.annotations?.[AnnoKeyFolder], dto.spec.title),
});
} else {
// when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version
const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined);
if (resWithSchemaV1.dashboard) {
if (dto.dashboard) {
setCurrent({
value: {
uid: resWithSchemaV1.dashboard.uid,
title: resWithSchemaV1.dashboard.title,
folderTitle: resWithSchemaV1.meta.folderTitle,
folderUid: resWithSchemaV1.meta.folderUid,
uid: dto.dashboard.uid,
title: dto.dashboard.title,
folderTitle: dto.meta.folderTitle,
folderUid: dto.meta.folderUid,
},
label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title),
label: formatLabel(dto.meta?.folderTitle, dto.dashboard.title),
});
}
}

@ -602,7 +602,7 @@ export class BackendSrv implements BackendService {
// NOTE: When this is removed, we can also remove most instances of:
// jest.mock('app/features/live/dashboard/dashboardWatcher
deprecationWarning('backend_srv', 'getDashboardByUid(uid)', 'getDashboardAPI().getDashboardDTO(uid)');
return getDashboardAPI().getDashboardDTO(uid);
return getDashboardAPI('v1').getDashboardDTO(uid);
}
validateDashboard(dashboard: DashboardModel): Promise<ValidateDashboardResponse> {

@ -8,11 +8,10 @@ import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils';
import { isDashboardV2Resource, isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import {
DashboardDTO,
DescendantCount,
DescendantCountDTO,
FolderDTO,
@ -240,27 +239,17 @@ export const browseDashboardsAPI = createApi({
// Move all the dashboards sequentially
// TODO error handling here
for (const dashboardUID of selectedDashboards) {
if (config.featureToggles.useV2DashboardsAPI) {
const fullDash = await getDashboardAPI('v2').getDashboardDTO(dashboardUID);
await getDashboardAPI('v2').saveDashboard({
dashboard: fullDash.spec,
folderUid: destinationUID,
overwrite: false,
message: '',
k8s: fullDash.metadata,
});
} else {
const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID);
const fullDash = await getDashboardAPI().getDashboardDTO(dashboardUID);
const dashboard = isDashboardV2Resource(fullDash) ? fullDash.spec : fullDash.dashboard;
const k8s = isDashboardV2Resource(fullDash) ? fullDash.metadata : undefined;
await getDashboardAPI().saveDashboard({
dashboard: fullDash.dashboard,
dashboard,
folderUid: destinationUID,
overwrite: false,
message: '',
k8s,
});
}
}
return { data: undefined };
},
onQueryStarted: ({ destinationUID, selectedItems }, { queryFulfilled, dispatch }) => {
@ -308,8 +297,7 @@ export const browseDashboardsAPI = createApi({
const name = response?.title;
if (name) {
const payload =
config.featureToggles.useV2DashboardsAPI || config.featureToggles.kubernetesDashboards
const payload = config.featureToggles.kubernetesDashboards
? ['Dashboard moved to Recently deleted']
: [
t('browse-dashboards.soft-delete.success', 'Dashboard {{name}} moved to Recently deleted', {
@ -322,7 +310,7 @@ export const browseDashboardsAPI = createApi({
payload,
});
}
} else if (config.featureToggles.useV2DashboardsAPI || config.featureToggles.kubernetesDashboards) {
} else if (config.featureToggles.kubernetesDashboards) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['Dashboard deleted'],
@ -344,8 +332,7 @@ export const browseDashboardsAPI = createApi({
saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand<Dashboard | DashboardV2Spec>>({
queryFn: async (cmd) => {
try {
// When we use the `useV2DashboardsAPI` flag, we can save 'v2' schema dashboards
if (config.featureToggles.useV2DashboardsAPI && isV2DashboardCommand(cmd)) {
if (isV2DashboardCommand(cmd)) {
const response = await getDashboardAPI('v2').saveDashboard(cmd);
return { data: response };
}

@ -4,7 +4,6 @@ import { useParams } from 'react-router-dom-v5-compat';
import { usePrevious } from 'react-use';
import { PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
@ -25,9 +24,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
const params = useParams();
const { type, slug, uid } = params;
const prevMatch = usePrevious({ params });
const stateManager = config.featureToggles.useV2DashboardsAPI
? getDashboardScenePageStateManager('v2')
: getDashboardScenePageStateManager();
const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState();
// After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need
const routeReloadCounter = (location.state as any)?.routeReloadCounter;

@ -4,15 +4,16 @@ import { BackendSrv, setBackendSrv } from '@grafana/runtime';
import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import store from 'app/core/store';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { getDashboardSnapshotSrv } from 'app/features/dashboard/services/SnapshotSrv';
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
import { DASHBOARD_FROM_LS_KEY, DashboardDataDTO, DashboardDTO, DashboardRoutes } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils';
import {
DashboardScenePageStateManager,
UnifiedDashboardScenePageStateManager,
DASHBOARD_CACHE_TTL,
DashboardScenePageStateManagerV2,
} from './DashboardScenePageStateManager';
@ -21,6 +22,22 @@ jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: jest.fn(),
}));
const setupDashboardAPI = (
d: DashboardWithAccessInfo<DashboardV2Spec> | undefined,
spy: jest.Mock,
effect?: () => void
) => {
(getDashboardAPI as jest.Mock).mockImplementation(() => ({
getDashboardDTO: async () => {
spy();
effect?.();
return d;
},
deleteDashboard: jest.fn(),
saveDashboard: jest.fn(),
}));
};
describe('DashboardScenePageStateManager v1', () => {
afterEach(() => {
store.delete(DASHBOARD_FROM_LS_KEY);
@ -165,7 +182,7 @@ describe('DashboardScenePageStateManager v1', () => {
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual({
message: 'v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle',
message: 'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.',
messageId: undefined,
status: undefined,
});
@ -267,24 +284,6 @@ describe('DashboardScenePageStateManager v2', () => {
});
describe('when fetching/loading a dashboard', () => {
const setupDashboardAPI = (
d: DashboardWithAccessInfo<DashboardV2Spec> | undefined,
spy: jest.Mock,
effect?: () => void
) => {
(getDashboardAPI as jest.Mock).mockImplementation(() => {
// Return whatever you want for this mock
return {
getDashboardDTO: async () => {
spy();
effect?.();
return d;
},
deleteDashboard: jest.fn(),
saveDashboard: jest.fn(),
};
});
};
it('should call loader from server if the dashboard is not cached', async () => {
const getDashSpy = jest.fn();
setupDashboardAPI(
@ -473,10 +472,9 @@ describe('DashboardScenePageStateManager v2', () => {
it('should not transform v2 custom home dashboard spec', async () => {
setBackendSrv({
get: () =>
Promise.resolve({
dashboard: customHomeDashboardV2Spec,
meta: {
get: () => {
return Promise.resolve({
access: {
canSave: false,
canEdit: true,
canAdmin: false,
@ -500,7 +498,16 @@ describe('DashboardScenePageStateManager v2', () => {
provisionedExternalId: '',
annotationsPermissions: null,
},
}),
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'home',
creationTimestamp: '',
resourceVersion: '1',
},
spec: customHomeDashboardV2Spec,
});
},
} as unknown as BackendSrv);
const loader = new DashboardScenePageStateManagerV2({});
@ -648,6 +655,142 @@ describe('DashboardScenePageStateManager v2', () => {
});
});
describe('UnifiedDashboardScenePageStateManager', () => {
afterEach(() => {
store.delete(DASHBOARD_FROM_LS_KEY);
});
describe('when fetching/loading a dashboard', () => {
it('should use v1 manager by default and handle v1 dashboards', async () => {
const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} });
const manager = new UnifiedDashboardScenePageStateManager({});
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash', undefined);
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager);
});
it('should switch to v2 manager when loading v2 dashboard', async () => {
setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1'));
const getDashSpy = jest.fn();
setupDashboardAPI(
{
access: {},
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'fake-dash',
creationTimestamp: '',
resourceVersion: '1',
},
spec: { ...defaultDashboardV2Spec() },
},
getDashSpy
);
const manager = new UnifiedDashboardScenePageStateManager({});
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
expect(getDashSpy).toHaveBeenCalledTimes(1);
});
it('should maintain active manager state between operations', async () => {
const getDashSpy = jest.fn();
setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1'));
setupDashboardAPI(
{
access: {},
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'fake-dash',
creationTimestamp: '',
resourceVersion: '1',
},
spec: { ...defaultDashboardV2Spec() },
},
getDashSpy
);
const manager = new UnifiedDashboardScenePageStateManager({});
// First load switches to v2
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
// Cache should use the active v2 manager
const cachedDash = manager.getDashboardFromCache('fake-dash');
expect(cachedDash).toBeDefined();
});
it.todo('should handle snapshot loading for both v1 and v2');
it('should handle dashboard reloading with current active manager', async () => {
const getDashSpy = jest.fn();
setupDashboardAPI(
{
access: {},
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'fake-dash',
creationTimestamp: '',
resourceVersion: '1',
},
spec: { ...defaultDashboardV2Spec() },
},
getDashSpy
);
setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1'));
const manager = new UnifiedDashboardScenePageStateManager({});
// Initial load with v2 dashboard
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
// Reload for v2 is not supported yet
await expect(
manager.reloadDashboard({ version: 1, scopes: [], timeRange: { from: 'now-1h', to: 'now' }, variables: {} })
).rejects.toThrow('Method not implemented.');
});
it('should transform responses correctly based on dashboard version', async () => {
const manager = new UnifiedDashboardScenePageStateManager({});
// V1 dashboard response
const v1Response: DashboardDTO = {
dashboard: { uid: 'v1-dash', title: 'V1 Dashboard' } as DashboardDataDTO,
meta: {},
};
const v1Scene = manager.transformResponseToScene(v1Response, { uid: 'v1-dash', route: DashboardRoutes.Normal });
expect(v1Scene).toBeInstanceOf(DashboardScene);
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager);
// V2 dashboard response
const v2Response: DashboardWithAccessInfo<DashboardV2Spec> = {
access: {},
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'v2-dash',
creationTimestamp: '',
resourceVersion: '1',
},
spec: { ...defaultDashboardV2Spec() },
};
const v2Scene = manager.transformResponseToScene(v2Response, { uid: 'v2-dash', route: DashboardRoutes.Normal });
expect(v2Scene).toBeInstanceOf(DashboardScene);
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
});
});
});
const customHomeDashboardV2Spec = {
title: 'Home Dashboard v2 schema',
cursorSync: 'Off',

@ -8,9 +8,8 @@ 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 { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
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';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
@ -293,6 +292,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
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');
if (isRedirectResponse(rsp)) {
@ -302,7 +302,9 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
if (isDashboardV2Spec(rsp.dashboard)) {
throw new Error('v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle');
throw new Error(
'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.'
);
}
if (rsp?.meta) {
@ -501,6 +503,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
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)) {
@ -509,16 +512,15 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
return null;
}
rsp = ResponseTransformers.ensureV2Response(dto);
// if custom home dashboard is v2 spec already, ignore the spec transformation
if (isDashboardV2Spec(dto.dashboard)) {
rsp.spec = dto.dashboard;
if (!isDashboardV2Resource(dto)) {
throw new Error('Custom home dashboard is not a v2 spec');
}
rsp.access.canSave = false;
rsp.access.canShare = false;
rsp.access.canStar = false;
rsp = dto;
dto.access.canSave = false;
dto.access.canShare = false;
dto.access.canStar = false;
break;
case DashboardRoutes.Public: {
@ -569,32 +571,143 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
}
}
export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase<
DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>
> {
private v1Manager: DashboardScenePageStateManager;
private v2Manager: DashboardScenePageStateManagerV2;
private activeManager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2;
constructor(initialState: Partial<DashboardScenePageState>) {
super(initialState);
this.v1Manager = new DashboardScenePageStateManager(initialState);
this.v2Manager = new DashboardScenePageStateManagerV2(initialState);
// Start with v2 if newDashboardLayout is enabled, otherwise v1
this.activeManager = this.v1Manager;
}
private async withVersionHandling<T>(
operation: (manager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2) => Promise<T>
): Promise<T> {
try {
const result = await operation(this.activeManager);
// need to sync the state of the active manager with the unified manager
// in cases when components are subscribed to unified manager's state
this.setState(this.activeManager.state);
return result;
} catch (error) {
if (error instanceof DashboardVersionError) {
const manager = error.data.storedVersion === 'v2alpha1' ? this.v2Manager : this.v1Manager;
this.activeManager = manager;
return await operation(manager);
} else {
throw error;
}
}
}
public async fetchDashboard(options: LoadDashboardOptions) {
return this.withVersionHandling<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | null>((manager) =>
manager.fetchDashboard(options)
);
}
public async reloadDashboard(params: LoadDashboardOptions['params']) {
return this.withVersionHandling((manager) => manager.reloadDashboard(params));
}
public getDashboardFromCache(uid: string) {
return this.activeManager.getDashboardFromCache(uid);
}
transformResponseToScene(
rsp: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | null,
options: LoadDashboardOptions
): DashboardScene | null {
if (!rsp) {
return null;
}
if (isDashboardV2Resource(rsp)) {
this.activeManager = this.v2Manager;
return this.v2Manager.transformResponseToScene(rsp, options);
}
return this.v1Manager.transformResponseToScene(rsp, options);
}
public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
try {
return await this.v1Manager.loadSnapshotScene(slug);
} catch (error) {
if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') {
return await this.v2Manager.loadSnapshotScene(slug);
}
throw new Error('Snapshot not found');
}
}
public async loadSnapshot(slug: string) {
return this.withVersionHandling((manager) => manager.loadSnapshot(slug));
}
public clearDashboardCache() {
this.v1Manager.clearDashboardCache();
this.v2Manager.clearDashboardCache();
}
public clearSceneCache() {
this.v1Manager.clearSceneCache();
this.v2Manager.clearSceneCache();
this.cache = {};
}
public getCache() {
return this.activeManager.getCache();
}
public setDashboardCache(cacheKey: string, dashboard: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>) {
if (isDashboardV2Resource(dashboard)) {
this.v2Manager.setDashboardCache(cacheKey, dashboard);
} else {
this.v1Manager.setDashboardCache(cacheKey, dashboard);
}
}
}
const managers: {
v1?: DashboardScenePageStateManager;
v2?: DashboardScenePageStateManagerV2;
unified?: UnifiedDashboardScenePageStateManager;
} = {
v1: undefined,
v2: undefined,
unified: undefined,
};
export function getDashboardScenePageStateManager(
v: 'v2'
): DashboardScenePageStateManagerLike<DashboardWithAccessInfo<DashboardV2Spec>>;
export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike<DashboardDTO>;
export function getDashboardScenePageStateManager(): UnifiedDashboardScenePageStateManager;
export function getDashboardScenePageStateManager(v: 'v1'): DashboardScenePageStateManager;
export function getDashboardScenePageStateManager(v: 'v2'): DashboardScenePageStateManagerV2;
export function getDashboardScenePageStateManager(v?: 'v1' | 'v2') {
if (v === 'v1') {
if (!managers.v1) {
managers.v1 = new DashboardScenePageStateManager({});
}
return managers.v1;
}
export function getDashboardScenePageStateManager(
v?: 'v2'
): DashboardScenePageStateManagerLike<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>> {
if (v === 'v2') {
if (!managers.v2) {
managers.v2 = new DashboardScenePageStateManagerV2({});
}
return managers.v2;
} else {
if (!managers.v1) {
managers.v1 = new DashboardScenePageStateManager({});
}
return managers.v1;
if (!managers.unified) {
managers.unified = new UnifiedDashboardScenePageStateManager({});
}
return managers.unified;
}

@ -42,7 +42,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
.resolve()
.getDashboardChanges(saveTimeRange, saveVariables, saveRefresh);
const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges } = changeInfo;
const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges, hasMigratedToV2 } = changeInfo;
const changesCount = diffCount + (hasFolderChanges ? 1 : 0);
const dashboard = model.state.dashboardRef.resolve();
const { meta } = dashboard.useState();
@ -77,6 +77,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
oldValue={initialSaveModel}
newValue={changedSaveModel}
hasFolderChanges={hasFolderChanges}
hasMigratedToV2={hasMigratedToV2}
oldFolder={dashboard.getInitialState()?.meta.folderTitle}
newFolder={folderTitle}
/>

@ -25,7 +25,7 @@ export interface Props {
}
export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
const { hasChanges, changedSaveModel } = changeInfo;
const { hasChanges, hasMigratedToV2, changedSaveModel } = changeInfo;
const { state, onSaveDashboard } = useSaveDashboard(false);
const [options, setOptions] = useState<SaveDashboardOptions>({
@ -128,6 +128,15 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
return (
<Stack gap={2} direction="column">
<SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />
{hasMigratedToV2 && (
<Alert title="Dashboard drastically changed" severity="warning">
<p>
Because you're using new dashboards features only supported on new Grafana dashboard schema format, the
dashboard will be saved in the new format. Please make sure you want to perform this action or you prefer to
save the dashboard as a new copy.
</p>
</Alert>
)}
<Field label="Message">
<TextArea
aria-label="message"

@ -8,6 +8,9 @@ import {
DashboardV2Spec,
VariableKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { DashboardDataDTO, DashboardDTO } from 'app/types';
import { jsonDiff } from '../settings/version-history/utils';
@ -36,13 +39,16 @@ export function isEqual(a: VariableOption | undefined, b: VariableOption | undef
}
export function getRawDashboardV2Changes(
initial: DashboardV2Spec,
initial: DashboardV2Spec | Dashboard,
changed: DashboardV2Spec,
saveTimeRange?: boolean,
saveVariables?: boolean,
saveRefresh?: boolean
) {
const initialSaveModel = initial;
// Transform initial dashboard values to v2 spec format to ensure consistent comparison of time settings,
// variables and refresh values. This handles cases where the initial dashboard is in v1 format
// but was converted to v2 during runtime due to dynamic dashboard features being used.
const initialSaveModel = convertToV2SpecIfNeeded(initial);
const changedSaveModel = changed;
const hasTimeChanged = getHasTimeChanged(changedSaveModel.timeSettings, initialSaveModel.timeSettings);
const hasVariableValueChanges = applyVariableChangesV2(changedSaveModel, initialSaveModel, saveVariables);
@ -57,7 +63,8 @@ export function getRawDashboardV2Changes(
changedSaveModel.timeSettings.autoRefresh = initialSaveModel.timeSettings.autoRefresh;
}
const diff = jsonDiff(initialSaveModel, changedSaveModel);
// Calculate differences using the non-transformed to v2 spec values to be able to compare the initial and changed dashboard values
const diff = jsonDiff(initial, changedSaveModel);
const diffCount = Object.values(diff).reduce((acc, cur) => acc + cur.length, 0);
return {
@ -69,7 +76,20 @@ export function getRawDashboardV2Changes(
hasTimeChanges: hasTimeChanged,
hasVariableValueChanges,
hasRefreshChange: hasRefreshChanged,
hasMigratedToV2: !isDashboardV2Spec(initial),
};
}
function convertToV2SpecIfNeeded(initial: DashboardV2Spec | Dashboard): DashboardV2Spec {
if (isDashboardV2Spec(initial)) {
return initial;
}
const dto: DashboardDTO = {
dashboard: initial as DashboardDataDTO,
meta: {},
};
return ResponseTransformers.ensureV2Response(dto).spec;
}
export function getRawDashboardChanges(

@ -20,6 +20,7 @@ export interface DashboardChangeInfo {
hasRefreshChange: boolean;
isNew?: boolean;
hasFolderChanges?: boolean;
hasMigratedToV2?: boolean;
}
export function isVersionMismatchError(error?: Error) {

@ -172,9 +172,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
private _serializer: DashboardSceneSerializerLike<
Dashboard | DashboardV2Spec,
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
> = getDashboardSceneSerializer();
>;
public constructor(state: Partial<DashboardSceneState>) {
public constructor(state: Partial<DashboardSceneState>, serializerVersion: 'v1' | 'v2' = 'v1') {
super({
title: 'Dashboard',
meta: {},
@ -187,6 +187,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
});
this._serializer =
serializerVersion === 'v2' ? getDashboardSceneSerializer('v2') : getDashboardSceneSerializer('v1');
this._changeTracker = new DashboardSceneChangeTracker(this);
this.addActivationHandler(() => this._activationHandler());

@ -623,10 +623,10 @@ export function ToolbarActions({ dashboard }: Props) {
},
});
// Will open a schema v2 editor drawer. Only available with useV2DashboardsAPI feature toggle on.
// Will open a schema v2 editor drawer. Only available with new dashboard layouts.
toolbarActions.push({
group: 'main-buttons',
condition: uid && config.featureToggles.useV2DashboardsAPI,
condition: uid && dashboardNewLayouts,
render: () => {
return (
<ToolbarButton

@ -60,10 +60,6 @@ jest.mock('@grafana/runtime', () => ({
describe('DashboardSceneSerializer', () => {
describe('v1 schema', () => {
beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = false;
});
it('Can detect no changes', () => {
const dashboard = setup();
const result = dashboard.getDashboardChanges(false);
@ -456,10 +452,6 @@ describe('DashboardSceneSerializer', () => {
});
describe('v2 schema', () => {
beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = true;
});
it('Can detect no changes', () => {
const dashboard = setupV2();
const result = dashboard.getDashboardChanges(false);

@ -1,4 +1,3 @@
import { config } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types';
@ -20,11 +19,16 @@ import { getVizPanelKeyForPanelId } from '../utils/utils';
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
export interface DashboardSceneSerializerLike<T, M> {
/**
* T is the type of the save model
* M is the type of the metadata
* I is the type of the initial save model. By default it's the same as T.
*/
export interface DashboardSceneSerializerLike<T, M, I = T> {
/**
* The save model which the dashboard scene was originally created from
*/
initialSaveModel?: T;
initialSaveModel?: I;
metadata?: M;
initializeMapping(saveModel: T | undefined): void;
getSaveModel: (s: DashboardScene) => T;
@ -132,6 +136,7 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb
...changeInfo,
hasFolderChanges,
hasChanges: changeInfo.hasChanges || hasFolderChanges,
hasMigratedToV2: false,
};
}
@ -170,9 +175,14 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb
}
export class V2DashboardSerializer
implements DashboardSceneSerializerLike<DashboardV2Spec, DashboardWithAccessInfo<DashboardV2Spec>['metadata']>
implements
DashboardSceneSerializerLike<
DashboardV2Spec,
DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
Dashboard | DashboardV2Spec
>
{
initialSaveModel?: DashboardV2Spec;
initialSaveModel?: DashboardV2Spec | Dashboard;
metadata?: DashboardWithAccessInfo<DashboardV2Spec>['metadata'];
protected elementPanelMap = new Map<string, number>();
@ -250,6 +260,7 @@ export class V2DashboardSerializer
hasFolderChanges,
hasChanges: changeInfo.hasChanges || hasFolderChanges,
isNew,
hasMigratedToV2: !!changeInfo.hasMigratedToV2,
};
}
@ -260,14 +271,20 @@ export class V2DashboardSerializer
}
getTrackingInformation(s: DashboardScene): DashboardTrackingInfo | undefined {
if (!this.initialSaveModel) {
return undefined;
}
const panelPluginIds =
Object.values(this.initialSaveModel?.elements ?? [])
'elements' in this.initialSaveModel
? Object.values(this.initialSaveModel.elements)
.filter((e) => e.kind === 'Panel')
.map((p) => p.spec.vizConfig.kind) || [];
.map((p) => p.spec.vizConfig.kind)
: [];
const panels = getPanelPluginCounts(panelPluginIds);
const variables = getV2SchemaVariables(this.initialSaveModel?.variables || []);
const variables =
'variables' in this.initialSaveModel! ? getV2SchemaVariables(this.initialSaveModel.variables) : [];
if (this.initialSaveModel) {
return {
schemaVersion: DASHBOARD_SCHEMA_VERSION,
uid: s.state.uid,
@ -280,19 +297,23 @@ export class V2DashboardSerializer
};
}
return undefined;
}
getSnapshotUrl() {
return this.metadata?.annotations?.[AnnoKeyDashboardSnapshotOriginalUrl];
}
}
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<Dashboard, DashboardMeta>;
export function getDashboardSceneSerializer(version: 'v1'): DashboardSceneSerializerLike<Dashboard, DashboardMeta>;
export function getDashboardSceneSerializer(
version: 'v2'
): DashboardSceneSerializerLike<DashboardV2Spec, DashboardWithAccessInfo<DashboardV2Spec>['metadata']>;
export function getDashboardSceneSerializer(
version?: 'v1' | 'v2'
): DashboardSceneSerializerLike<
Dashboard | DashboardV2Spec,
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
> {
if (config.featureToggles.useV2DashboardsAPI) {
if (version === 'v2') {
return new V2DashboardSerializer();
}

@ -155,7 +155,8 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
//createLayoutManager(dashboard);
const dashboardScene = new DashboardScene({
const dashboardScene = new DashboardScene(
{
description: dashboard.description,
editable: dashboard.editable,
preload: dashboard.preload,
@ -208,7 +209,9 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
}),
hideTimeControls: dashboard.timeSettings.hideTimepicker,
}),
});
},
'v2'
);
dashboardScene.setInitialSaveModel(dto.spec, dto.metadata);

@ -177,6 +177,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
let annotationLayers: SceneDataLayerProvider[] = [];
let alertStatesLayer: AlertStatesDataLayer | undefined;
const uid = dto.uid;
const serializerVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1';
if (oldModel.templating?.list?.length) {
if (oldModel.meta.isSnapshot) {
@ -246,7 +247,8 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
version: oldModel.version,
}),
];
const dashboardScene = new DashboardScene({
const dashboardScene = new DashboardScene(
{
uid,
description: oldModel.description,
editable: oldModel.editable,
@ -288,7 +290,9 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
}),
hideTimeControls: oldModel.timepicker.hidden,
}),
});
},
serializerVersion
);
return dashboardScene;
}

@ -0,0 +1,112 @@
import { Dashboard } from '@grafana/schema/dist/esm/index';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/types.gen';
import { DashboardDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
import { DashboardVersionError, DashboardWithAccessInfo } from './types';
import { isV2DashboardCommand } from './utils';
import { K8sDashboardAPI } from './v1';
import { K8sDashboardV2API } from './v2';
jest.mock('./v1');
jest.mock('./v2');
describe('UnifiedDashboardAPI', () => {
let api: UnifiedDashboardAPI;
let v1Client: jest.Mocked<K8sDashboardAPI>;
let v2Client: jest.Mocked<K8sDashboardV2API>;
beforeEach(() => {
jest.clearAllMocks();
api = new UnifiedDashboardAPI();
v1Client = api['v1Client'] as jest.Mocked<K8sDashboardAPI>;
v2Client = api['v2Client'] as jest.Mocked<K8sDashboardV2API>;
});
describe('getDashboardDTO', () => {
it('should try v1 first and return result if successful', async () => {
const mockResponse = { dashboard: { title: 'test' } };
v1Client.getDashboardDTO.mockResolvedValue(mockResponse as DashboardDTO);
const result = await api.getDashboardDTO('123');
expect(result).toBe(mockResponse);
expect(v1Client.getDashboardDTO).toHaveBeenCalledWith('123');
expect(v2Client.getDashboardDTO).not.toHaveBeenCalled();
});
it('should fallback to v2 if v1 throws DashboardVersionError', async () => {
const mockV2Response = { spec: { title: 'test' } };
v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format'));
v2Client.getDashboardDTO.mockResolvedValue(mockV2Response as DashboardWithAccessInfo<DashboardV2Spec>);
const result = await api.getDashboardDTO('123');
expect(result).toBe(mockV2Response);
expect(v2Client.getDashboardDTO).toHaveBeenCalledWith('123');
});
});
describe('saveDashboard', () => {
it('should use v1 client for v1 dashboard', async () => {
const mockCommand = { dashboard: { title: 'test' } };
v1Client.saveDashboard.mockResolvedValue({ id: 1, status: 'success', slug: '', uid: '', url: '', version: 1 });
await api.saveDashboard(mockCommand as SaveDashboardCommand<Dashboard>);
expect(v1Client.saveDashboard).toHaveBeenCalledWith(mockCommand);
expect(v2Client.saveDashboard).not.toHaveBeenCalled();
});
it('should use v2 client for v2 dashboard', async () => {
const mockCommand: SaveDashboardCommand<DashboardV2Spec> = {
dashboard: {
title: 'test',
elements: {},
annotations: [],
cursorSync: 'Crosshair',
layout: {
kind: 'GridLayout',
spec: { items: [] },
},
liveNow: false,
tags: [],
links: [],
preload: false,
timeSettings: {
from: 'now-1h',
to: 'now',
autoRefresh: '5s',
autoRefreshIntervals: ['5s', '1m', '5m', '15m', '30m', '1h', '4h', '8h', '12h', '24h'],
timezone: 'utc',
hideTimepicker: false,
fiscalYearStartMonth: 0,
},
variables: [],
},
};
v2Client.saveDashboard.mockResolvedValue({ id: 1, status: 'success', slug: '', uid: '', url: '', version: 1 });
await api.saveDashboard(mockCommand as SaveDashboardCommand<DashboardV2Spec>);
expect(isV2DashboardCommand(mockCommand)).toBe(true);
expect(v2Client.saveDashboard).toHaveBeenCalledWith(mockCommand);
expect(v1Client.saveDashboard).not.toHaveBeenCalled();
});
});
describe('deleteDashboard', () => {
it('should not try other version if fails', async () => {
v1Client.deleteDashboard.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format'));
try {
await api.deleteDashboard('123', true);
} catch (error) {}
expect(v1Client.deleteDashboard).toHaveBeenCalledWith('123', true);
expect(v2Client.deleteDashboard).not.toHaveBeenCalled();
});
});
});

@ -0,0 +1,50 @@
import { Dashboard } from '@grafana/schema/dist/esm/index';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { DashboardDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
import { isV1DashboardCommand, isV2DashboardCommand } from './utils';
import { K8sDashboardAPI } from './v1';
import { K8sDashboardV2API } from './v2';
export class UnifiedDashboardAPI
implements DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, Dashboard | DashboardV2Spec>
{
private v1Client: K8sDashboardAPI;
private v2Client: K8sDashboardV2API;
constructor() {
this.v1Client = new K8sDashboardAPI();
this.v2Client = new K8sDashboardV2API();
}
// Get operation depends on the dashboard format to use one of the two clients
async getDashboardDTO(uid: string) {
try {
return await this.v1Client.getDashboardDTO(uid);
} catch (error) {
if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') {
return await this.v2Client.getDashboardDTO(uid);
}
throw error;
}
}
// Save operation depends on the dashboard format to use one of the two clients
async saveDashboard(options: SaveDashboardCommand<Dashboard | DashboardV2Spec>) {
if (isV2DashboardCommand(options)) {
return await this.v2Client.saveDashboard(options);
}
if (isV1DashboardCommand(options)) {
return await this.v1Client.saveDashboard(options);
}
throw new Error('Invalid dashboard command');
}
// Delete operation for any version is supported in the v1 client
async deleteDashboard(uid: string, showSuccessAlert: boolean) {
return await this.v1Client.deleteDashboard(uid, showSuccessAlert);
}
}

@ -1,5 +1,6 @@
import { config } from '@grafana/runtime';
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
import { getDashboardAPI, setDashboardAPI } from './dashboard_api';
import { LegacyDashboardAPI } from './legacy';
import { K8sDashboardAPI } from './v1';
@ -29,14 +30,29 @@ describe('DashboardApi', () => {
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
});
it('should use v1 api when and kubernetesDashboards toggle is enabled', () => {
it('should use legacy when v1 is passed and kubernetesDashboards toggle is disabled', () => {
config.featureToggles.kubernetesDashboards = false;
expect(getDashboardAPI('v1')).toBeInstanceOf(LegacyDashboardAPI);
});
it('should use unified api when and kubernetesDashboards toggle is enabled', () => {
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
expect(getDashboardAPI()).toBeInstanceOf(UnifiedDashboardAPI);
});
it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => {
config.featureToggles.useV2DashboardsAPI = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2API);
it('should return v1 if it is passed in the params', () => {
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI('v1')).toBeInstanceOf(K8sDashboardAPI);
});
it('should return v2 if it is passed in the params', () => {
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI('v2')).toBeInstanceOf(K8sDashboardV2API);
});
it('should throw an error if v2 is passed in the params and kubernetesDashboards toggle is disabled', () => {
config.featureToggles.kubernetesDashboards = false;
expect(() => getDashboardAPI('v2')).toThrow('v2 is not supported if kubernetes dashboards are disabled');
});
});
@ -60,16 +76,12 @@ describe('DashboardApi', () => {
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
});
it('should use v1 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => {
config.featureToggles.useV2DashboardsAPI = true;
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
it('should use v1 when v1 is passed in the params', () => {
expect(getDashboardAPI('v1')).toBeInstanceOf(K8sDashboardAPI);
});
it('should use legacy useV2DashboardsAPI toggle is enabled', () => {
config.featureToggles.useV2DashboardsAPI = true;
config.featureToggles.kubernetesDashboards = undefined;
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
it('should use v2 when v2 is passed in the params', () => {
expect(() => getDashboardAPI('v2')).toThrow('v2 is not supported for legacy architecture');
});
});
});

@ -2,6 +2,7 @@ import { Dashboard } from '@grafana/schema';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { DashboardDTO } from 'app/types';
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
import { LegacyDashboardAPI } from './legacy';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
import { getDashboardsApiVersion } from './utils';
@ -12,10 +13,9 @@ type DashboardAPIClients = {
legacy: DashboardAPI<DashboardDTO, Dashboard>;
v1: DashboardAPI<DashboardDTO, Dashboard>;
v2: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, DashboardV2Spec>;
unified: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, Dashboard | DashboardV2Spec>;
};
type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>;
let clients: Partial<DashboardAPIClients> | undefined;
export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefined) {
@ -26,28 +26,28 @@ export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefin
}
// Overloads
export function getDashboardAPI(): DashboardAPI<DashboardDTO, Dashboard>;
export function getDashboardAPI(): DashboardAPI<
DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>,
Dashboard | DashboardV2Spec
>;
export function getDashboardAPI(responseFormat: 'v1'): DashboardAPI<DashboardDTO, Dashboard>;
export function getDashboardAPI(
requestV2Response: 'v2'
responseFormat: 'v2'
): DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec>, DashboardV2Spec>;
export function getDashboardAPI(
requestV2Response?: 'v2'
): DashboardAPI<DashboardReturnTypes, Dashboard | DashboardV2Spec> {
const v = getDashboardsApiVersion();
const isConvertingToV1 = !requestV2Response;
responseFormat?: 'v1' | 'v2'
): DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, Dashboard | DashboardV2Spec> {
const v = getDashboardsApiVersion(responseFormat);
if (!clients) {
clients = {
legacy: new LegacyDashboardAPI(),
v1: new K8sDashboardAPI(),
v2: new K8sDashboardV2API(isConvertingToV1),
v2: new K8sDashboardV2API(),
unified: new UnifiedDashboardAPI(),
};
}
if (v === 'v2' && requestV2Response === 'v2') {
return new K8sDashboardV2API(false);
}
if (!clients[v]) {
throw new Error(`Unknown Dashboard API version: ${v}`);
}

@ -1,4 +1,5 @@
import { UrlQueryMap } from '@grafana/data';
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
import { Resource } from 'app/features/apiserver/types';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types';
@ -15,7 +16,7 @@ export interface DashboardAPI<G, T> {
}
// Implemented using /api/dashboards/*
export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAccessInfo'> {
export interface DashboardWithAccessInfo<T> extends Resource<T, Status, 'DashboardWithAccessInfo'> {
access: {
url?: string;
slug?: string;
@ -28,3 +29,25 @@ export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAc
annotationsPermissions?: AnnotationsPermissions;
}; // TODO...
}
export interface DashboardVersionError extends Error {
status: number;
data: {
// The version which was stored when the dashboard was created / updated.
// Currently known versions are: 'v2alpha1' | 'v1alpha1' | 'v0alpha1'
storedVersion: string;
message: string;
};
}
export class DashboardVersionError extends Error {
constructor(storedVersion: string, message = 'Dashboard version mismatch') {
super(message);
this.name = 'DashboardVersionError';
this.status = 200;
this.data = {
storedVersion,
message,
};
}
}

@ -23,27 +23,17 @@ describe('getDashboardsApiVersion', () => {
expect(getDashboardsApiVersion()).toBe('legacy');
});
it('should return v2 when dashboardScene is enabled and useV2DashboardsAPI is enabled', () => {
it('should return unified when dashboardScene is enabled and kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: true,
useV2DashboardsAPI: true,
};
expect(getDashboardsApiVersion()).toBe('v2');
});
it('should return v1 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: true,
useV2DashboardsAPI: false,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('v1');
expect(getDashboardsApiVersion()).toBe('unified');
});
it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => {
it('should return legacy when dashboardScene is enabled and kubernetesDashboards is disabled', () => {
config.featureToggles = {
dashboardScene: true,
useV2DashboardsAPI: false,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
@ -57,20 +47,16 @@ describe('getDashboardsApiVersion', () => {
it('should return legacy when kubernetesDashboards is disabled', () => {
config.featureToggles = {
dashboardScene: false,
useV2DashboardsAPI: false,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
});
it('should return legacy when kubernetesDashboards is disabled', () => {
it('should return v1 when kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: false,
useV2DashboardsAPI: false,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('v1');
});
});

@ -7,27 +7,34 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardWithAccessInfo } from './types';
export const GRID_ROW_HEIGHT = 1;
export function getDashboardsApiVersion() {
export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') {
const isDashboardSceneEnabled = config.featureToggles.dashboardScene;
const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards;
const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false';
// if dashboard scene is disabled, use legacy API response for the old architecture
if (!config.featureToggles.dashboardScene || forcingOldDashboardArch) {
// for old architecture, use v1 API for k8s dashboards
if (config.featureToggles.kubernetesDashboards) {
return 'v1';
// Force legacy API when dashboard scene is disabled or explicitly forced
if (!isDashboardSceneEnabled || forcingOldDashboardArch) {
if (responseFormat === 'v2') {
throw new Error('v2 is not supported for legacy architecture');
}
return 'legacy';
return isKubernetesDashboardsEnabled ? 'v1' : 'legacy';
}
if (config.featureToggles.useV2DashboardsAPI) {
// Unified manages redirection between v1 and v2, but when responseFormat is undefined we get the unified API
if (isKubernetesDashboardsEnabled) {
if (responseFormat === 'v1') {
return 'v1';
}
if (responseFormat === 'v2') {
return 'v2';
}
return 'unified';
}
if (config.featureToggles.kubernetesDashboards) {
return 'v1';
// Handle non-kubernetes case
if (responseFormat === 'v2') {
throw new Error('v2 is not supported if kubernetes dashboards are disabled');
}
return 'legacy';

@ -18,7 +18,7 @@ import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/ty
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
private client: ResourceClient<DashboardDataDTO>;
@ -96,6 +96,11 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
try {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
// This could come as conversion error from v0 or v2 to V1.
if (dash.status?.conversion?.failed) {
throw new DashboardVersionError(dash.status.conversion.storedVersion, dash.status.conversion.error);
}
const result: DashboardDTO = {
meta: {
...dash.access,

@ -82,7 +82,7 @@ describe('v2 dashboard API', () => {
updatedBy: '',
});
const api = new K8sDashboardV2API(false);
const api = new K8sDashboardV2API();
// because the API can currently return both DashboardDTO and DashboardWithAccessInfo<DashboardV2Spec> based on the
// parameter convertToV1, we need to cast the result to DashboardWithAccessInfo<DashboardV2Spec> to be able to
// access
@ -96,7 +96,7 @@ describe('v2 dashboard API', () => {
it('throws an error if folder is not found', async () => {
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValue({ message: 'folder not found', status: 'not-found' });
const api = new K8sDashboardV2API(false);
const api = new K8sDashboardV2API();
await expect(api.getDashboardDTO('test')).rejects.toThrow('Failed to load folder');
});
});
@ -123,7 +123,7 @@ describe('v2 dashboard API - Save', () => {
};
it('should create new dashboard', async () => {
const api = new K8sDashboardV2API(false);
const api = new K8sDashboardV2API();
const result = await api.saveDashboard({
...defaultSaveCommand,
dashboard: {
@ -143,7 +143,7 @@ describe('v2 dashboard API - Save', () => {
});
it('should update existing dashboard', async () => {
const api = new K8sDashboardV2API(false);
const api = new K8sDashboardV2API();
const result = await api.saveDashboard({
...defaultSaveCommand,
@ -160,7 +160,7 @@ describe('v2 dashboard API - Save', () => {
});
it('should update existing dashboard that is store in a folder', async () => {
const api = new K8sDashboardV2API(false);
const api = new K8sDashboardV2API();
await api.saveDashboard({
dashboard: {
...defaultSaveCommand.dashboard,

@ -1,4 +1,4 @@
import { locationUtil, UrlQueryMap } from '@grafana/data';
import { locationUtil } from '@grafana/data';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
@ -21,15 +21,14 @@ import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { ResponseTransformers } from './ResponseTransformers';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
export class K8sDashboardV2API
implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO, DashboardV2Spec>
{
private client: ResourceClient<DashboardV2Spec>;
constructor(private convertToV1: boolean) {
constructor() {
this.client = new ScopedResourceClient<DashboardV2Spec>({
group: 'dashboard.grafana.app',
version: 'v2alpha1',
@ -37,40 +36,32 @@ export class K8sDashboardV2API
});
}
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
async getDashboardDTO(uid: string) {
try {
const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto');
let result: DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO | undefined;
// TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses.
result = ResponseTransformers.ensureV2Response(dashboard);
if (dashboard.status?.conversion?.failed) {
throw new DashboardVersionError(dashboard.status.conversion.storedVersion, dashboard.status.conversion.error);
}
// load folder info if available
if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) {
if (dashboard.metadata.annotations && dashboard.metadata.annotations[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]);
result.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
result.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
result.metadata.annotations[AnnoKeyFolderId] = folder.id;
const folder = await backendSrv.getFolderByUid(dashboard.metadata.annotations[AnnoKeyFolder]);
dashboard.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
dashboard.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
dashboard.metadata.annotations[AnnoKeyFolderId] = folder.id;
} catch (e) {
throw new Error('Failed to load folder');
}
} else if (result.metadata.annotations && !result.metadata.annotations[AnnoKeyFolder]) {
} else if (dashboard.metadata.annotations && !dashboard.metadata.annotations[AnnoKeyFolder]) {
// Set AnnoKeyFolder to empty string for top-level dashboards
// This ensures NestedFolderPicker correctly identifies it as being in the "Dashboard" root folder
// AnnoKeyFolder undefined -> top-level dashboard -> empty string
result.metadata.annotations[AnnoKeyFolder] = '';
dashboard.metadata.annotations[AnnoKeyFolder] = '';
}
// Depending on the ui components readiness, we might need to convert the response to v1
if (this.convertToV1) {
// Always return V1 format
result = ResponseTransformers.ensureV1Response(result);
return result;
}
// return the v2 response
return result;
return dashboard;
} catch (e) {
const status = getStatusFromError(e);
const message = getMessageFromError(e);

@ -1,7 +1,7 @@
import { ReactElement } from 'react';
import { useAsync } from 'react-use';
import { Box, Spinner, Stack } from '@grafana/ui';
import { Alert, Box, Spinner, Stack } from '@grafana/ui';
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
import { DiffGroup } from '../../../dashboard-scene/settings/version-history/DiffGroup';
@ -16,6 +16,7 @@ interface SaveDashboardDiffProps {
hasFolderChanges?: boolean;
oldFolder?: string;
newFolder?: string;
hasMigratedToV2?: boolean;
}
export const SaveDashboardDiff = ({
@ -25,6 +26,7 @@ export const SaveDashboardDiff = ({
hasFolderChanges,
oldFolder,
newFolder,
hasMigratedToV2,
}: SaveDashboardDiffProps) => {
const loader = useAsync(async () => {
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
@ -61,6 +63,16 @@ export const SaveDashboardDiff = ({
return (
<Stack direction="column" gap={1}>
{hasMigratedToV2 && (
<Box paddingTop={1}>
<Alert
title={
'The diff is hard to read because the dashboard has been migrated to the new Grafana dashboard format'
}
severity="info"
/>
</Box>
)}
{hasFolderChanges && (
<DiffGroup
diffs={[
@ -80,7 +92,7 @@ export const SaveDashboardDiff = ({
{(!value || !oldValue) && <Spinner />}
{value && value.count >= 1 ? (
<>
{value && value.schemaChange && value.schemaChange}
{!hasMigratedToV2 && value && value.schemaChange && value.schemaChange}
{value && value.showDiffs && value.diffs}
<Box paddingTop={1}>
<h4>Full JSON diff</h4>

@ -7,6 +7,8 @@ import DashboardScenePage from 'app/features/dashboard-scene/pages/DashboardScen
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { DashboardRoutes } from 'app/types';
import { isDashboardV2Resource } from '../api/utils';
import DashboardPage, { DashboardPageParams } from './DashboardPage';
import { DashboardPageError } from './DashboardPageError';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types';
@ -23,18 +25,12 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
const forceOld = props.queryParams.scenes === false;
const params = useParams<DashboardPageParams>();
const location = useLocation();
// Force scenes if v2 api and scenes are enabled
if (config.featureToggles.useV2DashboardsAPI && config.featureToggles.dashboardScene && !forceOld) {
console.log('DashboardPageProxy: forcing scenes because of v2 api');
return <DashboardScenePage {...props} />;
}
const stateManager = getDashboardScenePageStateManager();
if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) {
return <DashboardScenePage {...props} />;
}
const stateManager = getDashboardScenePageStateManager();
const isScenesSupportedRoute = Boolean(
props.route.routeName === DashboardRoutes.Home || (props.route.routeName === DashboardRoutes.Normal && params.uid)
);
@ -63,7 +59,17 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return null;
}
if (dashboard?.value?.dashboard?.uid !== params.uid && dashboard.value?.meta?.isNew !== true) {
const uid =
dashboard.value && isDashboardV2Resource(dashboard.value)
? dashboard.value.metadata.name
: dashboard.value?.meta.uid;
const canEdit =
dashboard.value && isDashboardV2Resource(dashboard.value)
? dashboard.value?.access.canEdit
: dashboard.value?.meta?.canEdit || dashboard.value?.meta?.canMakeEditable;
const isNew = !uid;
if (uid !== params.uid && !isNew) {
return null;
}
@ -71,11 +77,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return <DashboardPage {...props} params={params} location={location} />;
}
if (
dashboard.value &&
!(dashboard.value.meta?.canEdit || dashboard.value.meta?.canMakeEditable) &&
isScenesSupportedRoute
) {
if (!canEdit && isScenesSupportedRoute && !forceOld) {
return <DashboardScenePage {...props} />;
} else {
return <DashboardPage {...props} params={params} location={location} />;

@ -15,7 +15,7 @@ import { DashboardDTO } from 'app/types';
import { appEvents } from '../../../core/core';
import { ResponseTransformers } from '../api/ResponseTransformers';
import { getDashboardAPI } from '../api/dashboard_api';
import { DashboardWithAccessInfo } from '../api/types';
import { DashboardVersionError, DashboardWithAccessInfo } from '../api/types';
import { getDashboardSrv } from './DashboardSrv';
import { getDashboardSnapshotSrv } from './SnapshotSrv';
@ -118,7 +118,7 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
uid: string | undefined,
params?: UrlQueryMap
): Promise<DashboardDTO> {
const stateManager = getDashboardScenePageStateManager();
const stateManager = getDashboardScenePageStateManager('v1');
let promise;
if (type === 'script' && slug) {
@ -139,11 +139,14 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
}
}
promise = getDashboardAPI()
promise = getDashboardAPI('v1')
.getDashboardDTO(uid, params)
.then((result) => {
return result;
})
.catch((e) => {
console.error('Failed to load dashboard', e);
if (isFetchError(e)) {
if (isFetchError(e) && !(e instanceof DashboardVersionError)) {
e.isHandled = true;
if (e.status === 404) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
@ -204,9 +207,12 @@ export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAc
promise = getDashboardAPI('v2')
.getDashboardDTO(uid, params)
.then((result) => {
return result;
})
.catch((e) => {
console.error('Failed to load dashboard', e);
if (isFetchError(e)) {
if (isFetchError(e) && !(e instanceof DashboardVersionError)) {
e.isHandled = true;
if (e.status === 404) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);

@ -63,7 +63,7 @@ async function fetchDashboard(
try {
switch (args.routeName) {
case DashboardRoutes.Home: {
const stateManager = getDashboardScenePageStateManager();
const stateManager = getDashboardScenePageStateManager('v1');
const cachedDashboard = stateManager.getDashboardFromCache(HOME_DASHBOARD_CACHE_KEY);
if (cachedDashboard) {

@ -1,5 +1,7 @@
import { t } from 'app/core/internationalization';
import { AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { validationSrv } from '../services/ValidationSrv';
@ -49,7 +51,12 @@ export const validateUid = (value: string) => {
return getDashboardAPI()
.getDashboardDTO(value)
.then((existingDashboard) => {
return `Dashboard named '${existingDashboard?.dashboard.title}' in folder '${existingDashboard?.meta.folderTitle}' has the same UID`;
const isV2 = isDashboardV2Resource(existingDashboard);
const dashboard = isV2 ? existingDashboard.spec : existingDashboard.dashboard;
const folderTitle = isV2
? existingDashboard.metadata.annotations?.[AnnoKeyFolderTitle]
: existingDashboard.meta.folderTitle;
return `Dashboard named '${dashboard.title}' in folder '${folderTitle}' has the same UID`;
})
.catch((error) => {
error.isHandled = true;

Loading…
Cancel
Save