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;