From 070f0e4457c5967102ef157197073dc2662f6fb8 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Dec 2024 22:17:09 +0100 Subject: [PATCH] Dashboard API versions handling (#96666) * structure apic * API versioning proposal * Make api service independent from version * Update v2 * Fix public dashboards page test * Uncomment reload dashboard feature code * Revert * Betterer * Fix imports * useV2DashboardsAPI feature toggle * POC/v2 schema: Add v1<-> v2 transformers (#97058) * Make dshboard access interface more precise * Add first pass for schema v1<->v2 transformers * Update response transformer test * Import fixes * Manage dashboards validation: Handle v2 schema * Handle new dashboard with v2 * Fix tests * Move dashboard is folder error handling to legacy API implementation * Add tests for dashboard api client * betterer * Use dashboard DTO when capturing dashbaord impression * prettier * Dashboard API: resolve folder metadata * Add tests for resolving folder metadata * Fix DashboardPicker * Renames and nits * POC Alternative Suggestion for Dashboard API versions handling (#97789) * Add transitional_dashboard_api, reset components that are not ready for v2 schema, and start working on migrating DashboardPicker to use v2 schema * reset DashboardScenePageStateManager * Improve logic in transitional api, also remove isDashboardResource checks from components * REmove transitional_dashboard_api and apply PR feedback * Apply PR feedback, use 'v2' as a parameter and remove unnecesary if * Fix tests * Adding missing comments from original PR and also changing order to improve diffing in github :) * update betterer * fix prettier * Add tests for DashboardPicker * Do not use unified alerting mocks * Fix unused type in dashboard test * Improve comments in DahboardPicker * Update folder validation fn * Validation update * Update legacy api test * Lint --------- Co-authored-by: alexandra vargas Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> Co-authored-by: Ivan Ortega --- .betterer.results | 7 +- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../Select/DashboardPicker.test.tsx | 135 +++++++++++ .../components/Select/DashboardPicker.tsx | 35 ++- public/app/features/apiserver/client.ts | 3 +- public/app/features/apiserver/types.ts | 21 +- .../buildNewDashboardSaveModel.test.ts | 32 ++- .../buildNewDashboardSaveModel.ts | 83 +++++++ .../transformSaveModelSchemaV2ToScene.test.ts | 2 +- .../transformSaveModelSchemaV2ToScene.ts | 2 +- .../api/ResponseTransformers.test.ts | 216 ++++++++++++++++++ .../dashboard/api/ResponseTransformers.ts | 179 +++++++++++++++ .../dashboard/api/dashboard_api.test.ts | 75 ++++++ .../features/dashboard/api/dashboard_api.ts | 181 ++++----------- .../app/features/dashboard/api/legacy.test.ts | 55 +++++ public/app/features/dashboard/api/legacy.ts | 40 ++++ public/app/features/dashboard/api/types.ts | 31 +++ .../app/features/dashboard/api/utils.test.ts | 51 +++++ public/app/features/dashboard/api/utils.ts | 47 ++++ public/app/features/dashboard/api/v0.test.ts | 65 ++++++ public/app/features/dashboard/api/v0.ts | 109 +++++++++ public/app/features/dashboard/api/v2.test.ts | 69 ++++++ public/app/features/dashboard/api/v2.ts | 68 ++++++ .../utils/validation.test.ts | 64 ++++++ public/app/types/dashboard.ts | 1 + 30 files changed, 1443 insertions(+), 155 deletions(-) create mode 100644 public/app/core/components/Select/DashboardPicker.test.tsx create mode 100644 public/app/features/dashboard/api/ResponseTransformers.test.ts create mode 100644 public/app/features/dashboard/api/ResponseTransformers.ts create mode 100644 public/app/features/dashboard/api/dashboard_api.test.ts create mode 100644 public/app/features/dashboard/api/legacy.test.ts create mode 100644 public/app/features/dashboard/api/legacy.ts create mode 100644 public/app/features/dashboard/api/types.ts create mode 100644 public/app/features/dashboard/api/utils.test.ts create mode 100644 public/app/features/dashboard/api/utils.ts create mode 100644 public/app/features/dashboard/api/v0.test.ts create mode 100644 public/app/features/dashboard/api/v0.ts create mode 100644 public/app/features/dashboard/api/v2.test.ts create mode 100644 public/app/features/dashboard/api/v2.ts create mode 100644 public/app/features/manage-dashboards/utils/validation.test.ts diff --git a/.betterer.results b/.betterer.results index 45b148869ea..2251c15fce8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3433,7 +3433,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/features/dashboard/api/dashboard_api.ts:5381": [ + "public/app/features/dashboard/api/ResponseTransformers.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "public/app/features/dashboard/api/v0.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx:5381": [ diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 56aa8580034..03bd5d6a58c 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -227,6 +227,7 @@ Experimental features might be changed or removed without prior notice. | `crashDetection` | Enables browser crash detection reporting to Faro. | | `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy | | `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | +| `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 | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 628c520d832..9bb2f0f3cbe 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -240,6 +240,7 @@ export interface FeatureToggles { alertingUIOptimizeReducer?: boolean; azureMonitorEnableUserAuth?: boolean; alertingNotificationsStepMode?: boolean; + useV2DashboardsAPI?: boolean; feedbackButton?: boolean; elasticsearchCrossClusterSearch?: boolean; unifiedHistory?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index f2a69eef311..370497314d5 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1662,6 +1662,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "useV2DashboardsAPI", + Description: "Use the v2 kubernetes API in the frontend for dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: true, // changes the API routing + }, { Name: "feedbackButton", Description: "Enables a button to send feedback from the Grafana UI", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 10d467b4d99..f4a415c1f0f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -221,6 +221,7 @@ reportingUseRawTimeRange,preview,@grafana/sharing-squad,false,false,false alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true +useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a90b3ac5cb7..ef2def13559 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -895,6 +895,10 @@ const ( // Enables simplified step mode in the notifications section FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode" + // FlagUseV2DashboardsAPI + // Use the v2 kubernetes API in the frontend for dashboards + FlagUseV2DashboardsAPI = "useV2DashboardsAPI" + // FlagFeedbackButton // Enables a button to send feedback from the Grafana UI FlagFeedbackButton = "feedbackButton" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 9349be4cbbf..6241d3733f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3620,6 +3620,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "useV2DashboardsAPI", + "resourceVersion": "1732535420861", + "creationTimestamp": "2024-11-25T11:50:20Z" + }, + "spec": { + "description": "Use the v2 kubernetes API in the frontend for dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "userStorageAPI", diff --git a/public/app/core/components/Select/DashboardPicker.test.tsx b/public/app/core/components/Select/DashboardPicker.test.tsx new file mode 100644 index 00000000000..7e97f86c3d8 --- /dev/null +++ b/public/app/core/components/Select/DashboardPicker.test.tsx @@ -0,0 +1,135 @@ +import { noop } from 'lodash'; +import { Props } from 'react-virtualized-auto-sizer'; +import { render, screen, userEvent, waitFor } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; +import { defaultDashboard as defaultDashboardData } from '@grafana/schema'; +import { + DashboardV2Spec, + defaultDashboardV2Spec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { DashboardSearchItemType } from 'app/features/search/types'; +import { DashboardDTO } from 'app/types'; + +import { DashboardPicker } from './DashboardPicker'; + +jest.mock('app/core/services/backend_srv', () => ({ + ...jest.requireActual('app/core/services/backend_srv'), + backendSrv: { + ...jest.requireActual('app/core/services/backend_srv').backendSrv, + search: jest.fn(), + }, +})); + +const getDashboardDTO = jest.fn(); + +jest.mock('app/features/dashboard/api/dashboard_api', () => ({ + getDashboardAPI: () => ({ + getDashboardDTO: getDashboardDTO, + }), +})); + +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 600, + scaledHeight: 600, + scaledWidth: 1, + width: 1, + }); +}); + +jest.mocked(backendSrv.search).mockResolvedValue([ + { + uid: 'dash-1', + type: DashboardSearchItemType.DashDB, + title: 'Dashboard 1', + uri: '', + url: '', + tags: [], + isStarred: false, + }, + { + uid: 'dash-2', + type: DashboardSearchItemType.DashDB, + title: 'Dashboard 2', + uri: '', + url: '', + tags: [], + isStarred: false, + }, + { + uid: 'dash-3', + type: DashboardSearchItemType.DashDB, + title: 'Dashboard 3', + uri: '', + url: '', + tags: [], + isStarred: false, + }, +]); + +const mockDashboard: DashboardDTO = { + dashboard: { + ...defaultDashboardData, + uid: 'dash-2', + title: 'Dashboard 2', + }, + meta: {}, +}; + +const mockDashboardV2: DashboardWithAccessInfo = { + apiVersion: 'v2alpha0', + kind: 'DashboardWithAccessInfo', + spec: { + ...defaultDashboardV2Spec(), + title: 'Dashboard 2', + }, + metadata: { + name: 'dash-2', + resourceVersion: '0', + creationTimestamp: '0', + annotations: {}, + }, + access: { + canEdit: true, + canSave: true, + canStar: true, + canShare: true, + }, +}; + +describe('DashboardPicker', () => { + describe.each([ + ['v1', mockDashboard], + ['v2', mockDashboardV2], + ])('Dashboard %s', (format, dashboard) => { + beforeEach(() => { + config.featureToggles.useV2DashboardsAPI = format === 'v2'; + getDashboardDTO.mockResolvedValue(dashboard); + }); + + it('should fetch and display dashboards', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument(); + expect(getDashboardDTO).toHaveBeenCalledWith('dash-2', undefined); + }); + }); + + it('should search for dashboards', async () => { + render(); + + await userEvent.type(screen.getByRole('combobox'), 'Dashboard 2'); + + await waitFor(() => { + expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument(); + }); + + expect(backendSrv.search).toHaveBeenCalledWith({ type: 'dash-db', query: 'Dashboard 2', limit: 100 }); + }); + }); +}); diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index bbce99e64c2..530b98f4f61 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -2,8 +2,10 @@ import debounce from 'debounce-promise'; import { useCallback, useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { AsyncSelectProps, AsyncSelect } from '@grafana/ui'; import { backendSrv } from 'app/core/services/backend_srv'; +import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { DashboardSearchItem } from 'app/features/search/types'; import { DashboardDTO } from 'app/types'; @@ -55,17 +57,36 @@ export const DashboardPicker = ({ (async () => { // value was manually changed from outside or we are rendering for the first time. // We need to fetch dashboard information. - const res = await getDashboardAPI().getDashboardDTO(value); - if (res.dashboard) { + const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI; + if (isUIReadyForV2) { + // When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop + // That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape + const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined); + setCurrent({ value: { - uid: res.dashboard.uid, - title: res.dashboard.title, - folderTitle: res.meta.folderTitle, - folderUid: res.meta.folderUid, + uid: resWithSchemaV2.metadata.name, + title: resWithSchemaV2.spec.title, + folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle], + folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], }, - label: formatLabel(res.meta?.folderTitle, res.dashboard.title), + label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title), }); + } else { + // when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version + const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined); + + if (resWithSchemaV1.dashboard) { + setCurrent({ + value: { + uid: resWithSchemaV1.dashboard.uid, + title: resWithSchemaV1.dashboard.title, + folderTitle: resWithSchemaV1.meta.folderTitle, + folderUid: resWithSchemaV1.meta.folderUid, + }, + label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title), + }); + } } })(); // we don't need to rerun this effect every time `current` changes diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index 78d4c396d2f..76f0383665c 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -12,6 +12,7 @@ import { ResourceClient, ObjectMeta, K8sAPIGroupList, + AnnoKeySavedFromUI, } from './types'; export interface GroupVersionResource { @@ -104,7 +105,7 @@ function setSavedFromUIAnnotation(meta: Partial) { if (!meta.annotations) { meta.annotations = {}; } - meta.annotations['grafana.app/saved-from-ui'] = config.buildInfo.versionString; + meta.annotations[AnnoKeySavedFromUI] = config.buildInfo.versionString; } export class DatasourceAPIVersions { diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index 72f00b9c174..8c8395b070b 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -24,7 +24,7 @@ export interface ObjectMeta { // The first time this was saved creationTimestamp: string; // General resource annotations -- including the common grafana.app values - annotations?: GrafanaAnnotations; + annotations?: GrafanaAnnotations & GrafanaClientAnnotations; // General application level key+value pairs labels?: Record; } @@ -33,6 +33,9 @@ export const AnnoKeyCreatedBy = 'grafana.app/createdBy'; export const AnnoKeyUpdatedTimestamp = 'grafana.app/updatedTimestamp'; export const AnnoKeyUpdatedBy = 'grafana.app/updatedBy'; export const AnnoKeyFolder = 'grafana.app/folder'; +export const AnnoKeyFolderTitle = 'grafana.app/folderTitle'; +export const AnnoKeyFolderId = 'grafana.app/folderId'; +export const AnnoKeyFolderUrl = 'grafana.app/folderUrl'; export const AnnoKeyMessage = 'grafana.app/message'; export const AnnoKeySlug = 'grafana.app/slug'; @@ -40,8 +43,11 @@ export const AnnoKeySlug = 'grafana.app/slug'; export const AnnoKeyRepoName = 'grafana.app/repoName'; export const AnnoKeyRepoPath = 'grafana.app/repoPath'; export const AnnoKeyRepoHash = 'grafana.app/repoHash'; -const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp'; +export const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp'; +export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui'; + +// Annotations provided by the API type GrafanaAnnotations = { [AnnoKeyCreatedBy]?: string; [AnnoKeyUpdatedTimestamp]?: string; @@ -53,9 +59,16 @@ type GrafanaAnnotations = { [AnnoKeyRepoPath]?: string; [AnnoKeyRepoHash]?: string; [AnnoKeyRepoTimestamp]?: string; +}; - // Any key value - [key: string]: string | undefined; +// Annotations provided by the front-end client +type GrafanaClientAnnotations = { + [AnnoKeyMessage]?: string; + [AnnoKeyFolderTitle]?: string; + [AnnoKeyFolderUrl]?: string; + [AnnoKeyFolderId]?: number; + [AnnoKeyFolderId]?: number; + [AnnoKeySavedFromUI]?: string; }; export interface Resource extends TypeMeta { diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts index bfe7a4b658d..f425f2a7a5a 100644 --- a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts @@ -1,7 +1,7 @@ import { DataSourceApi, PluginType, VariableSupportType } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel'; +import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from './buildNewDashboardSaveModel'; const fakeDsMock: DataSourceApi = { name: 'fake-std', @@ -58,7 +58,7 @@ jest.mock('@grafana/runtime', () => ({ }), })); -describe('buildNewDashboardSaveModel', () => { +describe('buildNewDashboardSaveModelV1', () => { it('should not have template variables defined by default', async () => { const result = await buildNewDashboardSaveModel(); expect(result.dashboard.templating).toBeUndefined(); @@ -85,3 +85,31 @@ describe('buildNewDashboardSaveModel', () => { }); }); }); + +describe('buildNewDashboardSaveModelV2', () => { + it('should not have template variables defined by default', async () => { + const result = await buildNewDashboardSaveModelV2(); + expect(result.spec.variables).toEqual([]); + }); + + describe('when featureToggles.newDashboardWithFiltersAndGroupBy is true', () => { + beforeAll(() => { + config.featureToggles.newDashboardWithFiltersAndGroupBy = true; + }); + afterAll(() => { + config.featureToggles.newDashboardWithFiltersAndGroupBy = false; + }); + + it('should add filter and group by variables if the datasource supports it and is set as default', async () => { + const result = await buildNewDashboardSaveModelV2(); + expect(result.spec.variables).toHaveLength(2); + expect(result.spec.variables[0].kind).toBe('AdhocVariable'); + expect(result.spec.variables[1].kind).toBe('GroupByVariable'); + }); + + it("should set the new dashboard's timezone to the user's timezone", async () => { + const result = await buildNewDashboardSaveModelV2(); + expect(result.spec.timeSettings.timezone).toEqual('Africa/Abidjan'); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts index 62bd1985f6d..b61d89ec7d4 100644 --- a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts @@ -1,5 +1,16 @@ import { config } from '@grafana/runtime'; import { VariableModel, defaultDashboard } from '@grafana/schema'; +import { + AdhocVariableKind, + DashboardV2Spec, + defaultAdhocVariableSpec, + defaultDashboardV2Spec, + defaultGroupByVariableSpec, + defaultTimeSettingsSpec, + GroupByVariableKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { AnnoKeyFolder } from 'app/features/apiserver/types'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DashboardDTO } from 'app/types'; @@ -62,3 +73,75 @@ export async function buildNewDashboardSaveModel(urlFolderUid?: string): Promise return data; } + +export async function buildNewDashboardSaveModelV2( + urlFolderUid?: string +): Promise> { + let variablesList = defaultDashboardV2Spec().variables; + + if (config.featureToggles.newDashboardWithFiltersAndGroupBy) { + // Add filter and group by variables if the datasource supports it + const defaultDs = await getDatasourceSrv().get(); + + if (defaultDs.getTagKeys) { + const datasourceRef = { + type: defaultDs.meta.id, + uid: defaultDs.uid, + }; + + const filterVariable: AdhocVariableKind = { + kind: 'AdhocVariable', + spec: { ...defaultAdhocVariableSpec(), name: 'Filter', datasource: datasourceRef }, + }; + + const groupByVariable: GroupByVariableKind = { + kind: 'GroupByVariable', + spec: { + ...defaultGroupByVariableSpec(), + datasource: datasourceRef, + name: 'Group by', + }, + }; + + variablesList = (variablesList || []).concat([filterVariable, groupByVariable]); + } + } + + const data: DashboardWithAccessInfo = { + apiVersion: 'v2alpha0', + kind: 'DashboardWithAccessInfo', + spec: { + ...defaultDashboardV2Spec(), + title: 'New dashboard', + timeSettings: { + ...defaultTimeSettingsSpec(), + timezone: config.bootData.user?.timezone || defaultTimeSettingsSpec().timezone, + }, + }, + access: { + canStar: false, + canShare: false, + canDelete: false, + // Not sure this should belong here or to metadata.annotations + isNew: true, + }, + metadata: { + name: '', + resourceVersion: '0', + creationTimestamp: '0', + annotations: { + [AnnoKeyFolder]: '', + }, + }, + }; + + if (variablesList) { + data.spec.variables = variablesList; + } + + if (urlFolderUid && data.metadata.annotations) { + data.metadata.annotations[AnnoKeyFolder] = urlFolderUid; + } + + return data; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 12791e79736..3b340246c4d 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -26,7 +26,7 @@ import { TextVariableKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples'; -import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index ff3f2896f62..5eaae2ef633 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -51,7 +51,7 @@ import { QueryVariableKind, TextVariableKind, } from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen'; -import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts new file mode 100644 index 00000000000..a614f0b4a10 --- /dev/null +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -0,0 +1,216 @@ +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { DashboardDTO } from 'app/types'; + +import { ResponseTransformers } from './ResponseTransformers'; +import { DashboardWithAccessInfo } from './types'; + +describe('ResponseTransformers', () => { + describe('v1 transformation', () => { + it('should transform DashboardDTO to DashboardWithAccessInfo', () => { + const dashboardDTO: DashboardDTO = { + meta: { + created: '2023-01-01T00:00:00Z', + createdBy: 'user1', + updated: '2023-01-02T00:00:00Z', + updatedBy: 'user2', + folderUid: 'folder1', + slug: 'dashboard-slug', + url: '/d/dashboard-slug', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + canShare: true, + canStar: true, + annotationsPermissions: { + dashboard: { canAdd: true, canEdit: true, canDelete: true }, + organization: { canAdd: true, canEdit: true, canDelete: true }, + }, + }, + dashboard: { + uid: 'dashboard1', + title: 'Dashboard Title', + description: 'Dashboard Description', + tags: ['tag1', 'tag2'], + schemaVersion: 1, + graphTooltip: 0, + preload: true, + liveNow: false, + editable: true, + time: { from: 'now-6h', to: 'now' }, + timezone: 'browser', + refresh: '5m', + timepicker: { + refresh_intervals: ['5s', '10s', '30s'], + hidden: false, + time_options: ['5m', '15m', '1h'], + nowDelay: '1m', + }, + fiscalYearStartMonth: 1, + weekStart: 'monday', + version: 1, + links: [], + annotations: { + list: [], + }, + }, + }; + + const transformed = ResponseTransformers.ensureV2Response(dashboardDTO); + + expect(transformed.apiVersion).toBe('v2alpha1'); + expect(transformed.kind).toBe('DashboardWithAccessInfo'); + expect(transformed.metadata.creationTimestamp).toBe(dashboardDTO.meta.created); + expect(transformed.metadata.name).toBe(dashboardDTO.dashboard.uid); + expect(transformed.metadata.resourceVersion).toBe(dashboardDTO.dashboard.version?.toString()); + expect(transformed.metadata.annotations?.['grafana.app/createdBy']).toBe(dashboardDTO.meta.createdBy); + expect(transformed.metadata.annotations?.['grafana.app/updatedBy']).toBe(dashboardDTO.meta.updatedBy); + expect(transformed.metadata.annotations?.['grafana.app/updatedTimestamp']).toBe(dashboardDTO.meta.updated); + expect(transformed.metadata.annotations?.['grafana.app/folder']).toBe(dashboardDTO.meta.folderUid); + expect(transformed.metadata.annotations?.['grafana.app/slug']).toBe(dashboardDTO.meta.slug); + + const spec = transformed.spec; + expect(spec.title).toBe(dashboardDTO.dashboard.title); + expect(spec.description).toBe(dashboardDTO.dashboard.description); + expect(spec.tags).toEqual(dashboardDTO.dashboard.tags); + expect(spec.schemaVersion).toBe(dashboardDTO.dashboard.schemaVersion); + expect(spec.cursorSync).toBe('Off'); // Assuming transformCursorSynctoEnum(0) returns 'Off' + expect(spec.preload).toBe(dashboardDTO.dashboard.preload); + expect(spec.liveNow).toBe(dashboardDTO.dashboard.liveNow); + expect(spec.editable).toBe(dashboardDTO.dashboard.editable); + expect(spec.timeSettings.from).toBe(dashboardDTO.dashboard.time?.from); + expect(spec.timeSettings.to).toBe(dashboardDTO.dashboard.time?.to); + expect(spec.timeSettings.timezone).toBe(dashboardDTO.dashboard.timezone); + expect(spec.timeSettings.autoRefresh).toBe(dashboardDTO.dashboard.refresh); + expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardDTO.dashboard.timepicker?.refresh_intervals); + expect(spec.timeSettings.hideTimepicker).toBe(dashboardDTO.dashboard.timepicker?.hidden); + expect(spec.timeSettings.quickRanges).toEqual(dashboardDTO.dashboard.timepicker?.time_options); + expect(spec.timeSettings.nowDelay).toBe(dashboardDTO.dashboard.timepicker?.nowDelay); + expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardDTO.dashboard.fiscalYearStartMonth); + expect(spec.timeSettings.weekStart).toBe(dashboardDTO.dashboard.weekStart); + expect(spec.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns [] + expect(spec.annotations).toEqual([]); + }); + }); + + describe('v2 transformation', () => { + it('should return the same object if it is already a DashboardDTO', () => { + const dashboard: DashboardDTO = { + dashboard: { + schemaVersion: 1, + title: 'Dashboard Title', + uid: 'dashboard1', + version: 1, + }, + meta: {}, + }; + + expect(ResponseTransformers.ensureV1Response(dashboard)).toBe(dashboard); + }); + + it('should transform DashboardWithAccessInfo to DashboardDTO', () => { + const dashboardV2: DashboardWithAccessInfo = { + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + creationTimestamp: '2023-01-01T00:00:00Z', + name: 'dashboard1', + resourceVersion: '1', + annotations: { + 'grafana.app/createdBy': 'user1', + 'grafana.app/updatedBy': 'user2', + 'grafana.app/updatedTimestamp': '2023-01-02T00:00:00Z', + 'grafana.app/folder': 'folder1', + 'grafana.app/slug': 'dashboard-slug', + }, + }, + spec: { + title: 'Dashboard Title', + description: 'Dashboard Description', + tags: ['tag1', 'tag2'], + schemaVersion: 1, + cursorSync: 'Off', + preload: true, + liveNow: false, + editable: true, + timeSettings: { + from: 'now-6h', + to: 'now', + timezone: 'browser', + autoRefresh: '5m', + autoRefreshIntervals: ['5s', '10s', '30s'], + hideTimepicker: false, + quickRanges: ['5m', '15m', '1h'], + nowDelay: '1m', + fiscalYearStartMonth: 1, + weekStart: 'monday', + }, + links: [], + annotations: [], + variables: [], + elements: {}, + layout: { + kind: 'GridLayout', + spec: { + items: [], + }, + }, + }, + access: { + url: '/d/dashboard-slug', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + canShare: true, + canStar: true, + slug: 'dashboard-slug', + annotationsPermissions: { + dashboard: { canAdd: true, canEdit: true, canDelete: true }, + organization: { canAdd: true, canEdit: true, canDelete: true }, + }, + }, + }; + + const transformed = ResponseTransformers.ensureV1Response(dashboardV2); + + expect(transformed.meta.created).toBe(dashboardV2.metadata.creationTimestamp); + expect(transformed.meta.createdBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/createdBy']); + expect(transformed.meta.updated).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedTimestamp']); + expect(transformed.meta.updatedBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedBy']); + expect(transformed.meta.folderUid).toBe(dashboardV2.metadata.annotations?.['grafana.app/folder']); + expect(transformed.meta.slug).toBe(dashboardV2.metadata.annotations?.['grafana.app/slug']); + expect(transformed.meta.url).toBe(dashboardV2.access.url); + expect(transformed.meta.canAdmin).toBe(dashboardV2.access.canAdmin); + expect(transformed.meta.canDelete).toBe(dashboardV2.access.canDelete); + expect(transformed.meta.canEdit).toBe(dashboardV2.access.canEdit); + expect(transformed.meta.canSave).toBe(dashboardV2.access.canSave); + expect(transformed.meta.canShare).toBe(dashboardV2.access.canShare); + expect(transformed.meta.canStar).toBe(dashboardV2.access.canStar); + expect(transformed.meta.annotationsPermissions).toEqual(dashboardV2.access.annotationsPermissions); + + const dashboard = transformed.dashboard; + expect(dashboard.uid).toBe(dashboardV2.metadata.name); + expect(dashboard.title).toBe(dashboardV2.spec.title); + expect(dashboard.description).toBe(dashboardV2.spec.description); + expect(dashboard.tags).toEqual(dashboardV2.spec.tags); + expect(dashboard.schemaVersion).toBe(dashboardV2.spec.schemaVersion); + // expect(dashboard.graphTooltip).toBe(0); // Assuming transformCursorSynctoEnum('Off') returns 0 + expect(dashboard.preload).toBe(dashboardV2.spec.preload); + expect(dashboard.liveNow).toBe(dashboardV2.spec.liveNow); + expect(dashboard.editable).toBe(dashboardV2.spec.editable); + expect(dashboard.time?.from).toBe(dashboardV2.spec.timeSettings.from); + expect(dashboard.time?.to).toBe(dashboardV2.spec.timeSettings.to); + expect(dashboard.timezone).toBe(dashboardV2.spec.timeSettings.timezone); + expect(dashboard.refresh).toBe(dashboardV2.spec.timeSettings.autoRefresh); + expect(dashboard.timepicker?.refresh_intervals).toEqual(dashboardV2.spec.timeSettings.autoRefreshIntervals); + expect(dashboard.timepicker?.hidden).toBe(dashboardV2.spec.timeSettings.hideTimepicker); + expect(dashboard.timepicker?.time_options).toEqual(dashboardV2.spec.timeSettings.quickRanges); + expect(dashboard.timepicker?.nowDelay).toBe(dashboardV2.spec.timeSettings.nowDelay); + expect(dashboard.fiscalYearStartMonth).toBe(dashboardV2.spec.timeSettings.fiscalYearStartMonth); + expect(dashboard.weekStart).toBe(dashboardV2.spec.timeSettings.weekStart); + expect(dashboard.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns [] + expect(dashboard.annotations).toEqual({ list: [] }); + }); + }); +}); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts new file mode 100644 index 00000000000..98b7ac12cdc --- /dev/null +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -0,0 +1,179 @@ +import { + DashboardV2Spec, + defaultDashboardV2Spec, + defaultTimeSettingsSpec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { transformCursorSynctoEnum } from 'app/features/dashboard-scene/serialization/transformToV2TypesUtils'; +import { DashboardDataDTO, DashboardDTO } from 'app/types'; + +import { DashboardWithAccessInfo } from './types'; +import { isDashboardResource, isDashboardV0Spec, isDashboardV2Spec } from './utils'; + +export function ensureV2Response( + dto: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo +): DashboardWithAccessInfo { + if (isDashboardResource(dto) && isDashboardV2Spec(dto.spec)) { + return dto as DashboardWithAccessInfo; + } + + // after discarding the dto is not a v2 spec, we can safely assume it's a v0 spec or a dashboardDTO + dto = dto as unknown as DashboardWithAccessInfo | DashboardDTO; + + const timeSettingsDefaults = defaultTimeSettingsSpec(); + const dashboardDefaults = defaultDashboardV2Spec(); + + const dashboard = isDashboardResource(dto) ? dto.spec : dto.dashboard; + + const accessAndMeta = isDashboardResource(dto) + ? { + ...dto.access, + created: dto.metadata.creationTimestamp, + createdBy: dto.metadata.annotations?.['grafana.app/createdBy'], + updatedBy: dto.metadata.annotations?.['grafana.app/updatedBy'], + updated: dto.metadata.annotations?.['grafana.app/updatedTimestamp'], + folderUid: dto.metadata.annotations?.['grafana.app/folder'], + slug: dto.metadata.annotations?.['grafana.app/slug'], + } + : dto.meta; + + const spec: DashboardV2Spec = { + title: dashboard.title, + description: dashboard.description, + tags: dashboard.tags, + schemaVersion: dashboard.schemaVersion, + cursorSync: transformCursorSynctoEnum(dashboard.graphTooltip), + preload: dashboard.preload || dashboardDefaults.preload, + liveNow: dashboard.liveNow, + editable: dashboard.editable, + timeSettings: { + from: dashboard.time?.from || timeSettingsDefaults.from, + to: dashboard.time?.to || timeSettingsDefaults.to, + timezone: dashboard.timezone || timeSettingsDefaults.timezone, + autoRefresh: dashboard.refresh || timeSettingsDefaults.autoRefresh, + autoRefreshIntervals: dashboard.timepicker?.refresh_intervals || timeSettingsDefaults.autoRefreshIntervals, + fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth, + hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker, + quickRanges: dashboard.timepicker?.time_options || timeSettingsDefaults.quickRanges, + weekStart: dashboard.weekStart || timeSettingsDefaults.weekStart, + nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay, + }, + links: dashboard.links || [], + annotations: [], // TODO + variables: [], // todo + elements: {}, // todo + layout: { + // todo + kind: 'GridLayout', + spec: { + items: [], + }, + }, + }; + + return { + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + creationTimestamp: accessAndMeta.created || '', // TODO verify this empty string is valid + name: dashboard.uid, + resourceVersion: dashboard.version?.toString() || '0', + annotations: { + 'grafana.app/createdBy': accessAndMeta.createdBy, + 'grafana.app/updatedBy': accessAndMeta.updatedBy, + 'grafana.app/updatedTimestamp': accessAndMeta.updated, + 'grafana.app/folder': accessAndMeta.folderUid, + 'grafana.app/slug': accessAndMeta.slug, + }, + }, + spec, + access: { + url: accessAndMeta.url || '', + canAdmin: accessAndMeta.canAdmin, + canDelete: accessAndMeta.canDelete, + canEdit: accessAndMeta.canEdit, + canSave: accessAndMeta.canSave, + canShare: accessAndMeta.canShare, + canStar: accessAndMeta.canStar, + slug: accessAndMeta.slug, + annotationsPermissions: accessAndMeta.annotationsPermissions, + }, + }; +} + +export function ensureV1Response( + dashboard: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo +): DashboardDTO { + // if dashboard is not on v0 schema or v2 schema, return as is + if (!isDashboardResource(dashboard)) { + return dashboard; + } + + const spec = dashboard.spec; + // if dashboard is on v0 schema + if (isDashboardV0Spec(spec)) { + return { + meta: { + ...dashboard.access, + isNew: false, + isFolder: false, + uid: dashboard.metadata.name, + k8s: dashboard.metadata, + }, + dashboard: spec, + }; + } else { + // if dashboard is on v2 schema convert to v1 schema + return { + meta: { + created: dashboard.metadata.creationTimestamp, + createdBy: dashboard.metadata.annotations?.['grafana.app/createdBy'] ?? '', + updated: dashboard.metadata.annotations?.['grafana.app/updatedTimestamp'], + updatedBy: dashboard.metadata.annotations?.['grafana.app/updatedBy'], + folderUid: dashboard.metadata.annotations?.['grafana.app/folder'], + slug: dashboard.metadata.annotations?.['grafana.app/slug'], + url: dashboard.access.url, + canAdmin: dashboard.access.canAdmin, + canDelete: dashboard.access.canDelete, + canEdit: dashboard.access.canEdit, + canSave: dashboard.access.canSave, + canShare: dashboard.access.canShare, + canStar: dashboard.access.canStar, + annotationsPermissions: dashboard.access.annotationsPermissions, + }, + dashboard: { + uid: dashboard.metadata.name, + title: spec.title, + description: spec.description, + tags: spec.tags, + schemaVersion: spec.schemaVersion, + // @ts-ignore TODO: Use transformers for these enums + // graphTooltip: spec.cursorSync, // Assuming transformCursorSynctoEnum is reversible + preload: spec.preload, + liveNow: spec.liveNow, + editable: spec.editable, + time: { + from: spec.timeSettings.from, + to: spec.timeSettings.to, + }, + timezone: spec.timeSettings.timezone, + refresh: spec.timeSettings.autoRefresh, + timepicker: { + refresh_intervals: spec.timeSettings.autoRefreshIntervals, + hidden: spec.timeSettings.hideTimepicker, + time_options: spec.timeSettings.quickRanges, + nowDelay: spec.timeSettings.nowDelay, + }, + fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth, + weekStart: spec.timeSettings.weekStart, + version: parseInt(dashboard.metadata.resourceVersion, 10), + links: spec.links, // Assuming transformDashboardLinksToEnums is reversible + annotations: { list: [] }, // TODO + }, + }; + } +} + +export const ResponseTransformers = { + ensureV2Response, + ensureV1Response, +}; diff --git a/public/app/features/dashboard/api/dashboard_api.test.ts b/public/app/features/dashboard/api/dashboard_api.test.ts new file mode 100644 index 00000000000..9467d9d1385 --- /dev/null +++ b/public/app/features/dashboard/api/dashboard_api.test.ts @@ -0,0 +1,75 @@ +import { config } from '@grafana/runtime'; + +import { getDashboardAPI, setDashboardAPI } from './dashboard_api'; +import { LegacyDashboardAPI } from './legacy'; +import { K8sDashboardAPI } from './v0'; +import { K8sDashboardV2APIStub } from './v2'; + +describe('DashboardApi', () => { + it('should use legacy api by default', () => { + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should allow overriding clients in test environment', () => { + process.env.NODE_ENV = 'test'; + const mockClient = { legacy: new LegacyDashboardAPI() }; + setDashboardAPI(mockClient); + const api = getDashboardAPI(); + expect(api).toBe(mockClient.legacy); + setDashboardAPI(undefined); + }); + + describe('when scenes enabled', () => { + beforeEach(() => { + config.featureToggles.dashboardScene = true; + }); + + it('should use legacy api kubernetesDashboards toggle is disabled', () => { + config.featureToggles.kubernetesDashboards = false; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should use v0 api when and kubernetesDashboards toggle is enabled', () => { + config.featureToggles.kubernetesDashboards = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); + }); + + it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => { + config.featureToggles.useV2DashboardsAPI = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2APIStub); + }); + }); + + describe('when scenes disabled', () => { + beforeEach(() => { + config.featureToggles.dashboardScene = false; + }); + + it('should use legacy api when kubernetesDashboards toggle is disabled', () => { + config.featureToggles.kubernetesDashboards = undefined; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should use legacy api when kubernetesDashboards toggle is disabled', () => { + config.featureToggles.kubernetesDashboards = false; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should use v0 api when kubernetesDashboards toggle is enabled', () => { + config.featureToggles.kubernetesDashboards = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); + }); + + it('should use v0 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => { + config.featureToggles.useV2DashboardsAPI = true; + config.featureToggles.kubernetesDashboards = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); + }); + + it('should use legacy useV2DashboardsAPI toggle is enabled', () => { + config.featureToggles.useV2DashboardsAPI = true; + config.featureToggles.kubernetesDashboards = undefined; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + }); +}); diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 22988facd18..0a5f45c137e 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -1,147 +1,52 @@ -import { UrlQueryMap } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; -import { ScopedResourceClient } from 'app/features/apiserver/client'; -import { - AnnoKeyFolder, - AnnoKeyMessage, - Resource, - ResourceClient, - ResourceForCreate, -} from 'app/features/apiserver/types'; -import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; -import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; -import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; -import { DashboardDTO, DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { DashboardDTO } from 'app/types'; -export interface DashboardAPI { - /** Get a dashboard with the access control metadata */ - getDashboardDTO(uid: string, params?: UrlQueryMap): Promise; - /** Save dashboard */ - saveDashboard(options: SaveDashboardCommand): Promise; - /** Delete a dashboard */ - deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; -} +import { LegacyDashboardAPI } from './legacy'; +import { DashboardAPI, DashboardWithAccessInfo } from './types'; +import { getDashboardsApiVersion } from './utils'; +import { K8sDashboardAPI } from './v0'; +import { K8sDashboardV2APIStub } from './v2'; -// Implemented using /api/dashboards/* -class LegacyDashboardAPI implements DashboardAPI { - constructor() {} +type DashboardAPIClients = { + legacy: DashboardAPI; + v0: DashboardAPI; + // v1: DashboardDTO; TODO[schema]: enable v1 when available + v2: DashboardAPI>; +}; - saveDashboard(options: SaveDashboardCommand): Promise { - dashboardWatcher.ignoreNextSave(); +type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo; - return getBackendSrv().post('/api/dashboards/db/', { - dashboard: options.dashboard, - message: options.message ?? '', - overwrite: options.overwrite ?? false, - folderUid: options.folderUid, - }); - } +let clients: Partial | undefined; - deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { - return getBackendSrv().delete(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); - } - - getDashboardDTO(uid: string, params?: UrlQueryMap): Promise { - return getBackendSrv().get(`/api/dashboards/uid/${uid}`, params); - } -} - -export interface DashboardWithAccessInfo extends Resource { - access: Object; // TODO... -} - -// Implemented using /apis/dashboard.grafana.app/* -class K8sDashboardAPI implements DashboardAPI { - private client: ResourceClient; - - constructor() { - this.client = new ScopedResourceClient({ - group: 'dashboard.grafana.app', - version: 'v0alpha1', - resource: 'dashboards', - }); - } - - saveDashboard(options: SaveDashboardCommand): Promise { - const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property - const obj: ResourceForCreate = { - metadata: { - ...options?.k8s, - }, - spec: { - ...dashboard, - }, - }; - - if (options.message) { - obj.metadata.annotations = { - ...obj.metadata.annotations, - [AnnoKeyMessage]: options.message, - }; - } else if (obj.metadata.annotations) { - delete obj.metadata.annotations[AnnoKeyMessage]; - } - - if (options.folderUid) { - obj.metadata.annotations = { - ...obj.metadata.annotations, - [AnnoKeyFolder]: options.folderUid, - }; - } - - if (dashboard.uid) { - obj.metadata.name = dashboard.uid; - return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); - } - return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); - } - - asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO { - return { - uid: v.metadata.name, - version: v.spec.version ?? 0, - id: v.spec.id ?? 0, - status: 'success', - slug: '', - url: '', - }; - } - - deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { - return this.client.delete(uid).then((v) => ({ - id: 0, - message: v.message, - title: 'deleted', - })); - } - - async getDashboardDTO(uid: string): Promise { - const dash = await this.client.subresource(uid, 'dto'); - return { - meta: { - ...dash.access, - isNew: false, - isFolder: false, - uid: dash.metadata.name, - k8s: dash.metadata, - }, - dashboard: dash.spec, - }; - } -} - -let instance: DashboardAPI | undefined = undefined; - -export function getDashboardAPI() { - if (!instance) { - instance = config.featureToggles.kubernetesDashboards ? new K8sDashboardAPI() : new LegacyDashboardAPI(); - } - return instance; -} - -export function setDashboardAPI(override: DashboardAPI | undefined) { +export function setDashboardAPI(override: Partial | undefined) { if (process.env.NODE_ENV !== 'test') { throw new Error('dashboardAPI can be only overridden in test environment'); } - instance = override; + clients = override; +} + +// Overloads +export function getDashboardAPI(): DashboardAPI; +export function getDashboardAPI(requestV2Response: 'v2'): DashboardAPI>; +export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI { + const v = getDashboardsApiVersion(); + const isConvertingToV1 = !requestV2Response; + + if (!clients) { + clients = { + legacy: new LegacyDashboardAPI(), + v0: new K8sDashboardAPI(), + v2: new K8sDashboardV2APIStub(isConvertingToV1), + }; + } + + if (v === 'v2' && requestV2Response === 'v2') { + return new K8sDashboardV2APIStub(false); + } + + if (!clients[v]) { + throw new Error(`Unknown Dashboard API version: ${v}`); + } + + return clients[v]; } diff --git a/public/app/features/dashboard/api/legacy.test.ts b/public/app/features/dashboard/api/legacy.test.ts new file mode 100644 index 00000000000..54ceb4d1bf4 --- /dev/null +++ b/public/app/features/dashboard/api/legacy.test.ts @@ -0,0 +1,55 @@ +import { UrlQueryMap } from '@grafana/data'; +import { DashboardDTO } from 'app/types'; + +import { LegacyDashboardAPI } from './legacy'; + +const mockDashboardDto: DashboardDTO = { + meta: { + isFolder: false, + }, + dashboard: { + title: 'test', + uid: 'test', + schemaVersion: 0, + }, +}; + +const backendSrvGetSpy = jest.fn(); +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ + get: (dashUrl: string, params: Object) => { + backendSrvGetSpy(dashUrl, params); + const uid = dashUrl.split('/').pop(); + if (uid === 'folderUid') { + return Promise.resolve({ + meta: { + isFolder: true, + }, + }); + } + + return mockDashboardDto; + }, + }), +})); + +jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ + ignoreNextSave: jest.fn(), +})); + +describe('Legacy dashboard API', () => { + it('should throw an error if requesting a folder', async () => { + const api = new LegacyDashboardAPI(); + expect(async () => await api.getDashboardDTO('folderUid')).rejects.toThrowError('Dashboard not found'); + }); + + it('should return a valid dashboard', async () => { + const api = new LegacyDashboardAPI(); + const params: UrlQueryMap = { + param: 1, + }; + const result = await api.getDashboardDTO('validUid', params); + expect(result).toEqual(mockDashboardDto); + expect(backendSrvGetSpy).toHaveBeenLastCalledWith('/api/dashboards/uid/validUid', params); + }); +}); diff --git a/public/app/features/dashboard/api/legacy.ts b/public/app/features/dashboard/api/legacy.ts new file mode 100644 index 00000000000..66c86ad7511 --- /dev/null +++ b/public/app/features/dashboard/api/legacy.ts @@ -0,0 +1,40 @@ +import { AppEvents, UrlQueryMap } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import appEvents from 'app/core/app_events'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { SaveDashboardResponseDTO, DashboardDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +import { DashboardAPI } from './types'; + +export class LegacyDashboardAPI implements DashboardAPI { + constructor() {} + + saveDashboard(options: SaveDashboardCommand): Promise { + dashboardWatcher.ignoreNextSave(); + + return getBackendSrv().post('/api/dashboards/db/', { + dashboard: options.dashboard, + message: options.message ?? '', + overwrite: options.overwrite ?? false, + folderUid: options.folderUid, + }); + } + + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { + return getBackendSrv().delete(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); + } + + async getDashboardDTO(uid: string, params?: UrlQueryMap) { + const result = await getBackendSrv().get(`/api/dashboards/uid/${uid}`, params); + + if (result.meta.isFolder) { + appEvents.emit(AppEvents.alertError, ['Dashboard not found']); + throw new Error('Dashboard not found'); + } + + return result; + } +} diff --git a/public/app/features/dashboard/api/types.ts b/public/app/features/dashboard/api/types.ts new file mode 100644 index 00000000000..db41dab9bf0 --- /dev/null +++ b/public/app/features/dashboard/api/types.ts @@ -0,0 +1,31 @@ +import { UrlQueryMap } from '@grafana/data'; +import { Resource } from 'app/features/apiserver/types'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +export interface DashboardAPI { + /** Get a dashboard with the access control metadata */ + getDashboardDTO(uid: string, params?: UrlQueryMap): Promise; + /** Save dashboard */ + saveDashboard(options: SaveDashboardCommand): Promise; + /** Delete a dashboard */ + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; +} + +// Implemented using /api/dashboards/* +export interface DashboardWithAccessInfo extends Resource { + access: { + url?: string; + slug?: string; + canSave?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canShare?: boolean; + canStar?: boolean; + canAdmin?: boolean; + annotationsPermissions?: AnnotationsPermissions; + isNew?: boolean; + }; // TODO... +} diff --git a/public/app/features/dashboard/api/utils.test.ts b/public/app/features/dashboard/api/utils.test.ts new file mode 100644 index 00000000000..cad97447ce8 --- /dev/null +++ b/public/app/features/dashboard/api/utils.test.ts @@ -0,0 +1,51 @@ +import { config } from '@grafana/runtime'; + +import { getDashboardsApiVersion } from './utils'; + +describe('getDashboardsApiVersion', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should return v0 when dashboardScene is disabled and kubernetesDashboards is enabled', () => { + config.featureToggles = { + dashboardScene: false, + kubernetesDashboards: true, + }; + expect(getDashboardsApiVersion()).toBe('v0'); + }); + + it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => { + config.featureToggles = { + dashboardScene: false, + kubernetesDashboards: false, + }; + expect(getDashboardsApiVersion()).toBe('legacy'); + }); + + it('should return v2 when dashboardScene is enabled and useV2DashboardsAPI is enabled', () => { + config.featureToggles = { + dashboardScene: true, + useV2DashboardsAPI: true, + }; + expect(getDashboardsApiVersion()).toBe('v2'); + }); + + it('should return v0 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => { + config.featureToggles = { + dashboardScene: true, + useV2DashboardsAPI: false, + kubernetesDashboards: true, + }; + expect(getDashboardsApiVersion()).toBe('v0'); + }); + + it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => { + config.featureToggles = { + dashboardScene: true, + useV2DashboardsAPI: false, + kubernetesDashboards: false, + }; + expect(getDashboardsApiVersion()).toBe('legacy'); + }); +}); diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts new file mode 100644 index 00000000000..092c6b269cf --- /dev/null +++ b/public/app/features/dashboard/api/utils.ts @@ -0,0 +1,47 @@ +import { config } from '@grafana/runtime'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { DashboardDataDTO, DashboardDTO } from 'app/types'; + +import { DashboardWithAccessInfo } from './types'; + +export function getDashboardsApiVersion() { + // if dashboard scene is disabled, use legacy API response for the old architecture + if (!config.featureToggles.dashboardScene) { + // for old architecture, use v0 API for k8s dashboards + if (config.featureToggles.kubernetesDashboards) { + return 'v0'; + } + + return 'legacy'; + } + + if (config.featureToggles.useV2DashboardsAPI) { + return 'v2'; + } + + if (config.featureToggles.kubernetesDashboards) { + return 'v0'; + } + + return 'legacy'; +} + +// This function is used to determine if the dashboard is in v2 format or also v0 format +export function isDashboardResource( + obj?: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo | null +): obj is DashboardWithAccessInfo | DashboardWithAccessInfo { + if (!obj) { + return false; + } + // is v0 or v2 format? + const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo'; + return isK8sDashboard; +} + +export function isDashboardV2Spec(obj: object): obj is DashboardV2Spec { + return 'elements' in obj; +} + +export function isDashboardV0Spec(obj: object): obj is DashboardDataDTO { + return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec +} diff --git a/public/app/features/dashboard/api/v0.test.ts b/public/app/features/dashboard/api/v0.test.ts new file mode 100644 index 00000000000..f70deaeb347 --- /dev/null +++ b/public/app/features/dashboard/api/v0.test.ts @@ -0,0 +1,65 @@ +import { backendSrv } from 'app/core/services/backend_srv'; +import { AnnoKeyFolder } from 'app/features/apiserver/types'; +import { DashboardDataDTO } from 'app/types'; + +import { DashboardWithAccessInfo } from './types'; +import { K8sDashboardAPI } from './v0'; + +const mockDashboardDto: DashboardWithAccessInfo = { + kind: 'DashboardWithAccessInfo', + apiVersion: 'v0alpha1', + + metadata: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: '1', + annotations: { + [AnnoKeyFolder]: 'new-folder', + }, + }, + spec: { + title: 'test', + uid: 'test', + schemaVersion: 0, + }, + access: {}, +}; + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ + get: () => mockDashboardDto, + }), + config: {}, +})); + +jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ + ignoreNextSave: jest.fn(), +})); + +describe('v0 dashboard API', () => { + it('should provide folder annotations', async () => { + jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ + id: 1, + uid: 'new-folder', + title: 'New Folder', + url: '/folder/url', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '', + createdBy: '', + hasAcl: false, + updated: '', + updatedBy: '', + }); + + const api = new K8sDashboardAPI(); + const result = await api.getDashboardDTO('test'); + expect(result.meta.isFolder).toBe(false); + expect(result.meta.folderId).toBe(1); + expect(result.meta.folderTitle).toBe('New Folder'); + expect(result.meta.folderUrl).toBe('/folder/url'); + expect(result.meta.folderUid).toBe('new-folder'); + }); +}); diff --git a/public/app/features/dashboard/api/v0.ts b/public/app/features/dashboard/api/v0.ts new file mode 100644 index 00000000000..0f513f80174 --- /dev/null +++ b/public/app/features/dashboard/api/v0.ts @@ -0,0 +1,109 @@ +import { backendSrv } from 'app/core/services/backend_srv'; +import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { + ResourceClient, + ResourceForCreate, + AnnoKeyMessage, + AnnoKeyFolder, + Resource, +} from 'app/features/apiserver/types'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +import { DashboardAPI, DashboardWithAccessInfo } from './types'; + +export class K8sDashboardAPI implements DashboardAPI { + private client: ResourceClient; + + constructor() { + this.client = new ScopedResourceClient({ + group: 'dashboard.grafana.app', + version: 'v0alpha1', + resource: 'dashboards', + }); + } + + saveDashboard(options: SaveDashboardCommand): Promise { + const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property + const obj: ResourceForCreate = { + metadata: { + ...options?.k8s, + }, + spec: { + ...dashboard, + }, + }; + + if (options.message) { + obj.metadata.annotations = { + ...obj.metadata.annotations, + [AnnoKeyMessage]: options.message, + }; + } else if (obj.metadata.annotations) { + delete obj.metadata.annotations[AnnoKeyMessage]; + } + + if (options.folderUid) { + obj.metadata.annotations = { + ...obj.metadata.annotations, + [AnnoKeyFolder]: options.folderUid, + }; + } + + if (dashboard.uid) { + obj.metadata.name = dashboard.uid; + return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); + } + return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); + } + + asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO { + return { + uid: v.metadata.name, + version: v.spec.version ?? 0, + id: v.spec.id ?? 0, + status: 'success', + slug: '', + url: '', + }; + } + + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { + return this.client.delete(uid).then((v) => ({ + id: 0, + message: v.message, + title: 'deleted', + })); + } + + async getDashboardDTO(uid: string) { + const dash = await this.client.subresource>(uid, 'dto'); + + const result: DashboardDTO = { + meta: { + ...dash.access, + isNew: false, + isFolder: false, + uid: dash.metadata.name, + k8s: dash.metadata, + }, + dashboard: dash.spec, + }; + + if (dash.metadata.annotations?.[AnnoKeyFolder]) { + try { + const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]); + result.meta.folderTitle = folder.title; + result.meta.folderUrl = folder.url; + result.meta.folderUid = folder.uid; + result.meta.folderId = folder.id; + } catch (e) { + console.error('Failed to load a folder', e); + } + } + + return result; + } +} diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts new file mode 100644 index 00000000000..a3d0bdec9ca --- /dev/null +++ b/public/app/features/dashboard/api/v2.test.ts @@ -0,0 +1,69 @@ +import { + DashboardV2Spec, + defaultDashboardV2Spec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { AnnoKeyFolder, AnnoKeyFolderId, AnnoKeyFolderTitle, AnnoKeyFolderUrl } from 'app/features/apiserver/types'; + +import { DashboardWithAccessInfo } from './types'; +import { K8sDashboardV2APIStub } from './v2'; + +const mockDashboardDto: DashboardWithAccessInfo = { + kind: 'DashboardWithAccessInfo', + apiVersion: 'v0alpha1', + + metadata: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: '1', + annotations: { + [AnnoKeyFolder]: 'new-folder', + }, + }, + spec: { + ...defaultDashboardV2Spec(), + }, + access: {}, +}; + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ + get: () => mockDashboardDto, + }), + config: {}, +})); + +jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ + ignoreNextSave: jest.fn(), +})); + +describe('v2 dashboard API', () => { + it('should provide folder annotations', async () => { + jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ + id: 1, + uid: 'new-folder', + title: 'New Folder', + url: '/folder/url', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '', + createdBy: '', + hasAcl: false, + updated: '', + updatedBy: '', + }); + + const convertToV1 = false; + const api = new K8sDashboardV2APIStub(convertToV1); + // because the API can currently return both DashboardDTO and DashboardWithAccessInfo based on the + // parameter convertToV1, we need to cast the result to DashboardWithAccessInfo to be able to + // access + const result = (await api.getDashboardDTO('test')) as DashboardWithAccessInfo; + expect(result.metadata.annotations![AnnoKeyFolderId]).toBe(1); + expect(result.metadata.annotations![AnnoKeyFolderTitle]).toBe('New Folder'); + expect(result.metadata.annotations![AnnoKeyFolderUrl]).toBe('/folder/url'); + expect(result.metadata.annotations![AnnoKeyFolder]).toBe('new-folder'); + }); +}); diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts new file mode 100644 index 00000000000..2ee98a65e76 --- /dev/null +++ b/public/app/features/dashboard/api/v2.ts @@ -0,0 +1,68 @@ +import { UrlQueryMap } from '@grafana/data'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { + AnnoKeyFolder, + AnnoKeyFolderId, + AnnoKeyFolderTitle, + AnnoKeyFolderUrl, + ResourceClient, +} from 'app/features/apiserver/types'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +import { ResponseTransformers } from './ResponseTransformers'; +import { DashboardAPI, DashboardWithAccessInfo } from './types'; + +export class K8sDashboardV2APIStub implements DashboardAPI | DashboardDTO> { + private client: ResourceClient; + + constructor(private convertToV1: boolean) { + this.client = new ScopedResourceClient({ + group: 'dashboard.grafana.app', + version: 'v2alpha1', + resource: 'dashboards', + }); + } + + async getDashboardDTO(uid: string, params?: UrlQueryMap) { + const dashboard = await this.client.subresource>(uid, 'dto'); + + let result: DashboardWithAccessInfo | DashboardDTO | undefined; + + // TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses. + result = ResponseTransformers.ensureV2Response(dashboard); + + // load folder info if available + if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) { + try { + const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]); + result.metadata.annotations[AnnoKeyFolderTitle] = folder.title; + result.metadata.annotations[AnnoKeyFolderUrl] = folder.url; + result.metadata.annotations[AnnoKeyFolderId] = folder.id; + } catch (e) { + console.error('Failed to load a folder', e); + } + } + + // Depending on the ui components readiness, we might need to convert the response to v1 + if (this.convertToV1) { + // Always return V1 format + result = ResponseTransformers.ensureV1Response(result); + return result; + } + // return the v2 response + return result; + } + + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { + throw new Error('Method not implemented.'); + } + + saveDashboard(options: SaveDashboardCommand): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/public/app/features/manage-dashboards/utils/validation.test.ts b/public/app/features/manage-dashboards/utils/validation.test.ts new file mode 100644 index 00000000000..3f2b2bade41 --- /dev/null +++ b/public/app/features/manage-dashboards/utils/validation.test.ts @@ -0,0 +1,64 @@ +import { + DashboardV2Spec, + defaultDashboardV2Spec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; +import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { DashboardDTO } from 'app/types'; + +import { validateUid } from './validation'; + +const legacyDashboard: DashboardDTO = { + dashboard: { + title: 'Legacy Dashboard', + schemaVersion: 16, + uid: 'dashboard-uid', + }, + meta: { + folderTitle: 'Folder title', + folderUid: 'folder-uid', + }, +}; + +const v2Dashboard: DashboardWithAccessInfo = { + kind: 'DashboardWithAccessInfo', + apiVersion: 'v2alpha0', + metadata: { + creationTimestamp: '2021-09-29T14:00:00Z', + name: 'dashboard-uid', + resourceVersion: '1', + annotations: { + [AnnoKeyFolder]: 'folder-uid', + [AnnoKeyFolderTitle]: 'folder-title', + }, + }, + access: {}, + spec: { + ...defaultDashboardV2Spec(), + title: 'V2 Dashboard', + }, +}; + +describe('validateUid', () => { + beforeAll(() => { + setDashboardAPI({ + legacy: { + getDashboardDTO: jest.fn().mockResolvedValue(legacyDashboard), + deleteDashboard: jest.fn(), + saveDashboard: jest.fn(), + }, + v2: { + getDashboardDTO: jest.fn().mockResolvedValue(v2Dashboard), + deleteDashboard: jest.fn(), + saveDashboard: jest.fn(), + }, + }); + }); + describe('Dashboards API v1', () => { + it('should return a message with the existing dashboard title and folder title', async () => { + const result = await validateUid('dashboard-uid'); + expect(result).toBe(`Dashboard named 'Legacy Dashboard' in folder 'Folder title' has the same UID`); + }); + }); +}); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index b0bc619efd5..06a823d3025 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -56,6 +56,7 @@ export interface DashboardMeta { isSnapshot?: boolean; folderTitle?: string; folderUrl?: string; + folderId?: number; created?: string; createdBy?: string; updated?: string;