From 8f3fd8f91d7dd286def6f1711c89ad84bcc6b8a6 Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:14:19 +0100 Subject: [PATCH] Schema v2: Write Path Implement saveDashboard in v2 client API (#98263) Create new function for save dashboards written in schema v2 and using the v2 api from k8s --- .betterer.results | 6 - public/app/features/apiserver/types.ts | 10 +- .../api/browseDashboardsAPI.ts | 19 +- .../saving/SaveDashboardForm.tsx | 5 + .../saving/useSaveDashboard.ts | 2 +- .../DashboardSceneSerializer.test.ts | 212 ++++++++++++++++-- .../serialization/DashboardSceneSerializer.ts | 15 +- .../transformSaveModelSchemaV2ToScene.ts | 1 + .../transformSceneToSaveModelSchemaV2.ts | 8 +- .../features/dashboard/api/dashboard_api.ts | 17 +- public/app/features/dashboard/api/legacy.ts | 5 +- public/app/features/dashboard/api/types.ts | 4 +- public/app/features/dashboard/api/utils.ts | 17 +- public/app/features/dashboard/api/v0.ts | 5 +- public/app/features/dashboard/api/v2.test.ts | 77 ++++++- public/app/features/dashboard/api/v2.ts | 68 +++++- .../components/SaveDashboard/types.ts | 7 +- 17 files changed, 424 insertions(+), 54 deletions(-) diff --git a/.betterer.results b/.betterer.results index 28b253c9993..fc3779c97bf 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3501,9 +3501,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], - "public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/dashboard-scene/serialization/transformToV1TypesUtils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -5381,9 +5378,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], - "public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/manage-dashboards/components/SnapshotListTable.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index bbbdf2f1246..c335ee9f745 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -30,7 +30,7 @@ export interface ObjectMeta { // General resource annotations -- including the common grafana.app values annotations?: GrafanaAnnotations & GrafanaClientAnnotations; // General application level key+value pairs - labels?: Record; + labels?: GrafanaLabels; } export const AnnoKeyCreatedBy = 'grafana.app/createdBy'; @@ -56,6 +56,9 @@ export const AnnoKeyDashboardIsSnapshot = 'grafana.app/dashboard-is-snapshot'; export const AnnoKeyDashboardSnapshotOriginalUrl = 'grafana.app/dashboard-snapshot-original-url'; export const AnnoKeyDashboardGnetId = 'grafana.app/dashboard-gnet-id'; +// labels +export const DeprecatedInternalId = 'grafana.app/deprecatedInternalID'; + // Annotations provided by the API type GrafanaAnnotations = { [AnnoKeyCreatedBy]?: string; @@ -88,6 +91,11 @@ type GrafanaClientAnnotations = { [AnnoKeyDashboardGnetId]?: string; }; +// Labels +type GrafanaLabels = { + [DeprecatedInternalId]?: number; +}; + export interface Resource extends TypeMeta { metadata: ObjectMeta; spec: T; diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index f470946e40f..27b46514d70 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -2,11 +2,13 @@ import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; import { lastValueFrom } from 'rxjs'; import { AppEvents, isTruthy, locationUtil } from '@grafana/data'; -import { BackendSrvRequest, getBackendSrv, locationService } from '@grafana/runtime'; +import { BackendSrvRequest, config, getBackendSrv, locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { @@ -335,11 +337,20 @@ export const browseDashboardsAPI = createApi({ }), // save an existing dashboard - saveDashboard: builder.mutation({ + saveDashboard: builder.mutation>({ queryFn: async (cmd) => { try { - const rsp = await getDashboardAPI().saveDashboard(cmd); - return { data: rsp }; + // When we use the `useV2DashboardsAPI` flag, we can save 'v2' schema dashboards + if (config.featureToggles.useV2DashboardsAPI && isV2DashboardCommand(cmd)) { + const response = await getDashboardAPI('v2').saveDashboard(cmd); + return { data: response }; + } + + if (isV1DashboardCommand(cmd)) { + const rsp = await getDashboardAPI().saveDashboard(cmd); + return { data: rsp }; + } + throw new Error('Invalid dashboard version'); } catch (error) { return { error }; } diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx index 7845c4b8ee8..ac621a91baf 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx @@ -30,6 +30,11 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { const { state, onSaveDashboard } = useSaveDashboard(false); const [options, setOptions] = useState({ folderUid: dashboard.state.meta.folderUid, + // we need to set the uid here in order to save the dashboard + // in schema v2 we don't have the uid in the spec + k8s: { + ...dashboard.state.meta.k8s, + }, }); const onSave = async (overwrite: boolean) => { diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts index 7fa3694f584..a5d8352683e 100644 --- a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts +++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts @@ -46,7 +46,7 @@ export function useSaveDashboard(isCopy = false) { message: options.message, overwrite: options.overwrite, showErrorAlert: false, - k8s: undefined, // TODO? pass the original metadata + k8s: options.k8s, }); if ('error' in result) { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 6d20e16ed27..cc9bbea4cc6 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -12,11 +12,14 @@ import { defaultDashboardV2Spec, defaultPanelSpec, defaultTimeSettingsSpec, + PanelSpec, } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types'; +import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; import { buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { DashboardScene } from '../scene/DashboardScene'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { findVizPanelByKey } from '../utils/utils'; @@ -602,25 +605,204 @@ describe('DashboardSceneSerializer', () => { }); }); - it('should throw on getSaveAsModel', () => { - const serializer = new V2DashboardSerializer(); - const dashboard = setup(); - expect(() => serializer.getSaveAsModel(dashboard, {})).toThrow('Method not implemented.'); + describe('getSaveAsModel', () => { + let serializer: V2DashboardSerializer; + let dashboard: DashboardScene; + let baseOptions: SaveDashboardAsOptions; + + beforeEach(() => { + serializer = new V2DashboardSerializer(); + dashboard = setupV2(); + baseOptions = { + title: 'I am a new dashboard', + description: 'description goes here', + isNew: true, + copyTags: true, + }; + }); + + it('should set basic dashboard properties correctly', () => { + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + + expect(saveAsModel).toMatchObject({ + title: baseOptions.title, + description: baseOptions.description, + id: undefined, + editable: true, + annotations: [], + cursorSync: 'Off', + liveNow: false, + preload: false, + tags: [], + }); + }); + + it('should handle time settings correctly', () => { + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + + expect(saveAsModel.timeSettings).toEqual({ + autoRefresh: '10s', + autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + fiscalYearStartMonth: 0, + from: 'now-1h', + hideTimepicker: false, + nowDelay: undefined, + quickRanges: [], + timezone: 'browser', + to: 'now', + weekStart: '', + }); + }); + + it('should correctly serialize panel elements', () => { + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + + expect(saveAsModel.elements['panel-1']).toMatchObject({ + kind: 'Panel', + spec: { + data: { + kind: 'QueryGroup', + spec: { + queries: [], + queryOptions: {}, + transformations: [], + }, + }, + description: '', + id: 1, + links: [], + title: 'Panel 1', + }, + }); + }); + + it('should correctly serialize layout configuration', () => { + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + + expect(saveAsModel.layout).toEqual({ + kind: 'GridLayout', + spec: { + items: [ + { + kind: 'GridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: 'panel-1', + }, + height: 8, + width: 12, + x: 0, + y: 0, + }, + }, + ], + }, + }); + }); + + it('should correctly serialize variables', () => { + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + + expect(saveAsModel.variables).toEqual([ + { + kind: 'CustomVariable', + spec: { + allValue: undefined, + current: { + text: 'app1', + value: 'app1', + }, + description: 'A query variable', + hide: 'dontHide', + includeAll: false, + label: 'Query Variable', + multi: false, + name: 'app', + options: [], + query: 'app1', + skipUrlSync: false, + }, + }, + ]); + }); + + it('should handle empty dashboard state', () => { + const emptyDashboard = setupV2({ + elements: {}, + layout: { kind: 'GridLayout', spec: { items: [] } }, + variables: [], + }); + + const saveAsModel = serializer.getSaveAsModel(emptyDashboard, baseOptions); + + expect(saveAsModel.elements).toEqual({}); + expect(saveAsModel.layout.spec.items).toEqual([]); + expect(saveAsModel.variables).toEqual([]); + }); + + it('should preserve visualization config', () => { + const dashboardWithVizConfig = setupV2({ + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + ...defaultPanelSpec(), + id: 1, + title: 'Panel 1', + vizConfig: { + kind: 'graph', + spec: { + fieldConfig: { + defaults: { custom: { lineWidth: 2 } }, + overrides: [], + }, + options: { legend: { show: true } }, + pluginVersion: '1.0.0', + }, + }, + }, + }, + }, + }); + + const saveAsModel = serializer.getSaveAsModel(dashboardWithVizConfig, baseOptions); + + const panelSpec = saveAsModel.elements['panel-1'].spec as PanelSpec; + expect(panelSpec.vizConfig).toMatchObject({ + kind: 'graph', + spec: { + fieldConfig: { + defaults: { custom: { lineWidth: 2 } }, + overrides: [], + }, + options: { legend: { show: true } }, + pluginVersion: '1.0.0', + }, + }); + }); }); + }); - it('should throw on onSaveComplete', () => { + describe('onSaveComplete', () => { + it('should set the initialSaveModel correctly', () => { const serializer = new V2DashboardSerializer(); + const saveModel = defaultDashboardV2Spec(); + const response = { + id: 1, + uid: 'aa', + slug: 'slug', + url: 'url', + version: 2, + status: 'status', + }; - expect(() => - serializer.onSaveComplete({} as DashboardV2Spec, { - id: 1, - uid: 'aa', - slug: 'slug', - url: 'url', - version: 2, - status: 'status', - }) - ).toThrow('Method not implemented.'); + serializer.onSaveComplete(saveModel, response); + + expect(serializer.initialSaveModel).toEqual({ + ...saveModel, + id: response.id, + }); }); it('should allow retrieving snapshot url', () => { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index ec9d8bf4224..b185047241c 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -136,9 +136,13 @@ export class V2DashboardSerializer } getSaveAsModel(s: DashboardScene, options: SaveDashboardAsOptions) { - throw new Error('Method not implemented.'); - // eslint-disable-next-line - return {} as DashboardV2Spec; + const saveModel = this.getSaveModel(s); + return { + ...saveModel, + title: options.title || '', + description: options.description || '', + tags: options.isNew || options.copyTags ? saveModel.tags : [], + }; } getDashboardChangesFromScene( @@ -166,7 +170,10 @@ export class V2DashboardSerializer } onSaveComplete(saveModel: DashboardV2Spec, result: SaveDashboardResponseDTO): void { - throw new Error('v2 schema: Method not implemented.'); + this.initialSaveModel = { + ...saveModel, + id: result.id, + }; } getTrackingInformation(s: DashboardScene): DashboardTrackingInfo | undefined { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index bef41cce4c9..0ed452b3b01 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -150,6 +150,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo; - v0: DashboardAPI; + legacy: DashboardAPI; + v0: DashboardAPI; // v1: DashboardDTO; TODO[schema]: enable v1 when available - v2: DashboardAPI>; + v2: DashboardAPI, DashboardV2Spec>; }; type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo; @@ -26,9 +27,13 @@ export function setDashboardAPI(override: Partial | undefin } // Overloads -export function getDashboardAPI(): DashboardAPI; -export function getDashboardAPI(requestV2Response: 'v2'): DashboardAPI>; -export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI { +export function getDashboardAPI(): DashboardAPI; +export function getDashboardAPI( + requestV2Response: 'v2' +): DashboardAPI, DashboardV2Spec>; +export function getDashboardAPI( + requestV2Response?: 'v2' +): DashboardAPI { const v = getDashboardsApiVersion(); const isConvertingToV1 = !requestV2Response; diff --git a/public/app/features/dashboard/api/legacy.ts b/public/app/features/dashboard/api/legacy.ts index 66c86ad7511..08270fcc27b 100644 --- a/public/app/features/dashboard/api/legacy.ts +++ b/public/app/features/dashboard/api/legacy.ts @@ -1,5 +1,6 @@ import { AppEvents, UrlQueryMap } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; @@ -9,10 +10,10 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardAPI } from './types'; -export class LegacyDashboardAPI implements DashboardAPI { +export class LegacyDashboardAPI implements DashboardAPI { constructor() {} - saveDashboard(options: SaveDashboardCommand): Promise { + saveDashboard(options: SaveDashboardCommand): Promise { dashboardWatcher.ignoreNextSave(); return getBackendSrv().post('/api/dashboards/db/', { diff --git a/public/app/features/dashboard/api/types.ts b/public/app/features/dashboard/api/types.ts index 18cd3ce5cd6..bb7fd24aa3a 100644 --- a/public/app/features/dashboard/api/types.ts +++ b/public/app/features/dashboard/api/types.ts @@ -5,11 +5,11 @@ import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; -export interface DashboardAPI { +export interface DashboardAPI { /** Get a dashboard with the access control metadata */ getDashboardDTO(uid: string, params?: UrlQueryMap): Promise; /** Save dashboard */ - saveDashboard(options: SaveDashboardCommand): Promise; + saveDashboard(options: SaveDashboardCommand): Promise; /** Delete a dashboard */ deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; } diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index b31796bad2b..1b23e9a086e 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -1,7 +1,10 @@ import { config, locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { DashboardDataDTO, DashboardDTO } from 'app/types'; +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + import { DashboardWithAccessInfo } from './types'; export function getDashboardsApiVersion() { @@ -40,7 +43,7 @@ export function isDashboardResource( return isK8sDashboard; } -export function isDashboardV2Spec(obj: DashboardDataDTO | DashboardV2Spec): obj is DashboardV2Spec { +export function isDashboardV2Spec(obj: Dashboard | DashboardDataDTO | DashboardV2Spec): obj is DashboardV2Spec { return 'elements' in obj; } @@ -53,3 +56,15 @@ export function isDashboardV2Resource( ): obj is DashboardWithAccessInfo { return isDashboardResource(obj) && isDashboardV2Spec(obj.spec); } + +export function isV1DashboardCommand( + cmd: SaveDashboardCommand +): cmd is SaveDashboardCommand { + return !isDashboardV2Spec(cmd.dashboard); +} + +export function isV2DashboardCommand( + cmd: SaveDashboardCommand +): cmd is SaveDashboardCommand { + return isDashboardV2Spec(cmd.dashboard); +} diff --git a/public/app/features/dashboard/api/v0.ts b/public/app/features/dashboard/api/v0.ts index 8ec3eb77d2c..811fc46a5b6 100644 --- a/public/app/features/dashboard/api/v0.ts +++ b/public/app/features/dashboard/api/v0.ts @@ -1,4 +1,5 @@ import { locationUtil } from '@grafana/data'; +import { Dashboard } from '@grafana/schema'; import { backendSrv } from 'app/core/services/backend_srv'; import kbn from 'app/core/utils/kbn'; import { ScopedResourceClient } from 'app/features/apiserver/client'; @@ -17,7 +18,7 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardAPI, DashboardWithAccessInfo } from './types'; -export class K8sDashboardAPI implements DashboardAPI { +export class K8sDashboardAPI implements DashboardAPI { private client: ResourceClient; constructor() { @@ -28,7 +29,7 @@ export class K8sDashboardAPI implements DashboardAPI { }); } - saveDashboard(options: SaveDashboardCommand): Promise { + saveDashboard(options: SaveDashboardCommand): Promise { const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property const obj: ResourceForCreate = { metadata: { diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts index 712e496cbd6..07705c56657 100644 --- a/public/app/features/dashboard/api/v2.test.ts +++ b/public/app/features/dashboard/api/v2.test.ts @@ -1,6 +1,12 @@ import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { backendSrv } from 'app/core/services/backend_srv'; -import { AnnoKeyFolder, AnnoKeyFolderId, AnnoKeyFolderTitle, AnnoKeyFolderUrl } from 'app/features/apiserver/types'; +import { + AnnoKeyFolder, + AnnoKeyFolderId, + AnnoKeyFolderTitle, + AnnoKeyFolderUrl, + DeprecatedInternalId, +} from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from './types'; import { K8sDashboardV2API } from './v2'; @@ -27,6 +33,20 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ get: () => mockDashboardDto, + put: jest.fn().mockImplementation((url, data) => { + return { + apiVersion: 'dashboard.grafana.app/v2alpha1', + kind: 'Dashboard', + metadata: { + name: data.metadata.name, + resourceVersion: '2', + creationTimestamp: new Date().toISOString(), + labels: data.metadata.labels, + annotations: data.metadata.annotations, + }, + spec: data.spec, + }; + }), }), config: { ...jest.requireActual('@grafana/runtime').config, @@ -67,3 +87,58 @@ describe('v2 dashboard API', () => { expect(result.metadata.annotations![AnnoKeyFolder]).toBe('new-folder'); }); }); + +describe('v2 dashboard API - Save', () => { + const defaultSaveCommand = { + dashboard: defaultDashboardV2Spec(), + message: 'test save', + folderUid: 'test-folder', + k8s: { + name: 'test-dash', + labels: { + [DeprecatedInternalId]: 123, + }, + + annotations: { + [AnnoKeyFolder]: 'new-folder', + }, + }, + }; + + it('should create new dashboard', async () => { + const api = new K8sDashboardV2API(false); + const result = await api.saveDashboard({ + ...defaultSaveCommand, + dashboard: { + ...defaultSaveCommand.dashboard, + title: 'test-dashboard', + }, + }); + + expect(result).toEqual({ + id: 123, + uid: 'test-dash', + url: '/d/test-dash/testdashboard', + slug: '', + status: 'success', + version: 2, + }); + }); + + it('should update existing dashboard', async () => { + const api = new K8sDashboardV2API(false); + + const result = await api.saveDashboard({ + ...defaultSaveCommand, + dashboard: { + ...defaultSaveCommand.dashboard, + title: 'chaing-title-dashboard', + }, + k8s: { + ...defaultSaveCommand.k8s, + name: 'existing-dash', + }, + }); + expect(result.version).toBe(2); + }); +}); diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index e3a4190fe43..a1bc8d83d6a 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -1,14 +1,20 @@ -import { UrlQueryMap } from '@grafana/data'; +import { locationUtil, UrlQueryMap } from '@grafana/data'; import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { backendSrv } from 'app/core/services/backend_srv'; +import kbn from 'app/core/utils/kbn'; import { ScopedResourceClient } from 'app/features/apiserver/client'; import { AnnoKeyFolder, AnnoKeyFolderId, AnnoKeyFolderTitle, AnnoKeyFolderUrl, + AnnoKeyMessage, + DeprecatedInternalId, + Resource, ResourceClient, + ResourceForCreate, } from 'app/features/apiserver/types'; +import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; @@ -17,7 +23,9 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { ResponseTransformers } from './ResponseTransformers'; import { DashboardAPI, DashboardWithAccessInfo } from './types'; -export class K8sDashboardV2API implements DashboardAPI | DashboardDTO> { +export class K8sDashboardV2API + implements DashboardAPI | DashboardDTO, DashboardV2Spec> +{ private client: ResourceClient; constructor(private convertToV1: boolean) { @@ -62,7 +70,59 @@ export class K8sDashboardV2API implements DashboardAPI { - throw new Error('Method not implemented.'); + async saveDashboard(options: SaveDashboardCommand): Promise { + const dashboard = options.dashboard; + + const obj: ResourceForCreate = { + // the metadata will have the name that's the uid + metadata: { + ...options?.k8s, + }, + spec: { + ...dashboard, + }, + }; + + // add annotations + if (options.message) { + obj.metadata.annotations = { + ...obj.metadata.annotations, + [AnnoKeyMessage]: options.message, + }; + } else if (obj.metadata.annotations) { + delete obj.metadata.annotations[AnnoKeyMessage]; + } + + // add folder annotation + if (options.folderUid) { + obj.metadata.annotations = { + ...obj.metadata.annotations, + [AnnoKeyFolder]: options.folderUid, + }; + } + + if (obj.metadata.name) { + return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); + } + return await this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); + } + + asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO { + const url = locationUtil.assureBaseUrl( + getDashboardUrl({ + uid: v.metadata.name, + currentQueryParams: '', + slug: kbn.slugifyForUrl(v.spec.title), + }) + ); + + return { + uid: v.metadata.name, + version: parseInt(v.metadata.resourceVersion, 10) ?? 0, + id: v.metadata.labels?.[DeprecatedInternalId] ?? 0, + status: 'success', + url, + slug: '', + }; } } diff --git a/public/app/features/dashboard/components/SaveDashboard/types.ts b/public/app/features/dashboard/components/SaveDashboard/types.ts index 6abc2048991..7e8eccc605f 100644 --- a/public/app/features/dashboard/components/SaveDashboard/types.ts +++ b/public/app/features/dashboard/components/SaveDashboard/types.ts @@ -1,5 +1,4 @@ import { Dashboard } from '@grafana/schema'; -import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { ObjectMeta } from 'app/features/apiserver/types'; import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils'; @@ -17,6 +16,8 @@ export interface SaveDashboardOptions extends CloneOptions { overwrite?: boolean; message?: string; makeEditable?: boolean; + // for schema v2 we need to pass the k8s metadata + k8s?: Partial; } export interface SaveDashboardAsOptions { @@ -27,8 +28,8 @@ export interface SaveDashboardAsOptions { description?: string; } -export interface SaveDashboardCommand { - dashboard: Dashboard | DashboardV2Spec; +export interface SaveDashboardCommand { + dashboard: T; message?: string; folderUid?: string; overwrite?: boolean;