From 7812f783bb44e10cb0aea4cbc36af4c3ca47d854 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 19 Dec 2025 17:36:46 +0200 Subject: [PATCH] Provisioning: Enable editing dashboard via JSON model (#115420) * Provisioning: Enable save for json model changes * Do not pass props * Simplify logic and fix warnings * add tests * Show diff for json changes * Add try/catch --- eslint-suppressions.json | 5 -- .../dashboard-scene/scene/DashboardScene.tsx | 41 ++++++++--- .../serialization/DashboardSceneSerializer.ts | 21 ++++-- .../settings/JsonModelEditView.tsx | 15 +++- .../SaveProvisionedDashboardForm.test.tsx | 72 ++++++++++++++++++- .../SaveProvisionedDashboardForm.tsx | 21 +++--- public/locales/en-US/grafana.json | 2 +- 7 files changed, 147 insertions(+), 30 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 36eb78aaa72..597f2b94a1a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1911,11 +1911,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/settings/JsonModelEditView.tsx": { - "react/no-unescaped-entities": { - "count": 2 - } - }, "public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": { "react-hooks/rules-of-hooks": { "count": 4 diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 82a20dbcd52..7ddd7c4e779 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -59,6 +59,7 @@ import { gridItemToGridLayoutItemKind } from '../serialization/layoutSerializers import { getElement } from '../serialization/layoutSerializers/utils'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { JsonModelEditView } from '../settings/JsonModelEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history/HistorySrv'; @@ -855,30 +856,54 @@ export class DashboardScene extends SceneObjectBase impleme return this.serializer.getSaveModel(this); } - // Get the dashboard in native K8s form (using the appropriate apiVersion) - getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + // Helper method to build K8s resource structure + private buildResourceForCreate(spec: Dashboard | DashboardV2Spec, isNew: boolean): ResourceForCreate { const { meta } = this.state; - const spec = this.getSaveAsModel(options); - - const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; // get from the dashboard? + const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; return { apiVersion: `dashboard.grafana.app/${apiVersion}`, kind: 'Dashboard', metadata: { ...meta.k8s, - name: options.isNew ? undefined : (meta.uid ?? meta.k8s?.name), - generateName: options.isNew ? 'd' : undefined, + name: isNew ? undefined : (meta.uid ?? meta.k8s?.name), + generateName: isNew ? 'd' : undefined, }, spec, }; } + // Get the dashboard in native K8s form (using the appropriate apiVersion) + getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + const spec = this.getSaveAsModel(options); + return this.buildResourceForCreate(spec, options.isNew ?? false); + } + + // Wrap a raw dashboard spec in K8s resource format + // Used by JSON model editor for Git sync dashboards + getSaveResourceFromSpec(rawSpec: Dashboard | DashboardV2Spec): ResourceForCreate { + return this.buildResourceForCreate(rawSpec, false); + } + + // Get raw JSON from JSON model editor if currently active + // Returns undefined if not in JSON editor mode or if JSON is invalid + getRawJsonFromEditor(): Dashboard | DashboardV2Spec | undefined { + if (this.state.editview instanceof JsonModelEditView) { + try { + return JSON.parse(this.state.editview.state.jsonText); + } catch { + return undefined; + } + } + return undefined; + } + getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec { return this.serializer.getSaveAsModel(this, options); } getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo { - return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh }); + const rawJson = this.getRawJsonFromEditor(); + return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh, rawJson }); } getManagerKind(): ManagerKind | undefined { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index cede8f605a5..130c5450bf0 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -46,6 +46,7 @@ export interface DashboardSceneSerializerLike DashboardChangeInfo; onSaveComplete(saveModel: T, result: SaveDashboardResponseDTO): void; @@ -184,9 +185,15 @@ export class V1DashboardSerializer getDashboardChangesFromScene( scene: DashboardScene, - options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + rawJson?: Dashboard | DashboardV2Spec; + } ) { - const changedSaveModel = this.getSaveModel(scene); + const changedSaveModel = + options.rawJson && !isDashboardV2Spec(options.rawJson) ? options.rawJson : this.getSaveModel(scene); const changeInfo = getRawDashboardChanges( this.initialSaveModel!, changedSaveModel, @@ -396,9 +403,15 @@ export class V2DashboardSerializer getDashboardChangesFromScene( scene: DashboardScene, - options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + rawJson?: Dashboard | DashboardV2Spec; + } ) { - const changedSaveModel = this.getSaveModel(scene); + const changedSaveModel = + options.rawJson && isDashboardV2Spec(options.rawJson) ? options.rawJson : this.getSaveModel(scene); const changeInfo = getRawDashboardV2Changes( this.initialSaveModel!, changedSaveModel, diff --git a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx index 30979075d2a..99522077ae3 100644 --- a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx +++ b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { SceneComponentProps, SceneObjectBase, sceneUtils } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, sceneUtils } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Box, Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui'; @@ -11,8 +11,10 @@ import { Page } from 'app/core/components/Page/Page'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; +import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG'; import { DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; +import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { NameAlreadyExistsError, isNameExistsError, @@ -105,12 +107,21 @@ function JsonModelEditViewComponent({ model }: SceneComponentProps { + if (isProvisionedNG) { + const drawer = new SaveDashboardDrawer({ + dashboardRef: new SceneObjectRef(dashboard), + }); + dashboard.setState({ overlay: drawer }); + return; + } + const result = await onSaveDashboard(dashboard, { folderUid: dashboard.state.meta.folderUid, overwrite, @@ -136,7 +147,7 @@ function JsonModelEditViewComponent({ model }: SceneComponentProps {overwrite ? ( - 'Save and overwrite' + Save and overwrite ) : ( Save changes )} diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index bca9ca1939c..69a94c5e9ae 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -9,7 +9,7 @@ import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScen import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; -import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm'; +import { Props, SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; jest.mock('@grafana/runtime', () => { const actual = jest.requireActual('@grafana/runtime'); @@ -118,6 +118,7 @@ function setup(props: Partial = {}) { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue(mockDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, drawer: { onClose: jest.fn(), @@ -291,6 +292,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveResource: jest.fn().mockReturnValue(updatedDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -385,6 +387,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -431,6 +434,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -445,4 +449,70 @@ describe('SaveProvisionedDashboardForm', () => { expect(saveButton).toBeEnabled(); }); }); + + it('should save dashboard with raw JSON from editor', async () => { + const mockAction = jest.fn(); + const mockRequest = { ...mockRequestBase, isSuccess: true }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + + const rawJson = JSON.stringify({ + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }); + + const dashboardFromRawJson = { + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + generateName: 'p', + name: undefined, + }, + spec: { + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }, + }; + + const { user } = setup({ + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: false, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({}), + getSaveResource: jest.fn().mockReturnValue(dashboardFromRawJson), + getSaveResourceFromSpec: jest.fn().mockReturnValue(dashboardFromRawJson), + setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(rawJson), + } as unknown as DashboardScene, + }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeEnabled(); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + await user.clear(commentInput); + await user.type(commentInput, 'Save with raw JSON'); + + await user.click(saveButton); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: 'dashboard/2023-01-01-abcde', + name: 'test-repo', + path: 'test-dashboard.json', + message: 'Save with raw JSON', + body: dashboardFromRawJson, + }); + }); + }); }); diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index a2df43a1f95..3fee476b189 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -65,8 +65,9 @@ export function SaveProvisionedDashboardForm({ register, formState: { dirtyFields }, } = methods; - // button enabled if form comment is dirty or dashboard state is dirty - const isDirtyState = Boolean(dirtyFields.comment) || isDirty; + // button enabled if form comment is dirty or dashboard state is dirty or raw JSON was provided from editor + const rawDashboardJSON = dashboard.getRawJsonFromEditor(); + const isDirtyState = Boolean(dirtyFields.comment) || isDirty || Boolean(rawDashboardJSON); const [workflow, ref, path] = watch(['workflow', 'ref', 'path']); // Update the form if default values change @@ -191,13 +192,15 @@ export function SaveProvisionedDashboardForm({ const message = comment || `Save dashboard: ${dashboard.state.title}`; - const body = dashboard.getSaveResource({ - isNew, - title, - description, - copyTags, - saveAsCopy, - }); + const body = rawDashboardJSON + ? dashboard.getSaveResourceFromSpec(rawDashboardJSON) + : dashboard.getSaveResource({ + isNew, + title, + description, + copyTags, + saveAsCopy, + }); reportInteraction('grafana_provisioning_dashboard_save_submitted', { workflow, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7a1dd06e31f..8e4ed2c7b20 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6210,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard", "would-still-dashboard": "Would you still like to save this dashboard?" }, - "save-and-overwrite": "'Save and overwrite'" + "save-and-overwrite": "Save and overwrite" }, "library-viz-panel-info": { "last-edited": "{{timeAgo}} by ",