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. 53
      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. 161
      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. 71
      public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
  28. 105
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
  29. 84
      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. 33
      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()", "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()", "10"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"], [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 />", "13"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"], [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 />", "15"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "16"], [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": [ "public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [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.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"], [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": [ "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"], [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": [ "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 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 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 />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
], ],
"public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx:5381": [ "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"], [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 | | `enableSCIM` | Enables SCIM support for user and group management |
| `crashDetection` | Enables browser crash detection reporting to Faro. | | `crashDetection` | Enables browser crash detection reporting to Faro. |
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy | | `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 | | `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
| `investigationsBackend` | Enable the investigations backend API | | `investigationsBackend` | Enable the investigations backend API |
| `k8SFolderCounts` | Enable folder's api server counts | | `k8SFolderCounts` | Enable folder's api server counts |

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

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

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

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

@ -109,7 +109,7 @@ func RegisterAPIService(
} }
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { 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. // If dashboards v2 is enabled, we want to use v2alpha1 as the default API version.
return []schema.GroupVersion{ return []schema.GroupVersion{
dashboardv2alpha1.DashboardResourceInfo.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", name: "should return v2alpha1 as the default if dashboards v2 is enabled",
enabledFeatures: []string{ enabledFeatures: []string{
featuremgmt.FlagUseV2DashboardsAPI, featuremgmt.FlagDashboardNewLayouts,
}, },
expected: []schema.GroupVersion{ expected: []schema.GroupVersion{
v2alpha1.DashboardResourceInfo.GroupVersion(), v2alpha1.DashboardResourceInfo.GroupVersion(),

@ -1559,13 +1559,6 @@ var (
FrontendOnly: true, FrontendOnly: true,
Expression: "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", Name: "feedbackButton",
Description: "Enables a button to send feedback from the Grafana UI", 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 alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true 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 feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,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 // Enables simplified step mode in the notifications section
FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode" FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode"
// FlagUseV2DashboardsAPI
// Use the v2 kubernetes API in the frontend for dashboards
FlagUseV2DashboardsAPI = "useV2DashboardsAPI"
// FlagFeedbackButton // FlagFeedbackButton
// Enables a button to send feedback from the Grafana UI // Enables a button to send feedback from the Grafana UI
FlagFeedbackButton = "feedbackButton" FlagFeedbackButton = "feedbackButton"

@ -4259,7 +4259,8 @@
"metadata": { "metadata": {
"name": "useV2DashboardsAPI", "name": "useV2DashboardsAPI",
"resourceVersion": "1732535420861", "resourceVersion": "1732535420861",
"creationTimestamp": "2024-12-17T21:17:09Z" "creationTimestamp": "2024-12-17T21:17:09Z",
"deletionTimestamp": "2025-03-04T10:50:39Z"
}, },
"spec": { "spec": {
"description": "Use the v2 kubernetes API in the frontend for dashboards", "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 { Props } from 'react-virtualized-auto-sizer';
import { render, screen, userEvent, waitFor } from 'test/test-utils'; import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { defaultDashboard as defaultDashboardData } from '@grafana/schema'; import { defaultDashboard as defaultDashboardData } from '@grafana/schema';
import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
@ -104,7 +103,6 @@ describe('DashboardPicker', () => {
['v2', mockDashboardV2], ['v2', mockDashboardV2],
])('Dashboard %s', (format, dashboard) => { ])('Dashboard %s', (format, dashboard) => {
beforeEach(() => { beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = format === 'v2';
getDashboardDTO.mockResolvedValue(dashboard); getDashboardDTO.mockResolvedValue(dashboard);
}); });

@ -2,11 +2,11 @@ import debounce from 'debounce-promise';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui'; import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; 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 { DashboardSearchItem } from 'app/features/search/types';
import { DashboardDTO } from 'app/types'; import { DashboardDTO } from 'app/types';
@ -57,34 +57,28 @@ export const DashboardPicker = ({
(async () => { (async () => {
// value was manually changed from outside or we are rendering for the first time. // value was manually changed from outside or we are rendering for the first time.
// We need to fetch dashboard information. // We need to fetch dashboard information.
const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI; const dto = await getDashboardAPI().getDashboardDTO(value, undefined);
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);
if (isDashboardV2Resource(dto)) {
setCurrent({ setCurrent({
value: { value: {
uid: resWithSchemaV2.metadata.name, uid: dto.metadata.name,
title: resWithSchemaV2.spec.title, title: dto.spec.title,
folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle], folderTitle: dto.metadata.annotations?.[AnnoKeyFolderTitle],
folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], folderUid: dto.metadata.annotations?.[AnnoKeyFolder],
}, },
label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title), label: formatLabel(dto.metadata.annotations?.[AnnoKeyFolder], dto.spec.title),
}); });
} else { } else {
// when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version if (dto.dashboard) {
const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined);
if (resWithSchemaV1.dashboard) {
setCurrent({ setCurrent({
value: { value: {
uid: resWithSchemaV1.dashboard.uid, uid: dto.dashboard.uid,
title: resWithSchemaV1.dashboard.title, title: dto.dashboard.title,
folderTitle: resWithSchemaV1.meta.folderTitle, folderTitle: dto.meta.folderTitle,
folderUid: resWithSchemaV1.meta.folderUid, 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: // NOTE: When this is removed, we can also remove most instances of:
// jest.mock('app/features/live/dashboard/dashboardWatcher // jest.mock('app/features/live/dashboard/dashboardWatcher
deprecationWarning('backend_srv', 'getDashboardByUid(uid)', 'getDashboardAPI().getDashboardDTO(uid)'); deprecationWarning('backend_srv', 'getDashboardByUid(uid)', 'getDashboardAPI().getDashboardDTO(uid)');
return getDashboardAPI().getDashboardDTO(uid); return getDashboardAPI('v1').getDashboardDTO(uid);
} }
validateDashboard(dashboard: DashboardModel): Promise<ValidateDashboardResponse> { validateDashboard(dashboard: DashboardModel): Promise<ValidateDashboardResponse> {

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

@ -4,7 +4,6 @@ import { useParams } from 'react-router-dom-v5-compat';
import { usePrevious } from 'react-use'; import { usePrevious } from 'react-use';
import { PageLayoutType } from '@grafana/data'; import { PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { UrlSyncContextProvider } from '@grafana/scenes'; import { UrlSyncContextProvider } from '@grafana/scenes';
import { Box } from '@grafana/ui'; import { Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
@ -25,9 +24,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
const params = useParams(); const params = useParams();
const { type, slug, uid } = params; const { type, slug, uid } = params;
const prevMatch = usePrevious({ params }); const prevMatch = usePrevious({ params });
const stateManager = config.featureToggles.useV2DashboardsAPI const stateManager = getDashboardScenePageStateManager();
? getDashboardScenePageStateManager('v2')
: getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState(); 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 // 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; 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 { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import store from 'app/core/store'; import store from 'app/core/store';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; 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 { 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 { DashboardScene } from '../scene/DashboardScene';
import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils'; import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils';
import { import {
DashboardScenePageStateManager, DashboardScenePageStateManager,
UnifiedDashboardScenePageStateManager,
DASHBOARD_CACHE_TTL, DASHBOARD_CACHE_TTL,
DashboardScenePageStateManagerV2, DashboardScenePageStateManagerV2,
} from './DashboardScenePageStateManager'; } from './DashboardScenePageStateManager';
@ -21,6 +22,22 @@ jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: jest.fn(), 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', () => { describe('DashboardScenePageStateManager v1', () => {
afterEach(() => { afterEach(() => {
store.delete(DASHBOARD_FROM_LS_KEY); store.delete(DASHBOARD_FROM_LS_KEY);
@ -165,7 +182,7 @@ describe('DashboardScenePageStateManager v1', () => {
expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual({ 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, messageId: undefined,
status: undefined, status: undefined,
}); });
@ -267,24 +284,6 @@ describe('DashboardScenePageStateManager v2', () => {
}); });
describe('when fetching/loading a dashboard', () => { 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 () => { it('should call loader from server if the dashboard is not cached', async () => {
const getDashSpy = jest.fn(); const getDashSpy = jest.fn();
setupDashboardAPI( setupDashboardAPI(
@ -473,10 +472,9 @@ describe('DashboardScenePageStateManager v2', () => {
it('should not transform v2 custom home dashboard spec', async () => { it('should not transform v2 custom home dashboard spec', async () => {
setBackendSrv({ setBackendSrv({
get: () => get: () => {
Promise.resolve({ return Promise.resolve({
dashboard: customHomeDashboardV2Spec, access: {
meta: {
canSave: false, canSave: false,
canEdit: true, canEdit: true,
canAdmin: false, canAdmin: false,
@ -500,7 +498,16 @@ describe('DashboardScenePageStateManager v2', () => {
provisionedExternalId: '', provisionedExternalId: '',
annotationsPermissions: null, annotationsPermissions: null,
}, },
}), apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'home',
creationTimestamp: '',
resourceVersion: '1',
},
spec: customHomeDashboardV2Spec,
});
},
} as unknown as BackendSrv); } as unknown as BackendSrv);
const loader = new DashboardScenePageStateManagerV2({}); 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 = { const customHomeDashboardV2Spec = {
title: 'Home Dashboard v2 schema', title: 'Home Dashboard v2 schema',
cursorSync: 'Off', cursorSync: 'Off',

@ -8,9 +8,8 @@ import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { AnnoKeyFolder } from 'app/features/apiserver/types'; import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
@ -293,6 +292,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
break; break;
case DashboardRoutes.Home: 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'); rsp = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
if (isRedirectResponse(rsp)) { if (isRedirectResponse(rsp)) {
@ -302,7 +302,9 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
} }
if (isDashboardV2Spec(rsp.dashboard)) { 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) { if (rsp?.meta) {
@ -501,6 +503,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
rsp = await buildNewDashboardSaveModelV2(urlFolderUid); rsp = await buildNewDashboardSaveModelV2(urlFolderUid);
break; break;
case DashboardRoutes.Home: 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'); const dto = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
if (isRedirectResponse(dto)) { if (isRedirectResponse(dto)) {
@ -509,16 +512,15 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
return null; return null;
} }
rsp = ResponseTransformers.ensureV2Response(dto);
// if custom home dashboard is v2 spec already, ignore the spec transformation // if custom home dashboard is v2 spec already, ignore the spec transformation
if (isDashboardV2Spec(dto.dashboard)) { if (!isDashboardV2Resource(dto)) {
rsp.spec = dto.dashboard; throw new Error('Custom home dashboard is not a v2 spec');
} }
rsp.access.canSave = false; rsp = dto;
rsp.access.canShare = false; dto.access.canSave = false;
rsp.access.canStar = false; dto.access.canShare = false;
dto.access.canStar = false;
break; break;
case DashboardRoutes.Public: { 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: { const managers: {
v1?: DashboardScenePageStateManager; v1?: DashboardScenePageStateManager;
v2?: DashboardScenePageStateManagerV2; v2?: DashboardScenePageStateManagerV2;
unified?: UnifiedDashboardScenePageStateManager;
} = { } = {
v1: undefined, v1: undefined,
v2: undefined, v2: undefined,
unified: undefined,
}; };
export function getDashboardScenePageStateManager( export function getDashboardScenePageStateManager(): UnifiedDashboardScenePageStateManager;
v: 'v2' export function getDashboardScenePageStateManager(v: 'v1'): DashboardScenePageStateManager;
): DashboardScenePageStateManagerLike<DashboardWithAccessInfo<DashboardV2Spec>>; export function getDashboardScenePageStateManager(v: 'v2'): DashboardScenePageStateManagerV2;
export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike<DashboardDTO>;
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 (v === 'v2') {
if (!managers.v2) { if (!managers.v2) {
managers.v2 = new DashboardScenePageStateManagerV2({}); managers.v2 = new DashboardScenePageStateManagerV2({});
} }
return managers.v2; 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() .resolve()
.getDashboardChanges(saveTimeRange, saveVariables, saveRefresh); .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 changesCount = diffCount + (hasFolderChanges ? 1 : 0);
const dashboard = model.state.dashboardRef.resolve(); const dashboard = model.state.dashboardRef.resolve();
const { meta } = dashboard.useState(); const { meta } = dashboard.useState();
@ -77,6 +77,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
oldValue={initialSaveModel} oldValue={initialSaveModel}
newValue={changedSaveModel} newValue={changedSaveModel}
hasFolderChanges={hasFolderChanges} hasFolderChanges={hasFolderChanges}
hasMigratedToV2={hasMigratedToV2}
oldFolder={dashboard.getInitialState()?.meta.folderTitle} oldFolder={dashboard.getInitialState()?.meta.folderTitle}
newFolder={folderTitle} newFolder={folderTitle}
/> />

@ -25,7 +25,7 @@ export interface Props {
} }
export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
const { hasChanges, changedSaveModel } = changeInfo; const { hasChanges, hasMigratedToV2, changedSaveModel } = changeInfo;
const { state, onSaveDashboard } = useSaveDashboard(false); const { state, onSaveDashboard } = useSaveDashboard(false);
const [options, setOptions] = useState<SaveDashboardOptions>({ const [options, setOptions] = useState<SaveDashboardOptions>({
@ -128,6 +128,15 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
return ( return (
<Stack gap={2} direction="column"> <Stack gap={2} direction="column">
<SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} /> <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"> <Field label="Message">
<TextArea <TextArea
aria-label="message" aria-label="message"

@ -8,6 +8,9 @@ import {
DashboardV2Spec, DashboardV2Spec,
VariableKind, VariableKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; } 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'; import { jsonDiff } from '../settings/version-history/utils';
@ -36,13 +39,16 @@ export function isEqual(a: VariableOption | undefined, b: VariableOption | undef
} }
export function getRawDashboardV2Changes( export function getRawDashboardV2Changes(
initial: DashboardV2Spec, initial: DashboardV2Spec | Dashboard,
changed: DashboardV2Spec, changed: DashboardV2Spec,
saveTimeRange?: boolean, saveTimeRange?: boolean,
saveVariables?: boolean, saveVariables?: boolean,
saveRefresh?: 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 changedSaveModel = changed;
const hasTimeChanged = getHasTimeChanged(changedSaveModel.timeSettings, initialSaveModel.timeSettings); const hasTimeChanged = getHasTimeChanged(changedSaveModel.timeSettings, initialSaveModel.timeSettings);
const hasVariableValueChanges = applyVariableChangesV2(changedSaveModel, initialSaveModel, saveVariables); const hasVariableValueChanges = applyVariableChangesV2(changedSaveModel, initialSaveModel, saveVariables);
@ -57,7 +63,8 @@ export function getRawDashboardV2Changes(
changedSaveModel.timeSettings.autoRefresh = initialSaveModel.timeSettings.autoRefresh; 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); const diffCount = Object.values(diff).reduce((acc, cur) => acc + cur.length, 0);
return { return {
@ -69,7 +76,20 @@ export function getRawDashboardV2Changes(
hasTimeChanges: hasTimeChanged, hasTimeChanges: hasTimeChanged,
hasVariableValueChanges, hasVariableValueChanges,
hasRefreshChange: hasRefreshChanged, 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( export function getRawDashboardChanges(

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

@ -172,9 +172,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
private _serializer: DashboardSceneSerializerLike< private _serializer: DashboardSceneSerializerLike<
Dashboard | DashboardV2Spec, Dashboard | DashboardV2Spec,
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'] DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
> = getDashboardSceneSerializer(); >;
public constructor(state: Partial<DashboardSceneState>) { public constructor(state: Partial<DashboardSceneState>, serializerVersion: 'v1' | 'v2' = 'v1') {
super({ super({
title: 'Dashboard', title: 'Dashboard',
meta: {}, meta: {},
@ -187,6 +187,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined, scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
}); });
this._serializer =
serializerVersion === 'v2' ? getDashboardSceneSerializer('v2') : getDashboardSceneSerializer('v1');
this._changeTracker = new DashboardSceneChangeTracker(this); this._changeTracker = new DashboardSceneChangeTracker(this);
this.addActivationHandler(() => this._activationHandler()); 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({ toolbarActions.push({
group: 'main-buttons', group: 'main-buttons',
condition: uid && config.featureToggles.useV2DashboardsAPI, condition: uid && dashboardNewLayouts,
render: () => { render: () => {
return ( return (
<ToolbarButton <ToolbarButton

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

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

@ -155,60 +155,63 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
//createLayoutManager(dashboard); //createLayoutManager(dashboard);
const dashboardScene = new DashboardScene({ const dashboardScene = new DashboardScene(
description: dashboard.description, {
editable: dashboard.editable, description: dashboard.description,
preload: dashboard.preload, editable: dashboard.editable,
id: dashboardId, preload: dashboard.preload,
isDirty: false, id: dashboardId,
links: dashboard.links, isDirty: false,
meta, links: dashboard.links,
tags: dashboard.tags, meta,
title: dashboard.title, tags: dashboard.tags,
uid: metadata.name, title: dashboard.title,
version: parseInt(metadata.resourceVersion, 10), uid: metadata.name,
body: layoutManager, version: parseInt(metadata.resourceVersion, 10),
$timeRange: new SceneTimeRange({ body: layoutManager,
from: dashboard.timeSettings.from, $timeRange: new SceneTimeRange({
to: dashboard.timeSettings.to, from: dashboard.timeSettings.from,
fiscalYearStartMonth: dashboard.timeSettings.fiscalYearStartMonth, to: dashboard.timeSettings.to,
timeZone: dashboard.timeSettings.timezone, fiscalYearStartMonth: dashboard.timeSettings.fiscalYearStartMonth,
weekStart: dashboard.timeSettings.weekStart, timeZone: dashboard.timeSettings.timezone,
UNSAFE_nowDelay: dashboard.timeSettings.nowDelay, weekStart: dashboard.timeSettings.weekStart,
}), UNSAFE_nowDelay: dashboard.timeSettings.nowDelay,
$variables: getVariables(dashboard, meta.isSnapshot ?? false),
$behaviors: [
new behaviors.CursorSync({
sync: transformCursorSyncV2ToV1(dashboard.cursorSync),
}),
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
uid: dashboardId?.toString(),
version: 1,
}), }),
], $variables: getVariables(dashboard, meta.isSnapshot ?? false),
$data: new DashboardDataLayerSet({ $behaviors: [
annotationLayers, new behaviors.CursorSync({
}), sync: transformCursorSyncV2ToV1(dashboard.cursorSync),
controls: new DashboardControls({ }),
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()], new behaviors.SceneQueryController(),
timePicker: new SceneTimePicker({ registerDashboardMacro,
quickRanges: dashboard.timeSettings.quickRanges, registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
uid: dashboardId?.toString(),
version: 1,
}),
],
$data: new DashboardDataLayerSet({
annotationLayers,
}), }),
refreshPicker: new SceneRefreshPicker({ controls: new DashboardControls({
refresh: dashboard.timeSettings.autoRefresh, variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
intervals: dashboard.timeSettings.autoRefreshIntervals, timePicker: new SceneTimePicker({
withText: true, quickRanges: dashboard.timeSettings.quickRanges,
}),
refreshPicker: new SceneRefreshPicker({
refresh: dashboard.timeSettings.autoRefresh,
intervals: dashboard.timeSettings.autoRefreshIntervals,
withText: true,
}),
hideTimeControls: dashboard.timeSettings.hideTimepicker,
}), }),
hideTimeControls: dashboard.timeSettings.hideTimepicker, },
}), 'v2'
}); );
dashboardScene.setInitialSaveModel(dto.spec, dto.metadata); dashboardScene.setInitialSaveModel(dto.spec, dto.metadata);

@ -177,6 +177,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
let annotationLayers: SceneDataLayerProvider[] = []; let annotationLayers: SceneDataLayerProvider[] = [];
let alertStatesLayer: AlertStatesDataLayer | undefined; let alertStatesLayer: AlertStatesDataLayer | undefined;
const uid = dto.uid; const uid = dto.uid;
const serializerVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1';
if (oldModel.templating?.list?.length) { if (oldModel.templating?.list?.length) {
if (oldModel.meta.isSnapshot) { if (oldModel.meta.isSnapshot) {
@ -246,49 +247,52 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
version: oldModel.version, version: oldModel.version,
}), }),
]; ];
const dashboardScene = new DashboardScene({ const dashboardScene = new DashboardScene(
uid, {
description: oldModel.description, uid,
editable: oldModel.editable, description: oldModel.description,
preload: dto.preload ?? false, editable: oldModel.editable,
id: oldModel.id, preload: dto.preload ?? false,
isDirty: false, id: oldModel.id,
links: oldModel.links || [], isDirty: false,
meta: oldModel.meta, links: oldModel.links || [],
tags: oldModel.tags || [], meta: oldModel.meta,
title: oldModel.title, tags: oldModel.tags || [],
version: oldModel.version, title: oldModel.title,
scopeMeta, version: oldModel.version,
body: new DefaultGridLayoutManager({ scopeMeta,
grid: new SceneGridLayout({ body: new DefaultGridLayoutManager({
isLazy: !(dto.preload || contextSrv.user.authenticatedBy === 'render'), grid: new SceneGridLayout({
children: createSceneObjectsForPanels(oldModel.panels), isLazy: !(dto.preload || contextSrv.user.authenticatedBy === 'render'),
children: createSceneObjectsForPanels(oldModel.panels),
}),
}), }),
}), $timeRange: new SceneTimeRange({
$timeRange: new SceneTimeRange({ from: oldModel.time.from,
from: oldModel.time.from, to: oldModel.time.to,
to: oldModel.time.to, fiscalYearStartMonth: oldModel.fiscalYearStartMonth,
fiscalYearStartMonth: oldModel.fiscalYearStartMonth, timeZone: oldModel.timezone,
timeZone: oldModel.timezone, weekStart: isWeekStart(oldModel.weekStart) ? oldModel.weekStart : undefined,
weekStart: isWeekStart(oldModel.weekStart) ? oldModel.weekStart : undefined, UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
}),
$variables: variables,
$behaviors: behaviorList,
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
controls: new DashboardControls({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
timePicker: new SceneTimePicker({
quickRanges: oldModel.timepicker.quick_ranges,
}), }),
refreshPicker: new SceneRefreshPicker({ $variables: variables,
refresh: oldModel.refresh, $behaviors: behaviorList,
intervals: oldModel.timepicker.refresh_intervals, $data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
withText: true, controls: new DashboardControls({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
timePicker: new SceneTimePicker({
quickRanges: oldModel.timepicker.quick_ranges,
}),
refreshPicker: new SceneRefreshPicker({
refresh: oldModel.refresh,
intervals: oldModel.timepicker.refresh_intervals,
withText: true,
}),
hideTimeControls: oldModel.timepicker.hidden,
}), }),
hideTimeControls: oldModel.timepicker.hidden, },
}), serializerVersion
}); );
return dashboardScene; 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 { config } from '@grafana/runtime';
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
import { getDashboardAPI, setDashboardAPI } from './dashboard_api'; import { getDashboardAPI, setDashboardAPI } from './dashboard_api';
import { LegacyDashboardAPI } from './legacy'; import { LegacyDashboardAPI } from './legacy';
import { K8sDashboardAPI } from './v1'; import { K8sDashboardAPI } from './v1';
@ -29,14 +30,29 @@ describe('DashboardApi', () => {
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); 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; config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); expect(getDashboardAPI()).toBeInstanceOf(UnifiedDashboardAPI);
}); });
it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => { it('should return v1 if it is passed in the params', () => {
config.featureToggles.useV2DashboardsAPI = true; config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2API); 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); expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
}); });
it('should use v1 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => { it('should use v1 when v1 is passed in the params', () => {
config.featureToggles.useV2DashboardsAPI = true; expect(getDashboardAPI('v1')).toBeInstanceOf(K8sDashboardAPI);
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
}); });
it('should use legacy useV2DashboardsAPI toggle is enabled', () => { it('should use v2 when v2 is passed in the params', () => {
config.featureToggles.useV2DashboardsAPI = true; expect(() => getDashboardAPI('v2')).toThrow('v2 is not supported for legacy architecture');
config.featureToggles.kubernetesDashboards = undefined;
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
}); });
}); });
}); });

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

@ -1,4 +1,5 @@
import { UrlQueryMap } from '@grafana/data'; 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 { Resource } from 'app/features/apiserver/types';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types'; import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types';
@ -15,7 +16,7 @@ export interface DashboardAPI<G, T> {
} }
// Implemented using /api/dashboards/* // Implemented using /api/dashboards/*
export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAccessInfo'> { export interface DashboardWithAccessInfo<T> extends Resource<T, Status, 'DashboardWithAccessInfo'> {
access: { access: {
url?: string; url?: string;
slug?: string; slug?: string;
@ -28,3 +29,25 @@ export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAc
annotationsPermissions?: AnnotationsPermissions; annotationsPermissions?: AnnotationsPermissions;
}; // TODO... }; // 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'); 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 = { config.featureToggles = {
dashboardScene: true, 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, 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 = { config.featureToggles = {
dashboardScene: true, dashboardScene: true,
useV2DashboardsAPI: false,
kubernetesDashboards: false, kubernetesDashboards: false,
}; };
expect(getDashboardsApiVersion()).toBe('legacy'); expect(getDashboardsApiVersion()).toBe('legacy');
@ -57,20 +47,16 @@ describe('getDashboardsApiVersion', () => {
it('should return legacy when kubernetesDashboards is disabled', () => { it('should return legacy when kubernetesDashboards is disabled', () => {
config.featureToggles = { config.featureToggles = {
dashboardScene: false, dashboardScene: false,
useV2DashboardsAPI: false,
kubernetesDashboards: false, kubernetesDashboards: false,
}; };
expect(getDashboardsApiVersion()).toBe('legacy'); expect(getDashboardsApiVersion()).toBe('legacy');
}); });
it('should return legacy when kubernetesDashboards is disabled', () => { it('should return v1 when kubernetesDashboards is enabled', () => {
config.featureToggles = { config.featureToggles = {
dashboardScene: false, dashboardScene: false,
useV2DashboardsAPI: false,
kubernetesDashboards: true, kubernetesDashboards: true,
}; };
expect(getDashboardsApiVersion()).toBe('v1'); expect(getDashboardsApiVersion()).toBe('v1');
}); });
}); });

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

@ -18,7 +18,7 @@ import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/ty
import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardWithAccessInfo } from './types'; import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> { export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
private client: ResourceClient<DashboardDataDTO>; private client: ResourceClient<DashboardDataDTO>;
@ -96,6 +96,11 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
try { try {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto'); 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 = { const result: DashboardDTO = {
meta: { meta: {
...dash.access, ...dash.access,

@ -82,7 +82,7 @@ describe('v2 dashboard API', () => {
updatedBy: '', updatedBy: '',
}); });
const api = new K8sDashboardV2API(false); const api = new K8sDashboardV2API();
// because the API can currently return both DashboardDTO and DashboardWithAccessInfo<DashboardV2Spec> based on the // 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 // parameter convertToV1, we need to cast the result to DashboardWithAccessInfo<DashboardV2Spec> to be able to
// access // access
@ -96,7 +96,7 @@ describe('v2 dashboard API', () => {
it('throws an error if folder is not found', async () => { it('throws an error if folder is not found', async () => {
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValue({ message: 'folder not found', status: 'not-found' }); 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'); 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 () => { it('should create new dashboard', async () => {
const api = new K8sDashboardV2API(false); const api = new K8sDashboardV2API();
const result = await api.saveDashboard({ const result = await api.saveDashboard({
...defaultSaveCommand, ...defaultSaveCommand,
dashboard: { dashboard: {
@ -143,7 +143,7 @@ describe('v2 dashboard API - Save', () => {
}); });
it('should update existing dashboard', async () => { it('should update existing dashboard', async () => {
const api = new K8sDashboardV2API(false); const api = new K8sDashboardV2API();
const result = await api.saveDashboard({ const result = await api.saveDashboard({
...defaultSaveCommand, ...defaultSaveCommand,
@ -160,7 +160,7 @@ describe('v2 dashboard API - Save', () => {
}); });
it('should update existing dashboard that is store in a folder', async () => { it('should update existing dashboard that is store in a folder', async () => {
const api = new K8sDashboardV2API(false); const api = new K8sDashboardV2API();
await api.saveDashboard({ await api.saveDashboard({
dashboard: { dashboard: {
...defaultSaveCommand.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 { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors'; 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 { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { ResponseTransformers } from './ResponseTransformers'; import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
export class K8sDashboardV2API export class K8sDashboardV2API
implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO, DashboardV2Spec> implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO, DashboardV2Spec>
{ {
private client: ResourceClient<DashboardV2Spec>; private client: ResourceClient<DashboardV2Spec>;
constructor(private convertToV1: boolean) { constructor() {
this.client = new ScopedResourceClient<DashboardV2Spec>({ this.client = new ScopedResourceClient<DashboardV2Spec>({
group: 'dashboard.grafana.app', group: 'dashboard.grafana.app',
version: 'v2alpha1', version: 'v2alpha1',
@ -37,40 +36,32 @@ export class K8sDashboardV2API
}); });
} }
async getDashboardDTO(uid: string, params?: UrlQueryMap) { async getDashboardDTO(uid: string) {
try { try {
const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto'); const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto');
let result: DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO | undefined; if (dashboard.status?.conversion?.failed) {
throw new DashboardVersionError(dashboard.status.conversion.storedVersion, dashboard.status.conversion.error);
// 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);
// load folder info if available // load folder info if available
if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) { if (dashboard.metadata.annotations && dashboard.metadata.annotations[AnnoKeyFolder]) {
try { try {
const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]); const folder = await backendSrv.getFolderByUid(dashboard.metadata.annotations[AnnoKeyFolder]);
result.metadata.annotations[AnnoKeyFolderTitle] = folder.title; dashboard.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
result.metadata.annotations[AnnoKeyFolderUrl] = folder.url; dashboard.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
result.metadata.annotations[AnnoKeyFolderId] = folder.id; dashboard.metadata.annotations[AnnoKeyFolderId] = folder.id;
} catch (e) { } catch (e) {
throw new Error('Failed to load folder'); 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 // Set AnnoKeyFolder to empty string for top-level dashboards
// This ensures NestedFolderPicker correctly identifies it as being in the "Dashboard" root folder // This ensures NestedFolderPicker correctly identifies it as being in the "Dashboard" root folder
// AnnoKeyFolder undefined -> top-level dashboard -> empty string // 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 return dashboard;
if (this.convertToV1) {
// Always return V1 format
result = ResponseTransformers.ensureV1Response(result);
return result;
}
// return the v2 response
return result;
} catch (e) { } catch (e) {
const status = getStatusFromError(e); const status = getStatusFromError(e);
const message = getMessageFromError(e); const message = getMessageFromError(e);

@ -1,7 +1,7 @@
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import { useAsync } from 'react-use'; 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 { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
import { DiffGroup } from '../../../dashboard-scene/settings/version-history/DiffGroup'; import { DiffGroup } from '../../../dashboard-scene/settings/version-history/DiffGroup';
@ -16,6 +16,7 @@ interface SaveDashboardDiffProps {
hasFolderChanges?: boolean; hasFolderChanges?: boolean;
oldFolder?: string; oldFolder?: string;
newFolder?: string; newFolder?: string;
hasMigratedToV2?: boolean;
} }
export const SaveDashboardDiff = ({ export const SaveDashboardDiff = ({
@ -25,6 +26,7 @@ export const SaveDashboardDiff = ({
hasFolderChanges, hasFolderChanges,
oldFolder, oldFolder,
newFolder, newFolder,
hasMigratedToV2,
}: SaveDashboardDiffProps) => { }: SaveDashboardDiffProps) => {
const loader = useAsync(async () => { const loader = useAsync(async () => {
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2); const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
@ -61,6 +63,16 @@ export const SaveDashboardDiff = ({
return ( return (
<Stack direction="column" gap={1}> <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 && ( {hasFolderChanges && (
<DiffGroup <DiffGroup
diffs={[ diffs={[
@ -80,7 +92,7 @@ export const SaveDashboardDiff = ({
{(!value || !oldValue) && <Spinner />} {(!value || !oldValue) && <Spinner />}
{value && value.count >= 1 ? ( {value && value.count >= 1 ? (
<> <>
{value && value.schemaChange && value.schemaChange} {!hasMigratedToV2 && value && value.schemaChange && value.schemaChange}
{value && value.showDiffs && value.diffs} {value && value.showDiffs && value.diffs}
<Box paddingTop={1}> <Box paddingTop={1}>
<h4>Full JSON diff</h4> <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 { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { DashboardRoutes } from 'app/types'; import { DashboardRoutes } from 'app/types';
import { isDashboardV2Resource } from '../api/utils';
import DashboardPage, { DashboardPageParams } from './DashboardPage'; import DashboardPage, { DashboardPageParams } from './DashboardPage';
import { DashboardPageError } from './DashboardPageError'; import { DashboardPageError } from './DashboardPageError';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types'; import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types';
@ -23,18 +25,12 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
const forceOld = props.queryParams.scenes === false; const forceOld = props.queryParams.scenes === false;
const params = useParams<DashboardPageParams>(); const params = useParams<DashboardPageParams>();
const location = useLocation(); const location = useLocation();
const stateManager = getDashboardScenePageStateManager();
// 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} />;
}
if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) { if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) {
return <DashboardScenePage {...props} />; return <DashboardScenePage {...props} />;
} }
const stateManager = getDashboardScenePageStateManager();
const isScenesSupportedRoute = Boolean( const isScenesSupportedRoute = Boolean(
props.route.routeName === DashboardRoutes.Home || (props.route.routeName === DashboardRoutes.Normal && params.uid) props.route.routeName === DashboardRoutes.Home || (props.route.routeName === DashboardRoutes.Normal && params.uid)
); );
@ -63,7 +59,17 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return null; 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; return null;
} }
@ -71,11 +77,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return <DashboardPage {...props} params={params} location={location} />; return <DashboardPage {...props} params={params} location={location} />;
} }
if ( if (!canEdit && isScenesSupportedRoute && !forceOld) {
dashboard.value &&
!(dashboard.value.meta?.canEdit || dashboard.value.meta?.canMakeEditable) &&
isScenesSupportedRoute
) {
return <DashboardScenePage {...props} />; return <DashboardScenePage {...props} />;
} else { } else {
return <DashboardPage {...props} params={params} location={location} />; return <DashboardPage {...props} params={params} location={location} />;

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

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

@ -1,5 +1,7 @@
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { validationSrv } from '../services/ValidationSrv'; import { validationSrv } from '../services/ValidationSrv';
@ -49,7 +51,12 @@ export const validateUid = (value: string) => {
return getDashboardAPI() return getDashboardAPI()
.getDashboardDTO(value) .getDashboardDTO(value)
.then((existingDashboard) => { .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) => { .catch((error) => {
error.isHandled = true; error.isHandled = true;

Loading…
Cancel
Save