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
This commit is contained in:
Alex Khomenko
2025-12-19 17:36:46 +02:00
committed by GitHub
parent b2d6bb7a05
commit 7812f783bb
7 changed files with 147 additions and 30 deletions
-5
View File
@@ -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
@@ -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<DashboardSceneState> impleme
return this.serializer.getSaveModel(this);
}
// Get the dashboard in native K8s form (using the appropriate apiVersion)
getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate<unknown> {
// Helper method to build K8s resource structure
private buildResourceForCreate(spec: Dashboard | DashboardV2Spec, isNew: boolean): ResourceForCreate<unknown> {
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<unknown> {
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<unknown> {
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 {
@@ -46,6 +46,7 @@ export interface DashboardSceneSerializerLike<T, M, I = T, E = T | { error: unkn
saveTimeRange?: boolean;
saveVariables?: boolean;
saveRefresh?: boolean;
rawJson?: Dashboard | DashboardV2Spec;
}
) => 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,
@@ -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<JsonModelEdit
const [isSaving, setIsSaving] = useState(false);
const dashboard = model.getDashboard();
const isProvisionedNG = useIsProvisionedNG(dashboard);
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const canSave = dashboard.useState().meta.canSave;
const { jsonText } = model.useState();
const onSave = async (overwrite: boolean) => {
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<JsonModelEdit
variant={overwrite ? 'destructive' : 'primary'}
>
{overwrite ? (
<Trans i18nKey="dashboard-scene.json-model-edit-view.save-and-overwrite">'Save and overwrite'</Trans>
<Trans i18nKey="dashboard-scene.json-model-edit-view.save-and-overwrite">Save and overwrite</Trans>
) : (
<Trans i18nKey="dashboard-settings.json-editor.save-button">Save changes</Trans>
)}
@@ -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<Props> = {}) {
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,
});
});
});
});
@@ -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,
+1 -1
View File
@@ -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 <person />",