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:
@@ -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>
|
||||
)}
|
||||
|
||||
+71
-1
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+12
-9
@@ -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,
|
||||
|
||||
@@ -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 />",
|
||||
|
||||
Reference in New Issue
Block a user